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