Adjust HTML5 video playback speed and volume on webpages.
// ==UserScript==
// @name Video Control
// @namespace https://example.local/video-control
// @version 1.2.0
// @description Adjust HTML5 video playback speed and volume on webpages.
// @author local
// @match http://*/*
// @match https://*/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function () {
"use strict";
const DEFAULT_SETTINGS = Object.freeze({
speed: 1,
volume: 100,
collapsed: false,
antiPause: true,
siteMemory: false,
siteProfiles: {}
});
const LIMITS = Object.freeze({
minSpeed: 0.5,
maxSpeed: 5,
minVolume: 0,
maxVolume: 100
});
const STORAGE_KEY = "video-control-settings";
const BOOTSTRAP_INTERVAL_MS = 1000;
const RESUME_WINDOW_MS = 8000;
const controlledVideos = new WeakSet();
const videoStates = new WeakMap();
let settings = loadSettings();
let host = null;
let shadow = null;
let ui = {};
let pageIsBlurred = false;
let lastUrl = window.location.href;
let lastUserActivationAt = 0;
let bootstrapTimer = null;
let settingsSnapshot = "";
function isTopFrame() {
try {
return window.top === window;
} catch (error) {
return true;
}
}
function getSiteKey() {
let hostname = "";
if (!isTopFrame() && document.referrer) {
try {
hostname = new URL(document.referrer).hostname;
} catch (error) {
hostname = "";
}
}
if (!hostname) {
hostname = window.location.hostname;
}
return String(hostname || "local")
.toLowerCase()
.replace(/^www\./, "");
}
function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeSettings(nextSettings) {
const speed = Number(nextSettings && nextSettings.speed);
const volume = Number(nextSettings && nextSettings.volume);
const siteProfiles = isRecord(nextSettings && nextSettings.siteProfiles)
? { ...nextSettings.siteProfiles }
: {};
return {
speed: clamp(Number.isFinite(speed) ? speed : DEFAULT_SETTINGS.speed, LIMITS.minSpeed, LIMITS.maxSpeed),
volume: clamp(Number.isFinite(volume) ? Math.round(volume) : DEFAULT_SETTINGS.volume, LIMITS.minVolume, LIMITS.maxVolume),
collapsed: Boolean(nextSettings && nextSettings.collapsed),
antiPause: nextSettings && Object.prototype.hasOwnProperty.call(nextSettings, "antiPause")
? Boolean(nextSettings.antiPause)
: DEFAULT_SETTINGS.antiPause,
siteMemory: Boolean(nextSettings && nextSettings.siteMemory),
siteProfiles
};
}
function normalizeProfile(profile, fallback) {
const speed = Number(profile && profile.speed);
const volume = Number(profile && profile.volume);
return {
speed: clamp(Number.isFinite(speed) ? speed : fallback.speed, LIMITS.minSpeed, LIMITS.maxSpeed),
volume: clamp(Number.isFinite(volume) ? Math.round(volume) : fallback.volume, LIMITS.minVolume, LIMITS.maxVolume),
antiPause: profile && Object.prototype.hasOwnProperty.call(profile, "antiPause")
? Boolean(profile.antiPause)
: fallback.antiPause
};
}
function applySiteProfile(baseSettings) {
const normalized = normalizeSettings(baseSettings);
if (!normalized.siteMemory) {
return normalized;
}
const profile = normalized.siteProfiles[getSiteKey()];
if (!profile) {
return normalized;
}
return {
...normalized,
...normalizeProfile(profile, normalized)
};
}
function readValue(key, fallback) {
try {
if (typeof GM_getValue === "function") {
return GM_getValue(key, fallback);
}
} catch (error) {
// Fall back to localStorage below.
}
try {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch (error) {
return fallback;
}
}
function writeValue(key, value) {
try {
if (typeof GM_setValue === "function") {
GM_setValue(key, value);
return;
}
} catch (error) {
// Fall back to localStorage below.
}
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
// Storage can be disabled on some pages.
}
}
function loadSettings() {
return applySiteProfile(readValue(STORAGE_KEY, DEFAULT_SETTINGS));
}
function saveSettings() {
const storedSettings = normalizeSettings(readValue(STORAGE_KEY, DEFAULT_SETTINGS));
const nextSettings = {
...storedSettings,
speed: settings.speed,
volume: settings.volume,
collapsed: settings.collapsed,
antiPause: settings.antiPause,
siteMemory: settings.siteMemory,
siteProfiles: { ...storedSettings.siteProfiles }
};
if (settings.siteMemory) {
nextSettings.siteProfiles[getSiteKey()] = {
speed: settings.speed,
volume: settings.volume,
antiPause: settings.antiPause
};
}
writeValue(STORAGE_KEY, nextSettings);
settingsSnapshot = JSON.stringify(applySiteProfile(nextSettings));
}
function refreshSettingsFromStorage() {
const nextSettings = loadSettings();
const nextSnapshot = JSON.stringify(nextSettings);
if (nextSnapshot === settingsSnapshot) {
return false;
}
settings = nextSettings;
settingsSnapshot = nextSnapshot;
render();
return true;
}
function formatSpeed(speed) {
return `${speed.toFixed(2).replace(/\.?0+$/, "")}x`;
}
function isVideoElement(node) {
return Boolean(
node
&& node.nodeType === Node.ELEMENT_NODE
&& String(node.tagName).toLowerCase() === "video"
&& typeof node.play === "function"
);
}
function applySettingsToVideo(video) {
try {
video.playbackRate = settings.speed;
video.defaultPlaybackRate = settings.speed;
} catch (error) {
// Some protected players reject playback-rate changes.
}
try {
video.volume = settings.volume / 100;
video.muted = settings.volume === 0;
} catch (error) {
// Keep applying settings to other videos if this one is restricted.
}
}
function getVideoState(video) {
let state = videoStates.get(video);
if (!state) {
state = {
wasPlaying: false,
lastPlayingAt: 0,
resumeTimer: null,
resumeAttempts: 0
};
videoStates.set(video, state);
}
return state;
}
function isPageInactive() {
return pageIsBlurred || document.hidden || document.visibilityState === "hidden" || !document.hasFocus();
}
function rememberUserActivation() {
lastUserActivationAt = Date.now();
}
function canAutoResume(video) {
const state = getVideoState(video);
const wasRecentlyPlaying = state.wasPlaying || Date.now() - state.lastPlayingAt < RESUME_WINDOW_MS;
const userJustInteracted = Date.now() - lastUserActivationAt < 600;
return settings.antiPause
&& wasRecentlyPlaying
&& !userJustInteracted
&& isPageInactive()
&& !video.ended
&& video.readyState > 0;
}
function tryResume(video) {
if (!settings.antiPause || !video.paused || video.ended) {
return;
}
applySettingsToVideo(video);
try {
const result = video.play();
if (result && typeof result.catch === "function") {
result.catch(() => {
// Browsers can reject play() without a prior user gesture.
});
}
} catch (error) {
// Some players block programmatic playback.
}
}
function scheduleResume(video) {
if (!canAutoResume(video)) {
return;
}
const state = getVideoState(video);
window.clearTimeout(state.resumeTimer);
state.resumeAttempts = Math.min(state.resumeAttempts + 1, 6);
state.resumeTimer = window.setTimeout(() => {
if (canAutoResume(video)) {
tryResume(video);
}
}, state.resumeAttempts * 120);
}
function bindVideo(video) {
if (!isVideoElement(video)) {
return;
}
applySettingsToVideo(video);
if (controlledVideos.has(video)) {
return;
}
controlledVideos.add(video);
video.addEventListener("loadedmetadata", () => applySettingsToVideo(video));
video.addEventListener("playing", () => {
const state = getVideoState(video);
state.wasPlaying = true;
state.lastPlayingAt = Date.now();
state.resumeAttempts = 0;
applySettingsToVideo(video);
});
video.addEventListener("play", () => {
const state = getVideoState(video);
state.wasPlaying = true;
state.lastPlayingAt = Date.now();
applySettingsToVideo(video);
});
video.addEventListener("pause", () => {
scheduleResume(video);
}, true);
video.addEventListener("ratechange", () => {
if (video.playbackRate !== settings.speed) {
window.requestAnimationFrame(() => applySettingsToVideo(video));
}
});
video.addEventListener("volumechange", () => {
const expectedVolume = settings.volume / 100;
if (Math.abs(video.volume - expectedVolume) > 0.001 || video.muted !== (settings.volume === 0)) {
window.requestAnimationFrame(() => applySettingsToVideo(video));
}
});
}
function scanVideos(root) {
if (!root) {
return;
}
if (isVideoElement(root)) {
bindVideo(root);
return;
}
if (typeof root.querySelectorAll === "function") {
root.querySelectorAll("video").forEach(bindVideo);
root.querySelectorAll("*").forEach((element) => {
if (element.shadowRoot) {
scanVideos(element.shadowRoot);
}
});
root.querySelectorAll("iframe").forEach((iframe) => {
try {
if (iframe.contentDocument) {
scanVideos(iframe.contentDocument);
}
} catch (error) {
// Cross-origin frames are handled by their own userscript instance.
}
});
}
}
function applyToPage() {
scanVideos(document);
}
function updateSettings(partial) {
settings = normalizeSettings({ ...settings, ...partial });
saveSettings();
render();
applyToPage();
}
function toggleAntiPause() {
updateSettings({ antiPause: !settings.antiPause });
}
function toggleSiteMemory() {
updateSettings({ siteMemory: !settings.siteMemory });
}
function setCollapsed(collapsed) {
updateSettings({ collapsed });
}
function createUi() {
if (!isTopFrame()) {
return;
}
if (host) {
if (!document.documentElement.contains(host)) {
document.documentElement.appendChild(host);
}
return;
}
host = document.createElement("div");
host.id = "video-control-userscript";
host.style.position = "fixed";
host.style.right = "max(12px, env(safe-area-inset-right))";
host.style.bottom = "max(12px, env(safe-area-inset-bottom))";
host.style.zIndex = "2147483647";
shadow = host.attachShadow({ mode: "closed" });
shadow.innerHTML = `
<style>
:host {
color-scheme: light dark;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-tap-highlight-color: transparent;
}
* {
box-sizing: border-box;
}
button,
input {
font: inherit;
}
.panel {
width: min(300px, calc(100vw - 24px));
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
color: #172033;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.2);
backdrop-filter: blur(10px);
overflow: hidden;
}
.panel.is-collapsed {
display: none;
}
.mini {
display: none;
min-width: 52px;
min-height: 44px;
border: 1px solid rgba(15, 23, 42, 0.16);
border-radius: 999px;
padding: 0 12px;
background: #0f766e;
color: #ffffff;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
font-size: 13px;
font-weight: 750;
cursor: pointer;
touch-action: manipulation;
}
.mini.is-visible {
display: inline-flex;
align-items: center;
justify-content: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgba(15, 23, 42, 0.1);
}
.title {
margin: 0;
font-size: 14px;
font-weight: 750;
}
.hide {
min-width: 36px;
min-height: 34px;
border: 1px solid rgba(15, 23, 42, 0.16);
border-radius: 6px;
background: #ffffff;
color: #172033;
cursor: pointer;
touch-action: manipulation;
}
.body {
display: grid;
gap: 12px;
padding: 12px;
}
.control {
display: grid;
gap: 8px;
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 34px;
font-size: 13px;
font-weight: 650;
}
.switch-row input {
width: 20px;
height: 20px;
accent-color: #0f766e;
}
.switches {
display: grid;
gap: 6px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
font-weight: 650;
}
output {
color: #0f766e;
font-variant-numeric: tabular-nums;
font-weight: 800;
}
input[type="range"] {
width: 100%;
min-height: 28px;
accent-color: #0f766e;
}
.presets {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.presets button {
min-height: 34px;
border: 1px solid rgba(15, 23, 42, 0.16);
border-radius: 6px;
background: #f8fafc;
color: #172033;
font-size: 12px;
cursor: pointer;
touch-action: manipulation;
}
.presets button:active,
.hide:active,
.mini:active {
transform: translateY(1px);
}
@media (max-width: 520px) {
.panel {
width: min(280px, calc(100vw - 20px));
}
.body {
gap: 10px;
padding: 10px;
}
.presets {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (prefers-color-scheme: dark) {
.panel {
border-color: rgba(226, 232, 240, 0.16);
background: rgba(23, 27, 34, 0.96);
color: #edf2f7;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.34);
}
.header {
border-bottom-color: rgba(226, 232, 240, 0.12);
}
.hide,
.presets button {
border-color: rgba(226, 232, 240, 0.16);
background: #222936;
color: #edf2f7;
}
output {
color: #5eead4;
}
}
</style>
<button class="mini" type="button" aria-label="显示视频控制">VC</button>
<section class="panel" aria-label="视频控制">
<header class="header">
<h2 class="title">视频控制</h2>
<button class="hide" type="button" aria-label="隐藏视频控制">隐藏</button>
</header>
<div class="body">
<div class="control">
<div class="row">
<label for="vc-speed">倍速</label>
<output id="vc-speed-value" for="vc-speed">1x</output>
</div>
<input id="vc-speed" type="range" min="0.5" max="5" step="0.05" value="1">
<div class="presets" aria-label="常用倍速">
<button type="button" data-speed="0.5">0.5x</button>
<button type="button" data-speed="1">1x</button>
<button type="button" data-speed="1.5">1.5x</button>
<button type="button" data-speed="2">2x</button>
<button type="button" data-speed="3">3x</button>
<button type="button" data-speed="5">5x</button>
</div>
</div>
<div class="control">
<div class="row">
<label for="vc-volume">音量</label>
<output id="vc-volume-value" for="vc-volume">100%</output>
</div>
<input id="vc-volume" type="range" min="0" max="100" step="1" value="100">
<div class="presets" aria-label="常用音量">
<button type="button" data-volume="0">0%</button>
<button type="button" data-volume="5">5%</button>
<button type="button" data-volume="8">8%</button>
<button type="button" data-volume="10">10%</button>
<button type="button" data-volume="25">25%</button>
<button type="button" data-volume="50">50%</button>
<button type="button" data-volume="100">100%</button>
</div>
</div>
<div class="switches">
<label class="switch-row" for="vc-site-memory">
<span>本站记忆</span>
<input id="vc-site-memory" type="checkbox">
</label>
<label class="switch-row" for="vc-anti-pause">
<span>防失焦暂停</span>
<input id="vc-anti-pause" type="checkbox">
</label>
</div>
</div>
</section>
`;
ui = {
panel: shadow.querySelector(".panel"),
mini: shadow.querySelector(".mini"),
hide: shadow.querySelector(".hide"),
speed: shadow.getElementById("vc-speed"),
speedValue: shadow.getElementById("vc-speed-value"),
volume: shadow.getElementById("vc-volume"),
volumeValue: shadow.getElementById("vc-volume-value"),
antiPause: shadow.getElementById("vc-anti-pause"),
siteMemory: shadow.getElementById("vc-site-memory")
};
ui.speed.addEventListener("input", () => updateSettings({ speed: Number(ui.speed.value) }));
ui.volume.addEventListener("input", () => updateSettings({ volume: Number(ui.volume.value) }));
ui.antiPause.addEventListener("change", () => updateSettings({ antiPause: ui.antiPause.checked }));
ui.siteMemory.addEventListener("change", () => updateSettings({ siteMemory: ui.siteMemory.checked }));
ui.hide.addEventListener("click", () => setCollapsed(true));
ui.mini.addEventListener("click", () => setCollapsed(false));
shadow.querySelectorAll("[data-speed]").forEach((button) => {
button.addEventListener("click", () => updateSettings({ speed: Number(button.dataset.speed) }));
});
shadow.querySelectorAll("[data-volume]").forEach((button) => {
button.addEventListener("click", () => updateSettings({ volume: Number(button.dataset.volume) }));
});
document.documentElement.appendChild(host);
render();
}
function render() {
if (!ui.panel) {
return;
}
ui.speed.value = String(settings.speed);
ui.speedValue.value = formatSpeed(settings.speed);
ui.volume.value = String(settings.volume);
ui.volumeValue.value = `${settings.volume}%`;
ui.antiPause.checked = settings.antiPause;
ui.siteMemory.checked = settings.siteMemory;
ui.panel.classList.toggle("is-collapsed", settings.collapsed);
ui.mini.classList.toggle("is-visible", settings.collapsed);
}
function watchForVideos() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(scanVideos);
}
});
observer.observe(document.documentElement || document, {
childList: true,
subtree: true
});
}
function installPageGuards() {
const markBlurred = () => {
pageIsBlurred = true;
window.setTimeout(applyToPage, 0);
window.setTimeout(resumePausedVideos, 160);
};
const markFocused = () => {
pageIsBlurred = false;
applyToPage();
};
["pointerdown", "mousedown", "touchstart", "keydown"].forEach((eventName) => {
window.addEventListener(eventName, rememberUserActivation, true);
document.addEventListener(eventName, rememberUserActivation, true);
});
window.addEventListener("blur", markBlurred, true);
window.addEventListener("focus", markFocused, true);
document.addEventListener("visibilitychange", () => {
if (document.hidden || document.visibilityState === "hidden") {
markBlurred();
} else {
markFocused();
}
}, true);
["mouseleave", "mouseout", "pointerleave"].forEach((eventName) => {
document.addEventListener(eventName, () => {
if (!document.hasFocus()) {
markBlurred();
}
}, true);
});
patchPauseMethod();
}
function patchPauseMethod() {
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "pause");
if (!descriptor || typeof descriptor.value !== "function" || descriptor.value.__videoControlPatched) {
return;
}
const nativePause = descriptor.value;
const patchedPause = function () {
if (isVideoElement(this) && canAutoResume(this)) {
scheduleResume(this);
return undefined;
}
return nativePause.apply(this, arguments);
};
patchedPause.__videoControlPatched = true;
try {
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
...descriptor,
value: patchedPause
});
} catch (error) {
// Some browsers/pages prevent prototype patching.
}
}
function resumePausedVideos() {
const videos = [];
scanVideos(document);
document.querySelectorAll("video").forEach((video) => videos.push(video));
videos.forEach((video) => {
if (canAutoResume(video)) {
tryResume(video);
}
});
}
function installHistoryGuards() {
const scheduleAfterNavigation = () => {
window.setTimeout(bootstrap, 80);
window.setTimeout(bootstrap, 500);
window.setTimeout(bootstrap, 1500);
};
["pushState", "replaceState"].forEach((methodName) => {
const original = history[methodName];
if (typeof original !== "function" || original.__videoControlPatched) {
return;
}
history[methodName] = function () {
const result = original.apply(this, arguments);
scheduleAfterNavigation();
return result;
};
history[methodName].__videoControlPatched = true;
});
window.addEventListener("popstate", scheduleAfterNavigation, true);
window.addEventListener("hashchange", scheduleAfterNavigation, true);
}
function bootstrap() {
if (!document.documentElement) {
return;
}
const changed = refreshSettingsFromStorage();
createUi();
applyToPage();
if (changed) {
window.setTimeout(applyToPage, 80);
}
if (lastUrl !== window.location.href) {
lastUrl = window.location.href;
window.setTimeout(applyToPage, 300);
window.setTimeout(applyToPage, 1200);
}
}
function startBootstrapLoop() {
window.clearInterval(bootstrapTimer);
bootstrapTimer = window.setInterval(bootstrap, BOOTSTRAP_INTERVAL_MS);
bootstrap();
}
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== "function") {
return;
}
GM_registerMenuCommand("显示/隐藏视频控制", () => {
setCollapsed(!settings.collapsed);
});
GM_registerMenuCommand("开启/关闭防失焦暂停", () => {
toggleAntiPause();
});
GM_registerMenuCommand("开启/关闭本站记忆", () => {
toggleSiteMemory();
});
GM_registerMenuCommand("恢复默认视频设置", () => {
updateSettings(DEFAULT_SETTINGS);
});
}
installPageGuards();
installHistoryGuards();
startBootstrapLoop();
watchForVideos();
if (isTopFrame()) {
registerMenuCommands();
}
})();