chimo-chimo-loop

Adds Picture-in-Picture (PiP) and loop controls to supported HTML5 video players.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         chimo-chimo-loop
// @name:zh-CN   chimo-chimo-loop
// @namespace    https://github.com/ryu-dayo/chimo-chimo-loop
// @version      0.2.2
// @description  Adds Picture-in-Picture (PiP) and loop controls to supported HTML5 video players.
// @description:zh-CN  为支持的网站的视频播放器添加画中画(PiP)和循环播放按钮。
// @author       ryu-dayo
// @match        https://www.douyin.com/*
// @match        https://www.instagram.com/*
// @match        https://www.xiaohongshu.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=douyin.com
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // Inline base64-encoded SVG icons
    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',
    };

    const MIN_VIDEO_WIDTH = 300;
    const MIN_VIDEO_HEIGHT = 200;
    const BTN = 16;

    let currentPipButton = null;
    let currentVideo = null;

    const isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);

    // === Styles ===
    const injectStyle = () => {
        if (!document.querySelector('style[data-from="chimo-loop"]')) {
            const style = document.createElement('style');
            style.setAttribute('data-from', 'chimo-loop');
            style.textContent = `
            
            #controls-bar {
            position: absolute;
            top: 6px;
            left: 6px;
            z-index: 999;
            display: inline-flex;
            will-change: z-index;
            cursor: default;
            height: 31px;
            }

            .background-tint, .background-tint > div {
            position: absolute;
            width: 100%;
            height: 100%;
            border-radius: 8px;
            pointer-events: none;
            }

            .background-tint > .blur {
            background-color: rgba(0, 0, 0, 0.55);
            backdrop-filter: saturate(180%) blur(17.5px);
            -webkit-backdrop-filter: saturate(180%) blur(17.5px);
            }

            .background-tint > .tint {
            background-color: rgba(255, 255, 255, 0.14);
            mix-blend-mode: lighten;
            }

            .pip-button, .loop-button {
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 0;
            border-width: 0;
            background-color: transparent !important;
            appearance: none;
            transition: opacity 0.1s linear;
            }

            .picture {
            background-color: rgba(255, 255, 255, 1);
            mix-blend-mode: plus-lighter;
            mask-size: 100% 100%;
            mask-repeat: no-repeat;
            transition: transform 150ms;
            will-change: transform;
            pointer-events: none;
            }

            .pip-button:active picture,
            .loop-button:active picture {
            transform: scale(0.89);
            }

            #buttons-container {
            display: flex;
            gap: 16px;
            justify-content: center;
            align-items: center;
            padding: 0 16px;
            }

            #controls-bar.hidden {
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s ease;
            }

            #controls-bar.visible {
            opacity: 1;
            pointer-events: auto;
            transition: opacity 0.3s ease;
            }
            `;
            document.head.appendChild(style);
        }
    };

    const getActiveVideo = () => {
        const videos = Array.from(document.querySelectorAll('video'));
        if (videos.length === 0) return null;

        // Filter: Only consider videos that are visible in the viewport and sufficiently large
        const filtered = videos.filter(v => {
            const rect = v.getBoundingClientRect();
            return rect.width > MIN_VIDEO_WIDTH && rect.height > MIN_VIDEO_HEIGHT && rect.bottom > 0 && rect.top < window.innerHeight;
        });

        if (filtered.length === 0) return null;

        const playing = filtered.find(v => !v.paused && !v.ended);
        if (playing && !playing.__chimoControlsAttached) return playing;

        // Prefer videos without existing controls
        const unpatched = filtered.find(v => !v.__chimoControlsAttached);
        if (unpatched) return unpatched;

        // Fallback: select the video element closest to the center of the screen
        const centerX = window.innerWidth / 2;
        const centerY = window.innerHeight / 2;
        let best = null;
        let minDist = Infinity;

        for (const v of filtered) {
            const rect = v.getBoundingClientRect();
            const dx = rect.left + rect.width / 2 - centerX;
            const dy = rect.top + rect.height / 2 - centerY;
            const dist = dx * dx + dy * dy;
            if (dist < minDist) {
                best = v;
                minDist = dist;
            }
        }

        return best;
    };

    // Glassmorphic background (blur and tint) for the control bar
    const buildBackgroundTint = () => {
        const backgroundTint = document.createElement('div');
        backgroundTint.id = 'background-tint';
        backgroundTint.classList.add('background-tint');

        const blur = document.createElement('div');
        blur.classList.add('blur');

        const tint = document.createElement('div');
        tint.classList.add('tint');

        backgroundTint.append(blur, tint);
        return backgroundTint;
    };

    const createButton = ({ className, onClick }) => {
        const picture = document.createElement('picture');
        picture.classList.add('picture');
        picture.style.width = `${BTN}px`;
        picture.style.height = `${BTN}px`;

        const button = document.createElement('button');
        button.classList.add(className);
        button.style.pointerEvents = 'auto';

        button.append(picture);
        button.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            onClick?.();
        });

        return button;
    };

    // Ensure PiP attributes and iframe permissions are set
    const ensurePipEnabled = (video) => {
        if (!video) return false;
        try {
            // Remove disablepictureinpicture attribute if present
            if (video.hasAttribute('disablepictureinpicture')) {
                video.removeAttribute('disablepictureinpicture');
            }
            // Set disablePictureInPicture property to false if supported
            if ('disablePictureInPicture' in video) {
                try { video.disablePictureInPicture = false; } catch (_) { }
            }
            // Ensure iframe allows picture-in-picture if inside an iframe
            const frame = window.frameElement;
            if (frame && frame.tagName === 'IFRAME') {
                const allow = frame.getAttribute('allow') || '';
                if (!/picture-in-picture/.test(allow)) {
                    frame.setAttribute('allow', (allow ? allow + ';' : '') + 'picture-in-picture');
                }
            }
            return true;
        } catch (e) {
            console.warn('[chimo] Failed to ensure PiP enabled:', e);
            return false;
        }
    };

    const updatePipButton = () => {
        if (!currentPipButton || !currentVideo) return;
        const isInPip = document.pictureInPictureElement === currentVideo;
        const pipBase64 = isInPip ? icons.exitPip : icons.enterPip;
        currentPipButton.querySelector('picture').style.maskImage = `url('${pipBase64}')`;
    };

    const createPipButton = (video) => {
        if (!document.pictureInPictureEnabled || !ensurePipEnabled(video)) return null;
        const supportsSafariPip = typeof video.webkitSetPresentationMode === 'function';
        if (isSafari && !supportsSafariPip) return null;

        const togglePip = () => {
            if (!video) {
                console.log("[chimo-chimo-loop]Video not found");
                return;
            }

            if (isSafari) {
                const mode = video.webkitPresentationMode;
                video.webkitSetPresentationMode(mode === 'picture-in-picture' ? 'inline' : 'picture-in-picture');
                console.log(mode);
                return;
            }

            if (document.pictureInPictureElement === video) {
                document.exitPictureInPicture().catch(err => console.warn(err));
                console.log("[chimo-chimo-loop]Exit PiP");

            } else {
                video.requestPictureInPicture().catch(err => console.warn(err));
                console.log("[chimo-chimo-loop]Request PiP");
            }
        };

        const btn = createButton({ className: 'pip-button', onClick: togglePip });

        currentVideo = video;
        currentPipButton = btn;

        updatePipButton();

        return btn;
    };

    const createLoopButton = (video) => {
        // Update loop icon to reflect current loop state
        const updateLoopButton = () => {
            const loopBase64 = video?.loop ? icons.disableLoop : icons.enableLoop;
            btn.querySelector('picture').style.maskImage = `url('${loopBase64}')`;
        };

        const toggleLoop = () => {
            if (!video) {
                console.log("[chimo-chimo-loop]Video not found");
                return;
            }

            video.loop = !video.loop;
            updateLoopButton();
        };

        const btn = createButton({ className: 'loop-button', onClick: toggleLoop });
        updateLoopButton();

        return btn;
    };

    const buildButtonsContainer = (video) => {
        const buttonsContainer = document.createElement('div');
        buttonsContainer.id = 'buttons-container';
        buttonsContainer.classList.add('buttons-container');

        const pipBtn = createPipButton(video);
        const loopBtn = createLoopButton(video);

        if (pipBtn) buttonsContainer.append(pipBtn);
        if (loopBtn) buttonsContainer.append(loopBtn);

        return buttonsContainer;
    };

    const buildControlsBar = (video) => {
        const backgroundTint = buildBackgroundTint();
        const buttonsContainer = buildButtonsContainer(video);

        // Root element for the control bar
        const controlsBar = document.createElement('div');
        controlsBar.id = 'controls-bar';
        controlsBar.classList.add('hidden', 'controls-bar');

        controlsBar.append(backgroundTint, buttonsContainer);
        return controlsBar;
    };

    const attachControls = (video, controlsBar) => {
        // Attach to the video's parent if possible
        const parent = video.parentElement || document.body;
        const parentStyle = getComputedStyle(parent);

        if (parentStyle.position === 'static') {
            parent.style.position = 'relative';
        }
        parent.appendChild(controlsBar);
    };

    const setupAutoHide = (controlsBar, targetElement) => {
        let hideTimeout;

        const showControls = () => {
            controlsBar.classList.remove('hidden');
            controlsBar.classList.add('visible');

            clearTimeout(hideTimeout);
            hideTimeout = setTimeout(() => {
                controlsBar.classList.remove('visible');
                controlsBar.classList.add('hidden');
            }, 3000);
        };

        // Show controls on user interaction
        targetElement.addEventListener('pointermove', showControls, { passive: true });
        targetElement.addEventListener('touchstart', showControls, { passive: true });
        showControls();
    };

    const initializeControls = (video) => {
        if (video.__chimoControlsAttached) return;
        video.__chimoControlsAttached = true;

        injectStyle();

        const controlsBar = buildControlsBar(video);
        attachControls(video, controlsBar);

        setupAutoHide(controlsBar, video.parentElement);
    };

    // === Observers ===
    const observeVideoDom = () => {
        let moTimer = null;

        const observer = new MutationObserver((mutations) => {
            if (!mutations.some(mutation => mutation.type === 'childList')) return;

            if (moTimer) return;
            moTimer = requestAnimationFrame(() => {
                moTimer = null;
                const video = getActiveVideo();
                if (video) initializeControls(video);
            });
        });
        observer.observe(document.body, { childList: true, subtree: true });
    };

    const setupGlobalPipEvents = () => {
        document.addEventListener("enterpictureinpicture", updatePipButton);
        document.addEventListener("leavepictureinpicture", updatePipButton);
    };

    // === Init ===
    const main = () => {
        setupGlobalPipEvents();
        observeVideoDom();
    };

    main();
})();