Compare commits

...
7 Commits
16 changed files with 689 additions and 83 deletions
+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);
}); });
@@ -12,6 +12,10 @@
typeof tc === "object" && typeof tc.frameToken === "string" typeof tc === "object" && typeof tc.frameToken === "string"
? tc.frameToken ? tc.frameToken
: null, : null,
diagnostics:
typeof getDiagnosticsSnapshot === "function"
? getDiagnosticsSnapshot(v)
: null,
preferred: !v.paused, preferred: !v.paused,
forceLastSavedSpeed: Boolean( forceLastSavedSpeed: Boolean(
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
+123 -23
View File
@@ -90,6 +90,81 @@ function getPrimaryVideoElement(mediaElements) {
return best; return best;
} }
function getDiagnosticsSnapshot(media) {
var primary = media || getPrimaryVideoElement();
if (!primary) return null;
var controller = primary.vsc || null;
var wrapper = controller && controller.div;
var fullscreenElement = getFullscreenElement(primary.ownerDocument);
var rect = null;
try {
rect = primary.getBoundingClientRect();
} catch (_error) {}
return {
mediaCount: tc.mediaElements.filter(function(item) {
return item && item.isConnected;
}).length,
mediaType: String(primary.nodeName || "media").toLowerCase(),
playbackRate: Number(primary.playbackRate),
paused: primary.paused === true,
ended: primary.ended === true,
readyState: Number(primary.readyState) || 0,
muted: primary.muted === true,
volume: Number(primary.volume),
dimensions: rect
? [Math.round(Number(rect.width) || 0), Math.round(Number(rect.height) || 0)]
: [0, 0],
fullscreen: {
active: Boolean(fullscreenElement),
element: fullscreenElement
? String(fullscreenElement.nodeName || "element").toLowerCase()
: null,
ownsMedia: Boolean(
fullscreenElement &&
(fullscreenElement === primary ||
isComposedDescendant(primary, fullscreenElement))
)
},
controller: {
present: Boolean(controller && wrapper),
connected: Boolean(wrapper && wrapper.isConnected),
hidden: Boolean(wrapper && wrapper.classList.contains("vsc-hidden")),
geometryHidden: Boolean(
wrapper && wrapper.classList.contains("vsc-geometry-hidden")
),
fullscreenPopover: Boolean(
wrapper && wrapper.classList.contains("vsc-fullscreen-popover")
),
location: controller ? controller.controllerLocation : null
},
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,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
shortcutTargetMode: tc.settings.shortcutTargetMode
},
siteRule: {
matched: Boolean(tc.activeSiteRule),
disabled: Boolean(
tc.activeSiteRule &&
siteRuleUtils.isSiteRuleDisabled(tc.activeSiteRule)
),
overrideKeys: tc.activeSiteRule
? Object.keys(tc.activeSiteRule)
.filter(function(key) {
return key !== "pattern" && key !== "title";
})
.sort()
: []
}
};
}
var tc = { var tc = {
settings: { settings: {
lastSpeed: getSharedDefault("lastSpeed", 1.0), lastSpeed: getSharedDefault("lastSpeed", 1.0),
@@ -151,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: [],
@@ -163,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;
@@ -1516,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;
} }
@@ -2324,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);
@@ -2344,6 +2455,7 @@ function loadInitialRuntimeSettings(attempt) {
sendResponse({ sendResponse({
speed: videoGs.playbackRate, speed: videoGs.playbackRate,
frameToken: tc.frameToken, frameToken: tc.frameToken,
diagnostics: getDiagnosticsSnapshot(videoGs),
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true, forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
forceLastSavedSpeedControlledBySiteRule: Boolean( forceLastSavedSpeedControlledBySiteRule: Boolean(
tc.activeSiteRule && tc.activeSiteRule &&
@@ -2387,10 +2499,7 @@ function loadInitialRuntimeSettings(attempt) {
return false; return false;
} }
if ( if (
!siteRuleUtils.isSpeederActiveForSite( !isSpeederActiveForCurrentPage()
tc.settings.enabled,
tc.activeSiteRule
)
) { ) {
return false; return false;
} }
@@ -3168,14 +3277,14 @@ function disableDirectFullscreenPopover(videoController) {
wrapper.removeAttribute("popover"); wrapper.removeAttribute("popover");
} }
function enableFullscreenPopover(videoController, preferredMount) { function enableDirectFullscreenPopover(videoController) {
if (!videoController || !videoController.video || !videoController.div) { if (!videoController || !videoController.video || !videoController.div) {
return false; return false;
} }
var wrapper = videoController.div; var wrapper = videoController.div;
if (typeof wrapper.showPopover !== "function") return false; if (typeof wrapper.showPopover !== "function") return false;
var normalMount = preferredMount || videoController.normalControllerMount; var normalMount = videoController.normalControllerMount;
var normalMountIsConnected = Boolean( var normalMountIsConnected = Boolean(
normalMount && normalMount &&
(normalMount.isConnected || (normalMount.isConnected ||
@@ -3230,13 +3339,10 @@ function syncControllerFullscreenMount(videoController) {
if (!targetMount) return false; if (!targetMount) return false;
if (ownsFullscreen) { if (fullscreenElement === video) {
// Fullscreen elements and popovers both participate in the browser's top // A replaced <video> cannot paint author children, so direct-media
// layer. Showing Speeder's host after the player enters fullscreen keeps it // fullscreen is the only case that needs a separate top-layer popover.
// above provider-owned surfaces even when the provider clips descendants or if (enableDirectFullscreenPopover(videoController)) return true;
// creates a new fullscreen stacking context (notably Firefox + YouTube).
// Browsers without the Popover API retain the player-local remount fallback.
if (enableFullscreenPopover(videoController, targetMount)) return true;
} else { } else {
disableDirectFullscreenPopover(videoController); disableDirectFullscreenPopover(videoController);
} }
@@ -4163,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);
}); });
@@ -4421,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;
} }
@@ -4649,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.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "Speeder", "name": "Speeder",
"short_name": "Speeder", "short_name": "Speeder",
"version": "6.0.6.0", "version": "6.0.8.0",
"manifest_version": 2, "manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")", "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", "homepage_url": "https://github.com/SoPat712/speeder",
+1
View File
@@ -1029,6 +1029,7 @@
<button id="restore">Restore Defaults</button> <button id="restore">Restore Defaults</button>
<button id="exportSettings">Export Settings</button> <button id="exportSettings">Export Settings</button>
<button id="importSettings">Import Settings</button> <button id="importSettings">Import Settings</button>
<button id="copyDiagnostics">Copy Diagnostics</button>
</div> </div>
<div id="status" role="status" aria-live="polite"></div> <div id="status" role="status" aria-live="polite"></div>
+35
View File
@@ -2104,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
.addEventListener("change", updatePopupEditorDisabledState); .addEventListener("change", updatePopupEditorDisabledState);
document.getElementById("save").addEventListener("click", save_options); document.getElementById("save").addEventListener("click", save_options);
document.getElementById("copyDiagnostics").addEventListener("click", function () {
var status = document.getElementById("status");
if (
!navigator.clipboard ||
typeof navigator.clipboard.writeText !== "function"
) {
status.textContent = "Clipboard access is unavailable.";
return;
}
var report = popupControlUtils.buildDiagnosticReport({
speederVersion: manifest.version,
browser: navigator.userAgent,
platform: navigator.platform || null,
storage: {
enabled: document.getElementById("enabled").checked,
rememberSpeed: document.getElementById("rememberSpeed").checked,
forceLastSavedSpeed:
document.getElementById("forceLastSavedSpeed").checked,
audioBoolean: document.getElementById("audioBoolean").checked,
startHidden: document.getElementById("startHidden").checked,
hideWithControls: document.getElementById("hideWithControls").checked,
controllerLocation: document.getElementById("controllerLocation").value,
shortcutTargetMode:
document.getElementById("shortcutTargetMode").value
}
});
navigator.clipboard.writeText(report).then(
function() {
status.textContent = "Diagnostics copied. Review before sharing.";
},
function() {
status.textContent = "Could not copy diagnostics.";
}
);
});
const addSelector = document.getElementById("addShortcutSelector"); const addSelector = document.getElementById("addShortcutSelector");
if (addSelector) { if (addSelector) {
+13 -2
View File
@@ -86,8 +86,13 @@ button:active {
button:focus-visible { button:focus-visible {
outline: 2px solid var(--focus-ring); outline: 2px solid var(--focus-ring);
outline-offset: 2px; outline-offset: 2px;
} }
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
#refresh { #refresh {
background: var(--accent); background: var(--accent);
@@ -113,6 +118,12 @@ button:focus-visible {
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;
+8
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>
@@ -41,6 +47,8 @@
<div class="popup-links"> <div class="popup-links">
<button id="config">Settings</button> <button id="config">Settings</button>
<div class="popup-secondary"> <div class="popup-secondary">
<button id="addSiteRule" class="secondary">Add current site</button>
<button id="copyDiagnostics" class="secondary">Copy diagnostics</button>
<button id="feedback" class="secondary">Feedback</button> <button id="feedback" class="secondary">Feedback</button>
<button id="about" class="secondary">About</button> <button id="about" class="secondary">About</button>
</div> </div>
+190 -5
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 || {};
@@ -29,6 +31,61 @@ document.addEventListener("DOMContentLoaded", function () {
var forceLastSavedSpeedControlledBySiteRule = null; var forceLastSavedSpeedControlledBySiteRule = null;
var selectedFrameToken = null; var selectedFrameToken = null;
var shortcutTargetMode = "closest"; 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 getCurrentSiteRuleDetails(url) {
try {
var parsed = new URL(url);
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
!parsed.host
) {
return null;
}
return {
title: parsed.host,
pattern: parsed.protocol + "//" + parsed.host
};
} catch (_error) {
return null;
}
}
function buildDiagnosticReport() {
if (!diagnosticContext) return null;
return popupControlUtils.buildDiagnosticReport({
speederVersion: chrome.runtime.getManifest().version,
browser: navigator.userAgent,
platform: navigator.platform || null,
url: diagnosticContext.url,
storage: diagnosticContext.storage,
siteRule: diagnosticContext.siteRule,
siteRuleDisabled: isSiteRuleDisabled(diagnosticContext.siteRule),
tabPaused: diagnosticContext.tabPaused,
frame: diagnosticContext.frame
});
}
function persistExpandedSettings(rawStorage, settings, callback) { function persistExpandedSettings(rawStorage, settings, callback) {
var mutation = vscBuildManagedStorageMutation(rawStorage, settings); var mutation = vscBuildManagedStorageMutation(rawStorage, settings);
@@ -125,16 +182,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;
} }
@@ -143,13 +207,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 });
} }
); );
}); });
@@ -288,6 +352,104 @@ document.addEventListener("DOMContentLoaded", function () {
window.open("https://github.com/SoPat712/Speeder/issues"); window.open("https://github.com/SoPat712/Speeder/issues");
}); });
document.querySelector("#copyDiagnostics").addEventListener("click", function () {
var report = buildDiagnosticReport();
if (!report) {
setStatusMessage("Diagnostics are still loading.");
return;
}
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
setStatusMessage("Clipboard access is unavailable.");
return;
}
navigator.clipboard.writeText(report).then(
function () {
setStatusMessage("Diagnostics copied. Review before sharing.");
},
function () {
setStatusMessage("Could not copy diagnostics.");
}
);
});
document.querySelector("#addSiteRule").addEventListener("click", function () {
var details = diagnosticContext
? getCurrentSiteRuleDetails(diagnosticContext.url)
: null;
if (!details) {
setStatusMessage("A site rule cannot be created for this page.");
return;
}
var added = false;
updateStoredSettings(
function (settings) {
var rules = Array.isArray(settings.siteRules) ? settings.siteRules : [];
if (
rules.some(function(rule) {
return rule && rule.pattern === details.pattern;
})
) {
return;
}
settings.siteRules = rules.concat([
{
title: details.title,
pattern: details.pattern,
enabled: true
}
]);
added = true;
},
function (error) {
if (error) {
setStatusMessage("Could not add this site: " + error.message);
return;
}
setStatusMessage(
added
? "Site rule added. Opening settings..."
: "Site rule already exists. Opening settings..."
);
window.open(chrome.runtime.getURL("options/options.html"));
}
);
});
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");
@@ -400,6 +562,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);
@@ -419,9 +584,22 @@ document.addEventListener("DOMContentLoaded", function () {
forceLastSavedSpeedControlledBySiteRule forceLastSavedSpeedControlledBySiteRule
? siteRule.forceLastSavedSpeed === true ? siteRule.forceLastSavedSpeed === true
: storage.forceLastSavedSpeed === true; : storage.forceLastSavedSpeed === true;
diagnosticContext = {
tab: context && context.tab,
url: url,
storage: storage,
siteRule: siteRule,
frame: null,
tabPaused: tabPaused,
siteAvailable: siteAvailable,
showBar: showBar
};
document.querySelector("#addSiteRule").disabled =
!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);
@@ -430,7 +608,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);
@@ -443,6 +627,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (siteAvailable) { if (siteAvailable) {
querySpeed(function(frameContext) { querySpeed(function(frameContext) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
diagnosticContext.frame = frameContext || null;
if ( if (
frameContext && frameContext &&
typeof frameContext.forceLastSavedSpeed === "boolean" typeof frameContext.forceLastSavedSpeed === "boolean"
+57
View File
@@ -71,6 +71,9 @@
if (typeof result.frameToken === "string") { if (typeof result.frameToken === "string") {
normalized.frameToken = result.frameToken; normalized.frameToken = result.frameToken;
} }
if (result.diagnostics && typeof result.diagnostics === "object") {
normalized.diagnostics = result.diagnostics;
}
if (typeof result.forceLastSavedSpeed === "boolean") { if (typeof result.forceLastSavedSpeed === "boolean") {
normalized.forceLastSavedSpeed = result.forceLastSavedSpeed; normalized.forceLastSavedSpeed = result.forceLastSavedSpeed;
} }
@@ -94,7 +97,61 @@
return fallback; return fallback;
} }
function getSafePageDetails(url) {
try {
var parsed = new URL(url);
return {
protocol: parsed.protocol,
hostname: parsed.hostname || null
};
} catch (_error) {
return { protocol: null, hostname: null };
}
}
function buildDiagnosticReport(context) {
var config = context || {};
var storage = config.storage || {};
var siteRule = config.siteRule || null;
var frame = config.frame || null;
return JSON.stringify(
{
speederVersion: config.speederVersion || null,
browser: config.browser || null,
platform: config.platform || null,
page: getSafePageDetails(config.url),
globalSettings: {
enabled: storage.enabled !== false,
rememberSpeed: storage.rememberSpeed === true,
forceLastSavedSpeed: storage.forceLastSavedSpeed === true,
audioEnabled: storage.audioBoolean === true,
startHidden: storage.startHidden === true,
hideWithControls: storage.hideWithControls === true,
controllerLocation: storage.controllerLocation,
shortcutTargetMode: storage.shortcutTargetMode
},
matchedSiteRule: {
matched: Boolean(siteRule),
disabled: config.siteRuleDisabled === true,
overrideKeys: siteRule
? Object.keys(siteRule)
.filter(function(key) {
return key !== "pattern" && key !== "title";
})
.sort()
: []
},
tabPaused: config.tabPaused === true,
frame: frame && frame.diagnostics ? frame.diagnostics : null
},
null,
2
);
}
return { return {
buildDiagnosticReport: buildDiagnosticReport,
pickBestFrameSpeedResult: pickBestFrameSpeedResult, pickBestFrameSpeedResult: pickBestFrameSpeedResult,
resolvePopupButtons: resolvePopupButtons, resolvePopupButtons: resolvePopupButtons,
sanitizeButtonOrder: sanitizeButtonOrder sanitizeButtonOrder: sanitizeButtonOrder
+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,
+30 -48
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/",
@@ -590,6 +615,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
div: wrapper, div: wrapper,
normalControllerMount: normalMount normalControllerMount: normalMount
}; };
wrapper.showPopover = vi.fn();
window.setupControllerHostTracking(controller, wrapper, normalMount); window.setupControllerHostTracking(controller, wrapper, normalMount);
Object.defineProperty(document, "fullscreenElement", { Object.defineProperty(document, "fullscreenElement", {
configurable: true, configurable: true,
@@ -598,6 +624,8 @@ describe("inject.js media/controller lifecycle regressions", () => {
window.syncControllerFullscreenMount(controller); window.syncControllerFullscreenMount(controller);
expect(fullscreenPlayer.contains(wrapper)).toBe(true); expect(fullscreenPlayer.contains(wrapper)).toBe(true);
expect(wrapper.showPopover).not.toHaveBeenCalled();
expect(wrapper.hasAttribute("popover")).toBe(false);
Object.defineProperty(document, "fullscreenElement", { Object.defineProperty(document, "fullscreenElement", {
configurable: true, configurable: true,
@@ -610,53 +638,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
controller.controllerHostCleanup(); controller.controllerHostCleanup();
}); });
it("promotes an ancestor-fullscreen controller into the browser top layer", async () => { it("only promotes the directly-fullscreen video's controller", async () => {
bootInject();
await settleLifecycle();
const fullscreenPlayer = document.createElement("div");
const video = document.createElement("video");
const wrapper = document.createElement("div");
const rect = makeRect(0, 0, 1280, 720);
fullscreenPlayer.append(video, wrapper);
document.body.appendChild(fullscreenPlayer);
[fullscreenPlayer, video].forEach((element) => {
setRect(element, rect);
setBoxMetrics(element, rect.width, rect.height);
});
wrapper.showPopover = vi.fn();
wrapper.hidePopover = vi.fn();
const controller = {
video,
div: wrapper,
normalControllerMount: fullscreenPlayer
};
window.setupControllerHostTracking(controller, wrapper, fullscreenPlayer);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: fullscreenPlayer
});
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
expect(wrapper.parentNode).toBe(fullscreenPlayer);
expect(wrapper.showPopover).toHaveBeenCalledOnce();
expect(wrapper.getAttribute("popover")).toBe("manual");
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
value: null
});
window.syncControllerFullscreenMount(controller);
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
wrapper.remove();
controller.controllerHostCleanup();
});
it("only promotes the controller owned by the fullscreen player", async () => {
bootInject(); bootInject();
await settleLifecycle(); await settleLifecycle();
@@ -680,7 +662,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
Object.defineProperty(document, "fullscreenElement", { Object.defineProperty(document, "fullscreenElement", {
configurable: true, configurable: true,
value: fullscreenPlayer value: fullscreenVideo
}); });
window.syncControllerFullscreenMount(fullscreenVideo.vsc); window.syncControllerFullscreenMount(fullscreenVideo.vsc);
window.syncControllerFullscreenMount(otherVideo.vsc); window.syncControllerFullscreenMount(otherVideo.vsc);
+36
View File
@@ -84,6 +84,42 @@ describe("options.js", () => {
expect(document.getElementById("status").textContent).toBe("Auto-saved"); expect(document.getElementById("status").textContent).toBe("Auto-saved");
}); });
it("copies privacy-safe diagnostics from settings", async () => {
bootOptions({
syncData: {
enabled: false,
rememberSpeed: true,
siteRules: [
{
title: "Private account",
pattern: "secret.example/private-token",
enabled: true
}
]
}
});
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText }
});
await flushAsyncWork(3);
document.getElementById("copyDiagnostics").click();
await flushAsyncWork(3);
const reportText = writeText.mock.calls[0][0];
const report = JSON.parse(reportText);
expect(report.globalSettings.enabled).toBe(false);
expect(report.globalSettings.rememberSpeed).toBe(true);
expect(report.page).toEqual({ protocol: null, hostname: null });
expect(report.matchedSiteRule.matched).toBe(false);
expect(report.frame).toBeNull();
expect(reportText).not.toContain("Private account");
expect(reportText).not.toContain("private-token");
expect(document.getElementById("status").textContent).toContain("copied");
});
it("does not partially save options when a required site shortcut is invalid", async () => { it("does not partially save options when a required site shortcut is invalid", async () => {
const chrome = bootOptions({ syncData: { rememberSpeed: false } }); const chrome = bootOptions({ syncData: { rememberSpeed: false } });
await flushAsyncWork(3); await flushAsyncWork(3);
+103 -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 }
@@ -167,6 +168,107 @@ describe("popup.js", () => {
); );
}); });
it("copies redacted diagnostics for the active media frame", async () => {
bootPopup({
activeTab: {
id: 12,
active: true,
url: "https://video.example/watch?private_token=secret"
},
executeScriptImpl: (tabId, details, callback) => {
callback([
{
speed: 1.5,
preferred: true,
diagnostics: {
mediaType: "video",
fullscreen: { active: true, element: "div", ownsMedia: true },
controller: { present: true, hidden: false }
}
}
]);
}
});
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText }
});
await flushAsyncWork();
document.querySelector("#copyDiagnostics").click();
await flushAsyncWork();
expect(writeText).toHaveBeenCalled();
const reportText = writeText.mock.calls.at(-1)[0];
const report = JSON.parse(reportText);
expect(report.page).toEqual({
protocol: "https:",
hostname: "video.example"
});
expect(report.frame.mediaType).toBe("video");
expect(reportText).not.toContain("private_token");
expect(reportText).not.toContain("secret");
expect(document.querySelector("#status").textContent).toContain("copied");
});
it("adds one safe origin rule for the current site", async () => {
const chrome = bootPopup({
activeTab: {
id: 18,
active: true,
url: "https://courses.example:8443/watch/lesson?token=private"
}
});
await flushAsyncWork();
document.querySelector("#addSiteRule").click();
await flushAsyncWork();
document.querySelector("#addSiteRule").click();
await flushAsyncWork();
const rules = window.vscExpandStoredSettings(
chrome.storage.sync._dump()
).siteRules.filter(function(rule) {
return rule.pattern === "https://courses.example:8443";
});
expect(rules).toEqual([
{
title: "courses.example:8443",
pattern: "https://courses.example:8443",
enabled: true
}
]);
expect(window.open).toHaveBeenCalledWith(
"moz-extension://options/options.html"
);
});
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: {