feat(popup): pause Speeder per tab

This commit is contained in:
joshpatra committed 2026-08-02 21:18:39 -04:00
1 parent 745af2f1b9
commit d55006dd0a
10 files changed
+279 -24

No files matched your search

+42 -1
View File
@@ -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) { chrome.storage.sync.get(["enabled"], function(storage) {
if (!chrome.runtime.lastError) setToolbarIcon(storage.enabled !== false); 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") { if (request.action === "openOptions") {
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") }); 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);
}); });
+42 -14
View File
@@ -141,6 +141,7 @@ function getDiagnosticsSnapshot(media) {
}, },
effectiveSettings: { effectiveSettings: {
enabled: tc.settings.enabled !== false, enabled: tc.settings.enabled !== false,
tabPaused: tc.tabPaused === true,
startHidden: tc.settings.startHidden === true, startHidden: tc.settings.startHidden === true,
hideWithControls: tc.settings.hideWithControls === true, hideWithControls: tc.settings.hideWithControls === true,
rememberSpeed: tc.settings.rememberSpeed === true, rememberSpeed: tc.settings.rememberSpeed === true,
@@ -225,6 +226,7 @@ var tc = {
speedAccessTimes: {}, speedAccessTimes: {},
persistedLastSpeed: 1.0, persistedLastSpeed: 1.0,
activeSiteRule: null, activeSiteRule: null,
tabPaused: false,
siteRuleBase: null, siteRuleBase: null,
runtimeSettingsHydrated: false, runtimeSettingsHydrated: false,
pendingMediaCandidates: [], pendingMediaCandidates: [],
@@ -237,6 +239,29 @@ var tc = {
: String(Date.now()) + "-" + Math.random().toString(36).slice(2) : 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 MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.1;
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16; var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16;
var YT_NATIVE_MIN = 0.25; 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 // href selects site rules; re-run on every new/usable media so all runtime
// paths agree on activation and effective settings. // paths agree on activation and effective settings.
applySiteRuleOverrides(); applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { if (!isSpeederActiveForCurrentPage()) {
if (node.vsc) removeController(node); if (node.vsc) removeController(node);
return null; return null;
} }
@@ -2398,12 +2423,24 @@ function loadInitialRuntimeSettings(attempt) {
return; return;
} }
hydrateRuntimeSettings(rawStorage || {}); 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 // patchAttachShadow() is now called at top-level before this callback
// Add a listener for messages from the popup. // Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once. // We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) { if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener( chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) { 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") { if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4); log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true); initializeWhenReady(document, true);
@@ -2462,10 +2499,7 @@ function loadInitialRuntimeSettings(attempt) {
return false; return false;
} }
if ( if (
!siteRuleUtils.isSpeederActiveForSite( !isSpeederActiveForCurrentPage()
tc.settings.enabled,
tc.activeSiteRule
)
) { ) {
return false; return false;
} }
@@ -4235,7 +4269,7 @@ function refreshAllControllerGeometry() {
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */ /** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() { function reapplySiteRulesAndControllerGeometry() {
applySiteRuleOverrides(); applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { if (!isSpeederActiveForCurrentPage()) {
tc.mediaElements.slice().forEach(function(video) { tc.mediaElements.slice().forEach(function(video) {
removeController(video); removeController(video);
}); });
@@ -4493,10 +4527,7 @@ function attachKeydownListeners(doc) {
if (isEditableShortcutTarget(event)) return; if (isEditableShortcutTarget(event)) return;
if ( if (
!siteRuleUtils.isSpeederActiveForSite( !isSpeederActiveForCurrentPage()
tc.settings.enabled,
tc.activeSiteRule
)
) { ) {
return; return;
} }
@@ -4721,10 +4752,7 @@ function initializeNow(doc, forceReinit = false) {
attachNavigationListeners(); attachNavigationListeners();
if (typeof tc.videoController === "undefined") defineVideoController(); if (typeof tc.videoController === "undefined") defineVideoController();
applySiteRuleOverrides(); applySiteRuleOverrides();
var isActive = siteRuleUtils.isSpeederActiveForSite( var isActive = isSpeederActiveForCurrentPage();
tc.settings.enabled,
tc.activeSiteRule
);
// Keep observing while inactive so dynamically-created media/shadow roots // Keep observing while inactive so dynamically-created media/shadow roots
// are available to the next forced SPA rescan, but remove stale controls. // are available to the next forced SPA rescan, but remove stale controls.
+6
View File
@@ -118,6 +118,12 @@ button:disabled {
border-color: #9fbd98; border-color: #9fbd98;
color: #285d21; color: #285d21;
} }
#pauseTab[aria-pressed="true"] {
background: #fff3d6;
border-color: #d8b66b;
color: #704f0d;
}
.popup-divider { .popup-divider {
height: 1px; height: 1px;
+6
View File
@@ -24,6 +24,12 @@
type="button" type="button"
aria-pressed="false" aria-pressed="false"
>Force last saved speed</button> >Force last saved speed</button>
<button
id="pauseTab"
class="popup-compact-action"
type="button"
aria-pressed="false"
>Pause on this tab</button>
<div class="popup-divider"></div> <div class="popup-divider"></div>
<div id="popupControlBar" class="popup-control-bar"> <div id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span> <span id="popupSpeed" class="popup-speed">1.00</span>
+85 -6
View File
@@ -1,4 +1,6 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
if (window.vscPopupInitialized) return;
window.vscPopupInitialized = true;
var speederShared = var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {}; var siteRuleUtils = speederShared.siteRules || {};
@@ -31,6 +33,27 @@ document.addEventListener("DOMContentLoaded", function () {
var shortcutTargetMode = "closest"; var shortcutTargetMode = "closest";
var diagnosticContext = null; var diagnosticContext = null;
function updateTabPauseButton(paused) {
var button = document.querySelector("#pauseTab");
button.setAttribute("aria-pressed", paused ? "true" : "false");
button.textContent = paused ? "Resume on this tab" : "Pause on this tab";
}
function getTabPauseState(tab, callback) {
if (!tab || !Number.isInteger(tab.id)) {
callback(false);
return;
}
chrome.runtime.sendMessage(
{ action: "get_tab_pause_state", tabId: tab.id },
function(response) {
callback(
!chrome.runtime.lastError && response && response.paused === true
);
}
);
}
function getSafePageDetails(url) { function getSafePageDetails(url) {
try { try {
var parsed = new URL(url); var parsed = new URL(url);
@@ -93,6 +116,7 @@ document.addEventListener("DOMContentLoaded", function () {
.sort() .sort()
: [] : []
}, },
tabPaused: diagnosticContext.tabPaused === true,
frame: frame && frame.diagnostics ? frame.diagnostics : null frame: frame && frame.diagnostics ? frame.diagnostics : null
}, },
null, null,
@@ -195,16 +219,23 @@ document.addEventListener("DOMContentLoaded", function () {
} }
function getActiveTabContext(callback) { function getActiveTabContext(callback) {
function finish(context) {
getTabPauseState(context.tab, function(paused) {
context.tabPaused = paused;
if (callback) callback(context);
});
}
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
var activeTab = tabs && tabs[0] ? tabs[0] : null; var activeTab = tabs && tabs[0] ? tabs[0] : null;
if (!activeTab || !activeTab.id) { if (!activeTab || !activeTab.id) {
if (callback) callback({ tab: null, url: "" }); finish({ tab: null, url: "" });
return; return;
} }
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : ""; var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
if (tabUrl.length > 0) { if (tabUrl.length > 0) {
if (callback) callback({ tab: activeTab, url: tabUrl }); finish({ tab: activeTab, url: tabUrl });
return; return;
} }
@@ -213,13 +244,13 @@ document.addEventListener("DOMContentLoaded", function () {
{ action: "get_page_context" }, { action: "get_page_context" },
function (response) { function (response) {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
if (callback) callback({ tab: activeTab, url: "" }); finish({ tab: activeTab, url: "" });
return; return;
} }
var pageUrl = var pageUrl =
response && typeof response.url === "string" ? response.url : ""; response && typeof response.url === "string" ? response.url : "";
if (callback) callback({ tab: activeTab, url: pageUrl }); finish({ tab: activeTab, url: pageUrl });
} }
); );
}); });
@@ -422,6 +453,40 @@ document.addEventListener("DOMContentLoaded", function () {
); );
}); });
document.querySelector("#pauseTab").addEventListener("click", function () {
var tab = diagnosticContext && diagnosticContext.tab;
if (!tab || !Number.isInteger(tab.id)) {
setStatusMessage("This page cannot be paused.");
return;
}
var button = this;
var paused = button.getAttribute("aria-pressed") !== "true";
button.disabled = true;
chrome.runtime.sendMessage(
{ action: "set_tab_paused", tabId: tab.id, paused: paused },
function(response) {
button.disabled = false;
if (chrome.runtime.lastError || !response) {
setStatusMessage("Could not update this tab.");
return;
}
diagnosticContext.tabPaused = response.paused === true;
updateTabPauseButton(diagnosticContext.tabPaused);
setControlBarVisible(
!diagnosticContext.tabPaused &&
diagnosticContext.siteAvailable &&
diagnosticContext.showBar
);
setForceButtonLoading(diagnosticContext.tabPaused);
setStatusMessage(
diagnosticContext.tabPaused
? "Speeder is paused for this tab."
: "Speeder resumed for this tab."
);
}
);
});
document.querySelector("#donate").addEventListener("click", function () { document.querySelector("#donate").addEventListener("click", function () {
this.classList.add("hide"); this.classList.add("hide");
document.querySelector("#donateOptions").classList.remove("hide"); document.querySelector("#donateOptions").classList.remove("hide");
@@ -534,6 +599,9 @@ document.addEventListener("DOMContentLoaded", function () {
getActiveTabContext(function (context) { getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
var tabPaused = context && context.tabPaused === true;
updateTabPauseButton(tabPaused);
var url = context && context.url ? context.url : ""; var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules); var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule); var siteDisabled = isSiteRuleDisabled(siteRule);
@@ -554,16 +622,21 @@ document.addEventListener("DOMContentLoaded", function () {
? siteRule.forceLastSavedSpeed === true ? siteRule.forceLastSavedSpeed === true
: storage.forceLastSavedSpeed === true; : storage.forceLastSavedSpeed === true;
diagnosticContext = { diagnosticContext = {
tab: context && context.tab,
url: url, url: url,
storage: storage, storage: storage,
siteRule: siteRule, siteRule: siteRule,
frame: null frame: null,
tabPaused: tabPaused,
siteAvailable: siteAvailable,
showBar: showBar
}; };
document.querySelector("#addSiteRule").disabled = document.querySelector("#addSiteRule").disabled =
!getCurrentSiteRuleDetails(url); !getCurrentSiteRuleDetails(url);
if (siteRule && siteRule.showPopupControlBar !== undefined) { if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar; showBar = siteRule.showPopupControlBar;
diagnosticContext.showBar = showBar;
} }
toggleEnabledUI(storage.enabled !== false); toggleEnabledUI(storage.enabled !== false);
@@ -572,7 +645,13 @@ document.addEventListener("DOMContentLoaded", function () {
resolvePopupButtons(storage, siteRule), resolvePopupButtons(storage, siteRule),
customIconsMap customIconsMap
); );
setControlBarVisible(siteAvailable && showBar); setControlBarVisible(!tabPaused && siteAvailable && showBar);
if (tabPaused) {
setForceButtonLoading(true);
setStatusMessage("Speeder is paused for this tab.");
return;
}
if (siteDisabled) { if (siteDisabled) {
setForceButtonLoading(false); setForceButtonLoading(false);
+30
View File
@@ -33,4 +33,34 @@ describe("background toolbar state", () => {
} }
}); });
}); });
it("keeps tab pauses in memory and clears closed tabs", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock();
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("extension/background/background.js");
const listener = chrome.runtime.onMessage.listeners[0];
const respond = vi.fn();
listener(
{ action: "set_tab_paused", tabId: 42, paused: true },
{},
respond
);
expect(respond).toHaveBeenLastCalledWith({ paused: true });
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
42,
{ action: "set_tab_paused", paused: true },
expect.any(Function)
);
listener({ action: "get_tab_pause_state" }, { tab: { id: 42 } }, respond);
expect(respond).toHaveBeenLastCalledWith({ paused: true });
chrome.tabs.onRemoved.emit(42);
listener({ action: "get_tab_pause_state", tabId: 42 }, {}, respond);
expect(respond).toHaveBeenLastCalledWith({ paused: false });
});
}); });
+7 -1
View File
@@ -173,7 +173,13 @@ export function createChromeMock(options = {}) {
getManifest: vi.fn(() => ({ getManifest: vi.fn(() => ({
version: options.manifestVersion || "9.9.9" version: options.manifestVersion || "9.9.9"
})), })),
getURL: vi.fn((url) => "moz-extension://speeder/" + url) getURL: vi.fn((url) => "moz-extension://speeder/" + url),
sendMessage: vi.fn((message, callback) => {
if (options.runtimeSendMessageImpl) {
return options.runtimeSendMessageImpl(message, callback);
}
if (callback) callback({ paused: false });
})
}, },
storage: { storage: {
sync: syncArea, sync: syncArea,
+9 -1
View File
@@ -245,6 +245,7 @@ function createChromeMock(options) {
const storageOnChanged = createChromeEvent(); const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent(); const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent(); const tabsOnUpdated = createChromeEvent();
const tabsOnRemoved = createChromeEvent();
const runtimeOnMessage = createChromeEvent(); const runtimeOnMessage = createChromeEvent();
const chrome = { const chrome = {
@@ -252,6 +253,12 @@ function createChromeMock(options) {
lastError: null, lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }), getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
getURL: vi.fn((relPath) => `moz-extension://${relPath}`), getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
sendMessage: vi.fn((message, callback) => {
if (config.runtimeSendMessageImpl) {
return config.runtimeSendMessageImpl(message, callback);
}
if (callback) callback({ paused: false });
}),
onMessage: runtimeOnMessage onMessage: runtimeOnMessage
}, },
browserAction: { browserAction: {
@@ -272,7 +279,8 @@ function createChromeMock(options) {
}), }),
create: vi.fn(), create: vi.fn(),
onActivated: tabsOnActivated, onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated onUpdated: tabsOnUpdated,
onRemoved: tabsOnRemoved
}, },
storage: { storage: {
onChanged: storageOnChanged, onChanged: storageOnChanged,
+25
View File
@@ -329,6 +329,31 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(video.playbackRate).toBe(initialSpeed); expect(video.playbackRate).toBe(initialSpeed);
}); });
it("removes and restores controls when this tab is paused", async () => {
const chrome = bootInject();
await settleLifecycle();
const { video } = createControlledVideo();
const listener = chrome.runtime.onMessage.listeners[0];
const playbackRate = video.playbackRate;
listener(
{ action: "set_tab_paused", paused: true },
{},
vi.fn()
);
expect(window.tc.tabPaused).toBe(true);
expect(video.vsc).toBeUndefined();
expect(video.playbackRate).toBe(playbackRate);
listener(
{ action: "set_tab_paused", paused: false },
{},
vi.fn()
);
expect(window.tc.tabPaused).toBe(false);
expect(video.vsc).toBeTruthy();
});
it("drops a stale hover-preview shortcut target after SPA navigation", async () => { it("drops a stale hover-preview shortcut target after SPA navigation", async () => {
bootInject({ bootInject({
url: "https://www.youtube.com/", url: "https://www.youtube.com/",
+27 -1
View File
@@ -18,6 +18,7 @@ function bootPopup(options) {
manifest: { version: "9.9.9-test" }, manifest: { version: "9.9.9-test" },
syncData: config.syncData, syncData: config.syncData,
localData: config.localData, localData: config.localData,
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
tabsQueryResult: [ tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" } config.activeTab || { id: 99, active: true, url: "https://example.com/" }
] ]
@@ -107,7 +108,7 @@ describe("popup.js", () => {
executeScriptImpl: (tabId, details, callback) => { executeScriptImpl: (tabId, details, callback) => {
speedQueryCount += 1; speedQueryCount += 1;
callback( callback(
speedQueryCount <= 2 speedQueryCount === 1
? [ ? [
{ speed: 1.25, preferred: false }, { speed: 1.25, preferred: false },
{ speed: 1.5, frameToken: "playing-frame", preferred: true } { speed: 1.5, frameToken: "playing-frame", preferred: true }
@@ -243,6 +244,31 @@ describe("popup.js", () => {
); );
}); });
it("pauses only the active tab for the browser session", async () => {
let paused = false;
const chrome = bootPopup({
runtimeSendMessageImpl: (message, callback) => {
if (message.action === "set_tab_paused") paused = message.paused === true;
callback({ paused });
}
});
await flushAsyncWork();
document.querySelector("#pauseTab").click();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
{ action: "set_tab_paused", tabId: 99, paused: true },
expect.any(Function)
);
expect(document.querySelector("#pauseTab").textContent).toBe(
"Resume on this tab"
);
expect(document.querySelector("#popupControlBar").style.display).toBe(
"none"
);
expect(document.querySelector("#status").textContent).toContain("paused");
});
it("toggles enablement and closes after a successful refresh", async () => { it("toggles enablement and closes after a successful refresh", async () => {
const chrome = bootPopup({ const chrome = bootPopup({
syncData: { syncData: {