Enhance any HTML5 video: playback speed, volume boost, seek, screenshot, PiP, filters, transforms, progress memory & more. Works on YouTube, Twitch, Vimeo and all sites.
// ==UserScript==
// @name Video Enhancer
// @name:en Video Enhancer - Speed Control, Filters, Screenshot & More
// @namespace video-enhancer-compact-ui
// @version 1.0.0
// @description Enhance any HTML5 video: playback speed, volume boost, seek, screenshot, PiP, filters, transforms, progress memory & more. Works on YouTube, Twitch, Vimeo and all sites.
// @author Agent-324
// @icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%236366f1'/%3E%3Cstop offset='100%25' stop-color='%23a855f7'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Cpolygon points='38,25 38,75 78,50' fill='white'/%3E%3C/svg%3E
// @match *://*/*
// @grant unsafeWindow
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-start
// @license GPL
// ==/UserScript==
(function () {
'use strict';
/* ═══════════════════════════════════════════
§1 CONFIGURATION
═══════════════════════════════════════════ */
const CONF = {
cache: {},
get(key, fallback) {
if (this.cache[key] !== undefined) return this.cache[key];
try { const v = GM_getValue('ve_' + key); this.cache[key] = (v === undefined ? fallback : v); return this.cache[key]; } catch { return fallback; }
},
set(key, val) {
this.cache[key] = val;
try { GM_setValue('ve_' + key, val); } catch { /* silent */ }
}
};
const defaults = {
playbackRate: 1,
volume: 1,
lastRate: 1,
skipStep: 5,
enabled: true,
guiEnabled: true,
progressEnabled: true,
progressMap: {},
globalMode: true,
};
function cfg(key, val) {
let storageKey = key;
if (key === 'enabled' || key === 'guiEnabled' || key === 'progressEnabled') {
storageKey = key + '_' + location.hostname;
}
if (val !== undefined) { CONF.set(storageKey, val); return val; }
return CONF.get(storageKey, defaults[key]);
}
/* ═══════════════════════════════════════════
§2 UTILITIES
═══════════════════════════════════════════ */
function clamp(val, min, max) { return Math.min(Math.max(val, min), max); }
function formatTime(sec) {
if (isNaN(sec) || !isFinite(sec)) return "0:00";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function round1(n) { return Math.round(n * 10) / 10; }
function round2(n) { return Math.round(n * 100) / 100; }
function isEditable(el) {
if (!el || !el.tagName) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/i.test(el.tagName)) return true;
return el.isContentEditable;
}
function debounce(fn, ms) {
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
/* ═══════════════════════════════════════════
§3 PLAYER DETECTION & MANAGEMENT
═══════════════════════════════════════════ */
let activePlayer = null;
let allPlayers = [];
function isVideoEl(el) { return el instanceof HTMLVideoElement; }
function findVideos() {
const vids = Array.from(document.querySelectorAll('video'));
/* Also check shadow DOMs using a fast loop */
const hosts = document.querySelectorAll('*');
for (let i = 0; i < hosts.length; i++) {
const sr = hosts[i].shadowRoot;
if (sr) {
const nested = sr.querySelectorAll('video');
for (let j = 0; j < nested.length; j++) {
if (!vids.includes(nested[j])) vids.push(nested[j]);
}
}
}
return vids;
}
function getBestPlayer(vids) {
if (!vids.length) return null;
if (vids.length === 1) return vids[0];
/* Prefer the largest visible video */
let best = null, bestArea = 0;
for (const v of vids) {
const r = v.getBoundingClientRect();
if (r.width < 50 || r.height < 50) continue;
const area = r.width * r.height;
if (area > bestArea) { bestArea = area; best = v; }
}
return best || vids[0];
}
function refreshPlayers() {
const found = findVideos();
for (const v of found) {
if (!allPlayers.includes(v)) {
allPlayers.push(v);
initPlayerEvents(v);
} else {
tryAttachGUI(v); /* Ensure GUI stays attached even if DOM changes */
}
}
/* Remove detached */
allPlayers = allPlayers.filter(v => {
if (!v.isConnected) {
const t = veRetryTimers.get(v);
if (t) { clearTimeout(t); veRetryTimers.delete(v); }
return false;
}
return true;
});
if (!activePlayer || !activePlayer.isConnected) {
activePlayer = getBestPlayer(allPlayers);
}
}
function initPlayerEvents(video) {
video.addEventListener('mouseenter', () => { setActivePlayer(video); });
video.addEventListener('playing', () => {
if (video.duration && video.duration > 5) setActivePlayer(video);
});
video.addEventListener('play', () => tryAttachGUI(video));
video.addEventListener('pause', () => tryAttachGUI(video));
video.addEventListener('loadeddata', () => tryAttachGUI(video));
/* Sync rate on play */
video.addEventListener('playing', () => {
const rate = cfg('playbackRate');
if (rate && rate !== 1 && video.playbackRate !== rate) {
video.playbackRate = rate;
}
});
/* Restore playback progress when video metadata is ready */
const tryRestore = () => {
if (video.duration && video.duration > 120) {
restoreProgress(video);
}
};
video.addEventListener('loadedmetadata', tryRestore);
video.addEventListener('canplay', tryRestore);
video.addEventListener('durationchange', tryRestore);
/* Restore saved volume (including gain) */
const savedVol = cfg('volume');
if (savedVol !== undefined && savedVol !== null) {
if (savedVol > 1) {
video.volume = 1;
const amp = getAmplifier(video);
if (amp) amp.gain.value = savedVol;
} else if (savedVol >= 0) {
video.volume = savedVol;
}
}
tryAttachGUI(video);
/* Watch for video becoming visible / resized (e.g. YouTube SPA transition, theater mode) */
try {
const ro = new ResizeObserver(() => tryAttachGUI(video));
ro.observe(video);
} catch { }
}
function setActivePlayer(v) {
if (activePlayer === v) return;
activePlayer = v;
const rate = cfg('playbackRate');
if (rate && v.playbackRate !== rate) v.playbackRate = rate;
}
function player() {
if (activePlayer && activePlayer.isConnected) return activePlayer;
refreshPlayers();
return activePlayer;
}
/* ═══════════════════════════════════════════
§4 TIPS / NOTIFICATION OVERLAY
═══════════════════════════════════════════ */
let tipsTimer = null;
let tipsEl = null;
function tips(msg) {
const v = player();
if (!v) return;
const container = v.parentElement || document.body;
if (!tipsEl || !tipsEl.isConnected) {
tipsEl = document.createElement('div');
tipsEl.className = 've-tips';
container.appendChild(tipsEl);
} else if (tipsEl.parentElement !== container) {
container.appendChild(tipsEl);
}
/* Ensure container is positioned */
const pos = getComputedStyle(container).position;
if (!pos || pos === 'static') container.style.position = 'relative';
tipsEl.textContent = msg;
tipsEl.style.opacity = '1';
tipsEl.style.transform = 'translateY(0)';
clearTimeout(tipsTimer);
tipsTimer = setTimeout(() => {
if (tipsEl) {
tipsEl.style.opacity = '0';
tipsEl.style.transform = 'translateY(-8px)';
}
}, 1800);
}
/* ═══════════════════════════════════════════
§5 PLAYER CONTROLS
═══════════════════════════════════════════ */
/* ---- Speed ---- */
function setRate(rate) {
const v = player(); if (!v) return;
rate = clamp(round1(rate), 0.1, 16);
v.playbackRate = rate;
cfg('playbackRate', rate);
tips(`Speed: ${rate}x`);
}
function rateUp(step = 0.1) {
const v = player(); if (!v) return;
setRate(v.playbackRate + step);
}
function rateDown(step = 0.1) {
const v = player(); if (!v) return;
setRate(v.playbackRate - step);
}
function resetRate() {
const v = player(); if (!v) return;
const cur = round1(v.playbackRate);
if (cur === 1) {
const last = cfg('lastRate') || 1;
setRate(last);
} else {
cfg('lastRate', cur);
setRate(1);
}
}
/* Quick rate set with acceleration: pressing same key rapidly increases step */
const ratePlusInfo = {};
function setRatePlus(num) {
num = Number(num); if (!num) return;
const key = num;
const now = Date.now();
if (!ratePlusInfo[key]) ratePlusInfo[key] = { time: 0, value: num };
if (now - ratePlusInfo[key].time < 300) {
ratePlusInfo[key].value += num;
} else {
ratePlusInfo[key].value = num;
}
ratePlusInfo[key].time = now;
setRate(ratePlusInfo[key].value);
}
/* ---- Volume (with gain boost beyond 100%) ---- */
const ampMap = new WeakMap();
function getAmplifier(video) {
if (ampMap.has(video)) return ampMap.get(video);
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const source = ctx.createMediaElementSource(video);
const gainNode = ctx.createGain();
source.connect(gainNode);
gainNode.connect(ctx.destination);
ampMap.set(video, gainNode);
return gainNode;
} catch (e) {
console.warn('[VE] AudioContext gain failed:', e);
return null;
}
}
/* Current effective volume (including gain) */
let currentGainLevel = 1;
function setVolume(val) {
const v = player(); if (!v) return;
val = round2(val);
if (val > 1) {
/* Boost mode: set native volume to 1, use gain for amplification */
val = clamp(val, 0, 6); /* Max 600% */
v.volume = 1;
v.muted = false;
const amp = getAmplifier(v);
if (amp) {
amp.gain.value = val;
currentGainLevel = val;
cfg('volume', val);
tips(`🔊 Volume: ${Math.round(val * 100)}%`);
} else {
tips('Volume boost unavailable (cross-origin)');
}
} else {
/* Normal mode */
val = clamp(val, 0, 1);
v.volume = val;
v.muted = false;
/* Reset gain if previously amplified */
if (currentGainLevel > 1) {
const amp = ampMap.get(v);
if (amp) amp.gain.value = 1;
currentGainLevel = 1;
}
cfg('volume', val);
tips(`Volume: ${Math.round(val * 100)}%`);
}
}
function getEffectiveVolume() {
const v = player(); if (!v) return 1;
if (currentGainLevel > 1) return currentGainLevel;
return v.volume;
}
function volumeUp(step = 0.05) {
const v = player(); if (!v) return;
const cur = getEffectiveVolume();
/* At 100%, start using larger steps for gain boost */
if (cur >= 1 && step <= 0.1) step = 0.2;
setVolume(cur + step);
}
function volumeDown(step = 0.05) {
const v = player(); if (!v) return;
const cur = getEffectiveVolume();
/* When above 100%, use larger steps for gain reduction */
if (cur > 1 && step <= 0.1) step = 0.2;
setVolume(cur - step);
}
/* ---- Seeking ---- */
function seekBy(sec) {
const v = player(); if (!v) return;
const newTime = clamp(v.currentTime + sec, 0, v.duration || Infinity);
v.currentTime = newTime;
const label = sec > 0 ? `Forward: ${sec}s` : `Backward: ${Math.abs(sec)}s`;
tips(label);
}
/* ---- Play/Pause ---- */
function togglePlay() {
const v = player(); if (!v) return;
if (v.paused) {
v.play().catch(() => { });
tips('▶ Play');
} else {
v.pause();
tips('⏸ Pause');
}
}
/* ---- Fullscreen ---- */
function toggleFullscreen() {
const v = player(); if (!v) return;
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => { });
} else {
/* Try fullscreening the player's container for better controls */
const container = v.closest('[class*="player"]') || v.parentElement || v;
(container.requestFullscreen || container.webkitRequestFullscreen || container.msRequestFullscreen)
?.call(container)
?.catch(() => v.requestFullscreen?.().catch(() => { }));
}
}
/* ---- Web Fullscreen (fills page) ---- */
let webFsActive = false;
let webFsOrigStyles = null;
function toggleWebFullscreen() {
const v = player(); if (!v) return;
const container = v.parentElement;
if (!container) return;
if (!webFsActive) {
webFsOrigStyles = {
vStyle: v.getAttribute('style') || '',
cStyle: container.getAttribute('style') || '',
overflow: document.body.style.overflow
};
container.style.cssText = 'position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:100vh!important;z-index:2147483647!important;background:#000!important;';
v.style.cssText += ';width:100%!important;height:100%!important;object-fit:contain!important;';
document.body.style.overflow = 'hidden';
webFsActive = true;
tips('Web Fullscreen: ON');
} else {
container.setAttribute('style', webFsOrigStyles.cStyle);
v.setAttribute('style', webFsOrigStyles.vStyle);
document.body.style.overflow = webFsOrigStyles.overflow;
webFsActive = false;
tips('Web Fullscreen: OFF');
}
}
/* ---- Picture in Picture ---- */
function togglePiP() {
const v = player(); if (!v) return;
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => { });
} else {
v.requestPictureInPicture?.().catch(() => { });
}
}
/* ---- Frame-by-frame ---- */
function freezeFrame(dir = 1) {
const v = player(); if (!v) return;
if (!v.paused) v.pause();
v.currentTime += dir / 30; /* assume 30fps */
tips(dir > 0 ? 'Next Frame →' : '← Previous Frame');
}
/* ---- Screenshot ---- */
function captureFrame() {
const v = player(); if (!v) return;
try {
const canvas = document.createElement('canvas');
canvas.width = v.videoWidth;
canvas.height = v.videoHeight;
canvas.getContext('2d').drawImage(v, 0, 0);
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `screenshot_${Date.now()}.png`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}, 'image/png');
tips('📸 Screenshot saved');
} catch (e) {
tips('Screenshot failed (cross-origin)');
}
}
/* ═══════════════════════════════════════════
§6 VIDEO FILTERS & TRANSFORMS
═══════════════════════════════════════════ */
const filters = { brightness: 1, contrast: 1, saturate: 1, hueRotate: 0, blur: 0 };
let transform = { scale: 1, rotate: 0, rotateY: 0, rotateX: 0, x: 0, y: 0 };
function applyFilter() {
const v = player(); if (!v) return;
v.style.filter = `brightness(${filters.brightness}) contrast(${filters.contrast}) saturate(${filters.saturate}) hue-rotate(${filters.hueRotate}deg) blur(${filters.blur}px)`;
}
function applyTransform() {
const v = player(); if (!v) return;
const t = transform;
const mirror = t.rotateX === 180 ? `rotateX(180deg)` : (t.rotateY === 180 ? `rotateY(180deg)` : '');
v.style.transform = `scale(${t.scale}) translate(${t.x}px, ${t.y}px) rotate(${t.rotate}deg) ${mirror}`;
}
function adjustFilter(key, delta, label) {
filters[key] = round2(filters[key] + delta);
if (key !== 'hueRotate' && filters[key] < 0) filters[key] = 0;
applyFilter();
const display = key === 'hueRotate' ? `${filters[key]}°` : key === 'blur' ? `${filters[key]}px` : `${Math.round(filters[key] * 100)}%`;
tips(`${label}: ${display}`);
}
function resetFilterAndTransform() {
filters.brightness = 1; filters.contrast = 1; filters.saturate = 1;
filters.hueRotate = 0; filters.blur = 0;
transform = { scale: 1, rotate: 0, rotateY: 0, rotateX: 0, x: 0, y: 0 };
const v = player();
if (v) { v.style.filter = ''; v.style.transform = ''; }
tips('🔄 Filters & Transform reset');
}
function setRotate90() {
transform.rotate = (transform.rotate + 90) % 360;
applyTransform();
tips(`Rotation: ${transform.rotate}°`);
}
function toggleMirrorH() {
transform.rotateY = transform.rotateY === 0 ? 180 : 0;
applyTransform();
tips(`H-Mirror: ${transform.rotateY ? 'ON' : 'OFF'}`);
}
function toggleMirrorV() {
transform.rotateX = transform.rotateX === 0 ? 180 : 0;
applyTransform();
tips(`V-Mirror: ${transform.rotateX ? 'ON' : 'OFF'}`);
}
function scaleUp() { transform.scale = round2(transform.scale + 0.05); applyTransform(); tips(`Zoom: ${Math.round(transform.scale * 100)}%`); }
function scaleDown() { transform.scale = round2(Math.max(0.1, transform.scale - 0.05)); applyTransform(); tips(`Zoom: ${Math.round(transform.scale * 100)}%`); }
function resetTransform() { transform = { scale: 1, rotate: 0, rotateY: 0, rotateX: 0, x: 0, y: 0 }; applyTransform(); tips('Transform reset'); }
function translateR() { transform.x += 10; applyTransform(); tips(`Move X: ${transform.x}px`); }
function translateL() { transform.x -= 10; applyTransform(); tips(`Move X: ${transform.x}px`); }
function translateU() { transform.y -= 10; applyTransform(); tips(`Move Y: ${transform.y}px`); }
function translateD() { transform.y += 10; applyTransform(); tips(`Move Y: ${transform.y}px`); }
/* ═══════════════════════════════════════════
§7 PLAYBACK PROGRESS SAVE / RESTORE
═══════════════════════════════════════════ */
let hasRestoredFor = '';
function getProgressKey(v) {
if (location.hostname.includes('youtube.com')) {
try {
const url = new URL(location.href);
const vId = url.searchParams.get('v') || url.pathname.split('/shorts/')[1] || url.pathname.split('/embed/')[1];
if (vId) return 'yt_' + vId;
} catch { }
}
return location.href.split('#')[0].split('?')[0] + '::' + Math.round(v.duration || 0);
}
function saveProgress() {
const v = player();
if (!v || !v.duration || v.duration < 120 || !cfg('progressEnabled')) return;
const map = cfg('progressMap') || {};
const key = getProgressKey(v);
map[key] = { time: v.currentTime, dur: v.duration, ts: Date.now() };
/* Keep only last 100 entries */
const entries = Object.entries(map);
if (entries.length > 100) {
entries.sort((a, b) => a[1].ts - b[1].ts);
entries.slice(0, entries.length - 100).forEach(([k]) => delete map[k]);
}
cfg('progressMap', map);
}
function restoreProgress(v) {
if (!v || !v.duration || v.duration < 120 || !cfg('progressEnabled')) return;
const key = getProgressKey(v);
if (hasRestoredFor === key) return;
const map = cfg('progressMap') || {};
const saved = map[key];
if (!saved || saved.time < 10 || saved.time >= v.duration - 5) return;
if (Math.abs(saved.time - v.currentTime) < 3) return;
v.currentTime = saved.time - 1.5;
hasRestoredFor = key;
tips('📍 Playback progress restored');
}
function toggleProgressSave() {
const newVal = !cfg('progressEnabled');
cfg('progressEnabled', newVal);
tips(newVal ? '📍 Progress save: ON' : '📍 Progress save: OFF');
}
/* Periodically save progress */
setInterval(saveProgress, 1000);
/* ═══════════════════════════════════════════
§8 CONFIGURABLE KEYBOARD SHORTCUTS
═══════════════════════════════════════════ */
/* Action registry: all bindable actions */
const ACTIONS = {
seekForward: { fn: () => seekBy(cfg('skipStep')), label: 'Seek Forward' },
seekBackward: { fn: () => seekBy(-cfg('skipStep')), label: 'Seek Backward' },
seekForward30: { fn: () => seekBy(30), label: 'Seek Forward 30s' },
seekBackward30: { fn: () => seekBy(-30), label: 'Seek Backward 30s' },
volumeUp: { fn: () => volumeUp(0.05), label: 'Volume Up' },
volumeDown: { fn: () => volumeDown(0.05), label: 'Volume Down' },
volumeUpBig: { fn: () => volumeUp(0.2), label: 'Volume Up (Big)' },
volumeDownBig: { fn: () => volumeDown(0.2), label: 'Volume Down (Big)' },
togglePlay: { fn: () => togglePlay(), label: 'Play / Pause' },
rateUp: { fn: () => rateUp(), label: 'Speed Up' },
rateDown: { fn: () => rateDown(), label: 'Speed Down' },
rateReset: { fn: () => resetRate(), label: 'Speed Reset / Toggle' },
nextFrame: { fn: () => freezeFrame(1), label: 'Next Frame' },
prevFrame: { fn: () => freezeFrame(-1), label: 'Previous Frame' },
brightnessUp: { fn: () => adjustFilter('brightness', 0.1, 'Brightness'), label: 'Brightness +' },
brightnessDown: { fn: () => adjustFilter('brightness', -0.1, 'Brightness'), label: 'Brightness −' },
contrastUp: { fn: () => adjustFilter('contrast', 0.1, 'Contrast'), label: 'Contrast +' },
contrastDown: { fn: () => adjustFilter('contrast', -0.1, 'Contrast'), label: 'Contrast −' },
saturationUp: { fn: () => adjustFilter('saturate', 0.1, 'Saturation'), label: 'Saturation +' },
saturationDown: { fn: () => adjustFilter('saturate', -0.1, 'Saturation'), label: 'Saturation −' },
hueUp: { fn: () => adjustFilter('hueRotate', 1, 'Hue'), label: 'Hue +' },
hueDown: { fn: () => adjustFilter('hueRotate', -1, 'Hue'), label: 'Hue −' },
blurUp: { fn: () => adjustFilter('blur', 1, 'Blur'), label: 'Blur +' },
blurDown: { fn: () => adjustFilter('blur', -1, 'Blur'), label: 'Blur −' },
resetAll: { fn: () => resetFilterAndTransform(), label: 'Reset Filters' },
rotate90: { fn: () => setRotate90(), label: 'Rotate 90°' },
mirrorH: { fn: () => toggleMirrorH(), label: 'Mirror Horizontal' },
mirrorV: { fn: () => toggleMirrorV(), label: 'Mirror Vertical' },
fullscreen: { fn: () => toggleFullscreen(), label: 'Fullscreen' },
webFullscreen: { fn: () => toggleWebFullscreen(), label: 'Web Fullscreen' },
pip: { fn: () => togglePiP(), label: 'Picture-in-Picture' },
capture: { fn: () => captureFrame(), label: 'Screenshot' },
toggleProgress: { fn: () => toggleProgressSave(), label: 'Toggle Progress Save' },
scaleUp: { fn: () => scaleUp(), label: 'Zoom In' },
scaleDown: { fn: () => scaleDown(), label: 'Zoom Out' },
resetTransform: { fn: () => resetTransform(), label: 'Reset Transform' },
translateRight: { fn: () => translateR(), label: 'Move Right' },
translateLeft: { fn: () => translateL(), label: 'Move Left' },
translateUp: { fn: () => translateU(), label: 'Move Up' },
translateDown: { fn: () => translateD(), label: 'Move Down' },
};
/* Default key bindings: "modifier+key" → actionName */
const DEFAULT_BINDINGS = {
'arrowright': 'seekForward',
'arrowleft': 'seekBackward',
'ctrl+arrowright': 'seekForward30',
'ctrl+arrowleft': 'seekBackward30',
'arrowup': 'volumeUp',
'arrowdown': 'volumeDown',
'ctrl+arrowup': 'volumeUpBig',
'ctrl+arrowdown': 'volumeDownBig',
' ': 'togglePlay',
'x': 'rateDown',
'c': 'rateUp',
'z': 'rateReset',
'f': 'nextFrame',
'd': 'prevFrame',
'e': 'brightnessUp',
'w': 'brightnessDown',
't': 'contrastUp',
'r': 'contrastDown',
'u': 'saturationUp',
'y': 'saturationDown',
'o': 'hueUp',
'i': 'hueDown',
'k': 'blurUp',
'j': 'blurDown',
'q': 'resetAll',
's': 'rotate90',
'm': 'mirrorH',
'enter': 'fullscreen',
'shift+enter': 'webFullscreen',
'shift+p': 'pip',
'shift+s': 'capture',
'shift+r': 'toggleProgress',
'shift+m': 'mirrorV',
'shift+c': 'scaleUp',
'shift+x': 'scaleDown',
'shift+z': 'resetTransform',
'shift+arrowright': 'translateRight',
'shift+arrowleft': 'translateLeft',
'shift+arrowup': 'translateUp',
'shift+arrowdown': 'translateDown',
};
function getBindings() {
return cfg('keyBindings') || { ...DEFAULT_BINDINGS };
}
function saveBindings(bindings) {
cfg('keyBindings', bindings);
}
/* Convert a KeyboardEvent into a binding string like "ctrl+shift+a" */
function eventToBindingKey(e) {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.altKey) parts.push('alt');
if (e.shiftKey) parts.push('shift');
const key = e.key.toLowerCase();
if (!['control', 'alt', 'shift', 'meta'].includes(key)) parts.push(key);
return parts.join('+');
}
function handleKeydown(e) {
if (!cfg('enabled')) return;
/* Don't intercept when settings modal is open */
if (document.querySelector('.ve-settings-overlay')) return;
const target = e.composedPath?.()?.[0] || e.target;
if (isEditable(target)) return;
const v = player();
if (!v) return;
const bindingKey = eventToBindingKey(e);
const bindings = getBindings();
const actionName = bindings[bindingKey];
/* Handle 1-4 quick speed (always active, not rebindable) */
if (!actionName && !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
const code = e.keyCode;
if ((code >= 49 && code <= 52) || (code >= 97 && code <= 100)) {
setRatePlus(e.key);
e.stopPropagation();
e.preventDefault();
return;
}
}
if (actionName && ACTIONS[actionName]) {
e.stopPropagation();
e.preventDefault();
ACTIONS[actionName].fn();
}
}
/* ═══════════════════════════════════════════
§9 STYLES
═══════════════════════════════════════════ */
function injectStyles() {
const css = `
/* ─── Tips notification ─── */
.ve-tips {
position: absolute;
top: 16px;
left: 16px;
z-index: 2147483640;
padding: 8px 16px;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
color: #fff;
background: linear-gradient(135deg, rgba(99,102,241,0.85), rgba(168,85,247,0.85));
backdrop-filter: blur(12px) saturate(1.5);
-webkit-backdrop-filter: blur(12px) saturate(1.5);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(99,102,241,0.3), 0 0 0 1px rgba(255,255,255,0.05) inset;
pointer-events: none;
user-select: none;
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.35s cubic-bezier(.4,0,.2,1), transform 0.35s cubic-bezier(.4,0,.2,1);
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
/* ─── GUI Toolbar ─── */
.ve-toolbar-wrap {
position: fixed;
z-index: 2147483647; /* Max int */
display: flex;
justify-content: center;
pointer-events: none;
padding-bottom: 12px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.4s cubic-bezier(.4,0,.2,1), transform 0.4s cubic-bezier(.4,0,.2,1);
}
.ve-toolbar-wrap.ve-visible {
opacity: 1 !important;
transform: translateY(0) !important;
}
.ve-toolbar {
position: absolute;
display: flex;
align-items: center;
gap: 2px;
padding: 5px 8px;
background: linear-gradient(135deg, rgba(15,15,35,0.75), rgba(30,20,60,0.7));
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 14px;
box-shadow:
0 8px 32px rgba(0,0,0,0.35),
0 0 0 1px rgba(255,255,255,0.03) inset,
0 1px 0 rgba(255,255,255,0.06) inset;
pointer-events: auto;
user-select: none;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
transition: all 0.25s cubic-bezier(.4,0,.2,1);
}
/* Compact Mode / Single Icon Mode */
.ve-toolbar.ve-compact {
padding: 0;
width: 44px;
height: 44px;
border-radius: 22px;
opacity: 0.8;
cursor: pointer;
justify-content: center;
}
.ve-toolbar.ve-compact:hover {
opacity: 1;
}
.ve-toolbar.ve-compact > :not(.ve-brand) {
display: none !important;
}
.ve-toolbar.ve-compact .ve-brand {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
line-height: 1;
letter-spacing: normal;
display: flex !important;
align-items: center;
justify-content: center;
font-size: 15px;
}
/* Toolbar buttons */
.ve-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
border: none;
background: transparent;
color: rgba(255,255,255,0.8);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s cubic-bezier(.4,0,.2,1);
padding: 0;
outline: none;
font-size: 13px;
font-weight: 600;
font-family: inherit;
line-height: 1;
}
.ve-btn:hover {
background: rgba(255,255,255,0.1);
color: #fff;
transform: translateY(-1px);
}
.ve-btn:active {
transform: translateY(0) scale(0.95);
background: rgba(255,255,255,0.15);
}
.ve-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
flex-shrink: 0;
}
/* Playback Control Panel */
.ve-panel-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.ve-panel-row:last-child { margin-bottom: 0; }
.ve-panel-btn { background: rgba(255,255,255,0.1); border: none; border-radius: 6px; color: #fff; padding: 6px 10px; cursor: pointer; font-weight: 600; font-size: 13px; flex: 1; text-align: center; transition: all 0.2s; white-space: nowrap; outline: none; }
.ve-panel-btn:hover { background: rgba(255,255,255,0.2); transform: translateY(-1px); }
.ve-panel-btn:active { transform: translateY(0); }
.ve-slider { flex: 1; -webkit-appearance: none; height: 4px; background: rgba(255,255,255,0.2); border-radius: 2px; outline: none; cursor: pointer; }
.ve-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; background: #c084fc; border-radius: 50%; cursor: pointer; box-shadow: 0 0 5px rgba(0,0,0,0.5); }
.ve-panel-label { color: rgba(255,255,255,0.8); font-size: 12px; font-weight: 700; min-width: 45px; text-align: right; font-variant-numeric: tabular-nums; }
/* Dropdown */
.ve-dropdown-wrap {
position: relative;
}
.ve-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) scale(0.95);
background: linear-gradient(180deg, rgba(20,15,45,0.95), rgba(10,8,30,0.98));
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
padding: 6px;
min-width: 120px;
box-shadow:
0 12px 40px rgba(0,0,0,0.5),
0 0 0 1px rgba(255,255,255,0.04) inset;
opacity: 0;
pointer-events: none;
transition: all 0.25s cubic-bezier(.4,0,.2,1);
z-index: 2147483641;
max-height: var(--ve-max-h, 140px);
overflow-y: auto;
scrollbar-width: none;
}
.ve-dropdown::-webkit-scrollbar {
display: none;
}
.ve-dropdown.ve-open {
opacity: 1;
transform: translateX(-50%) scale(1);
pointer-events: auto;
}
.ve-dropdown.ve-drop-down {
top: calc(100% + 8px);
bottom: auto;
}
.ve-dropdown-item {
display: block;
width: 100%;
padding: 7px 14px;
border: none;
background: transparent;
color: rgba(255,255,255,0.8);
font-size: 13px;
font-weight: 500;
font-family: inherit;
text-align: left;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
outline: none;
}
.ve-dropdown-item:hover {
background: linear-gradient(135deg, rgba(99,102,241,0.3), rgba(168,85,247,0.3));
color: #fff;
}
.ve-dropdown-item.ve-active {
background: linear-gradient(135deg, rgba(99,102,241,0.5), rgba(168,85,247,0.5));
color: #fff;
font-weight: 700;
}
.ve-separator {
width: 1px;
height: 20px;
background: rgba(255,255,255,0.08);
margin: 0 3px;
flex-shrink: 0;
}
/* ─── Branding ─── */
.ve-brand {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
background: linear-gradient(135deg, #818cf8, #c084fc);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
padding: 0 5px;
cursor: default;
line-height: 1;
}
/* ─── Responsive: small video ─── */
.ve-toolbar.ve-compact .ve-btn-label {
display: none;
}
.ve-toolbar.ve-compact {
gap: 1px;
padding: 4px 6px;
}
.ve-toolbar.ve-compact .ve-btn {
width: 30px;
height: 28px;
}
.ve-toolbar.ve-compact .ve-speed-badge {
min-width: 36px;
font-size: 11px;
padding: 3px 5px;
}
/* ─── Settings Modal (Shortcuts) ─── */
.ve-settings-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
opacity: 0;
animation: ve-fade-in 0.2s forwards;
}
@keyframes ve-fade-in { to { opacity: 1; } }
.ve-settings-modal {
background: linear-gradient(180deg, rgba(20,15,45,0.95), rgba(10,8,30,0.98));
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04) inset;
transform: scale(0.95);
animation: ve-scale-up 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes ve-scale-up { to { transform: scale(1); } }
.ve-settings-header {
padding: 20px 24px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.ve-settings-title {
display: block;
font-size: 18px;
font-weight: 700;
color: #fff;
margin-bottom: 4px;
}
.ve-settings-subtitle {
display: block;
font-size: 13px;
color: rgba(255,255,255,0.6);
}
.ve-settings-list {
padding: 12px 24px;
overflow-y: auto;
flex-grow: 1;
}
.ve-settings-list::-webkit-scrollbar {
width: 8px;
}
.ve-settings-list::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.2);
border-radius: 4px;
}
.ve-settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.ve-settings-row:last-child {
border-bottom: none;
}
.ve-settings-label {
font-size: 14px;
color: rgba(255,255,255,0.85);
font-weight: 500;
}
.ve-settings-key {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
color: #c084fc;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
min-width: 100px;
text-align: center;
}
.ve-settings-key:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
.ve-settings-key.ve-recording {
background: linear-gradient(135deg, rgba(239,68,68,0.2), rgba(249,115,22,0.2));
border-color: rgba(239,68,68,0.5);
color: #fca5a5;
box-shadow: 0 0 12px rgba(239,68,68,0.3);
animation: ve-pulse 1.5s infinite;
}
@keyframes ve-pulse {
0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); }
70% { box-shadow: 0 0 0 10px rgba(239,68,68,0); }
100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); }
}
.ve-settings-footer {
padding: 16px 24px;
border-top: 1px solid rgba(255,255,255,0.08);
display: flex;
justify-content: flex-end;
gap: 12px;
}
.ve-settings-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
outline: none;
}
.ve-reset-btn {
background: transparent;
color: rgba(255,255,255,0.6);
border: 1px solid rgba(255,255,255,0.2);
margin-right: auto;
}
.ve-reset-btn:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
.ve-close-btn {
background: linear-gradient(135deg, #6366f1, #a855f7);
color: #fff;
border: none;
box-shadow: 0 4px 12px rgba(168,85,247,0.3);
}
.ve-close-btn:hover {
box-shadow: 0 6px 16px rgba(168,85,247,0.5);
transform: translateY(-1px);
}
`;
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
} else {
const style = document.createElement('style');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
}
/* ═══════════════════════════════════════════
§10 SVG ICONS
═══════════════════════════════════════════ */
const ICONS = {
capture: `<svg viewBox="0 0 24 24"><path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>`,
pip: `<svg viewBox="0 0 24 24"><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z"/></svg>`,
speed: `<svg viewBox="0 0 24 24"><path d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"/></svg>`,
filter: `<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM12 18c-1.3 0-2.5-.4-3.4-1.1l1.5-1.5c.5.4 1.2.6 1.9.6 1.7 0 3-1.3 3-3s-1.3-3-3-3c-.7 0-1.4.2-1.9.6L8.6 9.1C9.5 8.4 10.7 8 12 8c2.8 0 5 2.2 5 5s-2.2 5-5 5z"/></svg>`,
menu: `<svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>`,
reset: `<svg viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`,
fullscreen: `<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>`,
rotate: `<svg viewBox="0 0 24 24"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>`,
mirror: `<svg viewBox="0 0 24 24"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v22h-2V1zm8 10h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-12h2V5h-2v2zm0 8h2v-2h-2v2z"/></svg>`,
};
/* ═══════════════════════════════════════════
§11 GUI TOOLBAR
═══════════════════════════════════════════ */
const guiMap = new WeakMap();
const veRetryTimers = new WeakMap();
let openDropdown = null;
const isYouTube = location.hostname.includes('youtube.com');
let lastYTUrl = location.href;
function closeAllDropdowns() {
document.querySelectorAll('.ve-dropdown.ve-open').forEach(d => d.classList.remove('ve-open'));
openDropdown = null;
}
/* Find the best container to insert our toolbar into */
function getGUIContainer(video) {
if (isYouTube) {
/* YouTube: #movie_player is the most stable container */
const mp = document.getElementById('movie_player')
|| video.closest('#movie_player')
|| video.closest('.html5-video-player')
|| video.closest('[id^="movie_player"]');
if (mp) return mp;
}
/* Generic: try to find a player-like wrapper, fallback to parentElement */
return video.closest('[class*="player"][style*="position"]')
|| video.closest('[class*="player"]')
|| video.parentElement;
}
function tryAttachGUI(video, _retryCount) {
if (!cfg('guiEnabled')) return;
if (!video || !video.isConnected) return;
const r = video.getBoundingClientRect();
if (r.width < 120 || r.height < 80) {
/* Video not sized yet (common during YouTube SPA transitions) — retry with backoff */
if (veRetryTimers.has(video)) return; /* already retrying */
if (_retryCount === undefined) _retryCount = 0;
if (_retryCount < 10) {
const delay = 200 + _retryCount * 200;
veRetryTimers.set(video, setTimeout(() => {
veRetryTimers.delete(video);
tryAttachGUI(video, _retryCount + 1);
}, delay));
}
return;
}
if (guiMap.has(video)) {
const existing = guiMap.get(video);
if (existing.isConnected) return;
existing.remove();
guiMap.delete(video);
}
createGUI(video);
}
/* YouTube SPA: re-attach GUI when URL changes */
if (isYouTube) {
document.addEventListener('yt-navigate-finish', () => {
setTimeout(refreshPlayers, 500);
setTimeout(refreshPlayers, 1500);
setTimeout(refreshPlayers, 3000);
/* Robust resume on YouTube via SPA */
setTimeout(() => restoreProgress(player()), 1000);
setTimeout(() => restoreProgress(player()), 2500);
});
setInterval(() => {
if (location.href !== lastYTUrl) {
lastYTUrl = location.href;
setTimeout(refreshPlayers, 800);
setTimeout(refreshPlayers, 2000);
}
}, 500);
}
function createGUI(video) {
const container = getGUIContainer(video);
if (!container) return;
/* Ensure container is positioned */
const pos = getComputedStyle(container).position;
if (!pos || pos === 'static') container.style.position = 'relative';
const wrap = document.createElement('div');
wrap.className = 've-toolbar-wrap';
wrap.style.cssText = 'position: absolute !important; z-index: 2147483647 !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; pointer-events: none !important;';
const toolbar = document.createElement('div');
toolbar.className = 've-toolbar';
toolbar.style.cssText = 'pointer-events: auto !important; position: absolute !important; z-index: 2147483647 !important;';
const updateCompact = () => {
toolbar.classList.toggle('ve-compact', cfg('forceCompact'));
};
updateCompact();
/* ─── Draggable Toolbar Logic ─── */
let isDragging = false;
let hasDragged = false;
let dragStartX = 0, dragStartY = 0;
let initialDragX = 0, initialDragY = 0;
let currentDragX = 0;
let currentDragY = 0;
toolbar.style.cursor = 'grab';
toolbar.addEventListener('mousedown', (e) => {
if (e.target.closest('button, input, .ve-slider, .ve-dropdown') && !e.target.closest('.ve-brand')) return;
e.preventDefault();
isDragging = true;
hasDragged = false;
toolbar.style.cursor = 'grabbing';
// Convert current display anchors back to physical top/left for pure free-drag manipulation
toolbar.style.transition = 'none';
const tbBase = toolbar.getBoundingClientRect();
const wpBase = wrap.getBoundingClientRect();
currentDragX = tbBase.left - wpBase.left;
currentDragY = tbBase.top - wpBase.top;
dragStartX = e.clientX - currentDragX;
dragStartY = e.clientY - currentDragY;
initialDragX = currentDragX;
initialDragY = currentDragY;
// Override flex anchors to absolute coordinates during dragging
toolbar.style.bottom = 'auto'; toolbar.style.right = 'auto'; toolbar.style.transform = 'none';
toolbar.style.left = currentDragX + 'px';
toolbar.style.top = currentDragY + 'px';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
currentDragX = e.clientX - dragStartX;
currentDragY = e.clientY - dragStartY;
if (Math.abs(currentDragX - initialDragX) > 3 || Math.abs(currentDragY - initialDragY) > 3) {
hasDragged = true;
}
toolbar.style.left = currentDragX + 'px';
toolbar.style.top = currentDragY + 'px';
});
window.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
toolbar.style.cursor = 'grab';
toolbar.style.transition = '';
const wr = wrap.getBoundingClientRect();
const cx = currentDragX + (toolbar.offsetWidth / 2);
const cy = currentDragY + (toolbar.offsetHeight / 2);
let vAnchor = 'middle';
if (cy < wr.height / 3) vAnchor = 'top';
else if (cy > wr.height * 2 / 3) vAnchor = 'bottom';
let hAnchor = 'center';
if (cx < wr.width / 3) hAnchor = 'left';
else if (cx > wr.width * 2 / 3) hAnchor = 'right';
cfg('anchor', (vAnchor === 'middle' && hAnchor === 'center') ? 'bottom-center' : `${vAnchor}-${hAnchor}`);
veUpdatePos();
});
/* ─── Brand ─── */
const brand = document.createElement('span');
brand.className = 've-brand';
brand.textContent = 'VE';
brand.title = 'Drag to move, click to toggle size';
brand.style.cursor = 'grab';
brand.addEventListener('click', (e) => {
e.stopPropagation();
if (hasDragged) { hasDragged = false; return; }
cfg('forceCompact', !cfg('forceCompact'));
updateCompact();
closeAllDropdowns();
});
toolbar.appendChild(brand);
addSep(toolbar);
/* ─── Capture button ─── */
addBtn(toolbar, ICONS.capture, 'Screenshot (Shift+S)', () => captureFrame());
/* ─── Playback Control Panel ─── */
const playWrap = document.createElement('div');
playWrap.className = 've-dropdown-wrap';
const playBtn = document.createElement('button');
playBtn.className = 've-btn';
playBtn.title = 'Playback Controls';
playBtn.innerHTML = secureHTML(ICONS.speed);
playWrap.appendChild(playBtn);
const playPanel = document.createElement('div');
playPanel.className = 've-dropdown';
playPanel.style.minWidth = '220px';
playPanel.style.padding = '12px';
playPanel.innerHTML = secureHTML(`
<div class="ve-panel-row" style="margin-bottom: 12px;" title="Seek Video">
<span class="ve-panel-label ve-time-cur" style="opacity:0.8; min-width: 35px; text-align:left; font-size:11px;">0:00</span>
<input type="range" class="ve-slider ve-progress-slider" min="0" max="1000" step="1" value="0" style="background: rgba(255,255,255,0.3);">
<span class="ve-panel-label ve-time-max" style="opacity:0.8; min-width: 35px; font-size:11px;">0:00</span>
</div>
<div class="ve-panel-row">
<button class="ve-panel-btn ve-rw">⏪ -10s</button>
<button class="ve-panel-btn ve-pp">⏯️ Play</button>
<button class="ve-panel-btn ve-fw">+10s ⏩</button>
</div>
<div class="ve-panel-row" title="Playback Speed">
<span style="font-size:12px; opacity:0.8;">🚀</span>
<input type="range" class="ve-slider ve-speed-slider" min="0.1" max="5" step="0.1" value="1">
<span class="ve-panel-label ve-speed-val">1.0x</span>
</div>
<div class="ve-panel-row" title="Volume Amplification">
<span style="font-size:12px; opacity:0.8;">🔊</span>
<input type="range" class="ve-slider ve-vol-slider" min="0" max="6" step="0.05" value="1">
<span class="ve-panel-label ve-vol-val">100%</span>
</div>
`);
// Attach logic seamlessly to the new DOM elements inside playPanel
playPanel.querySelector('.ve-rw').addEventListener('click', (e) => { e.stopPropagation(); seekBy(-10); });
playPanel.querySelector('.ve-pp').addEventListener('click', (e) => { e.stopPropagation(); togglePlay(); });
playPanel.querySelector('.ve-fw').addEventListener('click', (e) => { e.stopPropagation(); seekBy(10); });
const progressSlider = playPanel.querySelector('.ve-progress-slider');
const timeCur = playPanel.querySelector('.ve-time-cur');
const timeMax = playPanel.querySelector('.ve-time-max');
let isScrubbing = false;
progressSlider.addEventListener('input', (e) => {
e.stopPropagation();
isScrubbing = true;
if (video.duration) {
const targetTime = (parseFloat(progressSlider.value) / 1000) * video.duration;
timeCur.textContent = formatTime(targetTime);
}
});
progressSlider.addEventListener('change', (e) => {
e.stopPropagation();
isScrubbing = false;
if (video.duration) {
video.currentTime = (parseFloat(progressSlider.value) / 1000) * video.duration;
}
});
video.addEventListener('timeupdate', () => {
if (playPanel.classList.contains('ve-open') && !isScrubbing && video.duration) {
const p = (video.currentTime / video.duration) * 1000;
progressSlider.value = p;
timeCur.textContent = formatTime(video.currentTime);
timeMax.textContent = formatTime(video.duration);
}
});
const speedSlider = playPanel.querySelector('.ve-speed-slider');
const speedVal = playPanel.querySelector('.ve-speed-val');
speedSlider.addEventListener('input', (e) => {
e.stopPropagation();
const val = parseFloat(speedSlider.value);
setRate(val);
speedVal.textContent = round1(val) + 'x';
});
const volSlider = playPanel.querySelector('.ve-vol-slider');
const volVal = playPanel.querySelector('.ve-vol-val');
volSlider.addEventListener('input', (e) => {
e.stopPropagation();
const val = parseFloat(volSlider.value);
setVolume(val);
volVal.textContent = Math.round(val * 100) + '%';
});
playWrap.appendChild(playPanel);
toolbar.appendChild(playWrap);
playBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = playPanel.classList.contains('ve-open');
closeAllDropdowns();
if (!isOpen) {
speedSlider.value = round1(video.playbackRate || 1);
speedVal.textContent = speedSlider.value + 'x';
volSlider.value = round2(getEffectiveVolume());
volVal.textContent = Math.round(volSlider.value * 100) + '%';
syncDropdownDirection(playPanel);
playPanel.classList.add('ve-open');
openDropdown = playPanel;
}
});
addSep(toolbar);
/* ─── PiP button ─── */
addBtn(toolbar, ICONS.pip, 'Picture-in-Picture (Shift+P)', () => togglePiP());
/* ─── Fullscreen button ─── */
addBtn(toolbar, ICONS.fullscreen, 'Fullscreen (Enter)', () => toggleFullscreen());
addSep(toolbar);
/* ─── Menu dropdown (filters, transform, etc.) ─── */
const menuWrap = document.createElement('div');
menuWrap.className = 've-dropdown-wrap';
const menuBtn = document.createElement('button');
menuBtn.className = 've-btn';
menuBtn.title = 'More Options';
menuBtn.innerHTML = secureHTML(ICONS.menu);
menuWrap.appendChild(menuBtn);
const menuDropdown = document.createElement('div');
menuDropdown.className = 've-dropdown';
menuDropdown.style.minWidth = '160px';
const syncDropdownDirection = (dropdown) => {
const anchor = String(cfg('anchor') || 'bottom-center');
let shouldDropDown;
if (anchor.includes('top')) {
shouldDropDown = true;
} else if (anchor.includes('bottom')) {
shouldDropDown = false;
} else {
/* Middle anchor — fall back to space-based detection */
const wrapRect = wrap.getBoundingClientRect();
const parentRect = dropdown.parentElement.getBoundingClientRect();
const dropdownHeight = Math.max(dropdown.scrollHeight || 0, 220);
const spaceAbove = parentRect.top - wrapRect.top;
const spaceBelow = wrapRect.bottom - parentRect.bottom;
shouldDropDown = spaceAbove < dropdownHeight + 16 && spaceBelow > spaceAbove;
}
dropdown.classList.toggle('ve-drop-down', shouldDropDown);
};
const menuItems = [
{ label: '↻ Rotate 90°', fn: () => setRotate90() },
{ label: '↔ Mirror Horizontal', fn: () => toggleMirrorH() },
{ label: '↕ Mirror Vertical', fn: () => toggleMirrorV() },
{ label: '🔆 Brightness +', fn: () => adjustFilter('brightness', 0.1, 'Brightness') },
{ label: '🔅 Brightness −', fn: () => adjustFilter('brightness', -0.1, 'Brightness') },
{ label: '◐ Contrast +', fn: () => adjustFilter('contrast', 0.1, 'Contrast') },
{ label: '◑ Contrast −', fn: () => adjustFilter('contrast', -0.1, 'Contrast') },
{ label: '🎨 Saturation +', fn: () => adjustFilter('saturate', 0.1, 'Saturation') },
{ label: '🎨 Saturation −', fn: () => adjustFilter('saturate', -0.1, 'Saturation') },
{ label: '🔄 Reset All', fn: () => resetFilterAndTransform() },
{ label: '📍 Toggle Progress Save', fn: () => toggleProgressSave() },
{ label: '✅ Toggle Video Enhancer', fn: () => toggleExtension() },
{ label: '🎛 Toggle GUI Toolbar', fn: () => toggleGUI() },
{ label: '⚙ Shortcuts', fn: () => { closeAllDropdowns(); openSettingsModal(); } },
];
menuItems.forEach(({ label, fn }) => {
const item = document.createElement('button');
item.className = 've-dropdown-item';
item.textContent = label;
item.addEventListener('click', (e) => {
e.stopPropagation();
fn();
});
menuDropdown.appendChild(item);
});
menuWrap.appendChild(menuDropdown);
toolbar.appendChild(menuWrap);
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = menuDropdown.classList.contains('ve-open');
closeAllDropdowns();
if (!isOpen) {
syncDropdownDirection(menuDropdown);
menuDropdown.classList.add('ve-open');
openDropdown = menuDropdown;
}
});
wrap.appendChild(toolbar);
container.appendChild(wrap);
guiMap.set(video, wrap);
/* ─── Position tracker: keep toolbar locked over the video ─── */
let vePosRAF = 0, vePosDirty = true;
const veUpdatePos = () => {
const r = video.getBoundingClientRect();
if (r.width >= 120 && r.height >= 80) {
wrap.style.display = 'block';
wrap.style.setProperty('--ve-max-h', Math.max(140, r.height - 90) + 'px');
if (container.tagName === 'BODY' || container.tagName === 'HTML') {
wrap.style.position = 'fixed';
wrap.style.top = r.top + 'px';
wrap.style.left = r.left + 'px';
wrap.style.width = r.width + 'px';
wrap.style.height = r.height + 'px';
}
if (!isDragging) {
const anchor = String(cfg('anchor') || 'bottom-center');
toolbar.style.top = ''; toolbar.style.bottom = '';
toolbar.style.left = ''; toolbar.style.right = '';
toolbar.style.transform = '';
const p = '20px', vp = '60px'; // padding
if (anchor.includes('top')) toolbar.style.top = p;
else if (anchor.includes('bottom')) toolbar.style.bottom = vp;
else { toolbar.style.top = '50%'; toolbar.style.transform = 'translateY(-50%)'; }
if (anchor.includes('left')) toolbar.style.left = p;
else if (anchor.includes('right')) toolbar.style.right = p;
else {
toolbar.style.left = '50%';
toolbar.style.transform += toolbar.style.transform ? ' translateX(-50%)' : 'translateX(-50%)';
}
syncDropdownDirection(menuDropdown);
syncDropdownDirection(playPanel);
}
} else {
wrap.style.display = 'none';
}
};
const vePosLoop = () => {
if (vePosDirty) { vePosDirty = false; veUpdatePos(); }
if (video.isConnected && wrap.isConnected) vePosRAF = requestAnimationFrame(vePosLoop);
};
const veMarkDirty = () => { vePosDirty = true; };
vePosRAF = requestAnimationFrame(vePosLoop);
window.addEventListener('scroll', veMarkDirty, { passive: true });
window.addEventListener('resize', veMarkDirty, { passive: true });
let veResizeObs;
try { veResizeObs = new ResizeObserver(veMarkDirty); veResizeObs.observe(video); } catch { }
/* ─── Show/hide logic ─── */
let hideTimer = null;
let isHovering = false;
const alwaysShow = false;
let usingNativeSync = false;
function showBar() {
if (!wrap.classList.contains('ve-visible')) wrap.classList.add('ve-visible');
}
function hideBar() {
if (wrap.classList.contains('ve-visible')) {
wrap.classList.remove('ve-visible');
closeAllDropdowns();
}
}
/* Sync perfectly with native YouTube controls if available */
if (isYouTube && container.classList.contains('html5-video-player')) {
usingNativeSync = true;
const nativeObserver = new MutationObserver(() => {
if (!container.classList.contains('ytp-autohide')) {
showBar();
} else {
hideBar();
}
});
nativeObserver.observe(container, { attributes: true, attributeFilter: ['class'] });
if (!container.classList.contains('ytp-autohide')) showBar(); else hideBar();
}
function scheduleHide(ms = 2500) {
if (usingNativeSync) return;
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (!isHovering && !video.paused && !alwaysShow) hideBar();
}, ms);
}
/* Show on pause */
video.addEventListener('pause', () => { if (!usingNativeSync) showBar(); });
video.addEventListener('ended', () => { if (!usingNativeSync) showBar(); });
/* Show briefly on play, then auto-hide */
video.addEventListener('play', () => {
if (usingNativeSync) return;
showBar();
scheduleHide(2000);
});
/* Show on mouse move over video, hide after idle */
const onMouseMove = debounce(() => {
if (usingNativeSync) return;
showBar();
scheduleHide(3000);
}, 50);
container.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });
container.addEventListener('mouseenter', () => {
showBar();
scheduleHide(3000);
}, { capture: true, passive: true });
container.addEventListener('mouseleave', () => {
if (!video.paused && !alwaysShow) {
scheduleHide(800);
}
});
/* Keep visible when hovering toolbar buttons */
toolbar.addEventListener('mouseenter', () => {
isHovering = true;
clearTimeout(hideTimer);
showBar();
});
toolbar.addEventListener('mouseleave', () => {
isHovering = false;
if (!video.paused) scheduleHide(1500);
});
/* Close dropdowns when clicking outside */
document.addEventListener('click', (e) => {
if (openDropdown && !wrap.contains(e.target)) {
closeAllDropdowns();
}
});
/* Sync speed badge when rate changes via keyboard */
const rateObserver = setInterval(() => {
if (!video.isConnected || !wrap.isConnected) {
clearInterval(rateObserver);
cancelAnimationFrame(vePosRAF);
window.removeEventListener('scroll', veMarkDirty);
window.removeEventListener('resize', veMarkDirty);
if (veResizeObs) veResizeObs.disconnect();
wrap.remove();
return;
}
if (playPanel && playPanel.classList.contains('ve-open')) {
const curRate = round1(video.playbackRate);
const curVol = round2(getEffectiveVolume());
if (parseFloat(speedSlider.value) !== curRate) {
speedSlider.value = curRate;
speedVal.textContent = curRate + 'x';
}
if (parseFloat(volSlider.value) !== curVol) {
volSlider.value = curVol;
volVal.textContent = Math.round(curVol * 100) + '%';
}
}
updateCompact();
}, 500);
/* Restore progress on first play */
video.addEventListener('playing', function onFirstPlay() {
restoreProgress(video);
video.removeEventListener('playing', onFirstPlay);
}, { once: true });
/* If already paused with content, show immediately */
if (video.paused && video.currentTime > 0) {
showBar();
}
/* Also show if video has loaded data */
if (video.readyState >= 2 && video.paused) {
showBar();
}
/* Reveal once on attach so the toolbar does not depend on a missed hover/play event. */
showBar();
if (!video.paused) {
scheduleHide(2200);
}
}
function secureHTML(str) {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
if (!window.veTrustPolicy) {
try { window.veTrustPolicy = window.trustedTypes.createPolicy('ve-trust', { createHTML: s => s }); } catch (e) { }
}
return window.veTrustPolicy ? window.veTrustPolicy.createHTML(str) : str;
}
return str;
}
function addBtn(parent, iconSvg, title, onClick) {
const btn = document.createElement('button');
btn.className = 've-btn';
btn.title = title;
btn.innerHTML = secureHTML(iconSvg);
btn.addEventListener('click', (e) => {
e.stopPropagation();
onClick();
});
parent.appendChild(btn);
return btn;
}
function addSep(parent) {
const sep = document.createElement('div');
sep.className = 've-separator';
parent.appendChild(sep);
}
/* ═══════════════════════════════════════════
§11b SETTINGS MODAL (Keyboard Shortcuts)
═══════════════════════════════════════════ */
function prettifyKey(k) {
return k.split('+').map(p => {
if (p === ' ') return 'Space';
if (p === 'arrowright') return '→';
if (p === 'arrowleft') return '←';
if (p === 'arrowup') return '↑';
if (p === 'arrowdown') return '↓';
return p.charAt(0).toUpperCase() + p.slice(1);
}).join(' + ');
}
function openSettingsModal() {
if (document.querySelector('.ve-settings-overlay')) return;
const bindings = getBindings();
/* Build a reverse map: actionName → key */
const actionToKey = {};
for (const [key, action] of Object.entries(bindings)) {
actionToKey[action] = key;
}
const overlay = document.createElement('div');
overlay.className = 've-settings-overlay';
const modal = document.createElement('div');
modal.className = 've-settings-modal';
/* Header */
modal.innerHTML = secureHTML(`
<div class="ve-settings-header">
<span class="ve-settings-title">⚙ Keyboard Shortcuts</span>
<span class="ve-settings-subtitle">Click a key field and press your new shortcut (Press Backspace, Delete or Escape to disable)</span>
</div>
<div class="ve-settings-list"></div>
<div class="ve-settings-footer">
<button class="ve-settings-btn ve-reset-btn">Reset to Defaults</button>
<button class="ve-settings-btn ve-close-btn">Close</button>
</div>
`);
const list = modal.querySelector('.ve-settings-list');
/* Sorted actions by category */
const orderedActions = Object.keys(ACTIONS);
orderedActions.forEach(actionName => {
const action = ACTIONS[actionName];
const currentKey = actionToKey[actionName] || '';
const row = document.createElement('div');
row.className = 've-settings-row';
const label = document.createElement('span');
label.className = 've-settings-label';
label.textContent = action.label;
const keyBtn = document.createElement('button');
keyBtn.className = 've-settings-key';
keyBtn.textContent = currentKey ? prettifyKey(currentKey) : '—';
keyBtn.dataset.action = actionName;
keyBtn.dataset.currentKey = currentKey;
keyBtn.addEventListener('click', () => {
/* Reset any other recording buttons */
list.querySelectorAll('.ve-settings-key.ve-recording').forEach(b => {
b.classList.remove('ve-recording');
b.textContent = b.dataset.currentKey ? prettifyKey(b.dataset.currentKey) : '—';
});
keyBtn.classList.add('ve-recording');
keyBtn.textContent = '⏺ Press a key...';
});
row.appendChild(label);
row.appendChild(keyBtn);
list.appendChild(row);
});
/* Global key listener for recording */
function onRecordKey(e) {
const recording = list.querySelector('.ve-settings-key.ve-recording');
if (!recording) return;
e.preventDefault();
e.stopPropagation();
const key = e.key.toLowerCase();
if (['control', 'alt', 'shift', 'meta'].includes(key)) return; /* wait for actual key */
const actionName = recording.dataset.action;
/* Remove old binding for this action */
const oldKey = recording.dataset.currentKey;
if (key === 'escape' || key === 'backspace' || key === 'delete') {
if (oldKey && bindings[oldKey] === actionName) delete bindings[oldKey];
recording.dataset.currentKey = '';
recording.textContent = '—';
recording.classList.remove('ve-recording');
saveBindings(bindings);
return;
}
const newBinding = eventToBindingKey(e);
if (oldKey && bindings[oldKey] === actionName) delete bindings[oldKey];
/* Remove any existing binding for this key combo */
if (bindings[newBinding]) {
const conflictAction = bindings[newBinding];
const conflictBtn = list.querySelector(`[data-action="${conflictAction}"]`);
if (conflictBtn) { conflictBtn.textContent = '—'; conflictBtn.dataset.currentKey = ''; }
delete bindings[newBinding];
}
/* Set new binding */
bindings[newBinding] = actionName;
recording.dataset.currentKey = newBinding;
recording.textContent = prettifyKey(newBinding);
recording.classList.remove('ve-recording');
saveBindings(bindings);
}
document.addEventListener('keydown', onRecordKey, true);
/* Reset to defaults */
modal.querySelector('.ve-reset-btn').addEventListener('click', () => {
Object.keys(bindings).forEach(k => delete bindings[k]);
Object.assign(bindings, { ...DEFAULT_BINDINGS });
saveBindings(bindings);
/* Refresh the key display */
const newActionToKey = {};
for (const [key, action] of Object.entries(bindings)) newActionToKey[action] = key;
list.querySelectorAll('.ve-settings-key').forEach(btn => {
const k = newActionToKey[btn.dataset.action] || '';
btn.textContent = k ? prettifyKey(k) : '—';
btn.dataset.currentKey = k;
btn.classList.remove('ve-recording');
});
tips('🔄 Shortcuts reset to defaults');
});
/* Close */
function closeModal() {
document.removeEventListener('keydown', onRecordKey, true);
overlay.remove();
}
modal.querySelector('.ve-close-btn').addEventListener('click', closeModal);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); });
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
/* ═══════════════════════════════════════════
§12 TAMPERMONKEY MENUS & GLOBAL TOGGLES
═══════════════════════════════════════════ */
function toggleExtension() {
const enabled = !cfg('enabled');
cfg('enabled', enabled);
tips(enabled ? '✅ Video Enhancer: ON' : '❌ Video Enhancer: OFF');
}
function toggleGUI() {
const guiEnabled = !cfg('guiEnabled');
cfg('guiEnabled', guiEnabled);
if (!guiEnabled) {
document.querySelectorAll('.ve-toolbar-wrap').forEach(el => el.remove());
} else {
refreshPlayers();
}
tips(guiEnabled ? '🎛 GUI: ON' : '🎛 GUI: OFF');
}
function registerMenus() {
try {
GM_registerMenuCommand('Toggle Video Enhancer', toggleExtension);
GM_registerMenuCommand('Toggle GUI Toolbar', toggleGUI);
GM_registerMenuCommand('Toggle Progress Save', toggleProgressSave);
GM_registerMenuCommand('Configure Shortcuts', () => openSettingsModal());
} catch { }
}
/* ═══════════════════════════════════════════
§13 INITIALIZATION
═══════════════════════════════════════════ */
function init() {
if (!document.documentElement) {
setTimeout(init, 50);
return;
}
injectStyles();
registerMenus();
/* Bind keyboard events */
document.addEventListener('keydown', handleKeydown, true);
/* Detect videos via MutationObserver */
const observer = new MutationObserver(debounce(() => {
refreshPlayers();
}, 500));
function startObserving() {
if (!document.body) {
setTimeout(startObserving, 50);
return;
}
observer.observe(document.body, { childList: true, subtree: true });
/* Initial scan */
refreshPlayers();
/* Periodic fallback for SPAs and dynamic content */
setInterval(refreshPlayers, 2500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startObserving);
} else {
startObserving();
}
}
/* Start */
try {
init();
} catch (e) {
console.error('[Video Enhancer] Init error:', e);
setTimeout(init, 500);
}
})();