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.1.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,%3Csvg%20width%3D%22101%22%20height%3D%2282%22%20viewBox%3D%220%200%20101%2082%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M12.4512%2063.2813H68.2129C76.5625%2063.2813%2080.6641%2059.2285%2080.6641%2051.0254V12.2559C80.6641%204.0527%2076.5625%200%2068.2129%200H12.4512C4.10158%200%200%204.0527%200%2012.2559V51.0254C0%2059.2285%204.10158%2063.2813%2012.4512%2063.2813ZM7.03128%2050.6348V12.6465C7.03128%208.9356%209.03318%207.0313%2012.5489%207.0313H68.1153C71.6309%207.0313%2073.6328%208.9356%2073.6328%2012.6465V50.6348C73.6328%2054.3457%2071.6309%2056.25%2068.1153%2056.25H12.5489C9.03318%2056.25%207.03128%2054.3457%207.03128%2050.6348Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M30.957%2016.8457C30.8105%2015.625%2029.1991%2014.209%2027.6366%2015.8692L23.4374%2019.9707L17.5781%2014.1113C16.5527%2013.0371%2014.8437%2013.0371%2013.8183%2014.1113C12.7441%2015.1367%2012.7441%2016.8457%2013.8183%2017.8711L19.6777%2023.7305L15.5761%2027.9297C13.9159%2029.4922%2015.332%2031.1035%2016.5527%2031.25L30.664%2033.3984C31.3476%2033.4961%2032.0312%2033.252%2032.5195%2032.8125C32.9589%2032.3242%2033.2031%2031.6406%2033.1054%2030.957L30.957%2016.8457Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M50.4883%2081.6407H87.6953C95.9964%2081.6407%20100.146%2077.5879%20100.146%2069.3848V44.7754C100.146%2036.6211%2095.9964%2032.5195%2087.6953%2032.5195H50.4883C42.1875%2032.5195%2038.0371%2036.5723%2038.0371%2044.7754V69.3848C38.0371%2077.5879%2042.1875%2081.6407%2050.4883%2081.6407Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E',
exitPip: 'data:image/svg+xml,%3Csvg%20width%3D%22101%22%20height%3D%2282%22%20viewBox%3D%220%200%20101%2082%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M12.4512%2063.2813H68.2129C76.5625%2063.2813%2080.6641%2059.2285%2080.6641%2051.0254V12.2559C80.6641%204.0527%2076.5625%200%2068.2129%200H12.4512C4.10158%200%200%204.0527%200%2012.2559V51.0254C0%2059.2285%204.10158%2063.2813%2012.4512%2063.2813ZM7.03128%2050.6348V12.6465C7.03128%208.9356%209.03318%207.0313%2012.5489%207.0313H68.1153C71.6309%207.0313%2073.6328%208.9356%2073.6328%2012.6465V50.6348C73.6328%2054.3457%2071.6309%2056.25%2068.1153%2056.25H12.5489C9.03318%2056.25%207.03128%2054.3457%207.03128%2050.6348Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M15.1366%2029.8827C15.2831%2031.1034%2016.9433%2032.4706%2018.5058%2030.8593L22.6562%2026.7577L28.5644%2032.6171C29.5898%2033.6425%2031.2988%2033.6425%2032.3241%2032.6171C33.3495%2031.5917%2033.3495%2029.8827%2032.3241%2028.8573L26.4648%2022.9491L30.5663%2018.7987C32.1777%2017.2362%2030.8105%2015.5761%2029.5409%2015.4296L15.4784%2013.33C14.746%2013.2323%2014.1113%2013.4765%2013.623%2013.9159C13.1835%2014.4042%2012.9394%2015.0878%2013.037%2015.7714L15.1366%2029.8827Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M50.4883%2081.6407H87.6953C95.9964%2081.6407%20100.146%2077.5879%20100.146%2069.3848V44.7754C100.146%2036.6211%2095.9964%2032.5195%2087.6953%2032.5195H50.4883C42.1875%2032.5195%2038.0371%2036.5723%2038.0371%2044.7754V69.3848C38.0371%2077.5879%2042.1875%2081.6407%2050.4883%2081.6407Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E',
enableLoop: 'data:image/svg+xml,%3Csvg%20width%3D%2299%22%20height%3D%2266%22%20viewBox%3D%220%200%2099%2066%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M28.1739%2065.8691H70.6543C87.6953%2065.8691%2098.8284%2054.834%2098.8284%2037.7441C98.8284%2020.6543%2087.6953%209.47259%2070.6543%209.47259H62.2559C60.3028%209.47259%2058.7403%2011.084%2058.7403%2012.9883C58.7403%2014.9414%2060.3028%2016.5527%2062.2559%2016.5527H70.6543C83.252%2016.5527%2091.7964%2025.1465%2091.7964%2037.7441C91.7964%2050.3418%2083.252%2058.8379%2070.6543%2058.8379H28.1739C15.5274%2058.8379%207.03128%2050.3418%207.03128%2037.7441C7.03128%2025.1465%2015.5274%2016.5527%2028.1739%2016.5527H33.3496C33.1055%2015.332%2032.959%2014.0625%2032.959%2012.7441C32.959%2011.6699%2033.0567%2010.5957%2033.252%209.52149L28.1739%209.47259C11.0352%209.32619%200%2020.6543%200%2037.7441C0%2054.834%2011.0352%2065.8691%2028.1739%2065.8691Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M51.3672%2025.4394C58.4473%2025.4394%2064.1114%2019.7266%2064.1114%2012.6953C64.1114%205.6641%2058.4473%200%2051.3672%200C44.336%200%2038.6719%205.6641%2038.6719%2012.6953C38.6719%2019.7266%2044.336%2025.4394%2051.3672%2025.4394ZM51.3672%2018.6035C48.0957%2018.6035%2045.5078%2015.9668%2045.5078%2012.6953C45.5078%209.375%2048.0957%206.8359%2051.3672%206.8359C54.7364%206.8359%2057.2754%209.375%2057.2754%2012.6953C57.2754%2015.9668%2054.7364%2018.6035%2051.3672%2018.6035Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E',
disableLoop: 'data:image/svg+xml,%3Csvg%20width%3D%2299%22%20height%3D%2266%22%20viewBox%3D%220%200%2099%2066%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M28.1739%2065.8691H70.6543C87.6953%2065.8691%2098.8284%2054.834%2098.8284%2037.7441C98.8284%2020.6543%2087.6953%209.47259%2070.6543%209.47259H62.2559C60.3028%209.47259%2058.7403%2011.084%2058.7403%2012.9883C58.7403%2014.9414%2060.3028%2016.5527%2062.2559%2016.5527H70.6543C83.252%2016.5527%2091.7964%2025.1465%2091.7964%2037.7441C91.7964%2050.3418%2083.252%2058.8379%2070.6543%2058.8379H28.1739C15.5274%2058.8379%207.03128%2050.3418%207.03128%2037.7441C7.03128%2025.1465%2015.5274%2016.5527%2028.1739%2016.5527H33.3496C33.1055%2015.332%2032.959%2014.0625%2032.959%2012.7441C32.959%2011.6699%2033.0567%2010.5957%2033.252%209.52149L28.1739%209.47259C11.0352%209.32619%200%2020.6543%200%2037.7441C0%2054.834%2011.0352%2065.8691%2028.1739%2065.8691Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M51.3672%2025.4394C58.4473%2025.4394%2064.1114%2019.7266%2064.1114%2012.6953C64.1114%205.6641%2058.4473%200%2051.3672%200C44.336%200%2038.6719%205.6641%2038.6719%2012.6953C38.6719%2019.7266%2044.336%2025.4394%2051.3672%2025.4394Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E',
stats: 'data:image/svg+xml,%3Csvg%20width%3D%2279%22%20height%3D%2279%22%20viewBox%3D%220%200%2079%2079%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M39.4043%2078.8086C61.1817%2078.8086%2078.8575%2061.1816%2078.8575%2039.4043C78.8575%2017.627%2061.1817%200%2039.4043%200C17.6758%200%200%2017.627%200%2039.4043C0%2061.1816%2017.6758%2078.8086%2039.4043%2078.8086ZM39.4043%2071.3867C21.7286%2071.3867%207.47066%2057.0801%207.47066%2039.4043C7.47066%2021.7285%2021.7286%207.42191%2039.4043%207.42191C57.0801%207.42191%2071.3868%2021.7285%2071.3868%2039.4043C71.3868%2057.0801%2057.0801%2071.3867%2039.4043%2071.3867Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M32.8125%2061.0352H48.3399C50.0977%2061.0352%2051.5137%2059.7656%2051.5137%2057.959C51.5137%2056.25%2050.0977%2054.9316%2048.3399%2054.9316H44.043V36.6699C44.043%2034.2773%2042.8711%2032.7637%2040.6739%2032.7637H33.545C31.7871%2032.7637%2030.42%2034.082%2030.42%2035.7422C30.42%2037.5488%2031.7871%2038.8184%2033.545%2038.8184H37.1094V54.9316H32.8125C31.0547%2054.9316%2029.6387%2056.25%2029.6387%2057.959C29.6387%2059.7656%2031.0547%2061.0352%2032.8125%2061.0352ZM39.0137%2026.7578C42.0899%2026.7578%2044.4825%2024.3164%2044.4825%2021.2402C44.4825%2018.1641%2042.0899%2015.7715%2039.0137%2015.7715C35.9864%2015.7715%2033.545%2018.1641%2033.545%2021.2402C33.545%2024.3164%2035.9864%2026.7578%2039.0137%2026.7578Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E',
playbackRate: 'data:image/svg+xml,%3Csvg%20width%3D%2279%22%20height%3D%2279%22%20viewBox%3D%220%200%2079%2079%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M39.4043%2078.8086C61.1817%2078.8086%2078.8575%2061.1816%2078.8575%2039.4043C78.8575%2017.627%2061.1817%200%2039.4043%200C17.6758%200%200%2017.627%200%2039.4043C0%2061.1816%2017.6758%2078.8086%2039.4043%2078.8086ZM39.4043%2071.3867C21.7286%2071.3867%207.47066%2057.0801%207.47066%2039.4043C7.47066%2021.7285%2021.7286%207.42191%2039.4043%207.42191C57.0801%207.42191%2071.3868%2021.7285%2071.3868%2039.4043C71.3868%2057.0801%2057.0801%2071.3867%2039.4043%2071.3867Z%22%20fill%3D%22black%22%2F%3E%3Cpath%20d%3D%22M24.3653%2057.8125C26.3184%2057.8125%2027.9297%2056.2012%2027.9297%2054.248C27.9297%2052.2949%2026.3184%2050.6836%2024.3653%2050.6836C22.4121%2050.6836%2020.8008%2052.2949%2020.8008%2054.248C20.8008%2056.2012%2022.4121%2057.8125%2024.3653%2057.8125ZM18.1641%2042.9687C20.1172%2042.9687%2021.7286%2041.3574%2021.7286%2039.4043C21.7286%2037.4512%2020.1172%2035.8398%2018.1641%2035.8398C16.211%2035.8398%2014.5508%2037.4512%2014.5508%2039.4043C14.5508%2041.3574%2016.211%2042.9687%2018.1641%2042.9687ZM24.3653%2028.125C26.3184%2028.125%2027.9297%2026.5137%2027.9297%2024.5605C27.9297%2022.6074%2026.3184%2020.9961%2024.3653%2020.9961C22.4121%2020.9961%2020.8008%2022.6074%2020.8008%2024.5605C20.8008%2026.5137%2022.4121%2028.125%2024.3653%2028.125ZM39.3555%2021.7773C41.3086%2021.7773%2042.9688%2020.166%2042.9688%2018.2129C42.9688%2016.2598%2041.3086%2014.6484%2039.3555%2014.6484C37.4024%2014.6484%2035.7911%2016.2598%2035.7911%2018.2129C35.7911%2020.166%2037.4024%2021.7773%2039.3555%2021.7773ZM60.4981%2042.9687C62.4512%2042.9687%2064.1114%2041.3574%2064.1114%2039.4043C64.1114%2037.4512%2062.4512%2035.8398%2060.4981%2035.8398C58.545%2035.8398%2056.9336%2037.4512%2056.9336%2039.4043C56.9336%2041.3574%2058.545%2042.9687%2060.4981%2042.9687ZM54.2969%2057.8125C56.25%2057.8125%2057.8614%2056.2012%2057.8614%2054.248C57.8614%2052.2949%2056.25%2050.6836%2054.2969%2050.6836C52.3438%2050.6836%2050.7325%2052.2949%2050.7325%2054.248C50.7325%2056.2012%2052.3438%2057.8125%2054.2969%2057.8125ZM32.8614%2045.8008C35.5469%2048.4863%2039.3067%2048.4863%2041.9922%2045.8008C42.5293%2045.2637%2043.3106%2044.1406%2043.7989%2043.457L56.3477%2025.0488C57.0801%2023.9746%2056.836%2022.998%2056.25%2022.3633C55.6641%2021.7773%2054.6387%2021.582%2053.6133%2022.2656L35.1563%2034.8144C34.5215%2035.3027%2033.3496%2036.1328%2032.8614%2036.6211C30.1758%2039.3066%2030.1758%2043.1152%2032.8614%2045.8008Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E',
};
const STYLE = `
.ccl-controls-container {
position: fixed;
z-index: 999;
display: block;
pointer-events: none;
will-change: top, left, width, height;
}
.ccl-bar {
position: absolute;
top: 6px;
left: 6px;
display: inline-flex;
will-change: transform;
height: 31px;
}
.ccl-bg, .ccl-bg > div {
position: absolute;
inset: 0;
border-radius: 24px;
pointer-events: none;
}
.ccl-bg > .blur {
background-color: rgba(0, 0, 0, 0.55);
backdrop-filter: saturate(180%) blur(17.5px);
-webkit-backdrop-filter: saturate(180%) blur(17.5px);
}
.ccl-btn-container > button {
display: flex;
align-items: center;
justify-content: center;
border-width: 0;
padding: 0;
cursor: pointer;
background-color: transparent !important;
transition: opacity 0.1s linear;
}
.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-stats { --icon: url('${ICONS.stats}'); }
.ccl-icon-rate { --icon: url('${ICONS.playbackRate}'); }
.ccl-btn-container picture {
width: 16px;
height: 16px;
background-color: white;
mix-blend-mode: plus-lighter;
mask-image: var(--icon);
mask-size: 100% 100%;
mask-repeat: no-repeat;
transition: transform 150ms;
pointer-events: none;
}
.ccl-btn-container > button:active:not(:has(.ccl-menu:active)) picture { transform: scale(0.89); }
.ccl-btn-container {
display: flex;
gap: 16px;
justify-content: center;
align-items: center;
padding: 0 16px;
}
.ccl-bar.hidden {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.ccl-bar.visible {
opacity: 1;
pointer-events: auto;
transition: opacity 0.3s ease;
}
.ccl-menu {
position: absolute;
top: 50%;
left: 80%;
display: none;
flex-direction: column;
gap: 2px;
padding: 6px;
background-color: hsla(0, 0%, 25%, 0.6);
transition: opacity 0.2s ease;
border-radius: 8px;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
cursor: default;
}
.ccl-menu.visible { display: flex; }
.ccl-menu-item {
display: flex;
align-items: center;
gap: 8px;
color: white;
font-size: 12px;
font-family: sans-serif;
padding: 4px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
pointer-events: auto;
}
.ccl-menu-item:hover {
background: rgba(255, 255, 255, 0.2) !important;
}
.ccl-menu-item:active {
color: rgba(255, 255, 255, 0.5);
font-weight: bold;
}
.ccl-menu-item::before {
content: '✓';
visibility: hidden;
color: white;
font-weight: bold;
width: 12px;
}
.ccl-menu-item.active::before {
visibility: visible;
}
.ccl-stats-container {
width: 100%;
height: 100%;
position: absolute;
justify-content: center;
align-items: center;
pointer-events: none;
display: none;
z-index: 999;
}
.ccl-stats-container.visible { display: flex; }
.ccl-stats-container > table {
padding: 4px;
background-color: hsla(0, 0%, 25%, 0.6);
border-radius: 6px;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.ccl-stats-container th {
padding-inline-end: 6px;
text-align: end;
}
.ccl-stats-container th, .ccl-stats-container td {
font-size: 12px;
font-family: sans-serif;
color: hsl(0, 0%, 95%);
}
`;
class BaseControl {
constructor(cls, onClick) {
this.video = null;
this.el = this.createButton(cls, onClick);
}
createButton(cls, onClick) {
const btn = document.createElement('button');
const picture = document.createElement('picture');
picture.classList.add(cls);
btn.appendChild(picture);
btn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
onClick();
};
return btn;
}
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() {
this.el.querySelector('picture').setAttribute('data-active', document.pictureInPictureElement === this.video);
}
}
class LoopControl extends BaseControl {
constructor() {
super('ccl-icon-loop', () => { this.video.loop = !this.video.loop; this.update(); });
this.observer = null;
}
setVideo(v) {
super.setVideo(v);
if (this.observer) this.observer.disconnect();
this.observer = new MutationObserver(() => this.update());
this.observer.observe(v, { attributes: true, attributeFilter: ['loop'] });
}
update() {
this.el.querySelector('picture').setAttribute('data-active', this.video.loop);
}
}
class StatsControl extends BaseControl {
constructor(onTogglePanel) {
super('ccl-icon-stats', () => onTogglePanel());
}
update() { }
}
class RateControl extends BaseControl {
constructor() {
super('ccl-icon-rate', () => this.toggleMenu());
this.menu = this.createMenu();
this.el.appendChild(this.menu);
this._handleDocumentClick = this.handleDocumentClick.bind(this);
}
createMenu() {
const menu = document.createElement('div');
menu.classList.add('ccl-menu');
[0.5, 1, 1.25, 1.5, 2].forEach(r => {
const item = document.createElement('div');
item.classList.add('ccl-menu-item');
item.textContent = r + '×';
item.dataset.rate = r;
item.onclick = (e) => {
e.stopPropagation();
if (this.video) this.video.playbackRate = r;
this.closeMenu();
};
menu.appendChild(item);
})
return menu;
}
toggleMenu() {
const isVisible = this.menu.classList.contains('visible');
isVisible ? this.closeMenu() : this.openMenu();
}
openMenu() {
this.menu.classList.add('visible');
this.update();
document.addEventListener('click', this._handleDocumentClick, true);
}
closeMenu() {
this.menu.classList.remove('visible');
document.removeEventListener('click', this._handleDocumentClick, true);
}
handleDocumentClick(e) {
if (this.el.contains(e.target)) return;
this.closeMenu();
}
update() {
if (!this.video) return;
Array.from(this.menu.children).forEach(item => {
const rate = parseFloat(item.textContent);
item.classList.toggle('active', Math.abs(this.video.playbackRate - rate) < 0.01);
});
}
}
class UIManager {
constructor() {
this.target = null;
this.container = this.createContainer();
this.pipControl = new PipControl();
this.loopControl = new LoopControl();
this.statsCotrol = new StatsControl(() => this.updateStats());
this.rateControl = new RateControl();
this.controls = [this.pipControl, this.loopControl, this.statsCotrol, this.rateControl];
this.init();
}
init() {
const style = document.createElement('style');
style.textContent = STYLE;
document.head.appendChild(style);
const bar = this.createControlsBar();
const stats = this.createStatsContainer();
this.container.append(bar, stats);
document.body.appendChild(this.container);
}
createContainer() {
const container = document.createElement('div');
container.classList.add('ccl-controls-container');
return container;
}
createControlsBar() {
const bg = document.createElement('div');
bg.classList.add('ccl-bg');
const blur = document.createElement('div');
blur.classList.add('blur');
bg.appendChild(blur);
const container = document.createElement('div');
container.classList.add('ccl-btn-container');
this.controls.forEach(c => container.appendChild(c.el));
const bar = document.createElement('div');
bar.classList.add('ccl-bar', 'hidden');
bar.append(bg, container);
return bar;
}
createStatsContainer() {
const container = document.createElement('div');
container.classList.add('ccl-stats-container');
const table = document.createElement('table');
container.append(table);
return container;
}
updateStats() {
const isVisible = this.container.querySelector('.ccl-stats-container').classList.toggle('visible');
if (isVisible && this.target) {
const getSourceType = () => {
const src = this.target.currentSrc;
if (src.startsWith('blob:')) return 'Media Source';
if (src.includes('m3u8')) return 'HLS';
return 'File';
};
const data = {
'Source': getSourceType(),
'Viewport': `${this.target.clientWidth}×${this.target.clientHeight} (${window.devicePixelRatio}x)`,
'Resolution': `${this.target.videoWidth}×${this.target.videoHeight}`
};
const table = this.container.querySelector('table');
table.textContent = '';
for (const [key, val] of Object.entries(data)) {
const row = document.createElement('tr');
const th = document.createElement('th');
th.textContent = key;
const td = document.createElement('td');
td.textContent = val;
row.append(th, td);
table.appendChild(row);
}
}
}
attach(video) {
this.target = video;
this.controls.forEach(c => c.setVideo(video));
this.container.querySelector('.ccl-stats-container').classList.remove('visible');
}
detach() {
this.target = null;
this.hide();
this.container.querySelector('.ccl-stats-container').classList.remove('visible');
}
reposition(rect) {
if (!rect) return;
Object.assign(this.container.style, {
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`
});
}
show() { this.container.querySelector('.ccl-bar').classList.replace('hidden', 'visible'); }
hide() { this.container.querySelector('.ccl-bar').classList.replace('visible', 'hidden'); }
}
class App {
constructor() {
this.ui = new UIManager();
this.activeVideo = null;
this.videoRect = null;
this.isPlayEventTriggered = false;
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.isPlayEventTriggered = true;
this.isPaused = false;
this.showAndTimer(0);
};
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.pipControl.update(), true);
document.addEventListener('leavepictureinpicture', () => this.ui.pipControl.update(), true);
document.addEventListener('webkitpresentationmodechanged', () => this.ui.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 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.isPlayEventTriggered = false;
this.showAndTimer();
} else {
if (!this.isPlayEventTriggered) this.ui.hide();
}
}
showAndTimer(timeout = 3000) {
this.clearHideTimer();
this.ui.show();
this.hideTimeout = setTimeout(() => { this.ui.hide(); this.isPlayEventTriggered = false; }, 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();
})();