// ==UserScript==
// @name chimo-chimo-loop - HTML5 Video Enhancer
// @name:zh-CN chimo-chimo-loop (HTML5 视频增强器)
// @namespace https://github.com/ryu-dayo/chimo-chimo-loop
// @version 1.3.0
// @description Supercharge HTML5 video playback with Picture-in-Picture (PiP), A-B loop, speed control, lossless screenshots, and advanced media statistics.
// @description:zh-CN HTML5 视频增强神器:支持画中画、A-B区间循环、倍速调节、无损截图以及硬核的媒体统计信息(实时FPS、色彩空间等)。
// @author ryu-dayo
// @match https://www.douyin.com/*
// @match https://www.instagram.com/*
// @match https://www.threads.com/*
// @match https://www.xiaohongshu.com/*
// @match https://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=douyin.com
// @grant none
// @license GPL-3.0
// @downloadURL none
// ==/UserScript==
(function () {
'use strict';
const ICONS = {
enterPip: `data:image/svg+xml,`,
exitPip: `data:image/svg+xml,`,
enableLoop: `data:image/svg+xml,`,
disableLoop: `data:image/svg+xml,`,
more: `data:image/svg+xml,`,
setPointB: `data:image/svg+xml,`,
screenshot: `data:image/svg+xml,`
};
const LOCALE = {
'en': {
playbackSpeed: 'Playback Speed',
speedUnit: '×',
statsLabel: 'Show Media Statistics',
sourceType: 'Source',
viewport: 'Viewport',
frameInfo: 'Frames',
resolution: 'Resolution',
codecInfo: 'Codecs',
colorProfile: 'Color',
screenshotError: 'Screenshot failed due to CORS restrictions.',
file: 'File',
mediaSource: 'Media Source',
},
'zh-CN': {
playbackSpeed: '播放速度',
speedUnit: '倍',
statsLabel: '显示媒体统计数据',
sourceType: '来源',
viewport: '视口',
frameInfo: '帧',
resolution: '分辨率',
codecInfo: '编解码器',
colorProfile: '色彩',
screenshotError: '由于跨域限制 (CORS),截图失败。',
file: '文件',
mediaSource: '媒体源',
},
};
const t = (k) => (LOCALE[navigator.language] || LOCALE[navigator.language.split('-')[0]] || LOCALE.en)[k] || k;
const STYLE = `
.ccl-controls-container, .ccl-controls-container * {
font-size: 12px;
line-height: 16px;
font-family: sans-serif;
font-weight: bold;
color: white;
}
.ccl-controls-container {
position: fixed;
z-index: 999;
pointer-events: none;
will-change: top, left, width, height;
}
.ccl-controls {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 16px;
padding: 6px;
pointer-events: none;
}
.ccl-controls.hidden { display: none; }
.ccl-bar {
display: inline-flex;
height: 31px;
flex-shrink: 0;
border-radius: 24px;
background-color: rgba(0, 0, 0, 0.55);
-webkit-backdrop-filter: saturate(180%) blur(17.5px);
backdrop-filter: saturate(180%) blur(17.5px);
pointer-events: auto;
}
.ccl-control-btn {
display: flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
cursor: pointer;
background: transparent !important;
}
.ccl-control-btn:active { transform: scale(0.89); }
.ccl-icon {
width: 16px;
height: 12px;
background-color: white;
mix-blend-mode: plus-lighter;
-webkit-mask: var(--icon) no-repeat center / contain;
mask: var(--icon) no-repeat center / contain;
transition: transform 150ms;
pointer-events: none;
}
.ccl-icon-pip { --icon: url('${ICONS.enterPip}'); }
.ccl-icon-pip[data-active="true"] { --icon: url('${ICONS.exitPip}'); }
.ccl-icon-loop { --icon: url('${ICONS.enableLoop}'); }
.ccl-icon-loop[data-active="true"] { --icon: url('${ICONS.disableLoop}'); }
.ccl-icon-more { --icon: url('${ICONS.more}'); }
.ccl-icon-ab { --icon: url('${ICONS.setPointB}'); }
.ccl-icon-screenshot { --icon: url('${ICONS.screenshot}'); }
.ccl-btn-container {
display: flex;
gap: 16px;
justify-content: center;
align-items: center;
padding: 0 16px;
}
.ccl-menu {
position: relative;
display: none;
border-radius: 8px;
cursor: default;
pointer-events: auto;
white-space: nowrap;
}
.ccl-menu.visible { display: flex; }
.ccl-menu.visible::before {
content: '';
position: fixed;
inset: 0;
background: transparent;
pointer-events: auto;
}
.ccl-menu-bg {
position: absolute;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
-webkit-backdrop-filter:saturate(180%) blur(17.5px);
backdrop-filter: saturate(180%) blur(17.5px);
border-radius: 8px;
}
.ccl-menu-container {
position: relative;
padding: 4px 8px;
}
.ccl-menu-head {
color: rgba(255, 255, 255, 0.2);
padding: 4px 8px;
pointer-events: none;
white-space: nowrap;
}
.ccl-menu-hr {
border: 0;
border-top: 1px solid rgba(255, 255, 255, 0.2);
margin: 4px 8px;
background: transparent;
}
.ccl-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
pointer-events: auto;
white-space: nowrap;
}
.ccl-menu-item:hover { background: rgba(255, 255, 255, 0.2) !important; }
.ccl-menu-item::before {
content: '✔';
visibility: hidden;
color: white;
font-weight: bold;
}
.ccl-menu-item.active::before { visibility: visible; }
.ccl-menu-item-stats { justify-content: center; }
.ccl-menu-item-stats::before { display: none; }
.ccl-menu-item-stats.active { justify-content: flex-start; }
.ccl-menu-item-stats.active::before {
display: block;
visibility: visible;
}
.ccl-stats-container {
position: absolute;
width: 100%; height: 100%;
top: 0;
justify-content: center;
align-items: center;
pointer-events: none;
display: none;
}
.ccl-stats-container.visible { display: flex; }
.ccl-stats-container > table {
padding: 4px;
background-color: rgba(64, 64, 64, 0.6);
border-radius: 6px;
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
}
.ccl-stats-container th {
padding-inline-end: 6px;
text-align: end;
}
`;
const el = (tag, className, text = '', click = null) => {
const e = document.createElement(tag);
if (className) e.className = className;
if (text) e.textContent = text;
if (click) {
e.addEventListener('click', (ev) => {
ev.stopPropagation();
click(ev);
});
}
return e;
}
class BaseControl {
constructor(iconClass, onClick) {
this.video = null;
this.el = el('button', 'ccl-control-btn', '', (e) => onClick(e));
this.icon = el('picture', `ccl-icon ${iconClass}`);
this.el.appendChild(this.icon);
}
setVideo(v) { this.video = v; this.update(); }
update() { }
}
class PipControl extends BaseControl {
constructor() {
super('ccl-icon-pip', () => {
if (typeof this.video.webkitSetPresentationMode === 'function') {
const mode = this.video.webkitPresentationMode;
this.video.webkitSetPresentationMode(mode === 'picture-in-picture' ? 'inline' : 'picture-in-picture');
} else {
if (document.pictureInPictureElement === this.video) document.exitPictureInPicture();
else this.video.requestPictureInPicture();
}
});
}
setVideo(v) {
this.video = v;
if (!this.isPipSupport(v)) this.el.style.display = 'none';
else { this.el.style.display = 'flex'; this.update(); }
}
isPipSupport(video) {
const isStandard = document.pictureInPictureEnabled && !video.disablePictureInPicture;
const isSafari = typeof video.webkitSetPresentationMode === 'function';
return isStandard || isSafari;
}
update() {
const active = document.pictureInPictureElement === this.video || this.video.webkitPresentationMode === 'picture-in-picture';
this.icon.dataset.active = active;
}
}
class LoopControl extends BaseControl {
constructor(onLoopToggle) {
super('ccl-icon-loop', () => {
this.video.loop = !this.video.loop;
this.update();
this.onLoopToggle(this.video.loop, this.video);
});
this.observer = null;
this.onLoopToggle = onLoopToggle;
}
setVideo(v) {
super.setVideo(v);
if (this.observer) this.observer.disconnect();
this.observer = new MutationObserver(() => {
this.update()
this.onLoopToggle(this.video.loop, this.video);
});
this.observer.observe(v, { attributes: true, attributeFilter: ['loop'] });
}
update() { this.icon.dataset.active = this.video.loop; }
}
class ABControl extends BaseControl {
constructor() {
super('ccl-icon-ab', () => this.handleClick());
this.el.style.display = 'none';
this.startTime = null;
this.endTime = null;
this.loopHandlerBound = this.loopHandler.bind(this);
}
setVideo(v) {
this.reset();
super.setVideo(v);
}
setDirectA(time) {
this.startTime = time;
this.show();
}
handleClick() {
if (!this.video) return;
const now = this.video.currentTime;
if (this.startTime) {
if (now <= this.startTime) {
alert('Please select a future time to start the loop.');
return;
}
this.endTime = now;
this.hide();
this.video.addEventListener('timeupdate', this.loopHandlerBound);
this.video.currentTime = this.startTime;
this.video.play();
}
}
loopHandler() {
if (this.endTime && this.video.currentTime >= this.endTime) {
this.video.currentTime = this.startTime;
}
}
reset() {
if (this.video) this.video.removeEventListener('timeupdate', this.loopHandlerBound);
this.startTime = null;
this.endTime = null;
this.hide();
}
show() { this.el.style.display = 'flex'; }
hide() { this.el.style.display = 'none'; }
}
class ScreenshotControl extends BaseControl {
constructor() {
super('ccl-icon-screenshot', () => this.handleScreenshot());
}
handleScreenshot() {
if (!this.video) return;
try {
// Create a canvas with the original video resolution
const canvas = document.createElement('canvas');
canvas.width = this.video.videoWidth;
canvas.height = this.video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(this.video, 0, 0, canvas.width, canvas.height);
// Export to PNG format (lossless highest quality)
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
// Generate file name
const now = new Date();
const yyyy = now.getFullYear();
const MM = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const timestamp = `${yyyy}${MM}${dd}_${hh}${mm}${ss}`;
a.download = `chimo-chimo-loop_${timestamp}.png`;
a.click();
} catch (err) {
// Fails if the video source is cross-origin without proper CORS headers
console.error('[chimo-chimo-loop] Screenshot failed:', err);
alert(t('screenshotError'));
}
}
}
class MoreControl extends BaseControl {
constructor(onToggle) {
super('ccl-icon-more', () => onToggle());
}
}
class ControlsBar {
constructor(onMenuToggle) {
this.pipControl = new PipControl();
this.loopControl = new LoopControl((isLooping, video) => {
if (isLooping && video) this.abControl.setDirectA(video.currentTime);
else this.abControl.reset();
});
this.abControl = new ABControl();
this.screenshotControl = new ScreenshotControl();
this.moreControl = new MoreControl(() => onMenuToggle());
this.controls = [this.pipControl, this.loopControl, this.abControl, this.screenshotControl, this.moreControl];
const container = el('div', 'ccl-btn-container')
this.controls.forEach(c => container.appendChild(c.el));
this.el = el('div', 'ccl-bar');
this.el.appendChild(container);
}
setVideo(video) { this.controls.forEach(c => c.setVideo(video)); }
}
class MediaControls {
constructor(onStatsToggle, onStatsVisible) {
this.el = el('div', 'ccl-controls');
this.controlsBar = new ControlsBar(() => this.menu.toggle());
this.menu = new Menu(onStatsToggle, onStatsVisible);
this.components = [this.controlsBar, this.menu];
this.components.forEach(c => this.el.appendChild(c.el));
}
show() { this.el.classList.remove('hidden'); };
hide() { this.el.classList.add('hidden'); };
setVideo(video) { this.components.forEach(c => c.setVideo(video)); }
}
class Menu {
constructor(onToggleStats, checkStatsState) {
this.video = null;
this.checkStatsState = checkStatsState;
this.el = el('div', 'ccl-menu');
this.container = el('div', 'ccl-menu-container');
this.el.append(el('div', 'ccl-menu-bg'), this.container);
this.container.appendChild(el('div', 'ccl-menu-head', t('playbackSpeed')));
[0.5, 1, 1.25, 1.5, 2].forEach(r => {
const item = el('div', 'ccl-menu-item', `${r} ${t('speedUnit')}`, () => {
if (this.video) this.video.playbackRate = r;
this.hide();
});
item.dataset.rate = r;
this.container.appendChild(item);
})
this.container.appendChild(el('hr', 'ccl-menu-hr'));
this.statsItem = el('div', 'ccl-menu-item ccl-menu-item-stats', t('statsLabel'), () => {
onToggleStats();
this.hide();
})
this.container.appendChild(this.statsItem);
this.el.addEventListener('click', () => { if (this.visible) this.hide(); });
}
update() {
if (!this.video) return;
Array.from(this.container.children).forEach(item => {
if (item.dataset.rate) {
const rate = parseFloat(item.dataset.rate);
item.classList.toggle('active', Math.abs(this.video.playbackRate - rate) < 0.01);
}
});
if (this.checkStatsState) this.statsItem.classList.toggle('active', this.checkStatsState());
}
get visible() { return this.el.classList.contains('visible'); }
show() { this.el.classList.add('visible'); this.update(); }
hide() { this.el.classList.remove('visible'); }
toggle() { this.visible ? this.hide() : this.show(); }
setVideo(v) { this.video = v; this.hide(); }
}
class StatsContainer {
constructor() {
this.video = null;
this.el = el('div', 'ccl-stats-container');
this.table = el('table');
this.el.appendChild(this.table);
this.isTracking = false;
this.updateInterval = null;
this.lastTime = 0;
this.currentFps = '0.0';
this.cachedColorSpace = null;
this.cells = {};
this.initTableDOM();
}
getSourceType() {
if (!this.video) return 'Unknown';
const src = this.video.currentSrc || this.video.src || '';
if (src.startsWith('blob:')) return t('mediaSource');
if (src.toLowerCase().includes('m3u8')) return 'HLS';
if (src.startsWith('http') || src.startsWith('/') || src.startsWith('data:')) return t('file');
return 'Unknown';
};
getColorSpace() {
if (this.cachedColorSpace) return this.cachedColorSpace;
try {
if (typeof VideoFrame === 'undefined') return 'Unsupported';
const frame = new VideoFrame(this.video);
const cs = frame.colorSpace;
frame.close();
if (cs) {
const primaries = cs.primaries || 'unknown';
const transfer = cs.transfer || 'unknown';
const matrix = cs.matrix || 'unknown';
this.cachedColorSpace = `${primaries} / ${transfer} / ${matrix}`;
return this.cachedColorSpace;
}
} catch (e) {
return 'Unsupported';
}
return 'Unknown';
}
initTableDOM() {
const addRow = (key, label) => {
const r = el('tr');
r.appendChild(el('th', '', label));
const td = el('td', '', '');
r.appendChild(td);
this.table.appendChild(r);
this.cells[key] = td;
};
addRow('source', t('sourceType'));
addRow('viewport', t('viewport'));
addRow('frameInfo', t('frameInfo'));
addRow('resolution', t('resolution'));
addRow('color', t('colorProfile'));
}
updateStaticUI() {
if (!this.video) return;
this.cells.source.textContent = this.getSourceType();
this.cells.color.textContent = this.getColorSpace();
}
updateDynamicUI() {
if (!this.video) return;
this.cells.viewport.textContent = `${this.video.clientWidth}×${this.video.clientHeight} (${window.devicePixelRatio}x)`;
this.cells.frameInfo.textContent = this.currentFps;
this.cells.resolution.textContent = `${this.video.videoWidth}×${this.video.videoHeight}`;
}
startTracking() {
this.stopTracking();
if (!this.video) return;
this.isTracking = true;
let frameCount = 0;
if ('requestVideoFrameCallback' in this.video) {
const loop = () => {
if (!this.isTracking) return;
frameCount++;
this.video.requestVideoFrameCallback(loop);
};
this.video.requestVideoFrameCallback(loop);
}
this.lastTime = performance.now();
this.updateInterval = setInterval(() => {
const now = performance.now();
const deltaMs = now - this.lastTime;
if (deltaMs > 0) this.currentFps = (frameCount / (deltaMs / 1000)).toFixed(1);
frameCount = 0;
this.lastTime = now;
this.updateDynamicUI();
}, 500);
}
stopTracking() {
this.isTracking = false;
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}
show() {
this.el.classList.add('visible');
this.updateStaticUI();
this.updateDynamicUI();
this.startTracking();
}
hide() {
this.el.classList.remove('visible');
this.stopTracking();
}
toggle() { this.el.classList.contains('visible') ? this.hide() : this.show(); }
get visible() { return this.el.classList.contains('visible'); }
setVideo(v) {
this.video = v;
this.cachedColorSpace = null;
this.currentFps = '0.0';
this.hide();
}
}
class UIManager {
constructor() {
const style = document.createElement('style');
style.textContent = STYLE;
document.head.appendChild(style);
this.stats = new StatsContainer();
this.mediaControls = new MediaControls(() => this.stats.toggle(), () => this.stats.visible);
this.video = null;
this.components = [this.mediaControls, this.stats];
this.container = el('div', 'ccl-controls-container');
this.components.forEach(c => this.container.appendChild(c.el));
document.body.appendChild(this.container);
}
attach(video) {
this.video = video;
this.components.forEach(c => c.setVideo(video));
}
detach() {
this.video = null;
this.components.forEach(c => c.hide());
}
reposition(rect) {
if (!rect) return;
this.container.style.top = rect.top + 'px';
this.container.style.left = rect.left + 'px';
this.container.style.width = rect.width + 'px';
this.container.style.height = rect.height + 'px';
}
show() { this.mediaControls.show(); }
hide() { this.mediaControls.hide(); }
}
class App {
constructor() {
this.ui = new UIManager();
this.activeVideo = null;
this.videoRect = null;
this.isPaused = false
this.hideTimeout = null;
this.isThrottled = false;
this.pollingId = null;
this.layoutObserver = null;
this.setupEvents();
this.scan();
}
setupEvents() {
const onPlay = (e) => {
if (e.target instanceof HTMLVideoElement) this.activate(e.target);
this.isPaused = false;
this.showAndTimer();
};
const onPause = () => {
this.isPaused = true;
this.showPersistent();
};
document.addEventListener('play', onPlay, true);
document.addEventListener('pause', onPause, true);
document.addEventListener('scroll', () => this.updateRectAndPosition(), { passive: true });
window.addEventListener('resize', () => this.updateRectAndPosition(), { passive: true });
document.addEventListener('enterpictureinpicture', () => this.ui.controlsBar.pipControl.update(), true);
document.addEventListener('leavepictureinpicture', () => this.ui.controlsBar.pipControl.update(), true);
document.addEventListener('webkitpresentationmodechanged', () => this.ui.controlsBar.pipControl.update(), true);
window.addEventListener('pointermove', (e) => this.handleGlobalPointer(e), { passive: true });
}
showPersistent() {
this.clearHideTimer();
this.ui.show();
}
updateRectAndPosition() {
if (!this.activeVideo) return;
if (!this.activeVideo.isConnected) {
this.detach();
return;
}
this.videoRect = this.activeVideo.getBoundingClientRect();
this.ui.reposition(this.videoRect);
}
activate(video) {
if (!this.shouldSwitchVideo(video)) return;
this.activeVideo = video;
this.ui.attach(video);
this.startPolling(500);
this.observerCleanup();
this.observeVideoLayout(video);
}
detach() {
this.ui.detach();
this.activeVideo = null;
this.observerCleanup();
}
shouldSwitchVideo(newVideo) {
const oldVideo = this.activeVideo;
if (!oldVideo) return true;
if (oldVideo === newVideo) return false;
if (!oldVideo.isConnected) return true;
const o = this.videoRect;
const n = newVideo.getBoundingClientRect();
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
const dNew = Math.hypot(n.left + n.width / 2 - cx, n.top + n.height / 2 - cy);
const dOld = Math.hypot(o.left + o.width / 2 - cx, o.top + o.height / 2 - cy);
if (dNew < dOld) return true;
if (!oldVideo.paused) {
if (o.width * o.height > n.width * n.height) return false;
}
return true;
}
observeVideoLayout(video) {
this.layoutObserver = new ResizeObserver(() => {
if (!video.isConnected || video.style.display === 'none') {
this.ui.hide();
return;
}
if (this.activeVideo === video) {
this.updateRectAndPosition();
}
})
this.layoutObserver.observe(video);
}
observerCleanup() {
if (this.layoutObserver) {
this.layoutObserver.disconnect();
this.layoutObserver = null;
}
}
scan() {
const v = document.querySelector('video');
if (v) this.activate(v);
}
handleGlobalPointer(e) {
if (this.isThrottled) return;
this.isThrottled = true;
setTimeout(() => { this.isThrottled = false; }, 200);
if (this.activeVideo && !this.activeVideo.isConnected) {
this.detach();
return;
}
if (!this.activeVideo || !this.videoRect || this.isPaused) return;
const menu = this.ui.container.querySelector('.ccl-menu');
if (menu.classList.contains('visible')) return;
const rect = this.videoRect;
const isOverVideo = (
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom
);
const isOverControls = this.ui.container.contains(e.target);
if (isOverVideo || isOverControls) {
this.showAndTimer();
} else {
this.ui.hide();
}
}
showAndTimer(timeout = 3000) {
this.clearHideTimer();
this.ui.show();
this.hideTimeout = setTimeout(() => {
const menu = this.ui.container.querySelector('.ccl-menu');
if (menu.classList.contains('visible')) return;
this.ui.hide();
}, timeout);
}
clearHideTimer() {
if (!this.hideTimeout) return;
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
startPolling(duration) {
this.stopPolling();
const startTime = performance.now();
const poll = (now) => {
this.updateRectAndPosition();
if (now - startTime < duration) {
this.pollingId = requestAnimationFrame(poll);
}
};
this.pollingId = requestAnimationFrame(poll);
}
stopPolling() {
if (!this.pollingId) return;
cancelAnimationFrame(this.pollingId);
this.pollingId = null;
}
}
new App();
})();