var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; var isUserSeek = false; // Track if seek was user-initiated var lastToggleSpeed = {}; // Store last toggle speeds per video var tc = { settings: { lastSpeed: 1.0, enabled: true, speeds: {}, displayKeyCode: 86, rememberSpeed: false, forceLastSavedSpeed: false, audioBoolean: false, startHidden: false, controllerOpacity: 0.3, keyBindings: [], blacklist: `\ www.instagram.com twitter.com vine.co imgur.com teams.microsoft.com `.replace(regStrip, ""), defaultLogLevel: 4, logLevel: 5, // Set to 5 to see your debug logs enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube subtitleNudgeInterval: 100, // Reduced from 25ms to 100ms (10x/sec instead of 40x/sec) subtitleNudgeAmount: 0.001 }, mediaElements: [], isNudging: false }; var MIN_SPEED = 0.0625; var MAX_SPEED = 16; 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 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 ) ]; } 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 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; return isValidSpeed(video.vsc.targetSpeed) ? video.vsc.targetSpeed : null; } function getRememberedSpeed(video) { var sourceKey = getVideoSourceKey(video); if (sourceKey !== "unknown_src") { var videoSpeed = tc.settings.speeds[sourceKey]; if (isValidSpeed(videoSpeed)) return videoSpeed; } if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) { return tc.settings.lastSpeed; } if (tc.settings.rememberSpeed && isValidSpeed(tc.settings.lastSpeed)) { return tc.settings.lastSpeed; } return null; } function getDesiredSpeed(video) { return getControllerTargetSpeed(video) || getRememberedSpeed(video) || 1.0; } function resolveTargetSpeed(video) { return getDesiredSpeed(video); } function extendSpeedRestoreWindow(video, duration) { if (!video || !video.vsc) return; var restoreDuration = Number(duration) || 1500; var restoreUntil = Date.now() + restoreDuration; var currentUntil = Number(video.vsc.speedRestoreUntil) || 0; video.vsc.speedRestoreUntil = Math.max(currentUntil, restoreUntil); } function scheduleSpeedRestore(video, desiredSpeed, reason) { if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return; if (video.vsc.restoreSpeedTimer) { clearTimeout(video.vsc.restoreSpeedTimer); } video.vsc.restoreSpeedTimer = setTimeout(function () { if (!video.vsc) return; if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) { log( `Restoring playbackRate to ${desiredSpeed.toFixed(2)} after ${reason}`, 4 ); setSpeed(video, desiredSpeed, false, false); } if (video.vsc) { video.vsc.restoreSpeedTimer = null; } }, 0); } function rememberPendingRateChange(video, speed) { if (!video || !video.vsc || !isValidSpeed(speed)) return; video.vsc.pendingRateChange = { speed: Number(speed), expiresAt: Date.now() + 1000 }; } function takePendingRateChange(video, currentSpeed) { if (!video || !video.vsc || !video.vsc.pendingRateChange) return null; var pendingRateChange = video.vsc.pendingRateChange; if ( !isValidSpeed(pendingRateChange.speed) || pendingRateChange.expiresAt <= Date.now() ) { video.vsc.pendingRateChange = null; return null; } if (Math.abs(Number(pendingRateChange.speed) - currentSpeed) > 0.01) { return null; } video.vsc.pendingRateChange = null; return pendingRateChange; } function matchesKeyBinding(binding, event) { if (!binding || binding.disabled) return false; var normalizedEventKey = normalizeBindingKey(event.key); if (binding.key && normalizedEventKey) { return binding.key === normalizedEventKey; } if (binding.code && event.code) { return binding.code === event.code; } var legacyKeyCode = getLegacyKeyCode(binding); return Number.isInteger(legacyKeyCode) && legacyKeyCode === event.keyCode; } function mediaSelector() { return tc.settings.audioBoolean ? "video,audio" : "video"; } function isMediaElement(node) { return ( node && node.nodeType === Node.ELEMENT_NODE && (node.nodeName === "VIDEO" || (node.nodeName === "AUDIO" && tc.settings.audioBoolean)) ); } function hasUsableMediaSource(node) { if (!isMediaElement(node) || !node.isConnected) return false; if (node.currentSrc || node.src || node.srcObject) return true; if (typeof node.readyState === "number" && node.readyState > 0) return true; if ( typeof node.networkState === "number" && typeof HTMLMediaElement !== "undefined" && (node.networkState === HTMLMediaElement.NETWORK_IDLE || node.networkState === HTMLMediaElement.NETWORK_LOADING) ) { return true; } if (node.querySelectorAll) { return Array.from(node.querySelectorAll("source[src]")).some(function ( source ) { var src = source.getAttribute("src"); return typeof src === "string" && src.trim().length > 0; }); } return false; } function ensureController(node, parent) { if (!isMediaElement(node) || node.vsc) return node && node.vsc; if (!hasUsableMediaSource(node)) { log( `Deferring controller creation for ${node.tagName}: no usable source yet`, 5 ); return null; } log( `Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`, 4 ); node.vsc = new tc.videoController( node, parent || node.parentElement || node.parentNode ); return node.vsc; } function removeController(node) { if (node && node.vsc) node.vsc.remove(); } function scanNodeForMedia(node, parent, added) { if (!node || typeof node === "function") return; if (node.nodeType === Node.DOCUMENT_NODE) { scanNodeForMedia(node.body || node.documentElement, node.body, added); return; } if ( node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE ) { return; } var ownerDocument = node.ownerDocument || document; if (!added && ownerDocument.body && ownerDocument.body.contains(node)) return; if (isMediaElement(node)) { if (added) ensureController(node, parent); else removeController(node); } if (node.children) { Array.from(node.children).forEach(function (child) { scanNodeForMedia(child, child.parentNode || parent, added); }); } if (node.shadowRoot) { observeRoot(node.shadowRoot); scanNodeForMedia(node.shadowRoot, node, added); } } function getScanNodeForRoot(root) { if (!root) return null; if (root.nodeType === Node.DOCUMENT_NODE) { return root.body || root.documentElement; } return root; } function scanRootForMedia(root) { var scanRoot = getScanNodeForRoot(root); if (!scanRoot) return; scanNodeForMedia(scanRoot, root.host || scanRoot.parentNode || scanRoot, true); if (root.nodeType === Node.DOCUMENT_NODE) { attachIframeListeners(root); } } function observeRoot(root) { if (!root || vscObservedRoots.has(root)) return; vscObservedRoots.add(root); setupListener(root); attachMutationObserver(root); attachMediaDetectionListeners(root); scanRootForMedia(root); } function patchAttachShadow() { if ( window.vscAttachShadowPatched || typeof Element === "undefined" || typeof Element.prototype.attachShadow !== "function" ) { return; } var originalAttachShadow = Element.prototype.attachShadow; Element.prototype.attachShadow = function () { var shadowRoot = originalAttachShadow.apply(this, arguments); try { if (shadowRoot) { observeRoot(shadowRoot); } } catch (error) { log(`Unable to observe shadow root: ${error.message}`, 3); } return shadowRoot; }; window.vscAttachShadowPatched = true; } /* Log levels */ function log(message, level) { verbosity = tc.settings.logLevel; if (typeof level === "undefined") level = tc.settings.defaultLogLevel; if (verbosity >= level) { let prefix = "[VSC] "; if (level === 2) console.log(prefix + "ERROR: " + message); else if (level === 3) console.log(prefix + "WARNING: " + message); else if (level === 4) console.log(prefix + "INFO: " + message); else if (level === 5) console.log(prefix + "DEBUG: " + message); else if (level === 6) { console.log(prefix + "DEBUG (VERBOSE): " + message); console.trace(); } } } chrome.storage.sync.get(tc.settings, function (storage) { var storedBindings = Array.isArray(storage.keyBindings) ? storage.keyBindings : []; tc.settings.keyBindings = storedBindings .map((binding) => normalizeStoredBinding(binding)) .filter(Boolean); if (tc.settings.keyBindings.length === 0) { tc.settings.keyBindings = defaultKeyBindings(storage); tc.settings.version = "0.5.3"; chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings, version: tc.settings.version, displayKeyCode: tc.settings.displayKeyCode, rememberSpeed: tc.settings.rememberSpeed, forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, audioBoolean: tc.settings.audioBoolean, startHidden: tc.settings.startHidden, enabled: tc.settings.enabled, controllerOpacity: tc.settings.controllerOpacity, blacklist: tc.settings.blacklist.replace(regStrip, "") }); } tc.settings.lastSpeed = Number(storage.lastSpeed); if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) { log(`Invalid lastSpeed detected: ${storage.lastSpeed}, resetting to 1.0`, 3); tc.settings.lastSpeed = 1.0; chrome.storage.sync.set({ lastSpeed: 1.0 }); } else if (!isValidSpeed(tc.settings.lastSpeed)) { tc.settings.lastSpeed = 1.0; } tc.settings.displayKeyCode = Number(storage.displayKeyCode); tc.settings.rememberSpeed = Boolean(storage.rememberSpeed); tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed); tc.settings.audioBoolean = Boolean(storage.audioBoolean); tc.settings.enabled = Boolean(storage.enabled); tc.settings.startHidden = Boolean(storage.startHidden); tc.settings.controllerOpacity = Number(storage.controllerOpacity); tc.settings.blacklist = String(storage.blacklist); tc.settings.enableSubtitleNudge = typeof storage.enableSubtitleNudge !== "undefined" ? Boolean(storage.enableSubtitleNudge) : tc.settings.enableSubtitleNudge; tc.settings.subtitleNudgeInterval = Number(storage.subtitleNudgeInterval) || 100; // Default 100ms for better performance tc.settings.subtitleNudgeAmount = Number(storage.subtitleNudgeAmount) || tc.settings.subtitleNudgeAmount; if ( tc.settings.keyBindings.filter((x) => x.action == "display").length == 0 ) { tc.settings.keyBindings.push( createDefaultBinding( "display", "V", Number(storage.displayKeyCode) || 86, 0 ) ); chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings }); } patchAttachShadow(); // Add a listener for messages from the popup. // We use a global flag to ensure the listener is only attached once. if (!window.vscMessageListener) { chrome.runtime.onMessage.addListener( function (request, sender, sendResponse) { // Check if the message is a request to re-scan the page. if (request.action === "rescan_page") { log("Re-scan command received from popup.", 4); initializeWhenReady(document, true); sendResponse({ status: "complete" }); } // Required to allow for asynchronous responses. return true; } ); // Set the flag to prevent adding the listener again. window.vscMessageListener = true; } initializeWhenReady(document); }); function getKeyBindings(action, what = "value") { try { return tc.settings.keyBindings.find((item) => item.action === action)[what]; } catch (e) { return false; } } function setKeyBindings(action, value) { tc.settings.keyBindings.find((item) => item.action === action)["value"] = value; } function defineVideoController() { tc.videoController = function (target, parent) { if (target.vsc) return target.vsc; tc.mediaElements.push(target); target.vsc = this; this.video = target; this.parent = target.parentElement || parent; this.nudgeAnimationId = null; this.restoreSpeedTimer = null; this.pendingRateChange = null; this.speedRestoreUntil = 0; log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || 'none'}`, 4); let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0); this.targetSpeed = storedSpeed; if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) { setKeyBindings("reset", getKeyBindings("fast")); } log("Explicitly setting playbackRate to: " + storedSpeed, 5); target.playbackRate = storedSpeed; this.div = this.initializeControls(); if (!this.div) { log("ERROR: Failed to create controller div!", 2); return; } log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, 4); var mediaEventAction = function (event) { if (event.type === "play") { extendSpeedRestoreWindow(event.target); if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) { setKeyBindings("reset", getKeyBindings("fast")); } var playSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0); if (Math.abs(event.target.playbackRate - playSpeed) > 0.01) { log("Play event: setting playbackRate to: " + playSpeed, 4); setSpeed(event.target, playSpeed, false, false); } else if (playSpeed === 1.0 || event.target.paused) { this.stopSubtitleNudge(); } else { this.startSubtitleNudge(); } } else if (event.type === "pause") { extendSpeedRestoreWindow(event.target); this.stopSubtitleNudge(); tc.isNudging = false; } else if (event.type === "seeking") { extendSpeedRestoreWindow(event.target); } else if (event.type === "ended") { this.speedRestoreUntil = 0; this.stopSubtitleNudge(); tc.isNudging = false; } else if (event.type === "seeked") { extendSpeedRestoreWindow(event.target); var expectedSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0); var currentSpeed = event.target.playbackRate; if ( Math.abs(currentSpeed - expectedSpeed) > 0.01 ) { log( `Seeked: speed changed from ${expectedSpeed} to ${currentSpeed}, restoring`, 4 ); setSpeed(event.target, expectedSpeed, false, false); } if (isUserSeek) { isUserSeek = false; } } }; target.addEventListener( "play", (this.handlePlay = mediaEventAction.bind(this)) ); target.addEventListener( "pause", (this.handlePause = mediaEventAction.bind(this)) ); target.addEventListener( "seeking", (this.handleSeeking = mediaEventAction.bind(this)) ); target.addEventListener( "ended", (this.handleEnded = mediaEventAction.bind(this)) ); target.addEventListener( "seeked", (this.handleSeek = mediaEventAction.bind(this)) ); var srcObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if ( mutation.type === "attributes" && (mutation.attributeName === "src" || mutation.attributeName === "currentSrc") ) { log("mutation of A/V element", 5); if (this.div) { this.stopSubtitleNudge(); if (!mutation.target.src && !mutation.target.currentSrc) { this.div.classList.add("vsc-nosource"); } else { this.div.classList.remove("vsc-nosource"); if (!mutation.target.paused) this.startSubtitleNudge(); } } } }); }); this.srcObserver = srcObserver; srcObserver.observe(target, { attributeFilter: ["src", "currentSrc"] }); if (!target.paused && target.playbackRate !== 1.0) this.startSubtitleNudge(); }; tc.videoController.prototype.remove = function () { this.stopSubtitleNudge(); if (this.div) this.div.remove(); if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer); if (this.video) { this.video.removeEventListener("play", this.handlePlay); this.video.removeEventListener("pause", this.handlePause); this.video.removeEventListener("seeking", this.handleSeeking); this.video.removeEventListener("ended", this.handleEnded); this.video.removeEventListener("seeked", this.handleSeek); delete this.video.vsc; } if (this.srcObserver) this.srcObserver.disconnect(); let idx = tc.mediaElements.indexOf(this.video); if (idx != -1) tc.mediaElements.splice(idx, 1); }; tc.videoController.prototype.startSubtitleNudge = function () { const isYouTube = (this.video && this.video.currentSrc && this.video.currentSrc.includes("googlevideo.com")) || location.hostname.includes("youtube.com"); if ( !isYouTube || !tc.settings.enableSubtitleNudge || this.nudgeAnimationId !== null || !this.video || this.video.paused || this.video.playbackRate === 1.0 ) { return; } // Store the target speed so we can always revert to it this.targetSpeed = this.video.playbackRate; const performNudge = () => { // Check if we should stop if (!this.video || this.video.paused || this.video.playbackRate === 1.0) { this.stopSubtitleNudge(); return; } // CRITICAL: Don't nudge if tab is hidden - prevents speed drift if (document.hidden) { this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval); return; } // Set flag to prevent ratechange listener from interfering tc.isNudging = true; // Cache values to avoid repeated property access const targetSpeed = this.targetSpeed; const nudgeAmount = tc.settings.subtitleNudgeAmount; // Apply nudge from the stored target speed (not current rate) this.video.playbackRate = targetSpeed + nudgeAmount; // Revert synchronously after a microtask to ensure it happens immediately Promise.resolve().then(() => { if (this.video && targetSpeed) { this.video.playbackRate = targetSpeed; } tc.isNudging = false; }); // Schedule next nudge this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval); }; // Start the first nudge this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval); log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5); }; tc.videoController.prototype.stopSubtitleNudge = function () { if (this.nudgeAnimationId !== null) { clearTimeout(this.nudgeAnimationId); this.nudgeAnimationId = null; log(`Nudge: Stopping.`, 5); } // Clear the target speed when stopping this.targetSpeed = null; }; tc.videoController.prototype.performImmediateNudge = function () { const isYouTube = (this.video && this.video.currentSrc && this.video.currentSrc.includes("googlevideo.com")) || location.hostname.includes("youtube.com"); if ( !isYouTube || !tc.settings.enableSubtitleNudge || !this.video || this.video.paused || this.video.playbackRate === 1.0 || document.hidden ) { return; } const targetRate = this.targetSpeed || this.video.playbackRate; const nudgeAmount = tc.settings.subtitleNudgeAmount; tc.isNudging = true; this.video.playbackRate = targetRate + nudgeAmount; // Revert synchronously via microtask Promise.resolve().then(() => { if (this.video) { this.video.playbackRate = targetRate; } tc.isNudging = false; }); log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5); }; tc.videoController.prototype.initializeControls = function () { const doc = this.video.ownerDocument; const speed = this.video.playbackRate.toFixed(2); var top = "0px", left = "0px"; var wrapper = doc.createElement("div"); wrapper.classList.add("vsc-controller"); if (!this.video.src && !this.video.currentSrc) wrapper.classList.add("vsc-nosource"); if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden"); var shadow = wrapper.attachShadow({ mode: "open" }); shadow.innerHTML = `