var isUserSeek = false; // Track if seek was user-initiated var lastToggleSpeed = {}; // Store last toggle speeds per video function getPrimaryVideoElement() { if (!tc.mediaElements || tc.mediaElements.length === 0) return null; for (var i = 0; i < tc.mediaElements.length; i++) { var el = tc.mediaElements[i]; if (el && !el.paused) return el; } return tc.mediaElements[0]; } var tc = { settings: { lastSpeed: 1.0, enabled: true, speeds: {}, displayKeyCode: 86, rememberSpeed: false, forceLastSavedSpeed: false, audioBoolean: false, startHidden: false, hideWithYouTubeControls: false, hideWithControls: false, hideWithControlsTimer: 2.0, controllerLocation: "top-left", controllerOpacity: 0.3, controllerMarginTop: 0, controllerMarginRight: 0, controllerMarginBottom: 65, controllerMarginLeft: 0, keyBindings: [], siteRules: [], controllerButtons: ["rewind", "slower", "faster", "advance", "display"], defaultLogLevel: 3, logLevel: 3, enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost subtitleNudgeAmount: 0.001, customButtonIcons: {} }, mediaElements: [], isNudging: false, pendingLastSpeedSave: null, pendingLastSpeedValue: null, persistedLastSpeed: 1.0, activeSiteRule: null, siteRuleBase: null }; var MIN_SPEED = 0.0625; var MAX_SPEED = 16; var YT_NATIVE_MIN = 0.25; var YT_NATIVE_MAX = 2.0; var YT_NATIVE_STEP = 0.05; var vscObservedRoots = new WeakSet(); var requestIdle = typeof window.requestIdleCallback === "function" ? window.requestIdleCallback.bind(window) : function (callback, options) { return setTimeout(callback, (options && options.timeout) || 1); }; var controllerLocations = [ "top-left", "top-center", "top-right", "middle-right", "bottom-right", "bottom-center", "bottom-left", "middle-left" ]; var defaultControllerLocation = controllerLocations[0]; var controllerLocationStyles = { "top-left": { top: "10px", left: "15px", transform: "translate(0, 0)" }, "top-center": { top: "10px", left: "50%", transform: "translate(-50%, 0)" }, "top-right": { top: "10px", left: "calc(100% - 10px)", transform: "translate(-100%, 0)" }, "middle-right": { top: "50%", left: "calc(100% - 10px)", transform: "translate(-100%, -50%)" }, "bottom-right": { top: "calc(100% - 0px)", left: "calc(100% - 10px)", transform: "translate(-100%, -100%)" }, "bottom-center": { top: "calc(100% - 0px)", left: "50%", transform: "translate(-50%, -100%)" }, "bottom-left": { top: "calc(100% - 0px)", left: "15px", transform: "translate(0, -100%)" }, "middle-left": { top: "50%", left: "15px", transform: "translate(0, -50%)" } }; /* `label` fallback only when ui-icons has no path for the action. */ var controllerButtonDefs = { rewind: { label: "", className: "rw" }, slower: { label: "", className: "" }, faster: { label: "", className: "" }, advance: { label: "", className: "rw" }, display: { label: "", className: "hideButton" }, reset: { label: "", className: "" }, fast: { label: "", className: "" }, settings: { label: "", className: "" }, pause: { label: "", className: "" }, muted: { label: "", className: "" }, mark: { label: "", className: "" }, jump: { label: "", className: "" } }; var keyCodeToEventKey = { 32: " ", 37: "ArrowLeft", 38: "ArrowUp", 39: "ArrowRight", 40: "ArrowDown", 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", 103: "7", 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", 111: "/", 112: "F1", 113: "F2", 114: "F3", 115: "F4", 116: "F5", 117: "F6", 118: "F7", 119: "F8", 120: "F9", 121: "F10", 122: "F11", 123: "F12", 186: ";", 188: "<", 189: "-", 187: "+", 190: ">", 191: "/", 192: "~", 219: "[", 220: "\\", 221: "]", 222: "'", 59: ";", 61: "+", 173: "-" }; function createDefaultBinding(action, key, keyCode, value) { return { action: action, key: key, keyCode: keyCode, value: value, force: false, predefined: true }; } function defaultKeyBindings(storage) { return [ createDefaultBinding( "slower", "S", Number(storage.slowerKeyCode) || 83, Number(storage.speedStep) || 0.1 ), createDefaultBinding( "faster", "D", Number(storage.fasterKeyCode) || 68, Number(storage.speedStep) || 0.1 ), createDefaultBinding( "rewind", "Z", Number(storage.rewindKeyCode) || 90, Number(storage.rewindTime) || 10 ), createDefaultBinding( "advance", "X", Number(storage.advanceKeyCode) || 88, Number(storage.advanceTime) || 10 ), createDefaultBinding( "reset", "R", Number(storage.resetKeyCode) || 82, 1.0 ), createDefaultBinding( "fast", "G", Number(storage.fastKeyCode) || 71, Number(storage.fastSpeed) || 1.8 ), createDefaultBinding( "move", "P", 80, 0 ), createDefaultBinding( "toggleSubtitleNudge", "N", 78, 0 ) ]; } function ensureDefaultKeyBinding(action, key, keyCode, value) { if (tc.settings.keyBindings.some((binding) => binding.action === action)) { return false; } tc.settings.keyBindings.push( createDefaultBinding(action, key, keyCode, value) ); return true; } function getLegacyKeyCode(binding) { if (!binding) return null; if (Number.isInteger(binding.keyCode)) return binding.keyCode; if (typeof binding.key === "number" && Number.isInteger(binding.key)) { return binding.key; } return null; } function normalizeControllerLocation(location) { if (controllerLocations.includes(location)) return location; return defaultControllerLocation; } var CONTROLLER_MARGIN_MAX_PX = 200; function normalizeControllerMarginPx(value, fallback) { var n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.min( CONTROLLER_MARGIN_MAX_PX, Math.max(0, Math.round(n)) ); } function applyControllerMargins(controller) { if (!controller) return; var d = tc.settings; var loc = controller.dataset.location; var manual = controller.dataset.positionMode === "manual"; var isTopAnchored = !manual && (loc === "top-left" || loc === "top-center" || loc === "top-right"); var isBottomAnchored = !manual && (loc === "bottom-right" || loc === "bottom-center" || loc === "bottom-left"); var isMiddleRow = !manual && (loc === "middle-left" || loc === "middle-right"); var mt = normalizeControllerMarginPx(d.controllerMarginTop, 0); var mb = normalizeControllerMarginPx(d.controllerMarginBottom, 65); if (isTopAnchored || isBottomAnchored || isMiddleRow) { mt = 0; mb = 0; } controller.style.marginTop = mt + "px"; var ml = normalizeControllerMarginPx(d.controllerMarginLeft, 0); var mr = normalizeControllerMarginPx(d.controllerMarginRight, 0); if (!manual) { ml = 0; mr = 0; } controller.style.marginRight = mr + "px"; controller.style.marginBottom = mb + "px"; controller.style.marginLeft = ml + "px"; } function getNextControllerLocation(location) { var normalizedLocation = normalizeControllerLocation(location); var currentIndex = controllerLocations.indexOf(normalizedLocation); return controllerLocations[(currentIndex + 1) % controllerLocations.length]; } function getControllerElement(videoOrController) { if (!videoOrController) return null; if ( videoOrController.shadowRoot && typeof videoOrController.shadowRoot.querySelector === "function" ) { return videoOrController.shadowRoot.querySelector("#controller"); } if ( videoOrController.div && videoOrController.div.shadowRoot && typeof videoOrController.div.shadowRoot.querySelector === "function" ) { return videoOrController.div.shadowRoot.querySelector("#controller"); } return null; } function applyControllerLocationToElement(controller, location) { if (!controller) return defaultControllerLocation; var normalizedLocation = normalizeControllerLocation(location); var styles = controllerLocationStyles[normalizedLocation]; controller.dataset.location = normalizedLocation; controller.dataset.positionMode = "anchored"; var top = styles.top; if ( normalizedLocation === "top-left" || normalizedLocation === "top-center" || normalizedLocation === "top-right" ) { var insetTop = normalizeControllerMarginPx( tc.settings.controllerMarginTop, 0 ); top = "calc(10px + " + insetTop + "px)"; } if ( normalizedLocation === "bottom-right" || normalizedLocation === "bottom-center" || normalizedLocation === "bottom-left" ) { var lift = normalizeControllerMarginPx( tc.settings.controllerMarginBottom, 65 ); top = "calc(100% - " + lift + "px)"; } // If in fullscreen, move the controller down to avoid overlapping video titles if ( document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement ) { if (normalizedLocation.startsWith("top-")) { var insetTopFs = normalizeControllerMarginPx( tc.settings.controllerMarginTop, 0 ); top = "calc(63px + " + insetTopFs + "px)"; } } controller.style.top = top; var left = styles.left; switch (normalizedLocation) { case "top-left": case "middle-left": case "bottom-left": left = "15px"; break; case "top-right": case "middle-right": case "bottom-right": left = "calc(100% - 10px)"; break; case "top-center": case "bottom-center": left = "50%"; break; default: break; } controller.style.left = left; controller.style.transform = styles.transform; applyControllerMargins(controller); return normalizedLocation; } function applyControllerLocation(videoController, location) { if (!videoController) return; var controller = getControllerElement(videoController); if (!controller) return; videoController.controllerLocation = applyControllerLocationToElement( controller, location ); } function captureSiteRuleBase() { tc.siteRuleBase = { startHidden: tc.settings.startHidden, hideWithControls: tc.settings.hideWithControls, hideWithControlsTimer: tc.settings.hideWithControlsTimer, controllerLocation: tc.settings.controllerLocation, rememberSpeed: tc.settings.rememberSpeed, forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, audioBoolean: tc.settings.audioBoolean, controllerOpacity: tc.settings.controllerOpacity, controllerMarginTop: tc.settings.controllerMarginTop, controllerMarginBottom: tc.settings.controllerMarginBottom, enableSubtitleNudge: tc.settings.enableSubtitleNudge, subtitleNudgeInterval: tc.settings.subtitleNudgeInterval, controllerButtons: Array.isArray(tc.settings.controllerButtons) ? tc.settings.controllerButtons.slice() : tc.settings.controllerButtons, keyBindings: Array.isArray(tc.settings.keyBindings) ? tc.settings.keyBindings.map(function (binding) { return Object.assign({}, binding); }) : tc.settings.keyBindings }; } function resetSettingsFromSiteRuleBase() { if (!tc.siteRuleBase) return; var base = tc.siteRuleBase; tc.settings.startHidden = base.startHidden; tc.settings.hideWithControls = base.hideWithControls; tc.settings.hideWithControlsTimer = base.hideWithControlsTimer; tc.settings.controllerLocation = base.controllerLocation; tc.settings.rememberSpeed = base.rememberSpeed; tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed; tc.settings.audioBoolean = base.audioBoolean; tc.settings.controllerOpacity = base.controllerOpacity; tc.settings.controllerMarginTop = base.controllerMarginTop; tc.settings.controllerMarginBottom = base.controllerMarginBottom; tc.settings.enableSubtitleNudge = base.enableSubtitleNudge; tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval; tc.settings.controllerButtons = Array.isArray(base.controllerButtons) ? base.controllerButtons.slice() : base.controllerButtons; tc.settings.keyBindings = Array.isArray(base.keyBindings) ? base.keyBindings.map(function (binding) { return Object.assign({}, binding); }) : base.keyBindings; } function clearManualControllerPosition(videoController) { if (!videoController) return; applyControllerLocation( videoController, videoController.controllerLocation || tc.settings.controllerLocation ); } function convertControllerToManualPosition(videoController) { if (!videoController) return null; var controller = getControllerElement(videoController); if (!controller) return null; controller.dataset.positionMode = "manual"; var offsetParent = controller.offsetParent; if (offsetParent) { var controllerRect = controller.getBoundingClientRect(); var offsetParentRect = offsetParent.getBoundingClientRect(); controller.style.setProperty( "left", controllerRect.left - offsetParentRect.left + "px", "important" ); controller.style.setProperty( "top", controllerRect.top - offsetParentRect.top + "px", "important" ); } else { controller.style.setProperty( "left", controller.offsetLeft + "px", "important" ); controller.style.setProperty( "top", controller.offsetTop + "px", "important" ); } controller.style.setProperty("transform", "none", "important"); return controller; } function cycleControllerLocation(video) { if (!video || !video.vsc) return; video.vsc.controllerLocation = getNextControllerLocation( video.vsc.controllerLocation || tc.settings.controllerLocation ); clearManualControllerPosition(video.vsc); } function normalizeBindingKey(key) { if (typeof key !== "string" || key.length === 0) return null; if (key === "Spacebar") return " "; if (key === "Esc") return "Escape"; if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase(); return key; } function legacyKeyCodeToBinding(keyCode) { if (!Number.isInteger(keyCode)) return null; var key = keyCodeToEventKey[keyCode]; if (!key && keyCode >= 48 && keyCode <= 57) { key = String.fromCharCode(keyCode); } if (!key && keyCode >= 65 && keyCode <= 90) { key = String.fromCharCode(keyCode); } return { key: normalizeBindingKey(key), keyCode: keyCode, code: null, disabled: false }; } function normalizeStoredBinding(binding, fallbackKeyCode) { var fallbackBinding = legacyKeyCodeToBinding(fallbackKeyCode); if (!binding) return fallbackBinding; if ( binding.disabled === true || (binding.key === null && binding.keyCode === null && binding.code === null) ) { return { action: binding.action, key: null, keyCode: null, code: null, disabled: true, value: Number(binding.value), force: String(binding.force) === "true" ? "true" : "false", predefined: Boolean(binding.predefined) }; } var normalized = { action: binding.action, key: null, keyCode: null, code: typeof binding.code === "string" && binding.code.length > 0 ? binding.code : null, disabled: false, value: Number(binding.value), force: String(binding.force) === "true" ? "true" : "false", predefined: Boolean(binding.predefined) }; if (typeof binding.key === "string") { normalized.key = normalizeBindingKey(binding.key); } var legacyKeyCode = getLegacyKeyCode(binding); if (Number.isInteger(legacyKeyCode)) { var legacyBinding = legacyKeyCodeToBinding(legacyKeyCode); if (legacyBinding) { normalized.key = normalized.key || legacyBinding.key; normalized.keyCode = legacyKeyCode; } } if (Number.isInteger(binding.keyCode)) { normalized.keyCode = binding.keyCode; } if (!normalized.key && fallbackBinding) { normalized.key = fallbackBinding.key; if (normalized.keyCode === null) normalized.keyCode = fallbackBinding.keyCode; } if (!normalized.key && !normalized.code && normalized.keyCode === null) { return null; } return normalized; } function isValidSpeed(speed) { return !isNaN(speed) && speed >= MIN_SPEED && speed <= MAX_SPEED; } function sanitizeSpeed(speed, fallback) { var numericSpeed = Number(speed); return isValidSpeed(numericSpeed) ? numericSpeed : fallback; } function getVideoSourceKey(video) { return (video && (video.currentSrc || video.src)) || "unknown_src"; } function getControllerTargetSpeed(video) { if (!video || !video.vsc) return null; if (!isValidSpeed(video.vsc.targetSpeed)) return null; var currentSourceKey = getVideoSourceKey(video); var targetSourceKey = video.vsc.targetSpeedSourceKey; // SPA sites (e.g. YouTube) can reuse the same