var isUserSeek = false; // Track if seek was user-initiated var lastToggleSpeed = {}; // Store last toggle speeds per video var speederShared = typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; var controllerUtils = speederShared.controllerUtils || {}; var keyBindingUtils = speederShared.keyBindings || {}; var siteRuleUtils = speederShared.siteRules || {}; 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: {}, 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: false, 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 = Array.isArray(controllerUtils.controllerLocations) ? controllerUtils.controllerLocations.slice() : [ "top-left", "top-center", "top-right", "middle-right", "bottom-right", "bottom-center", "bottom-left", "middle-left" ]; var defaultControllerLocation = controllerUtils.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: "\u21BB", className: "" }, fast: { label: "", className: "" }, nudge: { label: "", className: "" }, pause: { label: "", className: "" }, muted: { label: "", className: "" }, louder: { label: "", className: "" }, softer: { label: "", className: "" }, mark: { label: "", className: "" }, jump: { label: "", className: "" }, settings: { label: "", className: "" } }; function createDefaultBinding(action, code, value) { return { action: action, code: code, value: value, force: false, predefined: true }; } function defaultKeyBindings(storage) { return [ createDefaultBinding( "slower", "KeyS", Number(storage.speedStep) || 0.1 ), createDefaultBinding( "faster", "KeyD", Number(storage.speedStep) || 0.1 ), createDefaultBinding( "rewind", "KeyZ", Number(storage.rewindTime) || 10 ), createDefaultBinding( "advance", "KeyX", Number(storage.advanceTime) || 10 ), createDefaultBinding( "reset", "KeyR", 1.0 ), createDefaultBinding( "fast", "KeyG", Number(storage.fastSpeed) || 1.8 ), createDefaultBinding( "move", "KeyP", 0 ), createDefaultBinding( "toggleSubtitleNudge", "KeyN", 0 ) ]; } function ensureDefaultKeyBinding(action, code, value) { if (tc.settings.keyBindings.some((binding) => binding.action === action)) { return false; } tc.settings.keyBindings.push( createDefaultBinding(action, code, value) ); return true; } function getLegacyKeyCode(binding) { return keyBindingUtils.getLegacyKeyCode(binding); } function normalizeControllerLocation(location) { return controllerUtils.normalizeControllerLocation( location, defaultControllerLocation ); } var CONTROLLER_MARGIN_MAX_PX = 200; function normalizeControllerMarginPx(value, fallback) { return controllerUtils.clampControllerMarginPx(value, fallback); } 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) { return controllerUtils.getNextControllerLocation(location); } 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) { return keyBindingUtils.normalizeBindingKey(key); } function legacyBindingKeyToCode(key) { return keyBindingUtils.legacyBindingKeyToCode(key); } function legacyKeyCodeToCode(keyCode) { return keyBindingUtils.legacyKeyCodeToCode(keyCode); } function inferBindingCode(binding, fallbackCode) { return keyBindingUtils.inferBindingCode(binding, fallbackCode); } function normalizeStoredBinding(binding, fallbackCode) { if (!binding) { if (!fallbackCode) return null; return { code: fallbackCode, disabled: false, value: 0, force: "false", predefined: false }; } if ( binding.disabled === true || (binding.code === null && binding.key === null && binding.keyCode === null) ) { return { action: binding.action, code: null, disabled: true, value: Number(binding.value), force: String(binding.force) === "true" ? "true" : "false", predefined: Boolean(binding.predefined) }; } var normalizedCode = inferBindingCode(binding, fallbackCode); if (!normalizedCode) { return null; } var normalized = { action: binding.action, code: normalizedCode, disabled: false, value: Number(binding.value), force: String(binding.force) === "true" ? "true" : "false", predefined: Boolean(binding.predefined) }; 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