Greasy Fork

Greasy Fork is available in English.

Read Aloud Speedster

Set playback speed for Read Aloud on ChatGPT.com. Clicking the speed display opens a popup to save the default playback speed and toggle the square design. Also adds color-coded icons for copy, thumbs up, thumbs down, read aloud, and stop buttons. Highlight color for strong text is green in dark mode and violet in light mode.

当前为 2025-04-02 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Read Aloud Speedster
// @description  Set playback speed for Read Aloud on ChatGPT.com. Clicking the speed display opens a popup to save the default playback speed and toggle the square design. Also adds color-coded icons for copy, thumbs up, thumbs down, read aloud, and stop buttons. Highlight color for strong text is green in dark mode and violet in light mode.
// @author       Tim Macy
// @license      GNU AFFERO GENERAL PUBLIC LICENSE-3.0
// @version      3.0.2
// @namespace    TimMacy.ReadAloudSpeedster
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @match        https://*.chatgpt.com/*
// @grant        GM.setValue
// @grant        GM.getValue
// @run-at       document-start
// @homepageURL  https://github.com/TimMacy/ReadAloudSpeedster
// @supportURL   https://github.com/TimMacy/ReadAloudSpeedster/issues
// ==/UserScript==

/************************************************************************
*                                                                       *
*                    Copyright © 2025 Tim Macy                          *
*                    GNU Affero General Public License v3.0             *
*                    Version: 3.0.2 - Read Aloud Speedster              *
*                    All Rights Reserved.                               *
*                                                                       *
*             Visit: https://github.com/TimMacy                         *
*                                                                       *
************************************************************************/

