From d55006dd0ab9f2e74000af89b28f93648f8495ad Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sun, 2 Aug 2026 21:18:39 -0400 Subject: [PATCH] feat(popup): pause Speeder per tab --- extension/background/background.js | 43 ++++++++++- extension/content/inject.js | 56 ++++++++++---- extension/popup/popup.css | 6 ++ extension/popup/popup.html | 6 ++ extension/popup/popup.js | 91 +++++++++++++++++++++-- tests/background.spec.js | 30 ++++++++ tests/helpers/browser.js | 8 +- tests/helpers/extension-test-utils.js | 10 ++- tests/inject-lifecycle.regression.test.js | 25 +++++++ tests/popup.spec.js | 28 ++++++- 10 files changed, 279 insertions(+), 24 deletions(-) diff --git a/extension/background/background.js b/extension/background/background.js index 845527c..99ae68a 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -9,6 +9,15 @@ function setToolbarIcon(enabled) { }); } +var pausedTabIds = new Set(); + +function getMessageTabId(request, sender) { + if (request && Number.isInteger(request.tabId)) return request.tabId; + return sender && sender.tab && Number.isInteger(sender.tab.id) + ? sender.tab.id + : null; +} + chrome.storage.sync.get(["enabled"], function(storage) { if (!chrome.runtime.lastError) setToolbarIcon(storage.enabled !== false); }); @@ -19,8 +28,40 @@ chrome.storage.onChanged.addListener(function(changes, areaName) { } }); -chrome.runtime.onMessage.addListener(function (request) { +chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) { if (request.action === "openOptions") { chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") }); + return false; } + if (request.action === "get_tab_pause_state") { + var queriedTabId = getMessageTabId(request, sender); + sendResponse({ + paused: queriedTabId !== null && pausedTabIds.has(queriedTabId) + }); + return false; + } + if (request.action === "set_tab_paused") { + var tabId = getMessageTabId(request, sender); + if (tabId === null) { + sendResponse({ paused: false }); + return false; + } + var paused = request.paused === true; + if (paused) pausedTabIds.add(tabId); + else pausedTabIds.delete(tabId); + chrome.tabs.sendMessage( + tabId, + { action: "set_tab_paused", paused: paused }, + function () { + void chrome.runtime.lastError; + } + ); + sendResponse({ paused: paused }); + return false; + } + return false; +}); + +chrome.tabs.onRemoved.addListener(function(tabId) { + pausedTabIds.delete(tabId); }); diff --git a/extension/content/inject.js b/extension/content/inject.js index 1b9fbb4..b12447d 100644 --- a/extension/content/inject.js +++ b/extension/content/inject.js @@ -141,6 +141,7 @@ function getDiagnosticsSnapshot(media) { }, effectiveSettings: { enabled: tc.settings.enabled !== false, + tabPaused: tc.tabPaused === true, startHidden: tc.settings.startHidden === true, hideWithControls: tc.settings.hideWithControls === true, rememberSpeed: tc.settings.rememberSpeed === true, @@ -225,6 +226,7 @@ var tc = { speedAccessTimes: {}, persistedLastSpeed: 1.0, activeSiteRule: null, + tabPaused: false, siteRuleBase: null, runtimeSettingsHydrated: false, pendingMediaCandidates: [], @@ -237,6 +239,29 @@ var tc = { : String(Date.now()) + "-" + Math.random().toString(36).slice(2) }; +function isSpeederActiveForCurrentPage() { + return ( + tc.tabPaused !== true && + siteRuleUtils.isSpeederActiveForSite( + tc.settings.enabled, + tc.activeSiteRule + ) + ); +} + +function applyTabPausedState(paused) { + tc.tabPaused = paused === true; + if (!tc.runtimeSettingsHydrated) return; + if (tc.tabPaused) { + clearAllSpeedRestoreEnforcement(); + tc.mediaElements.slice().forEach(function(media) { + removeController(media); + }); + return; + } + initializeWhenReady(document, true); +} + var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.1; var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16; var YT_NATIVE_MIN = 0.25; @@ -1590,7 +1615,7 @@ function ensureController(node, parent) { // href selects site rules; re-run on every new/usable media so all runtime // paths agree on activation and effective settings. applySiteRuleOverrides(); - if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { + if (!isSpeederActiveForCurrentPage()) { if (node.vsc) removeController(node); return null; } @@ -2398,12 +2423,24 @@ function loadInitialRuntimeSettings(attempt) { return; } hydrateRuntimeSettings(rawStorage || {}); + if (chrome.runtime && typeof chrome.runtime.sendMessage === "function") { + chrome.runtime.sendMessage({ action: "get_tab_pause_state" }, function(response) { + if (!chrome.runtime.lastError && response) { + applyTabPausedState(response.paused === true); + } + }); + } // patchAttachShadow() is now called at top-level before this callback // 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) { + if (request.action === "set_tab_paused") { + applyTabPausedState(request.paused === true); + sendResponse({ paused: tc.tabPaused }); + return false; + } if (request.action === "rescan_page") { log("Re-scan command received from popup.", 4); initializeWhenReady(document, true); @@ -2462,10 +2499,7 @@ function loadInitialRuntimeSettings(attempt) { return false; } if ( - !siteRuleUtils.isSpeederActiveForSite( - tc.settings.enabled, - tc.activeSiteRule - ) + !isSpeederActiveForCurrentPage() ) { return false; } @@ -4235,7 +4269,7 @@ function refreshAllControllerGeometry() { /** Re-match site rules for current URL and refresh controller position/opacity on every video. */ function reapplySiteRulesAndControllerGeometry() { applySiteRuleOverrides(); - if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { + if (!isSpeederActiveForCurrentPage()) { tc.mediaElements.slice().forEach(function(video) { removeController(video); }); @@ -4493,10 +4527,7 @@ function attachKeydownListeners(doc) { if (isEditableShortcutTarget(event)) return; if ( - !siteRuleUtils.isSpeederActiveForSite( - tc.settings.enabled, - tc.activeSiteRule - ) + !isSpeederActiveForCurrentPage() ) { return; } @@ -4721,10 +4752,7 @@ function initializeNow(doc, forceReinit = false) { attachNavigationListeners(); if (typeof tc.videoController === "undefined") defineVideoController(); applySiteRuleOverrides(); - var isActive = siteRuleUtils.isSpeederActiveForSite( - tc.settings.enabled, - tc.activeSiteRule - ); + var isActive = isSpeederActiveForCurrentPage(); // Keep observing while inactive so dynamically-created media/shadow roots // are available to the next forced SPA rescan, but remove stale controls. diff --git a/extension/popup/popup.css b/extension/popup/popup.css index 06e212d..e495051 100644 --- a/extension/popup/popup.css +++ b/extension/popup/popup.css @@ -118,6 +118,12 @@ button:disabled { border-color: #9fbd98; color: #285d21; } + +#pauseTab[aria-pressed="true"] { + background: #fff3d6; + border-color: #d8b66b; + color: #704f0d; +} .popup-divider { height: 1px; diff --git a/extension/popup/popup.html b/extension/popup/popup.html index 5b5c587..6a833a2 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -24,6 +24,12 @@ type="button" aria-pressed="false" >Force last saved speed +