您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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 }); }); })();