diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fc94342..3636a26 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,6 +10,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + WEB_EXT_IGNORE_FILES: scripts/** steps: - uses: actions/checkout@v4 diff --git a/importExport.js b/importExport.js index 3e55fca..dff6cb4 100644 --- a/importExport.js +++ b/importExport.js @@ -1,5 +1,55 @@ // Import/Export functionality for Video Speed Controller settings +const EXPORTABLE_LOCAL_SETTINGS_KEYS = ["customButtonIcons"]; + +function getExportableLocalSettings(localStorage) { + const exportable = {}; + const customButtonIcons = + localStorage && + localStorage.customButtonIcons && + typeof localStorage.customButtonIcons === "object" && + !Array.isArray(localStorage.customButtonIcons) + ? localStorage.customButtonIcons + : null; + + if (customButtonIcons) { + exportable.customButtonIcons = customButtonIcons; + } + + return exportable; +} + +function replaceImportableLocalSettings(localSettings, callback) { + chrome.storage.local.remove(EXPORTABLE_LOCAL_SETTINGS_KEYS, function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to clear local icon overrides - " + + chrome.runtime.lastError.message, + true + ); + return; + } + + if (!localSettings || Object.keys(localSettings).length === 0) { + callback(); + return; + } + + chrome.storage.local.set(localSettings, function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to save local icon overrides - " + + chrome.runtime.lastError.message, + true + ); + return; + } + + callback(); + }); + }); +} + function generateBackupFilename() { const now = new Date(); const year = now.getFullYear(); @@ -11,30 +61,69 @@ function generateBackupFilename() { return `speeder-backup_${year}-${month}-${day}_${hours}.${minutes}.${seconds}.json`; } +function getBackupManifestVersion() { + var manifest = chrome.runtime.getManifest(); + return manifest && manifest.version ? manifest.version : "unknown"; +} + +function getExportableSyncSettings(syncStorage) { + return vscBuildStoredSettingsDiff(vscExpandStoredSettings(syncStorage)); +} + +function getImportableSyncSettings(backup, rawSettings) { + var importable = vscClonePlainData(rawSettings) || {}; + + if ( + backup && + backup.siteRulesFormat && + importable.siteRulesFormat === undefined + ) { + importable.siteRulesFormat = backup.siteRulesFormat; + } + + if ( + backup && + backup.siteRulesMeta && + importable.siteRulesMeta === undefined + ) { + importable.siteRulesMeta = backup.siteRulesMeta; + } + + return vscExpandStoredSettings(importable); +} + function exportSettings() { chrome.storage.sync.get(null, function (storage) { - chrome.storage.local.get(null, function (localStorage) { - const backup = { - version: "1.1", - exportDate: new Date().toISOString(), - settings: storage, - localSettings: localStorage || {} - }; + chrome.storage.local.get( + EXPORTABLE_LOCAL_SETTINGS_KEYS, + function (localStorage) { + const localSettings = getExportableLocalSettings(localStorage); + const syncSettings = getExportableSyncSettings(storage); + const backup = { + version: getBackupManifestVersion(), + exportDate: new Date().toISOString(), + settings: syncSettings + }; - const dataStr = JSON.stringify(backup, null, 2); - const blob = new Blob([dataStr], { type: "application/json" }); - const url = URL.createObjectURL(blob); + if (Object.keys(localSettings).length > 0) { + backup.localSettings = localSettings; + } - const link = document.createElement("a"); - link.href = url; - link.download = generateBackupFilename(); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + const dataStr = JSON.stringify(backup, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); - showStatus("Settings exported successfully"); - }); + const link = document.createElement("a"); + link.href = url; + link.download = generateBackupFilename(); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showStatus("Settings exported successfully"); + } + ); }); } @@ -55,9 +144,9 @@ function importSettings() { // Detect backup format: check for 'settings' wrapper or raw storage keys if (backup.settings && typeof backup.settings === "object") { - settingsToImport = backup.settings; + settingsToImport = getImportableSyncSettings(backup, backup.settings); } else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) { - settingsToImport = backup; // Raw storage object + settingsToImport = getImportableSyncSettings(backup, backup); } if (!settingsToImport) { @@ -65,49 +154,29 @@ function importSettings() { return; } - var localToImport = - backup.localSettings && typeof backup.localSettings === "object" - ? backup.localSettings - : null; + var localToImport = getExportableLocalSettings(backup.localSettings); 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) { + persistManagedSyncSettings(settingsToImport, function (error) { + if (error) { showStatus( - "Error: Failed to save local extension data - " + - chrome.runtime.lastError.message, + "Error: Failed to save imported settings - " + error.message, true ); return; } - afterLocalImport(); + showStatus("Settings imported successfully. Reloading..."); + setTimeout(function () { + if (typeof restore_options === "function") { + restore_options(); + } else { + location.reload(); + } + }, 500); }); - } else { - afterLocalImport(); } + + replaceImportableLocalSettings(localToImport, afterLocalImport); } catch (err) { showStatus("Error: Failed to parse backup file - " + err.message, true); } diff --git a/inject.js b/inject.js index a100184..1e31adf 100644 --- a/inject.js +++ b/inject.js @@ -1,5 +1,6 @@ var isUserSeek = false; // Track if seek was user-initiated var lastToggleSpeed = {}; // Store last toggle speeds per video +var sharedSettingsDefaults = vscGetSettingsDefaults(); function getPrimaryVideoElement() { if (!tc.mediaElements || tc.mediaElements.length === 0) return null; @@ -12,31 +13,37 @@ function getPrimaryVideoElement() { var tc = { settings: { - lastSpeed: 1.0, - enabled: true, + lastSpeed: sharedSettingsDefaults.lastSpeed, + enabled: sharedSettingsDefaults.enabled, speeds: {}, - displayKeyCode: 86, - rememberSpeed: false, - forceLastSavedSpeed: false, - audioBoolean: false, - startHidden: false, - hideWithYouTubeControls: false, - hideWithControls: false, - hideWithControlsTimer: 2.0, - controllerLocation: "top-left", - controllerOpacity: 0.3, - controllerMarginTop: 0, - controllerMarginRight: 0, - controllerMarginBottom: 65, - controllerMarginLeft: 0, - keyBindings: [], - siteRules: [], - controllerButtons: ["rewind", "slower", "faster", "advance", "display"], + displayKeyCode: sharedSettingsDefaults.displayKeyCode, + rememberSpeed: sharedSettingsDefaults.rememberSpeed, + forceLastSavedSpeed: sharedSettingsDefaults.forceLastSavedSpeed, + audioBoolean: sharedSettingsDefaults.audioBoolean, + startHidden: sharedSettingsDefaults.startHidden, + hideWithYouTubeControls: sharedSettingsDefaults.hideWithYouTubeControls, + hideWithControls: sharedSettingsDefaults.hideWithControls, + hideWithControlsTimer: sharedSettingsDefaults.hideWithControlsTimer, + controllerLocation: sharedSettingsDefaults.controllerLocation, + controllerOpacity: sharedSettingsDefaults.controllerOpacity, + controllerMarginTop: sharedSettingsDefaults.controllerMarginTop, + controllerMarginRight: sharedSettingsDefaults.controllerMarginRight, + controllerMarginBottom: sharedSettingsDefaults.controllerMarginBottom, + controllerMarginLeft: sharedSettingsDefaults.controllerMarginLeft, + keyBindings: Array.isArray(sharedSettingsDefaults.keyBindings) + ? sharedSettingsDefaults.keyBindings.slice() + : [], + siteRules: Array.isArray(sharedSettingsDefaults.siteRules) + ? sharedSettingsDefaults.siteRules.slice() + : [], + controllerButtons: Array.isArray(sharedSettingsDefaults.controllerButtons) + ? sharedSettingsDefaults.controllerButtons.slice() + : ["rewind", "slower", "faster", "advance", "display"], defaultLogLevel: 3, logLevel: 3, - enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube - subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost - subtitleNudgeAmount: 0.001, + enableSubtitleNudge: sharedSettingsDefaults.enableSubtitleNudge, + subtitleNudgeInterval: sharedSettingsDefaults.subtitleNudgeInterval, + subtitleNudgeAmount: sharedSettingsDefaults.subtitleNudgeAmount, customButtonIcons: {} }, mediaElements: [], @@ -184,6 +191,8 @@ function createDefaultBinding(action, key, keyCode, value) { action: action, key: key, keyCode: keyCode, + code: null, + disabled: false, value: value, force: false, predefined: true @@ -220,7 +229,7 @@ function defaultKeyBindings(storage) { "reset", "R", Number(storage.resetKeyCode) || 82, - 1.0 + 0 ), createDefaultBinding( "fast", @@ -429,6 +438,165 @@ function applyControllerLocation(videoController, location) { ); } +function getYouTubeAutoHidePlayer(video) { + if (!video || !isOnYouTube()) return null; + + return video.closest(".html5-video-player") || video.closest("#movie_player"); +} + +function getAutoHideModeForVideo(video) { + if (!tc.settings.hideWithControls) return "off"; + return getYouTubeAutoHidePlayer(video) ? "youtube" : "generic"; +} + +function getControllerMountParent(video, parentHint) { + var parentEl = parentHint || (video && (video.parentElement || video.parentNode)); + if (!parentEl) return null; + + switch (true) { + case location.hostname == "www.amazon.com": + case location.hostname == "www.reddit.com": + case /hbogo\./.test(location.hostname): + return parentEl.parentElement || parentEl; + case location.hostname == "www.facebook.com": + var facebookParent = parentEl; + for ( + var depth = 0; + depth < 8 && facebookParent && facebookParent.parentElement; + depth++ + ) { + facebookParent = facebookParent.parentElement; + } + return facebookParent || parentEl; + case location.hostname == "tv.apple.com": + var root = parentEl.getRootNode(); + var scrim = root && root.querySelector ? root.querySelector(".scrim") : null; + return scrim || parentEl; + case location.hostname == "www.youtube.com": + case location.hostname == "m.youtube.com": + case location.hostname == "music.youtube.com": + return getYouTubeAutoHidePlayer(video) || parentEl; + default: + return parentEl; + } +} + +function getControllerBehaviorSignature(video) { + return JSON.stringify({ + startHidden: Boolean(tc.settings.startHidden), + hideWithControls: Boolean(tc.settings.hideWithControls), + hideWithControlsTimer: Number(tc.settings.hideWithControlsTimer), + controllerLocation: normalizeControllerLocation(tc.settings.controllerLocation), + controllerOpacity: Number(tc.settings.controllerOpacity), + controllerMarginTop: normalizeControllerMarginPx(tc.settings.controllerMarginTop, 0), + controllerMarginBottom: normalizeControllerMarginPx( + tc.settings.controllerMarginBottom, + 0 + ), + controllerButtons: Array.isArray(tc.settings.controllerButtons) + ? tc.settings.controllerButtons.slice() + : [], + enableSubtitleNudge: Boolean(tc.settings.enableSubtitleNudge), + subtitleNudgeInterval: Number(tc.settings.subtitleNudgeInterval), + autoHideMode: getAutoHideModeForVideo(video), + mediaTag: video && video.tagName ? video.tagName : "" + }); +} + +function rebuildControllerForVideo(video, parentHint, reason) { + if (!video) return null; + + var previous = video.vsc || null; + var preservedState = previous + ? { + mark: previous.mark, + resetToggleArmed: previous.resetToggleArmed === true, + subtitleNudgeEnabledOverride: previous.subtitleNudgeEnabledOverride, + userHidden: + Boolean(previous.div) && + previous.div.classList.contains("vsc-hidden") + } + : null; + + if (previous) { + previous.remove(); + } + + if (!video.isConnected || !hasUsableMediaSource(video)) { + return null; + } + + var nextController = new tc.videoController( + video, + parentHint || video.parentElement || video.parentNode + ); + if (!nextController) return null; + + if (preservedState) { + nextController.mark = preservedState.mark; + nextController.resetToggleArmed = preservedState.resetToggleArmed; + + if ( + typeof preservedState.subtitleNudgeEnabledOverride === "boolean" + ) { + nextController.subtitleNudgeEnabledOverride = + preservedState.subtitleNudgeEnabledOverride; + updateSubtitleNudgeIndicator(video); + if (!preservedState.subtitleNudgeEnabledOverride) { + nextController.stopSubtitleNudge(); + } else if (!video.paused && video.playbackRate !== 1.0) { + nextController.startSubtitleNudge(); + } + } + + if (preservedState.userHidden && nextController.div) { + nextController.div.classList.add("vsc-hidden"); + } + } + + log("Rebuilt controller: " + (reason || "refresh"), 4); + return nextController; +} + +function refreshManagedController(video, parentHint) { + if (!video || !video.vsc) return null; + if (!video.isConnected) { + removeController(video); + return null; + } + + var controller = video.vsc; + controller.parent = video.parentElement || parentHint || controller.parent; + + var expectedMountParent = getControllerMountParent(video, controller.parent); + var nextSignature = getControllerBehaviorSignature(video); + var wrapper = controller.div; + var needsRebuild = + !wrapper || + !wrapper.isConnected || + !expectedMountParent || + wrapper.parentNode !== expectedMountParent || + controller.behaviorSignature !== nextSignature; + + if (needsRebuild) { + return rebuildControllerForVideo( + video, + controller.parent, + "DOM/source/site-rule change" + ); + } + + controller.mountParent = expectedMountParent; + controller.behaviorSignature = nextSignature; + applyControllerLocation(controller, tc.settings.controllerLocation); + var controllerEl = getControllerElement(controller); + if (controllerEl) { + controllerEl.style.opacity = String(tc.settings.controllerOpacity); + } + updateSubtitleNudgeIndicator(video); + return controller; +} + function captureSiteRuleBase() { tc.siteRuleBase = { startHidden: tc.settings.startHidden, @@ -776,27 +944,40 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) { return normalizedEnabled; } -function subtitleNudgeIconMarkup(isEnabled) { +function renderSubtitleNudgeIndicatorContent(target, isEnabled) { + if (!target) return; var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff"; var custom = tc.settings.customButtonIcons && tc.settings.customButtonIcons[action] && tc.settings.customButtonIcons[action].svg; + vscClearElement(target); if (custom) { - return ( - '' + custom + "" + var customWrap = vscCreateSvgWrap( + target.ownerDocument || document, + custom, + "vsc-btn-icon" ); + if (customWrap) { + target.appendChild(customWrap); + return; + } } if (typeof vscIconSvgString !== "function") { - return isEnabled ? "✓" : "×"; + target.textContent = isEnabled ? "✓" : "×"; + return; } var svg = vscIconSvgString(action, 14); if (!svg) { - return isEnabled ? "✓" : "×"; + target.textContent = isEnabled ? "✓" : "×"; + return; } - return ( - '' + svg + "" - ); + var wrap = vscCreateSvgWrap(target.ownerDocument || document, svg, "vsc-btn-icon"); + if (wrap) { + target.appendChild(wrap); + return; + } + target.textContent = isEnabled ? "✓" : "×"; } function updateSubtitleNudgeIndicator(video) { @@ -804,11 +985,10 @@ function updateSubtitleNudgeIndicator(video) { var isEnabled = isSubtitleNudgeEnabledForVideo(video); var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled"; - var mark = subtitleNudgeIconMarkup(isEnabled); var indicator = video.vsc.subtitleNudgeIndicator; if (indicator) { - indicator.innerHTML = mark; + renderSubtitleNudgeIndicatorContent(indicator, isEnabled); indicator.dataset.enabled = isEnabled ? "true" : "false"; indicator.dataset.supported = "true"; indicator.title = title; @@ -817,7 +997,7 @@ function updateSubtitleNudgeIndicator(video) { var flashEl = video.vsc.nudgeFlashIndicator; if (flashEl) { - flashEl.innerHTML = mark; + renderSubtitleNudgeIndicatorContent(flashEl, isEnabled); flashEl.dataset.enabled = isEnabled ? "true" : "false"; flashEl.dataset.supported = "true"; flashEl.setAttribute("aria-label", title); @@ -898,8 +1078,8 @@ function applySourceTransitionPolicy(video, forceUpdate) { 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. + // Same-tab SPA or DOM-driven player swaps can change the effective rule output + // after the media source updates, so refresh or rebuild controllers here too. reapplySiteRulesAndControllerGeometry(); } @@ -1021,8 +1201,14 @@ function hasUsableMediaSource(node) { } function ensureController(node, parent) { - if (!isMediaElement(node) || node.vsc) return node && node.vsc; - if (!hasUsableMediaSource(node)) { + if (!isMediaElement(node)) return node && node.vsc; + + if (!node.isConnected) { + removeController(node); + return null; + } + + if (!node.vsc && !hasUsableMediaSource(node)) { log( `Deferring controller creation for ${node.tagName}: no usable source yet`, 5 @@ -1033,9 +1219,13 @@ function ensureController(node, parent) { // href selects site rules; re-run on every new/usable media so margins/opacity match current URL. var siteDisabled = applySiteRuleOverrides(); if (!tc.settings.enabled || siteDisabled) { + removeController(node); return null; } - refreshAllControllerGeometry(); + + if (node.vsc) { + return refreshManagedController(node, parent); + } log( `Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`, @@ -1179,7 +1369,8 @@ function log(message, level) { } } -chrome.storage.sync.get(tc.settings, function (storage) { +chrome.storage.sync.get(null, function (storage) { + storage = vscExpandStoredSettings(storage); var storedBindings = Array.isArray(storage.keyBindings) ? storage.keyBindings : []; @@ -1190,19 +1381,6 @@ chrome.storage.sync.get(tc.settings, function (storage) { if (tc.settings.keyBindings.length === 0) { tc.settings.keyBindings = defaultKeyBindings(storage); - tc.settings.version = "0.5.3"; - chrome.storage.sync.set({ - keyBindings: tc.settings.keyBindings, - version: tc.settings.version, - displayKeyCode: tc.settings.displayKeyCode, - rememberSpeed: tc.settings.rememberSpeed, - forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, - audioBoolean: tc.settings.audioBoolean, - startHidden: tc.settings.startHidden, - enabled: tc.settings.enabled, - controllerLocation: tc.settings.controllerLocation, - controllerOpacity: tc.settings.controllerOpacity - }); } tc.settings.lastSpeed = Number(storage.lastSpeed); if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) { @@ -1280,7 +1458,14 @@ chrome.storage.sync.get(tc.settings, function (storage) { addedDefaultBinding; if (addedDefaultBinding) { - chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings }); + var keyBindingsDiff = vscBuildStoredSettingsDiff({ + keyBindings: tc.settings.keyBindings + }); + if (Object.prototype.hasOwnProperty.call(keyBindingsDiff, "keyBindings")) { + chrome.storage.sync.set({ keyBindings: keyBindingsDiff.keyBindings }); + } else { + chrome.storage.sync.remove("keyBindings"); + } } captureSiteRuleBase(); patchAttachShadow(); @@ -1357,12 +1542,15 @@ chrome.storage.sync.get(tc.settings, function (storage) { tc.settings.customButtonIcons && tc.settings.customButtonIcons[act] && tc.settings.customButtonIcons[act].svg; - btn.innerHTML = ""; + vscClearElement(btn); if (svg) { - var cw = doc.createElement("span"); - cw.className = "vsc-btn-icon"; - cw.innerHTML = svg; - btn.appendChild(cw); + var cw = vscCreateSvgWrap(doc, svg, "vsc-btn-icon"); + if (cw) { + btn.appendChild(cw); + } else { + var cdf = controllerButtonDefs[act]; + btn.textContent = (cdf && cdf.label) || "?"; + } } else if (typeof vscIconWrap === "function") { var wrap = vscIconWrap(doc, act, 14); if (wrap) { @@ -1405,10 +1593,12 @@ function createControllerButton(doc, action, label, className) { 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); + var customWrap = vscCreateSvgWrap(doc, custom, "vsc-btn-icon"); + if (customWrap) { + button.appendChild(customWrap); + } else { + button.textContent = label || "?"; + } } else if (typeof vscIconWrap === "function") { var wrap = vscIconWrap(doc, action, 14); if (wrap) { @@ -1466,7 +1656,14 @@ function defineVideoController() { return; } - log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, 4); + this.mountParent = + this.div.parentNode || getControllerMountParent(target, this.parent); + this.behaviorSignature = getControllerBehaviorSignature(target); + + log( + `Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, + 4 + ); var mediaEventAction = function (event) { if ( @@ -1740,10 +1937,10 @@ function defineVideoController() { }; tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) { - if (!wrapper || !isOnYouTube()) return; + if (!wrapper) return; const video = this.video; - const ytPlayer = video.closest(".html5-video-player"); + const ytPlayer = getYouTubeAutoHidePlayer(video); if (!ytPlayer) { log("YouTube player not found for auto-hide setup", 4); return; @@ -1883,7 +2080,8 @@ function defineVideoController() { wrapper.classList.add("vsc-nosource"); if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden"); // Use lower z-index for non-YouTube sites to avoid overlapping modals - if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube"); + if (!getYouTubeAutoHidePlayer(this.video)) + wrapper.classList.add("vsc-non-youtube"); var shadow = wrapper.attachShadow({ mode: "open" }); var shadowStylesheet = doc.createElement("link"); shadowStylesheet.rel = "stylesheet"; @@ -1999,7 +2197,7 @@ function defineVideoController() { // Setup auto-hide observers if enabled if (tc.settings.hideWithControls) { - if (isOnYouTube()) { + if (getAutoHideModeForVideo(this.video) === "youtube") { this.setupYouTubeAutoHide(wrapper); } else { this.setupGenericAutoHide(wrapper); @@ -2071,60 +2269,26 @@ function defineVideoController() { }; } -function escapeStringRegExp(str) { - const m = /[|\\{}()[\]^$+*?.]/g; - return str.replace(m, "\\$&"); -} function applySiteRuleOverrides() { resetSettingsFromSiteRuleBase(); + tc.activeSiteRule = null; if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) { return false; } var currentUrl = location.href; - var matchedRule = null; - - for (var i = 0; i < tc.settings.siteRules.length; i++) { - var rule = tc.settings.siteRules[i]; - var pattern = rule.pattern; - if (!pattern || pattern.length === 0) continue; - - var regex; - if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { - try { - var lastSlash = pattern.lastIndexOf("/"); - regex = new RegExp( - pattern.substring(1, lastSlash), - pattern.substring(lastSlash + 1) - ); - } catch (e) { - log(`Invalid site rule regex: ${pattern}. ${e.message}`, 2); - continue; - } - } else { - regex = new RegExp(escapeStringRegExp(pattern)); - } - - if (regex && regex.test(currentUrl)) { - matchedRule = rule; - break; - } - } + var matchedRule = vscMatchSiteRule(currentUrl, tc.settings.siteRules); if (!matchedRule) return false; tc.activeSiteRule = matchedRule; - log(`Matched site rule: ${matchedRule.pattern}`, 4); + log("Matched site rule overrides for current URL", 4); // Check if extension should be enabled/disabled on this site - if (matchedRule.enabled === false) { + if (vscIsSiteRuleDisabled(matchedRule)) { log(`Extension disabled for site: ${currentUrl}`, 4); return true; - } else if (matchedRule.disableExtension === true) { - // Handle old format - log(`Extension disabled (legacy) for site: ${currentUrl}`, 4); - return true; } // Override general settings with site-specific overrides @@ -2182,23 +2346,22 @@ 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. */ +/** Re-match site rules for current URL and refresh or rebuild every controller. */ function reapplySiteRulesAndControllerGeometry() { var siteDisabled = applySiteRuleOverrides(); - if (!tc.settings.enabled || siteDisabled) return; - refreshAllControllerGeometry(); + var videos = tc.mediaElements.slice(); + + if (!tc.settings.enabled || siteDisabled) { + videos.forEach(function (video) { + removeController(video); + }); + return; + } + + videos.forEach(function (video) { + if (!video) return; + ensureController(video, video.parentElement || video.parentNode); + }); } function shouldPreserveDesiredSpeed(video, speed) { @@ -2539,7 +2702,6 @@ function initializeNow(doc, forceReinit = false) { if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return; var siteDisabled = applySiteRuleOverrides(); - if (!tc.settings.enabled || siteDisabled) return; if (!doc.body.classList.contains("vsc-initialized")) { doc.body.classList.add("vsc-initialized"); @@ -2551,7 +2713,9 @@ function initializeNow(doc, forceReinit = false) { if (forceReinit) { log("Force re-initialization requested", 4); - refreshAllControllerGeometry(); + reapplySiteRulesAndControllerGeometry(); + } else if (!tc.settings.enabled || siteDisabled) { + reapplySiteRulesAndControllerGeometry(); } vscInitializedDocuments.add(doc); diff --git a/lucide-client.js b/lucide-client.js index 41727d0..ac4581b 100644 --- a/lucide-client.js +++ b/lucide-client.js @@ -31,32 +31,9 @@ function sanitizeLucideSvg(svgText) { 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 (doc.querySelector("parsererror")) return null; + var svg = vscSanitizeSvgTree(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%"); diff --git a/manifest.json b/manifest.json index db84a0d..fc70a61 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Speeder", "short_name": "Speeder", - "version": "5.1.5", + "version": "5.1.7", "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", @@ -59,6 +59,7 @@ "inject.css" ], "js": [ + "settings-core.js", "ui-icons.js", "inject.js" ] diff --git a/options.html b/options.html index 3d5be8d..4152b0d 100644 --- a/options.html +++ b/options.html @@ -5,6 +5,7 @@ Speeder Settings + @@ -550,18 +551,29 @@ Override auto-hide for this site - - - - Hide with controls (idle-based) - - + + + + Hide with controlsFade the controller in and out with the video + interface: perfect sync on YouTube, idle-based + elsewhere. + + + + + Auto-hide timer (seconds)Seconds of inactivity before hiding: 0.1–15 + for non-YouTube sites. + + - - Auto-hide timer (0.1–15s): - - - diff --git a/options.js b/options.js index e280f1e..425b609 100644 --- a/options.js +++ b/options.js @@ -172,8 +172,9 @@ var customButtonIconsLive = {}; function fillControlBarIconElement(icon, buttonId) { if (!icon || !buttonId) return; + var doc = icon.ownerDocument || document; if (buttonId === "nudge") { - icon.innerHTML = ""; + vscClearElement(icon); icon.className = "cb-icon cb-icon-nudge-pair"; function nudgeChipMarkup(action) { var c = customButtonIconsLive[action]; @@ -189,10 +190,10 @@ function fillControlBarIconElement(icon, buttonId) { 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); + var wrap = vscCreateSvgWrap(doc, inner, "vsc-btn-icon"); + if (wrap) { + sp.appendChild(wrap); + } } icon.appendChild(sp); } @@ -207,16 +208,15 @@ function fillControlBarIconElement(icon, buttonId) { icon.className = "cb-icon"; var custom = customButtonIconsLive[buttonId]; if (custom && custom.svg) { - icon.innerHTML = custom.svg; - return; + if (vscSetSvgContent(icon, custom.svg)) return; } if (typeof vscIconSvgString === "function") { var svgHtml = vscIconSvgString(buttonId, 16); if (svgHtml) { - icon.innerHTML = svgHtml; - return; + if (vscSetSvgContent(icon, svgHtml)) return; } } + vscClearElement(icon); var def = controllerButtonDefs[buttonId]; icon.textContent = (def && def.icon) || "?"; } @@ -226,64 +226,48 @@ function createDefaultBinding(action, key, keyCode, value) { action: action, key: key, keyCode: keyCode, + code: null, + disabled: false, value: value, force: false, predefined: true }; } -var tcDefaults = { - speed: 1.0, - lastSpeed: 1.0, - displayKeyCode: 86, - rememberSpeed: false, - audioBoolean: false, - startHidden: false, - hideWithYouTubeControls: false, - hideWithControls: false, - hideWithControlsTimer: 2.0, - controllerLocation: "top-left", - forceLastSavedSpeed: false, - enabled: true, - controllerOpacity: 0.3, - controllerMarginTop: 0, - controllerMarginRight: 0, - controllerMarginBottom: 65, - controllerMarginLeft: 0, - keyBindings: [ - createDefaultBinding("display", "V", 86, 0), - createDefaultBinding("move", "P", 80, 0), - createDefaultBinding("slower", "S", 83, 0.1), - createDefaultBinding("faster", "D", 68, 0.1), - createDefaultBinding("rewind", "Z", 90, 10), - createDefaultBinding("advance", "X", 88, 10), - createDefaultBinding("reset", "R", 82, 1), - createDefaultBinding("fast", "G", 71, 1.8), - createDefaultBinding("toggleSubtitleNudge", "N", 78, 0) - ], - siteRules: [ - { - pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/", - enabled: true, - enableSubtitleNudge: true, - subtitleNudgeInterval: 50 - }, - { - pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/", - enabled: true, - rememberSpeed: true, - controllerMarginTop: 60, - controllerMarginBottom: 85 +var tcDefaults = vscGetSettingsDefaults(); +var legacySyncKeys = [ + "resetSpeed", + "speedStep", + "fastSpeed", + "rewindTime", + "advanceTime", + "resetKeyCode", + "slowerKeyCode", + "fasterKeyCode", + "rewindKeyCode", + "advanceKeyCode", + "fastKeyCode", + "blacklist" +]; + +function persistManagedSyncSettings(settings, callback) { + var nextSettings = vscBuildStoredSettingsDiff(settings); + chrome.storage.sync.remove(vscGetManagedSyncKeys(), function () { + if (chrome.runtime.lastError) { + callback(chrome.runtime.lastError); + return; } - ], - controllerButtons: ["rewind", "slower", "faster", "advance", "display"], - showPopupControlBar: true, - popupMatchHoverControls: true, - popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"], - enableSubtitleNudge: false, - subtitleNudgeInterval: 50, - subtitleNudgeAmount: 0.001 -}; + + if (Object.keys(nextSettings).length === 0) { + callback(null); + return; + } + + chrome.storage.sync.set(nextSettings, function () { + callback(chrome.runtime.lastError || null); + }); + }); +} const actionLabels = { display: "Show/hide controller", @@ -889,15 +873,20 @@ function save_options() { settings.siteRules.push(rule); }); - // Legacy keys to remove - const legacyKeys = [ - "resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime", - "resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode", - "advanceKeyCode", "fastKeyCode", "blacklist" - ]; + chrome.storage.sync.remove(legacySyncKeys, function () { + if (chrome.runtime.lastError) { + status.textContent = + "Error: Failed to clear legacy settings - " + + chrome.runtime.lastError.message; + return; + } - chrome.storage.sync.remove(legacyKeys, function () { - chrome.storage.sync.set(settings, function () { + persistManagedSyncSettings(settings, function (error) { + if (error) { + status.textContent = + "Error: Failed to save settings - " + error.message; + return; + } status.textContent = "Options saved"; setTimeout(function () { status.textContent = ""; @@ -1200,8 +1189,8 @@ function createControlBarBlock(buttonId) { } function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) { - activeZone.innerHTML = ""; - availableZone.innerHTML = ""; + vscClearElement(activeZone); + vscClearElement(availableZone); var allowed = function (id) { if (!controllerButtonDefs[id]) return false; @@ -1394,7 +1383,7 @@ function initLucideButtonIconsUI() { if (!actionSel.dataset.lucideInit) { actionSel.dataset.lucideInit = "1"; - actionSel.innerHTML = ""; + vscClearElement(actionSel); Object.keys(controllerButtonDefs).forEach(function (aid) { if (aid === "nudge") { Object.keys(lucideSubtitleNudgeActionLabels).forEach(function (subId) { @@ -1415,7 +1404,7 @@ function initLucideButtonIconsUI() { } function renderResults(slugs) { - resultsEl.innerHTML = ""; + vscClearElement(resultsEl); slugs.forEach(function (slug) { var b = document.createElement("button"); b.type = "button"; @@ -1450,11 +1439,13 @@ function initLucideButtonIconsUI() { .then(function (txt) { var safe = sanitizeLucideSvg(txt); if (!safe) throw new Error("Bad SVG"); - previewEl.innerHTML = safe; + if (!vscSetSvgContent(previewEl, safe)) { + throw new Error("Preview render failed"); + } setLucideStatus("Preview: " + slug); }) .catch(function (e) { - previewEl.innerHTML = ""; + vscClearElement(previewEl); setLucideStatus( "Could not load: " + slug + " — " + e.message ); @@ -1473,7 +1464,7 @@ function initLucideButtonIconsUI() { .then(function (map) { var q = searchInput.value; if (!q.trim()) { - resultsEl.innerHTML = ""; + vscClearElement(resultsEl); return; } renderResults(searchLucideSlugs(map, q, 48)); @@ -1557,53 +1548,49 @@ function initLucideButtonIconsUI() { } function restore_options() { - chrome.storage.sync.get(tcDefaults, function (storage) { + chrome.storage.sync.get(null, function (storage) { + var settings = vscExpandStoredSettings(storage); chrome.storage.local.get(["customButtonIcons"], function (loc) { customButtonIconsLive = loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object" ? loc.customButtonIcons : {}; - document.getElementById("rememberSpeed").checked = storage.rememberSpeed; + document.getElementById("rememberSpeed").checked = settings.rememberSpeed; document.getElementById("forceLastSavedSpeed").checked = - storage.forceLastSavedSpeed; - document.getElementById("audioBoolean").checked = storage.audioBoolean; - document.getElementById("enabled").checked = storage.enabled; - document.getElementById("startHidden").checked = storage.startHidden; - - // Migration/Normalization for hideWithControls - const hideWithControls = typeof storage.hideWithControls !== "undefined" - ? storage.hideWithControls - : storage.hideWithYouTubeControls; - - document.getElementById("hideWithControls").checked = hideWithControls; + settings.forceLastSavedSpeed; + document.getElementById("audioBoolean").checked = settings.audioBoolean; + document.getElementById("enabled").checked = settings.enabled; + document.getElementById("startHidden").checked = settings.startHidden; + document.getElementById("hideWithControls").checked = + settings.hideWithControls; document.getElementById("hideWithControlsTimer").value = - storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer; + settings.hideWithControlsTimer || tcDefaults.hideWithControlsTimer; document.getElementById("controllerLocation").value = - normalizeControllerLocation(storage.controllerLocation); + normalizeControllerLocation(settings.controllerLocation); document.getElementById("controllerOpacity").value = - storage.controllerOpacity; + settings.controllerOpacity; document.getElementById("controllerMarginTop").value = - storage.controllerMarginTop ?? tcDefaults.controllerMarginTop; + settings.controllerMarginTop ?? tcDefaults.controllerMarginTop; document.getElementById("controllerMarginBottom").value = - storage.controllerMarginBottom ?? tcDefaults.controllerMarginBottom; + settings.controllerMarginBottom ?? tcDefaults.controllerMarginBottom; document.getElementById("showPopupControlBar").checked = - storage.showPopupControlBar !== false; + settings.showPopupControlBar !== false; document.getElementById("enableSubtitleNudge").checked = - storage.enableSubtitleNudge; + settings.enableSubtitleNudge; document.getElementById("subtitleNudgeInterval").value = - storage.subtitleNudgeInterval; + settings.subtitleNudgeInterval; - if (!Array.isArray(storage.keyBindings) || storage.keyBindings.length === 0) { - storage.keyBindings = tcDefaults.keyBindings.slice(); + if (!Array.isArray(settings.keyBindings) || settings.keyBindings.length === 0) { + settings.keyBindings = tcDefaults.keyBindings.slice(); } - ensureAllDefaultBindings(storage); + ensureAllDefaultBindings(settings); document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove()); - storage.keyBindings.forEach((item) => { + settings.keyBindings.forEach((item) => { var row = document.getElementById(item.action); var normalizedBinding = normalizeStoredBinding(item); @@ -1632,13 +1619,11 @@ function restore_options() { refreshAddShortcutSelector(); - // 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 || []; + var siteRules = Array.isArray(settings.siteRules) + ? settings.siteRules + : tcDefaults.siteRules || []; - document.getElementById("siteRulesContainer").innerHTML = ""; + vscClearElement(document.getElementById("siteRulesContainer")); if (siteRules.length > 0) { siteRules.forEach((rule) => { if (rule && rule.pattern) { @@ -1647,16 +1632,16 @@ function restore_options() { }); } - var controllerButtons = Array.isArray(storage.controllerButtons) - ? storage.controllerButtons + var controllerButtons = Array.isArray(settings.controllerButtons) + ? settings.controllerButtons : tcDefaults.controllerButtons; populateControlBarEditor(controllerButtons); document.getElementById("popupMatchHoverControls").checked = - storage.popupMatchHoverControls !== false; + settings.popupMatchHoverControls !== false; - var popupButtons = Array.isArray(storage.popupControllerButtons) - ? storage.popupControllerButtons + var popupButtons = Array.isArray(settings.popupControllerButtons) + ? settings.popupControllerButtons : tcDefaults.popupControllerButtons; populatePopupControlBarEditor(popupButtons); updatePopupEditorDisabledState(); @@ -1674,13 +1659,30 @@ function restore_defaults() { function () {} ); - chrome.storage.sync.set(tcDefaults, function () { - restore_options(); - var status = document.getElementById("status"); - status.textContent = "Default options restored"; - setTimeout(function () { - status.textContent = ""; - }, 1000); + chrome.storage.sync.remove(legacySyncKeys, function () { + if (chrome.runtime.lastError) { + var errorStatus = document.getElementById("status"); + errorStatus.textContent = + "Error: Failed to clear legacy settings - " + + chrome.runtime.lastError.message; + return; + } + + persistManagedSyncSettings(tcDefaults, function (error) { + if (error) { + var errorStatus = document.getElementById("status"); + errorStatus.textContent = + "Error: Failed to restore defaults - " + error.message; + return; + } + + restore_options(); + var status = document.getElementById("status"); + status.textContent = "Default options restored"; + setTimeout(function () { + status.textContent = ""; + }, 1000); + }); }); } diff --git a/popup.html b/popup.html index 35371f3..8463d54 100644 --- a/popup.html +++ b/popup.html @@ -4,6 +4,7 @@ Speeder + diff --git a/popup.js b/popup.js index 76c9273..be5e1f4 100644 --- a/popup.js +++ b/popup.js @@ -20,49 +20,15 @@ document.addEventListener("DOMContentLoaded", function () { 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: [] - }; var renderToken = 0; - function escapeStringRegExp(str) { - const m = /[|\\{}()[\]^$+*?.]/g; - return str.replace(m, "\\$&"); - } - function matchSiteRule(url, siteRules) { if (!url || !Array.isArray(siteRules)) return null; - for (var i = 0; i < siteRules.length; i++) { - var rule = siteRules[i]; - if (!rule || !rule.pattern) continue; - var pattern = rule.pattern.replace(regStrip, ""); - if (pattern.length === 0) continue; - var re; - if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { - try { - var ls = pattern.lastIndexOf("/"); - re = new RegExp(pattern.substring(1, ls), pattern.substring(ls + 1)); - } catch (e) { - continue; - } - } else { - re = new RegExp(escapeStringRegExp(pattern)); - } - if (re && re.test(url)) return rule; - } - return null; + return vscMatchSiteRule(url, siteRules); } function isSiteRuleDisabled(rule) { - return Boolean( - rule && - (rule.enabled === false || rule.disableExtension === true) - ); + return vscIsSiteRuleDisabled(rule); } function resolvePopupButtons(storage, siteRule) { @@ -230,17 +196,21 @@ document.addEventListener("DOMContentLoaded", function () { btn.dataset.action = btnId; 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); + var customSpan = vscCreateSvgWrap(document, customEntry.svg, "vsc-btn-icon"); + if (customSpan) { + btn.appendChild(customSpan); + } else { + btn.textContent = def.label || "?"; + } } else if (typeof vscIconSvgString === "function") { var svgStr = vscIconSvgString(btnId, 16); if (svgStr) { - var iconSpan = document.createElement("span"); - iconSpan.className = "vsc-btn-icon"; - iconSpan.innerHTML = svgStr; - btn.appendChild(iconSpan); + var iconSpan = vscCreateSvgWrap(document, svgStr, "vsc-btn-icon"); + if (iconSpan) { + btn.appendChild(iconSpan); + } else { + btn.textContent = def.label || "?"; + } } else { btn.textContent = def.label || "?"; } @@ -330,8 +300,9 @@ document.addEventListener("DOMContentLoaded", function () { ? loc.customButtonIcons : {}; - chrome.storage.sync.get(storageDefaults, function (storage) { + chrome.storage.sync.get(null, function (storage) { if (currentRenderToken !== renderToken) return; + storage = vscExpandStoredSettings(storage); getActiveTabContext(function (context) { if (currentRenderToken !== renderToken) return; @@ -393,7 +364,9 @@ document.addEventListener("DOMContentLoaded", function () { changes.controllerButtons || changes.popupMatchHoverControls || changes.popupControllerButtons || - changes.siteRules + changes.siteRules || + changes.siteRulesMeta || + changes.siteRulesFormat ) { renderForActiveTab(); } @@ -402,9 +375,37 @@ document.addEventListener("DOMContentLoaded", function () { renderForActiveTab(); function toggleEnabled(enabled, callback) { - chrome.storage.sync.set({ enabled: enabled }, function () { - toggleEnabledUI(enabled); - if (callback) callback(enabled); + chrome.storage.sync.get(null, function (storage) { + var nextSettings = vscExpandStoredSettings(storage); + nextSettings.enabled = enabled; + var storedSettings = vscBuildStoredSettingsDiff(nextSettings); + + chrome.storage.sync.remove(vscGetManagedSyncKeys(), function () { + if (chrome.runtime.lastError) { + setStatusMessage( + "Failed to update settings: " + chrome.runtime.lastError.message + ); + return; + } + + if (Object.keys(storedSettings).length === 0) { + toggleEnabledUI(enabled); + if (callback) callback(enabled); + return; + } + + chrome.storage.sync.set(storedSettings, function () { + if (chrome.runtime.lastError) { + setStatusMessage( + "Failed to update settings: " + chrome.runtime.lastError.message + ); + return; + } + + toggleEnabledUI(enabled); + if (callback) callback(enabled); + }); + }); }); } diff --git a/settings-core.js b/settings-core.js new file mode 100644 index 0000000..62a8195 --- /dev/null +++ b/settings-core.js @@ -0,0 +1,644 @@ +(function (global) { + "use strict"; + + var SITE_RULES_DIFF_FORMAT = "defaults-diff-v1"; + var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"]; + var SITE_RULE_OVERRIDE_KEYS = [ + "controllerLocation", + "controllerMarginTop", + "controllerMarginBottom", + "startHidden", + "hideWithControls", + "hideWithControlsTimer", + "rememberSpeed", + "forceLastSavedSpeed", + "audioBoolean", + "controllerOpacity", + "enableSubtitleNudge", + "subtitleNudgeInterval", + "controllerButtons", + "showPopupControlBar", + "popupControllerButtons", + "shortcuts", + "preferredSpeed" + ]; + var DIFFABLE_OPTION_KEYS = [ + "rememberSpeed", + "forceLastSavedSpeed", + "audioBoolean", + "enabled", + "startHidden", + "hideWithControls", + "hideWithControlsTimer", + "controllerLocation", + "controllerOpacity", + "controllerMarginTop", + "controllerMarginBottom", + "keyBindings", + "siteRules", + "siteRulesMeta", + "siteRulesFormat", + "controllerButtons", + "showPopupControlBar", + "popupMatchHoverControls", + "popupControllerButtons", + "enableSubtitleNudge", + "subtitleNudgeInterval", + "subtitleNudgeAmount" + ]; + var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([ + "hideWithYouTubeControls" + ]); + + var DEFAULT_SETTINGS = { + speed: 1.0, + lastSpeed: 1.0, + displayKeyCode: 86, + rememberSpeed: false, + audioBoolean: false, + startHidden: false, + hideWithYouTubeControls: false, + hideWithControls: false, + hideWithControlsTimer: 2.0, + controllerLocation: "top-left", + forceLastSavedSpeed: false, + enabled: true, + controllerOpacity: 0.3, + controllerMarginTop: 0, + controllerMarginRight: 0, + controllerMarginBottom: 65, + controllerMarginLeft: 0, + keyBindings: [ + { + action: "display", + key: "V", + keyCode: 86, + code: null, + disabled: false, + value: 0, + force: false, + predefined: true + }, + { + action: "move", + key: "P", + keyCode: 80, + code: null, + disabled: false, + value: 0, + force: false, + predefined: true + }, + { + action: "slower", + key: "S", + keyCode: 83, + code: null, + disabled: false, + value: 0.1, + force: false, + predefined: true + }, + { + action: "faster", + key: "D", + keyCode: 68, + code: null, + disabled: false, + value: 0.1, + force: false, + predefined: true + }, + { + action: "rewind", + key: "Z", + keyCode: 90, + code: null, + disabled: false, + value: 10, + force: false, + predefined: true + }, + { + action: "advance", + key: "X", + keyCode: 88, + code: null, + disabled: false, + value: 10, + force: false, + predefined: true + }, + { + action: "reset", + key: "R", + keyCode: 82, + code: null, + disabled: false, + value: 0, + force: false, + predefined: true + }, + { + action: "fast", + key: "G", + keyCode: 71, + code: null, + disabled: false, + value: 1.8, + force: false, + predefined: true + }, + { + action: "toggleSubtitleNudge", + key: "N", + keyCode: 78, + code: null, + disabled: false, + value: 0, + force: false, + predefined: true + } + ], + siteRules: [ + { + pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/", + enabled: true, + enableSubtitleNudge: true, + subtitleNudgeInterval: 50 + }, + { + pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/", + enabled: true, + rememberSpeed: true, + controllerMarginTop: 60, + controllerMarginBottom: 85 + } + ], + controllerButtons: DEFAULT_BUTTONS.slice(), + showPopupControlBar: true, + popupMatchHoverControls: true, + popupControllerButtons: DEFAULT_BUTTONS.slice(), + enableSubtitleNudge: false, + subtitleNudgeInterval: 50, + subtitleNudgeAmount: 0.001 + }; + + function clonePlainData(value) { + if (value === undefined) { + return undefined; + } + return JSON.parse(JSON.stringify(value)); + } + + function hasOwn(obj, key) { + return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key); + } + + function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } + + function sortComparableValue(value) { + if (Array.isArray(value)) { + return value.map(sortComparableValue); + } + + if (isPlainObject(value)) { + var sorted = {}; + Object.keys(value) + .sort() + .forEach(function (key) { + if (value[key] === undefined) { + return; + } + sorted[key] = sortComparableValue(value[key]); + }); + return sorted; + } + + return value; + } + + function areComparableValuesEqual(a, b) { + return ( + JSON.stringify(sortComparableValue(a)) === + JSON.stringify(sortComparableValue(b)) + ); + } + + function deepMergeDefaults(defaults, overrides) { + if (Array.isArray(defaults)) { + return Array.isArray(overrides) + ? clonePlainData(overrides) + : clonePlainData(defaults); + } + + if (isPlainObject(defaults)) { + var result = clonePlainData(defaults) || {}; + if (!isPlainObject(overrides)) { + return result; + } + + Object.keys(overrides).forEach(function (key) { + if (overrides[key] === undefined) { + return; + } + + if (hasOwn(defaults, key)) { + result[key] = deepMergeDefaults(defaults[key], overrides[key]); + } else { + result[key] = clonePlainData(overrides[key]); + } + }); + + return result; + } + + return overrides === undefined + ? clonePlainData(defaults) + : clonePlainData(overrides); + } + + function deepDiff(current, defaults) { + if (current === undefined) { + return undefined; + } + + if (Array.isArray(current)) { + return areComparableValuesEqual(current, defaults) + ? undefined + : clonePlainData(current); + } + + if (isPlainObject(current)) { + var result = {}; + Object.keys(current).forEach(function (key) { + var diff = deepDiff(current[key], defaults && defaults[key]); + if (diff !== undefined) { + result[key] = diff; + } + }); + return Object.keys(result).length > 0 ? result : undefined; + } + + return areComparableValuesEqual(current, defaults) + ? undefined + : clonePlainData(current); + } + + function getDefaultSiteRules() { + return clonePlainData(DEFAULT_SETTINGS.siteRules) || []; + } + + function getDefaultSiteRulesByPattern() { + var map = Object.create(null); + getDefaultSiteRules().forEach(function (rule) { + if (!rule || typeof rule.pattern !== "string" || !rule.pattern) { + return; + } + map[rule.pattern] = rule; + }); + return map; + } + + function normalizeSiteRuleForDiff(rule, baseSettings) { + if (!rule || typeof rule !== "object" || Array.isArray(rule)) { + return null; + } + + var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : ""; + if (!pattern) { + return null; + } + + var normalized = { pattern: pattern }; + var baseEnabled = hasOwn(baseSettings, "enabled") + ? Boolean(baseSettings.enabled) + : true; + var ruleEnabled = hasOwn(rule, "enabled") + ? Boolean(rule.enabled) + : hasOwn(rule, "disableExtension") + ? !Boolean(rule.disableExtension) + : baseEnabled; + + if (!areComparableValuesEqual(ruleEnabled, baseEnabled)) { + normalized.enabled = ruleEnabled; + } + + SITE_RULE_OVERRIDE_KEYS.forEach(function (key) { + var baseValue = clonePlainData(baseSettings[key]); + var effectiveValue = hasOwn(rule, key) + ? clonePlainData(rule[key]) + : baseValue; + + if (!areComparableValuesEqual(effectiveValue, baseValue)) { + normalized[key] = effectiveValue; + } + }); + + Object.keys(rule).forEach(function (key) { + if ( + key === "pattern" || + key === "enabled" || + key === "disableExtension" || + SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 || + rule[key] === undefined + ) { + return; + } + + normalized[key] = clonePlainData(rule[key]); + }); + + return normalized; + } + + function compressSiteRules(siteRules, baseSettings) { + if (!Array.isArray(siteRules)) { + return {}; + } + + var defaultRules = getDefaultSiteRules(); + var defaultRulesByPattern = getDefaultSiteRulesByPattern(); + var currentPatterns = new Set(); + var exportRules = []; + + siteRules.forEach(function (rule) { + if (!rule || typeof rule !== "object" || Array.isArray(rule)) { + return; + } + + var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : ""; + if (pattern) { + currentPatterns.add(pattern); + } + + var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings); + if (!normalizedRule || Object.keys(normalizedRule).length === 1) { + return; + } + + var defaultRule = pattern ? defaultRulesByPattern[pattern] : null; + var normalizedDefaultRule = defaultRule + ? normalizeSiteRuleForDiff(defaultRule, baseSettings) + : null; + if (normalizedDefaultRule) { + if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) { + return; + } + + var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule); + if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) { + defaultRuleDiff.pattern = pattern; + exportRules.push(defaultRuleDiff); + } + return; + } + + exportRules.push(normalizedRule); + }); + + var removedDefaultPatterns = defaultRules + .map(function (rule) { + return rule && typeof rule.pattern === "string" ? rule.pattern : ""; + }) + .filter(function (pattern) { + return pattern && !currentPatterns.has(pattern); + }); + + var result = {}; + if (exportRules.length > 0) { + result.siteRules = exportRules; + result.siteRulesFormat = SITE_RULES_DIFF_FORMAT; + } + if (removedDefaultPatterns.length > 0) { + result.siteRulesMeta = { + removedDefaultPatterns: removedDefaultPatterns + }; + result.siteRulesFormat = SITE_RULES_DIFF_FORMAT; + } + + return result; + } + + function expandSiteRules(siteRules, siteRulesMeta) { + var defaultRules = getDefaultSiteRules(); + var defaultRulesByPattern = getDefaultSiteRulesByPattern(); + if (defaultRules.length === 0) { + return Array.isArray(siteRules) ? clonePlainData(siteRules) : []; + } + + var removedDefaultPatterns = new Set( + siteRulesMeta && Array.isArray(siteRulesMeta.removedDefaultPatterns) + ? siteRulesMeta.removedDefaultPatterns + : [] + ); + var modifiedDefaultRules = Object.create(null); + var customRules = []; + + if (Array.isArray(siteRules)) { + siteRules.forEach(function (rule) { + if (!rule || typeof rule !== "object" || Array.isArray(rule)) { + return; + } + + var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : ""; + if ( + pattern && + Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern) + ) { + modifiedDefaultRules[pattern] = clonePlainData(rule); + return; + } + + customRules.push(clonePlainData(rule)); + }); + } + + var mergedRules = []; + + defaultRules.forEach(function (rule) { + var pattern = rule && typeof rule.pattern === "string" ? rule.pattern : ""; + if (!pattern || removedDefaultPatterns.has(pattern)) { + return; + } + + if (modifiedDefaultRules[pattern]) { + mergedRules.push( + Object.assign( + {}, + clonePlainData(rule), + clonePlainData(modifiedDefaultRules[pattern]) + ) + ); + return; + } + + mergedRules.push(clonePlainData(rule)); + }); + + customRules.forEach(function (rule) { + mergedRules.push(rule); + }); + + return mergedRules; + } + + function buildStoredSettingsDiff(currentSettings) { + var defaults = clonePlainData(DEFAULT_SETTINGS); + var normalized = deepMergeDefaults(defaults, currentSettings || {}); + var siteRuleData = compressSiteRules(normalized.siteRules, normalized); + var diffDefaults = {}; + var diff = {}; + + delete normalized.siteRules; + delete normalized.siteRulesMeta; + delete normalized.siteRulesFormat; + delete normalized.hideWithYouTubeControls; + + if (siteRuleData.siteRules) { + normalized.siteRules = siteRuleData.siteRules; + } + if (siteRuleData.siteRulesMeta) { + normalized.siteRulesMeta = siteRuleData.siteRulesMeta; + } + if (siteRuleData.siteRulesFormat) { + normalized.siteRulesFormat = siteRuleData.siteRulesFormat; + } + + DIFFABLE_OPTION_KEYS.forEach(function (key) { + if (hasOwn(DEFAULT_SETTINGS, key)) { + diffDefaults[key] = clonePlainData(DEFAULT_SETTINGS[key]); + } + if (!hasOwn(normalized, key)) { + return; + } + var valueDiff = deepDiff(normalized[key], diffDefaults[key]); + if (valueDiff !== undefined) { + diff[key] = valueDiff; + } + }); + + return diff; + } + + function expandStoredSettings(storage) { + var raw = clonePlainData(storage) || {}; + var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw); + + if ( + !hasOwn(raw, "hideWithControls") && + hasOwn(raw, "hideWithYouTubeControls") + ) { + expanded.hideWithControls = Boolean(raw.hideWithYouTubeControls); + } + expanded.hideWithYouTubeControls = expanded.hideWithControls; + + if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) { + expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta); + } else if (Array.isArray(raw.siteRules)) { + expanded.siteRules = clonePlainData(raw.siteRules); + } else { + expanded.siteRules = getDefaultSiteRules(); + } + + return expanded; + } + + function escapeStringRegExp(str) { + var matcher = /[|\\{}()[\]^$+*?.]/g; + return String(str).replace(matcher, "\\$&"); + } + + function siteRuleMatchesUrl(rule, currentUrl) { + if (!rule || !rule.pattern || !currentUrl) { + return false; + } + + var pattern = String(rule.pattern).trim(); + if (!pattern) { + return false; + } + + var regex; + if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { + try { + var lastSlash = pattern.lastIndexOf("/"); + regex = new RegExp( + pattern.substring(1, lastSlash), + pattern.substring(lastSlash + 1) + ); + } catch (_error) { + return false; + } + } else { + regex = new RegExp(escapeStringRegExp(pattern)); + } + + return Boolean(regex && regex.test(currentUrl)); + } + + function mergeMatchingSiteRules(currentUrl, siteRules) { + if (!currentUrl || !Array.isArray(siteRules)) { + return null; + } + + var matchedRules = []; + for (var i = 0; i < siteRules.length; i++) { + if (siteRuleMatchesUrl(siteRules[i], currentUrl)) { + matchedRules.push(siteRules[i]); + } + } + + if (!matchedRules.length) { + return null; + } + + var mergedRule = {}; + matchedRules.forEach(function (rule) { + Object.keys(rule).forEach(function (key) { + var value = rule[key]; + if (Array.isArray(value)) { + mergedRule[key] = clonePlainData(value); + return; + } + if (isPlainObject(value)) { + mergedRule[key] = clonePlainData(value); + return; + } + mergedRule[key] = value; + }); + }); + + return mergedRule; + } + + function isSiteRuleDisabled(rule) { + return Boolean( + rule && + ( + rule.enabled === false || + (typeof rule.enabled === "undefined" && rule.disableExtension === true) + ) + ); + } + + global.vscClonePlainData = clonePlainData; + global.vscAreComparableValuesEqual = areComparableValuesEqual; + global.vscDeepMergeDefaults = deepMergeDefaults; + global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff; + global.vscExpandStoredSettings = expandStoredSettings; + global.vscGetSettingsDefaults = function () { + return clonePlainData(DEFAULT_SETTINGS); + }; + global.vscGetManagedSyncKeys = function () { + return MANAGED_SYNC_KEYS.slice(); + }; + global.vscGetSiteRulesDiffFormat = function () { + return SITE_RULES_DIFF_FORMAT; + }; + global.vscMatchSiteRule = mergeMatchingSiteRules; + global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl; + global.vscIsSiteRuleDisabled = isSiteRuleDisabled; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/ui-icons.js b/ui-icons.js index 631cd9e..63ddf1a 100644 --- a/ui-icons.js +++ b/ui-icons.js @@ -3,6 +3,7 @@ * Use stroke="currentColor" so buttons inherit foreground for monochrome UI. */ var VSC_ICON_SIZE_DEFAULT = 18; +var VSC_SVG_NS = "http://www.w3.org/2000/svg"; /** Inner SVG markup only (paths / shapes inside ). */ var vscUiIconPaths = { @@ -54,6 +55,79 @@ function vscIconSvgString(action, size) { ); } +function vscClearElement(el) { + if (!el) return; + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +function vscSanitizeSvgTree(svg) { + if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null; + + svg.querySelectorAll("script, style, foreignObject").forEach(function (n) { + n.remove(); + }); + + svg.querySelectorAll("*").forEach(function (el) { + for (var i = el.attributes.length - 1; i >= 0; i--) { + var attr = el.attributes[i]; + var name = attr.name.toLowerCase(); + var val = attr.value; + if (name.indexOf("on") === 0) { + el.removeAttribute(attr.name); + continue; + } + if ( + (name === "href" || name === "xlink:href") && + /^\s*javascript:/i.test(val) + ) { + el.removeAttribute(attr.name); + } + } + }); + + svg.setAttribute("xmlns", VSC_SVG_NS); + svg.setAttribute("aria-hidden", "true"); + return svg; +} + +function vscCreateSvgNode(doc, svgText) { + if (!doc || !svgText || typeof svgText !== "string") return null; + var clean = String(svgText).replace(/\0/g, "").trim(); + if (!clean || !/]/i.test(clean)) return null; + + var parsed = new DOMParser().parseFromString(clean, "image/svg+xml"); + if (parsed.querySelector("parsererror")) return null; + + var svg = vscSanitizeSvgTree(parsed.querySelector("svg")); + if (!svg) return null; + + return doc.importNode(svg, true); +} + +function vscSetSvgContent(el, svgText) { + if (!el) return false; + vscClearElement(el); + + var doc = el.ownerDocument || document; + var svg = vscCreateSvgNode(doc, svgText); + if (!svg) return false; + + el.appendChild(svg); + return true; +} + +function vscCreateSvgWrap(doc, svgText, className) { + if (!doc) return null; + var span = doc.createElement("span"); + span.className = className || "vsc-btn-icon"; + if (!vscSetSvgContent(span, svgText)) { + return null; + } + return span; +} + /** * @param {Document} doc * @param {string} action @@ -62,8 +136,5 @@ function vscIconSvgString(action, size) { 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; + return vscCreateSvgWrap(doc, html, "vsc-btn-icon"); }