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) {
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);
});
+42 -14
View File
@@ -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.
+6
View File
@@ -119,6 +119,12 @@ button:disabled {
color: #285d21;
}
#pauseTab[aria-pressed="true"] {
background: #fff3d6;
border-color: #d8b66b;
color: #704f0d;
}
.popup-divider {
height: 1px;
background: var(--border);
+6
View File
@@ -24,6 +24,12 @@
type="button"
aria-pressed="false"
>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 id="popupControlBar" class="popup-control-bar">
<span id="popupSpeed" class="popup-speed">1.00</span>
+85 -6
View File
@@ -1,4 +1,6 @@
document.addEventListener("DOMContentLoaded", function () {
if (window.vscPopupInitialized) return;
window.vscPopupInitialized = true;
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {};
@@ -31,6 +33,27 @@ document.addEventListener("DOMContentLoaded", function () {
var shortcutTargetMode = "closest";
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) {
try {
var parsed = new URL(url);
@@ -93,6 +116,7 @@ document.addEventListener("DOMContentLoaded", function () {
.sort()
: []
},
tabPaused: diagnosticContext.tabPaused === true,
frame: frame && frame.diagnostics ? frame.diagnostics : null
},
null,
@@ -195,16 +219,23 @@ document.addEventListener("DOMContentLoaded", function () {
}
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) {
var activeTab = tabs && tabs[0] ? tabs[0] : null;
if (!activeTab || !activeTab.id) {
if (callback) callback({ tab: null, url: "" });
finish({ tab: null, url: "" });
return;
}
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
if (tabUrl.length > 0) {
if (callback) callback({ tab: activeTab, url: tabUrl });
finish({ tab: activeTab, url: tabUrl });
return;
}
@@ -213,13 +244,13 @@ document.addEventListener("DOMContentLoaded", function () {
{ action: "get_page_context" },
function (response) {
if (chrome.runtime.lastError) {
if (callback) callback({ tab: activeTab, url: "" });
finish({ tab: activeTab, url: "" });
return;
}
var pageUrl =
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 () {
this.classList.add("hide");
document.querySelector("#donateOptions").classList.remove("hide");
@@ -534,6 +599,9 @@ document.addEventListener("DOMContentLoaded", function () {
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
var tabPaused = context && context.tabPaused === true;
updateTabPauseButton(tabPaused);
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule);
@@ -554,16 +622,21 @@ document.addEventListener("DOMContentLoaded", function () {
? siteRule.forceLastSavedSpeed === true
: storage.forceLastSavedSpeed === true;
diagnosticContext = {
tab: context && context.tab,
url: url,
storage: storage,
siteRule: siteRule,
frame: null
frame: null,
tabPaused: tabPaused,
siteAvailable: siteAvailable,
showBar: showBar
};
document.querySelector("#addSiteRule").disabled =
!getCurrentSiteRuleDetails(url);
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
diagnosticContext.showBar = showBar;
}
toggleEnabledUI(storage.enabled !== false);
@@ -572,7 +645,13 @@ document.addEventListener("DOMContentLoaded", function () {
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
setControlBarVisible(!tabPaused && siteAvailable && showBar);
if (tabPaused) {
setForceButtonLoading(true);
setStatusMessage("Speeder is paused for this tab.");
return;
}
if (siteDisabled) {
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(() => ({
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: {
sync: syncArea,
+9 -1
View File
@@ -245,6 +245,7 @@ function createChromeMock(options) {
const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent();
const tabsOnRemoved = createChromeEvent();
const runtimeOnMessage = createChromeEvent();
const chrome = {
@@ -252,6 +253,12 @@ function createChromeMock(options) {
lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
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
},
browserAction: {
@@ -272,7 +279,8 @@ function createChromeMock(options) {
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
onUpdated: tabsOnUpdated,
onRemoved: tabsOnRemoved
},
storage: {
onChanged: storageOnChanged,
+25
View File
@@ -329,6 +329,31 @@ describe("inject.js media/controller lifecycle regressions", () => {
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 () => {
bootInject({
url: "https://www.youtube.com/",
+27 -1
View File
@@ -18,6 +18,7 @@ function bootPopup(options) {
manifest: { version: "9.9.9-test" },
syncData: config.syncData,
localData: config.localData,
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
]
@@ -107,7 +108,7 @@ describe("popup.js", () => {
executeScriptImpl: (tabId, details, callback) => {
speedQueryCount += 1;
callback(
speedQueryCount <= 2
speedQueryCount === 1
? [
{ speed: 1.25, preferred: false },
{ 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 () => {
const chrome = bootPopup({
syncData: {