// ==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.5.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,` }; 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-mirror[data-active="true"] { transform: scaleX(-1); } .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.video.style.transform = this.isMirrored ? 'scaleX(-1)' : 'scaleX(1)'; this.update(); } setVideo(v) { super.setVideo(v); // Reset mirror state when setting a new video this.isMirrored = false; if (v) { v.style.transform = 'scaleX(1)'; } this.update(); } update() { if (this.icon) { this.icon.dataset.active = this.isMirrored; } } } 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.moreControl = new MoreControl(() => onMenuToggle()); this.controls = [this.pipControl, this.loopControl, this.abControl, this.screenshotControl, this.mirrorControl, 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.contentEditable === 'true') 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(), }; 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(); })();