Compare commits

..

22 Commits

Author SHA1 Message Date
joshpatra 25d3acf576 v5.1.7.0-beta.1 2026-04-02 22:34:00 -04:00
joshpatra 7b8b4324af Bump version to 5.1.7.0 2026-04-02 22:33:59 -04:00
joshpatra 8b9e4bea1d fix: refresh site rules on DOM video changes 2026-04-02 21:16:37 -04:00
joshpatra 8c94cc2088 fix: clarify site rule auto-hide copy 2026-04-02 21:08:15 -04:00
joshpatra 19d3af02a2 refactor: store settings as sparse diffs 2026-04-02 21:07:31 -04:00
joshpatra 306e0e3ea0 Exclude Lucide cache from backups 2026-04-02 20:47:32 -04:00
joshpatra 1536c13c3e v5.1.6-beta.1 2026-04-02 18:20:49 -04:00
joshpatra 6bd319c8cc Bump version to 5.1.6 2026-04-02 18:20:48 -04:00
joshpatra 3aee8c8f9a fix: errors from web-ext 2026-04-02 18:20:33 -04:00
joshpatra 939ee08466 v5.1.4-beta.1 2026-04-02 14:17:18 -04:00
joshpatra 5a175c3cf8 Bump version to 5.1.4 2026-04-02 14:17:17 -04:00
joshpatra 805e5a82e5 fix: unicode reset glyph fallback in extension popup 2026-04-02 14:16:53 -04:00
joshpatra df34b1fee9 feat: Lucide subtitle nudge on/off targets and dual preview in options 2026-04-02 14:16:46 -04:00
joshpatra 0741c6e535 feat: custom Lucide icons for subtitle nudge on/off in inject 2026-04-02 14:16:40 -04:00
joshpatra fad0c49e65 v5.1.3-beta.1 2026-04-02 13:56:22 -04:00
joshpatra 66075fb6f3 Bump version to 5.1.3 2026-04-02 13:56:21 -04:00
joshpatra bf4025dcb4 fix: settings update 2026-04-02 13:54:01 -04:00
joshpatra 76a7b933bb v5.1.2-beta.1 2026-04-02 13:52:04 -04:00
joshpatra 1cd533fc5c Bump version to 5.1.2 2026-04-02 13:52:02 -04:00
joshpatra 8c5bd68d39 fix: popup control bar section layout in options 2026-04-02 13:44:03 -04:00
joshpatra 9c257af446 feat: omit settings from popup control bar 2026-04-02 13:43:56 -04:00
joshpatra 64a9b85587 fix: control bar icon clicks, hover/focus-within, nudge action 2026-04-02 13:43:43 -04:00
13 changed files with 1541 additions and 428 deletions
+2
View File
@@ -10,6 +10,8 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
WEB_EXT_IGNORE_FILES: scripts/**
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+124 -55
View File
@@ -1,5 +1,55 @@
// Import/Export functionality for Video Speed Controller settings // Import/Export functionality for Video Speed Controller settings
const EXPORTABLE_LOCAL_SETTINGS_KEYS = ["customButtonIcons"];
function getExportableLocalSettings(localStorage) {
const exportable = {};
const customButtonIcons =
localStorage &&
localStorage.customButtonIcons &&
typeof localStorage.customButtonIcons === "object" &&
!Array.isArray(localStorage.customButtonIcons)
? localStorage.customButtonIcons
: null;
if (customButtonIcons) {
exportable.customButtonIcons = customButtonIcons;
}
return exportable;
}
function replaceImportableLocalSettings(localSettings, callback) {
chrome.storage.local.remove(EXPORTABLE_LOCAL_SETTINGS_KEYS, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to clear local icon overrides - " +
chrome.runtime.lastError.message,
true
);
return;
}
if (!localSettings || Object.keys(localSettings).length === 0) {
callback();
return;
}
chrome.storage.local.set(localSettings, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save local icon overrides - " +
chrome.runtime.lastError.message,
true
);
return;
}
callback();
});
});
}
function generateBackupFilename() { function generateBackupFilename() {
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();
@@ -11,30 +61,69 @@ function generateBackupFilename() {
return `speeder-backup_${year}-${month}-${day}_${hours}.${minutes}.${seconds}.json`; return `speeder-backup_${year}-${month}-${day}_${hours}.${minutes}.${seconds}.json`;
} }
function getBackupManifestVersion() {
var manifest = chrome.runtime.getManifest();
return manifest && manifest.version ? manifest.version : "unknown";
}
function getExportableSyncSettings(syncStorage) {
return vscBuildStoredSettingsDiff(vscExpandStoredSettings(syncStorage));
}
function getImportableSyncSettings(backup, rawSettings) {
var importable = vscClonePlainData(rawSettings) || {};
if (
backup &&
backup.siteRulesFormat &&
importable.siteRulesFormat === undefined
) {
importable.siteRulesFormat = backup.siteRulesFormat;
}
if (
backup &&
backup.siteRulesMeta &&
importable.siteRulesMeta === undefined
) {
importable.siteRulesMeta = backup.siteRulesMeta;
}
return vscExpandStoredSettings(importable);
}
function exportSettings() { function exportSettings() {
chrome.storage.sync.get(null, function (storage) { chrome.storage.sync.get(null, function (storage) {
chrome.storage.local.get(null, function (localStorage) { chrome.storage.local.get(
const backup = { EXPORTABLE_LOCAL_SETTINGS_KEYS,
version: "1.1", function (localStorage) {
exportDate: new Date().toISOString(), const localSettings = getExportableLocalSettings(localStorage);
settings: storage, const syncSettings = getExportableSyncSettings(storage);
localSettings: localStorage || {} const backup = {
}; version: getBackupManifestVersion(),
exportDate: new Date().toISOString(),
settings: syncSettings
};
const dataStr = JSON.stringify(backup, null, 2); if (Object.keys(localSettings).length > 0) {
const blob = new Blob([dataStr], { type: "application/json" }); backup.localSettings = localSettings;
const url = URL.createObjectURL(blob); }
const link = document.createElement("a"); const dataStr = JSON.stringify(backup, null, 2);
link.href = url; const blob = new Blob([dataStr], { type: "application/json" });
link.download = generateBackupFilename(); const url = URL.createObjectURL(blob);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus("Settings exported successfully"); const link = document.createElement("a");
}); link.href = url;
link.download = generateBackupFilename();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus("Settings exported successfully");
}
);
}); });
} }
@@ -55,9 +144,9 @@ function importSettings() {
// Detect backup format: check for 'settings' wrapper or raw storage keys // Detect backup format: check for 'settings' wrapper or raw storage keys
if (backup.settings && typeof backup.settings === "object") { if (backup.settings && typeof backup.settings === "object") {
settingsToImport = backup.settings; settingsToImport = getImportableSyncSettings(backup, backup.settings);
} else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) { } else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) {
settingsToImport = backup; // Raw storage object settingsToImport = getImportableSyncSettings(backup, backup);
} }
if (!settingsToImport) { if (!settingsToImport) {
@@ -65,49 +154,29 @@ function importSettings() {
return; return;
} }
var localToImport = var localToImport = getExportableLocalSettings(backup.localSettings);
backup.localSettings && typeof backup.localSettings === "object"
? backup.localSettings
: null;
function afterLocalImport() { function afterLocalImport() {
chrome.storage.sync.clear(function () { persistManagedSyncSettings(settingsToImport, function (error) {
chrome.storage.sync.set(settingsToImport, function () { if (error) {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save imported settings - " +
chrome.runtime.lastError.message,
true
);
return;
}
showStatus("Settings imported successfully. Reloading...");
setTimeout(function () {
if (typeof restore_options === "function") {
restore_options();
} else {
location.reload();
}
}, 500);
});
});
}
if (localToImport && Object.keys(localToImport).length > 0) {
chrome.storage.local.set(localToImport, function () {
if (chrome.runtime.lastError) {
showStatus( showStatus(
"Error: Failed to save local extension data - " + "Error: Failed to save imported settings - " + error.message,
chrome.runtime.lastError.message,
true true
); );
return; return;
} }
afterLocalImport(); showStatus("Settings imported successfully. Reloading...");
setTimeout(function () {
if (typeof restore_options === "function") {
restore_options();
} else {
location.reload();
}
}, 500);
}); });
} else {
afterLocalImport();
} }
replaceImportableLocalSettings(localToImport, afterLocalImport);
} catch (err) { } catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true); showStatus("Error: Failed to parse backup file - " + err.message, true);
} }
+318 -132
View File
@@ -1,5 +1,6 @@
var isUserSeek = false; // Track if seek was user-initiated var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video var lastToggleSpeed = {}; // Store last toggle speeds per video
var sharedSettingsDefaults = vscGetSettingsDefaults();
function getPrimaryVideoElement() { function getPrimaryVideoElement() {
if (!tc.mediaElements || tc.mediaElements.length === 0) return null; if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
@@ -12,31 +13,37 @@ function getPrimaryVideoElement() {
var tc = { var tc = {
settings: { settings: {
lastSpeed: 1.0, lastSpeed: sharedSettingsDefaults.lastSpeed,
enabled: true, enabled: sharedSettingsDefaults.enabled,
speeds: {}, speeds: {},
displayKeyCode: 86, displayKeyCode: sharedSettingsDefaults.displayKeyCode,
rememberSpeed: false, rememberSpeed: sharedSettingsDefaults.rememberSpeed,
forceLastSavedSpeed: false, forceLastSavedSpeed: sharedSettingsDefaults.forceLastSavedSpeed,
audioBoolean: false, audioBoolean: sharedSettingsDefaults.audioBoolean,
startHidden: false, startHidden: sharedSettingsDefaults.startHidden,
hideWithYouTubeControls: false, hideWithYouTubeControls: sharedSettingsDefaults.hideWithYouTubeControls,
hideWithControls: false, hideWithControls: sharedSettingsDefaults.hideWithControls,
hideWithControlsTimer: 2.0, hideWithControlsTimer: sharedSettingsDefaults.hideWithControlsTimer,
controllerLocation: "top-left", controllerLocation: sharedSettingsDefaults.controllerLocation,
controllerOpacity: 0.3, controllerOpacity: sharedSettingsDefaults.controllerOpacity,
controllerMarginTop: 0, controllerMarginTop: sharedSettingsDefaults.controllerMarginTop,
controllerMarginRight: 0, controllerMarginRight: sharedSettingsDefaults.controllerMarginRight,
controllerMarginBottom: 65, controllerMarginBottom: sharedSettingsDefaults.controllerMarginBottom,
controllerMarginLeft: 0, controllerMarginLeft: sharedSettingsDefaults.controllerMarginLeft,
keyBindings: [], keyBindings: Array.isArray(sharedSettingsDefaults.keyBindings)
siteRules: [], ? sharedSettingsDefaults.keyBindings.slice()
controllerButtons: ["rewind", "slower", "faster", "advance", "display"], : [],
siteRules: Array.isArray(sharedSettingsDefaults.siteRules)
? sharedSettingsDefaults.siteRules.slice()
: [],
controllerButtons: Array.isArray(sharedSettingsDefaults.controllerButtons)
? sharedSettingsDefaults.controllerButtons.slice()
: ["rewind", "slower", "faster", "advance", "display"],
defaultLogLevel: 3, defaultLogLevel: 3,
logLevel: 3, logLevel: 3,
enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube enableSubtitleNudge: sharedSettingsDefaults.enableSubtitleNudge,
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost subtitleNudgeInterval: sharedSettingsDefaults.subtitleNudgeInterval,
subtitleNudgeAmount: 0.001, subtitleNudgeAmount: sharedSettingsDefaults.subtitleNudgeAmount,
customButtonIcons: {} customButtonIcons: {}
}, },
mediaElements: [], mediaElements: [],
@@ -121,7 +128,7 @@ var controllerButtonDefs = {
faster: { label: "", className: "" }, faster: { label: "", className: "" },
advance: { label: "", className: "rw" }, advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" }, display: { label: "", className: "hideButton" },
reset: { label: "", className: "" }, reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" }, fast: { label: "", className: "" },
settings: { label: "", className: "" }, settings: { label: "", className: "" },
pause: { label: "", className: "" }, pause: { label: "", className: "" },
@@ -184,6 +191,8 @@ function createDefaultBinding(action, key, keyCode, value) {
action: action, action: action,
key: key, key: key,
keyCode: keyCode, keyCode: keyCode,
code: null,
disabled: false,
value: value, value: value,
force: false, force: false,
predefined: true predefined: true
@@ -220,7 +229,7 @@ function defaultKeyBindings(storage) {
"reset", "reset",
"R", "R",
Number(storage.resetKeyCode) || 82, Number(storage.resetKeyCode) || 82,
1.0 0
), ),
createDefaultBinding( createDefaultBinding(
"fast", "fast",
@@ -429,6 +438,165 @@ function applyControllerLocation(videoController, location) {
); );
} }
function getYouTubeAutoHidePlayer(video) {
if (!video || !isOnYouTube()) return null;
return video.closest(".html5-video-player") || video.closest("#movie_player");
}
function getAutoHideModeForVideo(video) {
if (!tc.settings.hideWithControls) return "off";
return getYouTubeAutoHidePlayer(video) ? "youtube" : "generic";
}
function getControllerMountParent(video, parentHint) {
var parentEl = parentHint || (video && (video.parentElement || video.parentNode));
if (!parentEl) return null;
switch (true) {
case location.hostname == "www.amazon.com":
case location.hostname == "www.reddit.com":
case /hbogo\./.test(location.hostname):
return parentEl.parentElement || parentEl;
case location.hostname == "www.facebook.com":
var facebookParent = parentEl;
for (
var depth = 0;
depth < 8 && facebookParent && facebookParent.parentElement;
depth++
) {
facebookParent = facebookParent.parentElement;
}
return facebookParent || parentEl;
case location.hostname == "tv.apple.com":
var root = parentEl.getRootNode();
var scrim = root && root.querySelector ? root.querySelector(".scrim") : null;
return scrim || parentEl;
case location.hostname == "www.youtube.com":
case location.hostname == "m.youtube.com":
case location.hostname == "music.youtube.com":
return getYouTubeAutoHidePlayer(video) || parentEl;
default:
return parentEl;
}
}
function getControllerBehaviorSignature(video) {
return JSON.stringify({
startHidden: Boolean(tc.settings.startHidden),
hideWithControls: Boolean(tc.settings.hideWithControls),
hideWithControlsTimer: Number(tc.settings.hideWithControlsTimer),
controllerLocation: normalizeControllerLocation(tc.settings.controllerLocation),
controllerOpacity: Number(tc.settings.controllerOpacity),
controllerMarginTop: normalizeControllerMarginPx(tc.settings.controllerMarginTop, 0),
controllerMarginBottom: normalizeControllerMarginPx(
tc.settings.controllerMarginBottom,
0
),
controllerButtons: Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons.slice()
: [],
enableSubtitleNudge: Boolean(tc.settings.enableSubtitleNudge),
subtitleNudgeInterval: Number(tc.settings.subtitleNudgeInterval),
autoHideMode: getAutoHideModeForVideo(video),
mediaTag: video && video.tagName ? video.tagName : ""
});
}
function rebuildControllerForVideo(video, parentHint, reason) {
if (!video) return null;
var previous = video.vsc || null;
var preservedState = previous
? {
mark: previous.mark,
resetToggleArmed: previous.resetToggleArmed === true,
subtitleNudgeEnabledOverride: previous.subtitleNudgeEnabledOverride,
userHidden:
Boolean(previous.div) &&
previous.div.classList.contains("vsc-hidden")
}
: null;
if (previous) {
previous.remove();
}
if (!video.isConnected || !hasUsableMediaSource(video)) {
return null;
}
var nextController = new tc.videoController(
video,
parentHint || video.parentElement || video.parentNode
);
if (!nextController) return null;
if (preservedState) {
nextController.mark = preservedState.mark;
nextController.resetToggleArmed = preservedState.resetToggleArmed;
if (
typeof preservedState.subtitleNudgeEnabledOverride === "boolean"
) {
nextController.subtitleNudgeEnabledOverride =
preservedState.subtitleNudgeEnabledOverride;
updateSubtitleNudgeIndicator(video);
if (!preservedState.subtitleNudgeEnabledOverride) {
nextController.stopSubtitleNudge();
} else if (!video.paused && video.playbackRate !== 1.0) {
nextController.startSubtitleNudge();
}
}
if (preservedState.userHidden && nextController.div) {
nextController.div.classList.add("vsc-hidden");
}
}
log("Rebuilt controller: " + (reason || "refresh"), 4);
return nextController;
}
function refreshManagedController(video, parentHint) {
if (!video || !video.vsc) return null;
if (!video.isConnected) {
removeController(video);
return null;
}
var controller = video.vsc;
controller.parent = video.parentElement || parentHint || controller.parent;
var expectedMountParent = getControllerMountParent(video, controller.parent);
var nextSignature = getControllerBehaviorSignature(video);
var wrapper = controller.div;
var needsRebuild =
!wrapper ||
!wrapper.isConnected ||
!expectedMountParent ||
wrapper.parentNode !== expectedMountParent ||
controller.behaviorSignature !== nextSignature;
if (needsRebuild) {
return rebuildControllerForVideo(
video,
controller.parent,
"DOM/source/site-rule change"
);
}
controller.mountParent = expectedMountParent;
controller.behaviorSignature = nextSignature;
applyControllerLocation(controller, tc.settings.controllerLocation);
var controllerEl = getControllerElement(controller);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
updateSubtitleNudgeIndicator(video);
return controller;
}
function captureSiteRuleBase() { function captureSiteRuleBase() {
tc.siteRuleBase = { tc.siteRuleBase = {
startHidden: tc.settings.startHidden, startHidden: tc.settings.startHidden,
@@ -776,18 +944,40 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) {
return normalizedEnabled; return normalizedEnabled;
} }
function subtitleNudgeIconMarkup(isEnabled) { function renderSubtitleNudgeIndicatorContent(target, isEnabled) {
if (!target) return;
var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff"; var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff";
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
vscClearElement(target);
if (custom) {
var customWrap = vscCreateSvgWrap(
target.ownerDocument || document,
custom,
"vsc-btn-icon"
);
if (customWrap) {
target.appendChild(customWrap);
return;
}
}
if (typeof vscIconSvgString !== "function") { if (typeof vscIconSvgString !== "function") {
return isEnabled ? "✓" : "×"; target.textContent = isEnabled ? "✓" : "×";
return;
} }
var svg = vscIconSvgString(action, 14); var svg = vscIconSvgString(action, 14);
if (!svg) { if (!svg) {
return isEnabled ? "✓" : "×"; target.textContent = isEnabled ? "✓" : "×";
return;
} }
return ( var wrap = vscCreateSvgWrap(target.ownerDocument || document, svg, "vsc-btn-icon");
'<span class="vsc-btn-icon" aria-hidden="true">' + svg + "</span>" if (wrap) {
); target.appendChild(wrap);
return;
}
target.textContent = isEnabled ? "✓" : "×";
} }
function updateSubtitleNudgeIndicator(video) { function updateSubtitleNudgeIndicator(video) {
@@ -795,11 +985,10 @@ function updateSubtitleNudgeIndicator(video) {
var isEnabled = isSubtitleNudgeEnabledForVideo(video); var isEnabled = isSubtitleNudgeEnabledForVideo(video);
var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled"; var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled";
var mark = subtitleNudgeIconMarkup(isEnabled);
var indicator = video.vsc.subtitleNudgeIndicator; var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) { if (indicator) {
indicator.innerHTML = mark; renderSubtitleNudgeIndicatorContent(indicator, isEnabled);
indicator.dataset.enabled = isEnabled ? "true" : "false"; indicator.dataset.enabled = isEnabled ? "true" : "false";
indicator.dataset.supported = "true"; indicator.dataset.supported = "true";
indicator.title = title; indicator.title = title;
@@ -808,7 +997,7 @@ function updateSubtitleNudgeIndicator(video) {
var flashEl = video.vsc.nudgeFlashIndicator; var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) { if (flashEl) {
flashEl.innerHTML = mark; renderSubtitleNudgeIndicatorContent(flashEl, isEnabled);
flashEl.dataset.enabled = isEnabled ? "true" : "false"; flashEl.dataset.enabled = isEnabled ? "true" : "false";
flashEl.dataset.supported = "true"; flashEl.dataset.supported = "true";
flashEl.setAttribute("aria-label", title); flashEl.setAttribute("aria-label", title);
@@ -889,8 +1078,8 @@ function applySourceTransitionPolicy(video, forceUpdate) {
setSpeed(video, desiredSpeed, false, false); setSpeed(video, desiredSpeed, false, false);
} }
// Same-tab SPA (e.g. YouTube watch → Shorts): URL can change while remember-speed // Same-tab SPA or DOM-driven player swaps can change the effective rule output
// already ran on src mutation — re-apply margins / location / opacity for new rules. // after the media source updates, so refresh or rebuild controllers here too.
reapplySiteRulesAndControllerGeometry(); reapplySiteRulesAndControllerGeometry();
} }
@@ -1012,8 +1201,14 @@ function hasUsableMediaSource(node) {
} }
function ensureController(node, parent) { function ensureController(node, parent) {
if (!isMediaElement(node) || node.vsc) return node && node.vsc; if (!isMediaElement(node)) return node && node.vsc;
if (!hasUsableMediaSource(node)) {
if (!node.isConnected) {
removeController(node);
return null;
}
if (!node.vsc && !hasUsableMediaSource(node)) {
log( log(
`Deferring controller creation for ${node.tagName}: no usable source yet`, `Deferring controller creation for ${node.tagName}: no usable source yet`,
5 5
@@ -1024,9 +1219,13 @@ function ensureController(node, parent) {
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL. // href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
var siteDisabled = applySiteRuleOverrides(); var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) { if (!tc.settings.enabled || siteDisabled) {
removeController(node);
return null; return null;
} }
refreshAllControllerGeometry();
if (node.vsc) {
return refreshManagedController(node, parent);
}
log( log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`, `Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
@@ -1170,7 +1369,8 @@ function log(message, level) {
} }
} }
chrome.storage.sync.get(tc.settings, function (storage) { chrome.storage.sync.get(null, function (storage) {
storage = vscExpandStoredSettings(storage);
var storedBindings = Array.isArray(storage.keyBindings) var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings ? storage.keyBindings
: []; : [];
@@ -1181,19 +1381,6 @@ chrome.storage.sync.get(tc.settings, function (storage) {
if (tc.settings.keyBindings.length === 0) { if (tc.settings.keyBindings.length === 0) {
tc.settings.keyBindings = defaultKeyBindings(storage); tc.settings.keyBindings = defaultKeyBindings(storage);
tc.settings.version = "0.5.3";
chrome.storage.sync.set({
keyBindings: tc.settings.keyBindings,
version: tc.settings.version,
displayKeyCode: tc.settings.displayKeyCode,
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
startHidden: tc.settings.startHidden,
enabled: tc.settings.enabled,
controllerLocation: tc.settings.controllerLocation,
controllerOpacity: tc.settings.controllerOpacity
});
} }
tc.settings.lastSpeed = Number(storage.lastSpeed); tc.settings.lastSpeed = Number(storage.lastSpeed);
if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) { if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) {
@@ -1271,7 +1458,14 @@ chrome.storage.sync.get(tc.settings, function (storage) {
addedDefaultBinding; addedDefaultBinding;
if (addedDefaultBinding) { if (addedDefaultBinding) {
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings }); var keyBindingsDiff = vscBuildStoredSettingsDiff({
keyBindings: tc.settings.keyBindings
});
if (Object.prototype.hasOwnProperty.call(keyBindingsDiff, "keyBindings")) {
chrome.storage.sync.set({ keyBindings: keyBindingsDiff.keyBindings });
} else {
chrome.storage.sync.remove("keyBindings");
}
} }
captureSiteRuleBase(); captureSiteRuleBase();
patchAttachShadow(); patchAttachShadow();
@@ -1348,12 +1542,15 @@ chrome.storage.sync.get(tc.settings, function (storage) {
tc.settings.customButtonIcons && tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[act] && tc.settings.customButtonIcons[act] &&
tc.settings.customButtonIcons[act].svg; tc.settings.customButtonIcons[act].svg;
btn.innerHTML = ""; vscClearElement(btn);
if (svg) { if (svg) {
var cw = doc.createElement("span"); var cw = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
cw.className = "vsc-btn-icon"; if (cw) {
cw.innerHTML = svg; btn.appendChild(cw);
btn.appendChild(cw); } else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else if (typeof vscIconWrap === "function") { } else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, act, 14); var wrap = vscIconWrap(doc, act, 14);
if (wrap) { if (wrap) {
@@ -1367,6 +1564,7 @@ chrome.storage.sync.get(tc.settings, function (storage) {
btn.textContent = (cdf2 && cdf2.label) || "?"; btn.textContent = (cdf2 && cdf2.label) || "?";
} }
}); });
updateSubtitleNudgeIndicator(video);
}); });
} }
}); });
@@ -1395,10 +1593,12 @@ function createControllerButton(doc, action, label, className) {
tc.settings.customButtonIcons[action] && tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg; tc.settings.customButtonIcons[action].svg;
if (custom) { if (custom) {
var customWrap = doc.createElement("span"); var customWrap = vscCreateSvgWrap(doc, custom, "vsc-btn-icon");
customWrap.className = "vsc-btn-icon"; if (customWrap) {
customWrap.innerHTML = custom; button.appendChild(customWrap);
button.appendChild(customWrap); } else {
button.textContent = label || "?";
}
} else if (typeof vscIconWrap === "function") { } else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, action, 14); var wrap = vscIconWrap(doc, action, 14);
if (wrap) { if (wrap) {
@@ -1456,7 +1656,14 @@ function defineVideoController() {
return; return;
} }
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, 4); this.mountParent =
this.div.parentNode || getControllerMountParent(target, this.parent);
this.behaviorSignature = getControllerBehaviorSignature(target);
log(
`Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`,
4
);
var mediaEventAction = function (event) { var mediaEventAction = function (event) {
if ( if (
@@ -1730,10 +1937,10 @@ function defineVideoController() {
}; };
tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) { tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) {
if (!wrapper || !isOnYouTube()) return; if (!wrapper) return;
const video = this.video; const video = this.video;
const ytPlayer = video.closest(".html5-video-player"); const ytPlayer = getYouTubeAutoHidePlayer(video);
if (!ytPlayer) { if (!ytPlayer) {
log("YouTube player not found for auto-hide setup", 4); log("YouTube player not found for auto-hide setup", 4);
return; return;
@@ -1873,7 +2080,8 @@ function defineVideoController() {
wrapper.classList.add("vsc-nosource"); wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden"); if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// Use lower z-index for non-YouTube sites to avoid overlapping modals // Use lower z-index for non-YouTube sites to avoid overlapping modals
if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube"); if (!getYouTubeAutoHidePlayer(this.video))
wrapper.classList.add("vsc-non-youtube");
var shadow = wrapper.attachShadow({ mode: "open" }); var shadow = wrapper.attachShadow({ mode: "open" });
var shadowStylesheet = doc.createElement("link"); var shadowStylesheet = doc.createElement("link");
shadowStylesheet.rel = "stylesheet"; shadowStylesheet.rel = "stylesheet";
@@ -1939,14 +2147,20 @@ function defineVideoController() {
if (subtitleNudgeIndicator) { if (subtitleNudgeIndicator) {
updateSubtitleNudgeIndicator(this.video); updateSubtitleNudgeIndicator(this.video);
} }
function blurAfterPointerTap(target, e) {
if (!target || typeof target.blur !== "function") return;
var pt = e.pointerType;
if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) {
requestAnimationFrame(function () {
target.blur();
});
}
}
dragHandle.addEventListener( dragHandle.addEventListener(
"mousedown", "mousedown",
(e) => { (e) => {
runAction( var dragAction = dragHandle.dataset.action;
e.target.dataset["action"], runAction(dragAction, getKeyBindings(dragAction, "value"), e);
getKeyBindings(e.target.dataset["action"], "value"),
e
);
e.stopPropagation(); e.stopPropagation();
}, },
true true
@@ -1955,11 +2169,9 @@ function defineVideoController() {
button.addEventListener( button.addEventListener(
"click", "click",
(e) => { (e) => {
runAction( var action = button.dataset.action;
e.target.dataset["action"], runAction(action, getKeyBindings(action), e);
getKeyBindings(e.target.dataset["action"]), blurAfterPointerTap(button, e);
e
);
e.stopPropagation(); e.stopPropagation();
}, },
true true
@@ -1974,6 +2186,7 @@ function defineVideoController() {
var newState = !isSubtitleNudgeEnabledForVideo(video); var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState); setSubtitleNudgeEnabledForVideo(video, newState);
} }
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation(); e.stopPropagation();
}, },
true true
@@ -1984,7 +2197,7 @@ function defineVideoController() {
// Setup auto-hide observers if enabled // Setup auto-hide observers if enabled
if (tc.settings.hideWithControls) { if (tc.settings.hideWithControls) {
if (isOnYouTube()) { if (getAutoHideModeForVideo(this.video) === "youtube") {
this.setupYouTubeAutoHide(wrapper); this.setupYouTubeAutoHide(wrapper);
} else { } else {
this.setupGenericAutoHide(wrapper); this.setupGenericAutoHide(wrapper);
@@ -2056,60 +2269,26 @@ function defineVideoController() {
}; };
} }
function escapeStringRegExp(str) {
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function applySiteRuleOverrides() { function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase(); resetSettingsFromSiteRuleBase();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) { if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false; return false;
} }
var currentUrl = location.href; var currentUrl = location.href;
var matchedRule = null; var matchedRule = vscMatchSiteRule(currentUrl, tc.settings.siteRules);
for (var i = 0; i < tc.settings.siteRules.length; i++) {
var rule = tc.settings.siteRules[i];
var pattern = rule.pattern;
if (!pattern || pattern.length === 0) continue;
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (e) {
log(`Invalid site rule regex: ${pattern}. ${e.message}`, 2);
continue;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
if (regex && regex.test(currentUrl)) {
matchedRule = rule;
break;
}
}
if (!matchedRule) return false; if (!matchedRule) return false;
tc.activeSiteRule = matchedRule; tc.activeSiteRule = matchedRule;
log(`Matched site rule: ${matchedRule.pattern}`, 4); log("Matched site rule overrides for current URL", 4);
// Check if extension should be enabled/disabled on this site // Check if extension should be enabled/disabled on this site
if (matchedRule.enabled === false) { if (vscIsSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4); log(`Extension disabled for site: ${currentUrl}`, 4);
return true; return true;
} else if (matchedRule.disableExtension === true) {
// Handle old format
log(`Extension disabled (legacy) for site: ${currentUrl}`, 4);
return true;
} }
// Override general settings with site-specific overrides // Override general settings with site-specific overrides
@@ -2167,23 +2346,22 @@ function applySiteRuleOverrides() {
return false; return false;
} }
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */ /** Re-match site rules for current URL and refresh or rebuild every controller. */
function refreshAllControllerGeometry() {
tc.mediaElements.forEach(function (video) {
if (!video || !video.vsc) return;
applyControllerLocation(video.vsc, tc.settings.controllerLocation);
var controllerEl = getControllerElement(video.vsc);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
});
}
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() { function reapplySiteRulesAndControllerGeometry() {
var siteDisabled = applySiteRuleOverrides(); var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return; var videos = tc.mediaElements.slice();
refreshAllControllerGeometry();
if (!tc.settings.enabled || siteDisabled) {
videos.forEach(function (video) {
removeController(video);
});
return;
}
videos.forEach(function (video) {
if (!video) return;
ensureController(video, video.parentElement || video.parentNode);
});
} }
function shouldPreserveDesiredSpeed(video, speed) { function shouldPreserveDesiredSpeed(video, speed) {
@@ -2524,7 +2702,6 @@ function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return; if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
var siteDisabled = applySiteRuleOverrides(); var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
if (!doc.body.classList.contains("vsc-initialized")) { if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized"); doc.body.classList.add("vsc-initialized");
@@ -2536,7 +2713,9 @@ function initializeNow(doc, forceReinit = false) {
if (forceReinit) { if (forceReinit) {
log("Force re-initialization requested", 4); log("Force re-initialization requested", 4);
refreshAllControllerGeometry(); reapplySiteRulesAndControllerGeometry();
} else if (!tc.settings.enabled || siteDisabled) {
reapplySiteRulesAndControllerGeometry();
} }
vscInitializedDocuments.add(doc); vscInitializedDocuments.add(doc);
@@ -2667,6 +2846,7 @@ function runAction(action, value, e) {
"mark", "mark",
"jump", "jump",
"drag", "drag",
"nudge",
"toggleSubtitleNudge", "toggleSubtitleNudge",
"display" "display"
]; ];
@@ -2782,6 +2962,12 @@ function runAction(action, value, e) {
case "toggleSubtitleNudge": case "toggleSubtitleNudge":
setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue); setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue);
break; break;
case "nudge":
setSubtitleNudgeEnabledForVideo(
v,
!isSubtitleNudgeEnabledForVideo(v)
);
break;
} }
}); });
log("runAction End", 5); log("runAction End", 5);
+2 -25
View File
@@ -31,32 +31,9 @@ function sanitizeLucideSvg(svgText) {
var t = String(svgText).replace(/\0/g, "").trim(); var t = String(svgText).replace(/\0/g, "").trim();
if (!/<svg[\s>]/i.test(t)) return null; if (!/<svg[\s>]/i.test(t)) return null;
var doc = new DOMParser().parseFromString(t, "image/svg+xml"); var doc = new DOMParser().parseFromString(t, "image/svg+xml");
var svg = doc.querySelector("svg"); if (doc.querySelector("parsererror")) return null;
var svg = vscSanitizeSvgTree(doc.querySelector("svg"));
if (!svg) return null; if (!svg) return null;
svg.querySelectorAll("script").forEach(function (n) {
n.remove();
});
svg.querySelectorAll("style").forEach(function (n) {
n.remove();
});
svg.querySelectorAll("*").forEach(function (el) {
for (var i = el.attributes.length - 1; i >= 0; i--) {
var attr = el.attributes[i];
var name = attr.name.toLowerCase();
var val = attr.value;
if (name.indexOf("on") === 0) {
el.removeAttribute(attr.name);
continue;
}
if (
(name === "href" || name === "xlink:href") &&
/^javascript:/i.test(val)
) {
el.removeAttribute(attr.name);
}
}
});
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svg.removeAttribute("width"); svg.removeAttribute("width");
svg.removeAttribute("height"); svg.removeAttribute("height");
svg.setAttribute("width", "100%"); svg.setAttribute("width", "100%");
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "Speeder", "name": "Speeder",
"short_name": "Speeder", "short_name": "Speeder",
"version": "5.1.1", "version": "5.1.7.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",
@@ -59,6 +59,7 @@
"inject.css" "inject.css"
], ],
"js": [ "js": [
"settings-core.js",
"ui-icons.js", "ui-icons.js",
"inject.js" "inject.js"
] ]
+45
View File
@@ -545,6 +545,51 @@ label em {
flex-shrink: 0; flex-shrink: 0;
} }
.cb-icon.cb-icon-nudge-pair {
width: auto;
min-width: 0;
padding: 0 4px;
gap: 4px;
background: transparent;
border: none;
}
.cb-nudge-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
color: #fff;
}
.cb-nudge-chip[data-nudge-state="on"] {
background: #4b9135;
border: 1px solid #6ec754;
}
.cb-nudge-chip[data-nudge-state="off"] {
background: #943e3e;
border: 1px solid #c06060;
}
.cb-nudge-chip .vsc-btn-icon svg,
.cb-nudge-chip svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.cb-nudge-sep {
font-size: 11px;
font-weight: 600;
opacity: 0.45;
color: var(--text);
flex-shrink: 0;
}
.row-lucide-pair select { .row-lucide-pair select {
justify-self: end; justify-self: end;
} }
+44 -31
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speeder Settings</title> <title>Speeder Settings</title>
<link rel="stylesheet" href="options.css" /> <link rel="stylesheet" href="options.css" />
<script src="settings-core.js"></script>
<script src="ui-icons.js"></script> <script src="ui-icons.js"></script>
<script src="lucide-client.js"></script> <script src="lucide-client.js"></script>
<script src="options.js"></script> <script src="options.js"></script>
@@ -272,11 +273,6 @@
</label> </label>
<input id="hideWithControlsTimer" type="text" placeholder="2" /> <input id="hideWithControlsTimer" type="text" placeholder="2" />
</div> </div>
<div class="row row-checkbox">
<label for="showPopupControlBar">Show popup control bar</label>
<input id="showPopupControlBar" type="checkbox" />
</div>
<div class="defaults-divider"></div> <div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Subtitle sync</h4> <h4 class="defaults-sub-heading">Subtitle sync</h4>
@@ -350,7 +346,11 @@
Configure which buttons appear in the browser popup control bar. Configure which buttons appear in the browser popup control bar.
</p> </p>
</div> </div>
<div class="row"> <div class="row row-checkbox">
<label for="showPopupControlBar">Show popup control bar</label>
<input id="showPopupControlBar" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="popupMatchHoverControls">Match hover controls</label> <label for="popupMatchHoverControls">Match hover controls</label>
<input id="popupMatchHoverControls" type="checkbox" /> <input id="popupMatchHoverControls" type="checkbox" />
</div> </div>
@@ -383,9 +383,11 @@
rel="noopener noreferrer" rel="noopener noreferrer"
>Lucide</a >Lucide</a
> >
set (fetched from jsDelivr). Chosen SVGs are cached in local set (fetched from jsDelivr). Custom icons are cached in local
storage and included in settings export. storage and included when you export settings. Subtitle nudge
<strong>Reset speed</strong> stays numeric text only. icons use two menu entries (enabled and disabled), not the bar
block id
<code>nudge</code>.
</p> </p>
</div> </div>
<div class="row row-lucide-pair"> <div class="row row-lucide-pair">
@@ -549,18 +551,29 @@
<span>Override auto-hide for this site</span> <span>Override auto-hide for this site</span>
<input type="checkbox" class="override-autohide" /> <input type="checkbox" class="override-autohide" />
</label> </label>
<div class="site-autohide-container" style="display: none"> <div class="site-autohide-container" style="display: none">
<div class="site-rule-option site-rule-option-checkbox"> <div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label"> <label class="site-rule-split-label">
<span>Hide with controls (idle-based)</span> <span
<input type="checkbox" class="site-hideWithControls" /> >Hide with controls<br /><em
</label> >Fade the controller in and out with the video
interface: perfect sync on YouTube, idle-based
elsewhere.</em
></span
>
<input type="checkbox" class="site-hideWithControls" />
</label>
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Auto-hide timer (seconds)<br /><em
>Seconds of inactivity before hiding: 0.1&ndash;15
for non-YouTube sites.</em
></label
>
<input type="text" class="site-hideWithControlsTimer" />
</div>
</div> </div>
<div class="site-rule-option site-rule-option-field">
<label>Auto-hide timer (0.1&ndash;15s):</label>
<input type="text" class="site-hideWithControlsTimer" />
</div>
</div>
</div> </div>
<div class="site-rule-override-section"> <div class="site-rule-override-section">
<label class="site-override-lead"> <label class="site-override-lead">
@@ -677,6 +690,17 @@
<div id="status" role="status" aria-live="polite"></div> <div id="status" role="status" aria-live="polite"></div>
</section> </section>
<section id="faq" class="settings-card info-card">
<h4>Extension controls not appearing?</h4>
<p>
This extension only works with HTML5 audio and video. If the
controls never appear, you may be looking at Flash content instead.
Right-click the player to check: if the menu mentions Flash, that
is the issue. Most sites will fall back to HTML5 when Flash is not
available, so disabling Flash in the browser can help.
</p>
</section>
<footer class="support-footer settings-card"> <footer class="support-footer settings-card">
<p> <p>
If Speeder has been useful, consider supporting its development via If Speeder has been useful, consider supporting its development via
@@ -695,17 +719,6 @@
>. >.
</p> </p>
</footer> </footer>
<section id="faq" class="settings-card info-card">
<h4>Extension controls not appearing?</h4>
<p>
This extension only works with HTML5 audio and video. If the
controls never appear, you may be looking at Flash content instead.
Right-click the player to check: if the menu mentions Flash, that
is the issue. Most sites will fall back to HTML5 when Flash is not
available, so disabling Flash in the browser can help.
</p>
</section>
</main> </main>
</div> </div>
</body> </body>
+210 -123
View File
@@ -138,7 +138,7 @@ var controllerButtonDefs = {
faster: { icon: "+", name: "Increase speed" }, faster: { icon: "+", name: "Increase speed" },
advance: { icon: "\u00BB", name: "Advance" }, advance: { icon: "\u00BB", name: "Advance" },
display: { icon: "\u00D7", name: "Close controller" }, display: { icon: "\u00D7", name: "Close controller" },
reset: { icon: "", name: "Reset speed" }, reset: { icon: "\u21BB", name: "Reset speed" },
fast: { icon: "\u2605", name: "Preferred speed" }, fast: { icon: "\u2605", name: "Preferred speed" },
nudge: { icon: "\u2713", name: "Subtitle nudge" }, nudge: { icon: "\u2713", name: "Subtitle nudge" },
settings: { icon: "\u2699", name: "Settings" }, settings: { icon: "\u2699", name: "Settings" },
@@ -147,24 +147,76 @@ var controllerButtonDefs = {
mark: { icon: "\u2691", name: "Set marker" }, mark: { icon: "\u2691", name: "Set marker" },
jump: { icon: "\u21E5", name: "Jump to marker" } jump: { icon: "\u21E5", name: "Jump to marker" }
}; };
var popupExcludedButtonIds = new Set(["settings"]);
/** Lucide picker only — not control-bar blocks (chip uses subtitleNudgeOn/Off). */
var lucideSubtitleNudgeActionLabels = {
subtitleNudgeOn: "Subtitle nudge — enabled",
subtitleNudgeOff: "Subtitle nudge — disabled"
};
function sanitizePopupButtonOrder(buttonIds) {
if (!Array.isArray(buttonIds)) return [];
var seen = new Set();
return buttonIds.filter(function (id) {
if (!controllerButtonDefs[id] || popupExcludedButtonIds.has(id) || seen.has(id)) {
return false;
}
seen.add(id);
return true;
});
}
/** Cached custom Lucide SVGs (mirrors chrome.storage.local customButtonIcons). */ /** Cached custom Lucide SVGs (mirrors chrome.storage.local customButtonIcons). */
var customButtonIconsLive = {}; var customButtonIconsLive = {};
function fillControlBarIconElement(icon, buttonId) { function fillControlBarIconElement(icon, buttonId) {
if (!icon || !buttonId) return; if (!icon || !buttonId) return;
var doc = icon.ownerDocument || document;
if (buttonId === "nudge") {
vscClearElement(icon);
icon.className = "cb-icon cb-icon-nudge-pair";
function nudgeChipMarkup(action) {
var c = customButtonIconsLive[action];
if (c && c.svg) return c.svg;
if (typeof vscIconSvgString === "function") {
return vscIconSvgString(action, 14) || "";
}
return "";
}
function appendChip(action, stateKey) {
var sp = document.createElement("span");
sp.className = "cb-nudge-chip";
sp.setAttribute("data-nudge-state", stateKey);
var inner = nudgeChipMarkup(action);
if (inner) {
var wrap = vscCreateSvgWrap(doc, inner, "vsc-btn-icon");
if (wrap) {
sp.appendChild(wrap);
}
}
icon.appendChild(sp);
}
appendChip("subtitleNudgeOn", "on");
var sep = document.createElement("span");
sep.className = "cb-nudge-sep";
sep.textContent = "/";
icon.appendChild(sep);
appendChip("subtitleNudgeOff", "off");
return;
}
icon.className = "cb-icon";
var custom = customButtonIconsLive[buttonId]; var custom = customButtonIconsLive[buttonId];
if (custom && custom.svg) { if (custom && custom.svg) {
icon.innerHTML = custom.svg; if (vscSetSvgContent(icon, custom.svg)) return;
return;
} }
if (typeof vscIconSvgString === "function") { if (typeof vscIconSvgString === "function") {
var svgHtml = vscIconSvgString(buttonId, 16); var svgHtml = vscIconSvgString(buttonId, 16);
if (svgHtml) { if (svgHtml) {
icon.innerHTML = svgHtml; if (vscSetSvgContent(icon, svgHtml)) return;
return;
} }
} }
vscClearElement(icon);
var def = controllerButtonDefs[buttonId]; var def = controllerButtonDefs[buttonId];
icon.textContent = (def && def.icon) || "?"; icon.textContent = (def && def.icon) || "?";
} }
@@ -174,64 +226,48 @@ function createDefaultBinding(action, key, keyCode, value) {
action: action, action: action,
key: key, key: key,
keyCode: keyCode, keyCode: keyCode,
code: null,
disabled: false,
value: value, value: value,
force: false, force: false,
predefined: true predefined: true
}; };
} }
var tcDefaults = { var tcDefaults = vscGetSettingsDefaults();
speed: 1.0, var legacySyncKeys = [
lastSpeed: 1.0, "resetSpeed",
displayKeyCode: 86, "speedStep",
rememberSpeed: false, "fastSpeed",
audioBoolean: false, "rewindTime",
startHidden: false, "advanceTime",
hideWithYouTubeControls: false, "resetKeyCode",
hideWithControls: false, "slowerKeyCode",
hideWithControlsTimer: 2.0, "fasterKeyCode",
controllerLocation: "top-left", "rewindKeyCode",
forceLastSavedSpeed: false, "advanceKeyCode",
enabled: true, "fastKeyCode",
controllerOpacity: 0.3, "blacklist"
controllerMarginTop: 0, ];
controllerMarginRight: 0,
controllerMarginBottom: 65, function persistManagedSyncSettings(settings, callback) {
controllerMarginLeft: 0, var nextSettings = vscBuildStoredSettingsDiff(settings);
keyBindings: [ chrome.storage.sync.remove(vscGetManagedSyncKeys(), function () {
createDefaultBinding("display", "V", 86, 0), if (chrome.runtime.lastError) {
createDefaultBinding("move", "P", 80, 0), callback(chrome.runtime.lastError);
createDefaultBinding("slower", "S", 83, 0.1), return;
createDefaultBinding("faster", "D", 68, 0.1),
createDefaultBinding("rewind", "Z", 90, 10),
createDefaultBinding("advance", "X", 88, 10),
createDefaultBinding("reset", "R", 82, 1),
createDefaultBinding("fast", "G", 71, 1.8),
createDefaultBinding("toggleSubtitleNudge", "N", 78, 0)
],
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
} }
],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"], if (Object.keys(nextSettings).length === 0) {
showPopupControlBar: true, callback(null);
popupMatchHoverControls: true, return;
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"], }
enableSubtitleNudge: false,
subtitleNudgeInterval: 50, chrome.storage.sync.set(nextSettings, function () {
subtitleNudgeAmount: 0.001 callback(chrome.runtime.lastError || null);
}; });
});
}
const actionLabels = { const actionLabels = {
display: "Show/hide controller", display: "Show/hide controller",
@@ -713,7 +749,7 @@ function save_options() {
document.getElementById("showPopupControlBar").checked; document.getElementById("showPopupControlBar").checked;
settings.popupMatchHoverControls = settings.popupMatchHoverControls =
document.getElementById("popupMatchHoverControls").checked; document.getElementById("popupMatchHoverControls").checked;
settings.popupControllerButtons = getPopupControlBarOrder(); settings.popupControllerButtons = sanitizePopupButtonOrder(getPopupControlBarOrder());
// Collect site rules // Collect site rules
settings.siteRules = []; settings.siteRules = [];
@@ -802,7 +838,9 @@ function save_options() {
ruleEl.querySelector(".site-showPopupControlBar").checked; ruleEl.querySelector(".site-showPopupControlBar").checked;
var popupActiveZone = ruleEl.querySelector(".site-popup-cb-active"); var popupActiveZone = ruleEl.querySelector(".site-popup-cb-active");
if (popupActiveZone) { if (popupActiveZone) {
rule.popupControllerButtons = readControlBarOrder(popupActiveZone); rule.popupControllerButtons = sanitizePopupButtonOrder(
readControlBarOrder(popupActiveZone)
);
} }
} }
@@ -835,15 +873,20 @@ function save_options() {
settings.siteRules.push(rule); settings.siteRules.push(rule);
}); });
// Legacy keys to remove chrome.storage.sync.remove(legacySyncKeys, function () {
const legacyKeys = [ if (chrome.runtime.lastError) {
"resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime", status.textContent =
"resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode", "Error: Failed to clear legacy settings - " +
"advanceKeyCode", "fastKeyCode", "blacklist" chrome.runtime.lastError.message;
]; return;
}
chrome.storage.sync.remove(legacyKeys, function () { persistManagedSyncSettings(settings, function (error) {
chrome.storage.sync.set(settings, function () { if (error) {
status.textContent =
"Error: Failed to save settings - " + error.message;
return;
}
status.textContent = "Options saved"; status.textContent = "Options saved";
setTimeout(function () { setTimeout(function () {
status.textContent = ""; status.textContent = "";
@@ -1071,7 +1114,10 @@ function createSiteRule(rule) {
populateControlBarZones( populateControlBarZones(
sitePopupActive, sitePopupActive,
sitePopupAvailable, sitePopupAvailable,
rule.popupControllerButtons sanitizePopupButtonOrder(rule.popupControllerButtons),
function (id) {
return !popupExcludedButtonIds.has(id);
}
); );
} else if ( } else if (
sitePopupActive && sitePopupActive &&
@@ -1081,7 +1127,10 @@ function createSiteRule(rule) {
populateControlBarZones( populateControlBarZones(
sitePopupActive, sitePopupActive,
sitePopupAvailable, sitePopupAvailable,
getPopupControlBarOrder() getPopupControlBarOrder(),
function (id) {
return !popupExcludedButtonIds.has(id);
}
); );
} }
} }
@@ -1139,16 +1188,23 @@ function createControlBarBlock(buttonId) {
return block; return block;
} }
function populateControlBarZones(activeZone, availableZone, activeIds) { function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
activeZone.innerHTML = ""; vscClearElement(activeZone);
availableZone.innerHTML = ""; vscClearElement(availableZone);
var allowed = function (id) {
if (!controllerButtonDefs[id]) return false;
return typeof allowButtonId === "function" ? Boolean(allowButtonId(id)) : true;
};
activeIds.forEach(function (id) { activeIds.forEach(function (id) {
if (!allowed(id)) return;
var block = createControlBarBlock(id); var block = createControlBarBlock(id);
if (block) activeZone.appendChild(block); if (block) activeZone.appendChild(block);
}); });
Object.keys(controllerButtonDefs).forEach(function (id) { Object.keys(controllerButtonDefs).forEach(function (id) {
if (!allowed(id)) return;
if (!activeIds.includes(id)) { if (!activeIds.includes(id)) {
var block = createControlBarBlock(id); var block = createControlBarBlock(id);
if (block) availableZone.appendChild(block); if (block) availableZone.appendChild(block);
@@ -1176,15 +1232,21 @@ function getControlBarOrder() {
} }
function populatePopupControlBarEditor(activeIds) { function populatePopupControlBarEditor(activeIds) {
var popupActiveIds = sanitizePopupButtonOrder(activeIds);
populateControlBarZones( populateControlBarZones(
document.getElementById("popupControlBarActive"), document.getElementById("popupControlBarActive"),
document.getElementById("popupControlBarAvailable"), document.getElementById("popupControlBarAvailable"),
activeIds popupActiveIds,
function (id) {
return !popupExcludedButtonIds.has(id);
}
); );
} }
function getPopupControlBarOrder() { function getPopupControlBarOrder() {
return readControlBarOrder(document.getElementById("popupControlBarActive")); return sanitizePopupButtonOrder(
readControlBarOrder(document.getElementById("popupControlBarActive"))
);
} }
function updatePopupEditorDisabledState() { function updatePopupEditorDisabledState() {
@@ -1321,9 +1383,18 @@ function initLucideButtonIconsUI() {
if (!actionSel.dataset.lucideInit) { if (!actionSel.dataset.lucideInit) {
actionSel.dataset.lucideInit = "1"; actionSel.dataset.lucideInit = "1";
actionSel.innerHTML = ""; vscClearElement(actionSel);
Object.keys(controllerButtonDefs).forEach(function (aid) { Object.keys(controllerButtonDefs).forEach(function (aid) {
if (aid === "reset") return; if (aid === "nudge") {
Object.keys(lucideSubtitleNudgeActionLabels).forEach(function (subId) {
var o2 = document.createElement("option");
o2.value = subId;
o2.textContent =
lucideSubtitleNudgeActionLabels[subId] + " (" + subId + ")";
actionSel.appendChild(o2);
});
return;
}
var o = document.createElement("option"); var o = document.createElement("option");
o.value = aid; o.value = aid;
o.textContent = o.textContent =
@@ -1333,7 +1404,7 @@ function initLucideButtonIconsUI() {
} }
function renderResults(slugs) { function renderResults(slugs) {
resultsEl.innerHTML = ""; vscClearElement(resultsEl);
slugs.forEach(function (slug) { slugs.forEach(function (slug) {
var b = document.createElement("button"); var b = document.createElement("button");
b.type = "button"; b.type = "button";
@@ -1368,11 +1439,13 @@ function initLucideButtonIconsUI() {
.then(function (txt) { .then(function (txt) {
var safe = sanitizeLucideSvg(txt); var safe = sanitizeLucideSvg(txt);
if (!safe) throw new Error("Bad SVG"); if (!safe) throw new Error("Bad SVG");
previewEl.innerHTML = safe; if (!vscSetSvgContent(previewEl, safe)) {
throw new Error("Preview render failed");
}
setLucideStatus("Preview: " + slug); setLucideStatus("Preview: " + slug);
}) })
.catch(function (e) { .catch(function (e) {
previewEl.innerHTML = ""; vscClearElement(previewEl);
setLucideStatus( setLucideStatus(
"Could not load: " + slug + " — " + e.message "Could not load: " + slug + " — " + e.message
); );
@@ -1391,7 +1464,7 @@ function initLucideButtonIconsUI() {
.then(function (map) { .then(function (map) {
var q = searchInput.value; var q = searchInput.value;
if (!q.trim()) { if (!q.trim()) {
resultsEl.innerHTML = ""; vscClearElement(resultsEl);
return; return;
} }
renderResults(searchLucideSlugs(map, q, 48)); renderResults(searchLucideSlugs(map, q, 48));
@@ -1475,53 +1548,49 @@ function initLucideButtonIconsUI() {
} }
function restore_options() { function restore_options() {
chrome.storage.sync.get(tcDefaults, function (storage) { chrome.storage.sync.get(null, function (storage) {
var settings = vscExpandStoredSettings(storage);
chrome.storage.local.get(["customButtonIcons"], function (loc) { chrome.storage.local.get(["customButtonIcons"], function (loc) {
customButtonIconsLive = customButtonIconsLive =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object" loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons ? loc.customButtonIcons
: {}; : {};
document.getElementById("rememberSpeed").checked = storage.rememberSpeed; document.getElementById("rememberSpeed").checked = settings.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked = document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed; settings.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean; document.getElementById("audioBoolean").checked = settings.audioBoolean;
document.getElementById("enabled").checked = storage.enabled; document.getElementById("enabled").checked = settings.enabled;
document.getElementById("startHidden").checked = storage.startHidden; document.getElementById("startHidden").checked = settings.startHidden;
document.getElementById("hideWithControls").checked =
// Migration/Normalization for hideWithControls settings.hideWithControls;
const hideWithControls = typeof storage.hideWithControls !== "undefined"
? storage.hideWithControls
: storage.hideWithYouTubeControls;
document.getElementById("hideWithControls").checked = hideWithControls;
document.getElementById("hideWithControlsTimer").value = document.getElementById("hideWithControlsTimer").value =
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer; settings.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
document.getElementById("controllerLocation").value = document.getElementById("controllerLocation").value =
normalizeControllerLocation(storage.controllerLocation); normalizeControllerLocation(settings.controllerLocation);
document.getElementById("controllerOpacity").value = document.getElementById("controllerOpacity").value =
storage.controllerOpacity; settings.controllerOpacity;
document.getElementById("controllerMarginTop").value = document.getElementById("controllerMarginTop").value =
storage.controllerMarginTop ?? tcDefaults.controllerMarginTop; settings.controllerMarginTop ?? tcDefaults.controllerMarginTop;
document.getElementById("controllerMarginBottom").value = document.getElementById("controllerMarginBottom").value =
storage.controllerMarginBottom ?? tcDefaults.controllerMarginBottom; settings.controllerMarginBottom ?? tcDefaults.controllerMarginBottom;
document.getElementById("showPopupControlBar").checked = document.getElementById("showPopupControlBar").checked =
storage.showPopupControlBar !== false; settings.showPopupControlBar !== false;
document.getElementById("enableSubtitleNudge").checked = document.getElementById("enableSubtitleNudge").checked =
storage.enableSubtitleNudge; settings.enableSubtitleNudge;
document.getElementById("subtitleNudgeInterval").value = document.getElementById("subtitleNudgeInterval").value =
storage.subtitleNudgeInterval; settings.subtitleNudgeInterval;
if (!Array.isArray(storage.keyBindings) || storage.keyBindings.length === 0) { if (!Array.isArray(settings.keyBindings) || settings.keyBindings.length === 0) {
storage.keyBindings = tcDefaults.keyBindings.slice(); settings.keyBindings = tcDefaults.keyBindings.slice();
} }
ensureAllDefaultBindings(storage); ensureAllDefaultBindings(settings);
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove()); document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
storage.keyBindings.forEach((item) => { settings.keyBindings.forEach((item) => {
var row = document.getElementById(item.action); var row = document.getElementById(item.action);
var normalizedBinding = normalizeStoredBinding(item); var normalizedBinding = normalizeStoredBinding(item);
@@ -1550,13 +1619,11 @@ function restore_options() {
refreshAddShortcutSelector(); refreshAddShortcutSelector();
// Load site rules (use defaults if none in storage or empty array) var siteRules = Array.isArray(settings.siteRules)
var siteRules = ? settings.siteRules
Array.isArray(storage.siteRules) && storage.siteRules.length > 0 : tcDefaults.siteRules || [];
? storage.siteRules
: tcDefaults.siteRules || [];
document.getElementById("siteRulesContainer").innerHTML = ""; vscClearElement(document.getElementById("siteRulesContainer"));
if (siteRules.length > 0) { if (siteRules.length > 0) {
siteRules.forEach((rule) => { siteRules.forEach((rule) => {
if (rule && rule.pattern) { if (rule && rule.pattern) {
@@ -1565,16 +1632,16 @@ function restore_options() {
}); });
} }
var controllerButtons = Array.isArray(storage.controllerButtons) var controllerButtons = Array.isArray(settings.controllerButtons)
? storage.controllerButtons ? settings.controllerButtons
: tcDefaults.controllerButtons; : tcDefaults.controllerButtons;
populateControlBarEditor(controllerButtons); populateControlBarEditor(controllerButtons);
document.getElementById("popupMatchHoverControls").checked = document.getElementById("popupMatchHoverControls").checked =
storage.popupMatchHoverControls !== false; settings.popupMatchHoverControls !== false;
var popupButtons = Array.isArray(storage.popupControllerButtons) var popupButtons = Array.isArray(settings.popupControllerButtons)
? storage.popupControllerButtons ? settings.popupControllerButtons
: tcDefaults.popupControllerButtons; : tcDefaults.popupControllerButtons;
populatePopupControlBarEditor(popupButtons); populatePopupControlBarEditor(popupButtons);
updatePopupEditorDisabledState(); updatePopupEditorDisabledState();
@@ -1592,13 +1659,30 @@ function restore_defaults() {
function () {} function () {}
); );
chrome.storage.sync.set(tcDefaults, function () { chrome.storage.sync.remove(legacySyncKeys, function () {
restore_options(); if (chrome.runtime.lastError) {
var status = document.getElementById("status"); var errorStatus = document.getElementById("status");
status.textContent = "Default options restored"; errorStatus.textContent =
setTimeout(function () { "Error: Failed to clear legacy settings - " +
status.textContent = ""; chrome.runtime.lastError.message;
}, 1000); return;
}
persistManagedSyncSettings(tcDefaults, function (error) {
if (error) {
var errorStatus = document.getElementById("status");
errorStatus.textContent =
"Error: Failed to restore defaults - " + error.message;
return;
}
restore_options();
var status = document.getElementById("status");
status.textContent = "Default options restored";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
}); });
} }
@@ -1771,7 +1855,10 @@ document.addEventListener("DOMContentLoaded", function () {
populateControlBarZones( populateControlBarZones(
popupActiveZone, popupActiveZone,
popupAvailableZone, popupAvailableZone,
getPopupControlBarOrder() getPopupControlBarOrder(),
function (id) {
return !popupExcludedButtonIds.has(id);
}
); );
} }
} else { } else {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Speeder</title> <title>Speeder</title>
<link rel="stylesheet" href="popup.css" /> <link rel="stylesheet" href="popup.css" />
<script src="settings-core.js"></script>
<script src="ui-icons.js"></script> <script src="ui-icons.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</head> </head>
+70 -56
View File
@@ -8,8 +8,9 @@ document.addEventListener("DOMContentLoaded", function () {
faster: { label: "", className: "" }, faster: { label: "", className: "" },
advance: { label: "", className: "rw" }, advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" }, display: { label: "", className: "hideButton" },
reset: { label: "", className: "" }, reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" }, fast: { label: "", className: "" },
nudge: { label: "", className: "" },
settings: { label: "", className: "" }, settings: { label: "", className: "" },
pause: { label: "", className: "" }, pause: { label: "", className: "" },
muted: { label: "", className: "" }, muted: { label: "", className: "" },
@@ -18,71 +19,50 @@ document.addEventListener("DOMContentLoaded", function () {
}; };
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"]; var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
var storageDefaults = { var popupExcludedButtonIds = new Set(["settings"]);
enabled: true,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
popupControllerButtons: defaultButtons,
siteRules: []
};
var renderToken = 0; var renderToken = 0;
function escapeStringRegExp(str) {
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function matchSiteRule(url, siteRules) { function matchSiteRule(url, siteRules) {
if (!url || !Array.isArray(siteRules)) return null; if (!url || !Array.isArray(siteRules)) return null;
for (var i = 0; i < siteRules.length; i++) { return vscMatchSiteRule(url, siteRules);
var rule = siteRules[i];
if (!rule || !rule.pattern) continue;
var pattern = rule.pattern.replace(regStrip, "");
if (pattern.length === 0) continue;
var re;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var ls = pattern.lastIndexOf("/");
re = new RegExp(pattern.substring(1, ls), pattern.substring(ls + 1));
} catch (e) {
continue;
}
} else {
re = new RegExp(escapeStringRegExp(pattern));
}
if (re && re.test(url)) return rule;
}
return null;
} }
function isSiteRuleDisabled(rule) { function isSiteRuleDisabled(rule) {
return Boolean( return vscIsSiteRuleDisabled(rule);
rule &&
(rule.enabled === false || rule.disableExtension === true)
);
} }
function resolvePopupButtons(storage, siteRule) { function resolvePopupButtons(storage, siteRule) {
function sanitize(buttons) {
if (!Array.isArray(buttons)) return [];
var seen = new Set();
return buttons.filter(function (id) {
if (!controllerButtonDefs[id] || popupExcludedButtonIds.has(id) || seen.has(id)) {
return false;
}
seen.add(id);
return true;
});
}
if (siteRule && Array.isArray(siteRule.popupControllerButtons)) { if (siteRule && Array.isArray(siteRule.popupControllerButtons)) {
return siteRule.popupControllerButtons; return sanitize(siteRule.popupControllerButtons);
} }
if (storage.popupMatchHoverControls) { if (storage.popupMatchHoverControls) {
if (siteRule && Array.isArray(siteRule.controllerButtons)) { if (siteRule && Array.isArray(siteRule.controllerButtons)) {
return siteRule.controllerButtons; return sanitize(siteRule.controllerButtons);
} }
if (Array.isArray(storage.controllerButtons)) { if (Array.isArray(storage.controllerButtons)) {
return storage.controllerButtons; return sanitize(storage.controllerButtons);
} }
} }
if (Array.isArray(storage.popupControllerButtons)) { if (Array.isArray(storage.popupControllerButtons)) {
return storage.popupControllerButtons; return sanitize(storage.popupControllerButtons);
} }
return defaultButtons; return sanitize(defaultButtons);
} }
function setControlBarVisible(visible) { function setControlBarVisible(visible) {
@@ -209,7 +189,6 @@ document.addEventListener("DOMContentLoaded", function () {
var customMap = customIconsMap || {}; var customMap = customIconsMap || {};
buttons.forEach(function (btnId) { buttons.forEach(function (btnId) {
if (btnId === "nudge") return;
var def = controllerButtonDefs[btnId]; var def = controllerButtonDefs[btnId];
if (!def) return; if (!def) return;
@@ -217,17 +196,21 @@ document.addEventListener("DOMContentLoaded", function () {
btn.dataset.action = btnId; btn.dataset.action = btnId;
var customEntry = customMap[btnId]; var customEntry = customMap[btnId];
if (customEntry && customEntry.svg) { if (customEntry && customEntry.svg) {
var customSpan = document.createElement("span"); var customSpan = vscCreateSvgWrap(document, customEntry.svg, "vsc-btn-icon");
customSpan.className = "vsc-btn-icon"; if (customSpan) {
customSpan.innerHTML = customEntry.svg; btn.appendChild(customSpan);
btn.appendChild(customSpan); } else {
btn.textContent = def.label || "?";
}
} else if (typeof vscIconSvgString === "function") { } else if (typeof vscIconSvgString === "function") {
var svgStr = vscIconSvgString(btnId, 16); var svgStr = vscIconSvgString(btnId, 16);
if (svgStr) { if (svgStr) {
var iconSpan = document.createElement("span"); var iconSpan = vscCreateSvgWrap(document, svgStr, "vsc-btn-icon");
iconSpan.className = "vsc-btn-icon"; if (iconSpan) {
iconSpan.innerHTML = svgStr; btn.appendChild(iconSpan);
btn.appendChild(iconSpan); } else {
btn.textContent = def.label || "?";
}
} else { } else {
btn.textContent = def.label || "?"; btn.textContent = def.label || "?";
} }
@@ -317,8 +300,9 @@ document.addEventListener("DOMContentLoaded", function () {
? loc.customButtonIcons ? loc.customButtonIcons
: {}; : {};
chrome.storage.sync.get(storageDefaults, function (storage) { chrome.storage.sync.get(null, function (storage) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
storage = vscExpandStoredSettings(storage);
getActiveTabContext(function (context) { getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
@@ -380,7 +364,9 @@ document.addEventListener("DOMContentLoaded", function () {
changes.controllerButtons || changes.controllerButtons ||
changes.popupMatchHoverControls || changes.popupMatchHoverControls ||
changes.popupControllerButtons || changes.popupControllerButtons ||
changes.siteRules changes.siteRules ||
changes.siteRulesMeta ||
changes.siteRulesFormat
) { ) {
renderForActiveTab(); renderForActiveTab();
} }
@@ -389,9 +375,37 @@ document.addEventListener("DOMContentLoaded", function () {
renderForActiveTab(); renderForActiveTab();
function toggleEnabled(enabled, callback) { function toggleEnabled(enabled, callback) {
chrome.storage.sync.set({ enabled: enabled }, function () { chrome.storage.sync.get(null, function (storage) {
toggleEnabledUI(enabled); var nextSettings = vscExpandStoredSettings(storage);
if (callback) callback(enabled); nextSettings.enabled = enabled;
var storedSettings = vscBuildStoredSettingsDiff(nextSettings);
chrome.storage.sync.remove(vscGetManagedSyncKeys(), function () {
if (chrome.runtime.lastError) {
setStatusMessage(
"Failed to update settings: " + chrome.runtime.lastError.message
);
return;
}
if (Object.keys(storedSettings).length === 0) {
toggleEnabledUI(enabled);
if (callback) callback(enabled);
return;
}
chrome.storage.sync.set(storedSettings, function () {
if (chrome.runtime.lastError) {
setStatusMessage(
"Failed to update settings: " + chrome.runtime.lastError.message
);
return;
}
toggleEnabledUI(enabled);
if (callback) callback(enabled);
});
});
}); });
} }
+644
View File
@@ -0,0 +1,644 @@
(function (global) {
"use strict";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var SITE_RULE_OVERRIDE_KEYS = [
"controllerLocation",
"controllerMarginTop",
"controllerMarginBottom",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"controllerButtons",
"showPopupControlBar",
"popupControllerButtons",
"shortcuts",
"preferredSpeed"
];
var DIFFABLE_OPTION_KEYS = [
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"enabled",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"keyBindings",
"siteRules",
"siteRulesMeta",
"siteRulesFormat",
"controllerButtons",
"showPopupControlBar",
"popupMatchHoverControls",
"popupControllerButtons",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([
"hideWithYouTubeControls"
]);
var DEFAULT_SETTINGS = {
speed: 1.0,
lastSpeed: 1.0,
displayKeyCode: 86,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [
{
action: "display",
key: "V",
keyCode: 86,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "move",
key: "P",
keyCode: 80,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "slower",
key: "S",
keyCode: 83,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "faster",
key: "D",
keyCode: 68,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "rewind",
key: "Z",
keyCode: 90,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "advance",
key: "X",
keyCode: 88,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "reset",
key: "R",
keyCode: 82,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "fast",
key: "G",
keyCode: 71,
code: null,
disabled: false,
value: 1.8,
force: false,
predefined: true
},
{
action: "toggleSubtitleNudge",
key: "N",
keyCode: 78,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
}
],
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: DEFAULT_BUTTONS.slice(),
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
function clonePlainData(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function hasOwn(obj, key) {
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function sortComparableValue(value) {
if (Array.isArray(value)) {
return value.map(sortComparableValue);
}
if (isPlainObject(value)) {
var sorted = {};
Object.keys(value)
.sort()
.forEach(function (key) {
if (value[key] === undefined) {
return;
}
sorted[key] = sortComparableValue(value[key]);
});
return sorted;
}
return value;
}
function areComparableValuesEqual(a, b) {
return (
JSON.stringify(sortComparableValue(a)) ===
JSON.stringify(sortComparableValue(b))
);
}
function deepMergeDefaults(defaults, overrides) {
if (Array.isArray(defaults)) {
return Array.isArray(overrides)
? clonePlainData(overrides)
: clonePlainData(defaults);
}
if (isPlainObject(defaults)) {
var result = clonePlainData(defaults) || {};
if (!isPlainObject(overrides)) {
return result;
}
Object.keys(overrides).forEach(function (key) {
if (overrides[key] === undefined) {
return;
}
if (hasOwn(defaults, key)) {
result[key] = deepMergeDefaults(defaults[key], overrides[key]);
} else {
result[key] = clonePlainData(overrides[key]);
}
});
return result;
}
return overrides === undefined
? clonePlainData(defaults)
: clonePlainData(overrides);
}
function deepDiff(current, defaults) {
if (current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
if (isPlainObject(current)) {
var result = {};
Object.keys(current).forEach(function (key) {
var diff = deepDiff(current[key], defaults && defaults[key]);
if (diff !== undefined) {
result[key] = diff;
}
});
return Object.keys(result).length > 0 ? result : undefined;
}
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
function getDefaultSiteRules() {
return clonePlainData(DEFAULT_SETTINGS.siteRules) || [];
}
function getDefaultSiteRulesByPattern() {
var map = Object.create(null);
getDefaultSiteRules().forEach(function (rule) {
if (!rule || typeof rule.pattern !== "string" || !rule.pattern) {
return;
}
map[rule.pattern] = rule;
});
return map;
}
function normalizeSiteRuleForDiff(rule, baseSettings) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return null;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (!pattern) {
return null;
}
var normalized = { pattern: pattern };
var baseEnabled = hasOwn(baseSettings, "enabled")
? Boolean(baseSettings.enabled)
: true;
var ruleEnabled = hasOwn(rule, "enabled")
? Boolean(rule.enabled)
: hasOwn(rule, "disableExtension")
? !Boolean(rule.disableExtension)
: baseEnabled;
if (!areComparableValuesEqual(ruleEnabled, baseEnabled)) {
normalized.enabled = ruleEnabled;
}
SITE_RULE_OVERRIDE_KEYS.forEach(function (key) {
var baseValue = clonePlainData(baseSettings[key]);
var effectiveValue = hasOwn(rule, key)
? clonePlainData(rule[key])
: baseValue;
if (!areComparableValuesEqual(effectiveValue, baseValue)) {
normalized[key] = effectiveValue;
}
});
Object.keys(rule).forEach(function (key) {
if (
key === "pattern" ||
key === "enabled" ||
key === "disableExtension" ||
SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 ||
rule[key] === undefined
) {
return;
}
normalized[key] = clonePlainData(rule[key]);
});
return normalized;
}
function compressSiteRules(siteRules, baseSettings) {
if (!Array.isArray(siteRules)) {
return {};
}
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
var currentPatterns = new Set();
var exportRules = [];
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (pattern) {
currentPatterns.add(pattern);
}
var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings);
if (!normalizedRule || Object.keys(normalizedRule).length === 1) {
return;
}
var defaultRule = pattern ? defaultRulesByPattern[pattern] : null;
var normalizedDefaultRule = defaultRule
? normalizeSiteRuleForDiff(defaultRule, baseSettings)
: null;
if (normalizedDefaultRule) {
if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) {
return;
}
var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule);
if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) {
defaultRuleDiff.pattern = pattern;
exportRules.push(defaultRuleDiff);
}
return;
}
exportRules.push(normalizedRule);
});
var removedDefaultPatterns = defaultRules
.map(function (rule) {
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
})
.filter(function (pattern) {
return pattern && !currentPatterns.has(pattern);
});
var result = {};
if (exportRules.length > 0) {
result.siteRules = exportRules;
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
if (removedDefaultPatterns.length > 0) {
result.siteRulesMeta = {
removedDefaultPatterns: removedDefaultPatterns
};
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
return result;
}
function expandSiteRules(siteRules, siteRulesMeta) {
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
if (defaultRules.length === 0) {
return Array.isArray(siteRules) ? clonePlainData(siteRules) : [];
}
var removedDefaultPatterns = new Set(
siteRulesMeta && Array.isArray(siteRulesMeta.removedDefaultPatterns)
? siteRulesMeta.removedDefaultPatterns
: []
);
var modifiedDefaultRules = Object.create(null);
var customRules = [];
if (Array.isArray(siteRules)) {
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (
pattern &&
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern)
) {
modifiedDefaultRules[pattern] = clonePlainData(rule);
return;
}
customRules.push(clonePlainData(rule));
});
}
var mergedRules = [];
defaultRules.forEach(function (rule) {
var pattern = rule && typeof rule.pattern === "string" ? rule.pattern : "";
if (!pattern || removedDefaultPatterns.has(pattern)) {
return;
}
if (modifiedDefaultRules[pattern]) {
mergedRules.push(
Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
)
);
return;
}
mergedRules.push(clonePlainData(rule));
});
customRules.forEach(function (rule) {
mergedRules.push(rule);
});
return mergedRules;
}
function buildStoredSettingsDiff(currentSettings) {
var defaults = clonePlainData(DEFAULT_SETTINGS);
var normalized = deepMergeDefaults(defaults, currentSettings || {});
var siteRuleData = compressSiteRules(normalized.siteRules, normalized);
var diffDefaults = {};
var diff = {};
delete normalized.siteRules;
delete normalized.siteRulesMeta;
delete normalized.siteRulesFormat;
delete normalized.hideWithYouTubeControls;
if (siteRuleData.siteRules) {
normalized.siteRules = siteRuleData.siteRules;
}
if (siteRuleData.siteRulesMeta) {
normalized.siteRulesMeta = siteRuleData.siteRulesMeta;
}
if (siteRuleData.siteRulesFormat) {
normalized.siteRulesFormat = siteRuleData.siteRulesFormat;
}
DIFFABLE_OPTION_KEYS.forEach(function (key) {
if (hasOwn(DEFAULT_SETTINGS, key)) {
diffDefaults[key] = clonePlainData(DEFAULT_SETTINGS[key]);
}
if (!hasOwn(normalized, key)) {
return;
}
var valueDiff = deepDiff(normalized[key], diffDefaults[key]);
if (valueDiff !== undefined) {
diff[key] = valueDiff;
}
});
return diff;
}
function expandStoredSettings(storage) {
var raw = clonePlainData(storage) || {};
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
if (
!hasOwn(raw, "hideWithControls") &&
hasOwn(raw, "hideWithYouTubeControls")
) {
expanded.hideWithControls = Boolean(raw.hideWithYouTubeControls);
}
expanded.hideWithYouTubeControls = expanded.hideWithControls;
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta);
} else if (Array.isArray(raw.siteRules)) {
expanded.siteRules = clonePlainData(raw.siteRules);
} else {
expanded.siteRules = getDefaultSiteRules();
}
return expanded;
}
function escapeStringRegExp(str) {
var matcher = /[|\\{}()[\]^$+*?.]/g;
return String(str).replace(matcher, "\\$&");
}
function siteRuleMatchesUrl(rule, currentUrl) {
if (!rule || !rule.pattern || !currentUrl) {
return false;
}
var pattern = String(rule.pattern).trim();
if (!pattern) {
return false;
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (_error) {
return false;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
return Boolean(regex && regex.test(currentUrl));
}
function mergeMatchingSiteRules(currentUrl, siteRules) {
if (!currentUrl || !Array.isArray(siteRules)) {
return null;
}
var matchedRules = [];
for (var i = 0; i < siteRules.length; i++) {
if (siteRuleMatchesUrl(siteRules[i], currentUrl)) {
matchedRules.push(siteRules[i]);
}
}
if (!matchedRules.length) {
return null;
}
var mergedRule = {};
matchedRules.forEach(function (rule) {
Object.keys(rule).forEach(function (key) {
var value = rule[key];
if (Array.isArray(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
if (isPlainObject(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
mergedRule[key] = value;
});
});
return mergedRule;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
global.vscClonePlainData = clonePlainData;
global.vscAreComparableValuesEqual = areComparableValuesEqual;
global.vscDeepMergeDefaults = deepMergeDefaults;
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
global.vscExpandStoredSettings = expandStoredSettings;
global.vscGetSettingsDefaults = function () {
return clonePlainData(DEFAULT_SETTINGS);
};
global.vscGetManagedSyncKeys = function () {
return MANAGED_SYNC_KEYS.slice();
};
global.vscGetSiteRulesDiffFormat = function () {
return SITE_RULES_DIFF_FORMAT;
};
global.vscMatchSiteRule = mergeMatchingSiteRules;
global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl;
global.vscIsSiteRuleDisabled = isSiteRuleDisabled;
})(typeof globalThis !== "undefined" ? globalThis : this);
+4 -1
View File
@@ -10,8 +10,11 @@
line-height: 1; line-height: 1;
} }
/* Show extra buttons on hover or keyboard :focus-visible only. Plain :focus-within
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
margin) turned off when the pointer left the bar. */
#controller:hover #controls, #controller:hover #controls,
#controller:focus-within #controls, #controller:focus-within:has(:focus-visible) #controls,
:host(:hover) #controls { :host(:hover) #controls {
display: inline-flex; display: inline-flex;
vertical-align: middle; vertical-align: middle;
+75 -4
View File
@@ -3,6 +3,7 @@
* Use stroke="currentColor" so buttons inherit foreground for monochrome UI. * Use stroke="currentColor" so buttons inherit foreground for monochrome UI.
*/ */
var VSC_ICON_SIZE_DEFAULT = 18; var VSC_ICON_SIZE_DEFAULT = 18;
var VSC_SVG_NS = "http://www.w3.org/2000/svg";
/** Inner SVG markup only (paths / shapes inside <svg>). */ /** Inner SVG markup only (paths / shapes inside <svg>). */
var vscUiIconPaths = { var vscUiIconPaths = {
@@ -54,6 +55,79 @@ function vscIconSvgString(action, size) {
); );
} }
function vscClearElement(el) {
if (!el) return;
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
function vscSanitizeSvgTree(svg) {
if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null;
svg.querySelectorAll("script, style, foreignObject").forEach(function (n) {
n.remove();
});
svg.querySelectorAll("*").forEach(function (el) {
for (var i = el.attributes.length - 1; i >= 0; i--) {
var attr = el.attributes[i];
var name = attr.name.toLowerCase();
var val = attr.value;
if (name.indexOf("on") === 0) {
el.removeAttribute(attr.name);
continue;
}
if (
(name === "href" || name === "xlink:href") &&
/^\s*javascript:/i.test(val)
) {
el.removeAttribute(attr.name);
}
}
});
svg.setAttribute("xmlns", VSC_SVG_NS);
svg.setAttribute("aria-hidden", "true");
return svg;
}
function vscCreateSvgNode(doc, svgText) {
if (!doc || !svgText || typeof svgText !== "string") return null;
var clean = String(svgText).replace(/\0/g, "").trim();
if (!clean || !/<svg[\s>]/i.test(clean)) return null;
var parsed = new DOMParser().parseFromString(clean, "image/svg+xml");
if (parsed.querySelector("parsererror")) return null;
var svg = vscSanitizeSvgTree(parsed.querySelector("svg"));
if (!svg) return null;
return doc.importNode(svg, true);
}
function vscSetSvgContent(el, svgText) {
if (!el) return false;
vscClearElement(el);
var doc = el.ownerDocument || document;
var svg = vscCreateSvgNode(doc, svgText);
if (!svg) return false;
el.appendChild(svg);
return true;
}
function vscCreateSvgWrap(doc, svgText, className) {
if (!doc) return null;
var span = doc.createElement("span");
span.className = className || "vsc-btn-icon";
if (!vscSetSvgContent(span, svgText)) {
return null;
}
return span;
}
/** /**
* @param {Document} doc * @param {Document} doc
* @param {string} action * @param {string} action
@@ -62,8 +136,5 @@ function vscIconSvgString(action, size) {
function vscIconWrap(doc, action, size) { function vscIconWrap(doc, action, size) {
var html = vscIconSvgString(action, size); var html = vscIconSvgString(action, size);
if (!html) return null; if (!html) return null;
var span = doc.createElement("span"); return vscCreateSvgWrap(doc, html, "vsc-btn-icon");
span.className = "vsc-btn-icon";
span.innerHTML = html;
return span;
} }