Greasy Fork is available in English.
Adds PiP, loop, and speed controls to HTML5 videos.
// ==UserScript==
// @name chimo-chimo-loop
// @name:zh-CN chimo-chimo-loop
// @namespace https://github.com/ryu-dayo/chimo-chimo-loop
// @version 1.2.0
// @description Adds PiP, loop, and speed controls to HTML5 videos.
// @description:zh-CN 为 HTML5 视频播放器添加画中画(PiP)、循环播放和倍速控制按钮。
// @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 MIT
// ==/UserScript==
(function () {
'use strict';
const ICONS = {
enterPip: `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101 82"><path d="M12.5 63.3h55.7q12.6 0 12.5-12.3V12.3Q80.7 0 68.2 0H12.5Q0 0 0 12.3V51q0 12.3 12.5 12.3M7 50.6v-38Q7.1 7 12.5 7h55.6q5.4.1 5.5 5.6v38q-.1 5.6-5.5 5.6H12.5q-5.4 0-5.5-5.6"/><path d="M31 16.8c-.2-1.2-1.8-2.6-3.4-1L23.4 20l-5.8-6c-1-1-2.8-1-3.8 0s-1 2.7 0 3.8l5.9 5.8-4.1 4.2c-1.7 1.6-.3 3.2 1 3.4l14 2.1q1 .1 2-.6.6-.8.5-1.8zm19.5 64.8h37.2q12.4 0 12.4-12.2V44.8q0-12.3-12.4-12.3H50.5Q38 32.5 38 44.8v24.6q0 12.3 12.5 12.2"/></svg>`,
exitPip: `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101 82"><path d="M12.5 63.3h55.7q12.6 0 12.5-12.3V12.3Q80.7 0 68.2 0H12.5Q0 0 0 12.3V51q0 12.3 12.5 12.3M7 50.6v-38Q7.1 7 12.5 7h55.6q5.4.1 5.5 5.6v38q-.1 5.6-5.5 5.6H12.5q-5.4 0-5.5-5.6"/><path d="M15.1 29.9c.2 1.2 1.8 2.6 3.4 1l4.2-4.1 5.9 5.8c1 1 2.7 1 3.7 0s1-2.7 0-3.7l-5.8-6 4-4.1c1.7-1.6.3-3.2-1-3.4l-14-2q-1.2-.3-1.9.5t-.6 1.9zm35.4 51.7h37.2q12.4 0 12.4-12.2V44.8q0-12.3-12.4-12.3H50.5Q38 32.5 38 44.8v24.6q0 12.3 12.5 12.2"/></svg>`,
enableLoop: `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 70"><path d="M34.9 66.6V41.9q0-2.6-2.8-2.6-1.1 0-2.1.8L15.4 52.4c-1.2 1-1.3 2.6 0 3.8L30 68.5q1 .7 2.1.7 2.7 0 2.8-2.6m45.3-33.5c-2 0-3.5 1.5-3.5 3.6v3.7c0 6.2-4.6 10.5-11.2 10.5H29.2c-2 0-3.6 1.6-3.6 3.5 0 2 1.6 3.6 3.6 3.6h35.6c11.6 0 19-6.7 19-17v-4.3c0-2-1.5-3.6-3.6-3.6M49 2.6v24.7q0 2.6 2.7 2.6 1.1 0 2.1-.7l14.6-12.3c1.3-1 1.4-2.7 0-3.8L53.8.8q-1-.8-2.1-.8Q49 .1 49 2.6M3.6 36.2c2 0 3.6-1.6 3.6-3.6v-3.7c0-6.3 4.5-10.5 11-10.5h36.4a3.5 3.5 0 0 0 0-7.1H19c-11.6 0-19 6.6-19 17v4.3c0 2 1.6 3.6 3.6 3.6"/></svg>`,
disableLoop: `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 91 70"><path d="M3.6 36.2c2 0 3.6-1.6 3.6-3.6v-3.7c0-6.3 4.5-10.5 11-10.5h21c2 0 3.6-1.7 3.6-3.6s-1.6-3.5-3.6-3.5H19c-11.6 0-19 6.6-19 17v4.3c0 2 1.6 3.6 3.6 3.6m30-33.6v24.7q0 2.6 2.7 2.6 1.1 0 2-.7L53 16.9c1.2-1 1.3-2.7 0-3.8L38.4.8q-1-.8-2.1-.8-2.7.1-2.8 2.6m46.6 30.5c-2 0-3.5 1.5-3.5 3.6v3.7c0 6.2-4.6 10.5-11.2 10.5H29.2c-2 0-3.6 1.6-3.6 3.5 0 2 1.6 3.6 3.6 3.6h35.6c11.6 0 19-6.7 19-17v-4.3c0-2-1.5-3.6-3.6-3.6M35 66.6V41.9q0-2.6-2.8-2.6-1.1 0-2.1.8L15.4 52.4c-1.2 1-1.3 2.6 0 3.8L30 68.5q1 .7 2.1.7 2.7 0 2.8-2.6m40.6-45c-1 1.1-2.7.9-3.7-.2-1-1-1.2-2.6-.2-3.7l5.4-5.3-4.9-5c-1-1-1-2.6 0-3.5 1-1 2.6-1 3.5 0l5 4.9L86 3.4c1.1-1.1 2.7-1 3.7.1 1 1 1.3 2.7.1 3.7l-5.3 5.4 4.9 5c1 1 1 2.6 0 3.5-1 1-2.6 1-3.6 0l-5-4.9z"/></svg>`,
more: `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 73 69"><path d="M38.2 68.2q1.8 0 2.9-1.3l30.1-30q1.3-1.2 1.3-2.8a4 4 0 0 0-1.3-2.9l-30.1-30A4 4 0 0 0 38.2 0a4 4 0 0 0-4 4q0 1.7 1.2 3l29.5 29.3v-4.5L35.4 61.2q-1.1 1.2-1.2 3a4 4 0 0 0 4 4"/><path d="M4 68.2q1.8 0 2.9-1.3L37 37q1.1-1.2 1.2-2.8a4 4 0 0 0-1.2-2.9L6.9 1.2A4 4 0 0 0 4 0a4 4 0 0 0-4 4q0 1.7 1.2 3l29.5 29.3v-4.5L1.2 61.2Q0 62.4 0 64.2a4 4 0 0 0 4 4"/></svg>`,
setPointB: `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><path d="M12.5 71.2h46.2Q71 71.2 71 58.9V12.3Q71.2 0 58.7 0H12.5Q0 0 0 12.3v46.6q0 12.3 12.5 12.3m0-7q-5.4 0-5.5-5.7V12.6q.1-5.5 5.5-5.5h46q5.5 0 5.6 5.5v46Q64 64 58.6 64z"/><path d="M26.7 52.7h11c8.1 0 13.4-4 13.4-10 0-4.6-3.2-7.9-8.4-8.5v-.3c4-1 6.3-3.9 6.3-7.7 0-5.3-4.3-8.7-11-8.7H26.6q-3.9 0-4 4v27.3q.1 3.7 4 3.9m2.8-20.5v-9.6h7c3.6 0 5.9 1.8 5.9 4.7q0 5-7.9 5zm0 15.5V36.9H37q7 .1 7.2 5.5.1 5.4-9 5.3z"/></svg>`,
};
const LOCALE = {
'en': {
playbackSpeed: 'Playback Speed',
speedUnit: '×',
statsLabel: 'Show Media Statistics',
sourceType: 'Source',
viewport: 'Viewport',
frameInfo: 'Frames',
resolution: 'Resolution',
codecInfo: 'Codecs',
colorProfile: 'Color'
},
'zh-CN': {
playbackSpeed: '播放速度',
speedUnit: '倍',
statsLabel: '显示媒体统计数据',
sourceType: '来源',
viewport: '视口',
frameInfo: '帧',
resolution: '分辨率',
codecInfo: '编解码器',
colorProfile: '色彩'
},
};
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: 6px;
padding: 6px;
pointer-events: auto;
transition: opacity 0.1s linear;
}
.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);
}
.ccl-control-btn {
display: flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
cursor: pointer;
background: transparent !important;
transition: opacity 0.1s linear;
}
.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-btn-container {
display: flex;
gap: 16px;
justify-content: center;
align-items: center;
padding: 0 16px;
}
.ccl-menu {
top: 6px; left: 160px;
position: absolute;
display: none;
transition: opacity 0.2s ease;
border-radius: 8px;
cursor: default;
pointer-events: auto;
white-space: nowrap;
}
.ccl-menu.visible { display: flex; }
.ccl-menu.visible::before {
content: '';
position: fixed;
top: 0; left: 0;
width: 100vw;
height: 100vh;
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 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.moreControl = new MoreControl(() => onMenuToggle());
this.controls = [this.pipControl, this.loopControl, this.abControl, 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(onMenuToggle) {
this.el = el('div', 'ccl-controls');
this.controlsBar = new ControlsBar(() => onMenuToggle());
this.components = [this.controlsBar];
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);
}
update() {
const getSourceType = () => {
const src = this.video.src;
if (src.startsWith('blob:')) return 'Media Source';
if (src.includes('m3u8')) return 'HLS';
return 'File';
};
const data = {
[t('sourceType')]: getSourceType(),
[t('viewport')]: `${this.video.clientWidth}×${this.video.clientHeight} (${window.devicePixelRatio}x)`,
[t('resolution')]: `${this.video.videoWidth}×${this.video.videoHeight}`
};
this.table.textContent = '';
const addRow = (k, v) => {
const r = el('tr');
r.appendChild(el('th', '', k));
r.appendChild(el('td', '', v));
this.table.appendChild(r);
};
for (const [key, val] of Object.entries(data)) { addRow(key, val); }
}
show() { this.el.classList.add('visible'); this.update(); }
hide() { this.el.classList.remove('visible'); }
toggle() { this.el.classList.contains('visible') ? this.hide() : this.show(); }
get visible() { return this.el.classList.contains('visible'); }
setVideo(v) { this.video = v; this.hide(); }
}
class UIManager {
constructor() {
const style = document.createElement('style');
style.textContent = STYLE;
document.head.appendChild(style);
this.menu = new Menu(() => this.stats.toggle(), () => this.stats.visible);
this.stats = new StatsContainer();
this.mediaControls = new MediaControls(() => {
this.updateMenuPosition();
this.menu.toggle()
});
this.video = null;
this.components = [this.mediaControls, this.menu, this.stats];
this.container = el('div', 'ccl-controls-container');
this.components.forEach(c => this.container.appendChild(c.el));
document.body.appendChild(this.container);
}
updateMenuPosition() {
const barWidth = this.mediaControls.controlsBar.el.offsetWidth;
const leftPos = 6 + barWidth + 16;
this.menu.el.style.left = `${leftPos}px`;
}
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();
})();