ChatGPT Read Aloud - Auto Speaker

Automatically triggers ChatGPT built-in text-to-speech for new responses. Includes a movable UI panel.

// ==UserScript==
// @name            ChatGPT Read Aloud - Auto Speaker
// @version         1.1
// @description     Automatically triggers ChatGPT built-in text-to-speech for new responses. Includes a movable UI panel.
// @author          https://winagentgpt.com
// @namespace       https://winagentgpt.com
// @match           https://chat.openai.com/*
// @match           https://chatgpt.com/*
// @grant           none
// @run-at          document-idle
// @license         CC0-1.0
// @compatible      chrome version >=71 (older versions untested)
// ==/UserScript==


(function() {
    'use strict';

    // --- AUTO SPEAK PROJECT ---
    // Automatically triggers ChatGPT's built-in TTS when new responses are detected

    // --- CONFIGURATION ---
    const AI_NAME = "ChatGPT";
    const RESPONSE_CONTAINER_SELECTOR = "div[data-message-id]";
    const COLLAPSED_WIDTH = "40px";
    const COLLAPSED_HEIGHT = "35px";
    const COLLAPSED_TEXT = "≡";

    // --- STATE ---
    let lastDetectedButtonState = null;
    let isCollapsed = false;
    let autoSpeakEnabled = false;
    let stopIconIntervalId = null;

    // --- HELPER: Logging ---
    function logToConsole(message, type = "info") {
        const timestamp = new Date().toLocaleTimeString();
        const prefix = `[AUTO SPEAK] [${timestamp}]`;
        if (type === "error") console.error(`${prefix} ${message}`);
        else if (type === "warn") console.warn(`${prefix} ${message}`);
        else console.log(`${prefix} ${message}`);
    }

    // --- ONE-TIME SETUP: Audio Control ---
    (function setupAudioHijack() {
        if (HTMLMediaElement.prototype._isHookedByAutoSpeak) return;

        let _isPauseAllowedByScript = false;
        const originalPause = HTMLMediaElement.prototype.pause;

        HTMLMediaElement.prototype.pause = function() {
            if (_isPauseAllowedByScript) {
                originalPause.apply(this, arguments);
            } else {
                logToConsole("Pause blocked - audio continues playing");
            }
        };

        HTMLMediaElement.prototype._isHookedByAutoSpeak = true;

        window.forceStopAllTtsAudio = function() {
            _isPauseAllowedByScript = true;
            document.querySelectorAll('audio, video').forEach(media => {
                if (!media.paused) media.pause();
            });
            _isPauseAllowedByScript = false;
            hideStopButton();
        };

        logToConsole("Audio control setup complete");
    })();

    // --- UI: Stop Button Management ---
    function showStopButton() {
        const stopBtn = document.getElementById('autoSpeakStopBtn');
        if (stopBtn) {
            stopBtn.style.display = 'block';
            logToConsole("Stop button shown");
        }
    }

    function hideStopButton() {
        const stopBtn = document.getElementById('autoSpeakStopBtn');
        if (stopBtn) {
            stopBtn.style.display = 'none';
            logToConsole("Stop button hidden");
        }
        if (stopIconIntervalId) {
            clearInterval(stopIconIntervalId);
            stopIconIntervalId = null;
        }
    }

    // --- Audio State Monitor ---
    function monitorAudioState() {
        const previouslyMonitoredAudio = document.querySelector("audio[data-auto-speak-monitored='true']");
        if (previouslyMonitoredAudio) {
            delete previouslyMonitoredAudio.dataset.autoSpeakMonitored;
        }

        const interval = setInterval(() => {
            const audioEl = document.querySelector("audio");
            if (audioEl && !audioEl.dataset.autoSpeakMonitored) {
                audioEl.dataset.autoSpeakMonitored = "true";

                const onAudioPause = () => {
                    logToConsole("Audio paused - hiding stop button");
                    hideStopButton();
                };
                const onAudioEnded = () => {
                    logToConsole("Audio ended - cleaning up");
                    hideStopButton();
                    audioEl.removeEventListener("ended", onAudioEnded);
                    audioEl.removeEventListener("pause", onAudioPause);
                };

                audioEl.addEventListener("ended", onAudioEnded);
                audioEl.addEventListener("pause", onAudioPause);
                clearInterval(interval);
            }
        }, 250);
        setTimeout(() => clearInterval(interval), 5000);
    }

    // --- TTS Trigger Function ---
    function triggerAutoSpeak() {
        if (!autoSpeakEnabled) return;

        function forceClick(element) {
            element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }));
            element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
            element.click();
            element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
            element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, cancelable: true }));
            return true;
        }

        function hideTheMenu() {
            const menuPanel = document.querySelector('[role="menu"]');
            if (menuPanel) {
                logToConsole("Menu panel found. Hiding it now.");
                menuPanel.style.visibility = 'hidden';
                menuPanel.style.opacity = '0';
            } else {
                logToConsole("Could not find menu panel to hide, but will proceed.");
            }
        }

        function clickTheAudioOption() {
            logToConsole("Phase 2: Looking for the 'Audio' or 'Listen' button...");
            let attempts = 0;
            const maxAttempts = 15;
            const audioInterval = setInterval(() => {
                attempts++;
                let audioButton = null;
                const possibleTexts = ['audio', 'listen', 'read aloud'];
                const potentialButtons = document.querySelectorAll('[role="menuitem"], [role="button"], button');

                for (const button of potentialButtons) {
                    const buttonText = button.textContent.trim().toLowerCase();
                    if (possibleTexts.some(text => buttonText.includes(text))) {
                        audioButton = button;
                        break;
                    }
                }

                if (audioButton) {
                    clearInterval(audioInterval);
                    logToConsole("Audio button found inside the menu. Clicking it...");
                    forceClick(audioButton);
                    hideTheMenu();
                    logToConsole("SUCCESS: TTS should be playing. Starting stop button display.");
                    showStopButton();
                    monitorAudioState();
                } else if (attempts >= maxAttempts) {
                    clearInterval(audioInterval);
                    logToConsole("FAILED in Phase 2: Could not find the audio button.", "error");
                    hideTheMenu();
                }
            }, 500);
        }

        function openTheMenu() {
            logToConsole("Phase 1: Looking for the menu button on the last response...");
            let attempts = 0;
            const maxAttempts = 20;
            const menuInterval = setInterval(() => {
                attempts++;
                const selectors = [
                    'button[aria-label="More actions"]',
                    'button[aria-haspopup="menu"]',
                    'button[id^="radix-"]',
                    'div[role="button"][aria-haspopup="menu"]'
                ];
                let menuButton = null;

                for (const selector of selectors) {
                    const buttons = document.querySelectorAll(selector);
                    if (buttons.length > 0) {
                        menuButton = buttons[buttons.length - 1];
                        break;
                    }
                }

                if (menuButton) {
                    clearInterval(menuInterval);
                    logToConsole("Menu button found. Clicking it to open the menu...");
                    if (forceClick(menuButton)) {
                        setTimeout(clickTheAudioOption, 500);
                    } else {
                        logToConsole("FAILED in Phase 1: Could not click the menu button.", "error");
                    }
                } else if (attempts >= maxAttempts) {
                    clearInterval(menuInterval);
                    logToConsole("FAILED in Phase 1: The 'More actions' menu button could not be found.", "error");
                }
            }, 500);
        }
        logToConsole("Starting the text-to-speech sequence...");
        openTheMenu();
    }


    // --- HELPER: Make Element Draggable ---
    function makeDraggable(element) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        let originalProps = {
            initialWidth: "",
            initialHeight: "",
            initialPadding: "",
            preDragTop: "",
            preDragLeft: "",
            collapsedSide: null
        };

        const computedStyle = getComputedStyle(element);
        originalProps.initialWidth = computedStyle.width;
        originalProps.initialHeight = computedStyle.height;
        originalProps.initialPadding = computedStyle.padding;

        element._dragMouseDownHandler = dragMouseDown;
        element.onmousedown = element._dragMouseDownHandler;

        function dragMouseDown(e) {
            if (e.target.closest("input, button, label")) return;
            e = e || window.event;
            e.preventDefault();
            originalProps.preDragTop = element.offsetTop + "px";
            originalProps.preDragLeft = element.offsetLeft + "px";
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e = e || window.event;
            e.preventDefault();
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;
            element.style.top = (element.offsetTop - pos2) + "px";
            element.style.left = (element.offsetLeft - pos1) + "px";
            element.style.bottom = "auto";
            element.style.right = "auto";
        }

        function closeDragElement() {
            document.onmouseup = null;
            document.onmousemove = null;
            if (!isCollapsed) checkAndCollapse(element);
        }

        function checkAndCollapse(element) {
            const rect = element.getBoundingClientRect();
            const elWidth = parseFloat(originalProps.initialWidth);
            const elHeight = parseFloat(originalProps.initialHeight);
            const winWidth = window.innerWidth;
            const winHeight = window.innerHeight;

            const offScreenLeft = rect.left < 0 ? -rect.left : 0;
            const offScreenRight = rect.left + elWidth > winWidth ? (rect.left + elWidth) - winWidth : 0;
            const offScreenTop = rect.top < 0 ? -rect.top : 0;
            const offScreenBottom = rect.top + elHeight > winHeight ? (rect.top + elHeight) - winHeight : 0;

            const halfWidth = elWidth / 2;
            const halfHeight = elHeight / 2;
            let collapseSide = null;

            if (offScreenLeft > halfWidth) collapseSide = "left";
            else if (offScreenRight > halfWidth) collapseSide = "right";
            else if (offScreenTop > halfHeight) collapseSide = "top";
            else if (offScreenBottom > halfHeight) collapseSide = "bottom";

            if (collapseSide) collapseSelector(element, collapseSide, rect);
        }

        function collapseSelector(element, side, currentRect) {
            if (isCollapsed) return;
            isCollapsed = true;
            logToConsole(`Collapsing to ${side}`);
            originalProps.collapsedSide = side;
            document.getElementById("autoSpeakContent").style.display = "none";
            document.getElementById("autoSpeakTab").style.display = "flex";
            element.style.width = COLLAPSED_WIDTH;
            element.style.height = COLLAPSED_HEIGHT;
            element.style.padding = "0";
            element.style.cursor = "pointer";

            let tabTop = Math.max(0, Math.min(currentRect.top, window.innerHeight - parseInt(COLLAPSED_HEIGHT)));
            let tabLeft = Math.max(0, Math.min(currentRect.left, window.innerWidth - parseInt(COLLAPSED_WIDTH)));

            switch (side) {
                case "left":
                    element.style.left = "0px";
                    element.style.top = tabTop + "px";
                    break;
                case "right":
                    element.style.left = (window.innerWidth - parseInt(COLLAPSED_WIDTH)) + "px";
                    element.style.top = tabTop + "px";
                    break;
                case "top":
                    element.style.top = "0px";
                    element.style.left = tabLeft + "px";
                    break;
                case "bottom":
                    element.style.top = (window.innerHeight - parseInt(COLLAPSED_HEIGHT)) + "px";
                    element.style.left = tabLeft + "px";
                    break;
            }
            element.style.bottom = "auto";
            element.style.right = "auto";
            element.onmousedown = null;
            document.getElementById("autoSpeakTab").onclick = () => expandSelector(element);
        }

        function expandSelector(element) {
            if (!isCollapsed) return;
            isCollapsed = false;
            logToConsole(`Expanding from ${originalProps.collapsedSide}`);
            document.getElementById("autoSpeakContent").style.display = "block";
            document.getElementById("autoSpeakTab").style.display = "none";
            element.style.width = originalProps.initialWidth;
            element.style.height = originalProps.initialHeight;
            element.style.padding = originalProps.initialPadding || "10px";
            element.style.cursor = "move";

            let newTop = parseFloat(originalProps.preDragTop) || 0;
            let newLeft = parseFloat(originalProps.preDragLeft) || 0;
            const restoredWidth = parseFloat(originalProps.initialWidth);
            const restoredHeight = parseFloat(originalProps.initialHeight);
            newTop = Math.max(0, Math.min(newTop, window.innerHeight - restoredHeight));
            newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - restoredWidth));
            element.style.top = newTop + "px";
            element.style.left = newLeft + "px";
            element.style.bottom = "auto";
            element.style.right = "auto";

            document.getElementById("autoSpeakTab").onclick = null;
            element.onmousedown = element._dragMouseDownHandler;
        }
    }


    // --- UI: Create Auto Speak Panel ---
    function createAutoSpeakPanel() {
        if (document.getElementById('autoSpeakPanel')) return;

        const panelDiv = document.createElement('div');
        panelDiv.id = 'autoSpeakPanel';
        Object.assign(panelDiv.style, {
            position: 'fixed',
            bottom: '100px',
            right: '10px',
            backgroundColor: 'rgba(255, 255, 255, 0.95)',
            border: '2px solid #4CAF50',
            padding: '12px',
            zIndex: '9999',
            fontFamily: 'sans-serif',
            fontSize: '13px',
            borderRadius: '8px',
            cursor: 'move',
            boxSizing: 'border-box',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
        });

        const styleEl = document.createElement('style');
        styleEl.textContent = `
            #autoSpeakPanel label { display: flex; align-items: center; margin-bottom: 8px; cursor: pointer; padding: 4px; border-radius: 4px; font-weight: 500; }
            #autoSpeakPanel label:hover { background-color: #f0f8f0; }
            #autoSpeakPanel label.enabled { background-color: #e8f5e8; border: 1px solid #4CAF50; padding: 3px; }
            #autoSpeakPanel input { margin-right: 8px; transform: scale(1.2); }
            #autoSpeakTab { width: 100%; height: 100%; font-weight: bold; cursor: pointer; align-items: center; justify-content: center; display: none; background-color: #4CAF50; color: white; border-radius: 4px; }
            #autoSpeakStopBtn { width: 100%; padding: 8px; background-color: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; display: none; margin-top: 8px; }
            #autoSpeakStopBtn:hover { background-color: #d32f2f; }
        `;
        document.head.appendChild(styleEl);

        const contentDiv = document.createElement('div');
        contentDiv.id = 'autoSpeakContent';

        const titleStrong = document.createElement('strong');
        titleStrong.style.display = 'block';
        titleStrong.style.marginBottom = '8px';
        titleStrong.style.color = '#4CAF50';
        titleStrong.textContent = 'AUTO SPEAK';
        contentDiv.appendChild(titleStrong);

        const autoSpeakLabel = document.createElement('label');
        autoSpeakLabel.id = 'autoSpeakLabel';
        const autoSpeakCheckbox = document.createElement('input');
        autoSpeakCheckbox.type = 'checkbox';
        autoSpeakCheckbox.id = 'autoSpeakCheckbox';
        autoSpeakLabel.append(autoSpeakCheckbox, ' Automatically Read Aloud');
        contentDiv.appendChild(autoSpeakLabel);

        const stopButton = document.createElement('button');
        stopButton.id = 'autoSpeakStopBtn';
        stopButton.textContent = 'Stop Speech';
        contentDiv.appendChild(stopButton);

        panelDiv.appendChild(contentDiv);

        const tabDiv = document.createElement('div');
        tabDiv.id = 'autoSpeakTab';
        tabDiv.innerText = COLLAPSED_TEXT;
        panelDiv.appendChild(tabDiv);

        document.body.appendChild(panelDiv);

        // Event listeners
        autoSpeakCheckbox.addEventListener('change', (event) => {
            autoSpeakEnabled = event.target.checked;
            autoSpeakLabel.classList.toggle('enabled', autoSpeakEnabled);
            logToConsole(`Auto Speak ${autoSpeakEnabled ? 'enabled' : 'disabled'}`);
            if (!autoSpeakEnabled) {
                hideStopButton();
            }
        });

        stopButton.addEventListener('click', () => {
            logToConsole("Stop button clicked");
            if (typeof window.forceStopAllTtsAudio === 'function') {
                window.forceStopAllTtsAudio();
            }
        });

        makeDraggable(panelDiv);
        logToConsole("Auto Speak panel created");
    }

    // --- CORE LOGIC: Check Button States ---
    function checkButtonStates() {
        const sendButton = document.querySelector("[aria-label='Send message'], [data-testid='send-button']");
        const stopButton = document.querySelector("[aria-label='Stop generating'], [aria-label='Stop streaming']");
        let currentButtonState = null;

        // Determine current state
        if (stopButton && getComputedStyle(stopButton).display !== 'none') {
            currentButtonState = "Stop Generating";
        } else if (sendButton && !sendButton.disabled) {
            currentButtonState = "Send Prompt";
        } else {
            currentButtonState = "Idle/Input Disabled";
        }

        // Check for transition from busy to ready to trigger auto-speak
        if (lastDetectedButtonState === "Stop Generating" && currentButtonState !== "Stop Generating") {
            logToConsole("Response complete - triggering auto-speak");
            setTimeout(() => {
                triggerAutoSpeak();
            }, 1000); // Delay to allow final DOM updates
        }

        // Log state changes
        if (currentButtonState && currentButtonState !== lastDetectedButtonState) {
            logToConsole(`Button state: "${lastDetectedButtonState || '(initial)'}" -> "${currentButtonState}"`);
        }

        // Update the last detected state
        lastDetectedButtonState = currentButtonState;
    }

    // --- INITIALIZATION ---
    logToConsole("Initializing AUTO SPEAK...");
    createAutoSpeakPanel();
    setInterval(checkButtonStates, 500);
    setTimeout(checkButtonStates, 200); // Initial check
    logToConsole("AUTO SPEAK active. Waiting for responses...");

})();