Video Speed Controller (Userscript)

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或关注我们的公众号极客氢云获取最新地址