// ==UserScript==
// @name YouTube Speed and Loop
// @name:zh-TW YouTube 播放速度與循環
// @namespace https://github.com/Hank8933
// @version 1.1.1
// @description Enhances YouTube with playback speeds 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; padding: 0 8px; }
.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; padding: 0 8px;
}
.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-row {
display: flex; justify-content: space-between; align-items: center;
gap: 10px; padding: 4px 8px;
}
.yt-custom-row:not(:last-child) { margin-bottom: 8px; }
.yt-custom-label {
font-size: 14px; flex-shrink: 0;
}
.yt-custom-row .shortcut-label {
flex-basis: 120px; /* Fixed width for alignment */
text-align: right;
}
.yt-custom-toggle-btn {
flex-grow: 0; min-width: 60px; margin-right: 0;
}
.shortcut-input {
flex-grow: 1; /* Takes remaining space */
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; cursor: pointer;
}
.shortcut-input:focus {
outline: none; border-color: #3ea6ff;
box-shadow: 0 0 5px rgba(62, 166, 255, 0.5);
}
`;
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, 'yt-custom-control-title', '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');
loopSection.appendChild(createElement('div', null, 'yt-custom-control-title', 'Loop Controls'));
const loopToggleRow = createElement('div', null, 'yt-custom-row');
loopToggleRow.appendChild(createElement('label', null, 'yt-custom-label', 'Loop Playback'));
const loopToggle = createElement('button', null, 'yt-custom-btn yt-custom-toggle-btn', 'Off');
loopToggleRow.appendChild(loopToggle);
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);
loopSection.append(loopToggleRow, rangeButtons, loopInputContainer);
const shortcutSection = createElement('div', null, 'yt-custom-control-section');
shortcutSection.appendChild(createElement('div', null, 'yt-custom-control-title', 'Keyboard Shortcuts'));
const createShortcutRow = (label, id) => {
const row = createElement('div', null, 'yt-custom-row');
row.appendChild(createElement('label', null, 'yt-custom-label shortcut-label', label));
const input = createElement('input', id, 'shortcut-input');
input.type = 'text'; input.placeholder = 'Click and press a key'; input.readOnly = true;
row.appendChild(input);
return { row, input };
};
const { row: setStartRow, input: shortcutSetStartInput } = createShortcutRow('Set Start:', 'shortcut-set-start');
const { row: replayStartRow, input: shortcutReplayStartInput } = createShortcutRow('Jump to Start:', 'shortcut-replay-start');
const { row: setEndRow, input: shortcutSetEndInput } = createShortcutRow('Set End:', 'shortcut-set-end');
const { row: clearLoopRow, input: shortcutClearLoopInput } = createShortcutRow('Clear Loop:', 'shortcut-clear-loop');
shortcutSection.append(setStartRow, replayStartRow, setEndRow, clearLoopRow);
const autoConfirmSection = createElement('div', null, 'yt-custom-control-section');
const autoConfirmRow = createElement('div', null, 'yt-custom-row');
autoConfirmRow.appendChild(createElement('label', null, 'yt-custom-label', 'Auto-Click "Continue watching?"'));
const autoConfirmToggle = createElement('button', null, 'yt-custom-btn yt-custom-toggle-btn', 'Off');
autoConfirmRow.appendChild(autoConfirmToggle);
autoConfirmSection.appendChild(autoConfirmRow);
contentDiv.append(titleDiv, speedSection, loopSection, shortcutSection, autoConfirmSection);
panel.append(toggleBtn, contentDiv);
container.prepend(panel);
return {
speedSection, speedValue, speedSlider, presetSpeeds,
loopSection, loopToggle,
loopStartBtn, loopEndBtn, loopClearBtn, loopStartInput, loopEndInput,
shortcutSetStartInput, shortcutReplayStartInput, shortcutSetEndInput, shortcutClearLoopInput,
autoConfirmToggle
};
}
function waitForElement(selector, parent = document) {
return new Promise(resolve => {
const interval = setInterval(() => {
const element = parent.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; }
}
};
const ShortcutController = {
config: {},
storageKey: 'yt-speed-loop-shortcuts',
elements: {},
actions: ['setStart', 'replayStart', 'setEnd', 'clearLoop'],
init(uiElements) {
this.elements = {
setStart: uiElements.shortcutSetStartInput,
replayStart: uiElements.shortcutReplayStartInput,
setEnd: uiElements.shortcutSetEndInput,
clearLoop: uiElements.shortcutClearLoopInput,
loopStartInput: uiElements.loopStartInput,
loopEndInput: uiElements.loopEndInput
};
this.loadConfig();
this.updateAllInputs();
this.actions.forEach(action => {
const element = this.elements[action];
if (element) {
element.addEventListener('keydown', e => this.captureShortcut(e, action));
element.addEventListener('blur', () => this.updateInput(action));
}
});
document.addEventListener('keydown', e => this.handleGlobalKeyDown(e));
},
loadConfig() { try { const savedConfig = localStorage.getItem(this.storageKey); this.config = savedConfig ? JSON.parse(savedConfig) : {}; } catch (e) { console.error('[YouTube Enhanced Controls] Error loading shortcut config:', e); this.config = {}; } },
saveConfig() { localStorage.setItem(this.storageKey, JSON.stringify(this.config)); },
updateInput(action) { const keyConfig = this.config[action]; const inputEl = this.elements[action]; if (!inputEl) return; inputEl.value = keyConfig ? this.formatKey(keyConfig) : ''; if (!inputEl.value) inputEl.placeholder = 'Click and press a key'; },
updateAllInputs() { this.actions.forEach(action => this.updateInput(action)); },
formatKey(keyEvent) { if (!keyEvent || !keyEvent.key) return ''; const parts = []; if (keyEvent.ctrlKey) parts.push('Ctrl'); if (keyEvent.altKey) parts.push('Alt'); if (keyEvent.shiftKey) parts.push('Shift'); const key = keyEvent.key.trim(); if (key && !['Control', 'Alt', 'Shift', 'Meta'].includes(keyEvent.key)) { parts.push(key.length === 1 ? key.toUpperCase() : key); } return parts.join(' + '); },
captureShortcut(event, action) {
event.preventDefault(); event.stopPropagation();
if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return;
const newConfig = { key: event.key, code: event.code, ctrlKey: event.ctrlKey, altKey: event.altKey, shiftKey: event.shiftKey };
this.config[action] = newConfig;
this.elements[action].value = this.formatKey(newConfig);
this.saveConfig();
this.elements[action].blur();
},
handleGlobalKeyDown(event) {
if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || event.target.isContentEditable) return;
for (const action in this.config) {
const savedKey = this.config[action];
if (savedKey && savedKey.key === event.key && savedKey.ctrlKey === event.ctrlKey && savedKey.altKey === event.altKey && savedKey.shiftKey === event.shiftKey) { event.preventDefault(); event.stopPropagation(); this.executeAction(action); return; }
}
},
executeAction(action) {
const video = document.querySelector('video');
if (!video) return;
switch (action) {
case 'setStart':
LoopController.loopStart = video.currentTime;
if (this.elements.loopStartInput) this.elements.loopStartInput.value = LoopController.formatTime(LoopController.loopStart);
break;
case 'replayStart':
if (LoopController.loopStart !== null) video.currentTime = LoopController.loopStart;
break;
case 'setEnd':
LoopController.loopEnd = video.currentTime;
if (this.elements.loopEndInput) this.elements.loopEndInput.value = LoopController.formatTime(LoopController.loopEnd);
break;
case 'clearLoop':
LoopController.loopStart = null;
LoopController.loopEnd = null;
if (this.elements.loopStartInput) this.elements.loopStartInput.value = LoopController.formatTime(null);
if (this.elements.loopEndInput) this.elements.loopEndInput.value = LoopController.formatTime(null);
break;
}
}
};
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] Could not find #buttons container.'); return; }
const panelElements = createAndSetupControlPanel(buttonsContainer);
AutoConfirmController.init(panelElements.autoConfirmToggle);
ShortcutController.init(panelElements);
const videoContainer = document.querySelector('ytd-watch-flexy');
const isWatchPage = !!videoContainer;
panelElements.speedSection.style.display = isWatchPage ? 'block' : 'none';
panelElements.loopSection.style.display = isWatchPage ? 'block' : 'none';
document.querySelectorAll('.shortcut-input').forEach(el => { el.closest('.yt-custom-row').style.display = isWatchPage ? 'flex' : 'none'; });
if (isWatchPage) {
try {
const video = await waitForElement('video', videoContainer);
if (video.paused && video.currentTime < 3 && AutoConfirmController.isEnabled && document.hidden) {
console.log(`%c[YouTube Enhanced Controls]%c [${getFormattedTimestamp()}] Proactively playing video in background...`, 'font-weight: bold; color: #ff8c00;', 'color: inherit;');
video.play().catch(e => console.warn(`%c[YouTube Enhanced Controls]%c Proactive play failed:`, 'font-weight: bold; color: #ff8c00;', e));
}
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';
}
}
} finally { isInitializing = false; }
}
document.addEventListener('yt-navigate-finish', init);
waitForElement('title').then(titleElement => { new MutationObserver(init).observe(titleElement, { childList: true }); });
})();