From ed0f63e8bc025eb427abb12dac6e435f09af21c1 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 2 Apr 2026 12:52:27 -0400 Subject: [PATCH 1/5] feat: user-customizable Lucide controller button icons --- frameSpeedSnapshot.js | 6 +- importExport.js | 88 +++++++++----- inject.js | 167 ++++++++++++++------------ lucide-client.js | 173 +++++++++++++++++++++++++++ manifest.json | 4 +- options.css | 237 ++++++++++++++++++++++++++++++++++++- options.html | 190 +++++++++++++++++++++-------- options.js | 269 ++++++++++++++++++++++++++++++++++++------ popup.css | 16 +++ popup.html | 1 + popup.js | 142 ++++++++++------------ shadow.css | 69 +++++++++-- ui-icons.js | 64 ++++++++++ 13 files changed, 1142 insertions(+), 284 deletions(-) create mode 100644 lucide-client.js create mode 100644 ui-icons.js diff --git a/frameSpeedSnapshot.js b/frameSpeedSnapshot.js index b5aae4c..af921cd 100644 --- a/frameSpeedSnapshot.js +++ b/frameSpeedSnapshot.js @@ -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) { diff --git a/importExport.js b/importExport.js index a6a296a..3e55fca 100644 --- a/importExport.js +++ b/importExport.js @@ -13,25 +13,28 @@ function generateBackupFilename() { function exportSettings() { chrome.storage.sync.get(null, function (storage) { - const backup = { - version: "1.0", - exportDate: new Date().toISOString(), - settings: storage - }; + chrome.storage.local.get(null, function (localStorage) { + const backup = { + version: "1.1", + exportDate: new Date().toISOString(), + settings: storage, + localSettings: localStorage || {} + }; - const dataStr = JSON.stringify(backup, null, 2); - const blob = new Blob([dataStr], { type: "application/json" }); - const url = URL.createObjectURL(blob); + const dataStr = JSON.stringify(backup, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); - 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 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"); + showStatus("Settings exported successfully"); + }); }); } @@ -62,24 +65,49 @@ 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 () { + var localToImport = + backup.localSettings && typeof backup.localSettings === "object" + ? backup.localSettings + : null; + + function afterLocalImport() { + chrome.storage.sync.clear(function () { + chrome.storage.sync.set(settingsToImport, function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to save imported settings - " + + chrome.runtime.lastError.message, + true + ); + return; + } + showStatus("Settings imported successfully. Reloading..."); + setTimeout(function () { + if (typeof restore_options === "function") { + restore_options(); + } else { + location.reload(); + } + }, 500); + }); + }); + } + + if (localToImport && Object.keys(localToImport).length > 0) { + chrome.storage.local.set(localToImport, function () { if (chrome.runtime.lastError) { - showStatus("Error: Failed to save imported settings - " + chrome.runtime.lastError.message, true); + showStatus( + "Error: Failed to save local extension data - " + + chrome.runtime.lastError.message, + true + ); return; } - showStatus("Settings imported successfully. Reloading..."); - setTimeout(function () { - if (typeof restore_options === "function") { - restore_options(); - } else { - location.reload(); - } - }, 500); + afterLocalImport(); }); - }); + } else { + afterLocalImport(); + } } catch (err) { showStatus("Error: Failed to parse backup file - " + err.message, true); } diff --git a/inject.js b/inject.js index 9be02b9..e67c670 100644 --- a/inject.js +++ b/inject.js @@ -1,5 +1,3 @@ -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 @@ -38,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, @@ -115,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: "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: "", className: "" }, + fast: { label: "", className: "" }, + settings: { label: "", className: "" }, + pause: { label: "", className: "" }, + muted: { label: "", className: "" }, + mark: { label: "", className: "" }, + jump: { label: "", className: "" } }; var keyCodeToEventKey = { @@ -1219,25 +1219,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) @@ -1284,8 +1265,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 +1282,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 +1293,59 @@ 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; + 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) || "?"; + } + }); + }); + } + }); + } + initializeWhenReady(document); + }); }); function getKeyBindings(action, what = "value") { @@ -1332,7 +1363,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; } @@ -1859,7 +1908,6 @@ function defineVideoController() { this.resetButtonEl = shadow.querySelector('button[data-action="reset"]') || null; this.resetToggleArmed = false; - updateResetButtonLabel(this.video); if (subtitleNudgeIndicator) { updateSubtitleNudgeIndicator(this.video); } @@ -2127,7 +2175,6 @@ function setupListener(root) { if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge(); else video.vsc.startSubtitleNudge(); } - updateResetButtonLabel(video); } root.addEventListener( "ratechange", @@ -2163,7 +2210,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; } @@ -2452,40 +2498,6 @@ function initializeNow(doc, forceReinit = false) { 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 +2568,6 @@ function setSpeed( video.vsc.startSubtitleNudge(); } } - updateResetButtonLabel(video); } function runAction(action, value, e) { diff --git a/lucide-client.js b/lucide-client.js new file mode 100644 index 0000000..41727d0 --- /dev/null +++ b/lucide-client.js @@ -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 (!/]/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>} 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} 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); +} diff --git a/manifest.json b/manifest.json index 6f94631..0ee3cfc 100644 --- a/manifest.json +++ b/manifest.json @@ -26,7 +26,8 @@ ] }, "permissions": [ - "storage" + "storage", + "https://cdn.jsdelivr.net/*" ], "options_ui": { "page": "options.html", @@ -58,6 +59,7 @@ "inject.css" ], "js": [ + "ui-icons.js", "inject.js" ] } diff --git a/options.css b/options.css index c17eb5e..562ca8f 100644 --- a/options.css +++ b/options.css @@ -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,176 @@ 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; +} + +.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 { @@ -690,6 +894,10 @@ label em { } @media (max-width: 720px) { + .lucide-icon-preview-row { + grid-template-columns: 1fr; + } + .shortcut-row, .shortcut-row.customs, .row, @@ -740,6 +948,10 @@ label em { padding: 16px; } + .control-bars-group { + padding: 16px; + } + .site-rule-header { grid-template-columns: 1fr; } @@ -788,4 +1000,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; + } } diff --git a/options.html b/options.html index 1f1660f..c2cc1a9 100644 --- a/options.html +++ b/options.html @@ -5,6 +5,8 @@ Speeder Settings + + @@ -302,58 +304,154 @@ -
-
-

