From c626aca89c007ec1b7c9653321e736fc53b31ebb Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 9 Apr 2026 16:17:27 -0400 Subject: [PATCH] Release v5.2.1 --- .vscode/settings.json | 3 - README.md | 6 +- importExport.js | 217 +-- inject.js | 812 ++++----- manifest.json | 6 +- options.css | 135 +- options.html | 64 +- options.js | 1033 ++++++------ package-lock.json | 2128 ++++++++++++++++++++++++ package.json | 12 + popup.html | 3 +- popup.js | 119 +- shared/controller-utils.js | 55 + shared/import-export.js | 124 ++ shared/key-bindings.js | 122 ++ shared/popup-controls.js | 85 + shared/site-rules.js | 69 + tests/helpers/browser.js | 164 ++ tests/helpers/extension-test-utils.js | 240 +++ tests/importExport.integration.test.js | 276 +++ tests/importExport.spec.js | 189 +++ tests/inject.spec.js | 141 ++ tests/inject.test.js | 90 + tests/lucide-client.spec.js | 61 + tests/options.integration.test.js | 194 +++ tests/options.spec.js | 230 +++ tests/popup.integration.test.js | 121 ++ tests/popup.spec.js | 173 ++ tests/setup.js | 25 + tests/shared.test.js | 153 ++ ui-icons.js | 7 + vitest.config.js | 12 + 32 files changed, 5749 insertions(+), 1320 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 shared/controller-utils.js create mode 100644 shared/import-export.js create mode 100644 shared/key-bindings.js create mode 100644 shared/popup-controls.js create mode 100644 shared/site-rules.js create mode 100644 tests/helpers/browser.js create mode 100644 tests/helpers/extension-test-utils.js create mode 100644 tests/importExport.integration.test.js create mode 100644 tests/importExport.spec.js create mode 100644 tests/inject.spec.js create mode 100644 tests/inject.test.js create mode 100644 tests/lucide-client.spec.js create mode 100644 tests/options.integration.test.js create mode 100644 tests/options.spec.js create mode 100644 tests/popup.integration.test.js create mode 100644 tests/popup.spec.js create mode 100644 tests/setup.js create mode 100644 tests/shared.test.js create mode 100644 vitest.config.js diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5480842..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "kiroAgent.configureMCP": "Disabled" -} \ No newline at end of file diff --git a/README.md b/README.md index a3f5c7d..a0559aa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Available for Firefox -[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org//firefox/addon/speeder/) +[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org/firefox/addon/speeder/) # The science of accelerated playback @@ -33,7 +33,7 @@ last point to listen to it a few more times. ![Player](https://cloud.githubusercontent.com/assets/2400185/24076745/5723e6ae-0c41-11e7-820c-1d8e814a2888.png) -#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/videospeed/) Extension* +#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/speeder/) Extension* \*\* Once the extension is installed simply navigate to any page that offers HTML5 video ([example](https://www.youtube.com/watch?v=E9FxNzv1Tr8)), and you'll @@ -56,7 +56,7 @@ shortcuts with different values, which will allow you to quickly toggle between your most commonly used speeds. To add a new shortcut, open extension settings and click "Add New". -![settings Add New shortcut](https://user-images.githubusercontent.com/121805/50726471-50242200-1172-11e9-902f-0e5958387617.jpg) +image Some sites may assign other functionality to one of the assigned shortcut keys — these collisions are inevitable, unfortunately. As a workaround, the extension diff --git a/importExport.js b/importExport.js index dff6cb4..19b9f4f 100644 --- a/importExport.js +++ b/importExport.js @@ -1,129 +1,35 @@ // Import/Export functionality for Video Speed Controller settings - -const EXPORTABLE_LOCAL_SETTINGS_KEYS = ["customButtonIcons"]; - -function getExportableLocalSettings(localStorage) { - const exportable = {}; - const customButtonIcons = - localStorage && - localStorage.customButtonIcons && - typeof localStorage.customButtonIcons === "object" && - !Array.isArray(localStorage.customButtonIcons) - ? localStorage.customButtonIcons - : null; - - if (customButtonIcons) { - exportable.customButtonIcons = customButtonIcons; - } - - return exportable; -} - -function replaceImportableLocalSettings(localSettings, callback) { - chrome.storage.local.remove(EXPORTABLE_LOCAL_SETTINGS_KEYS, function () { - if (chrome.runtime.lastError) { - showStatus( - "Error: Failed to clear local icon overrides - " + - chrome.runtime.lastError.message, - true - ); - return; - } - - if (!localSettings || Object.keys(localSettings).length === 0) { - callback(); - return; - } - - chrome.storage.local.set(localSettings, function () { - if (chrome.runtime.lastError) { - showStatus( - "Error: Failed to save local icon overrides - " + - chrome.runtime.lastError.message, - true - ); - return; - } - - callback(); - }); - }); -} +var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; +var importExportUtils = speederShared.importExport || {}; function generateBackupFilename() { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const hours = String(now.getHours()).padStart(2, "0"); - const minutes = String(now.getMinutes()).padStart(2, "0"); - const seconds = String(now.getSeconds()).padStart(2, "0"); - return `speeder-backup_${year}-${month}-${day}_${hours}.${minutes}.${seconds}.json`; -} - -function getBackupManifestVersion() { - var manifest = chrome.runtime.getManifest(); - return manifest && manifest.version ? manifest.version : "unknown"; -} - -function getExportableSyncSettings(syncStorage) { - return vscBuildStoredSettingsDiff(vscExpandStoredSettings(syncStorage)); -} - -function getImportableSyncSettings(backup, rawSettings) { - var importable = vscClonePlainData(rawSettings) || {}; - - if ( - backup && - backup.siteRulesFormat && - importable.siteRulesFormat === undefined - ) { - importable.siteRulesFormat = backup.siteRulesFormat; - } - - if ( - backup && - backup.siteRulesMeta && - importable.siteRulesMeta === undefined - ) { - importable.siteRulesMeta = backup.siteRulesMeta; - } - - return vscExpandStoredSettings(importable); + return importExportUtils.generateBackupFilename(new Date()); } function exportSettings() { chrome.storage.sync.get(null, function (storage) { - chrome.storage.local.get( - EXPORTABLE_LOCAL_SETTINGS_KEYS, - function (localStorage) { - const localSettings = getExportableLocalSettings(localStorage); - const syncSettings = getExportableSyncSettings(storage); - const backup = { - version: getBackupManifestVersion(), - exportDate: new Date().toISOString(), - settings: syncSettings - }; + chrome.storage.local.get(null, function (localStorage) { + const backup = importExportUtils.buildBackupPayload( + storage, + localStorage, + new Date() + ); - if (Object.keys(localSettings).length > 0) { - backup.localSettings = localSettings; - } + const dataStr = JSON.stringify(backup, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); - const dataStr = JSON.stringify(backup, null, 2); - const blob = new Blob([dataStr], { type: "application/json" }); - const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = generateBackupFilename(); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); - const link = document.createElement("a"); - link.href = url; - link.download = generateBackupFilename(); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - showStatus("Settings exported successfully"); - } - ); + showStatus("Settings exported successfully"); + }); }); } @@ -139,44 +45,75 @@ function importSettings() { const reader = new FileReader(); reader.onload = function (e) { try { - const backup = JSON.parse(e.target.result); - let settingsToImport = null; + const parsedBackup = importExportUtils.parseImportText(e.target.result); - // Detect backup format: check for 'settings' wrapper or raw storage keys - if (backup.settings && typeof backup.settings === "object") { - settingsToImport = getImportableSyncSettings(backup, backup.settings); - } else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) { - settingsToImport = getImportableSyncSettings(backup, backup); - } - - if (!settingsToImport) { + if (!parsedBackup) { showStatus("Error: Invalid backup file format", true); return; } - var localToImport = getExportableLocalSettings(backup.localSettings); + var settingsToImport = parsedBackup.settings; + var localToImport = parsedBackup.localSettings; - function afterLocalImport() { - persistManagedSyncSettings(settingsToImport, function (error) { - if (error) { + function importLocalSettings(callback) { + if (parsedBackup.isWrappedBackup !== true) { + callback(); + return; + } + + chrome.storage.local.clear(function () { + if (chrome.runtime.lastError) { showStatus( - "Error: Failed to save imported settings - " + error.message, + "Error: Failed to clear local extension data - " + + chrome.runtime.lastError.message, true ); return; } - showStatus("Settings imported successfully. Reloading..."); - setTimeout(function () { - if (typeof restore_options === "function") { - restore_options(); - } else { - location.reload(); - } - }, 500); + + if (localToImport && Object.keys(localToImport).length > 0) { + chrome.storage.local.set(localToImport, function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to save local extension data - " + + chrome.runtime.lastError.message, + true + ); + return; + } + callback(); + }); + return; + } + + callback(); }); } - replaceImportableLocalSettings(localToImport, afterLocalImport); + function afterLocalImport() { + chrome.storage.sync.clear(function () { + chrome.storage.sync.set(settingsToImport, function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to save imported settings - " + + chrome.runtime.lastError.message, + true + ); + return; + } + showStatus("Settings imported successfully. Reloading..."); + setTimeout(function () { + if (typeof restore_options === "function") { + restore_options(); + } else { + location.reload(); + } + }, 500); + }); + }); + } + + importLocalSettings(afterLocalImport); } catch (err) { showStatus("Error: Failed to parse backup file - " + err.message, true); } diff --git a/inject.js b/inject.js index 1e31adf..386042f 100644 --- a/inject.js +++ b/inject.js @@ -1,6 +1,10 @@ var isUserSeek = false; // Track if seek was user-initiated var lastToggleSpeed = {}; // Store last toggle speeds per video -var sharedSettingsDefaults = vscGetSettingsDefaults(); +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; @@ -13,37 +17,30 @@ function getPrimaryVideoElement() { var tc = { settings: { - lastSpeed: sharedSettingsDefaults.lastSpeed, - enabled: sharedSettingsDefaults.enabled, + lastSpeed: 1.0, + enabled: true, speeds: {}, - displayKeyCode: sharedSettingsDefaults.displayKeyCode, - rememberSpeed: sharedSettingsDefaults.rememberSpeed, - forceLastSavedSpeed: sharedSettingsDefaults.forceLastSavedSpeed, - audioBoolean: sharedSettingsDefaults.audioBoolean, - startHidden: sharedSettingsDefaults.startHidden, - hideWithYouTubeControls: sharedSettingsDefaults.hideWithYouTubeControls, - hideWithControls: sharedSettingsDefaults.hideWithControls, - hideWithControlsTimer: sharedSettingsDefaults.hideWithControlsTimer, - controllerLocation: sharedSettingsDefaults.controllerLocation, - controllerOpacity: sharedSettingsDefaults.controllerOpacity, - controllerMarginTop: sharedSettingsDefaults.controllerMarginTop, - controllerMarginRight: sharedSettingsDefaults.controllerMarginRight, - controllerMarginBottom: sharedSettingsDefaults.controllerMarginBottom, - controllerMarginLeft: sharedSettingsDefaults.controllerMarginLeft, - keyBindings: Array.isArray(sharedSettingsDefaults.keyBindings) - ? sharedSettingsDefaults.keyBindings.slice() - : [], - siteRules: Array.isArray(sharedSettingsDefaults.siteRules) - ? sharedSettingsDefaults.siteRules.slice() - : [], - controllerButtons: Array.isArray(sharedSettingsDefaults.controllerButtons) - ? sharedSettingsDefaults.controllerButtons.slice() - : ["rewind", "slower", "faster", "advance", "display"], + 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: sharedSettingsDefaults.enableSubtitleNudge, - subtitleNudgeInterval: sharedSettingsDefaults.subtitleNudgeInterval, - subtitleNudgeAmount: sharedSettingsDefaults.subtitleNudgeAmount, + enableSubtitleNudge: false, + subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost + subtitleNudgeAmount: 0.001, customButtonIcons: {} }, mediaElements: [], @@ -64,20 +61,23 @@ var vscObservedRoots = new WeakSet(); var requestIdle = typeof window.requestIdleCallback === "function" ? window.requestIdleCallback.bind(window) - : function (callback, options) { + : 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 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", @@ -123,76 +123,27 @@ var controllerLocationStyles = { /* `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: "" }, - settings: { label: "", className: "" }, - pause: { label: "", className: "" }, - muted: { label: "", className: "" }, - mark: { label: "", className: "" }, - jump: { label: "", className: "" } + 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: "" } }; -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) { +function createDefaultBinding(action, code, value) { return { action: action, - key: key, - keyCode: keyCode, - code: null, - disabled: false, + code: code, value: value, force: false, predefined: true @@ -203,89 +154,73 @@ function defaultKeyBindings(storage) { return [ createDefaultBinding( "slower", - "S", - Number(storage.slowerKeyCode) || 83, + "KeyS", Number(storage.speedStep) || 0.1 ), createDefaultBinding( "faster", - "D", - Number(storage.fasterKeyCode) || 68, + "KeyD", Number(storage.speedStep) || 0.1 ), createDefaultBinding( "rewind", - "Z", - Number(storage.rewindKeyCode) || 90, + "KeyZ", Number(storage.rewindTime) || 10 ), createDefaultBinding( "advance", - "X", - Number(storage.advanceKeyCode) || 88, + "KeyX", Number(storage.advanceTime) || 10 ), createDefaultBinding( "reset", - "R", - Number(storage.resetKeyCode) || 82, - 0 + "KeyR", + 1.0 ), createDefaultBinding( "fast", - "G", - Number(storage.fastKeyCode) || 71, + "KeyG", Number(storage.fastSpeed) || 1.8 ), createDefaultBinding( "move", - "P", - 80, + "KeyP", 0 ), createDefaultBinding( "toggleSubtitleNudge", - "N", - 78, + "KeyN", 0 ) ]; } -function ensureDefaultKeyBinding(action, key, keyCode, value) { +function ensureDefaultKeyBinding(action, code, value) { if (tc.settings.keyBindings.some((binding) => binding.action === action)) { return false; } tc.settings.keyBindings.push( - createDefaultBinding(action, key, keyCode, value) + createDefaultBinding(action, code, 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; + return keyBindingUtils.getLegacyKeyCode(binding); } function normalizeControllerLocation(location) { - if (controllerLocations.includes(location)) return location; - return defaultControllerLocation; + return controllerUtils.normalizeControllerLocation( + location, + 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)) - ); + return controllerUtils.clampControllerMarginPx(value, fallback); } function applyControllerMargins(controller) { @@ -324,9 +259,7 @@ function applyControllerMargins(controller) { } function getNextControllerLocation(location) { - var normalizedLocation = normalizeControllerLocation(location); - var currentIndex = controllerLocations.indexOf(normalizedLocation); - return controllerLocations[(currentIndex + 1) % controllerLocations.length]; + return controllerUtils.getNextControllerLocation(location); } function getControllerElement(videoOrController) { @@ -438,165 +371,6 @@ function applyControllerLocation(videoController, location) { ); } -function getYouTubeAutoHidePlayer(video) { - if (!video || !isOnYouTube()) return null; - - return video.closest(".html5-video-player") || video.closest("#movie_player"); -} - -function getAutoHideModeForVideo(video) { - if (!tc.settings.hideWithControls) return "off"; - return getYouTubeAutoHidePlayer(video) ? "youtube" : "generic"; -} - -function getControllerMountParent(video, parentHint) { - var parentEl = parentHint || (video && (video.parentElement || video.parentNode)); - if (!parentEl) return null; - - switch (true) { - case location.hostname == "www.amazon.com": - case location.hostname == "www.reddit.com": - case /hbogo\./.test(location.hostname): - return parentEl.parentElement || parentEl; - case location.hostname == "www.facebook.com": - var facebookParent = parentEl; - for ( - var depth = 0; - depth < 8 && facebookParent && facebookParent.parentElement; - depth++ - ) { - facebookParent = facebookParent.parentElement; - } - return facebookParent || parentEl; - case location.hostname == "tv.apple.com": - var root = parentEl.getRootNode(); - var scrim = root && root.querySelector ? root.querySelector(".scrim") : null; - return scrim || parentEl; - case location.hostname == "www.youtube.com": - case location.hostname == "m.youtube.com": - case location.hostname == "music.youtube.com": - return getYouTubeAutoHidePlayer(video) || parentEl; - default: - return parentEl; - } -} - -function getControllerBehaviorSignature(video) { - return JSON.stringify({ - startHidden: Boolean(tc.settings.startHidden), - hideWithControls: Boolean(tc.settings.hideWithControls), - hideWithControlsTimer: Number(tc.settings.hideWithControlsTimer), - controllerLocation: normalizeControllerLocation(tc.settings.controllerLocation), - controllerOpacity: Number(tc.settings.controllerOpacity), - controllerMarginTop: normalizeControllerMarginPx(tc.settings.controllerMarginTop, 0), - controllerMarginBottom: normalizeControllerMarginPx( - tc.settings.controllerMarginBottom, - 0 - ), - controllerButtons: Array.isArray(tc.settings.controllerButtons) - ? tc.settings.controllerButtons.slice() - : [], - enableSubtitleNudge: Boolean(tc.settings.enableSubtitleNudge), - subtitleNudgeInterval: Number(tc.settings.subtitleNudgeInterval), - autoHideMode: getAutoHideModeForVideo(video), - mediaTag: video && video.tagName ? video.tagName : "" - }); -} - -function rebuildControllerForVideo(video, parentHint, reason) { - if (!video) return null; - - var previous = video.vsc || null; - var preservedState = previous - ? { - mark: previous.mark, - resetToggleArmed: previous.resetToggleArmed === true, - subtitleNudgeEnabledOverride: previous.subtitleNudgeEnabledOverride, - userHidden: - Boolean(previous.div) && - previous.div.classList.contains("vsc-hidden") - } - : null; - - if (previous) { - previous.remove(); - } - - if (!video.isConnected || !hasUsableMediaSource(video)) { - return null; - } - - var nextController = new tc.videoController( - video, - parentHint || video.parentElement || video.parentNode - ); - if (!nextController) return null; - - if (preservedState) { - nextController.mark = preservedState.mark; - nextController.resetToggleArmed = preservedState.resetToggleArmed; - - if ( - typeof preservedState.subtitleNudgeEnabledOverride === "boolean" - ) { - nextController.subtitleNudgeEnabledOverride = - preservedState.subtitleNudgeEnabledOverride; - updateSubtitleNudgeIndicator(video); - if (!preservedState.subtitleNudgeEnabledOverride) { - nextController.stopSubtitleNudge(); - } else if (!video.paused && video.playbackRate !== 1.0) { - nextController.startSubtitleNudge(); - } - } - - if (preservedState.userHidden && nextController.div) { - nextController.div.classList.add("vsc-hidden"); - } - } - - log("Rebuilt controller: " + (reason || "refresh"), 4); - return nextController; -} - -function refreshManagedController(video, parentHint) { - if (!video || !video.vsc) return null; - if (!video.isConnected) { - removeController(video); - return null; - } - - var controller = video.vsc; - controller.parent = video.parentElement || parentHint || controller.parent; - - var expectedMountParent = getControllerMountParent(video, controller.parent); - var nextSignature = getControllerBehaviorSignature(video); - var wrapper = controller.div; - var needsRebuild = - !wrapper || - !wrapper.isConnected || - !expectedMountParent || - wrapper.parentNode !== expectedMountParent || - controller.behaviorSignature !== nextSignature; - - if (needsRebuild) { - return rebuildControllerForVideo( - video, - controller.parent, - "DOM/source/site-rule change" - ); - } - - controller.mountParent = expectedMountParent; - controller.behaviorSignature = nextSignature; - applyControllerLocation(controller, tc.settings.controllerLocation); - var controllerEl = getControllerElement(controller); - if (controllerEl) { - controllerEl.style.opacity = String(tc.settings.controllerOpacity); - } - updateSubtitleNudgeIndicator(video); - return controller; -} - function captureSiteRuleBase() { tc.siteRuleBase = { startHidden: tc.settings.startHidden, @@ -615,7 +389,7 @@ function captureSiteRuleBase() { ? tc.settings.controllerButtons.slice() : tc.settings.controllerButtons, keyBindings: Array.isArray(tc.settings.keyBindings) - ? tc.settings.keyBindings.map(function (binding) { + ? tc.settings.keyBindings.map(function(binding) { return Object.assign({}, binding); }) : tc.settings.keyBindings @@ -641,7 +415,7 @@ function resetSettingsFromSiteRuleBase() { ? base.controllerButtons.slice() : base.controllerButtons; tc.settings.keyBindings = Array.isArray(base.keyBindings) - ? base.keyBindings.map(function (binding) { + ? base.keyBindings.map(function(binding) { return Object.assign({}, binding); }) : base.keyBindings; @@ -705,44 +479,41 @@ function cycleControllerLocation(video) { } 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; + return keyBindingUtils.normalizeBindingKey(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 legacyBindingKeyToCode(key) { + return keyBindingUtils.legacyBindingKeyToCode(key); } -function normalizeStoredBinding(binding, fallbackKeyCode) { - var fallbackBinding = legacyKeyCodeToBinding(fallbackKeyCode); - if (!binding) return fallbackBinding; +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.key === null && - binding.keyCode === null && - binding.code === null) + (binding.code === null && + binding.key === null && + binding.keyCode === null) ) { return { action: binding.action, - key: null, - keyCode: null, code: null, disabled: true, value: Number(binding.value), @@ -751,46 +522,20 @@ function normalizeStoredBinding(binding, fallbackKeyCode) { }; } + var normalizedCode = inferBindingCode(binding, fallbackCode); + if (!normalizedCode) { + return null; + } + var normalized = { action: binding.action, - key: null, - keyCode: null, - code: - typeof binding.code === "string" && binding.code.length > 0 - ? binding.code - : null, + code: normalizedCode, 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; } @@ -907,19 +652,32 @@ function isSubtitleNudgeSupported(video) { return Boolean(video); } +function isSubtitleNudgeAvailableForVideo(video) { + return isSubtitleNudgeSupported(video) && Boolean(tc.settings.enableSubtitleNudge); +} + function isSubtitleNudgeEnabledForVideo(video) { - if (!video || !video.vsc) return tc.settings.enableSubtitleNudge; + if (!isSubtitleNudgeAvailableForVideo(video)) return false; + + if (!video || !video.vsc) return true; if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") { return video.vsc.subtitleNudgeEnabledOverride; } - return tc.settings.enableSubtitleNudge; + return true; } function setSubtitleNudgeEnabledForVideo(video, enabled) { if (!video || !video.vsc) return false; + if (!isSubtitleNudgeAvailableForVideo(video)) { + video.vsc.subtitleNudgeEnabledOverride = null; + video.vsc.stopSubtitleNudge(); + updateSubtitleNudgeIndicator(video); + return false; + } + var normalizedEnabled = Boolean(enabled); video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled; @@ -936,7 +694,7 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) { if (flashEl) { flashEl.classList.add("visible"); clearTimeout(flashEl._flashTimer); - flashEl._flashTimer = setTimeout(function () { + flashEl._flashTimer = setTimeout(function() { flashEl.classList.remove("visible"); }, 1500); } @@ -983,14 +741,19 @@ function renderSubtitleNudgeIndicatorContent(target, isEnabled) { function updateSubtitleNudgeIndicator(video) { if (!video || !video.vsc) return; + var isAvailable = isSubtitleNudgeAvailableForVideo(video); var isEnabled = isSubtitleNudgeEnabledForVideo(video); - var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled"; + var title = !isAvailable + ? "Subtitle nudge unavailable on this site" + : isEnabled + ? "Subtitle nudge enabled" + : "Subtitle nudge disabled"; var indicator = video.vsc.subtitleNudgeIndicator; if (indicator) { renderSubtitleNudgeIndicatorContent(indicator, isEnabled); indicator.dataset.enabled = isEnabled ? "true" : "false"; - indicator.dataset.supported = "true"; + indicator.dataset.supported = isAvailable ? "true" : "false"; indicator.title = title; indicator.setAttribute("aria-label", title); } @@ -999,7 +762,7 @@ function updateSubtitleNudgeIndicator(video) { if (flashEl) { renderSubtitleNudgeIndicatorContent(flashEl, isEnabled); flashEl.dataset.enabled = isEnabled ? "true" : "false"; - flashEl.dataset.supported = "true"; + flashEl.dataset.supported = isAvailable ? "true" : "false"; flashEl.setAttribute("aria-label", title); } } @@ -1010,7 +773,7 @@ function schedulePersistLastSpeed(speed) { tc.pendingLastSpeedValue = speed; if (tc.pendingLastSpeedSave !== null) return; - tc.pendingLastSpeedSave = setTimeout(function () { + tc.pendingLastSpeedSave = setTimeout(function() { var speedToPersist = tc.pendingLastSpeedValue; tc.pendingLastSpeedSave = null; @@ -1018,7 +781,8 @@ function schedulePersistLastSpeed(speed) { return; } - chrome.storage.sync.set({ lastSpeed: speedToPersist }, function () { }); + chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() { + }); tc.persistedLastSpeed = speedToPersist; }, 250); } @@ -1078,8 +842,8 @@ function applySourceTransitionPolicy(video, forceUpdate) { setSpeed(video, desiredSpeed, false, false); } - // Same-tab SPA or DOM-driven player swaps can change the effective rule output - // after the media source updates, so refresh or rebuild controllers here too. + // Same-tab SPA (e.g. YouTube watch → Shorts): URL can change while remember-speed + // already ran on src mutation — re-apply margins / location / opacity for new rules. reapplySiteRulesAndControllerGeometry(); } @@ -1100,7 +864,7 @@ function scheduleSpeedRestore(video, desiredSpeed, reason) { clearTimeout(video.vsc.restoreSpeedTimer); } - video.vsc.restoreSpeedTimer = setTimeout(function () { + video.vsc.restoreSpeedTimer = setTimeout(function() { if (!video.vsc) return; if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) { @@ -1147,19 +911,13 @@ function takePendingRateChange(video, currentSpeed) { } 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; + return Boolean( + binding && + binding.disabled !== true && + typeof binding.code === "string" && + binding.code.length > 0 && + binding.code === event.code + ); } function mediaSelector() { @@ -1189,7 +947,7 @@ function hasUsableMediaSource(node) { } if (node.querySelectorAll) { - return Array.from(node.querySelectorAll("source[src]")).some(function ( + return Array.from(node.querySelectorAll("source[src]")).some(function( source ) { var src = source.getAttribute("src"); @@ -1201,14 +959,8 @@ function hasUsableMediaSource(node) { } function ensureController(node, parent) { - if (!isMediaElement(node)) return node && node.vsc; - - if (!node.isConnected) { - removeController(node); - return null; - } - - if (!node.vsc && !hasUsableMediaSource(node)) { + if (!isMediaElement(node) || node.vsc) return node && node.vsc; + if (!hasUsableMediaSource(node)) { log( `Deferring controller creation for ${node.tagName}: no usable source yet`, 5 @@ -1219,13 +971,9 @@ function ensureController(node, parent) { // href selects site rules; re-run on every new/usable media so margins/opacity match current URL. var siteDisabled = applySiteRuleOverrides(); if (!tc.settings.enabled || siteDisabled) { - removeController(node); return null; } - - if (node.vsc) { - return refreshManagedController(node, parent); - } + refreshAllControllerGeometry(); log( `Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`, @@ -1338,7 +1086,7 @@ function patchAttachShadow() { } var originalAttachShadow = Element.prototype.attachShadow; - Element.prototype.attachShadow = function () { + Element.prototype.attachShadow = function() { var shadowRoot = originalAttachShadow.apply(this, arguments); try { if (shadowRoot) { @@ -1369,8 +1117,7 @@ function log(message, level) { } } -chrome.storage.sync.get(null, function (storage) { - storage = vscExpandStoredSettings(storage); +chrome.storage.sync.get(tc.settings, function(storage) { var storedBindings = Array.isArray(storage.keyBindings) ? storage.keyBindings : []; @@ -1381,6 +1128,18 @@ chrome.storage.sync.get(null, function (storage) { 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, + rememberSpeed: tc.settings.rememberSpeed, + forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, + audioBoolean: tc.settings.audioBoolean, + startHidden: tc.settings.startHidden, + enabled: tc.settings.enabled, + controllerLocation: tc.settings.controllerLocation, + controllerOpacity: tc.settings.controllerOpacity + }); } tc.settings.lastSpeed = Number(storage.lastSpeed); if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) { @@ -1392,7 +1151,6 @@ chrome.storage.sync.get(null, function (storage) { tc.settings.lastSpeed = 1.0; } tc.persistedLastSpeed = tc.settings.lastSpeed; - tc.settings.displayKeyCode = Number(storage.displayKeyCode); tc.settings.rememberSpeed = Boolean(storage.rememberSpeed); tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed); tc.settings.audioBoolean = Boolean(storage.audioBoolean); @@ -1447,25 +1205,17 @@ chrome.storage.sync.get(null, function (storage) { addedDefaultBinding = ensureDefaultKeyBinding( "display", - "V", - Number(storage.displayKeyCode) || 86, + "KeyV", 0 ) || addedDefaultBinding; addedDefaultBinding = - ensureDefaultKeyBinding("move", "P", 80, 0) || addedDefaultBinding; + ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding; addedDefaultBinding = - ensureDefaultKeyBinding("toggleSubtitleNudge", "N", 78, 0) || + ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) || addedDefaultBinding; if (addedDefaultBinding) { - var keyBindingsDiff = vscBuildStoredSettingsDiff({ - keyBindings: tc.settings.keyBindings - }); - if (Object.prototype.hasOwnProperty.call(keyBindingsDiff, "keyBindings")) { - chrome.storage.sync.set({ keyBindings: keyBindingsDiff.keyBindings }); - } else { - chrome.storage.sync.remove("keyBindings"); - } + chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings }); } captureSiteRuleBase(); patchAttachShadow(); @@ -1473,7 +1223,7 @@ chrome.storage.sync.get(null, function (storage) { // We use a global flag to ensure the listener is only attached once. if (!window.vscMessageListener) { chrome.runtime.onMessage.addListener( - function (request, sender, sendResponse) { + function(request, sender, sendResponse) { if (request.action === "rescan_page") { log("Re-scan command received from popup.", 4); initializeWhenReady(document, true); @@ -1514,7 +1264,7 @@ chrome.storage.sync.get(null, function (storage) { // Set the flag to prevent adding the listener again. window.vscMessageListener = true; } - chrome.storage.local.get(["customButtonIcons"], function (loc) { + chrome.storage.local.get(["customButtonIcons"], function(loc) { tc.settings.customButtonIcons = loc && loc.customButtonIcons && @@ -1524,18 +1274,18 @@ chrome.storage.sync.get(null, function (storage) { if (!window.vscCustomIconListener) { window.vscCustomIconListener = true; - chrome.storage.onChanged.addListener(function (changes, area) { + chrome.storage.onChanged.addListener(function(changes, area) { if (area !== "local" || !changes.customButtonIcons) return; var nv = changes.customButtonIcons.newValue; tc.settings.customButtonIcons = nv && typeof nv === "object" ? nv : {}; if (tc.mediaElements && tc.mediaElements.length) { - tc.mediaElements.forEach(function (video) { + tc.mediaElements.forEach(function(video) { if (!video.vsc || !video.vsc.div) return; var doc = video.ownerDocument; var shadow = video.vsc.div.shadowRoot; if (!shadow) return; - shadow.querySelectorAll("button[data-action]").forEach(function (btn) { + shadow.querySelectorAll("button[data-action]").forEach(function(btn) { var act = btn.dataset.action; if (!act) return; var svg = @@ -1580,6 +1330,7 @@ function getKeyBindings(action, what = "value") { return false; } } + function setKeyBindings(action, value) { tc.settings.keyBindings.find((item) => item.action === action)["value"] = value; @@ -1616,7 +1367,7 @@ function createControllerButton(doc, action, label, className) { } function defineVideoController() { - tc.videoController = function (target, parent) { + tc.videoController = function(target, parent) { if (target.vsc) return target.vsc; tc.mediaElements.push(target); target.vsc = this; @@ -1636,7 +1387,7 @@ function defineVideoController() { tc.settings.controllerLocation ); - log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || 'none'}`, 4); + 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; @@ -1656,16 +1407,9 @@ function defineVideoController() { return; } - this.mountParent = - this.div.parentNode || getControllerMountParent(target, this.parent); - this.behaviorSignature = getControllerBehaviorSignature(target); + log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4); - log( - `Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, - 4 - ); - - var mediaEventAction = function (event) { + var mediaEventAction = function(event) { if ( event.type === "loadedmetadata" || event.type === "loadeddata" || @@ -1783,7 +1527,7 @@ function defineVideoController() { this.startSubtitleNudge(); }; - tc.videoController.prototype.remove = function () { + tc.videoController.prototype.remove = function() { this.stopSubtitleNudge(); if (this.youTubeAutoHideObserver) { this.youTubeAutoHideObserver.disconnect(); @@ -1815,7 +1559,7 @@ function defineVideoController() { if (idx != -1) tc.mediaElements.splice(idx, 1); }; - tc.videoController.prototype.startSubtitleNudge = function () { + tc.videoController.prototype.startSubtitleNudge = function() { if ( !isSubtitleNudgeSupported(this.video) || !isSubtitleNudgeEnabledForVideo(this.video) || @@ -1888,7 +1632,7 @@ function defineVideoController() { log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5); }; - tc.videoController.prototype.stopSubtitleNudge = function () { + tc.videoController.prototype.stopSubtitleNudge = function() { if (this.nudgeAnimationId !== null) { clearTimeout(this.nudgeAnimationId); this.nudgeAnimationId = null; @@ -1906,7 +1650,7 @@ function defineVideoController() { // doesn't lose the user's intended speed if the site hijacks it. }; - tc.videoController.prototype.performImmediateNudge = function () { + tc.videoController.prototype.performImmediateNudge = function() { if ( !isSubtitleNudgeSupported(this.video) || !isSubtitleNudgeEnabledForVideo(this.video) || @@ -1936,11 +1680,11 @@ function defineVideoController() { log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5); }; - tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) { - if (!wrapper) return; + tc.videoController.prototype.setupYouTubeAutoHide = function(wrapper) { + if (!wrapper || !isOnYouTube()) return; const video = this.video; - const ytPlayer = getYouTubeAutoHidePlayer(video); + const ytPlayer = video.closest(".html5-video-player"); if (!ytPlayer) { log("YouTube player not found for auto-hide setup", 4); return; @@ -1952,7 +1696,7 @@ function defineVideoController() { // The vsc-hidden class (from V key) takes precedence via CSS specificity if (ytPlayer.classList.contains("ytp-autohide")) { wrapper.classList.add("ytp-autohide"); - + // Immediately end any temporary "vsc-show" state to hide with YouTube // UNLESS it was forced by a shortcut (vsc-forced-show) if (!wrapper.classList.contains("vsc-forced-show")) { @@ -1962,7 +1706,7 @@ function defineVideoController() { wrapper.showTimeOut = undefined; } } - + log("YouTube controls hidden, hiding controller", 5); } else { wrapper.classList.remove("ytp-autohide"); @@ -2012,7 +1756,7 @@ function defineVideoController() { }; }; - tc.videoController.prototype.setupGenericAutoHide = function (wrapper) { + tc.videoController.prototype.setupGenericAutoHide = function(wrapper) { if (!wrapper) return; const video = this.video; @@ -2071,7 +1815,7 @@ function defineVideoController() { log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4); }; - tc.videoController.prototype.initializeControls = function () { + tc.videoController.prototype.initializeControls = function() { const doc = this.video.ownerDocument; const speed = this.video.playbackRate.toFixed(2); var wrapper = doc.createElement("div"); @@ -2080,8 +1824,7 @@ function defineVideoController() { wrapper.classList.add("vsc-nosource"); if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden"); // Use lower z-index for non-YouTube sites to avoid overlapping modals - if (!getYouTubeAutoHidePlayer(this.video)) - wrapper.classList.add("vsc-non-youtube"); + if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube"); var shadow = wrapper.attachShadow({ mode: "open" }); var shadowStylesheet = doc.createElement("link"); shadowStylesheet.rel = "stylesheet"; @@ -2110,7 +1853,7 @@ function defineVideoController() { var subtitleNudgeIndicator = null; - buttonConfig.forEach(function (btnId) { + buttonConfig.forEach(function(btnId) { if (btnId === "nudge") { subtitleNudgeIndicator = doc.createElement("span"); subtitleNudgeIndicator.id = "nudge-indicator"; @@ -2142,20 +1885,22 @@ function defineVideoController() { this.subtitleNudgeIndicator = subtitleNudgeIndicator; this.nudgeFlashIndicator = nudgeFlashIndicator; this.resetButtonEl = - shadow.querySelector('button[data-action="reset"]') || null; + shadow.querySelector("button[data-action=\"reset\"]") || null; this.resetToggleArmed = false; if (subtitleNudgeIndicator) { updateSubtitleNudgeIndicator(this.video); } + function blurAfterPointerTap(target, e) { if (!target || typeof target.blur !== "function") return; var pt = e.pointerType; if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) { - requestAnimationFrame(function () { + requestAnimationFrame(function() { target.blur(); }); } } + dragHandle.addEventListener( "mousedown", (e) => { @@ -2197,13 +1942,13 @@ function defineVideoController() { // Setup auto-hide observers if enabled if (tc.settings.hideWithControls) { - if (getAutoHideModeForVideo(this.video) === "youtube") { + if (isOnYouTube()) { this.setupYouTubeAutoHide(wrapper); } else { this.setupGenericAutoHide(wrapper); } } - + var fragment = doc.createDocumentFragment(); fragment.appendChild(wrapper); const parentEl = this.parent || this.video.parentElement; @@ -2271,22 +2016,21 @@ function defineVideoController() { function applySiteRuleOverrides() { resetSettingsFromSiteRuleBase(); - tc.activeSiteRule = null; if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) { return false; } var currentUrl = location.href; - var matchedRule = vscMatchSiteRule(currentUrl, tc.settings.siteRules); + var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules); if (!matchedRule) return false; tc.activeSiteRule = matchedRule; - log("Matched site rule overrides for current URL", 4); + log(`Matched site rule: ${matchedRule.pattern}`, 4); // Check if extension should be enabled/disabled on this site - if (vscIsSiteRuleDisabled(matchedRule)) { + if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) { log(`Extension disabled for site: ${currentUrl}`, 4); return true; } @@ -2317,7 +2061,7 @@ function applySiteRuleOverrides() { [ "controllerMarginTop", "controllerMarginBottom" - ].forEach(function (key) { + ].forEach(function(key) { tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0); }); @@ -2346,22 +2090,23 @@ function applySiteRuleOverrides() { return false; } -/** Re-match site rules for current URL and refresh or rebuild every controller. */ +/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */ +function refreshAllControllerGeometry() { + tc.mediaElements.forEach(function(video) { + if (!video || !video.vsc) return; + applyControllerLocation(video.vsc, tc.settings.controllerLocation); + var controllerEl = getControllerElement(video.vsc); + if (controllerEl) { + controllerEl.style.opacity = String(tc.settings.controllerOpacity); + } + }); +} + +/** Re-match site rules for current URL and refresh controller position/opacity on every video. */ function reapplySiteRulesAndControllerGeometry() { var siteDisabled = applySiteRuleOverrides(); - var videos = tc.mediaElements.slice(); - - if (!tc.settings.enabled || siteDisabled) { - videos.forEach(function (video) { - removeController(video); - }); - return; - } - - videos.forEach(function (video) { - if (!video) return; - ensureController(video, video.parentElement || video.parentNode); - }); + if (!tc.settings.enabled || siteDisabled) return; + refreshAllControllerGeometry(); } function shouldPreserveDesiredSpeed(video, speed) { @@ -2381,6 +2126,7 @@ function shouldPreserveDesiredSpeed(video, speed) { function setupListener(root) { root = root || document; if (root.vscRateListenerAttached) return; + function updateSpeedFromEvent(video, skipResetDisarm) { if (!video.vsc || !video.vsc.speedIndicator) return; if (!skipResetDisarm) { @@ -2401,9 +2147,10 @@ function setupListener(root) { else video.vsc.startSubtitleNudge(); } } + root.addEventListener( "ratechange", - function (event) { + function(event) { if (tc.isNudging) return; var video = event.target; if (!video || typeof video.playbackRate === "undefined" || !video.vsc) @@ -2447,6 +2194,7 @@ function setupListener(root) { } var vscInitializedDocuments = new Set(); + function clearPendingInitialization(doc) { if (!doc || !doc.vscPendingInitializeHandler) return; @@ -2483,7 +2231,7 @@ function initializeWhenReady(doc, forceReinit = false) { if (doc.vscPendingInitializeHandler) return; - var pendingInitializeHandler = function () { + var pendingInitializeHandler = function() { tryInitializeDocument(doc, doc.vscPendingForceReinit === true); }; @@ -2498,6 +2246,7 @@ function initializeWhenReady(doc, forceReinit = false) { setTimeout(pendingInitializeHandler, 0); } } + function inIframe() { try { return window.self !== window.top; @@ -2510,13 +2259,14 @@ function attachKeydownListeners(doc) { var docs = [doc]; try { if (inIframe() && window.top.document !== doc) docs.push(window.top.document); - } catch (e) { } + } catch (e) { + } - docs.forEach(function (keyDoc) { + docs.forEach(function(keyDoc) { if (keyDoc.vscKeydownListenerAttached) return; keyDoc.addEventListener( "keydown", - function (event) { + function(event) { if ( !event.getModifierState || event.getModifierState("Alt") || @@ -2539,7 +2289,7 @@ function attachKeydownListeners(doc) { if (!tc.mediaElements.length) return; - var item = tc.settings.keyBindings.find(function (binding) { + var item = tc.settings.keyBindings.find(function(binding) { return matchesKeyBinding(binding, event); }); @@ -2563,24 +2313,24 @@ function attachMutationObserver(root) { var pendingMutations = []; var mutationProcessingScheduled = false; - var observer = new MutationObserver(function (mutations) { + var observer = new MutationObserver(function(mutations) { pendingMutations.push(...mutations); if (mutationProcessingScheduled) return; mutationProcessingScheduled = true; requestIdle( - function () { + function() { var mutationsToProcess = pendingMutations.splice(0); mutationProcessingScheduled = false; - mutationsToProcess.forEach(function (mutation) { + mutationsToProcess.forEach(function(mutation) { if (mutation.type === "childList") { - mutation.addedNodes.forEach(function (node) { + mutation.addedNodes.forEach(function(node) { // Skip text nodes, comments, etc. — only elements can contain media if (node.nodeType !== Node.ELEMENT_NODE) return; scanNodeForMedia(node, node.parentNode || mutation.target, true); }); - mutation.removedNodes.forEach(function (node) { + mutation.removedNodes.forEach(function(node) { if (node.nodeType !== Node.ELEMENT_NODE) return; scanNodeForMedia(node, node.parentNode || mutation.target, false); }); @@ -2629,7 +2379,7 @@ function attachMutationObserver(root) { function attachMediaDetectionListeners(root) { if (root.vscMediaEventListenersAttached) return; - var handleDetectedMedia = function (event) { + var handleDetectedMedia = function(event) { var target = event.target; if (!isMediaElement(target)) return; ensureController(target, target.parentElement || target.parentNode); @@ -2642,21 +2392,22 @@ function attachMediaDetectionListeners(root) { "canplay", "playing", "play" - ].forEach(function (eventName) { + ].forEach(function(eventName) { root.addEventListener(eventName, handleDetectedMedia, true); }); root.vscMediaEventListenersAttached = true; } function attachIframeListeners(doc) { - Array.from(doc.getElementsByTagName("iframe")).forEach(function (frame) { + Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) { if (!frame.vscLoadListenerAttached) { - frame.addEventListener("load", function () { + frame.addEventListener("load", function() { try { if (frame.contentDocument) { initializeWhenReady(frame.contentDocument, true); } - } catch (e) { } + } catch (e) { + } }); frame.vscLoadListenerAttached = true; } @@ -2665,24 +2416,25 @@ function attachIframeListeners(doc) { if (frame.contentDocument) { initializeWhenReady(frame.contentDocument); } - } catch (e) { } + } catch (e) { + } }); } function attachNavigationListeners() { if (window.vscNavigationListenersAttached) return; - var scheduleRescan = function () { + var scheduleRescan = function() { clearTimeout(window.vscNavigationRescanTimer); - window.vscNavigationRescanTimer = setTimeout(function () { + window.vscNavigationRescanTimer = setTimeout(function() { initializeWhenReady(document, true); }, 300); }; - ["pushState", "replaceState"].forEach(function (method) { + ["pushState", "replaceState"].forEach(function(method) { if (typeof history[method] !== "function") return; var original = history[method]; - history[method] = function () { + history[method] = function() { var result = original.apply(this, arguments); scheduleRescan(); return result; @@ -2702,6 +2454,7 @@ function initializeNow(doc, forceReinit = false) { if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return; var siteDisabled = applySiteRuleOverrides(); + if (!tc.settings.enabled || siteDisabled) return; if (!doc.body.classList.contains("vsc-initialized")) { doc.body.classList.add("vsc-initialized"); @@ -2713,9 +2466,7 @@ function initializeNow(doc, forceReinit = false) { if (forceReinit) { log("Force re-initialization requested", 4); - reapplySiteRulesAndControllerGeometry(); - } else if (!tc.settings.enabled || siteDisabled) { - reapplySiteRulesAndControllerGeometry(); + refreshAllControllerGeometry(); } vscInitializedDocuments.add(doc); @@ -2830,7 +2581,7 @@ function runAction(action, value, e) { ); } - mediaTagsToProcess.forEach(function (v) { + mediaTagsToProcess.forEach(function(v) { if (!v.vsc) return; // Don't process videos without a controller var controller = v.vsc.div; const userDrivenActionsThatShowController = [ @@ -2838,6 +2589,8 @@ function runAction(action, value, e) { "advance", "faster", "slower", + "louder", + "softer", "reset", "fast", "move", @@ -2850,7 +2603,14 @@ function runAction(action, value, e) { "toggleSubtitleNudge", "display" ]; - if (userDrivenActionsThatShowController.includes(action) && action !== "display") { + var subtitleNudgeActionBlocked = + (action === "toggleSubtitleNudge" || action === "nudge") && + !isSubtitleNudgeAvailableForVideo(v); + if ( + userDrivenActionsThatShowController.includes(action) && + action !== "display" && + !subtitleNudgeActionBlocked + ) { showController(controller, 2000, true); } if (v.classList.contains("vsc-cancelled")) return; @@ -2953,6 +2713,12 @@ function runAction(action, value, e) { case "muted": muted(v); break; + case "louder": + volumeUp(v, Number.isFinite(numValue) ? numValue : 0.1); + break; + case "softer": + volumeDown(v, Number.isFinite(numValue) ? numValue : 0.1); + break; case "mark": setMark(v); break; @@ -3017,17 +2783,62 @@ function resetSpeed(v, target, isFastKey = false) { } function muted(v) { - v.muted = !v.muted; + var nextMuted = !v.muted; + v.muted = nextMuted; + if (!isOnYouTube()) return; + var ytApi = getYouTubePlayerApi(v); + if (!ytApi) return; + if (nextMuted && typeof ytApi.mute === "function") ytApi.mute(); + if (!nextMuted && typeof ytApi.unMute === "function") ytApi.unMute(); } + +function getYouTubePlayerApi(video) { + if (!isOnYouTube()) return null; + var playerEl = + (video && video.closest ? video.closest(".html5-video-player") : null) || + document.getElementById("movie_player") || + document.querySelector(".html5-video-player"); + if (!playerEl) return null; + return playerEl.wrappedJSObject || playerEl; +} + +function syncYouTubePlayerVolume(video, volume) { + var ytApi = getYouTubePlayerApi(video); + if (!ytApi || typeof ytApi.setVolume !== "function") return; + ytApi.setVolume(Math.round(volume * 100)); + if (volume > 0 && typeof ytApi.unMute === "function") { + ytApi.unMute(); + } +} + +function setVideoVolume(video, targetVolume) { + var nextVolume = Math.max(0, Math.min(1, Number(targetVolume.toFixed(2)))); + video.volume = nextVolume; + if (nextVolume > 0 && video.muted) { + video.muted = false; + } + syncYouTubePlayerVolume(video, nextVolume); +} + +function volumeUp(v, value) { + setVideoVolume(v, v.volume + value); +} + +function volumeDown(v, value) { + setVideoVolume(v, v.volume - value); +} + function setMark(v) { v.vsc.mark = v.currentTime; } + function jumpToMark(v) { if (v.vsc && typeof v.vsc.mark === "number") { extendSpeedRestoreWindow(v); v.currentTime = v.vsc.mark; } } + function handleDrag(video, e) { const c = video.vsc.div; const sC = convertControllerToManualPosition(video.vsc); @@ -3037,7 +2848,7 @@ function handleDrag(video, e) { pE.parentNode && pE.parentNode.offsetHeight === pE.offsetHeight && pE.parentNode.offsetWidth === pE.offsetWidth - ) + ) pE = pE.parentNode; video.classList.add("vcs-dragging"); sC.classList.add("dragging"); @@ -3066,6 +2877,7 @@ function handleDrag(video, e) { pE.addEventListener("mouseleave", eD); pE.addEventListener("mousemove", sD); } + function showController(controller, duration = 2000, forced = false) { if (!controller || typeof controller.classList === "undefined") return; var restoreHidden = @@ -3083,7 +2895,7 @@ function showController(controller, duration = 2000, forced = false) { clearTimeout(controller.showTimeOut); } - controller.showTimeOut = setTimeout(function () { + controller.showTimeOut = setTimeout(function() { controller.classList.remove("vsc-show"); controller.classList.remove("vsc-forced-show"); if (controller.restoreHiddenAfterShow === true) { diff --git a/manifest.json b/manifest.json index fc70a61..5307d46 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Speeder", "short_name": "Speeder", - "version": "5.1.7", + "version": "5.2.1", "manifest_version": 2, "description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")", "homepage_url": "https://github.com/SoPat712/speeder", @@ -59,7 +59,9 @@ "inject.css" ], "js": [ - "settings-core.js", + "shared/controller-utils.js", + "shared/key-bindings.js", + "shared/site-rules.js", "ui-icons.js", "inject.js" ] diff --git a/options.css b/options.css index 9119cb5..e4c16a9 100644 --- a/options.css +++ b/options.css @@ -7,6 +7,17 @@ --text: #17191c; --muted: #626b76; --accent: #111827; + --switch-track-off: #c1cad6; + --switch-track-off-border: #aeb8c5; + --switch-track-on: #111827; + --switch-track-on-border: #111827; + --switch-thumb-off: #ffffff; + --switch-thumb-on: #ffffff; + --toggle-open-fg: #111827; + --toggle-open-bg: #eef2f6; + --toggle-open-border: #c5ccd5; + --toggle-open-hover-bg: #e4eaf1; + --toggle-open-hover-border: #b5c0cc; --danger: #b42318; } @@ -210,6 +221,7 @@ button:active { } button:focus-visible, +input[type="checkbox"]:focus-visible, input[type="text"]:focus, select:focus, textarea:focus { @@ -247,10 +259,49 @@ textarea:focus { } input[type="checkbox"] { - width: 16px; - height: 16px; - margin: 2px 0 0; - accent-color: var(--accent); + appearance: none; + -webkit-appearance: none; + position: relative; + width: 46px; + min-width: 46px; + height: 28px; + margin: 0; + border: 1px solid var(--switch-track-off-border); + border-radius: 999px; + background: var(--switch-track-off); + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease, + box-shadow 120ms ease; + flex-shrink: 0; +} + +input[type="checkbox"]::before { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--switch-thumb-off); + box-shadow: 0 1px 2px rgba(17, 24, 39, 0.18), + inset 0 0 0 1px rgba(17, 24, 39, 0.08); + transition: transform 120ms ease, background-color 120ms ease; +} + +input[type="checkbox"]:checked { + background: var(--switch-track-on); + border-color: var(--switch-track-on-border); +} + +input[type="checkbox"]:checked::before { + transform: translateX(18px); + background: var(--switch-thumb-on); +} + +input[type="checkbox"]:disabled { + cursor: default; + opacity: 0.7; } label { @@ -322,6 +373,39 @@ label em { .toggle-site-rule { font-weight: 400; + color: var(--muted); +} + +.toggle-site-rule:hover { + color: var(--toggle-open-fg); + background: var(--toggle-open-hover-bg); + border-color: var(--toggle-open-hover-border); +} + +.toggle-site-rule .site-rule-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + pointer-events: none; +} + +.toggle-site-rule .site-rule-toggle-icon svg { + width: 18px; + height: 18px; +} + +.site-rule:not(.collapsed) .toggle-site-rule { + color: var(--toggle-open-fg); + background: var(--toggle-open-bg); + border-color: var(--toggle-open-border); +} + +.site-rule:not(.collapsed) .toggle-site-rule:hover { + color: var(--toggle-open-fg); + background: var(--toggle-open-hover-bg); + border-color: var(--toggle-open-hover-border); } .row { @@ -339,7 +423,7 @@ label em { } .row.row-checkbox { - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; } .row.row-checkbox input[type="checkbox"] { @@ -403,9 +487,9 @@ label em { .site-override-lead { display: grid; - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; - align-items: flex-start; + align-items: center; font-weight: 600; margin-bottom: 8px; cursor: pointer; @@ -414,7 +498,6 @@ label em { .site-override-lead input[type="checkbox"] { justify-self: end; - margin-top: 3px; } .site-override-lead span { @@ -427,10 +510,19 @@ label em { .site-rule-override-section .site-autohide-container, .site-rule-override-section .site-playback-container, .site-rule-override-section .site-opacity-container, -.site-rule-override-section .site-subtitleNudge-container { +.site-rule-override-section .site-subtitleNudge-container, +.site-controlbar-container, +.site-popup-controlbar-container, +.site-shortcuts-container { padding-left: 4px; } +.site-override-disabled { + opacity: 0.48; + pointer-events: none; + user-select: none; +} + .cb-editor { display: flex; flex-direction: column; @@ -803,7 +895,7 @@ button.lucide-result-tile.lucide-picked { } .site-rule-option-checkbox { - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; } .site-rule-option-checkbox > input[type="checkbox"] { @@ -833,7 +925,7 @@ button.lucide-result-tile.lucide-picked { .site-rule-split-label { display: grid; - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: flex-start; width: 100%; @@ -845,7 +937,7 @@ button.lucide-result-tile.lucide-picked { .site-rule-split-label input[type="checkbox"] { justify-self: end; - margin-top: 3px; + margin-top: 0; } .site-rule-option-checkbox > .site-rule-split-label { @@ -889,8 +981,8 @@ button.lucide-result-tile.lucide-picked { .force-label { display: flex; - align-items: flex-start; - gap: 8px; + align-items: center; + gap: 10px; width: auto; margin: 0; color: var(--muted); @@ -898,7 +990,7 @@ button.lucide-result-tile.lucide-picked { } .force-label input { - margin-top: 2px; + margin-top: 0; } .action-row { @@ -956,7 +1048,7 @@ button.lucide-result-tile.lucide-picked { } .site-override-lead { - grid-template-columns: minmax(0, 1fr) 24px; + grid-template-columns: minmax(0, 1fr) auto; } .action-row button, @@ -1005,6 +1097,17 @@ button.lucide-result-tile.lucide-picked { --text: #f2f4f6; --muted: #a0a8b2; --accent: #f2f4f6; + --switch-track-off: #374151; + --switch-track-off-border: #4b5563; + --switch-track-on: #aab7c6; + --switch-track-on-border: #aab7c6; + --switch-thumb-off: #f8fafc; + --switch-thumb-on: #111315; + --toggle-open-fg: #f2f4f6; + --toggle-open-bg: #2b3138; + --toggle-open-border: #4b5563; + --toggle-open-hover-bg: #374151; + --toggle-open-hover-border: #64748b; --danger: #ff8a80; } diff --git a/options.html b/options.html index 4152b0d..189fddf 100644 --- a/options.html +++ b/options.html @@ -5,10 +5,13 @@ Speeder Settings - + + + + @@ -480,7 +483,15 @@