// ==UserScript==
// @name chimo-chimo-loop - HTML5 Video Enhancer
// @name:ja chimo-chimo-loop - HTML5動画プレーヤー拡張
// @name:zh-CN chimo-chimo-loop (HTML5 视频增强器)
// @namespace https://github.com/ryu-dayo/chimo-chimo-loop
// @version 1.6.0
// @description Supercharge HTML5 video playback with Picture-in-Picture (PiP), A-B loop, speed control, mirror/flip, lossless screenshots, and advanced media statistics.
// @description:ja HTML5の動画再生を強化。ピクチャインピクチャ、A-Bリピート、再生速度調整、動画を左右反転(ミラー)、高画質スクリーンショット、詳細なメディア統計などの高度な機能を追加します。
// @description:zh-CN HTML5 视频增强神器:支持画中画、A-B区间循环、倍速调节、镜像翻转、无损截图以及硬核的媒体统计信息(实时FPS、色彩空间等)。
// @author ryu-dayo
// @match https://www.douyin.com/*
// @match https://www.facebook.com/*
// @match https://www.instagram.com/*
// @match https://www.threads.com/*
// @match https://x.com/*
// @match https://www.xiaohongshu.com/*
// @match https://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=douyin.com
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @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,`,
mirror: `data:image/svg+xml,`,
rotateLeft: `data:image/svg+xml,`
};
const LOCALE = {
'en': {
starGitHub: 'Support on GitHub (⭐)',
feedback: 'Feedback / Suggestions',
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',
},
'ja': {
starGitHub: 'GitHubで応援 (⭐)',
feedback: 'フィードバック / ご要望',
playbackSpeed: '再生速度',
speedUnit: '×',
statsLabel: 'メディアの統計情報を表示',
sourceType: 'ソース',
viewport: 'ビューポート',
frameInfo: 'フレーム',
resolution: '解像度',
codecInfo: 'コーデック',
colorProfile: 'カラー',
screenshotError: 'CORS制限によりスクリーンショットに失敗しました。',
file: 'ファイル',
mediaSource: 'メディアソース',
},
'zh-CN': {
starGitHub: '在 GitHub 上支持本项目 (⭐)',
feedback: '反馈 Bug / 建议',
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;
// Define common playback speed steps
const SPEED_STEPS = [0.5, 1, 1.25, 1.5, 2];
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-icon-mirror { --icon: url('${ICONS.mirror}'); }
.ccl-icon-rotate-left { --icon: url('${ICONS.rotateLeft}'); }
.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', () => this.handlePip());
}
handlePip() {
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 MirrorControl extends BaseControl {
constructor() {
super('ccl-icon-mirror', () => this.handleMirror());
this.isMirrored = false;
}
handleMirror() {
if (!this.video) return;
this.isMirrored = !this.isMirrored;
this.applyMirror();
this.update();
}
applyMirror() {
const v = this.video;
const scaleValue = this.isMirrored ? '-1' : '1';
v.style.setProperty('--ccl-scaleX', scaleValue);
const rotateAngle = v.style.getPropertyValue('--ccl-rotate');
if (rotateAngle && rotateAngle !== '0deg') {
v.style.transform = `rotate(${rotateAngle}) scale(var(--ccl-scale, 1)) scaleX(${scaleValue})`;
} else {
if (this.isMirrored) {
v.style.transform = `scaleX(-1)`;
} else {
if (v.style.transform === 'scaleX(-1)') {
v.style.removeProperty('transform');
}
}
}
}
setVideo(v) {
super.setVideo(v);
// Reset mirror state when setting a new video
this.isMirrored = false;
if (v) {
v.style.setProperty('--ccl-scaleX', '1');
if (v.style.transform === 'scaleX(-1)') {
v.style.removeProperty('transform');
}
}
}
}
class RotateControl extends BaseControl {
constructor() {
super('ccl-icon-rotate-left', () => this.handleRotate());
this.rotationAngle = 0;
// Listen for player size changes, recalculate scale in real-time
this.resizeObserver = new ResizeObserver(() => {
if (this.rotationAngle !== 0) {
this.applyRotation();
}
});
this.isObserving = false;
}
handleRotate() {
if (!this.video) return;
this.rotationAngle -= 90;
this.applyRotation();
}
applyRotation() {
const v = this.video;
if (!this.isObserving) {
this.resizeObserver.observe(v);
this.isObserving = true;
}
const scale = this.calculateScale(v);
v.style.setProperty('transition', 'transform 0.2s ease-out', 'important');
v.style.setProperty('transform-origin', 'center center', 'important');
v.style.setProperty('--ccl-rotate', `${this.rotationAngle}deg`);
v.style.setProperty('--ccl-scale', scale);
v.style.transform = `rotate(var(--ccl-rotate, 0deg)) scale(var(--ccl-scale, 1)) scaleX(var(--ccl-scaleX, 1))`;
}
// Core math formula: Calculate the scaling factor needed in pure visual state
calculateScale(v) {
const isPortrait = Math.abs(this.rotationAngle % 180) === 0;
if (isPortrait) return 1;
const cw = v.clientWidth || v.parentElement.clientWidth;
const ch = v.clientHeight || v.parentElement.clientHeight;
const vw = v.videoWidth;
const vh = v.videoHeight;
if (!vw || !vh || !cw || !ch) return 1;
// 1. Original scaling ratio under object-fit: contain state
const k1 = Math.min(cw / vw, ch / vh);
// 2. Originally drawn image dimensions
const paintedWidth = vw * k1;
const paintedHeight = vh * k1;
// 3. After rotating 90 degrees, width and height are reversed
const rotatedWidth = paintedHeight;
const rotatedHeight = paintedWidth;
// 4. Calculate: how many times to zoom in/out to fill the safe area again
const scaleX = cw / rotatedWidth;
const scaleY = ch / rotatedHeight;
// Take minimum value to ensure image is not cropped (take maximum for cover effect)
return Math.min(scaleX, scaleY);
}
resetRotation() {
const v = this.video;
if (!v) return;
this.rotationAngle = 0;
if (this.isObserving) {
this.resizeObserver.disconnect();
this.isObserving = false;
}
v.style.removeProperty('transition');
v.style.removeProperty('transform-origin');
v.style.removeProperty('--ccl-rotate');
v.style.removeProperty('--ccl-scale');
const scaleX = v.style.getPropertyValue('--ccl-scaleX');
if (scaleX === '-1') {
v.style.transform = 'scaleX(-1)';
} else {
v.style.removeProperty('transform');
}
}
setVideo(v) {
if (this.video && this.video !== v) {
this.resetRotation();
}
super.setVideo(v);
if (v) {
this.resetRotation();
}
}
}
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.mirrorControl = new MirrorControl();
this.rotateControl = new RotateControl();
this.moreControl = new MoreControl(() => onMenuToggle());
this.controls = [this.pipControl, this.loopControl, this.abControl, this.screenshotControl, this.mirrorControl, this.rotateControl, 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')));
SPEED_STEPS.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.setupMenuCommands();
this.setupEvents();
this.setupKeyboardShortcuts();
this.scan();
}
setupMenuCommands() {
if (typeof GM_registerMenuCommand !== 'undefined') {
const githubUrl = "https://github.com/ryu-dayo/chimo-chimo-loop";
GM_registerMenuCommand(t('starGitHub'), () => {
GM_openInTab(githubUrl, { active: true });
});
GM_registerMenuCommand(t('feedback'), () => {
GM_openInTab(`${githubUrl}/issues`, { active: true });
});
}
}
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 });
const pip = this.ui.mediaControls.controlsBar.pipControl;
document.addEventListener('enterpictureinpicture', () => pip.update(), true);
document.addEventListener('leavepictureinpicture', () => pip.update(), true);
document.addEventListener('webkitpresentationmodechanged', () => pip.update(), true);
window.addEventListener('pointermove', (e) => this.handleGlobalPointer(e), { passive: true });
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ignore keys pressed in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
// If Ctrl, Cmd, or Shift key is pressed, do not process to avoid conflicts with browser shortcuts
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
const v = this.activeVideo;
const bar = this.ui.mediaControls.controlsBar;
const menu = this.ui.mediaControls.menu;
// Use Alt/Option key as modifier to avoid conflicts with page shortcuts
if (v && e.altKey) {
const handlers = {
'Space': () => { v.paused ? v.play() : v.pause(); },
'ArrowUp': () => { v.volume = Math.min(1, v.volume + 0.1); },
'ArrowDown': () => { v.volume = Math.max(0, v.volume - 0.1); },
'ArrowLeft': () => { v.currentTime -= 5; },
'ArrowRight': () => { v.currentTime += 5; },
'Equal': () => this.adjustPlaybackSpeed(0.25),
'Minus': () => this.adjustPlaybackSpeed(-0.25),
'Digit0': () => { v.playbackRate = 1.0; menu.update(); },
'KeyP': () => bar.pipControl.handlePip(),
'KeyL': () => { v.loop = !v.loop; bar.loopControl.update(); },
'KeyS': () => bar.screenshotControl.handleScreenshot(),
'KeyM': () => bar.mirrorControl.handleMirror(),
'KeyI': () => {
this.ui.stats.toggle();
if (!menu.el.classList.contains('hidden')) menu.statsItem.classList.toggle('active');
},
'KeyU': () => { v.muted = !v.muted; },
'KeyB': () => bar.abControl.handleClick(),
'KeyR': () => bar.rotateControl.handleRotate(),
};
const action = handlers[e.code];
if (action) {
e.stopImmediatePropagation();
e.preventDefault();
action();
return;
}
}
}, true);
}
adjustPlaybackSpeed(delta) {
const v = this.activeVideo;
if (!v) return;
// Use the global SPEED_STEPS constant to ensure consistency
const currentSpeed = v.playbackRate;
// Find the closest speed step to the current speed
let closestIndex = 0;
let minDiff = Math.abs(SPEED_STEPS[0] - currentSpeed);
for (let i = 1; i < SPEED_STEPS.length; i++) {
const diff = Math.abs(SPEED_STEPS[i] - currentSpeed);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
}
// Adjust to the next or previous step based on delta
let newIndex = closestIndex + (delta > 0 ? 1 : -1);
// Ensure the index is within valid range
newIndex = Math.max(0, Math.min(newIndex, SPEED_STEPS.length - 1));
// Set the new playback speed
v.playbackRate = SPEED_STEPS[newIndex];
// Update the menu display
this.ui.mediaControls.menu.update();
}
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();
})();