From b3707c0803587158ccf3d952985ff6049e67fa83 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 2 Apr 2026 18:07:09 -0400 Subject: [PATCH] Release v5.1.4 --- .github/workflows/deploy.yml | 6 +- frameSpeedSnapshot.js | 16 ++ importExport.js | 88 +++++--- inject.js | 320 ++++++++++++++++++++-------- lucide-client.js | 173 +++++++++++++++ manifest.json | 37 +++- options.css | 398 ++++++++++++++++++++++++++++++++--- options.html | 267 +++++++++++++++-------- options.js | 391 ++++++++++++++++++++++++++++++---- popup.css | 16 ++ popup.html | 1 + popup.js | 197 +++++++++++------ scripts/deploy-amo-stable.sh | 98 +++++++++ scripts/deploy-beta.sh | 104 +++++++++ shadow.css | 154 +++++++++++--- ui-icons.js | 69 ++++++ 16 files changed, 1955 insertions(+), 380 deletions(-) create mode 100644 frameSpeedSnapshot.js create mode 100644 lucide-client.js create mode 100755 scripts/deploy-amo-stable.sh create mode 100755 scripts/deploy-beta.sh create mode 100644 ui-icons.js 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 ( + '" + ); + } + if (typeof vscIconSvgString !== "function") { + return isEnabled ? "✓" : "×"; + } + var svg = vscIconSvgString(action, 14); + if (!svg) { + return isEnabled ? "✓" : "×"; + } + return ( + '" + ); +} + function updateSubtitleNudgeIndicator(video) { if (!video || !video.vsc) return; var isEnabled = isSubtitleNudgeEnabledForVideo(video); - var label = isEnabled ? "✓" : "×"; var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled"; + var mark = subtitleNudgeIconMarkup(isEnabled); var indicator = video.vsc.subtitleNudgeIndicator; if (indicator) { - indicator.textContent = label; + indicator.innerHTML = mark; indicator.dataset.enabled = isEnabled ? "true" : "false"; indicator.dataset.supported = "true"; indicator.title = title; @@ -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

-
+
-
+
@@ -192,11 +194,11 @@

Playback

-
+
-
+

Controller

-
+
@@ -250,7 +252,7 @@
-
+
-
- - -
-

Subtitle sync

-
+
-
-