CCFOLIA Timer Resize & Auto Position

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();

})();