Compare commits

...

32 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
joshpatra edd997037a v5.1.1-beta.1 2026-04-02 13:11:47 -04:00
joshpatra f85a1f9f29 Bump version to 5.1.1 2026-04-02 13:11:46 -04:00
joshpatra 97366b76b6 chore: open options in tab 2026-04-02 13:09:09 -04:00
joshpatra 8269875bb1 fix: removed divider 2026-04-02 13:01:14 -04:00
joshpatra e34ec17f33 v5.1.0-beta.1 2026-04-02 12:53:10 -04:00
joshpatra 8d3905b654 Bump version to 5.1.0 2026-04-02 12:53:09 -04:00
joshpatra 7fd8a931d8 deploy: squash beta→main for stable; beta script pushes dev then pulls 2026-04-02 12:52:27 -04:00
joshpatra 17319c1e25 Re-run site rules on DOM media attach; extract refreshAllControllerGeometry 2026-04-02 12:52:27 -04:00
joshpatra 841c1a246e fix: nudge flash layout, Lucide icons, hover bar spacing 2026-04-02 12:52:27 -04:00
joshpatra ed0f63e8bc feat: user-customizable Lucide controller button icons 2026-04-02 12:52:27 -04:00
17 changed files with 2658 additions and 602 deletions
+2
View File
@@ -10,6 +10,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
WEB_EXT_IGNORE_FILES: scripts/**
steps:
- uses: actions/checkout@v4
+1 -5
View File
@@ -1,17 +1,13 @@
/* Runs via chrome.tabs.executeScript(allFrames) in the same isolated world as inject.js */
(function () {
try {
if (
typeof getPrimaryVideoElement !== "function" ||
typeof computeResetButtonLabelForVideo !== "function"
) {
if (typeof getPrimaryVideoElement !== "function") {
return null;
}
var v = getPrimaryVideoElement();
if (!v) return null;
return {
speed: v.playbackRate,
resetLabel: computeResetButtonLabelForVideo(v),
preferred: !v.paused
};
} catch (e) {
+122 -25
View File
@@ -1,5 +1,55 @@
// 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() {
const now = new Date();
const year = now.getFullYear();
@@ -11,27 +61,69 @@ function generateBackupFilename() {
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() {
chrome.storage.sync.get(null, function (storage) {
const backup = {
version: "1.0",
exportDate: new Date().toISOString(),
settings: storage
};
chrome.storage.local.get(
EXPORTABLE_LOCAL_SETTINGS_KEYS,
function (localStorage) {
const localSettings = getExportableLocalSettings(localStorage);
const syncSettings = getExportableSyncSettings(storage);
const backup = {
version: getBackupManifestVersion(),
exportDate: new Date().toISOString(),
settings: syncSettings
};
const dataStr = JSON.stringify(backup, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
if (Object.keys(localSettings).length > 0) {
backup.localSettings = localSettings;
}
const link = document.createElement("a");
link.href = url;
link.download = generateBackupFilename();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
const dataStr = JSON.stringify(backup, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
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");
}
);
});
}
@@ -52,9 +144,9 @@ function importSettings() {
// Detect backup format: check for 'settings' wrapper or raw storage keys
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)) {
settingsToImport = backup; // Raw storage object
settingsToImport = getImportableSyncSettings(backup, backup);
}
if (!settingsToImport) {
@@ -62,12 +154,15 @@ function importSettings() {
return;
}
// Import all settings
chrome.storage.sync.clear(function () {
// If clear fails, we still try to set
chrome.storage.sync.set(settingsToImport, function () {
if (chrome.runtime.lastError) {
showStatus("Error: Failed to save imported settings - " + chrome.runtime.lastError.message, true);
var localToImport = getExportableLocalSettings(backup.localSettings);
function afterLocalImport() {
persistManagedSyncSettings(settingsToImport, function (error) {
if (error) {
showStatus(
"Error: Failed to save imported settings - " + error.message,
true
);
return;
}
showStatus("Settings imported successfully. Reloading...");
@@ -79,7 +174,9 @@ function importSettings() {
}
}, 500);
});
});
}
replaceImportableLocalSettings(localToImport, afterLocalImport);
} catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true);
}
+424 -183
View File
@@ -1,7 +1,6 @@
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video
var sharedSettingsDefaults = vscGetSettingsDefaults();
function getPrimaryVideoElement() {
if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
@@ -14,31 +13,38 @@ function getPrimaryVideoElement() {
var tc = {
settings: {
lastSpeed: 1.0,
enabled: true,
lastSpeed: sharedSettingsDefaults.lastSpeed,
enabled: sharedSettingsDefaults.enabled,
speeds: {},
displayKeyCode: 86,
rememberSpeed: false,
forceLastSavedSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [],
siteRules: [],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
displayKeyCode: sharedSettingsDefaults.displayKeyCode,
rememberSpeed: sharedSettingsDefaults.rememberSpeed,
forceLastSavedSpeed: sharedSettingsDefaults.forceLastSavedSpeed,
audioBoolean: sharedSettingsDefaults.audioBoolean,
startHidden: sharedSettingsDefaults.startHidden,
hideWithYouTubeControls: sharedSettingsDefaults.hideWithYouTubeControls,
hideWithControls: sharedSettingsDefaults.hideWithControls,
hideWithControlsTimer: sharedSettingsDefaults.hideWithControlsTimer,
controllerLocation: sharedSettingsDefaults.controllerLocation,
controllerOpacity: sharedSettingsDefaults.controllerOpacity,
controllerMarginTop: sharedSettingsDefaults.controllerMarginTop,
controllerMarginRight: sharedSettingsDefaults.controllerMarginRight,
controllerMarginBottom: sharedSettingsDefaults.controllerMarginBottom,
controllerMarginLeft: sharedSettingsDefaults.controllerMarginLeft,
keyBindings: Array.isArray(sharedSettingsDefaults.keyBindings)
? sharedSettingsDefaults.keyBindings.slice()
: [],
siteRules: Array.isArray(sharedSettingsDefaults.siteRules)
? sharedSettingsDefaults.siteRules.slice()
: [],
controllerButtons: Array.isArray(sharedSettingsDefaults.controllerButtons)
? sharedSettingsDefaults.controllerButtons.slice()
: ["rewind", "slower", "faster", "advance", "display"],
defaultLogLevel: 3,
logLevel: 3,
enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
subtitleNudgeAmount: 0.001
enableSubtitleNudge: sharedSettingsDefaults.enableSubtitleNudge,
subtitleNudgeInterval: sharedSettingsDefaults.subtitleNudgeInterval,
subtitleNudgeAmount: sharedSettingsDefaults.subtitleNudgeAmount,
customButtonIcons: {}
},
mediaElements: [],
isNudging: false,
@@ -115,19 +121,20 @@ var controllerLocationStyles = {
}
};
/* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = {
rewind: { label: "\u00AB", className: "rw" },
slower: { label: "\u2212", className: "" },
faster: { label: "+", className: "" },
advance: { label: "\u00BB", className: "rw" },
display: { label: "\u00D7", className: "hideButton" },
reset: { label: "1.00x", className: "" },
fast: { label: "\u2605", className: "" },
settings: { label: "\u2699", className: "" },
pause: { label: "\u23EF", className: "" },
muted: { label: "M", className: "" },
mark: { label: "\u2691", className: "" },
jump: { label: "\u21E5", className: "" }
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
faster: { label: "", className: "" },
advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
settings: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" }
};
var keyCodeToEventKey = {
@@ -184,6 +191,8 @@ function createDefaultBinding(action, key, keyCode, value) {
action: action,
key: key,
keyCode: keyCode,
code: null,
disabled: false,
value: value,
force: false,
predefined: true
@@ -220,7 +229,7 @@ function defaultKeyBindings(storage) {
"reset",
"R",
Number(storage.resetKeyCode) || 82,
1.0
0
),
createDefaultBinding(
"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() {
tc.siteRuleBase = {
startHidden: tc.settings.startHidden,
@@ -776,16 +944,51 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) {
return normalizedEnabled;
}
function renderSubtitleNudgeIndicatorContent(target, isEnabled) {
if (!target) return;
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") {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var svg = vscIconSvgString(action, 14);
if (!svg) {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var wrap = vscCreateSvgWrap(target.ownerDocument || document, svg, "vsc-btn-icon");
if (wrap) {
target.appendChild(wrap);
return;
}
target.textContent = isEnabled ? "✓" : "×";
}
function updateSubtitleNudgeIndicator(video) {
if (!video || !video.vsc) return;
var isEnabled = isSubtitleNudgeEnabledForVideo(video);
var label = isEnabled ? "✓" : "×";
var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled";
var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) {
indicator.textContent = label;
renderSubtitleNudgeIndicatorContent(indicator, isEnabled);
indicator.dataset.enabled = isEnabled ? "true" : "false";
indicator.dataset.supported = "true";
indicator.title = title;
@@ -794,9 +997,10 @@ function updateSubtitleNudgeIndicator(video) {
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
flashEl.textContent = label;
renderSubtitleNudgeIndicatorContent(flashEl, isEnabled);
flashEl.dataset.enabled = isEnabled ? "true" : "false";
flashEl.dataset.supported = "true";
flashEl.setAttribute("aria-label", title);
}
}
@@ -873,6 +1077,10 @@ function applySourceTransitionPolicy(video, forceUpdate) {
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
setSpeed(video, desiredSpeed, false, false);
}
// Same-tab SPA or DOM-driven player swaps can change the effective rule output
// after the media source updates, so refresh or rebuild controllers here too.
reapplySiteRulesAndControllerGeometry();
}
function extendSpeedRestoreWindow(video, duration) {
@@ -993,14 +1201,32 @@ function hasUsableMediaSource(node) {
}
function ensureController(node, parent) {
if (!isMediaElement(node) || node.vsc) return node && node.vsc;
if (!hasUsableMediaSource(node)) {
if (!isMediaElement(node)) return node && node.vsc;
if (!node.isConnected) {
removeController(node);
return null;
}
if (!node.vsc && !hasUsableMediaSource(node)) {
log(
`Deferring controller creation for ${node.tagName}: no usable source yet`,
5
);
return null;
}
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) {
removeController(node);
return null;
}
if (node.vsc) {
return refreshManagedController(node, parent);
}
log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4
@@ -1143,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)
? storage.keyBindings
: [];
@@ -1154,19 +1381,6 @@ chrome.storage.sync.get(tc.settings, function (storage) {
if (tc.settings.keyBindings.length === 0) {
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);
if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) {
@@ -1219,25 +1433,6 @@ chrome.storage.sync.get(tc.settings, function (storage) {
? storage.controllerButtons
: tc.settings.controllerButtons;
// Migrate legacy blacklist if present
if (storage.blacklist && typeof storage.blacklist === "string" && tc.settings.siteRules.length === 0) {
var lines = storage.blacklist.split("\n");
lines.forEach((line) => {
var pattern = line.replace(regStrip, "");
if (pattern.length > 0) {
tc.settings.siteRules.push({
pattern: pattern,
disableExtension: true
});
}
});
if (tc.settings.siteRules.length > 0) {
chrome.storage.sync.set({ siteRules: tc.settings.siteRules });
chrome.storage.sync.remove(["blacklist"]);
log("Migrated legacy blacklist to site rules", 4);
}
}
tc.settings.enableSubtitleNudge =
typeof storage.enableSubtitleNudge !== "undefined"
? Boolean(storage.enableSubtitleNudge)
@@ -1263,7 +1458,14 @@ chrome.storage.sync.get(tc.settings, function (storage) {
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();
patchAttachShadow();
@@ -1284,8 +1486,7 @@ chrome.storage.sync.get(tc.settings, function (storage) {
var videoGs = getPrimaryVideoElement();
if (!videoGs) return false;
sendResponse({
speed: videoGs.playbackRate,
resetLabel: computeResetButtonLabelForVideo(videoGs)
speed: videoGs.playbackRate
});
return false;
}
@@ -1302,8 +1503,7 @@ chrome.storage.sync.get(tc.settings, function (storage) {
var videoAfter = getPrimaryVideoElement();
if (!videoAfter) return false;
sendResponse({
speed: videoAfter.playbackRate,
resetLabel: computeResetButtonLabelForVideo(videoAfter)
speed: videoAfter.playbackRate
});
return false;
}
@@ -1314,7 +1514,63 @@ chrome.storage.sync.get(tc.settings, function (storage) {
// Set the flag to prevent adding the listener again.
window.vscMessageListener = true;
}
initializeWhenReady(document);
chrome.storage.local.get(["customButtonIcons"], function (loc) {
tc.settings.customButtonIcons =
loc &&
loc.customButtonIcons &&
typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
if (!window.vscCustomIconListener) {
window.vscCustomIconListener = true;
chrome.storage.onChanged.addListener(function (changes, area) {
if (area !== "local" || !changes.customButtonIcons) return;
var nv = changes.customButtonIcons.newValue;
tc.settings.customButtonIcons =
nv && typeof nv === "object" ? nv : {};
if (tc.mediaElements && tc.mediaElements.length) {
tc.mediaElements.forEach(function (video) {
if (!video.vsc || !video.vsc.div) return;
var doc = video.ownerDocument;
var shadow = video.vsc.div.shadowRoot;
if (!shadow) return;
shadow.querySelectorAll("button[data-action]").forEach(function (btn) {
var act = btn.dataset.action;
if (!act) return;
var svg =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[act] &&
tc.settings.customButtonIcons[act].svg;
vscClearElement(btn);
if (svg) {
var cw = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
if (cw) {
btn.appendChild(cw);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, act, 14);
if (wrap) {
btn.appendChild(wrap);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else {
var cdf2 = controllerButtonDefs[act];
btn.textContent = (cdf2 && cdf2.label) || "?";
}
});
updateSubtitleNudgeIndicator(video);
});
}
});
}
initializeWhenReady(document);
});
});
function getKeyBindings(action, what = "value") {
@@ -1332,7 +1588,27 @@ function setKeyBindings(action, value) {
function createControllerButton(doc, action, label, className) {
var button = doc.createElement("button");
button.dataset.action = action;
button.textContent = label;
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
if (custom) {
var customWrap = vscCreateSvgWrap(doc, custom, "vsc-btn-icon");
if (customWrap) {
button.appendChild(customWrap);
} else {
button.textContent = label || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, action, 14);
if (wrap) {
button.appendChild(wrap);
} else {
button.textContent = label || "?";
}
} else {
button.textContent = label || "?";
}
if (className) {
button.className = className;
}
@@ -1380,7 +1656,14 @@ function defineVideoController() {
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) {
if (
@@ -1654,10 +1937,10 @@ function defineVideoController() {
};
tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) {
if (!wrapper || !isOnYouTube()) return;
if (!wrapper) return;
const video = this.video;
const ytPlayer = video.closest(".html5-video-player");
const ytPlayer = getYouTubeAutoHidePlayer(video);
if (!ytPlayer) {
log("YouTube player not found for auto-hide setup", 4);
return;
@@ -1797,7 +2080,8 @@ function defineVideoController() {
wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// 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 shadowStylesheet = doc.createElement("link");
shadowStylesheet.rel = "stylesheet";
@@ -1849,8 +2133,9 @@ function defineVideoController() {
nudgeFlashIndicator.setAttribute("aria-hidden", "true");
controller.appendChild(dragHandle);
controller.appendChild(nudgeFlashIndicator);
controller.appendChild(controls);
/* Flash sits after #controls so it never inserts space between speed and buttons. */
controller.appendChild(nudgeFlashIndicator);
shadow.appendChild(controller);
this.speedIndicator = dragHandle;
@@ -1859,18 +2144,23 @@ function defineVideoController() {
this.resetButtonEl =
shadow.querySelector('button[data-action="reset"]') || null;
this.resetToggleArmed = false;
updateResetButtonLabel(this.video);
if (subtitleNudgeIndicator) {
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(
"mousedown",
(e) => {
runAction(
e.target.dataset["action"],
getKeyBindings(e.target.dataset["action"], "value"),
e
);
var dragAction = dragHandle.dataset.action;
runAction(dragAction, getKeyBindings(dragAction, "value"), e);
e.stopPropagation();
},
true
@@ -1879,11 +2169,9 @@ function defineVideoController() {
button.addEventListener(
"click",
(e) => {
runAction(
e.target.dataset["action"],
getKeyBindings(e.target.dataset["action"]),
e
);
var action = button.dataset.action;
runAction(action, getKeyBindings(action), e);
blurAfterPointerTap(button, e);
e.stopPropagation();
},
true
@@ -1898,6 +2186,7 @@ function defineVideoController() {
var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState);
}
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation();
},
true
@@ -1908,7 +2197,7 @@ function defineVideoController() {
// Setup auto-hide observers if enabled
if (tc.settings.hideWithControls) {
if (isOnYouTube()) {
if (getAutoHideModeForVideo(this.video) === "youtube") {
this.setupYouTubeAutoHide(wrapper);
} else {
this.setupGenericAutoHide(wrapper);
@@ -1980,60 +2269,26 @@ function defineVideoController() {
};
}
function escapeStringRegExp(str) {
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
}
var currentUrl = location.href;
var matchedRule = null;
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;
}
}
var matchedRule = vscMatchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) return false;
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
if (matchedRule.enabled === false) {
if (vscIsSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4);
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
@@ -2091,6 +2346,24 @@ function applySiteRuleOverrides() {
return false;
}
/** Re-match site rules for current URL and refresh or rebuild every controller. */
function reapplySiteRulesAndControllerGeometry() {
var siteDisabled = applySiteRuleOverrides();
var videos = tc.mediaElements.slice();
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) {
if (!video || !video.vsc) return false;
var desiredSpeed = getDesiredSpeed(video);
@@ -2127,7 +2400,6 @@ function setupListener(root) {
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
else video.vsc.startSubtitleNudge();
}
updateResetButtonLabel(video);
}
root.addEventListener(
"ratechange",
@@ -2163,7 +2435,6 @@ function setupListener(root) {
video.vsc.resetToggleArmed = false;
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
scheduleSpeedRestore(video, desiredSpeed, "pause/play or seek");
updateResetButtonLabel(video);
return;
}
@@ -2420,6 +2691,10 @@ function attachNavigationListeners() {
window.addEventListener("popstate", scheduleRescan);
window.addEventListener("hashchange", scheduleRescan);
/* YouTube often navigates without a history API call the extension can see first */
if (typeof document !== "undefined" && isOnYouTube()) {
document.addEventListener("yt-navigate-finish", scheduleRescan);
}
window.vscNavigationListenersAttached = true;
}
@@ -2427,7 +2702,6 @@ function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
@@ -2439,53 +2713,14 @@ function initializeNow(doc, forceReinit = false) {
if (forceReinit) {
log("Force re-initialization requested", 4);
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);
}
});
reapplySiteRulesAndControllerGeometry();
} else if (!tc.settings.enabled || siteDisabled) {
reapplySiteRulesAndControllerGeometry();
}
vscInitializedDocuments.add(doc);
}
function formatSpeedWithX(speed) {
var n = Number(speed);
if (!isFinite(n)) return "?x";
return n.toFixed(2) + "x";
}
function computeResetButtonLabelForVideo(video) {
if (!video) return "1.00x";
var rate = video.playbackRate;
var atOne = Math.abs(rate - 1.0) < 0.01;
var armed = video.vsc && video.vsc.resetToggleArmed === true;
if (atOne) {
if (armed) {
var videoId = getVideoSourceKey(video);
var lastToggle = lastToggleSpeed[videoId];
var pref = getKeyBindings("fast") || 1.8;
var speedToRestore =
lastToggle == null || Math.abs(lastToggle - 1.0) < 0.01
? pref
: lastToggle;
return formatSpeedWithX(speedToRestore);
}
return "1.00x";
}
return formatSpeedWithX(1.0);
}
function updateResetButtonLabel(video) {
if (!video || !video.vsc || !video.vsc.resetButtonEl) return;
video.vsc.resetButtonEl.textContent =
computeResetButtonLabelForVideo(video);
}
function setSpeed(
video,
speed,
@@ -2556,7 +2791,6 @@ function setSpeed(
video.vsc.startSubtitleNudge();
}
}
updateResetButtonLabel(video);
}
function runAction(action, value, e) {
@@ -2612,6 +2846,7 @@ function runAction(action, value, e) {
"mark",
"jump",
"drag",
"nudge",
"toggleSubtitleNudge",
"display"
];
@@ -2727,6 +2962,12 @@ function runAction(action, value, e) {
case "toggleSubtitleNudge":
setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue);
break;
case "nudge":
setSubtitleNudgeEnabledForVideo(
v,
!isSubtitleNudgeEnabledForVideo(v)
);
break;
}
});
log("runAction End", 5);
+150
View File
@@ -0,0 +1,150 @@
/**
* Lucide static icons via jsDelivr (same registry as lucide.dev).
* ISC License — https://lucide.dev
*/
var LUCIDE_STATIC_VERSION = "1.7.0";
var LUCIDE_CDN_BASE =
"https://cdn.jsdelivr.net/npm/lucide-static@" +
LUCIDE_STATIC_VERSION;
var LUCIDE_TAGS_CACHE_KEY = "lucideTagsCacheV1";
var LUCIDE_TAGS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7; /* 7 days */
function lucideIconSvgUrl(slug) {
if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(slug)) {
return "";
}
return LUCIDE_CDN_BASE + "/icons/" + slug.toLowerCase() + ".svg";
}
function lucideTagsJsonUrl() {
return LUCIDE_CDN_BASE + "/tags.json";
}
/** Collapse whitespace for smaller storage. */
function lucideMinifySvg(s) {
return String(s).replace(/\s+/g, " ").trim();
}
function sanitizeLucideSvg(svgText) {
if (!svgText || typeof svgText !== "string") return null;
var t = String(svgText).replace(/\0/g, "").trim();
if (!/<svg[\s>]/i.test(t)) return null;
var doc = new DOMParser().parseFromString(t, "image/svg+xml");
if (doc.querySelector("parsererror")) return null;
var svg = vscSanitizeSvgTree(doc.querySelector("svg"));
if (!svg) return null;
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("aria-hidden", "true");
return lucideMinifySvg(svg.outerHTML);
}
function fetchLucideSvg(slug) {
var url = lucideIconSvgUrl(slug);
if (!url) {
return Promise.reject(new Error("Invalid icon name"));
}
return fetch(url, { cache: "force-cache" }).then(function (r) {
if (!r.ok) {
throw new Error("Icon not found: " + slug);
}
return r.text();
});
}
function fetchAndCacheLucideTags(chromeLocal, resolve, reject) {
fetch(lucideTagsJsonUrl(), { cache: "force-cache" })
.then(function (r) {
if (!r.ok) throw new Error("tags.json HTTP " + r.status);
return r.json();
})
.then(function (obj) {
var payload = {};
payload[LUCIDE_TAGS_CACHE_KEY] = obj;
payload[LUCIDE_TAGS_CACHE_KEY + "At"] = Date.now();
if (chromeLocal && chromeLocal.set) {
chromeLocal.set(payload, function () {
resolve(obj);
});
} else {
resolve(obj);
}
})
.catch(reject);
}
/** @returns {Promise<Object<string, string[]>>} slug -> tags */
function getLucideTagsMap(chromeLocal, bypassCache) {
return new Promise(function (resolve, reject) {
if (!chromeLocal || !chromeLocal.get) {
fetch(lucideTagsJsonUrl(), { cache: "force-cache" })
.then(function (r) {
return r.json();
})
.then(resolve)
.catch(reject);
return;
}
if (bypassCache) {
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
return;
}
chromeLocal.get(
[LUCIDE_TAGS_CACHE_KEY, LUCIDE_TAGS_CACHE_KEY + "At"],
function (stored) {
if (chrome.runtime.lastError) {
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
return;
}
var data = stored[LUCIDE_TAGS_CACHE_KEY];
var at = stored[LUCIDE_TAGS_CACHE_KEY + "At"];
if (
data &&
typeof data === "object" &&
at &&
Date.now() - at < LUCIDE_TAGS_MAX_AGE_MS
) {
resolve(data);
return;
}
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
}
);
});
}
/**
* @param {Object<string,string[]>} tagsMap
* @param {string} query
* @param {number} limit
* @returns {string[]} slugs
*/
function searchLucideSlugs(tagsMap, query, limit) {
var lim = limit != null ? limit : 60;
var q = String(query || "")
.toLowerCase()
.trim();
if (!tagsMap || !q) return [];
var matches = [];
for (var slug in tagsMap) {
if (!Object.prototype.hasOwnProperty.call(tagsMap, slug)) continue;
var hay =
slug +
" " +
(Array.isArray(tagsMap[slug]) ? tagsMap[slug].join(" ") : "");
if (hay.toLowerCase().indexOf(q) === -1) continue;
matches.push(slug);
}
matches.sort(function (a, b) {
var al = a.toLowerCase();
var bl = b.toLowerCase();
var ap = al.indexOf(q) === 0 ? 0 : 1;
var bp = bl.indexOf(q) === 0 ? 0 : 1;
if (ap !== bp) return ap - bp;
return al.localeCompare(bl);
});
return matches.slice(0, lim);
}
+6 -3
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.0.4",
"version": "5.1.7.0",
"manifest_version": 2,
"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",
@@ -26,11 +26,12 @@
]
},
"permissions": [
"storage"
"storage",
"https://cdn.jsdelivr.net/*"
],
"options_ui": {
"page": "options.html",
"open_in_tab": false
"open_in_tab": true
},
"browser_action": {
"default_icon": {
@@ -58,6 +59,8 @@
"inject.css"
],
"js": [
"settings-core.js",
"ui-icons.js",
"inject.js"
]
}
+280 -9
View File
@@ -15,12 +15,16 @@
}
html {
min-height: 100%;
/* Avoid coupling to the browser viewport: embedded options (e.g. Add-ons
* Manager iframe) must size to content, not 100vh, or a large empty band
* appears below the page. */
height: auto;
min-height: 0;
}
body {
margin: 0;
min-height: 100vh;
min-height: 0;
padding: 24px 16px 40px;
background: var(--bg);
color: var(--text);
@@ -49,6 +53,7 @@ body {
}
h1,
h2,
h3,
h4 {
margin: 0;
@@ -104,6 +109,35 @@ h4 {
background: var(--panel);
}
.control-bars-group {
padding: 20px;
}
.control-bars-inner {
display: grid;
gap: 10px;
}
.settings-card-nested {
background: var(--panel-subtle);
border-radius: 10px;
}
.section-heading-major {
margin-bottom: 14px;
}
.section-heading-major h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 650;
letter-spacing: -0.02em;
}
.section-heading-major .section-intro {
margin-top: 0;
}
.section-heading {
margin-bottom: 10px;
}
@@ -502,6 +536,221 @@ label em {
border: 1px solid var(--border);
font-size: 12px;
line-height: 1;
color: var(--text);
}
.cb-icon svg {
width: 16px;
height: 16px;
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 {
justify-self: end;
}
.row-lucide-search-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 12px 0;
}
.row-lucide-search-row .lucide-search-label {
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.lucide-search-field {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
max-width: 100%;
min-height: 44px;
padding: 0 14px 0 12px;
border: 1px solid var(--border-strong);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.lucide-search-field:focus-within {
border-color: #9ca3af;
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.08);
}
.lucide-search-icon {
display: flex;
color: var(--muted);
flex-shrink: 0;
}
.lucide-search-input {
flex: 1;
min-width: 0;
min-height: 40px;
padding: 8px 0;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
font-size: 14px;
}
.lucide-search-input::placeholder {
color: var(--muted);
opacity: 0.85;
}
.lucide-search-input:focus {
outline: none;
}
.lucide-icon-results {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 220px;
overflow-y: auto;
padding: 12px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
button.lucide-result-tile {
width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--panel-subtle);
border: 1px solid var(--border-strong);
cursor: pointer;
}
button.lucide-result-tile:hover {
background: var(--panel);
border-color: #9ca3af;
}
button.lucide-result-tile .lucide-result-thumb {
width: 22px;
height: 22px;
object-fit: contain;
pointer-events: none;
}
button.lucide-result-tile.lucide-picked {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.12);
background: var(--panel);
}
.lucide-icon-status {
margin: 8px 0 0;
font-size: 12px;
color: var(--muted);
min-height: 1.2em;
}
.lucide-icon-preview-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 16px;
align-items: start;
margin-top: 14px;
}
.lucide-icon-preview {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--panel-subtle);
color: var(--text);
}
.lucide-icon-preview svg {
width: 36px;
height: 36px;
}
.lucide-icon-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.lucide-icon-actions .lucide-apply {
background: #ffffff !important;
color: #111827 !important;
border: 1px solid var(--border-strong) !important;
font-weight: 600;
}
.lucide-icon-actions .lucide-apply:hover {
background: #f3f4f6 !important;
border-color: #9ca3af !important;
}
.lucide-icon-actions .secondary {
background: var(--panel-subtle);
color: var(--text);
border-color: var(--border-strong);
}
.cb-label {
@@ -669,13 +918,6 @@ label em {
display: none;
}
#faq hr {
height: 1px;
margin: 0 0 14px;
border: 0;
background: var(--border);
}
.support-footer {
padding: 16px 20px;
color: var(--muted);
@@ -690,6 +932,10 @@ label em {
}
@media (max-width: 720px) {
.lucide-icon-preview-row {
grid-template-columns: 1fr;
}
.shortcut-row,
.shortcut-row.customs,
.row,
@@ -740,6 +986,10 @@ label em {
padding: 16px;
}
.control-bars-group {
padding: 16px;
}
.site-rule-header {
grid-template-columns: 1fr;
}
@@ -788,4 +1038,25 @@ label em {
textarea:focus {
border-color: #6b7280;
}
.lucide-search-field:focus-within {
border-color: #6b7280;
box-shadow: 0 0 0 3px rgba(242, 244, 246, 0.12);
}
.lucide-icon-actions .lucide-apply {
background: #ffffff !important;
color: #111315 !important;
border-color: #e5e7eb !important;
}
.lucide-icon-actions .lucide-apply:hover {
background: #f3f4f6 !important;
border-color: #d1d5db !important;
}
button.lucide-result-tile .lucide-result-thumb {
filter: brightness(0) invert(1);
opacity: 0.92;
}
}
+173 -64
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speeder Settings</title>
<link rel="stylesheet" href="options.css" />
<script src="settings-core.js"></script>
<script src="ui-icons.js"></script>
<script src="lucide-client.js"></script>
<script src="options.js"></script>
<script src="importExport.js"></script>
</head>
@@ -270,11 +273,6 @@
</label>
<input id="hideWithControlsTimer" type="text" placeholder="2" />
</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>
<h4 class="defaults-sub-heading">Subtitle sync</h4>
@@ -302,58 +300,160 @@
</div>
</section>
<section id="controlBarSettings" class="settings-card">
<div class="section-heading">
<h3>Control bar</h3>
<section
id="controlBarsGroup"
class="settings-card control-bars-group"
aria-labelledby="controlBarsGroupHeading"
>
<div class="section-heading section-heading-major">
<h2 id="controlBarsGroupHeading">Control bars</h2>
<p class="section-intro">
Drag blocks to reorder. Move between Active and Available to show
or hide buttons.
In-page hover bar, extension popup bar, and Lucide icons for
buttons.
</p>
</div>
<div class="cb-editor">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div
id="controlBarActive"
class="cb-dropzone cb-active-zone"
></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="controlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
</div>
</section>
<div class="control-bars-inner">
<section id="controlBarSettings" class="settings-card settings-card-nested">
<div class="section-heading">
<h3>Hover control bar</h3>
<p class="section-intro">
Drag blocks to reorder. Move between Active and Available to
show or hide buttons.
</p>
</div>
<div class="cb-editor">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div
id="controlBarActive"
class="cb-dropzone cb-active-zone"
></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="controlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
</div>
</section>
<section id="popupControlBarSettings" class="settings-card">
<div class="section-heading">
<h3>Popup control bar</h3>
<p class="section-intro">
Configure which buttons appear in the browser popup control bar.
</p>
</div>
<div class="row">
<label for="popupMatchHoverControls">Match hover controls</label>
<input id="popupMatchHoverControls" type="checkbox" />
</div>
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<section id="popupControlBarSettings" class="settings-card settings-card-nested">
<div class="section-heading">
<h3>Popup control bar</h3>
<p class="section-intro">
Configure which buttons appear in the browser popup control bar.
</p>
</div>
<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>
<input id="popupMatchHoverControls" type="checkbox" />
</div>
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div
id="popupControlBarActive"
class="cb-dropzone cb-active-zone"
></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="popupControlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
</div>
</section>
<section id="lucideIconSettings" class="settings-card settings-card-nested">
<div class="section-heading">
<h3>Button icons (Lucide)</h3>
<p class="section-intro">
Search icons from the
<a
href="https://lucide.dev"
target="_blank"
rel="noopener noreferrer"
>Lucide</a
>
set (fetched from jsDelivr). Custom icons are cached in local
storage and included when you export settings. Subtitle nudge
icons use two menu entries (enabled and disabled), not the bar
block id
<code>nudge</code>.
</p>
</div>
<div class="row row-lucide-pair">
<label for="lucideIconActionSelect">Controller action</label>
<select id="lucideIconActionSelect"></select>
</div>
<div class="row row-lucide-search-row">
<label for="lucideIconSearch" class="lucide-search-label"
>Search icons</label
>
<div class="lucide-search-field">
<span class="lucide-search-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
type="search"
id="lucideIconSearch"
class="lucide-search-input"
placeholder="Search by name or tag (e.g. star, arrow, media)…"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div
id="popupControlBarActive"
class="cb-dropzone cb-active-zone"
id="lucideIconResults"
class="lucide-icon-results"
role="listbox"
aria-label="Matching Lucide icons"
></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="popupControlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
<div class="lucide-icon-preview-row">
<div
id="lucideIconPreview"
class="lucide-icon-preview"
aria-live="polite"
></div>
<div class="lucide-icon-actions">
<button type="button" id="lucideIconApply" class="lucide-apply">
Apply to action
</button>
<button type="button" id="lucideIconClearAction" class="secondary">
Clear this action
</button>
<button type="button" id="lucideIconClearAll" class="secondary">
Clear all custom icons
</button>
<button type="button" id="lucideIconReloadTags" class="secondary">
Refresh icon list from network
</button>
</div>
</div>
</section>
</div>
</section>
@@ -451,18 +551,29 @@
<span>Override auto-hide for this site</span>
<input type="checkbox" class="override-autohide" />
</label>
<div class="site-autohide-container" style="display: none">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span>Hide with controls (idle-based)</span>
<input type="checkbox" class="site-hideWithControls" />
</label>
<div class="site-autohide-container" style="display: none">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span
>Hide with controls<br /><em
>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 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 class="site-rule-override-section">
<label class="site-override-lead">
@@ -580,8 +691,6 @@
</section>
<section id="faq" class="settings-card info-card">
<hr />
<h4>Extension controls not appearing?</h4>
<p>
This extension only works with HTML5 audio and video. If the
+430 -140
View File
@@ -138,7 +138,7 @@ var controllerButtonDefs = {
faster: { icon: "+", name: "Increase speed" },
advance: { icon: "\u00BB", name: "Advance" },
display: { icon: "\u00D7", name: "Close controller" },
reset: { icon: "1.00x", name: "Reset speed" },
reset: { icon: "\u21BB", name: "Reset speed" },
fast: { icon: "\u2605", name: "Preferred speed" },
nudge: { icon: "\u2713", name: "Subtitle nudge" },
settings: { icon: "\u2699", name: "Settings" },
@@ -147,69 +147,127 @@ var controllerButtonDefs = {
mark: { icon: "\u2691", name: "Set 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). */
var customButtonIconsLive = {};
function fillControlBarIconElement(icon, buttonId) {
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];
if (custom && custom.svg) {
if (vscSetSvgContent(icon, custom.svg)) return;
}
if (typeof vscIconSvgString === "function") {
var svgHtml = vscIconSvgString(buttonId, 16);
if (svgHtml) {
if (vscSetSvgContent(icon, svgHtml)) return;
}
}
vscClearElement(icon);
var def = controllerButtonDefs[buttonId];
icon.textContent = (def && def.icon) || "?";
}
function createDefaultBinding(action, key, keyCode, value) {
return {
action: action,
key: key,
keyCode: keyCode,
code: null,
disabled: false,
value: value,
force: false,
predefined: true
};
}
var tcDefaults = {
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: [
createDefaultBinding("display", "V", 86, 0),
createDefaultBinding("move", "P", 80, 0),
createDefaultBinding("slower", "S", 83, 0.1),
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,
controllerMarginTop: 60,
controllerMarginBottom: 85
var tcDefaults = vscGetSettingsDefaults();
var legacySyncKeys = [
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"blacklist"
];
function persistManagedSyncSettings(settings, callback) {
var nextSettings = vscBuildStoredSettingsDiff(settings);
chrome.storage.sync.remove(vscGetManagedSyncKeys(), function () {
if (chrome.runtime.lastError) {
callback(chrome.runtime.lastError);
return;
}
],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
if (Object.keys(nextSettings).length === 0) {
callback(null);
return;
}
chrome.storage.sync.set(nextSettings, function () {
callback(chrome.runtime.lastError || null);
});
});
}
const actionLabels = {
display: "Show/hide controller",
@@ -691,7 +749,7 @@ function save_options() {
document.getElementById("showPopupControlBar").checked;
settings.popupMatchHoverControls =
document.getElementById("popupMatchHoverControls").checked;
settings.popupControllerButtons = getPopupControlBarOrder();
settings.popupControllerButtons = sanitizePopupButtonOrder(getPopupControlBarOrder());
// Collect site rules
settings.siteRules = [];
@@ -780,7 +838,9 @@ function save_options() {
ruleEl.querySelector(".site-showPopupControlBar").checked;
var popupActiveZone = ruleEl.querySelector(".site-popup-cb-active");
if (popupActiveZone) {
rule.popupControllerButtons = readControlBarOrder(popupActiveZone);
rule.popupControllerButtons = sanitizePopupButtonOrder(
readControlBarOrder(popupActiveZone)
);
}
}
@@ -813,15 +873,20 @@ function save_options() {
settings.siteRules.push(rule);
});
// Legacy keys to remove
const legacyKeys = [
"resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime",
"resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode",
"advanceKeyCode", "fastKeyCode", "blacklist"
];
chrome.storage.sync.remove(legacySyncKeys, function () {
if (chrome.runtime.lastError) {
status.textContent =
"Error: Failed to clear legacy settings - " +
chrome.runtime.lastError.message;
return;
}
chrome.storage.sync.remove(legacyKeys, function () {
chrome.storage.sync.set(settings, function () {
persistManagedSyncSettings(settings, function (error) {
if (error) {
status.textContent =
"Error: Failed to save settings - " + error.message;
return;
}
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = "";
@@ -847,27 +912,6 @@ function ensureAllDefaultBindings(storage) {
});
}
function migrateLegacyBlacklist(storage) {
if (!storage.blacklist || typeof storage.blacklist !== "string") {
return [];
}
var siteRules = [];
var lines = storage.blacklist.split("\n");
lines.forEach((line) => {
var pattern = line.replace(regStrip, "");
if (pattern.length === 0) return;
siteRules.push({
pattern: pattern,
disableExtension: true
});
});
return siteRules;
}
function addSiteRuleShortcut(container, action, binding, value, force) {
var div = document.createElement("div");
div.setAttribute("class", "shortcut-row customs");
@@ -1070,7 +1114,10 @@ function createSiteRule(rule) {
populateControlBarZones(
sitePopupActive,
sitePopupAvailable,
rule.popupControllerButtons
sanitizePopupButtonOrder(rule.popupControllerButtons),
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
} else if (
sitePopupActive &&
@@ -1080,7 +1127,10 @@ function createSiteRule(rule) {
populateControlBarZones(
sitePopupActive,
sitePopupAvailable,
getPopupControlBarOrder()
getPopupControlBarOrder(),
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
}
}
@@ -1125,7 +1175,7 @@ function createControlBarBlock(buttonId) {
var icon = document.createElement("span");
icon.className = "cb-icon";
icon.textContent = def.icon;
fillControlBarIconElement(icon, buttonId);
var label = document.createElement("span");
label.className = "cb-label";
@@ -1138,16 +1188,23 @@ function createControlBarBlock(buttonId) {
return block;
}
function populateControlBarZones(activeZone, availableZone, activeIds) {
activeZone.innerHTML = "";
availableZone.innerHTML = "";
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
vscClearElement(activeZone);
vscClearElement(availableZone);
var allowed = function (id) {
if (!controllerButtonDefs[id]) return false;
return typeof allowButtonId === "function" ? Boolean(allowButtonId(id)) : true;
};
activeIds.forEach(function (id) {
if (!allowed(id)) return;
var block = createControlBarBlock(id);
if (block) activeZone.appendChild(block);
});
Object.keys(controllerButtonDefs).forEach(function (id) {
if (!allowed(id)) return;
if (!activeIds.includes(id)) {
var block = createControlBarBlock(id);
if (block) availableZone.appendChild(block);
@@ -1175,15 +1232,21 @@ function getControlBarOrder() {
}
function populatePopupControlBarEditor(activeIds) {
var popupActiveIds = sanitizePopupButtonOrder(activeIds);
populateControlBarZones(
document.getElementById("popupControlBarActive"),
document.getElementById("popupControlBarAvailable"),
activeIds
popupActiveIds,
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
}
function getPopupControlBarOrder() {
return readControlBarOrder(document.getElementById("popupControlBarActive"));
return sanitizePopupButtonOrder(
readControlBarOrder(document.getElementById("popupControlBarActive"))
);
}
function updatePopupEditorDisabledState() {
@@ -1280,48 +1343,254 @@ function initControlBarEditor() {
});
}
function restore_options() {
chrome.storage.sync.get(tcDefaults, function (storage) {
document.getElementById("rememberSpeed").checked = storage.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean;
document.getElementById("enabled").checked = storage.enabled;
document.getElementById("startHidden").checked = storage.startHidden;
var lucidePickerSelectedSlug = null;
var lucideSearchTimer = null;
// Migration/Normalization for hideWithControls
const hideWithControls = typeof storage.hideWithControls !== "undefined"
? storage.hideWithControls
: storage.hideWithYouTubeControls;
document.getElementById("hideWithControls").checked = hideWithControls;
function setLucideStatus(msg) {
var el = document.getElementById("lucideIconStatus");
if (el) el.textContent = msg || "";
}
function repaintAllCbIconsFromCustomMap() {
document.querySelectorAll(".cb-block .cb-icon").forEach(function (icon) {
var block = icon.closest(".cb-block");
if (!block) return;
fillControlBarIconElement(icon, block.dataset.buttonId);
});
}
function persistCustomButtonIcons(map, callback) {
chrome.storage.local.set({ customButtonIcons: map }, function () {
if (chrome.runtime.lastError) {
setLucideStatus(
"Could not save icons: " + chrome.runtime.lastError.message
);
return;
}
customButtonIconsLive = map;
if (callback) callback();
repaintAllCbIconsFromCustomMap();
});
}
function initLucideButtonIconsUI() {
var actionSel = document.getElementById("lucideIconActionSelect");
var searchInput = document.getElementById("lucideIconSearch");
var resultsEl = document.getElementById("lucideIconResults");
var previewEl = document.getElementById("lucideIconPreview");
if (!actionSel || !searchInput || !resultsEl || !previewEl) return;
if (typeof getLucideTagsMap !== "function") return;
if (!actionSel.dataset.lucideInit) {
actionSel.dataset.lucideInit = "1";
vscClearElement(actionSel);
Object.keys(controllerButtonDefs).forEach(function (aid) {
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");
o.value = aid;
o.textContent =
controllerButtonDefs[aid].name + " (" + aid + ")";
actionSel.appendChild(o);
});
}
function renderResults(slugs) {
vscClearElement(resultsEl);
slugs.forEach(function (slug) {
var b = document.createElement("button");
b.type = "button";
b.className = "lucide-result-tile";
b.dataset.slug = slug;
b.title = slug;
b.setAttribute("aria-label", slug);
if (slug === lucidePickerSelectedSlug) {
b.classList.add("lucide-picked");
}
var url =
typeof lucideIconSvgUrl === "function" ? lucideIconSvgUrl(slug) : "";
if (url) {
var img = document.createElement("img");
img.className = "lucide-result-thumb";
img.src = url;
img.alt = "";
img.loading = "lazy";
b.appendChild(img);
} else {
b.textContent = slug.slice(0, 3);
}
b.addEventListener("click", function () {
lucidePickerSelectedSlug = slug;
Array.prototype.forEach.call(
resultsEl.querySelectorAll("button"),
function (x) {
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
}
);
fetchLucideSvg(slug)
.then(function (txt) {
var safe = sanitizeLucideSvg(txt);
if (!safe) throw new Error("Bad SVG");
if (!vscSetSvgContent(previewEl, safe)) {
throw new Error("Preview render failed");
}
setLucideStatus("Preview: " + slug);
})
.catch(function (e) {
vscClearElement(previewEl);
setLucideStatus(
"Could not load: " + slug + " — " + e.message
);
});
});
resultsEl.appendChild(b);
});
}
if (!searchInput.dataset.lucideBound) {
searchInput.dataset.lucideBound = "1";
searchInput.addEventListener("input", function () {
clearTimeout(lucideSearchTimer);
lucideSearchTimer = setTimeout(function () {
getLucideTagsMap(chrome.storage.local, false)
.then(function (map) {
var q = searchInput.value;
if (!q.trim()) {
vscClearElement(resultsEl);
return;
}
renderResults(searchLucideSlugs(map, q, 48));
})
.catch(function (e) {
setLucideStatus("Icon list error: " + e.message);
});
}, 200);
});
}
var applyBtn = document.getElementById("lucideIconApply");
if (applyBtn && !applyBtn.dataset.lucideBound) {
applyBtn.dataset.lucideBound = "1";
applyBtn.addEventListener("click", function () {
var action = actionSel.value;
var slug = lucidePickerSelectedSlug;
if (!action || !slug) {
setLucideStatus("Pick an action and click an icon first.");
return;
}
fetchLucideSvg(slug)
.then(function (txt) {
var safe = sanitizeLucideSvg(txt);
if (!safe) throw new Error("Sanitize failed");
var next = Object.assign({}, customButtonIconsLive);
next[action] = { slug: slug, svg: safe };
persistCustomButtonIcons(next, function () {
setLucideStatus(
"Saved " +
slug +
" for " +
action +
". Reload pages for the hover bar."
);
});
})
.catch(function (e) {
setLucideStatus("Apply failed: " + e.message);
});
});
}
var clrOne = document.getElementById("lucideIconClearAction");
if (clrOne && !clrOne.dataset.lucideBound) {
clrOne.dataset.lucideBound = "1";
clrOne.addEventListener("click", function () {
var action = actionSel.value;
if (!action) return;
var next = Object.assign({}, customButtonIconsLive);
delete next[action];
persistCustomButtonIcons(next, function () {
setLucideStatus("Cleared custom icon for " + action + ".");
});
});
}
var clrAll = document.getElementById("lucideIconClearAll");
if (clrAll && !clrAll.dataset.lucideBound) {
clrAll.dataset.lucideBound = "1";
clrAll.addEventListener("click", function () {
persistCustomButtonIcons({}, function () {
setLucideStatus("All custom icons cleared.");
});
});
}
var reloadTags = document.getElementById("lucideIconReloadTags");
if (reloadTags && !reloadTags.dataset.lucideBound) {
reloadTags.dataset.lucideBound = "1";
reloadTags.addEventListener("click", function () {
getLucideTagsMap(chrome.storage.local, true)
.then(function () {
setLucideStatus("Icon name list refreshed.");
})
.catch(function (e) {
setLucideStatus("Refresh failed: " + e.message);
});
});
}
}
function restore_options() {
chrome.storage.sync.get(null, function (storage) {
var settings = vscExpandStoredSettings(storage);
chrome.storage.local.get(["customButtonIcons"], function (loc) {
customButtonIconsLive =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
document.getElementById("rememberSpeed").checked = settings.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked =
settings.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = settings.audioBoolean;
document.getElementById("enabled").checked = settings.enabled;
document.getElementById("startHidden").checked = settings.startHidden;
document.getElementById("hideWithControls").checked =
settings.hideWithControls;
document.getElementById("hideWithControlsTimer").value =
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
settings.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
document.getElementById("controllerLocation").value =
normalizeControllerLocation(storage.controllerLocation);
normalizeControllerLocation(settings.controllerLocation);
document.getElementById("controllerOpacity").value =
storage.controllerOpacity;
settings.controllerOpacity;
document.getElementById("controllerMarginTop").value =
storage.controllerMarginTop ?? tcDefaults.controllerMarginTop;
settings.controllerMarginTop ?? tcDefaults.controllerMarginTop;
document.getElementById("controllerMarginBottom").value =
storage.controllerMarginBottom ?? tcDefaults.controllerMarginBottom;
settings.controllerMarginBottom ?? tcDefaults.controllerMarginBottom;
document.getElementById("showPopupControlBar").checked =
storage.showPopupControlBar !== false;
settings.showPopupControlBar !== false;
document.getElementById("enableSubtitleNudge").checked =
storage.enableSubtitleNudge;
settings.enableSubtitleNudge;
document.getElementById("subtitleNudgeInterval").value =
storage.subtitleNudgeInterval;
settings.subtitleNudgeInterval;
if (!Array.isArray(storage.keyBindings) || storage.keyBindings.length === 0) {
storage.keyBindings = tcDefaults.keyBindings.slice();
if (!Array.isArray(settings.keyBindings) || settings.keyBindings.length === 0) {
settings.keyBindings = tcDefaults.keyBindings.slice();
}
ensureAllDefaultBindings(storage);
ensureAllDefaultBindings(settings);
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
storage.keyBindings.forEach((item) => {
settings.keyBindings.forEach((item) => {
var row = document.getElementById(item.action);
var normalizedBinding = normalizeStoredBinding(item);
@@ -1350,18 +1619,11 @@ function restore_options() {
refreshAddShortcutSelector();
// Load site rules (use defaults if none in storage or if storage has empty array)
var siteRules = Array.isArray(storage.siteRules) && storage.siteRules.length > 0
? storage.siteRules
: (storage.blacklist ? migrateLegacyBlacklist(storage) : (tcDefaults.siteRules || []));
var siteRules = Array.isArray(settings.siteRules)
? settings.siteRules
: tcDefaults.siteRules || [];
// If we migrated from blacklist, save the new format
if (storage.blacklist && siteRules.length > 0) {
chrome.storage.sync.set({ siteRules: siteRules });
chrome.storage.sync.remove(["blacklist"]);
}
document.getElementById("siteRulesContainer").innerHTML = "";
vscClearElement(document.getElementById("siteRulesContainer"));
if (siteRules.length > 0) {
siteRules.forEach((rule) => {
if (rule && rule.pattern) {
@@ -1370,32 +1632,57 @@ function restore_options() {
});
}
var controllerButtons = Array.isArray(storage.controllerButtons)
? storage.controllerButtons
var controllerButtons = Array.isArray(settings.controllerButtons)
? settings.controllerButtons
: tcDefaults.controllerButtons;
populateControlBarEditor(controllerButtons);
document.getElementById("popupMatchHoverControls").checked =
storage.popupMatchHoverControls !== false;
settings.popupMatchHoverControls !== false;
var popupButtons = Array.isArray(storage.popupControllerButtons)
? storage.popupControllerButtons
var popupButtons = Array.isArray(settings.popupControllerButtons)
? settings.popupControllerButtons
: tcDefaults.popupControllerButtons;
populatePopupControlBarEditor(popupButtons);
updatePopupEditorDisabledState();
initLucideButtonIconsUI();
});
});
}
function restore_defaults() {
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
chrome.storage.sync.set(tcDefaults, function () {
restore_options();
var status = document.getElementById("status");
status.textContent = "Default options restored";
setTimeout(function () {
status.textContent = "";
}, 1000);
chrome.storage.local.remove(
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
function () {}
);
chrome.storage.sync.remove(legacySyncKeys, function () {
if (chrome.runtime.lastError) {
var errorStatus = document.getElementById("status");
errorStatus.textContent =
"Error: Failed to clear legacy settings - " +
chrome.runtime.lastError.message;
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);
});
});
}
@@ -1568,7 +1855,10 @@ document.addEventListener("DOMContentLoaded", function () {
populateControlBarZones(
popupActiveZone,
popupAvailableZone,
getPopupControlBarOrder()
getPopupControlBarOrder(),
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
}
} else {
+16
View File
@@ -159,6 +159,22 @@ button:focus-visible {
opacity: 0.55;
}
.popup-control-bar button .vsc-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
line-height: 0;
vertical-align: middle;
}
.popup-control-bar button .vsc-btn-icon svg {
width: 100%;
height: 100%;
flex-shrink: 0;
}
.popup-status {
font-size: 12px;
color: var(--muted);
+2
View File
@@ -4,6 +4,8 @@
<meta charset="utf-8" />
<title>Speeder</title>
<link rel="stylesheet" href="popup.css" />
<script src="settings-core.js"></script>
<script src="ui-icons.js"></script>
<script src="popup.js"></script>
</head>
<body>
+117 -125
View File
@@ -1,115 +1,68 @@
document.addEventListener("DOMContentLoaded", function () {
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
/* `label` is only used if ui-icons.js has no path for this action (fallback). */
var controllerButtonDefs = {
rewind: { label: "\u00AB", className: "rw" },
slower: { label: "\u2212", className: "" },
faster: { label: "+", className: "" },
advance: { label: "\u00BB", className: "rw" },
display: { label: "\u00D7", className: "hideButton" },
reset: { label: "1.00x", className: "" },
fast: { label: "\u2605", className: "" },
settings: { label: "\u2699", className: "" },
pause: { label: "\u23EF", className: "" },
muted: { label: "M", className: "" },
mark: { label: "\u2691", className: "" },
jump: { label: "\u21E5", className: "" }
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
faster: { label: "", className: "" },
advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
nudge: { label: "", className: "" },
settings: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" }
};
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
var storageDefaults = {
enabled: true,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
popupControllerButtons: defaultButtons,
siteRules: [],
blacklist: `\
www.instagram.com
twitter.com
vine.co
imgur.com
teams.microsoft.com
`.replace(/^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm, "")
};
var popupExcludedButtonIds = new Set(["settings"]);
var renderToken = 0;
function escapeStringRegExp(str) {
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function isBlacklisted(url, blacklist) {
let b = false;
const l = blacklist ? blacklist.split("\n") : [];
l.forEach((m) => {
if (b) return;
m = m.replace(regStrip, "");
if (m.length == 0) return;
let r;
if (m.startsWith("/") && m.lastIndexOf("/") > 0) {
try {
const ls = m.lastIndexOf("/");
r = new RegExp(m.substring(1, ls), m.substring(ls + 1));
} catch (e) {
return;
}
} else r = new RegExp(escapeStringRegExp(m));
if (r && r.test(url)) b = true;
});
return b;
}
function matchSiteRule(url, siteRules) {
if (!url || !Array.isArray(siteRules)) return null;
for (var i = 0; i < siteRules.length; i++) {
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;
return vscMatchSiteRule(url, siteRules);
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(rule.enabled === false || rule.disableExtension === true)
);
return vscIsSiteRuleDisabled(rule);
}
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)) {
return siteRule.popupControllerButtons;
return sanitize(siteRule.popupControllerButtons);
}
if (storage.popupMatchHoverControls) {
if (siteRule && Array.isArray(siteRule.controllerButtons)) {
return siteRule.controllerButtons;
return sanitize(siteRule.controllerButtons);
}
if (Array.isArray(storage.controllerButtons)) {
return storage.controllerButtons;
return sanitize(storage.controllerButtons);
}
}
if (Array.isArray(storage.popupControllerButtons)) {
return storage.popupControllerButtons;
return sanitize(storage.popupControllerButtons);
}
return defaultButtons;
return sanitize(defaultButtons);
}
function setControlBarVisible(visible) {
@@ -171,20 +124,10 @@ document.addEventListener("DOMContentLoaded", function () {
if (el) el.textContent = (speed != null ? Number(speed) : 1).toFixed(2);
}
function updatePopupResetLabel(resetLabel) {
var bar = document.getElementById("popupControlBar");
if (!bar || typeof resetLabel !== "string") return;
var btn = bar.querySelector('button[data-action="reset"]');
if (btn) btn.textContent = resetLabel;
}
function applySpeedAndResetFromResponse(response) {
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
}
if (response && response.resetLabel != null) {
updatePopupResetLabel(response.resetLabel);
}
}
function pickBestFrameSpeedResult(results) {
@@ -194,18 +137,14 @@ document.addEventListener("DOMContentLoaded", function () {
var fallback = null;
for (i = 0; i < results.length; i++) {
r = results[i];
if (
!r ||
typeof r.speed !== "number" ||
typeof r.resetLabel !== "string"
) {
if (!r || typeof r.speed !== "number") {
continue;
}
if (r.preferred) {
return { speed: r.speed, resetLabel: r.resetLabel };
return { speed: r.speed };
}
if (!fallback) {
fallback = { speed: r.speed, resetLabel: r.resetLabel };
fallback = { speed: r.speed };
}
}
return fallback;
@@ -223,9 +162,7 @@ document.addEventListener("DOMContentLoaded", function () {
function (results) {
if (chrome.runtime.lastError) {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(
response || { speed: 1, resetLabel: "1.00x" }
);
applySpeedAndResetFromResponse(response || { speed: 1 });
});
return;
}
@@ -234,9 +171,7 @@ document.addEventListener("DOMContentLoaded", function () {
applySpeedAndResetFromResponse(best);
} else {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(
response || { speed: 1, resetLabel: "1.00x" }
);
applySpeedAndResetFromResponse(response || { speed: 1 });
});
}
}
@@ -244,21 +179,44 @@ document.addEventListener("DOMContentLoaded", function () {
});
}
function buildControlBar(buttons) {
function buildControlBar(buttons, customIconsMap) {
var bar = document.getElementById("popupControlBar");
if (!bar) return;
var existing = bar.querySelectorAll("button");
existing.forEach(function (btn) { btn.remove(); });
var customMap = customIconsMap || {};
buttons.forEach(function (btnId) {
if (btnId === "nudge") return;
var def = controllerButtonDefs[btnId];
if (!def) return;
var btn = document.createElement("button");
btn.dataset.action = btnId;
btn.textContent = def.label;
var customEntry = customMap[btnId];
if (customEntry && customEntry.svg) {
var customSpan = vscCreateSvgWrap(document, customEntry.svg, "vsc-btn-icon");
if (customSpan) {
btn.appendChild(customSpan);
} else {
btn.textContent = def.label || "?";
}
} else if (typeof vscIconSvgString === "function") {
var svgStr = vscIconSvgString(btnId, 16);
if (svgStr) {
var iconSpan = vscCreateSvgWrap(document, svgStr, "vsc-btn-icon");
if (iconSpan) {
btn.appendChild(iconSpan);
} else {
btn.textContent = def.label || "?";
}
} else {
btn.textContent = def.label || "?";
}
} else {
btn.textContent = def.label || "?";
}
if (def.className) btn.className = def.className;
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
@@ -335,18 +293,24 @@ document.addEventListener("DOMContentLoaded", function () {
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
chrome.storage.sync.get(storageDefaults, function (storage) {
chrome.storage.local.get(["customButtonIcons"], function (loc) {
if (currentRenderToken !== renderToken) return;
var customIconsMap =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
chrome.storage.sync.get(null, function (storage) {
if (currentRenderToken !== renderToken) return;
storage = vscExpandStoredSettings(storage);
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var blacklisted = isBlacklisted(url, storage.blacklist);
var siteDisabled = isSiteRuleDisabled(siteRule);
var siteAvailable =
storage.enabled !== false && !blacklisted && !siteDisabled;
var siteAvailable = storage.enabled !== false && !siteDisabled;
var showBar = storage.showPopupControlBar !== false;
if (siteRule && siteRule.showPopupControlBar !== undefined) {
@@ -354,20 +318,15 @@ document.addEventListener("DOMContentLoaded", function () {
}
toggleEnabledUI(storage.enabled !== false);
buildControlBar(resolvePopupButtons(storage, siteRule));
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
if (blacklisted) {
setStatusMessage("Site is blacklisted.");
updateSpeedDisplay(1);
updatePopupResetLabel("1.00x");
return;
}
if (siteDisabled) {
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
updatePopupResetLabel("1.00x");
return;
}
@@ -376,9 +335,9 @@ document.addEventListener("DOMContentLoaded", function () {
querySpeed();
} else {
updateSpeedDisplay(1);
updatePopupResetLabel("1.00x");
}
});
});
});
}
@@ -394,6 +353,10 @@ document.addEventListener("DOMContentLoaded", function () {
});
chrome.storage.onChanged.addListener(function (changes, areaName) {
if (areaName === "local" && changes.customButtonIcons) {
renderForActiveTab();
return;
}
if (areaName !== "sync") return;
if (
changes.enabled ||
@@ -402,7 +365,8 @@ document.addEventListener("DOMContentLoaded", function () {
changes.popupMatchHoverControls ||
changes.popupControllerButtons ||
changes.siteRules ||
changes.blacklist
changes.siteRulesMeta ||
changes.siteRulesFormat
) {
renderForActiveTab();
}
@@ -411,9 +375,37 @@ document.addEventListener("DOMContentLoaded", function () {
renderForActiveTab();
function toggleEnabled(enabled, callback) {
chrome.storage.sync.set({ enabled: enabled }, function () {
toggleEnabledUI(enabled);
if (callback) callback(enabled);
chrome.storage.sync.get(null, function (storage) {
var nextSettings = vscExpandStoredSettings(storage);
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);
});
});
});
}
+16 -21
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Bump manifest on dev, merge dev→beta→main, push an annotated stable tag (v* without -beta).
# Squash beta onto main, set manifest version, one release commit, push stable tag (v* without -beta).
# Does not merge dev or push to beta — promote only what is already on beta.
# Triggers .github/workflows/deploy.yml: listed AMO submission.
set -euo pipefail
@@ -54,11 +55,11 @@ if [[ -n "$(git status --porcelain)" ]]; then
exit 1
fi
git checkout dev
git pull origin dev
git checkout beta
git pull origin beta
echo "Current version in manifest.json: $(manifest_version)"
read -r -p "New version for manifest.json (e.g. 5.0.4): " SEMVER_IN
echo "Current version on beta (manifest.json): $(manifest_version)"
read -r -p "Release version for manifest.json + tag (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
@@ -71,28 +72,22 @@ fi
echo
echo "This will:"
echo " 1. set manifest.json version to $SEMVER and commit on dev"
echo " 2. merge dev → beta and push beta"
echo " 3. merge beta → main and push main"
echo " 4. create tag $TAG on main and push it (triggers listed AMO submit)"
echo " 5. checkout dev"
echo " 1. checkout main, merge --squash origin/beta (single release commit on main)"
echo " 2. set manifest.json to $SEMVER in that commit (if anything else changed, it is included too)"
echo " 3. push origin main, create tag $TAG, push tag (triggers listed AMO submit)"
echo " 4. checkout dev (merge main→dev yourself if you want them aligned)"
read -r -p "Continue? [y/N] " confirm
[[ "${confirm:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
echo "🚀 Releasing stable $TAG to AMO (listed)"
bump_manifest "$SEMVER"
git add manifest.json
git commit -m "Bump version to $SEMVER"
git checkout beta
git pull origin beta
git merge dev --no-ff -m "Merge dev ($TAG)"
git push origin beta
git checkout main
git pull origin main
git merge beta --no-ff -m "Merge beta ($TAG)"
git merge --squash beta
bump_manifest "$SEMVER"
git add -A
git commit -m "Release $TAG"
git push origin main
git tag -a "$TAG" -m "$TAG"
@@ -100,4 +95,4 @@ git push origin "$TAG"
git checkout dev
echo "✅ Done: stable $TAG (manifest $SEMVER, main + tag pushed)"
echo "✅ Done: main squashed from beta, tagged $TAG (manifest $SEMVER)"
+5 -3
View File
@@ -76,10 +76,10 @@ fi
echo
echo "This will:"
echo " 1. set manifest.json version to $SEMVER and commit on dev"
echo " 1. set manifest.json version to $SEMVER, commit on dev, push origin dev"
echo " 2. checkout beta, merge dev (no-ff), push origin beta"
echo " 3. create tag $TAG and push it (triggers beta AMO + prerelease)"
echo " 4. checkout dev"
echo " 4. checkout dev (main is not modified)"
read -r -p "Continue? [y/N] " confirm
[[ "${confirm:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
@@ -88,6 +88,7 @@ echo "🚀 Releasing beta $TAG"
bump_manifest "$SEMVER"
git add manifest.json
git commit -m "Bump version to $SEMVER"
git push origin dev
git checkout beta
git pull origin beta
@@ -98,5 +99,6 @@ git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
git pull origin dev
echo "✅ Done: beta $TAG (manifest $SEMVER, merge + tag pushed)"
echo "✅ Done: beta $TAG (manifest $SEMVER; dev + beta + tag pushed)"
+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);
+130 -24
View File
@@ -4,10 +4,20 @@
font-size: 13px;
}
/* Global * uses 1.9em line-height; without this, every node inside #controller
(including svg) keeps a tall line box and the bar grows + content rides high. */
#controller * {
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:focus-within #controls,
#controller:focus-within:has(:focus-visible) #controls,
:host(:hover) #controls {
display: inline;
display: inline-flex;
vertical-align: middle;
}
#controller {
@@ -40,8 +50,9 @@
opacity: 0.7;
}
/* Space between speed readout and hover buttons — tweak this value (px) as you like */
#controller:hover > .draggable {
margin-right: 0.8em;
margin-right: 5px;
}
/* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */
@@ -55,25 +66,30 @@
#controls {
display: none;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 3px;
white-space: nowrap;
overflow: visible;
max-width: none;
}
#controls > * + * {
margin-left: 3px;
}
/* Standalone flash indicator next to speed text — hidden by default,
briefly shown when nudge is toggled via N key or click */
/* Standalone flash next to speed when N is pressed — hidden = no layout footprint */
#nudge-flash-indicator {
display: none;
margin: 0;
padding: 0;
border: 0;
width: 0;
min-width: 0;
max-width: 0;
height: 0;
min-height: 0;
overflow: hidden;
vertical-align: middle;
align-items: center;
justify-content: center;
margin-left: 0.3em;
padding: 3px 6px;
border-radius: 5px;
font-size: 14px;
line-height: 14px;
font-weight: bold;
@@ -81,8 +97,27 @@
box-sizing: border-box;
}
/* Same 24×24 footprint as #controls button */
#nudge-flash-indicator.visible {
display: inline-flex;
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-width: 24px;
max-height: 24px;
margin-left: 5px;
padding: 0;
border-width: 1px;
border-style: solid;
border-radius: 5px;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 0;
overflow: hidden;
flex-shrink: 0;
}
/* Hide flash indicator when hovering — the one in #controls is visible instead */
@@ -100,46 +135,75 @@
#nudge-flash-indicator[data-enabled="true"] {
color: #fff;
background: #4b9135;
border: 1px solid #6ec754;
border-color: #6ec754;
}
#nudge-flash-indicator[data-enabled="false"] {
color: #fff;
background: #943e3e;
border: 1px solid #c06060;
border-color: #c06060;
}
/* Same 24×24 chip as control buttons (Lucide check / x inside) */
#nudge-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 6px;
border-radius: 5px;
font-size: 14px;
line-height: 14px;
font-weight: bold;
font-family: "Lucida Console", Monaco, monospace;
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-height: 24px;
padding: 0;
border-width: 1px;
border-style: solid;
border-radius: 5px;
font-size: 0;
line-height: 0;
cursor: pointer;
margin-bottom: 2px;
margin: 0;
flex-shrink: 0;
overflow: hidden;
}
#nudge-indicator[data-enabled="true"] {
color: #fff;
background: #4b9135;
border: 1px solid #6ec754;
border-color: #6ec754;
}
#nudge-indicator[data-enabled="false"] {
color: #fff;
background: #943e3e;
border: 1px solid #c06060;
border-color: #c06060;
}
#nudge-indicator[data-supported="false"] {
opacity: 0.6;
}
#nudge-flash-indicator.visible .vsc-btn-icon,
#nudge-indicator .vsc-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
line-height: 0;
}
#nudge-flash-indicator.visible .vsc-btn-icon svg,
#nudge-indicator .vsc-btn-icon svg {
display: block;
width: 100%;
height: 100%;
flex-shrink: 0;
transform: translateY(0.5px);
}
#controller.dragging {
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
@@ -148,12 +212,14 @@
}
#controller.dragging #controls {
display: inline;
display: inline-flex;
vertical-align: middle;
}
.draggable {
cursor: -webkit-grab;
cursor: -moz-grab;
vertical-align: middle;
}
.draggable:active {
@@ -175,6 +241,46 @@ button {
margin-bottom: 2px;
}
/* Icon buttons: square targets, compact bar (no extra vertical stretch). */
#controls button {
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-height: 24px;
padding: 0;
margin: 0;
border-width: 1px;
line-height: 0;
font-size: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
button .vsc-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
line-height: 0;
}
button .vsc-btn-icon svg {
display: block;
width: 100%;
height: 100%;
flex-shrink: 0;
/* Lucide 24×24 paths sit slightly high in the viewBox */
transform: translateY(0.5px);
}
button:focus {
outline: 0;
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Inline SVG icons (Lucide-style strokes, compatible with https://lucide.dev — ISC license).
* Use stroke="currentColor" so buttons inherit foreground for monochrome UI.
*/
var VSC_ICON_SIZE_DEFAULT = 18;
var VSC_SVG_NS = "http://www.w3.org/2000/svg";
/** Inner SVG markup only (paths / shapes inside <svg>). */
var vscUiIconPaths = {
rewind:
'<polygon points="11 19 2 12 11 5 11 19"/><polygon points="22 19 13 12 22 5 22 19"/>',
advance:
'<polygon points="13 19 22 12 13 5 13 19"/><polygon points="2 19 11 12 2 5 2 19"/>',
reset:
'<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
slower: '<line x1="5" y1="12" x2="19" y2="12"/>',
faster:
'<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
display:
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
fast: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
settings:
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
pause:
'<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
muted:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>',
mark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
jump:
'<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/>',
nudge: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
/** Lucide check — subtitle nudge on */
subtitleNudgeOn: '<path d="M20 6 9 17l-5-5"/>',
/** Lucide x — subtitle nudge off */
subtitleNudgeOff:
'<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'
};
/**
* @param {number} [size] - width/height in px
* @returns {string} full <svg>…</svg>
*/
function vscIconSvgString(action, size) {
var inner = vscUiIconPaths[action];
if (!inner) return "";
var s = size != null ? size : VSC_ICON_SIZE_DEFAULT;
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="' +
s +
'" height="' +
s +
'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
inner +
"</svg>"
);
}
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 {string} action
* @returns {HTMLElement|null} wrapper span containing svg, or null if no icon
*/
function vscIconWrap(doc, action, size) {
var html = vscIconSvgString(action, size);
if (!html) return null;
return vscCreateSvgWrap(doc, html, "vsc-btn-icon");
}