Video Speed Controller (Userscript)

Control HTML5 video playback speed with shortcuts and an on-screen controller. Based on codebicycle/videospeed.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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