diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 699f867..c5209c8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,18 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install deps + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install project dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Install web-ext run: npm install -g web-ext - name: Lint diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..4e873ee --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,34 @@ +# Implementation Plan + +Constraints: local commits only; no push; no browser testing; one commit per feature. + +## Controller and targeting + +- [x] Keep the controller visible for its own video in element and ancestor fullscreen. +- [x] Target popup actions at the frame represented by the displayed speed; keep “all videos” intentional. +- [x] Ignore shortcuts originating from editable controls, including shadow-DOM inputs. + +## Accessibility and usability + +- [x] Give in-player controls accessible names, keyboard behavior, and visible focus. +- [x] Make control-bar customization operable by keyboard as well as drag and drop. +- [x] Label generated shortcut and site-rule form controls. +- [x] Improve popup status announcements, focus indicators, and icon-search semantics. + +## Settings safety and validation + +- [x] Confirm before Restore Defaults removes preferences and remembered data. +- [x] Report partial imports accurately when custom icons cannot be restored. +- [x] Reject malformed slash-prefixed regular expressions before saving site rules. + +## Extension lifecycle and copy + +- [x] Initialize and synchronize the disabled toolbar icon from background state. +- [x] Correct shortcut, subtitle-nudge, live-update, and obsolete troubleshooting copy. +- [x] Run automated tests in the release workflow before packaging. + +## Verification + +- [x] Run focused automated checks after each non-trivial change. +- [x] Run the complete non-browser test suite and review the final local commit series. +- [x] Leave cross-site fullscreen visual verification for reporter/user validation. diff --git a/README.md b/README.md index 5c197bb..badd7c7 100644 --- a/README.md +++ b/README.md @@ -56,16 +56,14 @@ settings page, as well as add additional shortcut keys to match your preferences. For example, you can assign multiple different "preferred speed" 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". +and choose an action from "Add shortcut…". image -Some sites may assign other functionality to one of the assigned shortcut keys — -these collisions are inevitable, unfortunately. As a workaround, the extension -listens both for lower and upper case values (i.e. you can use -`Shift-`) if there is other functionality assigned to the lowercase -key. This is not a perfect solution, as some sites may listen to both, but works -most of the time. +Some sites may assign other functionality to one of the assigned shortcut keys. +You can record `Shift+` as a separate, exact binding, or use a site +rule to block the site from capturing a particular Speeder shortcut. Shift is +not applied automatically to an unshifted binding. ## Development @@ -81,12 +79,10 @@ npx --yes web-ext lint --source-dir extension ### The video controls are not showing up? -This extension is only compatible -with HTML5 video. If you don't see the controls showing up, chances are you are -viewing a Flash video. If you want to confirm, try right-clicking on the video -and inspect the menu: if it mentions flash, then that's the issue. That said, -most sites will fallback to HTML5 if they detect that Flash is not available. -You can try manually disabling Flash from the browser. +Speeder works with HTML5 video and, when enabled in settings, HTML5 audio. Check +that Speeder is enabled for the current site, then use the popup's "Rescan page +for videos" action after a player loads dynamically. Browser-internal pages and +players that do not expose HTML5 media to extensions cannot be controlled. ### What is this fork all about? diff --git a/extension/background/background.js b/extension/background/background.js index 7e64d08..845527c 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -1,3 +1,24 @@ +function setToolbarIcon(enabled) { + var suffix = enabled === false ? "_disabled" : ""; + chrome.browserAction.setIcon({ + path: { + 19: "assets/icons/icon19" + suffix + ".png", + 38: "assets/icons/icon38" + suffix + ".png", + 48: "assets/icons/icon48" + suffix + ".png" + } + }); +} + +chrome.storage.sync.get(["enabled"], function(storage) { + if (!chrome.runtime.lastError) setToolbarIcon(storage.enabled !== false); +}); + +chrome.storage.onChanged.addListener(function(changes, areaName) { + if (areaName === "sync" && changes.enabled) { + setToolbarIcon(changes.enabled.newValue !== false); + } +}); + chrome.runtime.onMessage.addListener(function (request) { if (request.action === "openOptions") { chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") }); diff --git a/extension/content/frame-speed-snapshot.js b/extension/content/frame-speed-snapshot.js index fe765e6..f08bf36 100644 --- a/extension/content/frame-speed-snapshot.js +++ b/extension/content/frame-speed-snapshot.js @@ -8,6 +8,10 @@ if (!v) return null; return { speed: v.playbackRate, + frameToken: + typeof tc === "object" && typeof tc.frameToken === "string" + ? tc.frameToken + : null, preferred: !v.paused, forceLastSavedSpeed: Boolean( typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed diff --git a/extension/content/inject.js b/extension/content/inject.js index 5a32986..77c9e9f 100644 --- a/extension/content/inject.js +++ b/extension/content/inject.js @@ -156,7 +156,11 @@ var tc = { pendingMediaCandidates: [], settingsReloadRetries: 0, lastPointerPosition: null, - lastInteractedMedia: null + lastInteractedMedia: null, + frameToken: + window.crypto && typeof window.crypto.randomUUID === "function" + ? window.crypto.randomUUID() + : String(Date.now()) + "-" + Math.random().toString(36).slice(2) }; var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.1; @@ -235,21 +239,25 @@ 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: "" }, - nudge: { label: "", className: "" }, - pause: { label: "", className: "" }, - muted: { label: "", className: "" }, - louder: { label: "", className: "" }, - softer: { label: "", className: "" }, - mark: { label: "", className: "" }, - jump: { label: "", className: "" }, - settings: { label: "", className: "" } + rewind: { label: "", name: "Rewind", className: "rw" }, + slower: { label: "", name: "Decrease speed", className: "" }, + faster: { label: "", name: "Increase speed", className: "" }, + advance: { label: "", name: "Advance", className: "rw" }, + display: { + label: "", + name: "Show or hide controller", + className: "hideButton" + }, + reset: { label: "\u21BB", name: "Reset speed", className: "" }, + fast: { label: "", name: "Toggle preferred speed", className: "" }, + nudge: { label: "", name: "Toggle subtitle nudge", className: "" }, + pause: { label: "", name: "Play or pause", className: "" }, + muted: { label: "", name: "Mute or unmute", className: "" }, + louder: { label: "", name: "Increase volume", className: "" }, + softer: { label: "", name: "Decrease volume", className: "" }, + mark: { label: "", name: "Mark position", className: "" }, + jump: { label: "", name: "Jump to marked position", className: "" }, + settings: { label: "", name: "Open Speeder settings", className: "" } }; function createDefaultBinding(action, code, value) { @@ -2335,6 +2343,7 @@ function loadInitialRuntimeSettings(attempt) { if (!videoGs) return false; sendResponse({ speed: videoGs.playbackRate, + frameToken: tc.frameToken, forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true, forceLastSavedSpeedControlledBySiteRule: Boolean( tc.activeSiteRule && @@ -2371,6 +2380,12 @@ function loadInitialRuntimeSettings(attempt) { return false; } if (request.action === "run_action") { + if ( + request.targetFrameToken && + request.targetFrameToken !== tc.frameToken + ) { + return false; + } if ( !siteRuleUtils.isSpeederActiveForSite( tc.settings.enabled, @@ -2590,7 +2605,11 @@ function setKeyBindings(action, value) { function createControllerButton(doc, action, label, className) { var button = doc.createElement("button"); + var name = controllerButtonDefs[action] && controllerButtonDefs[action].name; + button.type = "button"; button.dataset.action = action; + button.setAttribute("aria-label", name || action); + button.title = name || action; var custom = tc.settings.customButtonIcons && tc.settings.customButtonIcons[action] && @@ -3196,12 +3215,13 @@ function syncControllerFullscreenMount(videoController) { var doc = video.ownerDocument; var fullscreenElement = getFullscreenElement(doc); var targetMount = videoController.normalControllerMount; - - if ( + var ownsFullscreen = Boolean( fullscreenElement && - (fullscreenElement === video || - isComposedDescendant(video, fullscreenElement)) - ) { + (fullscreenElement === video || + isComposedDescendant(video, fullscreenElement)) + ); + + if (ownsFullscreen) { targetMount = getControllerMount(video, fullscreenElement); } else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) { targetMount = getControllerMount(video); @@ -3210,7 +3230,7 @@ function syncControllerFullscreenMount(videoController) { if (!targetMount) return false; - if (fullscreenElement) { + if (ownsFullscreen) { // Fullscreen elements and popovers both participate in the browser's top // layer. Showing Speeder's host after the player enters fullscreen keeps it // above provider-owned surfaces even when the provider clips descendants or @@ -3861,11 +3881,14 @@ function defineVideoController() { buttonConfig.forEach(function(btnId) { if (btnId === "nudge") { - subtitleNudgeIndicator = doc.createElement("span"); + subtitleNudgeIndicator = createControllerButton( + doc, + btnId, + controllerButtonDefs.nudge.label, + controllerButtonDefs.nudge.className + ); subtitleNudgeIndicator.id = "nudge-indicator"; - subtitleNudgeIndicator.setAttribute("role", "button"); subtitleNudgeIndicator.setAttribute("aria-live", "polite"); - subtitleNudgeIndicator.setAttribute("tabindex", "0"); controls.appendChild(subtitleNudgeIndicator); } else { var def = controllerButtonDefs[btnId]; @@ -3949,21 +3972,6 @@ function defineVideoController() { true ); }); - if (subtitleNudgeIndicator) { - subtitleNudgeIndicator.addEventListener( - "click", - (e) => { - var video = this.video; - if (video) { - var newState = !isSubtitleNudgeEnabledForVideo(video); - setSubtitleNudgeEnabledForVideo(video, newState); - } - blurAfterPointerTap(subtitleNudgeIndicator, e); - e.stopPropagation(); - }, - true - ); - } controller.addEventListener("click", (e) => e.stopPropagation(), false); controller.addEventListener("mousedown", (e) => e.stopPropagation(), false); @@ -4361,6 +4369,32 @@ function inIframe() { } } +function isEditableShortcutTarget(event) { + var path = + event && typeof event.composedPath === "function" + ? event.composedPath() + : [event && event.target]; + + return path.some(function(target) { + if (!target || target.nodeType !== 1) return false; + var nodeName = target.nodeName; + var role = target.getAttribute && target.getAttribute("role"); + var contentEditable = + target.getAttribute && target.getAttribute("contenteditable"); + return ( + nodeName === "INPUT" || + nodeName === "TEXTAREA" || + nodeName === "SELECT" || + target.isContentEditable || + (contentEditable !== null && contentEditable !== "false") || + role === "textbox" || + role === "searchbox" || + role === "combobox" || + role === "spinbutton" + ); + }); +} + function attachKeydownListeners(doc) { // Content scripts already run in every frame. Keeping each listener scoped // to its own frame avoids duplicate shortcuts and stale iframe ownership. @@ -4384,13 +4418,7 @@ function attachKeydownListeners(doc) { return; } - if ( - event.target.nodeName === "INPUT" || - event.target.nodeName === "TEXTAREA" || - event.target.isContentEditable - ) { - return; - } + if (isEditableShortcutTarget(event)) return; if ( !siteRuleUtils.isSpeederActiveForSite( diff --git a/extension/content/shadow.css b/extension/content/shadow.css index 9d06da0..ac542a0 100644 --- a/extension/content/shadow.css +++ b/extension/content/shadow.css @@ -10,6 +10,24 @@ overflow: visible !important; } +/* Important declarations inside a shadow tree outrank important page styles. + Repeat the top-layer positioning here so fullscreen cannot fall back to the + base absolute host rule. */ +:host(.vsc-fullscreen-popover) { + position: fixed !important; + inset: auto !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + background: transparent !important; + overflow: visible !important; +} + +:host(.vsc-fullscreen-popover)::backdrop { + pointer-events: none !important; + background: transparent !important; +} + :host(.vsc-nosource), :host(.vsc-hidden) { display: none !important; @@ -316,10 +334,15 @@ button .vsc-btn-icon svg { transform: translateY(0.5px); } -button:focus { +button:focus:not(:focus-visible) { outline: 0; } +button:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + button:hover { opacity: 1; } diff --git a/extension/manifest.json b/extension/manifest.json index 21a5c08..4defe7b 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "name": "Speeder", "short_name": "Speeder", - "version": "6.0.4.1", + "version": "6.0.6.0", "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", diff --git a/extension/options/import-export.js b/extension/options/import-export.js index 79c8756..83c571a 100644 --- a/extension/options/import-export.js +++ b/extension/options/import-export.js @@ -215,14 +215,15 @@ function importSettings() { importLocalSettings(scopedLocalSettings, function (localError) { if (localError) { showStatus( - "Error: Failed to save local extension data - " + - localError.message, + "Settings imported, but custom icons could not be updated - " + + localError.message + + ". Reloading...", true ); - return; + } else { + showStatus("Settings imported successfully. Reloading..."); } - showStatus("Settings imported successfully. Reloading..."); setTimeout(function () { if (typeof restore_options === "function") { restore_options(); diff --git a/extension/options/options.css b/extension/options/options.css index b0a80a3..b65ec11 100644 --- a/extension/options/options.css +++ b/extension/options/options.css @@ -7,6 +7,7 @@ --text: #17191c; --muted: #626b76; --accent: #111827; + --focus-ring: #2563eb; --switch-track-off: #c1cad6; --switch-track-off-border: #aeb8c5; --switch-track-on: #111827; @@ -177,9 +178,14 @@ a:visited { text-underline-offset: 0.14em; } -a:hover, -a:focus { - color: #000; +a:hover { + color: var(--text); + text-decoration-thickness: 2px; +} + +a:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; } code { @@ -225,7 +231,7 @@ input[type="checkbox"]:focus-visible, input[type="text"]:focus, select:focus, textarea:focus { - outline: 2px solid rgba(17, 24, 39, 0.14); + outline: 2px solid var(--focus-ring); outline-offset: 2px; } @@ -624,6 +630,9 @@ label em { cursor: grab; user-select: none; transition: box-shadow 150ms ease, opacity 150ms ease; + color: var(--text); + font-weight: 500; + text-align: left; } .cb-block:hover { @@ -1239,6 +1248,7 @@ button.lucide-result-tile.lucide-picked { --text: #f2f4f6; --muted: #a0a8b2; --accent: #f2f4f6; + --focus-ring: #93c5fd; --switch-track-off: #374151; --switch-track-off-border: #4b5563; --switch-track-on: #aab7c6; diff --git a/extension/options/options.html b/extension/options/options.html index 897ba1f..77f3aaa 100644 --- a/extension/options/options.html +++ b/extension/options/options.html @@ -468,8 +468,8 @@

Hover control bar

- Drag blocks to reorder. Move between Active and Available to - show or hide buttons. + Drag blocks or use arrow keys to reorder. Press a block to + move it between Active and Available.

@@ -591,7 +591,7 @@

@@ -943,8 +943,8 @@
- +