Gemini to VOICEVOX

Geminiのお返事を、VOICEVOXと連携して自動読み上げ!

// ==UserScript==
// @name         Gemini to VOICEVOX
// @namespace    https://bsky.app/profile/neon-ai.art
// @homepage     https://bsky.app/profile/neon-ai.art
// @version      4.5
// @description  Geminiのお返事を、VOICEVOXと連携して自動読み上げ!
// @author       ねおん
// @match        https://gemini.google.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_addStyle
// @connect      localhost
// @license      CC BY-NC 4.0
// ==/UserScript==

(function() {
    'use strict';

    const SCRIPT_VERSION = '4.5';
    const STORE_KEY = 'gemini_voicevox_config';

    // ========= グローバルな再生・操作制御変数 =========
    let currentAudio = null;
    let currentXhr = null; // 合成中のXHRを保持(中断用)
    let currentSpeakerNameXhr = null; // スピーカー名取得用のXHR
    let isPlaying = false;
    let lastAutoPlayedText = ''; // 最後に自動再生したテキストをキャッシュ

    // ========= 永続化された設定値の読み込み =========
    let config = GM_getValue(STORE_KEY, {
        speakerId: 4,
        apiUrl: 'http://localhost:50021',
        autoPlay: true,
        minTextLength: 10,
        shortcutKey: 'Ctrl+Shift+V'
    });

    let debounceTimerId = null;
    const DEBOUNCE_DELAY = 1000;

    let settingsMenuId = null;

    // スタイル定義 (GM_addStyle)
    GM_addStyle(`
        #mei-settings-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0, 0, 0, 0.7);
            z-index: 99999;
        }
        #mei-settings-panel {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: #333; /* Dark background */
            padding: 25px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
            width: 90%;
            max-width: 560px;
            color: #e8eaed; /* Light text */
        }
        .mei-input-field {
            width: 100%;
            padding: 8px 10px;
            margin-top: 5px;
            border: 1px solid #5f6368;
            border-radius: 4px;
            box-sizing: border-box;
            background-color: #202124; /* Darker input background */
            color: #e8eaed;
            font-size: 14px;
        }
        .mei-input-field:focus {
            border-color: #8ab4f8;
            outline: none;
        }
        .mei-button-primary {
            padding: 8px 15px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
            background-color: #8ab4f8; /* Blue button */
            color: #202124;
        }
        .mei-button-secondary {
            padding: 8px 15px;
            border: 1px solid #5f6368;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
            background-color: #333;
            color: #e8eaed;
        }
    `);

    // ========= トーストメッセージを表示する関数 =========
    function showToast(msg, isSuccess) {
        const toastId = 'hgf-toast-mei';
        console.log(`[TOAST] ${msg}`);

        // 既存のトーストはすぐに削除
        const existingToast = document.getElementById(toastId);
        if (existingToast) {
            existingToast.remove();
        }

        // 20ms遅延させて、重いDOM操作中のレンダリング競合を回避
        setTimeout(() => {
            const toast = document.createElement('div');
            toast.textContent = msg;
            toast.id = toastId;
            toast.classList.add('hgf-toast');

            let bgColor;
            if (isSuccess === true) {
                bgColor = '#007bff';
            } else if (isSuccess === false) {
                bgColor = '#dc3545';
            } else {
                bgColor = '#6c757d';
            }

            toast.style.cssText = `
                position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
                background: ${bgColor}; color: white; padding: 10px 20px;
                border-radius: 6px; z-index: 100000;
                font-size: 14px; transition: opacity 1.0s ease, transform 1.0s ease; opacity: 0;
            `;
            document.body.appendChild(toast);

            // フェードインアニメーションを起動
            setTimeout(() => {
                toast.style.opacity = '1';
                toast.style.transform = 'translate(-50%, -20px)';
            }, 10);

            // 自動非表示ロジック
            if (isSuccess !== null) {
                setTimeout(() => {
                    toast.style.opacity = '0';
                    toast.style.transform = 'translate(-50%, 0)';
                    setTimeout(() => {
                        if (document.body.contains(toast)) {
                            toast.remove();
                        }
                    }, 1000);
                }, 3000);
            }
        }, 20);
    }

    // 再生・合成中の処理をすべてリセットし、ボタンを初期状態に戻す関数
    function resetOperation(button, isStopRequest = false) {
        // 1. Audioリセット
        if (currentAudio) {
            currentAudio.pause();
            currentAudio.src = '';
            currentAudio = null;
        }
        isPlaying = false;

        // 2. XHR/合成リセット(中断)
        if (currentXhr) {
            currentXhr.abort(); // リクエストを中断
            currentXhr = null;
            if (isStopRequest) {
                showToast('音声合成を中断したわ!', false);
            }
        }

        // 3. ボタンリセット
        if (button) {
            button.textContent = '🔊 再生';
            button.style.backgroundColor = '#007bff';
            button.style.color = 'white';
            button.disabled = false;
            button.removeEventListener('click', stopConversion);
            button.addEventListener('click', startConversion);
        }

        // サンプルボタンが合成中・再生中だった場合もリセット
        const sampleButton = document.getElementById('mei-sample-play-btn');
        if(sampleButton && sampleButton.textContent === '■ 再生停止') {
             resetSampleButtonState(sampleButton);
        } else if (sampleButton && sampleButton.textContent === '⏱ 合成中...') {
             resetSampleButtonState(sampleButton);
        }
    }

    // 停止処理
    function stopConversion() {
        const button = document.getElementById('convertButton');
        if (isPlaying || currentXhr) {
             // 再生中または合成中の停止
             showToast('音声再生を停止したわ!', false);
             resetOperation(button, true); // trueで合成中断メッセージはresetOperationに任せる
        } else {
            // 念のためリセット
            resetOperation(button);
        }
    }

    // 特定のクラス名に依存せず、操作ボタンを子孫に持つ祖先を動的に探索するロジックを強化
    function addConvertButton() {
        const buttonId = 'convertButton';
        const wrapperId = 'convertButtonWrapper';
        let button = document.getElementById(buttonId);
        let wrapper = document.getElementById(wrapperId);

        // .response-container のすべてを取得し、配列の最後の要素を使う
        const allResponseContainers = document.querySelectorAll('.response-container');
        if (allResponseContainers.length === 0) return;

        const lastAnswerPanel = allResponseContainers[allResponseContainers.length - 1];

        // 挿入ターゲットの Flexコンテナ本体 (.buttons-container-v2) を取得
        const buttonsContainer = lastAnswerPanel.querySelector(
            '.response-container-footer .actions-container-v2 .buttons-container-v2'
        );
        if (!buttonsContainer) {
            return;
        }

        // 挿入の基準点となるスペーサー (.spacer) を取得
        const spacer = buttonsContainer.querySelector('.spacer');
        if (!spacer) {
            console.log("Gemini Voice: スペーサーが見つかりませんでした。");
            return;
        }

        if (buttonsContainer && spacer) {
            if (!wrapper) {
                // ラッパーを作成 (Flex Itemとして機能させるため)
                wrapper = document.createElement('div');
                wrapper.id = wrapperId;

                button = document.createElement('button');
                button.id = buttonId;
                // v3.5のカスタムCSSを適用
                button.style.cssText = 'padding: 5px 10px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; margin-left: 4px;';

                resetOperation(button);

                wrapper.appendChild(button);
            } else {
                button = document.getElementById(buttonId);
                if (!button) return;
            }

            // ボタン群の最後尾(spacerの直前)にラッパーを挿入
            buttonsContainer.insertBefore(wrapper, spacer);
            // console.log("Gemini Voice: 読み上げボタンを挿入しました。ターゲット:", buttonsContainer.id || buttonsContainer.tagName);
        } else {
            console.log("ターゲット要素またはスペーサーが見つかりませんでした。");
            return;
        }

        // ボタンの状態更新ロジック
        if (currentXhr) {
            // 合成中の状態
            button.textContent = '⏱ 合成中...';
            button.style.backgroundColor = '#6c757d';
            button.disabled = false;
            button.removeEventListener('click', startConversion);
            button.addEventListener('click', stopConversion);
        } else if (isPlaying) {
            // 再生中の状態
            button.textContent = '■ 停止';
            button.style.backgroundColor = '#dc3545';
            button.disabled = false;
            button.removeEventListener('click', startConversion);
            button.addEventListener('click', stopConversion);
        } else {
            // 停止中の状態
            button.textContent = '🔊 再生';
            button.style.backgroundColor = '#007bff';
            button.disabled = false;
            button.removeEventListener('click', stopConversion);
            button.addEventListener('click', startConversion);
        }
    }

    function getGeminiAnswerText(){
        const allResponseContainer = document.querySelectorAll('.response-container');
        if (allResponseContainer.length === 0) return '';

        const textContainer = allResponseContainer[allResponseContainer.length - 1];
        if (!textContainer) return '';

        const clonedContainer = textContainer.cloneNode(true);
        const isProcessingState = clonedContainer.querySelector('.processing-state');
        const buttonWrapper = clonedContainer.querySelector('#convertButtonWrapper');
        if (buttonWrapper) {
            buttonWrapper.remove();
        }
        const loadingElement = clonedContainer.querySelector('.gpi-static-text-loader');
        if (loadingElement) {
            loadingElement.remove();
        }
        const avatarGutter = clonedContainer.querySelector('.avatar-gutter');
        if (avatarGutter) {
            avatarGutter.remove();
        }
        const sourceButton = clonedContainer.querySelector('.legacy-sources-sidebar-button');
        if (sourceButton) {
            sourceButton.remove();
        }
        const codeElements = clonedContainer.querySelectorAll('pre, code, code-block');
        codeElements.forEach(code => code.remove());

        // 🌟 V4.4 デバッグコードの追加: 「お待ちください」検出時にDOM構造を出力
        /*
        const rawText = clonedContainer.innerText || '';
        if (rawText.includes('お待ちください')) {
            console.warn("🔊 デバッグ情報: 「お待ちください」が検出されました。この時点のDOM構造を出力します。");

            // 検出された回答パネル(クローン)のouterHTMLを出力。
            // これで「お待ちください」を囲んでいる要素のクラス名や構造がわかるわ!
            console.log("【検出された回答パネルのHTML】(innerText: '" + rawText.substring(0, 50).replace(/\n/g, ' ') + "...')");
            console.log(clonedContainer.outerHTML);

            // 5階層上の要素のタグとクラス名だけを表示
            let targetElement = clonedContainer;
            let parentInfo = '';
            for (let i = 0; i < 5; i++) {
                if (targetElement.parentElement) {
                    targetElement = targetElement.parentElement;
                    parentInfo += targetElement.tagName + (targetElement.className ? '.' + targetElement.className.split(' ').join('.') : '') + ' > ';
                } else {
                    break;
                }
            }
            console.log("【親階層情報】(5階層まで): " + parentInfo.slice(0, -3));

            // ★★★ デバッグ情報収集後、この処理は必ず削除すること! ★★★
        }
        */

        let text = clonedContainer.innerText || '';

        // 1. コードブロック、コメント、タイトル記号の除去
        text = text.replace(/```[a-z]*[\s\S]*?```|\/\/.*|^\s*[#*]+\s/gim, ' ');
        // 2. その他のマークダウン記号の除去 (ここは現状維持で良さそう)
        text = text.replace(/(\*{1,2}|_{1,2}|~{1,2}|#|\$|>|-|\[.*?\]\(.*?\)|`|\(|\)|\[|\]|<|>|\/|\\|:|\?|!|;|=|\+|\|)/gim, ' ');
        // 3. 連続する句読点や空白の調整 (ここは現状維持で良さそう)
        text = text.replace(/([\.\!\?、。?!]{2,})/g, function(match, p1) {
            return p1.substring(0, 1);
        });
        text = text.replace(/(\s{2,})/g, ' ').trim();

        if (isProcessingState) {
            return '';
        }
        if (text.startsWith('お待ちください')) {
            return '';
        }
        if (text.includes('Analyzing input...') || text.includes('Generating response...')) {
            return '';
        }

        return text;
    }

    // synthesizeAudio (Audioオブジェクトの保持とボタン状態の変更)
    function synthesizeAudio(audioQuery, button, isAutoPlay = false) {
        // XHRリクエストを準備
        const currentConfig = GM_getValue(STORE_KEY, config);
        const synthesizeUrl = `${currentConfig.apiUrl}/synthesis?speaker=${currentConfig.speakerId}`;

        // XHRオブジェクトを保存
        const xhr = GM_xmlhttpRequest({
            method: 'POST',
            url: synthesizeUrl,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(audioQuery),
            responseType: 'blob',
            onload: function(response) {
                currentXhr = null; // リクエスト完了

                if (response.status === 200 && response.response) {
                    const audioBlob = response.response;

                    const audioUrl = URL.createObjectURL(audioBlob);
                    const audio = new Audio(audioUrl);

                    // グローバル変数にAudioオブジェクトを保持
                    currentAudio = audio;
                    isPlaying = true;

                    audio.play();

                    audio.onended = () => {
                        URL.revokeObjectURL(audioUrl);
                        // 再生終了時に状態をリセット
                        resetOperation(button);
                    };

                    // 再生が始まったらボタンを停止表示に変更
                    if(button) {
                        button.textContent = '■ 停止';
                        button.style.backgroundColor = '#dc3545';
                        button.removeEventListener('click', startConversion);
                        button.addEventListener('click', stopConversion);
                    }

                    showToast('WAVデータの取得に成功したわ!音声再生中よ!', true);
                } else {
                    showToast(`VOICEVOX合成に失敗したわ... (Status: ${response.status})`, false);
                    console.error('VOICEVOX Synthesize Error:', response);
                    resetOperation(button); // エラー時もリセット
                }
            },
            onerror: function(error) {
                currentXhr = null; // リクエスト完了
                showToast('合成中にエラーが発生したわ。VOICEVOXエンジンは起動している?', false);
                console.error('VOICEVOX Synthesize Connection Error:', error);
                resetOperation(button); // エラー時もリセット
            }
        });
        currentXhr = xhr; // XHRオブジェクトを保存
    }

    function synthesizeSampleAudio(audioQuery, button, text, speakerId) {
        showToast(`テストテキスト合成中...`, null);

        const currentConfig = GM_getValue(STORE_KEY, config);
        const synthesizeUrl = `${currentConfig.apiUrl}/synthesis?speaker=${speakerId}`;

        // 再生停止ボタンに切り替え
        if (button) {
            button.textContent = '■ 再生停止';
            button.style.backgroundColor = '#dc3545'; // Red
            button.removeEventListener('click', startSampleConversion);
            button.addEventListener('click', stopConversion); // グローバル停止関数を呼ぶ
        }

        const xhr = GM_xmlhttpRequest({
            method: 'POST',
            url: synthesizeUrl,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(audioQuery),
            responseType: 'blob',
            onload: function(response) {
                currentXhr = null; // リクエスト完了
                if (response.status === 200 && response.response) {
                    const audioBlob = response.response;
                    const audioUrl = URL.createObjectURL(audioBlob);
                    const audio = new Audio(audioUrl);
                    currentAudio = audio;
                    isPlaying = true;
                    audio.play();

                    audio.onended = () => {
                        URL.revokeObjectURL(audioUrl);
                        // 再生終了時に状態をリセット (メインボタンは操作しない)
                        resetOperation(null);
                        resetSampleButtonState(button); // サンプルボタンを再開表示に戻す
                    };
                    showToast('テスト音声再生中よ!', true);
                } else {
                    showToast(`VOICEVOX合成に失敗したわ... (Status: ${response.status})`, false);
                    console.error('VOICEVOX Synthesize Error:', response);
                    resetOperation(null);
                    resetSampleButtonState(button);
                }
            },
            onerror: function(error) {
                currentXhr = null;
                showToast('テスト音声の合成中にエラーが発生したわ。', false);
                console.error('VOICEVOX Synthesize Connection Error:', error);
                resetOperation(null);
                resetSampleButtonState(button);
            }
        });
        currentXhr = xhr;
    }

    function startSampleConversion() {
        const SAMPLE_TEXT = '音声のテストだよ!この声で読み上げするよ!';
        const button = document.getElementById('mei-sample-play-btn');
        const speakerIdInput = document.getElementById('speakerId');

        if (isPlaying || currentXhr) {
            showToast('今は再生中か合成中よ。停止ボタンで止めてね。', false);
            return;
        }

        // 入力値を取得し、不正な値ならエラー
        if (!speakerIdInput) return; // 念の為のガード
        const currentSpeakerId = parseInt(speakerIdInput.value, 10);

        if (isNaN(currentSpeakerId) || currentSpeakerId < 0) {
            showToast('スピーカーIDが不正よ!半角数字を確認してね。', false);
            return;
        }

        // 合成中の状態
        if (button) {
            button.textContent = '⏱ 合成中...';
            button.style.backgroundColor = '#6c757d';
            button.removeEventListener('click', startSampleConversion);
            button.addEventListener('click', stopConversion); // グローバル停止関数を呼ぶ
        }

        const currentConfig = GM_getValue(STORE_KEY, config);
        const audioQueryUrl = `${currentConfig.apiUrl}/audio_query`;
        const queryParams = new URLSearchParams({
            text: SAMPLE_TEXT,
            speaker: currentSpeakerId
        });

        const xhr = GM_xmlhttpRequest({
            method: 'POST',
            url: `${audioQueryUrl}?${queryParams.toString()}`,
            headers: { 'Content-Type': 'application/json' },
            onload: function(response) {
                currentXhr = null; // リクエスト完了
                if (response.status === 200) {
                    const audioQuery = JSON.parse(response.responseText);
                    synthesizeSampleAudio(audioQuery, button, SAMPLE_TEXT, currentSpeakerId);
                } else {
                    showToast(`VOICEVOXとの連携に失敗したわ... (Status: ${response.status})`, false);
                    console.error('VOICEVOX Query Error:', response);
                    resetOperation(null);
                    resetSampleButtonState(button);
                }
            },
            onerror: function(error) {
                currentXhr = null; // リクエスト完了
                showToast('VOICEVOXエンジンに接続できないわ... 起動しているか確認してね。', false);
                console.error('VOICEVOX Connection Error:', error);
                resetOperation(null);
                resetSampleButtonState(button);
            }
        });
        currentXhr = xhr; // XHRオブジェクトを保存
    }

    function resetSampleButtonState(button) {
        if (button) {
            button.textContent = '🔊 サンプル再生';
            button.style.backgroundColor = '#5cb85c'; // Green
            button.removeEventListener('click', stopConversion);
            button.addEventListener('click', startSampleConversion);
            button.disabled = false;
        }
    }

    async function startConversion(isAutoPlay = false) {
        const button = document.getElementById('convertButton');

        // 1. 再生中チェック
        if (isPlaying) {
            if (isAutoPlay) {
                // 自動再生時は再生中の音声を強制停止して、新しい合成を優先
                // console.log('[ABORT_PLAYING] 新しい自動再生が検出されたため、再生中の音声を強制停止するわ!');
                resetOperation(button); // Audio停止と状態リセットを実行
            } else {
                // 手動再生中に別の手動操作が来た場合はブロック
                showToast('今は再生中よ。停止ボタンで止めてから次の操作をしてね。', false);
                return;
            }
        }

        // 2. 合成中チェック(自動再生時は中断して優先、手動時はブロック)
        if (currentXhr) {
            if (isAutoPlay) {
                // 新しい自動再生が来たら、前の合成処理をキャンセルして、新しい合成を優先する
                // console.log('[ABORT] 新しい自動再生が検出されたため、前の合成処理をキャンセルします。');
                resetOperation(button);
            } else {
                // 手動合成中に別の手動合成が来た場合はブロック
                showToast('今は合成中よ。停止ボタンで止めてから次の操作をしてね。', false);
                return;
            }
        }

        if (isAutoPlay) {
            // 自動再生の場合はトーストを控えめに
        } else {
            showToast('Geminiの回答を取得中...', null);
        }

        const text = getGeminiAnswerText();

        if (!text || text.trim() === '') {
            showToast('回答テキストが取得できなかったか、全て除去されたわ...', false);
            return;
        }

        if (!isAutoPlay) {
            showToast(`テキストクレンジング完了!送信テキスト: "${text.substring(0, 30)}..."`, null);
        } else {
            // 自動再生の場合はテキスト表示も省略
        }

        // 合成中のボタン表示に変更
        if (button) {
            button.textContent = '⏱ 合成中...';
            button.style.backgroundColor = '#6c757d';
            button.removeEventListener('click', startConversion);
            button.addEventListener('click', stopConversion);
        }
        showToast('音声データを合成準備中...', null);

        const currentConfig = GM_getValue(STORE_KEY, config);
        const audioQueryUrl = `${currentConfig.apiUrl}/audio_query`;
        const queryParams = new URLSearchParams({
            text: text,
            speaker: currentConfig.speakerId
        });

        // XHRオブジェクトを保存
        const xhr = GM_xmlhttpRequest({
            method: 'POST',
            url: `${audioQueryUrl}?${queryParams.toString()}`,
            headers: { 'Content-Type': 'application/json' },
            onload: function(response) {
                currentXhr = null; // リクエスト完了

                if (response.status === 200) {
                    const audioQuery = JSON.parse(response.responseText);
                    synthesizeAudio(audioQuery, button, isAutoPlay);
                } else {
                    showToast(`VOICEVOXとの連携に失敗したわ... (Status: ${response.status})`, false);
                    console.error('VOICEVOX Query Error:', response);
                    resetOperation(button); // エラー時もリセット
                }
            },
            onerror: function(error) {
                currentXhr = null; // リクエスト完了
                showToast('VOICEVOXエンジンに接続できないわ... 起動しているか確認してね。', false);
                console.error('VOICEVOX Connection Error:', error);
                resetOperation(button);
            }
        });
        currentXhr = xhr; // XHRオブジェクトを保存
        if (isAutoPlay) {
            lastAutoPlayedText = text; // 自動再生の場合、本文をキャッシュする
        }
    }

    // ========= 設定UI表示関数 =========
    function openSettings() {
        if (document.getElementById('mei-settings-overlay')) {
            return;
        }

        config = GM_getValue(STORE_KEY, config);

        // 1. OVERLAY (トップコンテナ)
        const overlay = document.createElement('div');
        overlay.id = 'mei-settings-overlay';
        overlay.style.cssText = 'display: flex; justify-content: center; align-items: center;';
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) {
                overlay.remove();
                document.removeEventListener('keydown', escListener); // ESCリスナーも削除
            }
        });

        // ESCキーで閉じる
        const escListener = (e) => {
            if (e.key === 'Escape') {
                e.preventDefault();
                overlay.remove();
                document.removeEventListener('keydown', escListener);
            }
        };
        document.addEventListener('keydown', escListener);

        // 2. PANEL (設定パネル本体)
        const panel = document.createElement('div');
        panel.id = 'mei-settings-panel';

        // 3. TITLE (タイトル)
        const titleH2 = document.createElement('h2');
        titleH2.textContent = `🔊 VOICEVOX連携 設定 (V${SCRIPT_VERSION})`;
        titleH2.style.cssText = 'margin-top: 0; margin-bottom: 20px; font-size: 1.5em; color: #e8eaed;';
        panel.appendChild(titleH2);
        panel.addEventListener('click', (e) => {
            // パネル内でのクリックイベントの伝播をここで完全に停止させる
            e.stopPropagation();
        });

        // 4. SPEAKER ID GROUP
        const speakerGroup = document.createElement('div');
        speakerGroup.style.cssText = 'display: flex; align-items: center; margin-bottom: 5px;';

        const speakerLabel = document.createElement('label');
        speakerLabel.textContent = 'VOICEVOX スピーカーID:';
        speakerLabel.setAttribute('for', 'speakerId');
        speakerLabel.style.cssText = 'font-weight: bold; color: #9aa0a6; margin-right: 15px; flex-shrink: 0;';
        speakerGroup.appendChild(speakerLabel);

        const speakerInput = document.createElement('input');
        speakerInput.type = 'number';
        speakerInput.id = 'speakerId';
        speakerInput.value = config.speakerId;
        speakerInput.min = '0';
        speakerInput.step = '1';
        speakerInput.style.cssText = 'width: 80px; flex-grow: 0;';
        speakerInput.classList.add('mei-input-field');
        speakerGroup.appendChild(speakerInput);

        // 話者名表示エリアを追加
        const speakerNameDisplay = document.createElement('span');
        speakerNameDisplay.id = 'speakerNameDisplay';
        speakerNameDisplay.textContent = '(確認中...)';
        speakerNameDisplay.style.cssText = 'margin-left: 10px; font-weight: bold; color: #4CAF50;'; // Green for cool success
        speakerGroup.appendChild(speakerNameDisplay);
        panel.appendChild(speakerGroup);

        // ヘルプテキストを追加のdivで分離し、1行表示を維持
        const speakerHelpGroup = document.createElement('div');
        speakerHelpGroup.style.marginBottom = '15px';
        const speakerHelp = document.createElement('p');
        speakerHelp.textContent = '*使用する声のIDを半角数字で入力してね。';
        speakerHelp.style.cssText = 'margin-top: 5px; font-size: 0.8em; color: #9aa0a6;';
        speakerHelpGroup.appendChild(speakerHelp);
        panel.appendChild(speakerHelpGroup);

        function updateSpeakerNameDisplay(id) {
            const apiUrl = config.apiUrl;
            const display = document.getElementById('speakerNameDisplay');
            if (!display) return;

            display.textContent = '(確認中...)';
            display.style.color = '#5bc0de'; // Info Blue

            // 進行中のリクエストがあればキャンセル
            if (currentSpeakerNameXhr) {
                currentSpeakerNameXhr.abort();
                currentSpeakerNameXhr = null;
            }

            // APIリクエスト
            currentSpeakerNameXhr = GM_xmlhttpRequest({
                method: 'GET',
                url: `${apiUrl}/speakers`,
                onload: function(response) {
                    currentSpeakerNameXhr = null;
                    console.log(`[VOICEVOX_NAME] /speakers 応答 Status: ${response.status}`); 
                    
                    if (response.status === 200) {
                        try {
                            const speakers = JSON.parse(response.responseText);
                            
                            // 🌟 V4.5 FIX: 話者リスト全体をログにダンプ 🌟
                            console.groupCollapsed(`[VOICEVOX_NAME] 検出された話者リスト(全 ${speakers.length} 件)`);
                            console.log(speakers); // 全話者の詳細を表示
                            console.groupEnd();
                            
                            const targetId = parseInt(id, 10);
                            console.log(`[VOICEVOX_NAME] 検索中のID: ${targetId}`); // 検索対象IDを表示
                            
                            let speakerName = '不明なID';
                            let styleName = '';

                            // IDから話者とスタイルを探索
                            for (const speaker of speakers) {
                                for (const style of speaker.styles) {
                                    // スタイルIDが一致するかチェック
                                    if (style.id === targetId) { // targetId(数値)と比較
                                        speakerName = speaker.name;
                                        styleName = style.name;
                                        break;
                                    }
                                }
                                if (styleName) break;
                            }
                            
                            if (styleName) {
                                display.textContent = `${speakerName}(${styleName})`;
                                display.style.color = '#4CAF50'; 
                                console.log(`[VOICEVOX_NAME] ID ${targetId} は ${speakerName}(${styleName})よ!`);
                            } else {
                                // 200だがIDが見つからない
                                display.textContent = '(IDが見つからないわ...)';
                                display.style.color = '#d9534f'; 
                                console.warn(`[VOICEVOX_NAME] 設定されたID ${targetId} はリストに見つからなかったわ...`);
                            }

                        } catch (e) {
                            display.textContent = '(JSONパースエラーよ...)';
                            display.style.color = '#d9534f'; 
                            console.error('[VOICEVOX_NAME] JSONパースエラー:', e);
                        }
                    } else {
                        // 200以外のステータス
                        display.textContent = `(APIエラー: ${response.status})`;
                        display.style.color = '#d9534f'; 
                    }
                },
                onerror: function(error) {
                    currentSpeakerNameXhr = null;
                    display.textContent = '(接続エラーよ...)';
                    display.style.color = '#d9534f'; 
                    // 🌟 V4.5 FIX: 接続エラーをログ出力 🌟
                    console.error('[VOICEVOX_NAME] 接続エラー!', error);
                }
            });
        }

        // 🌟 入力値が変わったら更新 🌟
        speakerInput.addEventListener('input', (e) => {
             updateSpeakerNameDisplay(e.target.value);
        });

        // サンプル再生ボタン
        const sampleGroup = document.createElement('div');
        sampleGroup.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-top: 5px; border-top: 1px solid #444;';

        const sampleText = document.createElement('p');
        sampleText.textContent = '👆この声で合っているかテストよ!';
        sampleText.style.cssText = 'margin: 0; font-size: 0.9em; color: #e8eaed;';
        sampleGroup.appendChild(sampleText);

        const sampleBtn = document.createElement('button');
        sampleBtn.id = 'mei-sample-play-btn';
        sampleBtn.textContent = '🔊 サンプル再生';
        sampleBtn.classList.add('mei-button-primary');
        sampleBtn.style.backgroundColor = '#5cb85c'; // Green color for sample
        sampleBtn.style.color = 'white';
        sampleBtn.style.fontWeight = 'bold';
        sampleBtn.addEventListener('click', startSampleConversion);
        sampleGroup.appendChild(sampleBtn);
        panel.appendChild(sampleGroup);

        // 5. API URL GROUP
        const apiGroup = document.createElement('div');
        apiGroup.style.cssText = 'display: flex; align-items: center; margin-bottom: 20px;';

        const apiLabel = document.createElement('label');
        apiLabel.textContent = 'VOICEVOX API URL:';
        apiLabel.setAttribute('for', 'apiUrl');
        apiLabel.style.cssText = 'font-weight: bold; color: #9aa0a6; margin-right: 15px; flex-shrink: 0;';
        apiGroup.appendChild(apiLabel);

        const apiInput = document.createElement('input');
        apiInput.type = 'url';
        apiInput.id = 'apiUrl';
        apiInput.value = config.apiUrl;
        apiInput.style.cssText = 'flex-grow: 1;';
        apiInput.classList.add('mei-input-field');
        apiGroup.appendChild(apiInput);
        panel.appendChild(apiGroup);

        // 自動再生 ON/OFF トグル
        const autoPlayGroup = document.createElement('div');
        autoPlayGroup.style.cssText = 'display: flex; align-items: center; margin-bottom: 20px;';

        const autoPlayInput = document.createElement('input');
        autoPlayInput.type = 'checkbox';
        autoPlayInput.id = 'autoPlay';
        autoPlayInput.checked = config.autoPlay;
        autoPlayInput.style.cssText = 'width: 20px; height: 20px; margin-right: 10px; flex-shrink: 0;';
        autoPlayGroup.appendChild(autoPlayInput);

        const autoPlayLabel = document.createElement('label');
        autoPlayLabel.textContent = '自動再生を有効にする (Geminiが回答完了したら自動再生)';
        autoPlayLabel.setAttribute('for', 'autoPlay');
        autoPlayLabel.style.cssText = 'font-weight: bold; color: #e8eaed; cursor: pointer;';
        autoPlayGroup.appendChild(autoPlayLabel);
        panel.appendChild(autoPlayGroup);

        // 最低読み上げ文字数 GROUP (minTextLength)
        const minLengthGroup = document.createElement('div');
        minLengthGroup.style.cssText = 'display: flex; align-items: center; margin-bottom: 5px;';

        const minLengthLabel = document.createElement('label');
        minLengthLabel.textContent = '最低読み上げ文字数 (文字):';
        minLengthLabel.setAttribute('for', 'minTextLength');
        minLengthLabel.style.cssText = 'font-weight: bold; color: #9aa0a6; margin-right: 15px; flex-shrink: 0;';
        minLengthGroup.appendChild(minLengthLabel);

        const minLengthInput = document.createElement('input');
        minLengthInput.type = 'number';
        minLengthInput.id = 'minTextLength';

        // 設定ファイルから値を取得
        minLengthInput.value = config.minTextLength;
        minLengthInput.min = '0';
        minLengthInput.step = '1';
        minLengthInput.classList.add('mei-input-field');
        minLengthInput.style.cssText = 'width: 80px; flex-grow: 0;'; // 幅を固定
        minLengthGroup.appendChild(minLengthInput);
        panel.appendChild(minLengthGroup);

        const minLengthHelp = document.createElement('p');
        minLengthHelp.textContent = '*この文字数以下の短い回答や待機メッセージは自動再生されないわ!';
        minLengthHelp.style.cssText = 'margin-top: 5px; margin-bottom: 20px; font-size: 0.8em; color: #9aa0a6;';
        panel.appendChild(minLengthHelp);

        // キー設定グループ
        const keyGroup = document.createElement('div');
        keyGroup.style.cssText = 'display: flex; align-items: center; margin-bottom: 5px;';

        const keyLabel = document.createElement('label');
        keyLabel.textContent = '再生/停止 ショートカットキー:';
        keyLabel.setAttribute('for', 'shortcutKey');
        keyLabel.style.cssText = 'font-weight: bold; color: #9aa0a6; margin-right: 15px; flex-shrink: 0;';
        keyGroup.appendChild(keyLabel);

        const keyInput = document.createElement('input');
        keyInput.type = 'text';
        keyInput.id = 'shortcutKey';
        keyInput.value = config.shortcutKey;
        keyInput.classList.add('mei-input-field');
        keyInput.style.cssText = 'background-color: #2c2c2c; width: 160px; flex-grow: 0;'; // 幅を固定
        keyInput.readOnly = true;
        keyGroup.appendChild(keyInput);
        panel.appendChild(keyGroup);

        const keyHelp = document.createElement('p');
        keyHelp.textContent = '*クリックしてから「Ctrl+Shift+V」などのキーをクールに押して設定してね!';
        keyHelp.style.cssText = 'margin-top: 5px; margin-bottom: 20px; font-size: 0.8em; color: #9aa0a6;';
        panel.appendChild(keyHelp);

        // キー録音ロジック
        let isRecording = false;

        keyInput.addEventListener('click', () => {
            if (isRecording) {
                isRecording = false;
                keyInput.style.backgroundColor = '#2c2c2c';
                if (keyInput.value.includes('...')) {
                    keyInput.value = config.shortcutKey; // 途中でやめたら元の値に戻す
                }
                return;
            }

            isRecording = true;
            keyInput.value = 'キーを押してください...';
            keyInput.style.backgroundColor = '#4d4d4d';
        });

        const recordKey = (e) => {
            if (!isRecording) return;
            e.preventDefault();
            e.stopPropagation();

            const isControl = e.ctrlKey || e.metaKey; // CommandキーもControlとして扱う
            const isAlt = e.altKey;
            const isShift = e.shiftKey;

            // ファンクションキー, Alt, Ctrl, Shift単体は許可しない
            if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key.startsWith('F')) {
                keyInput.value = '単体キーはダメよ!組み合わせてね。';
                return;
            }

            // IME入力中は処理しない
            if (e.isComposing || e.keyCode === 229) return;

            // Keyを大文字化
            let key = e.key;
            if (key.length === 1) {
                key = key.toUpperCase();
            } else if (key === ' ') {
                key = 'Space';
            }

            let shortcut = '';

            // V3.6 修正: 'Control' ではなく 'Ctrl' を使用
            if (isControl) shortcut += 'Ctrl+';
            if (isAlt) shortcut += 'Alt+';
            if (isShift) shortcut += 'Shift+';

            // 組み合わせがない場合は、エラーを出す
            if (!isControl && !isAlt && !isShift) {
                keyInput.value = 'Ctrl, Alt, Shiftのどれかは必須よ!';
                return;
            }

            if (key !== 'Control' && key !== 'Shift' && key !== 'Alt' && key !== 'Meta') {
                shortcut += key;
            }

            if (shortcut.endsWith('+') || shortcut === '' || shortcut === 'Ctrl+' || shortcut === 'Alt+' || shortcut === 'Shift+') {
                keyInput.value = '有効なキーの組み合わせじゃないわ...';
                return;
            }

            // 成功
            keyInput.value = shortcut;
            keyInput.style.backgroundColor = '#2c2c2c';
            isRecording = false;
        };

        keyInput.addEventListener('keydown', recordKey);
        panel.addEventListener('keydown', (e) => {
            // Spaceキーが押された場合にスクロールを防ぐ
            if (e.key === ' ' && isRecording) e.preventDefault();
        });

        // 6. BUTTON GROUP
        const buttonGroup = document.createElement('div');
        buttonGroup.style.cssText = 'display: flex; justify-content: flex-end; gap: 10px;';

        const closeBtn = document.createElement('button');
        closeBtn.id = 'mei-close';
        closeBtn.textContent = 'キャンセル';
        closeBtn.classList.add('mei-button-secondary');
        buttonGroup.appendChild(closeBtn);

        const saveBtn = document.createElement('button');
        saveBtn.id = 'mei-save';
        saveBtn.textContent = '保存';
        saveBtn.classList.add('mei-button-primary');
        buttonGroup.appendChild(saveBtn);
        panel.appendChild(buttonGroup);

        // 7. DOMにパネルとオーバーレイを追加
        overlay.appendChild(panel);
        document.body.appendChild(overlay);

        // 8. イベントリスナーの設定
        closeBtn.addEventListener('click', () => {
            document.removeEventListener('keydown', escListener);
            overlay.remove();
        });

        // 🌟 初期表示時に実行 🌟
        updateSpeakerNameDisplay(config.speakerId);

        saveBtn.addEventListener('click', () => {
            const newSpeakerId = parseInt(speakerInput.value, 10);
            const newApiUrl = apiInput.value.trim();
            const newAutoPlay = autoPlayInput.checked;
            const newShortcutKey = keyInput.value.trim();
            const minTextLengthInput = document.getElementById('minTextLength');
            const newMinTextLength = parseInt(minTextLengthInput.value, 10);

            if (isNaN(newSpeakerId) || newSpeakerId < 0) {
                showToast('スピーカーIDは半角数字で、0以上の値を入力してね!', false);
                return;
            }

            if (newShortcutKey === 'キーを押してください...' || newShortcutKey.includes('は必須よ!') || newShortcutKey.includes('じゃないわ...')) {
                showToast('ショートカットキーを正しく設定してね!', false);
                return;
            }

            if (isNaN(newMinTextLength) || newMinTextLength < 0) {
                showToast('最低読み上げ文字数は半角数字で、0以上の値を入力してね!', false);
                return;
            }

            const newConfig = {
                speakerId: newSpeakerId,
                apiUrl: newApiUrl,
                autoPlay: newAutoPlay,
                minTextLength: newMinTextLength,
                shortcutKey: newShortcutKey
            };

            GM_setValue(STORE_KEY, newConfig);
            config = newConfig;
            showToast('設定をクールに保存したわ!', true);
            document.removeEventListener('keydown', escListener);
            overlay.remove();
        });
    }

    // グローバルキーイベントリスナー
    function handleGlobalKeyDown(e) {
        // IME入力中は処理しない
        if (e.isComposing || e.keyCode === 229) return;

        // 設定が読み込まれていない、または設定が無効な場合は何もしない
        if (!config || !config.shortcutKey) return;

        const isControl = e.ctrlKey || e.metaKey; // CtrlまたはCommand
        const isAlt = e.altKey;
        const isShift = e.shiftKey;
        const button = document.getElementById('convertButton');

        // ボタンが存在しないか、設定パネルが開いている場合は何もしない
        if (!button || document.getElementById('mei-settings-overlay')) return;

        // Keyを大文字化
        let key = e.key;
        if (key.length === 1) {
            key = key.toUpperCase();
        } else if (key === ' ') {
            key = 'Space';
        }

        let pressedShortcut = '';

        if (isControl) pressedShortcut += 'Ctrl+'; // 'Ctrl' に統一
        if (isAlt) pressedShortcut += 'Alt+';
        if (isShift) pressedShortcut += 'Shift+';

        // 最後のキーが修飾キーではないことを確認 (Control, Shift, Alt, Meta)
        if (key !== 'Control' && key !== 'Shift' && key !== 'Alt' && key !== 'Meta') {
            pressedShortcut += key;
        }

        // キーが一致したら実行
        if (pressedShortcut === config.shortcutKey) {
            e.preventDefault(); // デフォルトの動作を抑制 (ブラウザショートカットなど)
            e.stopPropagation();

            // 再生中または合成中なら停止、それ以外なら再生
            if (isPlaying || currentXhr) {
                stopConversion();
            } else {
                // 再生開始。手動操作なので isAutoPlay は false
                startConversion(false);
            }
        }
    }

    // MutationObserverのロジック
    function observeDOMChanges() {
        // 監視ノードをdocument.bodyに固定
        const TARGET_NODE = document.body;
        const observer = new MutationObserver(function(mutations, observer) {
            // DOM操作が落ち着くまで待つ (デバウンス)
            clearTimeout(debounceTimerId);

            debounceTimerId = setTimeout(function() {
                addConvertButton();

                // 自動再生ロジック
                const currentConfig = GM_getValue(STORE_KEY, config);
                const button = document.getElementById('convertButton');

                // 自動再生がONで、ボタンが存在し、再生/合成中でなく、まだ自動再生されていない場合
                // 🌟 currentXhr のチェックも、新しい startConversion の中で行うため、本来はここでなくても大丈夫だが、安全のため残す 🌟
                if (currentConfig.autoPlay && button) {
                    // 正確な最新回答パネルの特定
                    const allResponseContainers = document.querySelectorAll('.response-container');
                    // コンテナが一つもない場合は処理を終了(安全のために return を使用)
                    if (allResponseContainers.length === 0) return;
                    const answerContainer = allResponseContainers[allResponseContainers.length - 1]; // 最後の回答パネルを取得
                    const hasFooter = answerContainer ? answerContainer.querySelector('.response-container-footer') : null;
                    const minLength = currentConfig.minTextLength || 0;
                    const currentText = getGeminiAnswerText();

                    // フッターがあり&最低文字数を超えている&キャッシュと比較して別のものの場合に自動再生
                    if (currentText.length > minLength && hasFooter && currentText !== lastAutoPlayedText) {
                        startConversion(true); // trueで自動再生として実行
                    }
                }
            }, DEBOUNCE_DELAY);
        });

        const observerConfig = { childList: true, subtree: true };
        observer.observe(TARGET_NODE, observerConfig);

        // 初回実行
        addConvertButton();
    }

    // メニュー登録
    if (settingsMenuId) GM_unregisterMenuCommand(settingsMenuId);
    settingsMenuId = GM_registerMenuCommand('🔊 設定', openSettings);

    // DOM監視を開始
    // window.onloadを待つと設定UIが登録されない場合があるため、即時実行に戻す
    observeDOMChanges();
    // グローバルキーイベントリスナー
    document.addEventListener('keydown', handleGlobalKeyDown);

})();