(function() {
    'use strict';
    const className = "sm:mt-5";
    const escapedClassName = CSS.escape(className);
    const styleSheet = document.createElement('style');
    styleSheet.textContent = `

        /**************************************
                     user settings
        **************************************/

        /* chatbox - reduced vertical margin */
        .${escapedClassName} {
            margin-top: .5rem !important;
            margin-bottom: .25rem !important;
        }

        /* chatbox - fade effect for content */
        main form > div > .w-full {
            box-shadow: 0 -10px 10px 0px var(--main-surface-primary);
        }

        /* copy icon */
        button[aria-label="Copy"] .icon-md-heavy {
            color: darkorange !important;
            opacity: .8;
        }

        /* thumbs up icon */
        button[aria-label="Good response"] .icon-md-heavy {
            color: forestgreen !important;
        }

        /* thumbs down icon */
        button[aria-label="Bad response"] .icon-md-heavy {
            color: crimson !important;
            opacity: .8;
        }

        /* read aloud and stop icon */
        button[aria-label="Read aloud"] .icon-md-heavy,
        button[aria-label="Stop"] .icon-md-heavy {
            color: deepskyblue !important;
        }

        /* highlight color - dark mode */
        .markdown strong {
            color: springgreen !important;
        }

        /* highlight color - light mode */
        .light .markdown strong {
            color: darkviolet !important;
        }

        /* hide 'view plans' banner
        .h-full.w-\\[260px\\] .dark\\:border-white\\/20:is(.dark *) {
            display: none;
        } */

        /* hide 'chat can make mistakes' text
        .relative.flex.min-h-8.w-full.items-center.justify-center.p-2.text-center.text-xs.text-token-text-secondary.md\\:px-\\[60px\\] {
            display: none;
        } */

        /* add space below chatbox
        .xl\\:px-5 {
            padding-bottom: 1rem;
        } */

        /* disable voice mode button
        button[aria-label="Start voice mode"] {
            pointer-events: none;
            opacity: 0.5;
        } */

        /* select color */
        ::selection {
            /*background: #00519d;*/
            background-color: var(--text-primary);
            color: var(--main-surface-tertiary);
        }

        /* right button height
        .w-7 {
            width: 2.25rem !important
        }

        .h-7 {
            height: 2.25rem !important
        }

        [data-testid="composer-speech-button"].w-7.\\!h-7,
        button[data-testid="composer-speech-button"] {
            width: 2.25rem !important;
            height: 2.25rem !important;
        } */

        /* darker bg for chatbox and header
        main form .bg-token-main-surface-primary {
            background-color: #141414 !important;;
        }

        .h-header-height {
            background: #171717 !important;
        } */

        /**************************************
                 Read Aloud Speedster
        **************************************/

        .speed-control-container {
            position: relative;
            display: flex;
            align-items: center;
        }

        .speed-btn {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 36px;
            min-width: 36px;
            border: 1px solid var(--border-light);
            font-size: .75rem;
            line-height: 1rem;
            font-weight: 600;
            background: transparent;
            color: var(--text-secondary);
            cursor: pointer;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        .speed-btn.minus {
            border-radius: 100% 0 0 100%;
            border-right: none;
        }

        .speed-btn.plus {
            border-radius: 0 100% 100% 0;
            border-left: none;
        }

        .speed-btn:hover,
        .speed-control-config-popup button:hover {
            background-color: rgb(66 66 66/var(--tw-bg-opacity));
        }

        .speed-btn:active,
        .speed-control-config-popup button:active {
            background-color: #0000001a;
        }

        .speed-display {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 36px;
            min-width: 36px;
            padding: .5rem;
            border: 1px solid var(--border-light);
            font-size: .75rem;
            line-height: 1rem;
            font-weight: 600;
            background: transparent;
            color: var(--text-secondary);
            cursor: default;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
        }

        .speed-control-config-popup {
            position: absolute;
            bottom: 100%;
            left: 50%;
            transform: translateX(-50%);
            background: var(--main-surface-primary);
            border: 1px solid var(--border-light);
            border-radius: 3px;
            padding: 8px;
            margin-bottom: 4px;
            z-index: 2077;
            display: none;
            flex-direction: row;
            gap: 10px;
        }

        .speed-control-config-popup.show {
            display: flex;
        }

        .speed-control-config-popup input[type="number"] {
            width: 6ch;
            border: 1px solid var(--border-light);
            border-radius: 3px;
            background: transparent;
            color: var(--text-primary);
            text-align: center;
            margin-right: 10px;
        }

        .speed-control-config-popup button {
            padding: 4px 8px;
            border: 1px solid var(--border-light);
            border-radius: 3px;
            background: transparent;
            color: var(--text-secondary);
            cursor: pointer;
        }

        .speed-control-config-popup .toggle-container {
            display: flex;
            align-items: center;
            text-wrap: nowrap;
            gap: 5px;
        }
    `; document.head.appendChild(styleSheet);

    let squareDesignEnabled = false;
    let squareStyleSheet = null;
    let audioListeners = new Map();
    let controlsContainer = null;
    let configPopup = null;
    let observer = null;
    let playbackSpeed = 1;
    let ignoreRateChange = false;
    let lastUserRate = playbackSpeed;
    let savedSpeed;

    const MIN_SPEED = 1;
    const MAX_SPEED = 17;
    const DELTA = 0.25;

    // toggle square design
    function applySquareDesign(enabled) {
        if (enabled) {
            if (!squareStyleSheet) {
                squareStyleSheet = document.createElement('style');
                squareStyleSheet.textContent = `
                    /* button 'send prompt' radius */
                    button[aria-label="Send prompt"], button[aria-label="Stop streaming"], button[aria-label="Start voice mode"] {
                        border-radius: 4px !important;
                    }

                    /* button radii */
                    .btn, .rounded-full {
                        border-radius: 2px !important;
                    }

                    /* button minus radius */
                    .speed-btn.minus {
                        border-radius: 2px 0 0 2px;
                    }

                    /* button plus radius */
                    .speed-btn.plus {
                        border-radius: 0 2px 2px 0;
                    }

                    /* chatbox - radius */
                    .rounded-3xl,
                    .rounded-b-3xl,
                    .rounded-t-3xl,
                    .rounded-\\[28px\\] {
                        border-radius: .25em !important;
                    }

                    /* popup radii */
                    .rounded-lg, .rounded-2xl {
                        border-radius: .25rem !important;
                    }

                    /* reply radii */
                    .rounded-b-lg,
                    .rounded-t-\\[20px\\] {
                        border-radius: 0 !important;
                    }
                `;
                document.head.appendChild(squareStyleSheet);
            }
        } else {
            if (squareStyleSheet) {
                squareStyleSheet.remove();
                squareStyleSheet = null;
            }
        }
    }

    // load playback speed and square design values from config or use default
    async function initializeSpeed() {
        savedSpeed = await GM.getValue('defaultSpeed');
        if (savedSpeed !== undefined) {
            playbackSpeed = savedSpeed;
            lastUserRate = playbackSpeed;
        }

        // load square design setting
        squareDesignEnabled = await GM.getValue('squareDesign');
        if (squareDesignEnabled === undefined) {
            squareDesignEnabled = false;
        }

        applySquareDesign(squareDesignEnabled);
        updateSpeedDisplay();
        setPlaybackSpeed();
    }

    // set playback speed and manage listeners
    function setPlaybackSpeed() {
        const audioElements = document.querySelectorAll('audio');
        audioElements.forEach(audio => {
            if (audioListeners.has(audio)) {
                const { playListener, rateListener } = audioListeners.get(audio);
                audio.removeEventListener('play', playListener);
                audio.removeEventListener('ratechange', rateListener);
                audioListeners.delete(audio);
            }

            audio.playbackRate = playbackSpeed;
            const playListener = () => audio.playbackRate = playbackSpeed;

            const rateListener = () => {
                if (ignoreRateChange) { ignoreRateChange = false; return; }
                audio.playbackRate = lastUserRate;
            };

            audio.addEventListener('play', playListener);
            audio.addEventListener('ratechange', rateListener);
            audioListeners.set(audio, { playListener, rateListener });
        });
    }

    // config popup
    function createConfigPopup() {
        if (configPopup) {
            document.removeEventListener('click', handleDocumentClick);
            configPopup.remove();
        }

        configPopup = document.createElement('div');
        configPopup.classList.add('speed-control-config-popup');

        // input for speed
        const input = document.createElement('input');
        input.type = 'number';
        input.min = MIN_SPEED;
        input.max = MAX_SPEED;
        input.step = DELTA;
        input.value = savedSpeed;

        // square design toggle
        const toggleContainer = document.createElement('div');
        toggleContainer.classList.add('toggle-container');
        const squareCheckbox = document.createElement('input');
        squareCheckbox.type = 'checkbox';
        squareCheckbox.id = 'squareDesignToggle';
        squareCheckbox.checked = squareDesignEnabled;
        const squareLabel = document.createElement('label');
        squareLabel.textContent = 'Square Design';
        squareLabel.htmlFor = 'squareDesignToggle';
        toggleContainer.appendChild(squareCheckbox);
        toggleContainer.appendChild(squareLabel);

        // save button
        const saveButton = document.createElement('button');
        saveButton.textContent = 'Save';

        async function handleSave() {
            const newSpeed = parseFloat(input.value);
            if (newSpeed >= MIN_SPEED && newSpeed <= MAX_SPEED) {
                await GM.setValue('defaultSpeed', newSpeed);
                playbackSpeed = newSpeed;
                updateSpeedDisplay();
                setPlaybackSpeed();
            }

            // save square design setting
            squareDesignEnabled = squareCheckbox.checked;
            await GM.setValue('squareDesign', squareDesignEnabled);
            applySquareDesign(squareDesignEnabled);

            configPopup.classList.remove('show');
        }

        saveButton.addEventListener('click', handleSave);

        document.addEventListener('click', handleDocumentClick);

        configPopup.appendChild(input);
        configPopup.appendChild(toggleContainer);
        configPopup.appendChild(saveButton);
        document.body.appendChild(configPopup);

        return configPopup;
    }

    function handleDocumentClick(e) {
        if (!configPopup.contains(e.target) && !e.target.classList.contains('speed-display')) {
            configPopup.classList.remove('show');
        }
    }

    // speed display
    function updateSpeedDisplay() {
        const speedDisplay = controlsContainer.querySelector('.speed-display');
        if (speedDisplay) {
            // speedDisplay.textContent = `${playbackSpeed.toFixed(2)}x`; // display speed with always two decimals
            speedDisplay.textContent = `${playbackSpeed}x`; // raw speed value without formatting
        }
    }

    // create controls
    function createControlButtons() {
        if (controlsContainer && document.body.contains(controlsContainer)) return;

        controlsContainer = document.createElement('div');
        controlsContainer.classList.add('speed-control-container');

        const minusButton = document.createElement('button');
        minusButton.textContent = '-';
        minusButton.classList.add('speed-btn', 'minus');

        const speedDisplay = document.createElement('span');
        speedDisplay.classList.add('speed-display');
        // speedDisplay.textContent = `${playbackSpeed.toFixed(2)}x`; // display speed with always two decimals
        speedDisplay.textContent = `${playbackSpeed}x`; // raw speed value without formatting

        const plusButton = document.createElement('button');
        plusButton.textContent = '+';
        plusButton.classList.add('speed-btn', 'plus');

        function handleMinus() {
            ignoreRateChange = true;
            playbackSpeed = Math.max(MIN_SPEED, playbackSpeed - DELTA);
            lastUserRate = playbackSpeed;
            updateSpeedDisplay();
            setPlaybackSpeed();
        }

        function handlePlus() {
            ignoreRateChange = true;
            playbackSpeed = Math.min(MAX_SPEED, playbackSpeed + DELTA);
            lastUserRate = playbackSpeed;
            updateSpeedDisplay();
            setPlaybackSpeed();
        }

        function handleSpeedClick(e) {
            e.stopPropagation();
            if (!configPopup || !document.body.contains(configPopup)) {
                configPopup = createConfigPopup();
            }
            configPopup.classList.toggle('show');

            if (configPopup.classList.contains('show')) {
                const rect = e.target.getBoundingClientRect();
                configPopup.style.position = 'absolute';
                configPopup.style.bottom = `${window.innerHeight - rect.top + 10}px`;
                configPopup.style.left = `${rect.left + (rect.width / 2)}px`;
                configPopup.style.transform = 'translateX(-50%)';
            }
        }

        minusButton.addEventListener('click', handleMinus);
        plusButton.addEventListener('click', handlePlus);
        speedDisplay.addEventListener('click', handleSpeedClick);

        controlsContainer.appendChild(minusButton);
        controlsContainer.appendChild(speedDisplay);
        controlsContainer.appendChild(plusButton);

        const target = document.querySelector('div[style*="var(--vt-composer-system-hint-action)"]');
        if (target) target.insertAdjacentElement('beforebegin', controlsContainer);
        else if (document.querySelector('div[style*="var(--vt-composer-attach-file-action)"]')?.insertAdjacentElement('afterend', controlsContainer));
    }

    // handle cleanup
    function cleanup() {
        audioListeners.forEach((listeners, audio) => {
            audio.removeEventListener('play', listeners.playListener);
            audio.removeEventListener('ratechange', listeners.rateListener);
        });

        audioListeners.clear();
        if (observer) { observer.disconnect(); observer = null; }
        controlsContainer?.remove();
        configPopup?.remove();
    }

    // initialize everything when DOM is fully loaded
    function init() {
        // observer for new audio elements
        observer = new MutationObserver(mutations => {
            if (!document.body.contains(controlsContainer)) createControlButtons();

            let audioFound = false;
            for (const mutation of mutations) {
                if (mutation.addedNodes.length) {
                    const newAudioElements = mutation.target.querySelectorAll('audio');
                    if (newAudioElements.length > 0) {
                        audioFound = true;
                        break;
                    }
                }
            }

            if (audioFound) setPlaybackSpeed();
        });

        if (document.body) {
            observer.observe(document.body, { childList: true, subtree: true });

            // initiate the script
            initializeSpeed();
            createControlButtons();
        }
    }

    // wait for document to be ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else init();

    // cleanup when page unloads
    window.addEventListener('unload', cleanup);
})();