diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4b0f0b7..fc94342 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 }} \ diff --git a/frameSpeedSnapshot.js b/frameSpeedSnapshot.js new file mode 100644 index 0000000..af921cd --- /dev/null +++ b/frameSpeedSnapshot.js @@ -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; + } +})(); 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 4dc435b..a100184 100644 --- a/inject.js +++ b/inject.js @@ -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 ( + '' + custom + "" + ); + } + if (typeof vscIconSvgString !== "function") { + return isEnabled ? "✓" : "×"; + } + var svg = vscIconSvgString(action, 14); + if (!svg) { + return isEnabled ? "✓" : "×"; + } + return ( + '' + svg + "" + ); +} + 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; - } - } - if (speed === 1.0 && tc.mediaElements[0]) { - speed = tc.mediaElements[0].playbackRate; - } - } - sendResponse({ speed: speed }); - } else if (request.action === "get_page_context") { + 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 (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; - } - sendResponse({ speed: newSpeed }); + var videoAfter = getPrimaryVideoElement(); + if (!videoAfter) return false; + sendResponse({ + speed: videoAfter.playbackRate + }); + return false; } - - return true; + return false; } ); // 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) || "?"; + } + }); + 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); } } } 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 43ff021..63e22e0 100644 --- a/manifest.json +++ b/manifest.json @@ -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" + ] } diff --git a/options.css b/options.css index 6730f77..9119cb5 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; } @@ -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; + } } diff --git a/options.html b/options.html index 7da53b8..3d5be8d 100644 --- a/options.html +++ b/options.html @@ -5,6 +5,8 @@ Speeder Settings + + @@ -180,11 +182,11 @@ General - + Enable - + Work on audio @@ -192,11 +194,11 @@ Playback - + Remember playback speed - + Force last saved speed Controller - + Hide controller by default @@ -250,7 +252,7 @@ - + Hide with controls - - Show popup control bar - - - Subtitle sync - + Enable subtitle nudgeMakes tiny playback changes to help keep subtitles aligned. - - - 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. - - - - Match hover controls - - - - - Active + + + Popup control bar + + Configure which buttons appear in the browser popup control bar. + + + + Show popup control bar + + + + Match hover controls + + + + + Active + + + + Available + + + + + + + + Button icons (Lucide) + + Search icons from the + Lucide + 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 + nudge. + + + + Controller action + + + + Search icons + + + + + + + + + + - - - Available - - + + + + + + Apply to action + + + Clear this action + + + Clear all custom icons + + + Refresh icon list from network + + + + @@ -389,20 +488,20 @@ Remove - - + + + Enable Speeder on this site - Enable Speeder on this site + Override placement for this site - Override placement for this site - + Default controller location: Top left @@ -436,11 +535,11 @@ + Override hide-by-default for this site - Override hide-by-default for this site - + Hide controller by default: @@ -448,17 +547,17 @@ + Override auto-hide for this site - Override auto-hide for this site - - + + + Hide with controls (idle-based) - Hide with controls (idle-based) - + Auto-hide timer (0.1–15s): @@ -466,19 +565,19 @@ + Override playback for this site - Override playback for this site - + Remember playback speed: - + Force last saved speed: - + Work on audio: @@ -486,11 +585,11 @@ + Override opacity for this site - Override opacity for this site - + Controller opacity: @@ -498,15 +597,15 @@ + Override subtitle nudge for this site - Override subtitle nudge for this site - + Enable subtitle nudge: - + Nudge interval (10–1000ms): @@ -514,8 +613,8 @@ + Override in-player control bar for this site - Override in-player control bar for this site @@ -532,11 +631,11 @@ + Override extension popup for this site - Override extension popup for this site - + Show popup control bar @@ -554,8 +653,8 @@ + Override shortcuts for this site - Override shortcuts for this site @@ -580,8 +679,6 @@ - - Extension controls not appearing? This extension only works with HTML5 audio and video. If the diff --git a/options.js b/options.js index 7673879..e280f1e 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: "\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 - ? 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) { @@ -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 { 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 ef86045..76c9273 100644 --- a/popup.js +++ b/popup.js @@ -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) { - if (response && response.speed != null) { - updateSpeedDisplay(response.speed); + 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,18 +323,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) { @@ -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); @@ -313,6 +366,7 @@ document.addEventListener("DOMContentLoaded", function () { updateSpeedDisplay(1); } }); + }); }); } @@ -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(); } diff --git a/scripts/deploy-amo-stable.sh b/scripts/deploy-amo-stable.sh new file mode 100755 index 0000000..eafd495 --- /dev/null +++ b/scripts/deploy-amo-stable.sh @@ -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)" diff --git a/scripts/deploy-beta.sh b/scripts/deploy-beta.sh new file mode 100755 index 0000000..d780d33 --- /dev/null +++ b/scripts/deploy-beta.sh @@ -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)" diff --git a/shadow.css b/shadow.css index 66d6db4..7057d28 100644 --- a/shadow.css +++ b/shadow.css @@ -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; } diff --git a/ui-icons.js b/ui-icons.js new file mode 100644 index 0000000..631cd9e --- /dev/null +++ b/ui-icons.js @@ -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 ). */ +var vscUiIconPaths = { + rewind: + '', + advance: + '', + reset: + '', + slower: '', + faster: + '', + display: + '', + fast: '', + settings: + '', + pause: + '', + muted: + '', + mark: '', + jump: + '', + nudge: '', + /** Lucide check — subtitle nudge on */ + subtitleNudgeOn: '', + /** Lucide x — subtitle nudge off */ + subtitleNudgeOff: + '' +}; + +/** + * @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 ( + '' + + inner + + "" + ); +} + +/** + * @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; +}
- 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.
+ Drag blocks to reorder. Move between Active and Available to + show or hide buttons. +
- Configure which buttons appear in the browser popup control bar. -
+ Configure which buttons appear in the browser popup control bar. +
+ Search icons from the + Lucide + 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 + nudge. +
nudge
This extension only works with HTML5 audio and video. If the diff --git a/options.js b/options.js index 7673879..e280f1e 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: "\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 - ? 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) { @@ -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 { 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 @@