// ==UserScript==
// @name YouTubeTempo - Ultimate Playback Speed Controller
// @namespace https://github.com/hasanbeder/YouTubeTempo
// @version 1.0.0
// @description Master your YouTube experience with fully customizable, precision speed controls, a volume booster, and a clean, accessible, collapsible settings menu.
// @license GPL-3.0
// @author hasanbeder
// @match https://www.youtube.com/*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_info
// @run-at document-end
// @homepageURL https://github.com/hasanbeder/YouTubeTempo
// @supportURL https://github.com/hasanbeder/YouTubeTempo/issues
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// ==/UserScript==
'use strict';
(function() {
// Main configuration object for the script.
const CONFIG = {
speedStep: 0.05, // The increment/decrement value for speed changes.
minSpeed: 0.25, // Minimum playback speed allowed.
maxSpeed: 4.0, // Maximum playback speed allowed.
defaults: { // Default values for user-configurable settings.
speedStep: 0.05,
minSpeed: 0.25,
maxSpeed: 4.0,
volumeBoost: 1.0,
shortcutSlower: '[',
shortcutReset: '\\',
shortcutFaster: ']'
},
shortcuts: { // Default shortcut keys.
slower: '[',
reset: '\\',
faster: ']'
},
selectors: { // CSS selectors for various YouTube player elements.
playerControls: '.ytp-chrome-controls .ytp-right-controls',
videoElement: '#movie_player video',
timeDisplay: '.ytp-time-display',
liveIndicator: '.ytp-live',
playerContainer: '#movie_player',
chromeControls: '.ytp-chrome-controls',
watchContainer: '#page-manager ytd-watch-flexy',
fallbackPlayerControls: ['.ytp-right-controls', '#movie_player .ytp-chrome-controls .ytp-right-controls'], // Fallbacks for player controls.
fallbackVideoElement: ['video.html5-main-video', 'video[src]'] // Fallbacks for the video element.
},
storageKeys: { // Keys for storing settings in GM_storage or localStorage.
speed: 'youtubetempo-playback-rate',
settingsSpeedStep: 'youtubetempo-speed-step',
settingsMinSpeed: 'youtubetempo-min-speed',
settingsMaxSpeed: 'youtubetempo-max-speed',
isRemainingTimeEnabled: 'youtubetempo-remaining-time-enabled',
shortcutSlower: 'youtubetempo-shortcut-slower',
shortcutReset: 'youtubetempo-shortcut-reset',
shortcutFaster: 'youtubetempo-shortcut-faster',
volumeBoost: 'youtubetempo-volume-boost',
overrideYouTubeShortcuts: 'youtubetempo-override-youtube-shortcuts',
isSoundEffectsEnabled: 'youtubetempo-sound-effects-enabled'
},
ui: { // UI-related constants.
settingsMenuWidth: 300, // Width of the settings menu in pixels.
settingsMenuBottomMargin: 5, // Margin below the settings menu.
indicatorFontSize: 15, // Font size for the speed indicator.
elementWaitTimeout: 10000, // Timeout for waiting for elements to appear.
debounceRate: 250 // Debounce rate for frequent events.
},
enableErrorReporting: false // Flag to enable/disable error reporting.
};
// SVG icons used in the UI.
const ICONS = {
slower: '<svg viewBox="0 0 24 24"><path fill="#ff1744" opacity="0.6" d="M11.996 15.992V8.008L6.004 12l5.992 3.992z"/><path fill="#ff1744" d="M17.996 15.992V8.008L12.004 12l5.992 3.992z"/></svg>',
reset: '<svg viewBox="0 0 24 24"><path fill="#2979ff" opacity="0.6" d="M15 15H9V9h6v6z"/><path fill="#2979ff" d="M17.004 17.004H6.996V6.996h10.008v10.008zM9 15h6V9H9v6z"/></svg>',
faster: '<svg viewBox="0 0 24 24"><path fill="#00e676" opacity="0.6" d="M12.004 15.992V8.008L17.996 12l-5.992 3.992z"/><path fill="#00e676" d="M6.004 15.992V8.008L11.996 12l-5.992 3.992z"/></svg>',
settingsResetIcon: '<svg style="width:16px;height:16px;" viewBox="0 0 24 24"><path fill="currentColor" d="M12 5V2.21c0-.45-.54-.67-.85-.35l-3.8 3.79c-.2.2-.2.51 0 .71l3.8 3.79c.31.32.85.09.85-.35V7c3.73 0 6.68 3.42 5.86 7.29-.47 2.27-2.31 4.1-4.57 4.57-3.57.75-6.75-1.7-7.23-5.01-.07-.48-.49-.85-.98-.85-.6 0-1.08.53-1 1.13.62 4.39 4.8 7.64 9.53 6.72 3.12-.61 5.63-3.12 6.24-6.24C20.84 9.48 16.94 5 12 5z"/></svg>',
github: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.32 1.08 2.89.83.09-.65.35-1.08.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.1.39-1.99 1.03-2.69a3.6 3.6 0 0 1 .1-2.64s.84-.27 2.75 1.02a9.58 9.58 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.4.1 2.64.64.7 1.03 1.6 1.03 2.69 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.85v2.72c0 .27.18.58.69.48A10 10 0 0 0 22 12 10 10 0 0 0 12 2Z"/></svg>',
socialX: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
chevron: '<svg style="width:20px;height:20px" viewBox="0 0 24 24"><path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>'
};
// CSS styles for the script's UI elements.
const STYLES = `
@keyframes heartbeat { 0%{transform:scale(1)} 15%{transform:scale(1.15)} 30%{transform:scale(1)} 45%{transform:scale(1.15)} 60%{transform:scale(1)} 100%{transform:scale(1)} }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
.youtubetempo-button, .youtubetempo-speed-indicator, .youtubetempo-settings-group-header, .youtubetempo-settings-reset-btn {
background: none; border: none; padding: 0; font-family: inherit; color: inherit; margin: 0;
}
.youtubetempo-button {
float:left; cursor:pointer; opacity:.9; transition: opacity .2s ease, background-size 0.4s ease-out;
width:36px; height:100%; display:flex; align-items:center; justify-content:center;
border-radius:50%; background-position:center; background-repeat:no-repeat; background-size:0% 0%;
/* Using CSS variables for a cleaner and more maintainable way to style button glows. */
background-image: radial-gradient(circle, var(--youtubetempo-glow-color, transparent) 0%, transparent 50%);
}
.youtubetempo-slower { --youtubetempo-glow-color: rgba(255, 23, 68, 0.4); }
.youtubetempo-reset { --youtubetempo-glow-color: rgba(41, 121, 255, 0.4); }
.youtubetempo-faster { --youtubetempo-glow-color: rgba(0, 230, 118, 0.4); }
.youtubetempo-button:not(:active):hover { opacity:1; animation:heartbeat 1.5s ease-in-out infinite; }
.youtubetempo-button:active { transition:background-size 0s; background-size:100% 100%; transform:scale(0.95); }
.youtubetempo-speed-indicator { float:left; line-height:48px; margin:0 8px; font-size:${CONFIG.ui.indicatorFontSize}px; font-weight:500; font-family:Roboto,Arial,sans-serif; display:flex; align-items:center; justify-content:center; height:100%; min-width:50px; cursor:pointer; user-select:none; transition:all .2s ease; position:relative; padding: 0 4px; border-radius: 4px; }
.youtubetempo-speed-indicator:hover { background: rgba(255,255,255,0.1); }
.youtubetempo-speed-text { display: inline-block; transition: opacity 0.2s ease-out; }
.youtubetempo-speed-indicator::after {
content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: auto;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23ffffff" opacity="0.9" d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/></svg>');
background-repeat: no-repeat; background-position: center; background-size: 20px 20px;
opacity: 0; transform: scale(0.8); transition: opacity 0.2s ease-out, transform 0.2s ease-out;
}
.youtubetempo-speed-indicator:hover .youtubetempo-speed-text { opacity: 0; }
.youtubetempo-speed-indicator:hover::after { opacity: 1; transform: scale(1); }
.youtubetempo-remaining-time { margin-left:8px; font-size:${CONFIG.ui.indicatorFontSize}px; font-weight:400; font-family:Roboto,Arial,sans-serif; display:flex; align-items:center; color:#ddd; user-select:none; }
.youtubetempo-settings-wrapper { float:left; position:relative; height:100%; }
.youtubetempo-settings-menu { display:none; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); margin-bottom:${CONFIG.ui.settingsMenuBottomMargin}px; background:rgba(33,33,33,0.98); border-radius:8px; padding:4px 0; color:white; font-family:"YouTube Noto",Roboto,Arial,Helvetica,sans-serif; z-index:2002; width:${CONFIG.ui.settingsMenuWidth}px; box-shadow:0 4px 16px rgba(0,0,0,0.4); }
.youtubetempo-settings-title { font-size:14px; font-weight:600; padding:0 12px; color:#fff; border-bottom:1px solid rgba(255,255,255,0.1); height:32px; line-height:32px; display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; }
.youtubetempo-settings-links { display:flex; align-items:center; gap:10px; }
.youtubetempo-settings-link-icon { color:rgba(255,255,255,0.7); display:inline-flex; align-items:center; transition:all 0.2s ease; }
.youtubetempo-settings-link-icon:hover { color:#fff; transform:scale(1.1); }
.youtubetempo-settings-link-icon svg { width:18px; height:18px; }
.youtubetempo-settings-row { display:flex; justify-content:space-between; align-items:center; padding:0 12px; position:relative; cursor:default; height:32px; transition: background-color 0.2s; }
.youtubetempo-settings-label { font-size:13px; color:#fff; flex-grow:1; display:flex; align-items:center; gap:6px; font-weight:400; }
.youtubetempo-settings-input { width:50px; background:rgba(255,255,255,0.1); border:1px solid transparent; border-radius:2px; color:#fff; padding:3px 5px; font-size:12px; text-align:center; outline:none; transition:all .1s ease; font-family:"YouTube Noto",Roboto,Arial,Helvetica,sans-serif; }
.youtubetempo-settings-input-invalid { border-color: rgba(255, 82, 82, 0.7) !important; background: rgba(255, 82, 82, 0.1) !important; }
.youtubetempo-settings-input[type="text"] { width: 70px; text-align: left; }
.youtubetempo-settings-input[type="range"] { flex-grow: 1; padding: 0; margin: 0; width: 90px; }
.youtubetempo-settings-controls-wrapper { display: flex; align-items: center; gap: 8px; }
.youtubetempo-settings-input-display { width: 40px; text-align: center; }
.youtubetempo-settings-input:focus { border-color:#3ea6ff; background:rgba(62,166,255,0.1); }
.youtubetempo-settings-reset-btn { opacity:0; padding:4px; margin-right:6px; cursor:pointer; color:rgba(255,255,255,0.5); transition:all .2s ease; }
.youtubetempo-settings-row:hover .youtubetempo-settings-reset-btn { opacity:0.7; }
.youtubetempo-settings-row:hover { background:rgba(255,255,255,0.1); }
.youtubetempo-toggle-switch { position:relative; display:inline-block; width:34px; height:20px; flex-shrink: 0; cursor: pointer; }
.youtubetempo-toggle-switch input { opacity:0; width:0; height:0; }
.youtubetempo-toggle-slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#ccc; transition:.4s; border-radius:20px; }
.youtubetempo-toggle-slider:before { position:absolute; content:""; height:12px; width:12px; left:4px; bottom:4px; background-color:white; transition:.4s; border-radius:50%; }
input:checked + .youtubetempo-toggle-slider { background-color:#3ea6ff; }
input:checked + .youtubetempo-toggle-slider:before { transform:translateX(14px); }
.youtubetempo-settings-group-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 2px 12px; font-weight: 500; font-size: 13px; border-radius: 4px; margin: 2px 6px 0; transition: background-color 0.2s; height: 28px; width: calc(100% - 12px); text-align: left; }
.youtubetempo-settings-group-header:hover { background-color: rgba(255, 255, 255, 0.1); }
.youtubetempo-group-header-arrow { transition: transform 0.3s ease-out; }
.youtubetempo-settings-group-content { max-height: 500px; overflow: hidden; transition: max-height 0.3s ease-out; }
.youtubetempo-group-collapsed .youtubetempo-settings-group-content { max-height: 0; overflow: hidden; }
.youtubetempo-group-collapsed .youtubetempo-group-header-arrow { transform: rotate(-90deg); }
.youtubetempo-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
`;
// Holds the dynamic state of the script.
const state = {
audioContext: null, gainNode: null, audioSourceNode: null,
currentVideoElement: null, settingsWrapperElement: null, speedIndicatorElement: null,
remainingTimeElement: null, settingsMenuElement: null, isSettingsUIVisible: false,
videoMutationObserver: null, activePlayerControls: null, liveIndicatorCache: null,
cleanupFunctions: [],
liveWarningShown: false // Flag to ensure the live stream warning is shown only once per page.
};
// Holds the user's configuration, loaded from storage.
const userConfig = {
speedStep: CONFIG.speedStep, minSpeed: CONFIG.minSpeed, maxSpeed: CONFIG.maxSpeed,
isRemainingTimeEnabled: true, shortcutSlower: CONFIG.shortcuts.slower,
shortcutReset: CONFIG.shortcuts.reset, shortcutFaster: CONFIG.shortcuts.faster, volumeBoost: 1.0,
overrideYouTubeShortcuts: false, isSoundEffectsEnabled: true
};
// Handles errors and displays notifications to the user.
class ErrorHandler {
static handle(error, context) {
console.error(`YouTubeTempo Error in ${context}:`, error);
if (error.name === 'NotAllowedError' || error.message.includes("permission")) {
this.showNotification('Audio permission required for Volume Boost. Please refresh and allow.');
}
if (CONFIG.enableErrorReporting) this.reportError(error, context);
}
static showNotification(message) {
const ytNotification = document.querySelector('ytd-notification-manager');
if (ytNotification && typeof ytNotification.show === 'function') {
try { ytNotification.show({ text: message }); return; } catch (e) { /* Fallback */ }
}
const notification = document.createElement('div');
notification.style.cssText = `position: fixed; top: 20px; right: 20px; background: #212121; color: white; padding: 12px 20px; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); z-index: 9999; font-family: Roboto, Arial, sans-serif; font-size: 14px; animation: slideIn 0.3s ease-out;`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease-out';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
static reportError(error, context) { /* Future implementation for error reporting */ }
}
// Manages saving and loading data from storage.
const Storage = {
save(key, value) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
} catch (e) { ErrorHandler.handle(e, 'Storage.save'); }
},
load(key, defaultValue) {
try {
if (typeof GM_getValue === 'function') {
return GM_getValue(key, defaultValue);
}
const value = localStorage.getItem(key);
return value === null ? defaultValue : JSON.parse(value);
} catch (e) {
ErrorHandler.handle(e, 'Storage.load');
return defaultValue;
}
},
loadUserConfig() {
userConfig.speedStep = this.load(CONFIG.storageKeys.settingsSpeedStep, CONFIG.defaults.speedStep);
userConfig.minSpeed = this.load(CONFIG.storageKeys.settingsMinSpeed, CONFIG.defaults.minSpeed);
userConfig.maxSpeed = this.load(CONFIG.storageKeys.settingsMaxSpeed, CONFIG.defaults.maxSpeed);
userConfig.isRemainingTimeEnabled = this.load(CONFIG.storageKeys.isRemainingTimeEnabled, true);
userConfig.shortcutSlower = this.load(CONFIG.storageKeys.shortcutSlower, CONFIG.defaults.shortcutSlower);
userConfig.shortcutReset = this.load(CONFIG.storageKeys.shortcutReset, CONFIG.defaults.shortcutReset);
userConfig.shortcutFaster = this.load(CONFIG.storageKeys.shortcutFaster, CONFIG.defaults.shortcutFaster);
userConfig.volumeBoost = this.load(CONFIG.storageKeys.volumeBoost, CONFIG.defaults.volumeBoost);
userConfig.overrideYouTubeShortcuts = this.load(CONFIG.storageKeys.overrideYouTubeShortcuts, false);
userConfig.isSoundEffectsEnabled = this.load(CONFIG.storageKeys.isSoundEffectsEnabled, true);
}
};
// General utility functions.
function waitForElement(selector, timeout = CONFIG.ui.elementWaitTimeout) {
return new Promise((resolve, reject) => {
let element = document.querySelector(selector);
if (element) return resolve(element);
const observer = new MutationObserver(() => {
element = document.querySelector(selector);
if (element) { observer.disconnect(); resolve(element); }
});
const container = document.querySelector(CONFIG.selectors.watchContainer) || document.querySelector(CONFIG.selectors.playerContainer) || document.body;
observer.observe(container, { childList: true, subtree: true });
setTimeout(() => {
observer.disconnect();
reject(new Error(`Element ${selector} not found within ${timeout}ms`));
}, timeout);
});
}
async function findElementWithFallbacks(selectors, timeout = 5000) {
for (const selector of selectors) {
try {
const element = await waitForElement(selector, timeout / selectors.length);
if (element) return element;
} catch (error) {
// Ignore error and try the next selector in the list.
}
}
throw new Error(`None of the selectors found: ${selectors.join(', ')}`);
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// Manages all audio-related functionality.
const Audio = {
getContext() {
if (!state.audioContext) {
try { state.audioContext = new(window.AudioContext || window.webkitAudioContext)(); }
catch (e) { ErrorHandler.handle(e, 'AudioContext initialization'); return null; }
}
if (state.audioContext.state === 'suspended') state.audioContext.resume();
return state.audioContext;
},
playSound(type) {
const ctx = this.getContext(); if (!ctx) return;
const now = ctx.currentTime, oscillator = ctx.createOscillator(), gain = ctx.createGain();
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.00001, now + 0.1);
oscillator.connect(gain); gain.connect(ctx.destination);
switch (type) {
case 'slower': oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(800, now); oscillator.frequency.exponentialRampToValueAtTime(200, now + 0.1); break;
case 'reset': oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(330, now); gain.gain.exponentialRampToValueAtTime(0.00001, now + 0.08); break;
case 'faster': oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(600, now); oscillator.frequency.exponentialRampToValueAtTime(1200, now + 0.1); break;
}
oscillator.start(now); oscillator.stop(now + 0.12);
},
setupBooster(videoElement) {
const ctx = this.getContext();
if (!ctx || !videoElement || (state.audioSourceNode && state.audioSourceNode.mediaElement === videoElement)) return;
if (state.audioSourceNode) try { state.audioSourceNode.disconnect(); } catch (e) {}
if (state.gainNode) try { state.gainNode.disconnect(); } catch (e) {}
try {
state.audioSourceNode = ctx.createMediaElementSource(videoElement);
state.gainNode = ctx.createGain();
state.audioSourceNode.connect(state.gainNode);
state.gainNode.connect(ctx.destination);
this.applyVolumeBoost(userConfig.volumeBoost);
} catch (e) { ErrorHandler.handle(e, 'Audio booster setup'); state.audioSourceNode = null; state.gainNode = null; }
},
applyVolumeBoost(level) {
if (state.gainNode && state.audioContext) state.gainNode.gain.setValueAtTime(level, state.audioContext.currentTime);
}
};
// Manages all UI elements and interactions.
const UI = {
// Interpolates colors in HSL space for more natural transitions (e.g., avoids muddy gray when transitioning between red and green).
lerpColor(colorA, colorB, amount) {
const hexToRgb = (hex) => {
const b = parseInt(hex.replace(/#/, ''), 16);
return [(b >> 16) & 255, (b >> 8) & 255, b & 255];
};
const rgbToHsl = (r, g, b) => {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h * 360, s * 100, l * 100];
};
const hslToRgb = (h, s, l) => {
let r, g, b; h /= 360; s /= 100; l /= 100;
if (s === 0) { r = g = b = l; }
else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
};
const [ar, ag, ab] = hexToRgb(colorA), [br, bg, bb] = hexToRgb(colorB);
const [h1, s1, l1] = rgbToHsl(ar, ag, ab), [h2, s2, l2] = rgbToHsl(br, bg, bb);
const h = h1 + (h2 - h1) * amount, s = s1 + (s2 - s1) * amount, l = l1 + (l2 - l1) * amount;
const [r, g, b] = hslToRgb(h, s, l);
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
},
getSpeedColor(speed) {
if (speed === 1) return '#2979ff';
if (speed < 1) {
const amount = (speed - userConfig.minSpeed) / (1 - userConfig.minSpeed);
return this.lerpColor('#ff1744', '#2979ff', amount);
}
const amount = Math.min((speed - 1) / (userConfig.maxSpeed - 1), 1);
return this.lerpColor('#2979ff', '#00e676', amount);
},
updateSpeedIndicator(speed) {
if (!state.speedIndicatorElement) return;
const displaySpeed = Number(speed).toFixed(2);
state.speedIndicatorElement.innerHTML = `<span class="youtubetempo-speed-text" aria-live="polite" aria-atomic="true">${displaySpeed}x</span>`;
state.speedIndicatorElement.style.color = this.getSpeedColor(parseFloat(displaySpeed));
},
formatTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600), m = Math.floor((totalSeconds % 3600) / 60), s = Math.floor(totalSeconds % 60);
return h > 0 ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
},
updateRemainingTime: debounce(() => {
if (!userConfig.isRemainingTimeEnabled || !state.currentVideoElement || !state.remainingTimeElement) return;
if (state.currentVideoElement.paused) { state.remainingTimeElement.textContent = ''; return; }
const isLive = state.currentVideoElement.duration === Infinity || (state.liveIndicatorCache = state.liveIndicatorCache || document.querySelector(CONFIG.selectors.liveIndicator));
if (isLive || !isFinite(state.currentVideoElement.duration)) { state.remainingTimeElement.textContent = ''; return; }
const remaining = (state.currentVideoElement.duration - state.currentVideoElement.currentTime) / state.currentVideoElement.playbackRate;
if (remaining > 0 && isFinite(remaining)) { state.remainingTimeElement.textContent = `(${UI.formatTime(remaining)})`; } else { state.remainingTimeElement.textContent = ''; }
}, CONFIG.ui.debounceRate),
toggleSettings() {
if (!state.settingsMenuElement || !state.speedIndicatorElement) return;
state.isSettingsUIVisible = !state.isSettingsUIVisible;
const isVisible = state.isSettingsUIVisible;
state.speedIndicatorElement.setAttribute('aria-expanded', isVisible);
state.settingsMenuElement.style.display = isVisible ? 'block' : 'none';
if (isVisible) {
const firstFocusable = state.settingsMenuElement.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) firstFocusable.focus();
} else { state.speedIndicatorElement.focus(); }
},
createSpeedControlButton(label, iconHtml, className, onClickAction) {
const button = document.createElement('button');
button.className = `ytp-button youtubetempo-button ${className}`;
button.title = label; button.setAttribute('aria-label', label);
button.innerHTML = iconHtml; button.onclick = onClickAction;
return button;
},
updateRemainingTimeVisibility(shouldBeVisible) {
if (shouldBeVisible) {
if (!state.remainingTimeElement || !state.remainingTimeElement.parentElement) {
const youtubeTimeDisplay = document.querySelector(CONFIG.selectors.timeDisplay);
if (youtubeTimeDisplay) {
state.remainingTimeElement = document.createElement('div');
state.remainingTimeElement.className = 'youtubetempo-remaining-time';
youtubeTimeDisplay.insertAdjacentElement('afterend', state.remainingTimeElement);
this.updateRemainingTime();
}
}
} else {
if (state.remainingTimeElement && state.remainingTimeElement.parentElement) {
state.remainingTimeElement.remove();
state.remainingTimeElement = null;
}
}
},
_createSettingsHeader() {
const title = document.createElement('div');
title.className = 'youtubetempo-settings-title';
const titleGroup = document.createElement('div');
titleGroup.style.display = 'flex';
titleGroup.style.alignItems = 'baseline';
titleGroup.style.gap = '6px';
const titleText = document.createElement('span');
titleText.textContent = 'YouTubeTempo Settings';
titleText.id = 'youtubetempo-settings-title-id';
titleGroup.appendChild(titleText);
const versionSpan = document.createElement('span');
const scriptVersion = (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script.version : '1.0.0';
versionSpan.textContent = `v${scriptVersion}`;
versionSpan.style.fontSize = '10px';
versionSpan.style.color = 'rgba(255,255,255,0.6)';
versionSpan.style.fontWeight = 'normal';
titleGroup.appendChild(versionSpan);
title.appendChild(titleGroup);
const linksContainer = document.createElement('div');
linksContainer.className = 'youtubetempo-settings-links';
const createLink = (href, title, icon) => {
const a = document.createElement('a');
a.href = href; a.title = title; a.innerHTML = icon; a.className = 'youtubetempo-settings-link-icon';
a.target = '_blank'; a.rel = 'noopener noreferrer'; return a;
};
linksContainer.appendChild(createLink('https://github.com/hasanbeder/YouTubeTempo', 'GitHub', ICONS.github));
linksContainer.appendChild(createLink('https://x.com/hasanbeder', 'Author on X', ICONS.socialX));
title.appendChild(linksContainer);
return title;
},
_createCollapsibleGroup(title, id, contentElements, isInitiallyCollapsed = false) {
const storageKey = `youtubetempo-group-state-${id}`;
let isCollapsed = Storage.load(storageKey, isInitiallyCollapsed);
const group = document.createElement('div');
group.className = 'youtubetempo-settings-group';
if (isCollapsed) group.classList.add('youtubetempo-group-collapsed');
const header = document.createElement('button');
header.className = 'youtubetempo-settings-group-header';
const contentId = `youtubetempo-group-content-${id}`;
header.setAttribute('aria-expanded', !isCollapsed);
header.setAttribute('aria-controls', contentId);
header.innerHTML = `<span>${title}</span><span class="youtubetempo-group-header-arrow">${ICONS.chevron}</span>`;
const content = document.createElement('div');
content.className = 'youtubetempo-settings-group-content';
content.id = contentId;
contentElements.forEach(el => content.appendChild(el));
header.onclick = () => {
isCollapsed = !isCollapsed;
group.classList.toggle('youtubetempo-group-collapsed', isCollapsed);
header.setAttribute('aria-expanded', !isCollapsed);
Storage.save(storageKey, isCollapsed);
};
group.append(header, content);
return group;
},
_createRow(labelText, storageKey, configKey, min, max, step, defaultValue, description = null) {
const row = document.createElement('div'); row.className = 'youtubetempo-settings-row';
if (description) row.title = description;
const label = document.createElement('div'); label.className = 'youtubetempo-settings-label';
const resetButton = document.createElement('button');
resetButton.className = 'youtubetempo-settings-reset-btn';
resetButton.innerHTML = ICONS.settingsResetIcon;
resetButton.title = 'Reset to default';
resetButton.setAttribute('aria-label', `Reset ${labelText.replace(':', '')} to default`);
resetButton.onclick = () => {
input.value = defaultValue;
userConfig[configKey] = defaultValue;
Storage.save(storageKey, defaultValue);
ErrorHandler.showNotification(`${labelText.replace(':', '')} reset to default.`);
};
label.append(resetButton, document.createTextNode(labelText));
const input = document.createElement('input');
input.className = 'youtubetempo-settings-input'; input.type = 'number';
input.value = userConfig[configKey]; input.min = min; input.max = max; input.step = step;
input.onchange = () => {
let newValue = parseFloat(input.value);
if (isNaN(newValue)) newValue = defaultValue;
newValue = Math.max(min, Math.min(max, newValue));
input.value = newValue; userConfig[configKey] = newValue; Storage.save(storageKey, newValue);
};
if (description) {
const descId = `desc-${configKey}`;
input.setAttribute('aria-describedby', descId);
const descSpan = document.createElement('span');
descSpan.id = descId;
descSpan.className = 'youtubetempo-sr-only';
descSpan.textContent = description;
row.appendChild(descSpan);
}
row.append(label, input);
return row;
},
_createToggleRow(labelText, storageKey, configKey, description = null) {
const row = document.createElement('div'); row.className = 'youtubetempo-settings-row';
if (description) row.title = description;
const inputId = `youtubetempo-toggle-${configKey}`;
const label = document.createElement('label');
label.className = 'youtubetempo-settings-label'; label.textContent = labelText;
label.setAttribute('for', inputId); label.style.cursor = 'pointer';
const switchDiv = document.createElement('div'); switchDiv.className = 'youtubetempo-toggle-switch';
const input = document.createElement('input');
input.type = 'checkbox'; input.id = inputId; input.checked = userConfig[configKey];
input.onchange = () => {
const isChecked = input.checked; userConfig[configKey] = isChecked;
Storage.save(storageKey, isChecked);
if (configKey === 'isRemainingTimeEnabled') UI.updateRemainingTimeVisibility(isChecked);
};
if (description) {
const descId = `desc-${configKey}`;
input.setAttribute('aria-describedby', descId);
const descSpan = document.createElement('span');
descSpan.id = descId;
descSpan.className = 'youtubetempo-sr-only';
descSpan.textContent = description;
row.appendChild(descSpan);
}
switchDiv.onclick = () => input.click();
const slider = document.createElement('span'); slider.className = 'youtubetempo-toggle-slider';
switchDiv.append(input, slider); row.append(label, switchDiv);
return row;
},
_createSliderRow(labelText, storageKey, configKey, min, max, step, defaultValue, description = null) {
const row = document.createElement('div'); row.className = 'youtubetempo-settings-row';
if (description) row.title = description;
const label = document.createElement('div'); label.className = 'youtubetempo-settings-label';
const resetButton = document.createElement('button');
resetButton.className = 'youtubetempo-settings-reset-btn'; resetButton.innerHTML = ICONS.settingsResetIcon;
resetButton.title = 'Reset to default';
resetButton.setAttribute('aria-label', `Reset ${labelText.replace(':', '')} to default`);
resetButton.onclick = () => {
input.value = defaultValue;
valueDisplay.textContent = `${Math.round(defaultValue * 100)}%`;
userConfig[configKey] = defaultValue;
Storage.save(storageKey, defaultValue);
Audio.applyVolumeBoost(defaultValue);
ErrorHandler.showNotification(`${labelText.replace(':', '')} reset to default.`);
};
label.append(resetButton, document.createTextNode(labelText));
const controlsWrapper = document.createElement('div'); controlsWrapper.className = 'youtubetempo-settings-controls-wrapper';
const input = document.createElement('input');
input.className = 'youtubetempo-settings-input'; input.type = 'range';
input.value = userConfig[configKey]; input.min = min; input.max = max; input.step = step;
if (description) {
const descId = `desc-${configKey}`;
input.setAttribute('aria-describedby', descId);
const descSpan = document.createElement('span');
descSpan.id = descId;
descSpan.className = 'youtubetempo-sr-only';
descSpan.textContent = description;
row.appendChild(descSpan);
}
const valueDisplay = document.createElement('span');
valueDisplay.className = 'youtubetempo-settings-input-display';
valueDisplay.textContent = `${Math.round(userConfig[configKey] * 100)}%`;
valueDisplay.setAttribute('aria-live', 'polite');
input.oninput = () => {
const newValue = parseFloat(input.value); valueDisplay.textContent = `${Math.round(newValue * 100)}%`;
userConfig[configKey] = newValue; Audio.applyVolumeBoost(newValue);
};
input.onchange = () => { Storage.save(storageKey, input.value); };
controlsWrapper.append(input, valueDisplay); row.append(label, controlsWrapper);
return row;
},
_createShortcutRow(labelText, storageKey, configKey, defaultValue, description = null) {
const row = document.createElement('div'); row.className = 'youtubetempo-settings-row';
if (description) row.title = description;
const label = document.createElement('div'); label.className = 'youtubetempo-settings-label';
const resetButton = document.createElement('button');
resetButton.className = 'youtubetempo-settings-reset-btn'; resetButton.innerHTML = ICONS.settingsResetIcon;
resetButton.title = 'Reset to default';
resetButton.setAttribute('aria-label', `Reset ${labelText.replace(':', '')} to default`);
resetButton.onclick = () => {
input.value = defaultValue;
userConfig[configKey] = defaultValue;
Storage.save(storageKey, defaultValue);
ErrorHandler.showNotification(`${labelText.replace(':', '')} reset to default.`);
};
label.append(resetButton, document.createTextNode(labelText));
const input = document.createElement('input');
input.className = 'youtubetempo-settings-input'; input.type = 'text'; input.value = userConfig[configKey];
input.onkeydown = (e) => {
e.preventDefault();
const key = e.key === ' ' ? 'Space' : e.key;
// Validate against modifier keys being used alone
if (['Control', 'Alt', 'Shift', 'Meta'].includes(key)) {
input.classList.add('youtubetempo-settings-input-invalid');
const feedback = document.createElement('div');
feedback.setAttribute('aria-live', 'assertive');
feedback.className = 'youtubetempo-sr-only';
feedback.textContent = 'Invalid key. Modifier keys alone cannot be shortcuts.';
row.appendChild(feedback);
setTimeout(() => {
input.classList.remove('youtubetempo-settings-input-invalid');
if (feedback.parentElement) feedback.remove();
}, 1500);
return;
}
// --- START: Shortcut Conflict Validation ---
const allShortcutConfigKeys = ['shortcutSlower', 'shortcutReset', 'shortcutFaster'];
const conflictingShortcut = allShortcutConfigKeys.find(otherKey =>
otherKey !== configKey && userConfig[otherKey] === key
);
if (conflictingShortcut) {
input.classList.add('youtubetempo-settings-input-invalid');
const feedback = document.createElement('div');
feedback.setAttribute('aria-live', 'assertive');
feedback.className = 'youtubetempo-sr-only';
feedback.textContent = `Key "${key}" is already used for another shortcut.`;
row.appendChild(feedback);
setTimeout(() => {
input.classList.remove('youtubetempo-settings-input-invalid');
if (feedback.parentElement) feedback.remove();
}, 2000);
return; // Do not save the conflicting key
}
// --- END: Shortcut Conflict Validation ---
input.value = key; userConfig[configKey] = key; Storage.save(storageKey, key); input.blur();
};
input.onfocus = () => input.value = 'Press a key...';
input.onblur = () => input.value = userConfig[configKey];
if (description) {
const descId = `desc-${configKey}`;
input.setAttribute('aria-describedby', descId);
const descSpan = document.createElement('span');
descSpan.id = descId;
descSpan.className = 'youtubetempo-sr-only';
descSpan.textContent = description;
row.appendChild(descSpan);
}
row.append(label, input); return row;
},
createSettingsUI() {
state.settingsMenuElement = document.createElement('div');
state.settingsMenuElement.className = 'youtubetempo-settings-menu';
state.settingsMenuElement.setAttribute('role', 'dialog');
state.settingsMenuElement.setAttribute('aria-labelledby', 'youtubetempo-settings-title-id');
state.settingsMenuElement.setAttribute('aria-modal', 'true');
const header = this._createSettingsHeader();
const speedContent = [
this._createRow('Speed Step:', CONFIG.storageKeys.settingsSpeedStep, 'speedStep', 0.01, 0.5, 0.01, CONFIG.defaults.speedStep, 'Amount to change speed per click or shortcut press.'),
this._createRow('Min Speed:', CONFIG.storageKeys.settingsMinSpeed, 'minSpeed', 0.1, 1.0, 0.05, CONFIG.defaults.minSpeed, 'The minimum allowed playback speed.'),
this._createRow('Max Speed:', CONFIG.storageKeys.settingsMaxSpeed, 'maxSpeed', 1.0, 16.0, 0.1, CONFIG.defaults.maxSpeed, 'The maximum allowed playback speed.')
];
const audioContent = [
this._createSliderRow('Volume Boost:', CONFIG.storageKeys.volumeBoost, 'volumeBoost', 0, 3, 0.05, CONFIG.defaults.volumeBoost, 'Boost volume beyond 100%. High levels may cause distortion or damage speakers/headphones.'),
this._createToggleRow('Enable Sound Effects', CONFIG.storageKeys.isSoundEffectsEnabled, 'isSoundEffectsEnabled', 'Plays a sound effect when changing speed.')
];
const shortcutsContent = [
this._createShortcutRow('Slower Key:', CONFIG.storageKeys.shortcutSlower, 'shortcutSlower', CONFIG.defaults.shortcutSlower, 'Shortcut to decrease speed.'),
this._createShortcutRow('Reset Key:', CONFIG.storageKeys.shortcutReset, 'shortcutReset', CONFIG.defaults.shortcutReset, 'Shortcut to reset speed to 1.0x.'),
this._createShortcutRow('Faster Key:', CONFIG.storageKeys.shortcutFaster, 'shortcutFaster', CONFIG.defaults.shortcutFaster, 'Shortcut to increase speed.'),
this._createToggleRow('Override YouTube Shortcuts', CONFIG.storageKeys.overrideYouTubeShortcuts, 'overrideYouTubeShortcuts', 'If enabled, YouTubeTempo shortcuts will prevent default YouTube actions for the same keys.')
];
const generalContent = [
this._createToggleRow('Show Remaining Time', CONFIG.storageKeys.isRemainingTimeEnabled, 'isRemainingTimeEnabled', 'Displays the calculated remaining time of the video next to the time display.')
];
const speedGroup = this._createCollapsibleGroup('Speed Control', 'speed', speedContent, false);
const audioGroup = this._createCollapsibleGroup('Audio', 'audio', audioContent, true);
const shortcutsGroup = this._createCollapsibleGroup('Shortcuts', 'shortcuts', shortcutsContent, false);
const generalGroup = this._createCollapsibleGroup('General Settings', 'general', generalContent, true);
state.settingsMenuElement.append(header, speedGroup, audioGroup, shortcutsGroup, generalGroup);
return state.settingsMenuElement;
},
injectUI(playerControlsElement) {
if (document.querySelector('.youtubetempo-button')) return;
state.activePlayerControls = playerControlsElement;
state.settingsWrapperElement = document.createElement('div');
state.settingsWrapperElement.className = 'youtubetempo-settings-wrapper';
state.speedIndicatorElement = document.createElement('button');
state.speedIndicatorElement.className = 'youtubetempo-speed-indicator';
state.speedIndicatorElement.title = 'Open YouTubeTempo Settings';
state.speedIndicatorElement.setAttribute('aria-haspopup', 'dialog');
state.speedIndicatorElement.setAttribute('aria-expanded', 'false');
state.speedIndicatorElement.onclick = () => UI.toggleSettings();
state.settingsWrapperElement.appendChild(state.speedIndicatorElement);
if (!state.settingsMenuElement) this.createSettingsUI();
state.settingsWrapperElement.appendChild(state.settingsMenuElement);
const slowerButton = this.createSpeedControlButton('Slow Down', ICONS.slower, 'youtubetempo-slower', () => Core.changeSpeed(-userConfig.speedStep, 'slower'));
const resetButton = this.createSpeedControlButton('Reset Speed', ICONS.reset, 'youtubetempo-reset', () => Core.resetSpeed());
const fasterButton = this.createSpeedControlButton('Speed Up', ICONS.faster, 'youtubetempo-faster', () => Core.changeSpeed(userConfig.speedStep, 'faster'));
playerControlsElement.prepend(slowerButton, resetButton, fasterButton, state.settingsWrapperElement);
if (userConfig.isRemainingTimeEnabled) this.updateRemainingTimeVisibility(true);
Core.loadAndApplyPersistedSpeed();
this.updateRemainingTime();
// Show a one-time notification for live streams to inform the user.
const videoEl = state.currentVideoElement;
if (videoEl && videoEl.duration === Infinity && !state.liveWarningShown) {
ErrorHandler.showNotification("Live stream detected. Speed controls can be used to catch up.");
state.liveWarningShown = true;
}
},
cleanup() {
document.querySelectorAll('.youtubetempo-button, .youtubetempo-remaining-time, .youtubetempo-settings-wrapper')
.forEach(e => e.remove());
state.settingsWrapperElement = null;
state.speedIndicatorElement = null;
state.remainingTimeElement = null;
state.isSettingsUIVisible = false;
state.activePlayerControls = null;
state.liveIndicatorCache = null;
}
};
// Core logic for playback speed manipulation.
const Core = {
setPlaybackSpeed(speed) {
if (!state.currentVideoElement) return;
const newSpeed = parseFloat(speed.toFixed(2));
state.currentVideoElement.playbackRate = newSpeed;
UI.updateSpeedIndicator(newSpeed);
Storage.save(CONFIG.storageKeys.speed, newSpeed);
UI.updateRemainingTime();
},
changeSpeed(delta, soundType) {
if (!state.currentVideoElement) return;
if (userConfig.isSoundEffectsEnabled) Audio.playSound(soundType);
const currentSpeed = state.currentVideoElement.playbackRate;
let newSpeed = Math.round((currentSpeed + delta) * 100) / 100;
newSpeed = Math.max(userConfig.minSpeed, Math.min(userConfig.maxSpeed, newSpeed));
this.setPlaybackSpeed(newSpeed);
},
resetSpeed() {
if (userConfig.isSoundEffectsEnabled) Audio.playSound('reset');
this.setPlaybackSpeed(1);
},
loadAndApplyPersistedSpeed() {
const persistedSpeed = Storage.load(CONFIG.storageKeys.speed, 1);
this.setPlaybackSpeed(persistedSpeed);
},
cleanup() {
if (state.cleanupFunctions.length) { state.cleanupFunctions.forEach(fn => fn()); state.cleanupFunctions = []; }
if (state.audioSourceNode) try { state.audioSourceNode.disconnect(); } catch (e) {} finally { state.audioSourceNode = null; }
if (state.gainNode) try { state.gainNode.disconnect(); } catch (e) {} finally { state.gainNode = null; }
if (state.audioContext && state.audioContext.state !== 'closed') { state.audioContext.close(); state.audioContext = null; }
if (state.videoMutationObserver) { state.videoMutationObserver.disconnect(); state.videoMutationObserver = null; }
UI.cleanup();
state.currentVideoElement = null;
state.liveWarningShown = false; // Reset the warning flag on cleanup.
}
};
// Manages all event listeners.
const EventHandlers = {
onVideoEvent() {
if (state.currentVideoElement) {
UI.updateSpeedIndicator(state.currentVideoElement.playbackRate);
UI.updateRemainingTime();
}
},
setupVideoEventListeners(videoEl) {
const handlers = {
play: () => Core.loadAndApplyPersistedSpeed(),
pause: () => UI.updateRemainingTime(),
seeked: () => UI.updateRemainingTime(),
loadedmetadata: () => Core.loadAndApplyPersistedSpeed(),
ratechange: () => this.onVideoEvent(),
timeupdate: throttle(() => UI.updateRemainingTime(), 1000)
};
Object.entries(handlers).forEach(([event, handler]) => {
videoEl.addEventListener(event, handler, { passive: true });
state.cleanupFunctions.push(() => videoEl.removeEventListener(event, handler));
});
},
handlePlayerOrVideoChange: debounce(async () => {
if (window.location.pathname.startsWith('/shorts/')) {
if (state.activePlayerControls) Core.cleanup();
return;
}
try {
const playerControlSelectors = [CONFIG.selectors.playerControls, ...CONFIG.selectors.fallbackPlayerControls];
const videoElementSelectors = [CONFIG.selectors.videoElement, ...CONFIG.selectors.fallbackVideoElement];
const playerControls = await findElementWithFallbacks(playerControlSelectors);
const video = await findElementWithFallbacks(videoElementSelectors);
if (playerControls !== state.activePlayerControls || !document.querySelector('.youtubetempo-button')) {
Core.cleanup();
UI.injectUI(playerControls);
}
if (video !== state.currentVideoElement) {
if (state.currentVideoElement) { state.cleanupFunctions.forEach(fn => fn()); state.cleanupFunctions = []; }
if (state.videoMutationObserver) state.videoMutationObserver.disconnect();
state.currentVideoElement = video;
EventHandlers.setupVideoEventListeners(state.currentVideoElement);
Audio.setupBooster(state.currentVideoElement);
state.videoMutationObserver = new MutationObserver(() => { Core.loadAndApplyPersistedSpeed(); UI.updateRemainingTime(); });
state.videoMutationObserver.observe(video, { attributes: true, attributeFilter: ['src'] });
Core.loadAndApplyPersistedSpeed();
} else if (userConfig.isRemainingTimeEnabled && !document.querySelector('.youtubetempo-remaining-time')) {
UI.updateRemainingTimeVisibility(true);
}
} catch (error) {
if (state.activePlayerControls) Core.cleanup();
}
}, CONFIG.ui.debounceRate),
handleKeyDown(e) {
if (state.isSettingsUIVisible && e.key === 'Tab') {
const focusableElements = Array.from(state.settingsMenuElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')).filter(el => el.offsetParent !== null);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) { lastElement.focus(); e.preventDefault(); }
} else {
if (document.activeElement === lastElement) { firstElement.focus(); e.preventDefault(); }
}
}
if (e.key === 'Escape' && state.isSettingsUIVisible) { e.preventDefault(); UI.toggleSettings(); return; }
if (e.target.isContentEditable || ['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
const key = e.key === ' ' ? 'Space' : e.key;
const isOurShortcut = [userConfig.shortcutSlower, userConfig.shortcutReset, userConfig.shortcutFaster].includes(key);
if (isOurShortcut) {
if (userConfig.overrideYouTubeShortcuts) { e.preventDefault(); e.stopPropagation(); }
switch (key) {
case userConfig.shortcutSlower: Core.changeSpeed(-userConfig.speedStep, 'slower'); break;
case userConfig.shortcutReset: Core.resetSpeed(); break;
case userConfig.shortcutFaster: Core.changeSpeed(userConfig.speedStep, 'faster'); break;
}
}
},
handleClickOutside(event) {
if (state.isSettingsUIVisible && state.settingsWrapperElement && !state.settingsWrapperElement.contains(event.target)) {
UI.toggleSettings();
}
}
};
// Initializes the script.
async function initialize() {
if (window.location.pathname.startsWith('/shorts/')) {
console.log('YouTubeTempo: Detected Shorts page, not initializing.');
return;
}
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
window.trustedTypes.createPolicy('default', { createHTML: string => string });
} catch (e) { /* Policy may already exist, ignore */ }
}
console.log('YouTubeTempo: Initializing...');
GM_addStyle(STYLES);
Storage.loadUserConfig();
await EventHandlers.handlePlayerOrVideoChange();
document.addEventListener('yt-navigate-finish', EventHandlers.handlePlayerOrVideoChange);
document.addEventListener('yt-page-data-updated', EventHandlers.handlePlayerOrVideoChange);
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(history, args);
EventHandlers.handlePlayerOrVideoChange();
};
const originalReplaceState = history.replaceState;
history.replaceState = function(...args) {
originalReplaceState.apply(history, args);
EventHandlers.handlePlayerOrVideoChange();
};
window.addEventListener('popstate', EventHandlers.handlePlayerOrVideoChange);
document.addEventListener('click', EventHandlers.handleClickOutside, true);
document.addEventListener('keydown', EventHandlers.handleKeyDown, true);
const cleanupHandler = () => {
document.removeEventListener('click', EventHandlers.handleClickOutside, true);
document.removeEventListener('keydown', EventHandlers.handleKeyDown, true);
Core.cleanup();
};
window.addEventListener('beforeunload', cleanupHandler);
state.cleanupFunctions.push(() => window.removeEventListener('beforeunload', cleanupHandler));
}
initialize();
})();