Control bar

+
+
+

Control bars

- 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.

-
-
-
Active
-
-
-
-
Available
-
-
-
-
+
+
+
+

Hover control bar

+

+ Drag blocks to reorder. Move between Active and Available to + show or hide buttons. +

+
+
+
+
Active
+
+
+
+
Available
+
+
+
+
-
-
-

Popup control bar

-

- Configure which buttons appear in the browser popup control bar. -

-
-
- - -
-
-
-
Active
+
+
+

Popup control bar

+

+ Configure which buttons appear in the browser popup control bar. +

+
+
+ + +
+
+
+
Active
+
+
+
+
Available
+
+
+
+
+ +
+
+

Button icons (Lucide)

+

+ Search icons from the + Lucide + set (fetched from jsDelivr). Chosen SVGs are cached in local + storage and included in settings export. + Reset speed stays numeric text only. +

+
+
+ + +
+
+ +
+ + +
+
-
-
-
Available
-
-
+

+
+
+
+ + + + +
+
+
diff --git a/options.js b/options.js index 28f079a..93f93b5 100644 --- a/options.js +++ b/options.js @@ -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: "", name: "Reset speed" }, fast: { icon: "\u2605", name: "Preferred speed" }, nudge: { icon: "\u2713", name: "Subtitle nudge" }, settings: { icon: "\u2699", name: "Settings" }, @@ -148,6 +148,27 @@ var controllerButtonDefs = { jump: { icon: "\u21E5", name: "Jump to marker" } }; +/** Cached custom Lucide SVGs (mirrors chrome.storage.local customButtonIcons). */ +var customButtonIconsLive = {}; + +function fillControlBarIconElement(icon, buttonId) { + if (!icon || !buttonId) return; + 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 { action: action, @@ -198,6 +219,7 @@ var tcDefaults = { { pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/", enabled: true, + rememberSpeed: true, controllerMarginTop: 60, controllerMarginBottom: 85 } @@ -847,27 +869,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"); @@ -1125,7 +1126,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"; @@ -1280,8 +1281,207 @@ 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 === "reset") 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; @@ -1350,16 +1550,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 || [])); - - // 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"]); - } + // Load site rules (use defaults if none in storage or empty array) + var siteRules = + Array.isArray(storage.siteRules) && storage.siteRules.length > 0 + ? storage.siteRules + : tcDefaults.siteRules || []; document.getElementById("siteRulesContainer").innerHTML = ""; if (siteRules.length > 0) { @@ -1383,12 +1578,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"); diff --git a/popup.css b/popup.css index 5a1a08f..adf694e 100644 --- a/popup.css +++ b/popup.css @@ -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); diff --git a/popup.html b/popup.html index 58537bc..35371f3 100644 --- a/popup.html +++ b/popup.html @@ -4,6 +4,7 @@ Speeder + diff --git a/popup.js b/popup.js index c5e8a63..c2d91a8 100644 --- a/popup.js +++ b/popup.js @@ -1,19 +1,20 @@ 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: "", className: "" }, + fast: { label: "", className: "" }, + settings: { label: "", className: "" }, + pause: { label: "", className: "" }, + muted: { label: "", className: "" }, + mark: { label: "", className: "" }, + jump: { label: "", className: "" } }; var defaultButtons = ["rewind", "slower", "faster", "advance", "display"]; @@ -23,14 +24,7 @@ document.addEventListener("DOMContentLoaded", function () { 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 +33,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++) { @@ -171,20 +144,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 +157,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 +182,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 +191,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,13 +199,15 @@ 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]; @@ -258,7 +215,25 @@ document.addEventListener("DOMContentLoaded", function () { 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); @@ -335,18 +310,23 @@ 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(storageDefaults, function (storage) { + if (currentRenderToken !== renderToken) return; 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 +334,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 +351,9 @@ document.addEventListener("DOMContentLoaded", function () { querySpeed(); } else { updateSpeedDisplay(1); - updatePopupResetLabel("1.00x"); } }); + }); }); } @@ -394,6 +369,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 || @@ -401,8 +380,7 @@ document.addEventListener("DOMContentLoaded", function () { changes.controllerButtons || changes.popupMatchHoverControls || changes.popupControllerButtons || - changes.siteRules || - changes.blacklist + changes.siteRules ) { renderForActiveTab(); } diff --git a/shadow.css b/shadow.css index 66d6db4..941b074 100644 --- a/shadow.css +++ b/shadow.css @@ -4,10 +4,17 @@ 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; +} + #controller:hover #controls, #controller:focus-within #controls, :host(:hover) #controls { - display: inline; + display: inline-flex; + vertical-align: middle; } #controller { @@ -55,15 +62,15 @@ #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 */ #nudge-flash-indicator { @@ -121,7 +128,8 @@ font-family: "Lucida Console", Monaco, monospace; box-sizing: border-box; cursor: pointer; - margin-bottom: 2px; + margin: 0; + flex-shrink: 0; } #nudge-indicator[data-enabled="true"] { @@ -140,6 +148,11 @@ opacity: 0.6; } +#controller #nudge-indicator, +#controller #nudge-flash-indicator { + line-height: 14px; +} + #controller.dragging { cursor: -webkit-grabbing; cursor: -moz-grabbing; @@ -148,12 +161,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 +190,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; } diff --git a/ui-icons.js b/ui-icons.js new file mode 100644 index 0000000..fee827e --- /dev/null +++ b/ui-icons.js @@ -0,0 +1,64 @@ +/** + * 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 ). */ +var vscUiIconPaths = { + rewind: + '', + advance: + '', + reset: + '', + slower: '', + faster: + '', + display: + '', + fast: '', + settings: + '', + pause: + '', + muted: + '', + mark: '', + jump: + '', + nudge: '' +}; + +/** + * @param {number} [size] - width/height in px + * @returns {string} full + */ +function vscIconSvgString(action, size) { + var inner = vscUiIconPaths[action]; + if (!inner) return ""; + var s = size != null ? size : VSC_ICON_SIZE_DEFAULT; + return ( + '" + ); +} + +/** + * @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; +} From 841c1a246ed8aad65a6b5f3b4f67cb5967539529 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 2 Apr 2026 12:52:27 -0400 Subject: [PATCH 2/5] fix: nudge flash layout, Lucide icons, hover bar spacing --- inject.js | 24 ++++++++++++--- shadow.css | 86 +++++++++++++++++++++++++++++++++++++++++------------ ui-icons.js | 7 ++++- 3 files changed, 93 insertions(+), 24 deletions(-) diff --git a/inject.js b/inject.js index e67c670..6ea1a75 100644 --- a/inject.js +++ b/inject.js @@ -776,16 +776,30 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) { return normalizedEnabled; } +function subtitleNudgeIconMarkup(isEnabled) { + var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff"; + if (typeof vscIconSvgString !== "function") { + return isEnabled ? "✓" : "×"; + } + var svg = vscIconSvgString(action, 14); + if (!svg) { + return isEnabled ? "✓" : "×"; + } + return ( + '" + ); +} + 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; @@ -794,9 +808,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); } } @@ -1898,8 +1913,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; diff --git a/shadow.css b/shadow.css index 941b074..9e12285 100644 --- a/shadow.css +++ b/shadow.css @@ -47,8 +47,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. */ @@ -71,16 +72,21 @@ max-width: none; } -/* 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; @@ -88,8 +94,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 */ @@ -107,50 +132,73 @@ #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: 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; } -#controller #nudge-indicator, -#controller #nudge-flash-indicator { - line-height: 14px; +#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 { diff --git a/ui-icons.js b/ui-icons.js index fee827e..631cd9e 100644 --- a/ui-icons.js +++ b/ui-icons.js @@ -27,7 +27,12 @@ var vscUiIconPaths = { mark: '', jump: '', - nudge: '' + nudge: '', + /** Lucide check — subtitle nudge on */ + subtitleNudgeOn: '', + /** Lucide x — subtitle nudge off */ + subtitleNudgeOff: + '' }; /** From 17319c1e251d9dc5cd88e1a25033739f9469523d Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 2 Apr 2026 12:52:27 -0400 Subject: [PATCH 3/5] Re-run site rules on DOM media attach; extract refreshAllControllerGeometry --- inject.js | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/inject.js b/inject.js index 6ea1a75..dcebae2 100644 --- a/inject.js +++ b/inject.js @@ -888,6 +888,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) { @@ -1016,6 +1020,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 @@ -2155,6 +2167,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); @@ -2482,6 +2513,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; } @@ -2501,14 +2536,7 @@ 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); From 7fd8a931d87c355c8726ddab0e3aff4e842d1217 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 2 Apr 2026 12:52:27 -0400 Subject: [PATCH 4/5] =?UTF-8?q?deploy:=20squash=20beta=E2=86=92main=20for?= =?UTF-8?q?=20stable;=20beta=20script=20pushes=20dev=20then=20pulls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/deploy-amo-stable.sh | 37 ++++++++++++++++-------------------- scripts/deploy-beta.sh | 8 +++++--- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/scripts/deploy-amo-stable.sh b/scripts/deploy-amo-stable.sh index 340aa6f..eafd495 100755 --- a/scripts/deploy-amo-stable.sh +++ b/scripts/deploy-amo-stable.sh @@ -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)" diff --git a/scripts/deploy-beta.sh b/scripts/deploy-beta.sh index 6976001..d780d33 100755 --- a/scripts/deploy-beta.sh +++ b/scripts/deploy-beta.sh @@ -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)" From 8d3905b654fb76c1b576a08e276a01931d9f2634 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 2 Apr 2026 12:53:09 -0400 Subject: [PATCH 5/5] Bump version to 5.1.0 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 0ee3cfc..929f511 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Speeder", "short_name": "Speeder", - "version": "5.0.4", + "version": "5.1.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",