Release v5.1.4

This commit is contained in:
2026-04-02 18:07:09 -04:00
parent fb25c56230
commit b3707c0803
16 changed files with 1955 additions and 380 deletions
+4 -2
View File
@@ -46,7 +46,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Beta ${{ github.ref_name }}
name: ${{ github.ref_name }}
files: ${{ steps.xpi.outputs.file }}
prerelease: true
body: |
@@ -61,7 +61,9 @@ jobs:
# Stable tag (v* without -beta) → Sign & submit to public AMO listing
- name: Sign & Submit to AMO (stable)
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-beta')
if:
startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name,
'-beta')
run: |
web-ext sign \
--api-key ${{ secrets.FIREFOX_API_KEY }} \
+16
View File
@@ -0,0 +1,16 @@
/* Runs via chrome.tabs.executeScript(allFrames) in the same isolated world as inject.js */
(function () {
try {
if (typeof getPrimaryVideoElement !== "function") {
return null;
}
var v = getPrimaryVideoElement();
if (!v) return null;
return {
speed: v.playbackRate,
preferred: !v.paused
};
} catch (e) {
return null;
}
})();
+33 -5
View File
@@ -13,10 +13,12 @@ function generateBackupFilename() {
function exportSettings() {
chrome.storage.sync.get(null, function (storage) {
chrome.storage.local.get(null, function (localStorage) {
const backup = {
version: "1.0",
version: "1.1",
exportDate: new Date().toISOString(),
settings: storage
settings: storage,
localSettings: localStorage || {}
};
const dataStr = JSON.stringify(backup, null, 2);
@@ -33,6 +35,7 @@ function exportSettings() {
showStatus("Settings exported successfully");
});
});
}
function importSettings() {
@@ -62,12 +65,20 @@ function importSettings() {
return;
}
// Import all settings
var localToImport =
backup.localSettings && typeof backup.localSettings === "object"
? backup.localSettings
: null;
function afterLocalImport() {
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);
showStatus(
"Error: Failed to save imported settings - " +
chrome.runtime.lastError.message,
true
);
return;
}
showStatus("Settings imported successfully. Reloading...");
@@ -80,6 +91,23 @@ function importSettings() {
}, 500);
});
});
}
if (localToImport && Object.keys(localToImport).length > 0) {
chrome.storage.local.set(localToImport, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save local extension data - " +
chrome.runtime.lastError.message,
true
);
return;
}
afterLocalImport();
});
} else {
afterLocalImport();
}
} catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true);
}
+230 -84
View File
@@ -1,8 +1,15 @@
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
function getPrimaryVideoElement() {
if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
for (var i = 0; i < tc.mediaElements.length; i++) {
var el = tc.mediaElements[i];
if (el && !el.paused) return el;
}
return tc.mediaElements[0];
}
var tc = {
settings: {
lastSpeed: 1.0,
@@ -29,7 +36,8 @@ var tc = {
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
subtitleNudgeAmount: 0.001,
customButtonIcons: {}
},
mediaElements: [],
isNudging: false,
@@ -106,19 +114,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: "\u21BA", 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 = {
@@ -767,16 +776,39 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) {
return normalizedEnabled;
}
function subtitleNudgeIconMarkup(isEnabled) {
var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff";
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
if (custom) {
return (
'<span class="vsc-btn-icon" aria-hidden="true">' + custom + "</span>"
);
}
if (typeof vscIconSvgString !== "function") {
return isEnabled ? "✓" : "×";
}
var svg = vscIconSvgString(action, 14);
if (!svg) {
return isEnabled ? "✓" : "×";
}
return (
'<span class="vsc-btn-icon" aria-hidden="true">' + svg + "</span>"
);
}
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 mark = subtitleNudgeIconMarkup(isEnabled);
var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) {
indicator.textContent = label;
indicator.innerHTML = mark;
indicator.dataset.enabled = isEnabled ? "true" : "false";
indicator.dataset.supported = "true";
indicator.title = title;
@@ -785,9 +817,10 @@ function updateSubtitleNudgeIndicator(video) {
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
flashEl.textContent = label;
flashEl.innerHTML = mark;
flashEl.dataset.enabled = isEnabled ? "true" : "false";
flashEl.dataset.supported = "true";
flashEl.setAttribute("aria-label", title);
}
}
@@ -864,6 +897,10 @@ function applySourceTransitionPolicy(video, forceUpdate) {
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
setSpeed(video, desiredSpeed, false, false);
}
// Same-tab SPA (e.g. YouTube watch → Shorts): URL can change while remember-speed
// already ran on src mutation — re-apply margins / location / opacity for new rules.
reapplySiteRulesAndControllerGeometry();
}
function extendSpeedRestoreWindow(video, duration) {
@@ -992,6 +1029,14 @@ function ensureController(node, parent) {
);
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) {
return null;
}
refreshAllControllerGeometry();
log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4
@@ -1210,25 +1255,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)
@@ -1267,43 +1293,96 @@ chrome.storage.sync.get(tc.settings, function (storage) {
log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true);
sendResponse({ status: "complete" });
} else if (request.action === "get_speed") {
var speed = 1.0;
if (tc.mediaElements && tc.mediaElements.length > 0) {
for (var i = 0; i < tc.mediaElements.length; i++) {
if (tc.mediaElements[i] && !tc.mediaElements[i].paused) {
speed = tc.mediaElements[i].playbackRate;
break;
return false;
}
if (request.action === "get_speed") {
// Do not sendResponse in frames with no media — only one response is
// accepted tab-wide, and the top frame often wins before an iframe.
var videoGs = getPrimaryVideoElement();
if (!videoGs) return false;
sendResponse({
speed: videoGs.playbackRate
});
return false;
}
if (speed === 1.0 && tc.mediaElements[0]) {
speed = tc.mediaElements[0].playbackRate;
}
}
sendResponse({ speed: speed });
} else if (request.action === "get_page_context") {
if (request.action === "get_page_context") {
sendResponse({ url: location.href });
} else if (request.action === "run_action") {
return false;
}
if (request.action === "run_action") {
var value = request.value;
if (value === undefined || value === null) {
value = getKeyBindings(request.actionName, "value");
}
runAction(request.actionName, value);
var newSpeed = 1.0;
if (tc.mediaElements && tc.mediaElements.length > 0) {
newSpeed = tc.mediaElements[0].playbackRate;
var videoAfter = getPrimaryVideoElement();
if (!videoAfter) return false;
sendResponse({
speed: videoAfter.playbackRate
});
return false;
}
sendResponse({ speed: newSpeed });
}
return true;
return false;
}
);
// Set the flag to prevent adding the listener again.
window.vscMessageListener = true;
}
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;
btn.innerHTML = "";
if (svg) {
var cw = doc.createElement("span");
cw.className = "vsc-btn-icon";
cw.innerHTML = svg;
btn.appendChild(cw);
} 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") {
@@ -1321,7 +1400,25 @@ 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 = doc.createElement("span");
customWrap.className = "vsc-btn-icon";
customWrap.innerHTML = custom;
button.appendChild(customWrap);
} 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;
}
@@ -1343,6 +1440,8 @@ function defineVideoController() {
this.suppressedRateChangeCount = 0;
this.suppressedRateChangeUntil = 0;
this.visibilityResumeHandler = null;
this.resetToggleArmed = false;
this.resetButtonEl = null;
this.controllerLocation = normalizeControllerLocation(
tc.settings.controllerLocation
);
@@ -1836,24 +1935,34 @@ 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;
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
this.nudgeFlashIndicator = nudgeFlashIndicator;
this.resetButtonEl =
shadow.querySelector('button[data-action="reset"]') || null;
this.resetToggleArmed = false;
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
@@ -1862,11 +1971,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
@@ -1881,6 +1988,7 @@ function defineVideoController() {
var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState);
}
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation();
},
true
@@ -2074,6 +2182,25 @@ function applySiteRuleOverrides() {
return false;
}
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
function refreshAllControllerGeometry() {
tc.mediaElements.forEach(function (video) {
if (!video || !video.vsc) return;
applyControllerLocation(video.vsc, tc.settings.controllerLocation);
var controllerEl = getControllerElement(video.vsc);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
});
}
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() {
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
refreshAllControllerGeometry();
}
function shouldPreserveDesiredSpeed(video, speed) {
if (!video || !video.vsc) return false;
var desiredSpeed = getDesiredSpeed(video);
@@ -2091,8 +2218,11 @@ function shouldPreserveDesiredSpeed(video, speed) {
function setupListener(root) {
root = root || document;
if (root.vscRateListenerAttached) return;
function updateSpeedFromEvent(video) {
function updateSpeedFromEvent(video, skipResetDisarm) {
if (!video.vsc || !video.vsc.speedIndicator) return;
if (!skipResetDisarm) {
video.vsc.resetToggleArmed = false;
}
var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
video.vsc.speedIndicator.textContent = speed.toFixed(2);
video.vsc.targetSpeed = speed;
@@ -2119,7 +2249,7 @@ function setupListener(root) {
if (tc.settings.forceLastSavedSpeed) {
if (event.detail && event.detail.origin === "videoSpeed") {
video.playbackRate = event.detail.speed;
updateSpeedFromEvent(video);
updateSpeedFromEvent(video, true);
} else {
video.playbackRate = sanitizeSpeed(tc.settings.lastSpeed, 1.0);
}
@@ -2130,7 +2260,7 @@ function setupListener(root) {
var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (pendingRateChange) {
updateSpeedFromEvent(video);
updateSpeedFromEvent(video, true);
return;
}
@@ -2139,6 +2269,7 @@ function setupListener(root) {
`Ignoring external rate change to ${currentSpeed.toFixed(4)} while preserving ${desiredSpeed.toFixed(4)}`,
4
);
video.vsc.resetToggleArmed = false;
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
scheduleSpeedRestore(video, desiredSpeed, "pause/play or seek");
return;
@@ -2397,6 +2528,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;
}
@@ -2416,20 +2551,19 @@ 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);
}
});
refreshAllControllerGeometry();
}
vscInitializedDocuments.add(doc);
}
function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) {
function setSpeed(
video,
speed,
isInitialCall = false,
isUserKeyPress = false,
fromResetSpeedToggle = false
) {
const numericSpeed = Number(speed);
if (!isValidSpeed(numericSpeed)) {
@@ -2442,6 +2576,10 @@ function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) {
if (!video || !video.vsc || !video.vsc.speedIndicator) return;
if (isUserKeyPress && !fromResetSpeedToggle) {
video.vsc.resetToggleArmed = false;
}
log(
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
4
@@ -2544,6 +2682,7 @@ function runAction(action, value, e) {
"mark",
"jump",
"drag",
"nudge",
"toggleSubtitleNudge",
"display"
];
@@ -2659,6 +2798,12 @@ function runAction(action, value, e) {
case "toggleSubtitleNudge":
setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue);
break;
case "nudge":
setSubtitleNudgeEnabledForVideo(
v,
!isSubtitleNudgeEnabledForVideo(v)
);
break;
}
});
log("runAction End", 5);
@@ -2697,11 +2842,12 @@ function resetSpeed(v, target, isFastKey = false) {
Math.abs(lastToggle - 1.0) < 0.01
? getKeyBindings("fast") || 1.8
: lastToggle;
setSpeed(v, speedToRestore, false, true);
setSpeed(v, speedToRestore, false, true, true);
} else {
// Not at 1.0, save current as toggle speed and go to 1.0
lastToggleSpeed[videoId] = currentSpeed;
setSpeed(v, resetSpeedValue, false, true);
v.vsc.resetToggleArmed = true;
setSpeed(v, resetSpeedValue, false, true, true);
}
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* 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");
var svg = doc.querySelector("svg");
if (!svg) return null;
svg.querySelectorAll("script").forEach(function (n) {
n.remove();
});
svg.querySelectorAll("style").forEach(function (n) {
n.remove();
});
svg.querySelectorAll("*").forEach(function (el) {
for (var i = el.attributes.length - 1; i >= 0; i--) {
var attr = el.attributes[i];
var name = attr.name.toLowerCase();
var val = attr.value;
if (name.indexOf("on") === 0) {
el.removeAttribute(attr.name);
continue;
}
if (
(name === "href" || name === "xlink:href") &&
/^javascript:/i.test(val)
) {
el.removeAttribute(attr.name);
}
}
});
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
svg.removeAttribute("width");
svg.removeAttribute("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);
}
+28 -9
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "5.0.2",
"version": "5.1.4",
"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",
@@ -9,7 +9,9 @@
"gecko": {
"id": "{ed860648-f54f-4dc9-9a0d-501aec4313f5}",
"data_collection_permissions": {
"required": ["none"]
"required": [
"none"
]
}
}
},
@@ -19,12 +21,17 @@
"128": "icons/icon128.png"
},
"background": {
"scripts": ["background.js"]
"scripts": [
"background.js"
]
},
"permissions": ["storage"],
"permissions": [
"storage",
"https://cdn.jsdelivr.net/*"
],
"options_ui": {
"page": "options.html",
"open_in_tab": false
"open_in_tab": true
},
"browser_action": {
"default_icon": {
@@ -37,16 +44,28 @@
"content_scripts": [
{
"all_frames": true,
"matches": ["http://*/*", "https://*/*", "file:///*"],
"matches": [
"http://*/*",
"https://*/*",
"file:///*"
],
"match_about_blank": true,
"exclude_matches": [
"https://plus.google.com/hangouts/*",
"https://hangouts.google.com/*",
"https://meet.google.com/*"
],
"css": ["inject.css"],
"js": ["inject.js"]
"css": [
"inject.css"
],
"js": [
"ui-icons.js",
"inject.js"
]
}
],
"web_accessible_resources": ["inject.css", "shadow.css"]
"web_accessible_resources": [
"inject.css",
"shadow.css"
]
}
+369 -29
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;
}
@@ -299,6 +333,19 @@ label em {
border-top: 1px solid var(--border);
}
.row input[type="text"],
.row select {
justify-self: end;
}
.row.row-checkbox {
grid-template-columns: minmax(0, 1fr) 24px;
}
.row.row-checkbox input[type="checkbox"] {
justify-self: end;
}
.settings-card .row:first-of-type {
padding-top: 0;
border-top: 0;
@@ -310,16 +357,17 @@ label em {
.controller-margin-inputs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 116px));
gap: 8px;
width: 100%;
width: max-content;
justify-self: end;
}
.margin-pad-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
min-width: 116px;
}
.margin-pad-mini {
@@ -332,12 +380,13 @@ label em {
.controller-margin-inputs input[type="text"] {
width: 100%;
min-width: 0;
min-width: 116px;
box-sizing: border-box;
text-align: right;
}
.site-rule-option.site-rule-margin-option {
grid-template-columns: minmax(0, 1fr) minmax(0, 220px);
grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
}
.site-rule-override-section {
@@ -353,19 +402,25 @@ label em {
}
.site-override-lead {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 24px;
gap: 16px;
align-items: flex-start;
gap: 10px;
font-weight: 600;
margin-bottom: 8px;
cursor: pointer;
width: auto;
width: 100%;
}
.site-override-lead input {
.site-override-lead input[type="checkbox"] {
justify-self: end;
margin-top: 3px;
}
.site-override-lead span {
margin: 0;
}
.site-rule-override-section .site-override-fields,
.site-rule-override-section .site-placement-container,
.site-rule-override-section .site-visibility-container,
@@ -481,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 {
@@ -525,24 +795,61 @@ label em {
.site-rule-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
grid-template-columns: minmax(0, 1fr) 160px;
gap: 16px;
align-items: start;
padding: 8px 0;
border-top: 1px solid var(--border);
}
.site-rule-option-checkbox {
grid-template-columns: minmax(0, 1fr) 24px;
}
.site-rule-option-checkbox > input[type="checkbox"] {
justify-self: end;
}
.site-rule-option > input[type="text"],
.site-rule-option > select {
justify-self: end;
text-align: right;
}
.site-rule-option.site-rule-margin-option .controller-margin-inputs {
justify-self: end;
}
.site-rule-body > .site-rule-option:first-child,
.site-rule-content > .site-rule-option:first-child {
padding-top: 0;
border-top: 0;
}
.site-rule-option label {
display: flex;
.site-rule-option > label:not(.site-rule-split-label) {
display: block;
margin: 0;
}
.site-rule-split-label {
display: grid;
grid-template-columns: minmax(0, 1fr) 24px;
gap: 16px;
align-items: flex-start;
gap: 10px;
width: auto;
width: 100%;
margin: 0;
cursor: pointer;
font-weight: 500;
color: var(--text);
}
.site-rule-split-label input[type="checkbox"] {
justify-self: end;
margin-top: 3px;
}
.site-rule-option-checkbox > .site-rule-split-label {
grid-column: 1 / -1;
}
.site-rule-controlbar,
@@ -552,12 +859,8 @@ label em {
border-top: 1px solid var(--border);
}
.site-rule-controlbar > label,
.site-rule-shortcuts > label {
display: flex;
align-items: flex-start;
gap: 10px;
width: auto;
.site-rule-controlbar > label.site-override-lead,
.site-rule-shortcuts > label.site-override-lead {
margin: 0;
}
@@ -615,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);
@@ -636,14 +932,33 @@ label em {
}
@media (max-width: 720px) {
.lucide-icon-preview-row {
grid-template-columns: 1fr;
}
.shortcut-row,
.shortcut-row.customs,
.row,
.row.row-checkbox,
.site-rule-option,
.site-shortcuts-container .shortcut-row {
grid-template-columns: 1fr;
}
.row input[type="text"],
.row select {
justify-self: stretch;
}
.site-rule-option > input[type="text"],
.site-rule-option > select {
justify-self: stretch;
}
.site-override-lead {
grid-template-columns: minmax(0, 1fr) 24px;
}
.action-row button,
#addShortcutSelector {
width: 100%;
@@ -671,6 +986,10 @@ label em {
padding: 16px;
}
.control-bars-group {
padding: 16px;
}
.site-rule-header {
grid-template-columns: 1fr;
}
@@ -719,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;
}
}
+143 -46
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speeder Settings</title>
<link rel="stylesheet" href="options.css" />
<script src="ui-icons.js"></script>
<script src="lucide-client.js"></script>
<script src="options.js"></script>
<script src="importExport.js"></script>
</head>
@@ -180,11 +182,11 @@
<h4 class="defaults-sub-heading">General</h4>
<div class="row">
<div class="row row-checkbox">
<label for="enabled">Enable</label>
<input id="enabled" type="checkbox" />
</div>
<div class="row">
<div class="row row-checkbox">
<label for="audioBoolean">Work on audio</label>
<input id="audioBoolean" type="checkbox" />
</div>
@@ -192,11 +194,11 @@
<div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Playback</h4>
<div class="row">
<div class="row row-checkbox">
<label for="rememberSpeed">Remember playback speed</label>
<input id="rememberSpeed" type="checkbox" />
</div>
<div class="row">
<div class="row row-checkbox">
<label for="forceLastSavedSpeed"
>Force last saved speed<br />
<em
@@ -210,7 +212,7 @@
<div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Controller</h4>
<div class="row">
<div class="row row-checkbox">
<label for="startHidden">Hide controller by default</label>
<input id="startHidden" type="checkbox" />
</div>
@@ -250,7 +252,7 @@
</div>
</div>
</div>
<div class="row">
<div class="row row-checkbox">
<label for="hideWithControls"
>Hide with controls<br />
<em
@@ -270,15 +272,10 @@
</label>
<input id="hideWithControlsTimer" type="text" placeholder="2" />
</div>
<div class="row">
<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>
<div class="row">
<div class="row row-checkbox">
<label for="enableSubtitleNudge"
>Enable subtitle nudge<br /><em
>Makes tiny playback changes to help keep subtitles aligned.</em
@@ -302,12 +299,25 @@
</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="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">
@@ -328,14 +338,18 @@
</div>
</section>
<section id="popupControlBarSettings" class="settings-card">
<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">
<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>
@@ -357,6 +371,91 @@
</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="lucideIconResults"
class="lucide-icon-results"
role="listbox"
aria-label="Matching Lucide icons"
></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>
<section id="siteRules" class="settings-card">
<div class="section-heading">
<h3>Site rules</h3>
@@ -389,20 +488,20 @@
<button type="button" class="remove-site-rule">Remove</button>
</div>
<div class="site-rule-body">
<div class="site-rule-option">
<label>
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span>Enable Speeder on this site</span>
<input type="checkbox" class="site-enabled" />
Enable Speeder on this site
</label>
</div>
<div class="site-rule-content">
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override placement for this site</span>
<input type="checkbox" class="override-placement" />
Override placement for this site
</label>
<div class="site-placement-container" style="display: none">
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-field">
<label>Default controller location:</label>
<select class="site-controllerLocation">
<option value="top-left">Top left</option>
@@ -436,11 +535,11 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override hide-by-default for this site</span>
<input type="checkbox" class="override-visibility" />
Override hide-by-default for this site
</label>
<div class="site-visibility-container" style="display: none">
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-checkbox">
<label>Hide controller by default:</label>
<input type="checkbox" class="site-startHidden" />
</div>
@@ -448,17 +547,17 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override auto-hide for this site</span>
<input type="checkbox" class="override-autohide" />
Override auto-hide for this site
</label>
<div class="site-autohide-container" style="display: none">
<div class="site-rule-option">
<label>
<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" />
Hide with controls (idle-based)
</label>
</div>
<div class="site-rule-option">
<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>
@@ -466,19 +565,19 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override playback for this site</span>
<input type="checkbox" class="override-playback" />
Override playback for this site
</label>
<div class="site-playback-container" style="display: none">
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-checkbox">
<label>Remember playback speed:</label>
<input type="checkbox" class="site-rememberSpeed" />
</div>
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-checkbox">
<label>Force last saved speed:</label>
<input type="checkbox" class="site-forceLastSavedSpeed" />
</div>
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-checkbox">
<label>Work on audio:</label>
<input type="checkbox" class="site-audioBoolean" />
</div>
@@ -486,11 +585,11 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override opacity for this site</span>
<input type="checkbox" class="override-opacity" />
Override opacity for this site
</label>
<div class="site-opacity-container" style="display: none">
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-field">
<label>Controller opacity:</label>
<input type="text" class="site-controllerOpacity" />
</div>
@@ -498,15 +597,15 @@
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span>Override subtitle nudge for this site</span>
<input type="checkbox" class="override-subtitleNudge" />
Override subtitle nudge for this site
</label>
<div class="site-subtitleNudge-container" style="display: none">
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-checkbox">
<label>Enable subtitle nudge:</label>
<input type="checkbox" class="site-enableSubtitleNudge" />
</div>
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-field">
<label>Nudge interval (10&ndash;1000ms):</label>
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
</div>
@@ -514,8 +613,8 @@
</div>
<div class="site-rule-controlbar">
<label class="site-override-lead">
<span>Override in-player control bar for this site</span>
<input type="checkbox" class="override-controlbar" />
Override in-player control bar for this site
</label>
<div class="site-controlbar-container" style="display: none">
<div class="cb-editor">
@@ -532,11 +631,11 @@
</div>
<div class="site-rule-controlbar">
<label class="site-override-lead">
<span>Override extension popup for this site</span>
<input type="checkbox" class="override-popup-controlbar" />
Override extension popup for this site
</label>
<div class="site-popup-controlbar-container" style="display: none">
<div class="site-rule-option">
<div class="site-rule-option site-rule-option-checkbox">
<label>Show popup control bar</label>
<input type="checkbox" class="site-showPopupControlBar" />
</div>
@@ -554,8 +653,8 @@
</div>
<div class="site-rule-shortcuts">
<label class="site-override-lead">
<span>Override shortcuts for this site</span>
<input type="checkbox" class="override-shortcuts" />
Override shortcuts for this site
</label>
<div class="site-shortcuts-container" style="display: none"></div>
</div>
@@ -580,8 +679,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
+346 -43
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: "\u21BA", 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,6 +147,79 @@ 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;
if (buttonId === "nudge") {
icon.innerHTML = "";
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 = document.createElement("span");
wrap.className = "vsc-btn-icon";
wrap.innerHTML = inner;
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) {
icon.innerHTML = custom.svg;
return;
}
if (typeof vscIconSvgString === "function") {
var svgHtml = vscIconSvgString(buttonId, 16);
if (svgHtml) {
icon.innerHTML = svgHtml;
return;
}
}
var def = controllerButtonDefs[buttonId];
icon.textContent = (def && def.icon) || "?";
}
function createDefaultBinding(action, key, keyCode, value) {
return {
@@ -198,6 +271,7 @@ var tcDefaults = {
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
@@ -227,6 +301,19 @@ const actionLabels = {
toggleSubtitleNudge: "Toggle subtitle nudge"
};
const speedBindingActions = ["slower", "faster", "fast"];
function formatSpeedBindingDisplay(action, value) {
if (!speedBindingActions.includes(action)) {
return value;
}
var n = Number(value);
if (!isFinite(n)) {
return value;
}
return n.toFixed(2);
}
const customActionsNoValues = [
"reset",
"display",
@@ -526,7 +613,7 @@ function add_shortcut(action, value) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = value || 0;
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
}
var removeButton = document.createElement("button");
@@ -678,7 +765,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 = [];
@@ -767,7 +854,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)
);
}
}
@@ -834,27 +923,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");
@@ -899,9 +967,11 @@ function addSiteRuleShortcut(container, action, binding, value, force) {
valueInput.className = "customValue";
valueInput.type = "text";
valueInput.placeholder = "value (0.10)";
valueInput.value = value || 0;
if (customActionsNoValues.includes(action)) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
}
var forceLabel = document.createElement("label");
@@ -1055,7 +1125,10 @@ function createSiteRule(rule) {
populateControlBarZones(
sitePopupActive,
sitePopupAvailable,
rule.popupControllerButtons
sanitizePopupButtonOrder(rule.popupControllerButtons),
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
} else if (
sitePopupActive &&
@@ -1065,7 +1138,10 @@ function createSiteRule(rule) {
populateControlBarZones(
sitePopupActive,
sitePopupAvailable,
getPopupControlBarOrder()
getPopupControlBarOrder(),
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
}
}
@@ -1110,7 +1186,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";
@@ -1123,16 +1199,23 @@ function createControlBarBlock(buttonId) {
return block;
}
function populateControlBarZones(activeZone, availableZone, activeIds) {
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
activeZone.innerHTML = "";
availableZone.innerHTML = "";
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);
@@ -1160,15 +1243,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() {
@@ -1265,8 +1354,216 @@ function initControlBarEditor() {
});
}
var lucidePickerSelectedSlug = null;
var lucideSearchTimer = null;
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";
actionSel.innerHTML = "";
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) {
resultsEl.innerHTML = "";
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");
previewEl.innerHTML = safe;
setLucideStatus("Preview: " + slug);
})
.catch(function (e) {
previewEl.innerHTML = "";
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()) {
resultsEl.innerHTML = "";
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(tcDefaults, function (storage) {
chrome.storage.local.get(["customButtonIcons"], function (loc) {
customButtonIconsLive =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
document.getElementById("rememberSpeed").checked = storage.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed;
@@ -1329,22 +1626,17 @@ function restore_options() {
valueInput.disabled = true;
}
} else if (valueInput) {
valueInput.value = item.value;
valueInput.value = formatSpeedBindingDisplay(item.action, item.value);
}
});
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
// Load site rules (use defaults if none in storage or empty array)
var siteRules =
Array.isArray(storage.siteRules) && storage.siteRules.length > 0
? storage.siteRules
: (storage.blacklist ? migrateLegacyBlacklist(storage) : (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"]);
}
: tcDefaults.siteRules || [];
document.getElementById("siteRulesContainer").innerHTML = "";
if (siteRules.length > 0) {
@@ -1368,12 +1660,20 @@ function restore_options() {
: tcDefaults.popupControllerButtons;
populatePopupControlBarEditor(popupButtons);
updatePopupEditorDisabledState();
initLucideButtonIconsUI();
});
});
}
function restore_defaults() {
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
chrome.storage.local.remove(
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
function () {}
);
chrome.storage.sync.set(tcDefaults, function () {
restore_options();
var status = document.getElementById("status");
@@ -1553,7 +1853,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);
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<title>Speeder</title>
<link rel="stylesheet" href="popup.css" />
<script src="ui-icons.js"></script>
<script src="popup.js"></script>
</head>
<body>
+124 -67
View File
@@ -1,36 +1,32 @@
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: "\u21BA", 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 popupExcludedButtonIds = new Set(["settings"]);
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, "")
siteRules: []
};
var renderToken = 0;
@@ -39,27 +35,6 @@ document.addEventListener("DOMContentLoaded", function () {
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++) {
@@ -91,25 +66,37 @@ document.addEventListener("DOMContentLoaded", function () {
}
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,29 +158,95 @@ document.addEventListener("DOMContentLoaded", function () {
if (el) el.textContent = (speed != null ? Number(speed) : 1).toFixed(2);
}
function querySpeed() {
sendToActiveTab({ action: "get_speed" }, function (response) {
function applySpeedAndResetFromResponse(response) {
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
}
}
function pickBestFrameSpeedResult(results) {
if (!results || !results.length) return null;
var i;
var r;
var fallback = null;
for (i = 0; i < results.length; i++) {
r = results[i];
if (!r || typeof r.speed !== "number") {
continue;
}
if (r.preferred) {
return { speed: r.speed };
}
if (!fallback) {
fallback = { speed: r.speed };
}
}
return fallback;
}
function querySpeed() {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (!tabs[0] || tabs[0].id == null) {
return;
}
var tabId = tabs[0].id;
chrome.tabs.executeScript(
tabId,
{ allFrames: true, file: "frameSpeedSnapshot.js" },
function (results) {
if (chrome.runtime.lastError) {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
});
return;
}
var best = pickBestFrameSpeedResult(results);
if (best) {
applySpeedAndResetFromResponse(best);
} else {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
});
}
}
);
});
}
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 = document.createElement("span");
customSpan.className = "vsc-btn-icon";
customSpan.innerHTML = customEntry.svg;
btn.appendChild(customSpan);
} else if (typeof vscIconSvgString === "function") {
var svgStr = vscIconSvgString(btnId, 16);
if (svgStr) {
var iconSpan = document.createElement("span");
iconSpan.className = "vsc-btn-icon";
iconSpan.innerHTML = svgStr;
btn.appendChild(iconSpan);
} 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);
@@ -204,10 +257,8 @@ document.addEventListener("DOMContentLoaded", function () {
}
sendToActiveTab(
{ action: "run_action", actionName: btnId },
function (response) {
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
}
function () {
querySpeed();
}
);
});
@@ -272,6 +323,13 @@ document.addEventListener("DOMContentLoaded", function () {
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
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(storageDefaults, function (storage) {
if (currentRenderToken !== renderToken) return;
@@ -280,10 +338,8 @@ document.addEventListener("DOMContentLoaded", function () {
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) {
@@ -291,15 +347,12 @@ 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);
return;
}
if (siteDisabled) {
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
@@ -314,6 +367,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
});
});
});
}
chrome.tabs.onActivated.addListener(function () {
@@ -328,6 +382,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 ||
@@ -335,8 +393,7 @@ document.addEventListener("DOMContentLoaded", function () {
changes.controllerButtons ||
changes.popupMatchHoverControls ||
changes.popupControllerButtons ||
changes.siteRules ||
changes.blacklist
changes.siteRules
) {
renderForActiveTab();
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# 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
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
manifest_version() {
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
}
normalize_semver() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
s="${s#v}"
s="${s#V}"
printf '%s' "$s"
}
validate_semver() {
local s="$1"
if [[ -z "$s" ]]; then
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4)." >&2
return 1
fi
}
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash before releasing." >&2
exit 1
fi
git checkout beta
git pull origin beta
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"
TAG="v${SEMVER}"
if [[ "$TAG" == *-beta* ]]; then
echo "Warning: stable tags should not contain '-beta' (workflow would use unlisted + prerelease, not AMO listed)."
read -r -p "Continue anyway? [y/N] " w
[[ "${w:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
fi
echo
echo "This will:"
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)"
git checkout main
git pull origin main
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"
git push origin "$TAG"
git checkout dev
echo "✅ Done: main squashed from beta, tagged $TAG (manifest $SEMVER)"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Merge dev → beta, push beta, and push an annotated beta tag (v*-beta*).
# Triggers .github/workflows/deploy.yml: unlisted AMO sign + GitHub prerelease.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
manifest_version() {
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
}
normalize_semver() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
s="${s#v}"
s="${s#V}"
printf '%s' "$s"
}
validate_semver() {
local s="$1"
if [[ -z "$s" ]]; then
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4 or 5.0.4-beta.1)." >&2
return 1
fi
}
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash before releasing." >&2
exit 1
fi
git checkout dev
git pull origin dev
echo "Current version in manifest.json: $(manifest_version)"
read -r -p "New version for manifest.json (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
echo "Beta git tag will include '-beta' (required by deploy.yml)."
read -r -p "Beta tag suffix [beta.1]: " SUFFIX_IN
SUFFIX="${SUFFIX_IN#"${SUFFIX_IN%%[![:space:]]*}"}"
SUFFIX="${SUFFIX%"${SUFFIX##*[![:space:]]}"}"
SUFFIX="${SUFFIX:-beta.1}"
TAG="v${SEMVER}-${SUFFIX}"
if [[ "$TAG" != *-beta* ]]; then
echo "Error: beta tag must contain '-beta' for the workflow (got $TAG). Try suffix like beta.1." >&2
exit 1
fi
echo
echo "This will:"
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 (main is not modified)"
read -r -p "Continue? [y/N] " confirm
[[ "${confirm:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
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
git merge dev --no-ff -m "$TAG"
git push origin beta
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
git pull origin dev
echo "✅ Done: beta $TAG (manifest $SEMVER; dev + beta + tag pushed)"
+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;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 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;
/** 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>"
);
}
/**
* @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;
var span = doc.createElement("span");
span.className = "vsc-btn-icon";
span.innerHTML = html;
return span;
}