您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Control HTML5 video playback speed with shortcuts and an on-screen controller. Based on codebicycle/videospeed.
// ==UserScript== // @name Video Speed Controller (Userscript) // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description Control HTML5 video playback speed with shortcuts and an on-screen controller. Based on codebicycle/videospeed. // @author Based on codebicycle/videospeed, adapted by [https://github.com/codebicycle/videospeed] // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_log // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; // --- Start: CSS --- const shadowCSS = ` :host { all: initial; /* Isolate from host page CSS */ } #controller { position: fixed; z-index: 2147483647; /* Max possible z-index */ top: 10px; left: 10px; background-color: rgba(0, 0, 0, 0.7); border-radius: 5px; display: flex; align-items: center; justify-content: center; padding: 3px 5px; transition: opacity 0.2s ease-in-out; font-family: sans-serif; font-size: 14px; color: white; cursor: pointer; box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); } #controller.dragging { cursor: grabbing; } #controller.vsc-hidden:not(.vsc-manual) { opacity: 0 !important; /* Override inline style if not manually hidden */ pointer-events: none; } #controller.vsc-nosource { display: none !important; } #controls { display: flex; align-items: center; margin-left: 5px; } span.draggable { padding: 5px 8px; cursor: grab; user-select: none; /* Prevent text selection during drag */ } button { background: none; border: none; color: white; font-size: 16px; font-weight: bold; cursor: pointer; padding: 2px 5px; margin: 0 1px; min-width: 20px; line-height: 1; } button:hover { background-color: rgba(255, 255, 255, 0.2); border-radius: 3px; } button.rw { /* Rewind/Advance buttons */ font-size: 14px; } button.hideButton { font-size: 14px; padding: 2px 4px; } `; const settingsDialogCSS = ` #vscSettingsDialog { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: #f0f0f0; border: 1px solid #ccc; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 2147483647; /* Above controller */ max-height: 80vh; overflow-y: auto; padding: 20px; border-radius: 8px; font-family: sans-serif; font-size: 14px; color: #333; min-width: 450px; max-width: 90vw; } #vscSettingsDialog h3 { margin-top: 0; margin-bottom: 15px; border-bottom: 1px solid #ddd; padding-bottom: 5px; } #vscSettingsDialog .vsc-settings-row { display: flex; align-items: center; margin-bottom: 10px; gap: 10px; } #vscSettingsDialog .vsc-settings-row label { flex-basis: 150px; flex-shrink: 0; text-align: right; font-weight: bold; } #vscSettingsDialog .vsc-settings-row label i { font-weight: normal; font-size: 0.9em; color: #666; display: block; } #vscSettingsDialog input[type="text"], #vscSettingsDialog input[type="number"], #vscSettingsDialog select, #vscSettingsDialog textarea { padding: 5px; border: 1px solid #ccc; border-radius: 3px; flex-grow: 1; } #vscSettingsDialog input[type="checkbox"] { margin-left: 5px; } #vscSettingsDialog textarea { min-height: 100px; resize: vertical; } #vscSettingsDialog .vsc-keybinding-row { display: grid; grid-template-columns: 150px 100px 80px 1fr auto; gap: 5px; margin-bottom: 5px; align-items: center; } #vscSettingsDialog .vsc-keybinding-row select, #vscSettingsDialog .vsc-keybinding-row input { width: 100%; box-sizing: border-box; } #vscSettingsDialog button { padding: 8px 15px; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; margin-right: 5px; } #vscSettingsDialog button.vsc-save { background-color: #4CAF50; color: white; border-color: #4CAF50; } #vscSettingsDialog button.vsc-restore { background-color: #ff9800; color: white; border-color: #ff9800; } #vscSettingsDialog button.vsc-close { background-color: #f44336; color: white; border-color: #f44336;} #vscSettingsDialog button.vsc-remove-binding { background-color: #ddd; color: #333; border-color: #ccc; padding: 2px 5px; font-size: 12px;} #vscSettingsDialog .vsc-settings-actions { margin-top: 20px; text-align: right; } #vscSettingsDialog #vscStatus { margin-top: 10px; color: green; font-weight: bold; } `; // --- End: CSS --- var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; var tcDefaults = { lastSpeed: 1.0, enabled: true, speeds: {}, // Holds speed per source URL (if rememberSpeed is on) rememberSpeed: false, forceLastSavedSpeed: false, audioBoolean: false, // Process <audio> tags startHidden: false, controllerOpacity: 0.3, keyBindings: [ { action: "display", key: 86, value: 0, force: false, predefined: true }, // V { action: "slower", key: 83, value: 0.1, force: false, predefined: true }, // S { action: "faster", key: 68, value: 0.1, force: false, predefined: true }, // D { action: "rewind", key: 90, value: 10, force: false, predefined: true }, // Z { action: "advance", key: 88, value: 10, force: false, predefined: true }, // X { action: "reset", key: 82, value: 1.0, force: false, predefined: true }, // R - special handling: toggles between 1.0 and 'fast' speed { action: "fast", key: 71, value: 1.8, force: false, predefined: true } // G - the 'preferred' speed for reset toggle // Users can add more, e.g., pause, mute, mark, jump ], blacklist: `\ www.instagram.com twitter.com vine.co imgur.com teams.microsoft.com `.replace(regStrip, ""), defaultLogLevel: 4, // 1=none, 2=error, 3=warn, 4=info, 5=debug, 6=verbose+trace logLevel: 3 // Default level }; var tc = { settings: {}, // Loaded settings will go here mediaElements: [], // Attached video/audio elements videoController: null, // Will hold the constructor coolDown: false // For ratechange event handler }; /* Log levels (depends on caller specifying the correct level) 1 - none, 2 - error, 3 - warning, 4 - info, 5 - debug, 6 - verbose+trace */ function log(message, level) { const verbosity = tc.settings.logLevel || tcDefaults.logLevel; level = level || tc.settings.defaultLogLevel || tcDefaults.defaultLogLevel; if (verbosity >= level) { let prefix = ""; switch(level) { case 2: prefix = "ERROR:"; break; case 3: prefix = "WARNING:"; break; case 4: prefix = "INFO:"; break; case 5: prefix = "DEBUG:"; break; case 6: prefix = "DEBUG(V):"; break; } if (typeof GM_log === 'function') { GM_log(prefix + message); // Use GM_log if available } else { console.log("VSC: " + prefix + message); } if (level === 6) console.trace(); } } // --- KeyCode Utilities (from options.js) --- var keyCodeAliases = { /* ... (same as in your options.js) ... */ 0: "null", null: "null", undefined: "null", 32: "Space", 37: "Left", 38: "Up", 39: "Right", 40: "Down", 96: "Num 0", 97: "Num 1", 98: "Num 2", 99: "Num 3", 100: "Num 4", 101: "Num 5", 102: "Num 6", 103: "Num 7", 104: "Num 8", 105: "Num 9", 106: "Num *", 107: "Num +", 109: "Num -", 110: "Num .", 111: "Num /", 112: "F1", 113: "F2", 114: "F3", 115: "F4", 116: "F5", 117: "F6", 118: "F7", 119: "F8", 120: "F9", 121: "F10", 122: "F11", 123: "F12", 186: ";", 188: "<", 189: "-", 187: "+", 190: ">", 191: "/", 192: "~", 219: "[", 220: "\\", 221: "]", 222: "'", 59: ";", 61: "+", 173: "-" }; var customActionsNoValues = ["pause", "muted", "mark", "jump", "display"]; function recordKeyPress(e) { /* ... (same as in your options.js) ... */ if ((e.keyCode >= 48 && e.keyCode <= 57) || (e.keyCode >= 65 && e.keyCode <= 90) || keyCodeAliases[e.keyCode]) { e.target.value = keyCodeAliases[e.keyCode] || String.fromCharCode(e.keyCode); e.target.keyCode = e.keyCode; e.preventDefault(); e.stopPropagation(); } else if (e.keyCode === 8) { e.target.value = ""; e.target.keyCode = null; } else if (e.keyCode === 27) { e.target.value = "null"; e.target.keyCode = null; } } function inputFilterNumbersOnly(e) { /* ... (same as in your options.js) ... */ var char = String.fromCharCode(e.keyCode); if (!/[\d\.]$/.test(char) || !/^\d+(\.\d*)?$/.test(e.target.value + char)) { e.preventDefault(); e.stopPropagation(); } } function inputFocus(e) { e.target.value = ""; } function inputBlur(e) { e.target.value = keyCodeAliases[e.target.keyCode] || String.fromCharCode(e.target.keyCode) || 'press a key'; } // --- End KeyCode Utilities --- async function loadSettings() { log("Loading settings...", 5); const storedSettings = await GM_getValue('vscSettings', {}); // Deep merge defaults and stored settings (simple version) tc.settings = { ...tcDefaults }; // Start with defaults for (const key in tcDefaults) { if (storedSettings.hasOwnProperty(key)) { // Special handling for potentially complex types like keyBindings if (key === 'keyBindings' && Array.isArray(storedSettings[key])) { tc.settings[key] = JSON.parse(JSON.stringify(storedSettings[key])); // Deep copy array } else if (key === 'speeds' && typeof storedSettings[key] === 'object') { tc.settings[key] = { ...storedSettings[key] }; // Copy speeds object } else if (typeof storedSettings[key] === typeof tcDefaults[key] || storedSettings[key] == null) { // Basic type match or stored is null/undefined -> use stored tc.settings[key] = storedSettings[key]; } // Otherwise, keep the default (type mismatch?) - could log a warning } } // Ensure essential structure exists (especially after updates) tc.settings.keyBindings = tc.settings.keyBindings || []; tc.settings.speeds = tc.settings.speeds || {}; // Upgrade/Fix: Ensure 'display' keybinding exists if (!tc.settings.keyBindings.some(b => b.action === 'display')) { log("Adding missing 'display' keybinding", 3); tc.settings.keyBindings.push({ action: "display", key: 86, value: 0, force: false, predefined: true }); // Default V } // Convert relevant settings to correct types (GM_getValue might return strings) tc.settings.lastSpeed = Number(tc.settings.lastSpeed) || tcDefaults.lastSpeed; tc.settings.enabled = tc.settings.enabled === true; // Force boolean tc.settings.rememberSpeed = tc.settings.rememberSpeed === true; tc.settings.forceLastSavedSpeed = tc.settings.forceLastSavedSpeed === true; tc.settings.audioBoolean = tc.settings.audioBoolean === true; tc.settings.startHidden = tc.settings.startHidden === true; tc.settings.controllerOpacity = Number(tc.settings.controllerOpacity) || tcDefaults.controllerOpacity; tc.settings.logLevel = Number(tc.settings.logLevel) || tcDefaults.logLevel; tc.settings.blacklist = String(tc.settings.blacklist || tcDefaults.blacklist); // Clean up keybindings (ensure numbers) tc.settings.keyBindings.forEach(b => { b.key = Number(b.key); b.value = Number(b.value); b.force = String(b.force) === 'true'; // Force boolean from string/bool b.predefined = b.predefined === true; }); log("Settings loaded.", 4); // GM_log(JSON.stringify(tc.settings, null, 2)); // Debug: Log loaded settings } async function saveSettings() { log("Saving settings...", 5); // Ensure types are correct before saving const settingsToSave = { ...tc.settings, lastSpeed: Number(tc.settings.lastSpeed), enabled: Boolean(tc.settings.enabled), rememberSpeed: Boolean(tc.settings.rememberSpeed), forceLastSavedSpeed: Boolean(tc.settings.forceLastSavedSpeed), audioBoolean: Boolean(tc.settings.audioBoolean), startHidden: Boolean(tc.settings.startHidden), controllerOpacity: Number(tc.settings.controllerOpacity), blacklist: String(tc.settings.blacklist).replace(regStrip, ""), logLevel: Number(tc.settings.logLevel), keyBindings: tc.settings.keyBindings.map(b => ({ action: String(b.action), key: Number(b.key) || null, // Store null if no key value: Number(b.value) || 0, force: String(b.force), // Store as string 'true'/'false' predefined: Boolean(b.predefined) })) // Note: tc.settings.speeds is managed internally by the controller/ratechange, // but we might want to persist it IF rememberSpeed is true long-term. // For simplicity here, we are NOT saving the 'speeds' map. It relies on lastSpeed. }; // Remove the potentially large 'speeds' map before saving general settings delete settingsToSave.speeds; delete settingsToSave.defaultLogLevel; // Don't save defaults await GM_setValue('vscSettings', settingsToSave); log("Settings saved.", 4); } function getKeyBindings(action, what = "value") { // Find function remains useful try { const binding = tc.settings.keyBindings.find((item) => item.action === action); return binding ? binding[what] : undefined; // Return undefined if not found } catch (e) { log("Error getting key binding for action '" + action + "': " + e, 2); return undefined; } } function setKeyBindings(action, value, what = "value") { // Find and update function remains useful try { const binding = tc.settings.keyBindings.find((item) => item.action === action); if (binding) { binding[what] = value; } else { log("Could not find key binding for action '" + action + "' to set " + what, 3); } } catch (e) { log("Error setting key binding for action '" + action + "': " + e, 2); } } function defineVideoController() { // --- This function is largely the same as in inject.js --- // Key differences: // - CSS is embedded in shadowTemplate // - GM_addStyle is not needed here (used elsewhere for settings dialog) // - chrome.runtime.getURL is removed tc.videoController = function (target, parent) { if (target.vsc) { return target.vsc; } log("Attaching controller to: " + (target.currentSrc || target.src || target.tagName), 5); tc.mediaElements.push(target); this.video = target; this.parent = target.parentElement || parent; // --- Speed initialization logic (slightly modified) --- let currentSpeed = tc.settings.lastSpeed; // Start with global last speed if (tc.settings.rememberSpeed && tc.settings.speeds && target.currentSrc && tc.settings.speeds[target.currentSrc]) { // If remembering and we have a specific speed for this URL, use it currentSpeed = tc.settings.speeds[target.currentSrc]; log(`Recalling stored speed for ${target.currentSrc}: ${currentSpeed}`, 5); } else if (!tc.settings.rememberSpeed) { // If not remembering speed, always reset to 1.0 unless forced if (!tc.settings.forceLastSavedSpeed) { currentSpeed = 1.0; log("Setting speed to 1.0 (rememberSpeed false, forceLastSavedSpeed false)", 5); } else { log(`Using last saved speed ${tc.settings.lastSpeed} (rememberSpeed false, forceLastSavedSpeed true)`, 5); } } else { log(`Using global last speed ${tc.settings.lastSpeed} (rememberSpeed true, no specific URL speed)`, 5); } // --- End Speed initialization --- log("Applying initial playbackRate: " + currentSpeed, 4); target.playbackRate = currentSpeed; // Apply the determined speed this.div = this.initializeControls(currentSpeed); // Pass speed for initial display var mediaEventAction = (event) => { // Simplified event action: On play/seek, re-apply the *current* speed setting. // The ratechange listener handles the actual updates and persistence. // This prevents sites that reset speed on play/seek from breaking things. const targetSpeed = target.vsc?.speedIndicator?.textContent ? Number(target.vsc.speedIndicator.textContent) : tc.settings.lastSpeed; log(`"${event.type}" event detected. Re-applying speed: ${targetSpeed}`, 5); // Use setSpeed to ensure consistency and events if needed setSpeed(event.target, targetSpeed); }; target.addEventListener("play", this.handlePlay = mediaEventAction.bind(this)); target.addEventListener("seeked", this.handleSeek = mediaEventAction.bind(this)); // Use 'seeked' not 'seek' // --- MutationObserver for src changes --- var observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === "attributes" && (mutation.attributeName === "src" || mutation.attributeName === "currentSrc")) { log("Mutation observer: src/currentSrc changed on video element.", 5); var controllerDiv = this.div; // The outer wrapper div if (!mutation.target.src && !mutation.target.currentSrc) { controllerDiv.classList.add("vsc-nosource"); } else { controllerDiv.classList.remove("vsc-nosource"); // Optionally re-apply speed for the new source let newSrcSpeed = tc.settings.lastSpeed; if (tc.settings.rememberSpeed && tc.settings.speeds && mutation.target.currentSrc && tc.settings.speeds[mutation.target.currentSrc]) { newSrcSpeed = tc.settings.speeds[mutation.target.currentSrc]; } else if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) { newSrcSpeed = 1.0; } log(`Applying speed ${newSrcSpeed} for new source: ${mutation.target.currentSrc}`, 4); setSpeed(mutation.target, newSrcSpeed); } } }); }); observer.observe(target, { attributeFilter: ["src", "currentSrc"] }); target.vscObserver = observer; // Store observer for cleanup }; tc.videoController.prototype.remove = function () { log("Removing controller from: " + (this.video.currentSrc || this.video.src || this.video.tagName), 5); if (this.div) this.div.remove(); this.video.removeEventListener("play", this.handlePlay); this.video.removeEventListener("seeked", this.handleSeek); if (this.video.vscObserver) this.video.vscObserver.disconnect(); let idx = tc.mediaElements.indexOf(this.video); if (idx != -1) { tc.mediaElements.splice(idx, 1); } delete this.video.vscObserver; delete this.video.vsc; // Remove back-reference }; tc.videoController.prototype.initializeControls = function (initialSpeed) { log("initializeControls Begin", 5); const document = this.video.ownerDocument; const speed = Number(initialSpeed).toFixed(2); // Use passed initial speed var top = "10px"; // Use fixed position now var left = "10px"; // Use fixed position now log("Initial speed indicator: " + speed, 5); var wrapper = document.createElement("div"); wrapper.classList.add("vsc-controller"); // Outer wrapper for positioning/visibility if (!this.video.src && !this.video.currentSrc) { wrapper.classList.add("vsc-nosource"); } if (tc.settings.startHidden) { wrapper.classList.add("vsc-hidden"); } var shadow = wrapper.attachShadow({ mode: "open" }); var shadowTemplate = ` <style> ${shadowCSS} /* Embed CSS here */ </style> <div id="controller" style="opacity:${tc.settings.controllerOpacity}"> <span data-action="drag" class="draggable" title="Drag to move">${speed}</span> <span id="controls"> <button data-action="rewind" class="rw" title="Rewind (${getKeyBindings('rewind', 'value')}s)">«</button> <button data-action="slower" title="Slower (by ${getKeyBindings('slower', 'value')})">−</button> <button data-action="faster" title="Faster (by ${getKeyBindings('faster', 'value')})">+</button> <button data-action="advance" class="rw" title="Advance (${getKeyBindings('advance', 'value')}s)">»</button> <button data-action="display" class="hideButton" title="Show/Hide Controller">×</button> </span> </div> `; shadow.innerHTML = shadowTemplate; this.speedIndicator = shadow.querySelector("span.draggable"); // Correct selector const controllerElement = shadow.querySelector("#controller"); // --- Event Listeners for Shadow DOM elements --- this.speedIndicator.addEventListener("mousedown", (e) => { if (e.button === 0) { // Only drag on left-click runAction(e.target.dataset["action"], false, e, this.video); e.stopPropagation(); } }, true); shadow.querySelectorAll("button").forEach((button) => { button.addEventListener("click", (e) => { const action = e.target.dataset["action"]; // Pass the specific video element this controller belongs to runAction(action, getKeyBindings(action), e, this.video); e.stopPropagation(); }, true); }); controllerElement.addEventListener("click", e => e.stopPropagation(), false); controllerElement.addEventListener("mousedown", e => e.stopPropagation(), false); // --- End Event Listeners --- var fragment = document.createDocumentFragment(); fragment.appendChild(wrapper); // Simplified insertion: Use document.body or a high-level container. // The fixed positioning makes the exact parent less critical, but stacking context might matter. // Appending to body is usually safest for fixed elements. if (document.body) { document.body.appendChild(fragment); } else { // Fallback if body isn't ready? Unlikely with @run-at document-idle document.documentElement.appendChild(fragment); } return wrapper; // Return the outer wrapper }; } // end defineVideoController function escapeStringRegExp(str) { const matchOperatorsRe = /[|\\{}()[\]^$+*?.]/g; return str.replace(matchOperatorsRe, "\\$&"); } function isBlacklisted() { let blacklisted = false; const lines = tc.settings.blacklist?.split("\n") || []; const currentHref = location.href; lines.forEach((match) => { match = match.replace(regStrip, ""); if (match.length === 0) return; try { let regexp; if (match.startsWith("/") && match.endsWith("/")) { // Basic regex detection (might need improvement for flags) regexp = new RegExp(match.slice(1, -1)); } else { // Treat as plain string, escape for regex regexp = new RegExp(escapeStringRegExp(match)); } if (regexp.test(currentHref)) { blacklisted = true; log(`Current URL ${currentHref} matches blacklist pattern: ${match}`, 4); } } catch (err) { log(`Invalid blacklist pattern: ${match}. Error: ${err}`, 2); } }); return blacklisted; } function refreshCoolDown() { // log("Begin refreshCoolDown", 6); // Usually too verbose if (tc.coolDown) { clearTimeout(tc.coolDown); } tc.coolDown = setTimeout(() => { tc.coolDown = false; }, 50); // Shorter cooldown? 1000ms seems long. // log("End refreshCoolDown", 6); } function setupListeners() { // --- ratechange listener --- document.addEventListener("ratechange", (event) => { const video = event.target; // Ignore if no controller attached OR if cooldown active if (!video.vsc || tc.coolDown) { if (tc.coolDown) log("Rate change event blocked by cooldown", 6); return; } const currentRate = Number(video.playbackRate.toFixed(2)); const lastTrackedSpeed = tc.settings.lastSpeed; const origin = event.detail?.origin; // Check for our custom event origin log(`Rate change detected. New rate: ${currentRate}, Origin: ${origin || 'browser/site'}, Forced: ${tc.settings.forceLastSavedSpeed}`, 5); if (tc.settings.forceLastSavedSpeed) { if (origin === "videoSpeedController") { // Event originated from us, update everything updateSpeedFromEvent(video, currentRate); } else { // Event originated elsewhere, force speed back log(`Force mode: Rate changed externally to ${currentRate}, reverting to ${lastTrackedSpeed}`, 4); // Re-apply speed *without* triggering cooldown/loop video.playbackRate = lastTrackedSpeed; // No updateSpeedFromEvent here, as we want the UI to reflect the forced speed event.stopImmediatePropagation(); // Prevent further listeners } } else { // Not forcing speed, just update based on the event if (video.vsc.speedIndicator && Number(video.vsc.speedIndicator.textContent) !== currentRate) { updateSpeedFromEvent(video, currentRate); } else if (!video.vsc.speedIndicator) { log("Rate change on video without speed indicator?", 3); } } }, true); // Use capture phase // --- keydown listener --- document.addEventListener("keydown", (event) => { const keyCode = event.keyCode; log("Keydown event: " + keyCode, 6); // Ignore if modifier keys are pressed (Alt, Ctrl, Meta, etc.) if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { // Added Shift check - often used by sites // Allow Shift+Key combos if needed? Maybe check specific bindings later. // log("Keydown ignored due to modifier key.", 6); return; } // Ignore if typing in input fields/contentEditable const target = event.target; if (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable) { log("Keydown ignored in input field.", 6); return; } // Ignore if no media elements are being controlled on the page if (!tc.mediaElements.length) { // log("Keydown ignored, no controlled media elements.", 6); return; } // Find matching key binding const binding = tc.settings.keyBindings.find(item => item.key === keyCode); if (binding) { log(`Matched key binding: Action=${binding.action}, Value=${binding.value}, Force=${binding.force}`, 5); // Run action on ALL controlled media elements by default runAction(binding.action, binding.value, event); if (binding.force) { log("Forcing keybind, stopping propagation.", 5); event.preventDefault(); event.stopPropagation(); } } }, true); // Use capture phase } // end setupListeners function updateSpeedFromEvent(video, newSpeed) { if (!video.vsc) return; // Should not happen if called correctly newSpeed = Number(newSpeed.toFixed(2)); // Ensure 2 decimal places const speedIndicator = video.vsc.speedIndicator; const src = video.currentSrc; log(`Updating speed indicator to ${newSpeed}`, 5); if (speedIndicator) { speedIndicator.textContent = newSpeed.toFixed(2); } tc.settings.lastSpeed = newSpeed; // Update global last speed if (tc.settings.rememberSpeed && src) { log(`Storing speed ${newSpeed} for src: ${src}`, 5); tc.settings.speeds[src] = newSpeed; // Update speed for this specific source } // Persist global lastSpeed (throttled saving might be better) saveSettings(); // Consider debouncing this for performance // Blink controller momentarily runAction("blink", 1000, null, video); // Blink only the specific video's controller } function initializeWhenReady(doc) { log("initializeWhenReady called", 5); // Blacklist check moved here - crucial first step if (isBlacklisted()) { log("Page is blacklisted. Aborting initialization.", 4); return; } // Enabled check if (!tc.settings.enabled) { log("Extension is disabled. Aborting initialization.", 4); return; } // Wait for document readiness if (doc.readyState === "complete" || doc.readyState === "interactive") { log("Document ready, initializing now.", 5); initializeNow(doc); } else { log("Document not ready, adding readystatechange listener.", 5); doc.addEventListener('readystatechange', () => { if (doc.readyState === "complete") { log("Document reached complete state, initializing now.", 5); initializeNow(doc); } }, { once: true }); // Use once option } } function inIframe() { try { return window.self !== window.top; } catch (e) { return true; } // Assume iframe if top access fails } // Helper to find elements including shadow DOM (basic version) function querySelectorAllIncludingShadows(selector, root = document.body) { const results = Array.from(root.querySelectorAll(selector)); const elementsWithShadow = root.querySelectorAll('*'); elementsWithShadow.forEach(el => { if (el.shadowRoot) { results.push(...querySelectorAllIncludingShadows(selector, el.shadowRoot)); } }); return results; } var initialized = false; // Prevent multiple initializations function initializeNow(doc) { log("initializeNow Begin", 5); if (initialized || !doc.body || doc.body.classList.contains("vsc-initialized")) { log(`Initialization skipped: ${initialized ? 'already run' : !doc.body ? 'no body' : 'body flag set'}`, 5); return; } initialized = true; // Set flag early doc.body.classList.add("vsc-initialized"); // Mark as initialized try { // Define the controller constructor if it hasn't been defined yet // This usually happens only once for the top-level document if (!tc.videoController) { defineVideoController(); setupListeners(); // Setup global listeners once } } catch(e) { log("Error defining controller or setting up listeners: " + e, 2); return; // Critical error, stop initialization } // --- Find and attach controllers to existing media --- const mediaSelector = tc.settings.audioBoolean ? 'video, audio' : 'video'; // Use a function that can potentially pierce shadow DOM if necessary // const mediaTags = querySelectorAllIncludingShadows(mediaSelector, doc); // More robust but slower const mediaTags = doc.querySelectorAll(mediaSelector); // Standard way log(`Found ${mediaTags.length} initial media elements matching selector "${mediaSelector}"`, 4); mediaTags.forEach(media => { if (!media.vsc) { // Check if controller not already attached try { media.vsc = new tc.videoController(media, media.parentElement); } catch (e) { log(`Error creating controller for media element: ${e}`, 2); } } }); // --- Setup MutationObserver to detect dynamically added media --- const observer = new MutationObserver((mutations) => { // Debounce or throttle observer callback if performance is an issue mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if it's an element findAndAddControllers(node, mutation.target); } }); mutation.removedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { findAndRemoveControllers(node); } }); // Handling attribute changes (like aria-hidden) is complex, omitted for simplicity here // but could be added back if needed for specific sites like the original code did. }); }); observer.observe(doc.body || doc.documentElement, { childList: true, subtree: true }); log("MutationObserver initialized to watch for new media elements.", 4); // --- Handle IFrames --- // Recurse into iframes only if not already in an iframe (or handle permissions) if (!inIframe()) { // Only the top level window should scan for iframes const frameTags = doc.getElementsByTagName("iframe"); log(`Scanning ${frameTags.length} iframes.`, 5); Array.from(frameTags).forEach((frame) => { try { const childDocument = frame.contentDocument; if (childDocument) { // Pass the settings down? Or assume iframe runs its own instance? // Running own instance is simpler for Userscripts. // However, need to be careful not to double-initialize if script runs in iframe too. log(`Attempting to initialize in iframe: ${frame.src || '(no src)'}`, 5); initializeWhenReady(childDocument); // Recurse } } catch (e) { // Log cross-origin iframe access error only once or at lower level // log(`Cannot access contentDocument of iframe: ${frame.src || '(no src)'}. Error: ${e}`, 6); } }); } log("initializeNow End", 5); } // end initializeNow // Helper function for MutationObserver to find media in added nodes function findAndAddControllers(node, parent) { const mediaSelector = tc.settings.audioBoolean ? 'video, audio' : 'video'; if (node.matches && node.matches(mediaSelector)) { if (!node.vsc) { log(`Dynamically added media found: ${node.tagName}`, 5); try { node.vsc = new tc.videoController(node, parent); } catch (e) { log(`Error creating controller for dynamic media: ${e}`, 2); } } } else if (node.querySelectorAll) { // Check descendants node.querySelectorAll(mediaSelector).forEach(media => { if (!media.vsc) { log(`Dynamically added media found (descendant): ${media.tagName}`, 5); try { media.vsc = new tc.videoController(media, media.parentElement); } catch (e) { log(`Error creating controller for dynamic descendant media: ${e}`, 2); } } }); } } // Helper function for MutationObserver to clean up controllers from removed nodes function findAndRemoveControllers(node) { const mediaSelector = tc.settings.audioBoolean ? 'video, audio' : 'video'; if (node.matches && node.matches(mediaSelector)) { if (node.vsc) { log(`Media element removed, cleaning up controller: ${node.tagName}`, 5); node.vsc.remove(); } } else if (node.querySelectorAll) { // Check descendants node.querySelectorAll(mediaSelector).forEach(media => { if (media.vsc) { log(`Descendant media element removed, cleaning up controller: ${media.tagName}`, 5); media.vsc.remove(); } }); } } function setSpeed(video, speed) { log(`setSpeed called: ${speed}`, 5); const speedValue = Number(speed.toFixed(2)); if (isNaN(speedValue)) { log(`Invalid speed value: ${speed}`, 2); return; } // Use a custom event to signal the change *originated* from the controller // This helps the ratechange listener distinguish our changes from external ones when forceLastSavedSpeed is on. const eventDetail = { detail: { origin: "videoSpeedController", speed: speedValue } }; const rateChangeEvent = new CustomEvent("ratechange", eventDetail); log(`Dispatching ratechange event with origin. Speed: ${speedValue}`, 6); video.dispatchEvent(rateChangeEvent); // Dispatch *before* setting rate if !forceLastSavedSpeed? Order matters. // Actually set the playback rate // Check if the rate is already correct to avoid unnecessary changes/event loops if (video.playbackRate !== speedValue) { video.playbackRate = speedValue; log(`Set video.playbackRate to: ${speedValue}`, 5); refreshCoolDown(); // Apply cooldown *after* setting rate } else { log(`Video.playbackRate already ${speedValue}, no change needed.`, 6); } // Update UI indicator (redundant if ratechange handler works, but safe fallback) if (video.vsc && video.vsc.speedIndicator) { video.vsc.speedIndicator.textContent = speedValue.toFixed(2); } // Update global/local speed tracking (also done in ratechange, maybe debounce?) tc.settings.lastSpeed = speedValue; if (tc.settings.rememberSpeed && video.currentSrc) { tc.settings.speeds[video.currentSrc] = speedValue; } // saveSettings(); // Avoid saving on every single speed change - do it in ratechange or debounced. log(`setSpeed finished for: ${speedValue}`, 5); } function runAction(action, value, event, specificVideo = null) { // log(`runAction Begin: Action=${action}, Value=${value}`, 5); // Determine target elements: either a specific video or all controlled elements const targetMedia = specificVideo ? [specificVideo] : tc.mediaElements; if (!targetMedia || targetMedia.length === 0) { log("runAction: No target media elements found.", 3); return; } targetMedia.forEach((v) => { if (!v || !v.vsc || !v.vsc.div) { log(`runAction: Skipping element without valid controller. ${v ? v.tagName : 'null'}`, 6); return; // Skip if controller isn't properly initialized } const controllerDiv = v.vsc.div; // The outer wrapper div // Show controller temporarily on most actions (except drag/blink itself) if (action !== 'drag' && action !== 'blink' && action !== 'display') { showControllerTemporarily(controllerDiv); } // Skip action if the video source is invalid (e.g., element removed but not cleaned up yet) if (controllerDiv.classList.contains("vsc-nosource")) { log("runAction: Skipping action on element with no source.", 6); return; } try { switch (action) { case "rewind": log(`Rewind by ${value}s`, 5); v.currentTime = Math.max(0, v.currentTime - value); break; case "advance": log(`Advance by ${value}s`, 5); v.currentTime = Math.min(v.duration || Infinity, v.currentTime + value); break; case "faster": log(`Increase speed by ${value}`, 5); const nextFasterSpeed = Math.min((v.playbackRate < 0.1 ? 0.0 : v.playbackRate) + value, 16); setSpeed(v, nextFasterSpeed); break; case "slower": log(`Decrease speed by ${value}`, 5); const nextSlowerSpeed = Math.max(v.playbackRate - value, 0.07); // Chrome min is ~0.0625 setSpeed(v, nextSlowerSpeed); break; case "reset": // Special toggle logic log("Reset/Toggle speed", 5); resetSpeed(v); // Uses internal logic based on current/fast/1.0 speeds break; case "fast": // Set to preferred fast speed directly log(`Set preferred speed to ${value}`, 5); setSpeed(v, value); break; case "display": log("Toggle controller display", 5); controllerDiv.classList.toggle("vsc-hidden"); controllerDiv.classList.toggle("vsc-manual", controllerDiv.classList.contains("vsc-hidden")); // Mark manual hide/show break; case "blink": log("Blink controller", 5); blinkController(controllerDiv, value || 1000); break; case "drag": if (event) handleDrag(v, event); break; case "pause": log(v.paused ? "Play" : "Pause", 5); if (v.paused) v.play(); else v.pause(); break; case "muted": log(v.muted ? "Unmute" : "Mute", 5); v.muted = !v.muted; break; case "mark": log(`Set marker at ${v.currentTime.toFixed(1)}s`, 5); v.vsc.mark = v.currentTime; break; case "jump": if (v.vsc.mark && typeof v.vsc.mark === "number") { log(`Jump to marker at ${v.vsc.mark.toFixed(1)}s`, 5); v.currentTime = v.vsc.mark; } else { log("Jump action failed: No marker set", 3); } break; default: log(`Unknown action: ${action}`, 3); } } catch (e) { log(`Error executing action "${action}" on video: ${e}`, 2); } }); // end forEach media element // log("runAction End", 5); } // end runAction function resetSpeed(v) { const currentSpeed = v.playbackRate; const fastSpeed = getKeyBindings("fast", "value") || tcDefaults.keyBindings.find(b=>b.action==='fast').value; // Ensure fallback const resetSpeedTarget = 1.0; // Standard reset is always 1.0 if (currentSpeed === resetSpeedTarget) { // If currently at 1.0, toggle to 'fast' speed log(`Toggling speed from ${resetSpeedTarget} to fast speed ${fastSpeed}`, 4); setSpeed(v, fastSpeed); } else { // Otherwise, reset to 1.0 log(`Resetting speed from ${currentSpeed} to ${resetSpeedTarget}`, 4); setSpeed(v, resetSpeedTarget); } } function handleDrag(video, e) { const controller = video.vsc.div; // The outer wrapper const shadowController = controller.shadowRoot.querySelector("#controller"); // The element inside shadow DOM if (!controller || !shadowController) return; controller.classList.add("vcs-dragging"); // Use a class on the *outer* element for potential global styles shadowController.classList.add("dragging"); // Class within shadow DOM for styling/cursor const initialMouseX = e.clientX; const initialMouseY = e.clientY; // Get initial position relative to viewport const rect = shadowController.getBoundingClientRect(); const initialControllerX = rect.left; const initialControllerY = rect.top; const startDragging = (moveEvent) => { let dx = moveEvent.clientX - initialMouseX; let dy = moveEvent.clientY - initialMouseY; // Calculate new top-left based on viewport coordinates let newX = initialControllerX + dx; let newY = initialControllerY + dy; // Basic boundary checks (optional) newX = Math.max(0, Math.min(newX, window.innerWidth - shadowController.offsetWidth)); newY = Math.max(0, Math.min(newY, window.innerHeight - shadowController.offsetHeight)); shadowController.style.left = newX + "px"; shadowController.style.top = newY + "px"; shadowController.style.right = 'auto'; // Ensure right/bottom aren't interfering shadowController.style.bottom = 'auto'; }; const stopDragging = () => { document.removeEventListener("mousemove", startDragging); document.removeEventListener("mouseup", stopDragging); document.removeEventListener("mouseleave", stopDragging); // In case mouse leaves window controller.classList.remove("vcs-dragging"); shadowController.classList.remove("dragging"); log(`Drag ended. Final position: left=${shadowController.style.left}, top=${shadowController.style.top}`, 5); // TODO: Persist the position? This would require adding properties to settings and saving/loading them. // For simplicity, position is not saved in this version. }; // Add listeners to the document to capture mouse movements anywhere document.addEventListener("mousemove", startDragging); document.addEventListener("mouseup", stopDragging); document.addEventListener("mouseleave", stopDragging); // Handle mouse leaving the window } var controllerTimers = new Map(); // Use a Map to track timers per controller function showControllerTemporarily(controllerDiv, duration = 2000) { if (!controllerDiv) return; // log("Showing controller temporarily", 6); controllerDiv.classList.add("vcs-show"); // Maybe use opacity transition instead of a class? const existingTimer = controllerTimers.get(controllerDiv); if (existingTimer) clearTimeout(existingTimer); const timer = setTimeout(() => { controllerDiv.classList.remove("vcs-show"); controllerTimers.delete(controllerDiv); // log("Hiding controller after timeout", 6); }, duration); controllerTimers.set(controllerDiv, timer); } function blinkController(controllerDiv, duration = 1000) { if (!controllerDiv) return; const wasHidden = controllerDiv.classList.contains("vsc-hidden"); const existingTimer = controllerTimers.get(controllerDiv); // Use the same map as showControllerTemporarily if (existingTimer) clearTimeout(existingTimer); controllerDiv.classList.remove("vsc-hidden"); // Ensure it's visible const timer = setTimeout(() => { // Only re-hide if it was originally hidden and not manually shown if (wasHidden && !controllerDiv.classList.contains("vsc-manual")) { controllerDiv.classList.add("vsc-hidden"); } controllerTimers.delete(controllerDiv); }, duration); controllerTimers.set(controllerDiv, timer); } // --- Settings Dialog --- function showSettingsDialog() { let dialog = document.getElementById('vscSettingsDialog'); if (dialog) { dialog.style.display = 'block'; // Show if already exists return; } GM_addStyle(settingsDialogCSS); // Inject CSS if not already done dialog = document.createElement('div'); dialog.id = 'vscSettingsDialog'; let contentHTML = ` <h3>Video Speed Controller Settings</h3> <div id="vscSettingsContent"> <h4>General</h4> <div class="vsc-settings-row"> <label for="vscEnabled">Enable Extension:</label> <input type="checkbox" id="vscEnabled"> </div> <div class="vsc-settings-row"> <label for="vscRememberSpeed">Remember Playback Speed:</label> <input type="checkbox" id="vscRememberSpeed"> </div> <div class="vsc-settings-row"> <label for="vscForceLastSavedSpeed">Force Last Saved Speed:<br><i>Overrides website speed changes</i></label> <input type="checkbox" id="vscForceLastSavedSpeed"> </div> <div class="vsc-settings-row"> <label for="vscAudioBoolean">Enable on Audio Elements:</label> <input type="checkbox" id="vscAudioBoolean"> </div> <div class="vsc-settings-row"> <label for="vscStartHidden">Hide Controller By Default:</label> <input type="checkbox" id="vscStartHidden"> </div> <div class="vsc-settings-row"> <label for="vscControllerOpacity">Controller Opacity (0.0-1.0):</label> <input type="number" id="vscControllerOpacity" step="0.1" min="0" max="1"> </div> <h4>Shortcuts</h4> <div id="vscKeyBindingsContainer"> <!-- Keybindings will be added here --> </div> <button id="vscAddBindingBtn">+ Add Shortcut</button> <h4>Blacklist</h4> <div class="vsc-settings-row"> <label for="vscBlacklist">Disabled Sites (one per line):<br><i>Plain text or /regex/</i></label> <textarea id="vscBlacklist" rows="6"></textarea> </div> <h4>Debugging</h4> <div class="vsc-settings-row"> <label for="vscLogLevel">Log Level:</label> <select id="vscLogLevel"> <option value="1">1: None</option> <option value="2">2: Errors</option> <option value="3">3: Warnings</option> <option value="4">4: Info</option> <option value="5">5: Debug</option> <option value="6">6: Verbose Debug</option> </select> </div> </div> <div id="vscStatus" style="min-height: 1em;"></div> <div class="vsc-settings-actions"> <button id="vscSaveBtn" class="vsc-save">Save and Close</button> <button id="vscRestoreBtn" class="vsc-restore">Restore Defaults</button> <button id="vscCloseBtn" class="vsc-close">Close</button> </div> `; dialog.innerHTML = contentHTML; document.body.appendChild(dialog); // Populate dialog with current settings populateSettingsDialog(); // Add event listeners for dialog elements dialog.querySelector('#vscSaveBtn').addEventListener('click', saveSettingsFromDialog); dialog.querySelector('#vscRestoreBtn').addEventListener('click', restoreDefaultSettingsInDialog); dialog.querySelector('#vscCloseBtn').addEventListener('click', () => dialog.remove()); dialog.querySelector('#vscAddBindingBtn').addEventListener('click', addKeyBindingRow); // Event delegation for dynamic elements (key inputs, remove buttons) const bindingsContainer = dialog.querySelector('#vscKeyBindingsContainer'); bindingsContainer.addEventListener('keydown', e => { if (e.target.classList.contains('vsc-binding-key')) recordKeyPress(e); }); bindingsContainer.addEventListener('focus', e => { if (e.target.classList.contains('vsc-binding-key')) inputFocus(e); }, true); bindingsContainer.addEventListener('blur', e => { if (e.target.classList.contains('vsc-binding-key')) inputBlur(e); }, true); bindingsContainer.addEventListener('keypress', e => { if (e.target.classList.contains('vsc-binding-value')) inputFilterNumbersOnly(e); }); bindingsContainer.addEventListener('click', e => { if (e.target.classList.contains('vsc-remove-binding')) removeKeyBindingRow(e.target); }); bindingsContainer.addEventListener('change', e => { if (e.target.classList.contains('vsc-binding-action')) toggleValueInputDisabled(e.target); }); } function populateSettingsDialog() { const dialog = document.getElementById('vscSettingsDialog'); if (!dialog) return; dialog.querySelector('#vscEnabled').checked = tc.settings.enabled; dialog.querySelector('#vscRememberSpeed').checked = tc.settings.rememberSpeed; dialog.querySelector('#vscForceLastSavedSpeed').checked = tc.settings.forceLastSavedSpeed; dialog.querySelector('#vscAudioBoolean').checked = tc.settings.audioBoolean; dialog.querySelector('#vscStartHidden').checked = tc.settings.startHidden; dialog.querySelector('#vscControllerOpacity').value = tc.settings.controllerOpacity; dialog.querySelector('#vscBlacklist').value = tc.settings.blacklist; dialog.querySelector('#vscLogLevel').value = tc.settings.logLevel; // Populate keybindings const bindingsContainer = dialog.querySelector('#vscKeyBindingsContainer'); bindingsContainer.innerHTML = ''; // Clear previous bindings tc.settings.keyBindings.forEach(binding => addKeyBindingRow(binding)); } function addKeyBindingRow(binding = null) { const dialog = document.getElementById('vscSettingsDialog'); if (!dialog) return; const container = dialog.querySelector('#vscKeyBindingsContainer'); const row = document.createElement('div'); row.className = 'vsc-keybinding-row'; if (binding?.predefined) { row.dataset.predefined = true; // Mark predefined for potential styling/logic } const actionOptions = ` <option value="display" ${binding?.action === 'display' ? 'selected' : ''}>Show/hide controller</option> <option value="slower" ${binding?.action === 'slower' ? 'selected' : ''}>Decrease speed</option> <option value="faster" ${binding?.action === 'faster' ? 'selected' : ''}>Increase speed</option> <option value="rewind" ${binding?.action === 'rewind' ? 'selected' : ''}>Rewind</option> <option value="advance" ${binding?.action === 'advance' ? 'selected' : ''}>Advance</option> <option value="reset" ${binding?.action === 'reset' ? 'selected' : ''}>Reset/Toggle speed</option> <option value="fast" ${binding?.action === 'fast' ? 'selected' : ''}>Preferred speed</option> <option value="muted" ${binding?.action === 'muted' ? 'selected' : ''}>Mute/Unmute</option> <option value="pause" ${binding?.action === 'pause' ? 'selected' : ''}>Play/Pause</option> <option value="mark" ${binding?.action === 'mark' ? 'selected' : ''}>Set marker</option> <option value="jump" ${binding?.action === 'jump' ? 'selected' : ''}>Jump to marker</option> `; const forceOptions = ` <option value="false" ${binding?.force?.toString() === 'false' ? 'selected': ''}>Allow website keybind</option> <option value="true" ${binding?.force?.toString() === 'true' ? 'selected': ''}>Disable website keybind</option> `; const keyText = binding?.key ? (keyCodeAliases[binding.key] || String.fromCharCode(binding.key)) : ''; const isValueDisabled = binding ? customActionsNoValues.includes(binding.action) : false; row.innerHTML = ` <select class="vsc-binding-action" ${binding?.predefined ? 'disabled' : ''}>${actionOptions}</select> <input class="vsc-binding-key" type="text" value="${keyText}" placeholder="press a key"> <input class="vsc-binding-value" type="number" step="0.1" value="${binding?.value || 0}" placeholder="value" ${isValueDisabled ? 'disabled' : ''}> <select class="vsc-binding-force">${forceOptions}</select> <button class="vsc-remove-binding" ${binding?.predefined ? 'disabled style="visibility:hidden;"' : ''} title="Remove shortcut">X</button> `; // Store the actual keycode on the input element const keyInput = row.querySelector('.vsc-binding-key'); if (binding?.key) keyInput.keyCode = binding.key; container.appendChild(row); } function removeKeyBindingRow(button) { button.closest('.vsc-keybinding-row').remove(); } function toggleValueInputDisabled(selectElement) { const valueInput = selectElement.closest('.vsc-keybinding-row').querySelector('.vsc-binding-value'); const action = selectElement.value; if (customActionsNoValues.includes(action)) { valueInput.disabled = true; valueInput.value = 0; // Reset value for actions that don't use it } else { valueInput.disabled = false; // Optionally set a default value based on action? // if (action === 'slower' || action === 'faster') valueInput.value = 0.1; // if (action === 'rewind' || action === 'advance') valueInput.value = 10; // if (action === 'fast') valueInput.value = 1.8; } } function validateSettingsDialog() { // Basic validation (e.g., check regex in blacklist) const dialog = document.getElementById('vscSettingsDialog'); const blacklistText = dialog.querySelector('#vscBlacklist').value; const status = dialog.querySelector('#vscStatus'); let isValid = true; blacklistText.split('\n').forEach(match => { match = match.replace(regStrip, ""); if (match.startsWith('/') && match.endsWith('/')) { try { new RegExp(match.slice(1, -1)); } catch (err) { status.textContent = `Error: Invalid blacklist regex: ${match}`; status.style.color = 'red'; isValid = false; } } }); // Validate opacity const opacity = Number(dialog.querySelector('#vscControllerOpacity').value); if (isNaN(opacity) || opacity < 0 || opacity > 1) { status.textContent = `Error: Opacity must be between 0.0 and 1.0`; status.style.color = 'red'; isValid = false; } // Validate Keybindings (ensure key is set, etc.) dialog.querySelectorAll('.vsc-keybinding-row').forEach((row, index) => { const keyInput = row.querySelector('.vsc-binding-key'); if (!keyInput.keyCode && keyInput.value !== 'null') { status.textContent = `Error: Key not set for shortcut #${index + 1}`; status.style.color = 'red'; isValid = false; } // Check for duplicate keys? }); if (isValid) { status.textContent = ''; // Clear error return true; } return false; } async function saveSettingsFromDialog() { const dialog = document.getElementById('vscSettingsDialog'); if (!dialog || !validateSettingsDialog()) return; const status = dialog.querySelector('#vscStatus'); status.textContent = "Saving..."; status.style.color = 'orange'; // Read values from dialog tc.settings.enabled = dialog.querySelector('#vscEnabled').checked; tc.settings.rememberSpeed = dialog.querySelector('#vscRememberSpeed').checked; tc.settings.forceLastSavedSpeed = dialog.querySelector('#vscForceLastSavedSpeed').checked; tc.settings.audioBoolean = dialog.querySelector('#vscAudioBoolean').checked; tc.settings.startHidden = dialog.querySelector('#vscStartHidden').checked; tc.settings.controllerOpacity = Number(dialog.querySelector('#vscControllerOpacity').value); tc.settings.blacklist = dialog.querySelector('#vscBlacklist').value; tc.settings.logLevel = Number(dialog.querySelector('#vscLogLevel').value); // Read keybindings const newBindings = []; dialog.querySelectorAll('.vsc-keybinding-row').forEach(row => { const keyInput = row.querySelector('.vsc-binding-key'); newBindings.push({ action: row.querySelector('.vsc-binding-action').value, key: keyInput.keyCode || null, // Get stored keyCode value: Number(row.querySelector('.vsc-binding-value').value) || 0, force: row.querySelector('.vsc-binding-force').value, // Store as string 'true'/'false' predefined: row.dataset.predefined === 'true' }); }); tc.settings.keyBindings = newBindings; try { await saveSettings(); // Use the async save function status.textContent = "Settings Saved! Reload page to apply some changes (e.g., blacklist, audio)."; status.style.color = 'green'; setTimeout(() => { dialog.remove(); // Close dialog after successful save // Dynamically update running script? (e.g., attach/detach controllers if enabled changed) // For simplicity, we often require a reload for major changes. location.reload(); // Force reload to ensure all settings apply cleanly }, 1500); } catch (e) { status.textContent = "Error saving settings: " + e; status.style.color = 'red'; } } async function restoreDefaultSettingsInDialog() { if (!confirm("Are you sure you want to restore default settings? All customizations will be lost.")) { return; } const dialog = document.getElementById('vscSettingsDialog'); const status = dialog.querySelector('#vscStatus'); status.textContent = "Restoring defaults..."; status.style.color = 'orange'; // Reset tc.settings to defaults tc.settings = JSON.parse(JSON.stringify(tcDefaults)); // Deep copy defaults // Repopulate the dialog with the new default settings populateSettingsDialog(); status.textContent = "Defaults restored. Click Save to keep them."; status.style.color = 'blue'; } function toggleScriptEnabled(enable) { log(`Setting script enabled status to: ${enable}`, 4); tc.settings.enabled = enable; saveSettings().then(() => { alert(`Video Speed Controller ${enable ? 'Enabled' : 'Disabled'}. Please reload the page for the change to take full effect.`); location.reload(); // Force reload }); } // --- Main Execution --- async function main() { await loadSettings(); // Register GM Menu Commands GM_registerMenuCommand("VSC Settings", showSettingsDialog); if (tc.settings.enabled) { GM_registerMenuCommand("Disable VSC (Reload Required)", () => toggleScriptEnabled(false)); } else { GM_registerMenuCommand("Enable VSC (Reload Required)", () => toggleScriptEnabled(true)); } // Add more commands? e.g. quick toggle remember speed? // Start initialization process if enabled and not blacklisted initializeWhenReady(document); } // --- Run Main --- main().catch(e => { log("Critical error during script initialization: " + e, 2); console.error("VSC Userscript Error:", e); }); })(); // End IIFE
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址