YouTube Speed and Loop

Enhances YouTube with playback speeds beyond 2x and repeat functionality

// ==UserScript==
// @name               YouTube Speed and Loop
// @name:zh-TW         YouTube 播放速度與循環
// @namespace          https://github.com/Hank8933
// @version            1.1.0
// @description        Enhances YouTube with playback speeds beyond 2x and repeat functionality
// @description:zh-TW  為 YouTube 提供超過 2 倍的播放速度控制和重複播放功能
// @author             Hank8933
// @homepage           https://github.com/Hank8933/YouTube-Speed-and-Loop
// @match              https://www.youtube.com/*
// @grant              none
// @license            MIT
// ==/UserScript==

(function() {
    'use strict';

    const PANEL_ID = 'yt-enhancements-panel';
    let isInitializing = false;

    const panelCSS = `
        :root {
            --primary-bg: transparent; --hover-bg: rgba(255, 255, 255, 0.1); --active-bg: #f00;
            --panel-bg: #282828; --text-color: #fff; --shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
            --input-bg: rgba(0, 0, 0, 0.3); --input-border: rgba(255, 255, 255, 0.2);
        }
        .yt-custom-control-panel {
            position: relative; z-index: 99999; font-family: Roboto, Arial, sans-serif;
            align-self: center; margin-right: 8px;
        }
        .yt-custom-control-toggle {
            background-color: var(--primary-bg); color: var(--text-color);
            border: 1px solid rgba(255, 255, 255, 0.1); font-weight: 500; cursor: pointer;
            transition: background-color 0.3s; display: flex; align-items: center; justify-content: center;
            width: 40px; height: 40px; box-sizing: border-box; border-radius: 50%;
            font-size: 2rem; line-height: 0;
        }
        .yt-custom-control-toggle:hover { background-color: var(--hover-bg); }
        .yt-custom-control-content {
            position: absolute; top: calc(100% + 10px); right: 0; transform: none; left: auto;
            background-color: var(--panel-bg); color: var(--text-color); padding: 12px;
            border: 1px solid var(--input-border); border-radius: 12px; box-shadow: var(--shadow);
            display: none; flex-direction: column; gap: 12px; min-width: 320px; white-space: nowrap;
        }
        .yt-custom-control-panel.expanded .yt-custom-control-content { display: flex; }
        .yt-custom-control-title {
            font-weight: bold; margin-bottom: 8px; padding: 0 5px; font-size: 16px;
        }
        .yt-custom-control-section { padding: 8px; border-radius: 8px; transition: background-color 0.2s; }
        .yt-custom-control-section:hover { background-color: rgba(255, 255, 255, 0.05); }
        .yt-custom-btn {
            background-color: rgba(255, 255, 255, 0.15); border: none; color: var(--text-color);
            padding: 6px 12px; border-radius: 18px; cursor: pointer; font-size: 13px;
            white-space: nowrap; text-align: center; flex-grow: 1; margin-right: 8px;
        }
        .yt-custom-btn:last-child { margin-right: 0; }
        .yt-custom-btn:hover { background-color: rgba(255, 255, 255, 0.25); }
        .yt-custom-btn.active { background-color: var(--active-bg); }
        .yt-custom-btn-group { display: flex; justify-content: space-between; }
        .yt-speed-controls { display: flex; flex-direction: column; gap: 8px; white-space: nowrap; }
        .yt-slider-row { display: flex; align-items: center; width: 100%; }
        .yt-custom-slider { flex-grow: 1; min-width: 100px; }
        .yt-preset-speeds { display: flex; gap: 5px; width: 100%; }
        .loop-input-container {
            display: flex; align-items: center; justify-content: space-between;
            gap: 8px; margin-top: 10px;
        }
        .loop-time-input {
            width: 100%; background-color: var(--input-bg);
            border: 1px solid var(--input-border); color: var(--text-color);
            border-radius: 8px; padding: 8px; font-family: 'Courier New', Courier, monospace;
            font-size: 14px; text-align: center; transition: border-color 0.3s, box-shadow 0.3s;
        }
        .loop-time-input:focus {
            outline: none; border-color: #3ea6ff;
            box-shadow: 0 0 5px rgba(62, 166, 255, 0.5);
        }
        .yt-custom-toggle-section {
            display: flex; justify-content: space-between; align-items: center;
            padding: 4px 8px;
        }
        .yt-custom-toggle-section .yt-custom-btn {
            flex-grow: 0; min-width: 60px; margin-right: 0;
        }
    `;
    const styleEl = document.createElement('style');
    styleEl.textContent = panelCSS;
    document.head.appendChild(styleEl);

    function getFormattedTimestamp() {
        const now = new Date();
        const hours = String(now.getHours()).padStart(2, '0');
        const minutes = String(now.getMinutes()).padStart(2, '0');
        const seconds = String(now.getSeconds()).padStart(2, '0');
        return `${hours}:${minutes}:${seconds}`;
    }

    function createElement(tag, id, className, textContent) {
        const el = document.createElement(tag);
        if (id) el.id = id;
        if (className) el.className = className;
        if (textContent) el.textContent = textContent;
        return el;
    }

    let playbackRateDisconnect = () => {};
    let loopDisconnect = () => {};

    function cleanUpVideoFeatures() {
        playbackRateDisconnect();
        loopDisconnect();
        AutoConfirmController.stop();
        playbackRateDisconnect = () => {};
        loopDisconnect = () => {};
    }

    function createAndSetupControlPanel(container) {
        const panel = createElement('div', PANEL_ID, 'yt-custom-control-panel');
        const toggleBtn = createElement('button', null, 'yt-custom-control-toggle', '≡');
        const contentDiv = createElement('div', null, 'yt-custom-control-content');
        toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); panel.classList.toggle('expanded'); toggleBtn.textContent = panel.classList.contains('expanded') ? '×' : '≡'; });
        document.addEventListener('click', () => { if (panel.classList.contains('expanded')) { panel.classList.remove('expanded'); toggleBtn.textContent = '≡'; } });
        contentDiv.addEventListener('click', (e) => e.stopPropagation());

        const titleDiv = createElement('div', null, 'yt-custom-control-title', 'YouTube Enhanced Controls');

        const speedSection = createElement('div', null, 'yt-custom-control-section');
        const speedText = createElement('div', null, null, 'Playback Speed: ');
        const speedValue = createElement('span', null, null, '1.0');
        speedText.appendChild(speedValue); speedText.append('x');
        const speedControls = createElement('div', null, 'yt-speed-controls');
        const sliderRow = createElement('div', null, 'yt-slider-row');
        const speedSlider = createElement('input', null, 'yt-custom-slider');
        speedSlider.type = 'range'; speedSlider.min = '0.25'; speedSlider.max = '5'; speedSlider.step = '0.25'; speedSlider.value = '1';
        sliderRow.appendChild(speedSlider);
        const presetSpeeds = createElement('div', null, 'yt-preset-speeds yt-custom-btn-group');
        [1, 1.5, 2, 3, 4, 5].forEach(speed => { const btn = createElement('button', null, 'yt-custom-btn yt-speed-preset', `${speed}x`); btn.dataset.speed = speed; presetSpeeds.appendChild(btn); });
        speedControls.append(sliderRow, presetSpeeds);
        speedSection.append(speedText, speedControls);

        const loopSection = createElement('div', null, 'yt-custom-control-section yt-custom-toggle-section');
        loopSection.appendChild(createElement('span', null, null, 'Loop Playback'));
        const loopToggle = createElement('button', null, 'yt-custom-btn', 'Off');
        loopSection.appendChild(loopToggle);

        const loopRangeSection = createElement('div', null, 'yt-custom-control-section');
        loopRangeSection.appendChild(createElement('span', null, null, 'Loop Range'));
        const rangeButtons = createElement('div', null, 'yt-custom-btn-group');
        const loopStartBtn = createElement('button', null, 'yt-custom-btn', 'Set Start');
        const loopEndBtn = createElement('button', null, 'yt-custom-btn', 'Set End');
        const loopClearBtn = createElement('button', null, 'yt-custom-btn', 'Clear');
        rangeButtons.append(loopStartBtn, loopEndBtn, loopClearBtn);
        const loopInputContainer = createElement('div', null, 'loop-input-container');
        const loopStartInput = createElement('input', null, 'loop-time-input');
        loopStartInput.type = 'text'; loopStartInput.placeholder = '00:00.000';
        const loopInputSeparator = createElement('span', null, null, '→');
        const loopEndInput = createElement('input', null, 'loop-time-input');
        loopEndInput.type = 'text'; loopEndInput.placeholder = '00:00.000';
        loopInputContainer.append(loopStartInput, loopInputSeparator, loopEndInput);
        loopRangeSection.append(rangeButtons, loopInputContainer);

        const autoConfirmSection = createElement('div', null, 'yt-custom-control-section yt-custom-toggle-section');
        autoConfirmSection.appendChild(createElement('span', null, null, 'Auto-Click "Continue watching?"'));
        const autoConfirmToggle = createElement('button', null, 'yt-custom-btn', 'Off');
        autoConfirmSection.appendChild(autoConfirmToggle);

        contentDiv.append(titleDiv, speedSection, loopSection, loopRangeSection, autoConfirmSection);
        panel.append(toggleBtn, contentDiv);
        container.prepend(panel);

        return {
            speedSection, speedValue, speedSlider, presetSpeeds,
            loopSection, loopToggle,
            loopRangeSection, loopStartBtn, loopEndBtn, loopClearBtn, loopStartInput, loopEndInput,
            autoConfirmToggle
        };
    }

    function waitForElement(selector) {
        return new Promise(resolve => {
            const interval = setInterval(() => {
                const element = document.querySelector(selector);
                if (element) {
                    clearInterval(interval);
                    resolve(element);
                }
            }, 250);
        });
    }

    const SpeedController = {
        updatePlaybackRate(rate, elements) {
            if (!document.querySelector('video') || !elements) return;
            elements.speedValue.textContent = parseFloat(rate).toFixed(2);
            elements.speedSlider.value = rate;
            elements.presetSpeeds.querySelectorAll('.yt-speed-preset').forEach(btn => {
                btn.classList.toggle('active', parseFloat(btn.dataset.speed) === parseFloat(rate));
            });
        },
        init(video, elements) {
            elements.speedSlider.addEventListener('input', () => { video.playbackRate = parseFloat(elements.speedSlider.value); this.updatePlaybackRate(video.playbackRate, elements); });
            elements.presetSpeeds.addEventListener('click', (e) => { const btn = e.target.closest('.yt-speed-preset'); if (btn) { video.playbackRate = parseFloat(btn.dataset.speed); this.updatePlaybackRate(video.playbackRate, elements); } });
            let lastRate = video.playbackRate;
            const observer = setInterval(() => { const cv = document.querySelector('video'); if (cv && cv.playbackRate !== lastRate) { lastRate = cv.playbackRate; this.updatePlaybackRate(lastRate, elements); } }, 500);
            playbackRateDisconnect = () => clearInterval(observer);
        }
    };

    const LoopController = {
        loopStart: null,
        loopEnd: null,
        formatTime(seconds) { if (seconds === null || isNaN(seconds)) return ''; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); const ms = Math.round((seconds - Math.floor(seconds)) * 1000); return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(ms).padStart(3, '0')}`; },
        parseTime(timeStr) { if (!timeStr) return null; const parts = timeStr.split(':'); let seconds = 0; try { if (parts.length === 2) { seconds = parseInt(parts[0], 10) * 60 + parseFloat(parts[1]); } else { seconds = parseFloat(parts[0]); } return isNaN(seconds) ? null : seconds; } catch (e) { return null; } },
        init(video, elements) {
            let isLooping = video.loop;
            const { loopToggle, loopStartBtn, loopEndBtn, loopClearBtn, loopStartInput, loopEndInput } = elements;
            const updateLoopInputs = () => { loopStartInput.value = this.formatTime(this.loopStart); loopEndInput.value = this.formatTime(this.loopEnd); };
            const updateLoopState = (newState) => { isLooping = newState; loopToggle.textContent = isLooping ? 'On' : 'Off'; loopToggle.classList.toggle('active', isLooping); };
            updateLoopState(isLooping);
            updateLoopInputs();
            loopToggle.addEventListener('click', () => { video.loop = !video.loop; updateLoopState(video.loop); });
            loopStartBtn.addEventListener('click', () => { this.loopStart = video.currentTime; updateLoopInputs(); });
            loopEndBtn.addEventListener('click', () => { this.loopEnd = video.currentTime; updateLoopInputs(); });
            loopClearBtn.addEventListener('click', () => { this.loopStart = null; this.loopEnd = null; updateLoopInputs(); });
            loopStartInput.addEventListener('change', () => { const parsed = this.parseTime(loopStartInput.value); this.loopStart = parsed; loopStartInput.value = this.formatTime(parsed); });
            loopEndInput.addEventListener('change', () => { const parsed = this.parseTime(loopEndInput.value); this.loopEnd = parsed; loopEndInput.value = this.formatTime(parsed); });
            video.addEventListener('timeupdate', () => { if (isLooping && this.loopStart !== null && this.loopEnd !== null && this.loopStart < this.loopEnd && video.currentTime >= this.loopEnd) { video.currentTime = this.loopStart; } });
            let lastLoopState = video.loop;
            const observer = setInterval(() => { const cv = document.querySelector('video'); if (cv && cv.loop !== lastLoopState) { lastLoopState = cv.loop; updateLoopState(lastLoopState); } }, 500);
            loopDisconnect = () => clearInterval(observer);
        }
    };

    const AutoConfirmController = {
        observer: null,
        isEnabled: false,
        storageKey: 'yt-auto-confirm-enabled',
        init(toggleButton) {
            const savedState = localStorage.getItem(this.storageKey);
            this.isEnabled = savedState === 'true';
            this.updateButtonState(toggleButton);
            if (this.isEnabled) this.start();
            toggleButton.addEventListener('click', () => { this.isEnabled = !this.isEnabled; localStorage.setItem(this.storageKey, this.isEnabled); this.updateButtonState(toggleButton); this.isEnabled ? this.start() : this.stop(); });
        },
        updateButtonState(toggleButton) {
            if (toggleButton) { toggleButton.textContent = this.isEnabled ? 'On' : 'Off'; toggleButton.classList.toggle('active', this.isEnabled); }
        },
        start() {
            if (this.observer) return;
            this.observer = new MutationObserver(() => {
                const dialog = document.querySelector('yt-confirm-dialog-renderer');
                if (dialog && dialog.offsetParent !== null) {
                    console.log(`%c[YouTube Enhanced Controls]%c [${getFormattedTimestamp()}] Auto-clicked "Continue Watching?" dialog.`, 'font-weight: bold; color: #ff8c00;', 'color: inherit;');
                    dialog.querySelector('#confirm-button')?.click();
                }
            });
            this.observer.observe(document.body, { childList: true, subtree: true });
        },
        stop() {
            if (this.observer) { this.observer.disconnect(); this.observer = null; }
        }
    };

    async function init() {
        if (isInitializing) return;
        isInitializing = true;

        try {
            document.getElementById(PANEL_ID)?.remove();
            cleanUpVideoFeatures();

            const anchorElement = await waitForElement(
                'ytd-masthead #end #buttons #avatar-btn, ytd-masthead #end #buttons ytd-button-renderer'
            );

            const buttonsContainer = anchorElement.closest('#buttons');

            if (!buttonsContainer) {
                console.error('[YouTube Enhanced Controls] Found an anchor button, but could not find its parent #buttons container.');
                return;
            }

            const panelElements = createAndSetupControlPanel(buttonsContainer);
            AutoConfirmController.init(panelElements.autoConfirmToggle);

            if (window.location.pathname.startsWith('/watch')) {
                try {
                    const video = await waitForElement('video');

                    if (video.paused && video.currentTime < 3 && AutoConfirmController.isEnabled && document.hidden) {
                        console.log(`%c[YouTube Enhanced Controls]%c [${getFormattedTimestamp()}] Page is in background and new video is paused (at ${video.currentTime.toFixed(2)}s). Attempting to play proactively...`, 'font-weight: bold; color: #ff8c00;', 'color: inherit;');
                        video.play().catch(error => {
                            console.warn(`%c[YouTube Enhanced Controls]%c [${getFormattedTimestamp()}] Proactive play failed. Browser may have blocked it. Error:`, 'font-weight: bold; color: #ff8c00;', error);
                        });
                    }

                    panelElements.speedSection.style.display = 'block';
                    panelElements.loopSection.style.display = 'flex';
                    panelElements.loopRangeSection.style.display = 'block';
                    SpeedController.init(video, panelElements);
                    LoopController.init(video, panelElements);
                    SpeedController.updatePlaybackRate(video.playbackRate, panelElements);
                } catch (error) {
                    panelElements.speedSection.style.display = 'none';
                    panelElements.loopSection.style.display = 'none';
                    panelElements.loopRangeSection.style.display = 'none';
                }
            } else {
                panelElements.speedSection.style.display = 'none';
                panelElements.loopSection.style.display = 'none';
                panelElements.loopRangeSection.style.display = 'none';
            }
        } finally {
            isInitializing = false;
        }
    }

    document.addEventListener('yt-navigate-finish', init);

    const titleObserver = new MutationObserver(init);
    waitForElement('title').then(titleElement => {
        titleObserver.observe(titleElement, { childList: true });
    });

})();