CCFOLIA Timer Resize & Auto Position

ココフォリアのタイマーをリサイズ可能にし、初期位置をBGMの下に自動調整します

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         CCFOLIA Timer Resize & Auto Position
// @namespace    http://tampermonkey.net/
// @version      5.1
// @description  ココフォリアのタイマーをリサイズ可能にし、初期位置をBGMの下に自動調整します
// @author       You
// @match        https://ccfolia.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=ccfolia.com
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    /**
     * 設定・定数
     */
    const STORAGE_KEY = 'ccfolia_timer_scale_v5';
    // Chrome/Edge等はzoomプロパティで判定ごとの縮小が可能
    const IS_ZOOM_SUPPORTED = 'zoom' in document.documentElement.style;
    const BGM_MARGIN_BOTTOM = 16; // BGM UIとの間隔(px)

    /**
     * スタイル定義
     * リサイズハンドルの定義(元のデザインを阻害しない)
     */
    const RESIZER_STYLE = `
        #ccfolia-timer-resizer {
            position: absolute;
            bottom: 0;
            right: 0;
            width: 24px;
            height: 24px;
            /* 視覚的なハンドル(右下の斜線グリップ) */
            background: linear-gradient(135deg, transparent 50%, rgba(200,200,200,0.5) 50%, rgba(200,200,200,0.8) 55%, transparent 60%);
            cursor: se-resize;
            z-index: 9999;
            opacity: 0;
            transition: opacity 0.2s;
            border-bottom-right-radius: 4px;
            transform-origin: bottom right;
            pointer-events: auto;
        }
        /* 親要素(タイマーパネル)ホバー時のみ表示 */
        div[class*="MuiPaper-elevation5"]:hover #ccfolia-timer-resizer {
            opacity: 1;
        }
    `;

    // スタイルをドキュメントに注入
    const styleEl = document.createElement('style');
    styleEl.innerHTML = RESIZER_STYLE;
    document.head.appendChild(styleEl);

    /**
     * 状態管理変数
     */
    let currentScale = parseFloat(localStorage.getItem(STORAGE_KEY)) || 1.0;
    let rawX = 0;
    let rawY = 0;

    /**
     * タイマー要素を特定する関数
     */
    function findTimerElement() {
        // "MuiPaper-elevation5" を持つ要素の中から、時間表記や再生ボタンを含むものを探す
        const papers = document.querySelectorAll('div[class*="MuiPaper-elevation5"]');
        for (const paper of papers) {
            if (paper.innerText.match(/\d{2}:\d{2}:\d{2}/) || paper.querySelector('svg[data-testid="PlayCircleFilledIcon"]')) {
                return paper;
            }
        }
        return null;
    }

    /**
     * BGM UIの下端座標を取得する関数
     * 音量アイコンを基準に親要素を遡ってパネルの底を探す
     */
    function getBgmBottomPosition() {
        const icon = document.querySelector('svg[data-testid="VolumeOffIcon"]');
        if (!icon) return 0;

        // アイコンから親要素をいくつか遡り、適切なBGMパネル領域の下端を探す
        let target = icon;
        let maxBottom = 0;

        // 5階層程度遡ればコンテナに到達するはず
        for(let i = 0; i < 5; i++) {
            if (!target.parentElement) break;
            target = target.parentElement;

            const rect = target.getBoundingClientRect();
            // 画面全体を覆うような巨大な要素は除外
            if (rect.width > window.innerWidth * 0.8) continue;

            // 最も下にある要素の下端を採用
            if (rect.bottom > maxBottom) {
                maxBottom = rect.bottom;
            }
        }
        return maxBottom;
    }

    /**
     * 座標補正ロジック
     * zoom使用時は座標系も拡大縮小されるため、逆数を掛けて補正する
     */
    function updatePosition(element, x, y) {
        if (!IS_ZOOM_SUPPORTED) return;

        // 拡大率に応じた座標補正
        // 画面上の狙った位置(x,y)に置くには、zoom倍された座標系で (x/scale, y/scale) を指定する
        const fixedX = x * (1 / currentScale);
        const fixedY = y * (1 / currentScale);

        // 無限ループ防止用のフラグ設定
        element.dataset.lastFixedX = fixedX;
        element.dataset.lastFixedY = fixedY;

        element.style.transform = `translate3d(${fixedX}px, ${fixedY}px, 0px)`;
    }

    /**
     * 初期位置調整ロジック
     * BGM UIと重なっている場合のみ移動させる
     */
    function adjustInitialPosition(timerPanel) {
        // 既に調整済みなら何もしない
        if (timerPanel.dataset.positionAdjusted === 'true') return;

        const bgmBottom = getBgmBottomPosition();
        if (bgmBottom === 0) return; // BGM UIが見つからない場合はスキップ

        const timerRect = timerPanel.getBoundingClientRect();

        // 重なり判定 & 初期位置判定
        // タイマーの上端がBGMの下端より上にあり、かつ左上付近(初期位置)にある場合
        const isOverlapping = timerRect.top < bgmBottom;
        const isInitialPosition = Math.abs(timerRect.left) < 50 && Math.abs(timerRect.top) < 100;

        if (isOverlapping || isInitialPosition) {
            // 左端は0、上端はBGMの下端 + マージン
            rawX = 0;
            rawY = bgmBottom + BGM_MARGIN_BOTTOM;

            updatePosition(timerPanel, rawX, rawY);
            timerPanel.dataset.positionAdjusted = 'true';
            console.log('Timer position adjusted to:', rawY);
        }
    }

    /**
     * メイン初期化処理
     */
    function initTimerResizer() {
        const timerPanel = findTimerElement();

        // まだロードされていない場合は待機
        if (!timerPanel) {
            setTimeout(initTimerResizer, 1000);
            return;
        }

        // --- 初期位置調整の実行 ---
        // scale適用前に座標計算用の基準値をセットするため、ここで座標を取得しておく
        if (IS_ZOOM_SUPPORTED && timerPanel.style.transform) {
            const match = timerPanel.style.transform.match(/translate3d\(([^,]+)px,\s*([^,]+)px/);
            if (match) {
                // transformの値はzoom適用後の座標系なので、実画面座標(raw)に換算するには scale を掛ける必要があるが、
                // 初期ロード直後は zoom=1 のはずなのでそのまま取得してよい
                // ただし、この後 adjustInitialPosition で強制的に書き換える可能性がある
                rawX = parseFloat(match[1]);
                rawY = parseFloat(match[2]);
            }
        }

        // BGMの下に移動させる
        adjustInitialPosition(timerPanel);

        // 既にハンドルがある場合は終了
        if (timerPanel.querySelector('#ccfolia-timer-resizer')) return;

        // ハンドル要素の生成
        const resizer = document.createElement('div');
        resizer.id = 'ccfolia-timer-resizer';
        resizer.title = 'ドラッグでサイズ変更';
        timerPanel.appendChild(resizer);

        // --- 初期倍率の適用 ---
        if (IS_ZOOM_SUPPORTED) {
            timerPanel.style.zoom = currentScale;
            resizer.style.transform = `scale(${1 / currentScale})`; // ハンドル自体の大きさは維持

            // 倍率適用後に座標を再適用(adjustInitialPositionで設定されたrawX, rawYを使用)
            updatePosition(timerPanel, rawX, rawY);
        } else {
            // Firefox用フォールバック
            timerPanel.style.transformOrigin = "0 0";
            timerPanel.style.transform = `${timerPanel.style.transform} scale(${currentScale})`;
        }

        // --- ドラッグ操作によるリサイズ処理 ---
        let isResizing = false;
        let startX, startY, startScale;

        const onMouseMove = (e) => {
            if (!isResizing) return;
            const delta = (e.clientX - startX) + (e.clientY - startY);
            const sensitivity = 0.003; // 感度調整
            let newScale = startScale + (delta * sensitivity);

            // 倍率制限 (0.5倍 ~ 3.0倍)
            if (newScale < 0.5) newScale = 0.5;
            if (newScale > 3.0) newScale = 3.0;

            currentScale = newScale;

            if (IS_ZOOM_SUPPORTED) {
                timerPanel.style.zoom = currentScale;
                resizer.style.transform = `scale(${1 / currentScale})`;
                updatePosition(timerPanel, rawX, rawY);
            } else {
                const currentTrans = timerPanel.style.transform.replace(/scale\([^)]+\)/, '').trim();
                timerPanel.style.transform = `${currentTrans} scale(${currentScale})`;
            }
        };

        const onMouseUp = () => {
            if (isResizing) {
                isResizing = false;
                document.body.style.cursor = '';
                document.removeEventListener('mousemove', onMouseMove);
                document.removeEventListener('mouseup', onMouseUp);
                localStorage.setItem(STORAGE_KEY, currentScale); // 設定保存
            }
        };

        resizer.addEventListener('mousedown', (e) => {
            e.stopPropagation();
            e.preventDefault();
            isResizing = true;
            startX = e.clientX;
            startY = e.clientY;
            startScale = currentScale;
            document.body.style.cursor = 'se-resize';
            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
        });

        // --- 監視処理 (本体ドラッグ移動時の補正) ---
        // ココフォリア本体がタイマーを動かした際、座標が上書きされるのを検知して再補正する
        const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
                if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
                    if (!IS_ZOOM_SUPPORTED) return;

                    // 外部要因でzoomが消されていたら再適用
                    if (timerPanel.style.zoom != currentScale) {
                        timerPanel.style.zoom = currentScale;
                    }

                    // transformの変更を検知
                    const currentTransform = timerPanel.style.transform;
                    if (!currentTransform.includes('translate3d')) return;

                    const match = currentTransform.match(/translate3d\(([^,]+)px,\s*([^,]+)px/);
                    if (!match) return;

                    const curX = parseFloat(match[1]);
                    const curY = parseFloat(match[2]);

                    const lastFixedX = parseFloat(timerPanel.dataset.lastFixedX);
                    const lastFixedY = parseFloat(timerPanel.dataset.lastFixedY);

                    // 自身による補正更新ならループ防止のため無視
                    if (Math.abs(curX - lastFixedX) < 0.1 && Math.abs(curY - lastFixedY) < 0.1) {
                        return;
                    }

                    // アプリ本体による座標変更であれば、値を更新して補正適用
                    rawX = curX;
                    rawY = curY;
                    updatePosition(timerPanel, rawX, rawY);
                }
            });
        });

        observer.observe(timerPanel, { attributes: true });
    }

    // DOM監視開始 (ページ遷移やリロードで要素が作り直された場合に対応)
    const bodyObserver = new MutationObserver(() => {
        if (!document.querySelector('#ccfolia-timer-resizer')) {
            if (findTimerElement()) {
                initTimerResizer();
            }
        }
    });
    bodyObserver.observe(document.body, { childList: true, subtree: true });

    // 初回実行
    initTimerResizer();

})();