☰

TwitterGoonScroller

The perfect tool for gooning on Twitter/X ! Fullscreen video auto-scroll + replaces black side bars for vertical videos with more goon.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         TwitterGoonScroller
// @namespace    local.x-goon-scroller
// @version      1.5.1
// @description  The perfect tool for gooning on Twitter/X ! Fullscreen video auto-scroll + replaces black side bars for vertical videos with more goon.
// @match        https://x.com/*
// @match        https://twitter.com/*
// @match        https://www.twitter.com/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @license MIT
// ==/UserScript==

(() => {
    "use strict";
    const modes = {
        duplicate: "Next media on both sides",
        different: "Next left / second next right",
        blur: "Blur main video",
    };
    let mode = GM_getValue("sidefill.mode", "duplicate");
    if (!modes[mode]) mode = "duplicate";
    let enabled = GM_getValue("sidefill.enabled", true);
    // Auto-play is a core feature: switch it on once for existing installs that
    // saved it as off; later choices in the panel are respected.
    if (!GM_getValue("sidefill.autoAdvanceDefaulted", false)) {
        GM_setValue("sidefill.autoAdvance", true);
        GM_setValue("sidefill.autoAdvanceDefaulted", true);
    }
    let autoAdvance = GM_getValue("sidefill.autoAdvance", true);
    let sideAudio = GM_getValue("sidefill.sideAudio", false);
    let active = null;
    let candidates = [];
    let lastScan = 0;
    let lastFrame = 0;
    let dirty = true;
    let transition = null;
    // Browser fullscreen needs a user gesture, so it cannot be re-entered when a
    // video ends. Instead, X's fullscreen request is redirected to <html> (which
    // stays fullscreen), and the requested player is lifted into the top layer.
    // Advancing then only moves that "pseudo fullscreen" to the next player.
    let pseudoTarget = null;
    let pseudoDepth = 0;
    // While switching videos, X is told fullscreen ended so its feed updates
    // (it freezes loading/mounting posts while a player is fullscreen).
    let spoofSuspended = false;
    let handoffActive = false;
    // Entering fullscreen is asynchronous; do not treat the gap before the
    // browser's fullscreenchange as "fullscreen was left".
    let pseudoPendingUntil = 0;
    const pageWindow = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
    const nativeFullscreenGet = Object.getOwnPropertyDescriptor(
        Document.prototype,
        "fullscreenElement",
    )?.get;
    const realFullscreenElement = () =>
        nativeFullscreenGet
            ? nativeFullscreenGet.call(document)
            : document.fullscreenElement;
    const fullscreenElement = () =>
        (pseudoTarget?.isConnected && pseudoTarget) || realFullscreenElement();
    const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const advanceLog = [];
    function logAdvance(message) {
        advanceLog.push(message);
        if (advanceLog.length > 12) advanceLog.shift();
    }
    const sidePlayers = new Map();
    const resolvedMedia = new WeakMap();
    const routeOK = () =>
        /^\/(home|i\/(history|bookmarks))(\/|$)/.test(location.pathname);
    // X loads video metadata lazily, so upcoming videos often have no size yet.
    // Their thumbnail has the same aspect ratio and is available immediately.
    const posters = new Map();
    function posterImage(video) {
        return cachedImage(
            video.poster ||
                video
                    .closest('[data-testid="videoPlayer"]')
                    ?.querySelector("img")?.src ||
                "",
        );
    }
    function cachedImage(url) {
        if (!url) return null;
        let img = posters.get(url);
        if (!img) {
            img = new Image();
            img.decoding = "async";
            img.src = url;
            posters.set(url, img);
            if (posters.size > 200) posters.delete(posters.keys().next().value);
        }
        return img.complete && img.naturalWidth ? img : null;
    }
    const dimensions = (el) => {
        if (!(el instanceof HTMLVideoElement))
            return [el.naturalWidth, el.naturalHeight];
        if (el.videoWidth) return [el.videoWidth, el.videoHeight];
        const poster = el.isConnected ? posterImage(el) : null;
        return poster ? [poster.naturalWidth, poster.naturalHeight] : [0, 0];
    };
    const portrait = (el) => {
        const [w, h] = dimensions(el);
        return w > 0 && h > w;
    };
    const visible = (el) => {
        const r = el.getBoundingClientRect();
        return (
            r.width > 0 &&
            r.height > 0 &&
            r.bottom > 0 &&
            r.top < innerHeight &&
            r.right > 0 &&
            r.left < innerWidth
        );
    };

    const panel = document.createElement("div");
    panel.id = "x-sidefill-settings";
    const shadow = panel.attachShadow({ mode: "open" });
    shadow.innerHTML = `<style>
      :host { position:fixed; bottom:16px; right:128px; z-index:2147483647; font:13px system-ui,sans-serif; color:#eee; }
      details { background:#16181cee; border:1px solid #536471; border-radius:12px; padding:10px 12px; max-width:280px; box-shadow:0 3px 18px #0007; }
      summary { cursor:pointer; user-select:none; display:flex; align-items:center; list-style:none; }
      summary::-webkit-details-marker { display:none; }
      .logo { font:900 16px/1.1 Georgia, 'Times New Roman', serif; letter-spacing:.12em; color:#fff; border:1px solid #8899a6; border-radius:5px; padding:3px 5px 3px 6px; }
      .logo-g { color:#e0245e; }
      select { display:block; width:100%; margin-top:6px; background:#222; color:white; padding:6px; border:1px solid #777; border-radius:5px; }
      p { margin:10px 0 0; color:#bbc3cb; line-height:1.4; }
    </style><details><summary><span class="logo">T<span class="logo-g">G</span>S</span></summary><label><input id="enabled" type="checkbox"> Enabled</label>
    <label>Fill mode<select></select></label>
    <label><input id="advance" type="checkbox"> Auto-play next video</label>
    <label><input id="audio" type="checkbox"> Play side-video audio</label>
    <p>Auto-play follows every loaded video. Portrait videos get side fills. Duplicate sides share one audio track.</p><p role="status"></p>
    <button id="resume-fullscreen" hidden>Enter X fullscreen</button></details>`;
    const checkbox = shadow.querySelector("#enabled");
    const advanceCheckbox = shadow.querySelector("#advance");
    const audioCheckbox = shadow.querySelector("#audio");
    const select = shadow.querySelector("select");
    const status = shadow.querySelector("[role=status]");
    const resumeFullscreen = shadow.querySelector("#resume-fullscreen");
    resumeFullscreen.addEventListener("click", () => {
        if (!active) return;
        const button = findControl(active, "enter");
        if (button) {
            button.click();
            active.notice = "";
        } else active.notice = "X fullscreen controls are not loaded yet.";
    });
    for (const [value, label] of Object.entries(modes))
        select.add(new Option(label, value));
    select.value = mode;
    checkbox.checked = enabled;
    advanceCheckbox.checked = autoAdvance;
    audioCheckbox.checked = sideAudio;
    function configure() {
        enabled = checkbox.checked;
        mode = select.value;
        GM_setValue("sidefill.enabled", enabled);
        GM_setValue("sidefill.mode", mode);
        teardown();
        dirty = true;
    }
    checkbox.addEventListener("change", configure);
    select.addEventListener("change", configure);
    advanceCheckbox.addEventListener("change", () => {
        autoAdvance = advanceCheckbox.checked;
        GM_setValue("sidefill.autoAdvance", autoAdvance);
        if (!autoAdvance) cancelTransition();
        if (active) {
            active.video.loop = autoAdvance ? false : active.originalLoop;
            active.notice = "";
        }
    });
    audioCheckbox.addEventListener("change", () => {
        sideAudio = audioCheckbox.checked;
        GM_setValue("sidefill.sideAudio", sideAudio);
        // Apply within the click gesture so browsers can grant audible playback.
        for (const saved of sidePlayers.values()) {
            saved.audioBlocked = false;
            saved.player.muted = !sideAudio;
            saved.player.volume = active?.video.volume ?? 1;
            if (active && !active.video.paused && !active.video.ended)
                playSide(saved);
        }
    });
    GM_registerMenuCommand("Portrait side fill: settings", () => {
        shadow.querySelector("details").open = true;
    });
    for (const [value, label] of Object.entries(modes))
        GM_registerMenuCommand(label, () => {
            select.value = value;
            configure();
        });
    document.body.append(panel);

    const pseudoStyle = document.createElement("style");
    pseudoStyle.textContent = `
      [data-tgs-fs] { position:fixed !important; inset:0 !important; width:100vw !important; height:100vh !important;
        min-width:0 !important; min-height:0 !important; max-width:none !important; max-height:none !important;
        margin:0 !important; padding:0 !important; border:0 !important; box-sizing:border-box !important;
        transform:none !important; background:#000 !important; color:inherit !important;
        overflow:hidden !important; z-index:2147483646 !important; object-fit:contain; }
      [data-tgs-fs]::backdrop { background:#000; }
      html[data-tgs-fs-active], html[data-tgs-fs-active] body {
        scrollbar-width:none !important; scrollbar-gutter:auto !important; }
      html[data-tgs-fs-active]::-webkit-scrollbar, html[data-tgs-fs-active] body::-webkit-scrollbar { display:none !important; }
      [data-tgs-fs], [data-tgs-fs] * { scrollbar-width:none !important; }
      [data-tgs-fs]::-webkit-scrollbar, [data-tgs-fs] *::-webkit-scrollbar { display:none !important; }`;
    document.head.append(pseudoStyle);
    function openPseudo(node) {
        node.setAttribute("data-tgs-fs", "");
        try {
            if (!node.matches(":popover-open")) {
                node.setAttribute("popover", "manual");
                node.showPopover();
            }
        } catch {
            // No popover support: fall back to plain fixed positioning.
            node.removeAttribute("popover");
        }
    }
    function closePseudo(node) {
        try {
            if (node.matches(":popover-open")) node.hidePopover();
        } catch {
            /* unsupported */
        }
        node.removeAttribute("popover");
        node.removeAttribute("data-tgs-fs");
    }
    function showPseudo(target) {
        const previous = pseudoTarget;
        pseudoTarget = target;
        document.documentElement.setAttribute("data-tgs-fs-active", "");
        // Open the new player before closing the old one so nothing flashes.
        openPseudo(target);
        if (previous && previous !== target) closePseudo(previous);
    }
    // A still of the finished video that covers the feed while X re-renders.
    const curtain = document.createElement("canvas");
    curtain.dataset.xSidefill = "true";
    curtain.setAttribute("aria-hidden", "true");
    curtain.setAttribute("popover", "manual");
    curtain.style.cssText =
        "position:fixed;inset:0;width:100vw;height:100vh;max-width:none;max-height:none;margin:0;padding:0;border:0;background:#000;z-index:2147483647;";
    function raiseCurtain() {
        // Re-inserting puts it on top of anything added to the top layer since.
        curtain.remove();
        document.body.append(curtain);
        try {
            curtain.showPopover();
        } catch {
            curtain.removeAttribute("popover");
        }
    }
    function showCurtain(video, extras = []) {
        curtain.width = innerWidth;
        curtain.height = innerHeight;
        const ctx = curtain.getContext("2d");
        ctx.fillStyle = "#000";
        ctx.fillRect(0, 0, curtain.width, curtain.height);
        for (const el of [video, ...extras]) {
            try {
                if (!el?.isConnected || el.style.display === "none") continue;
                const r = el.getBoundingClientRect();
                if (el instanceof HTMLVideoElement) {
                    if (!el.videoWidth) continue;
                    const fit = Math.min(
                        r.width / el.videoWidth,
                        r.height / el.videoHeight,
                    );
                    const w = el.videoWidth * fit;
                    const h = el.videoHeight * fit;
                    ctx.drawImage(
                        el,
                        r.left + (r.width - w) / 2,
                        r.top + (r.height - h) / 2,
                        w,
                        h,
                    );
                } else ctx.drawImage(el, r.left, r.top, r.width, r.height);
            } catch {
                /* frame not drawable */
            }
        }
        raiseCurtain();
    }
    function hideCurtain() {
        curtain.remove();
    }
    function beginHandoff(video) {
        if (handoffActive || !pseudoTarget) return;
        handoffActive = true;
        showCurtain(video, active?.canvases);
        spoofSuspended = true;
        announceFullscreenChange([pseudoTarget]);
    }
    // Hand fullscreen back to X for whichever player is the pseudo target now.
    function endHandoff(keepCurtain = false) {
        if (!handoffActive) return;
        handoffActive = false;
        spoofSuspended = false;
        announceFullscreenChange([pseudoTarget]);
        if (!keepCurtain)
            requestAnimationFrame(() => setTimeout(hideCurtain, 120));
    }
    function clearPseudo() {
        spoofSuspended = false;
        handoffActive = false;
        hideCurtain();
        pseudoPendingUntil = 0;
        if (pseudoTarget) closePseudo(pseudoTarget);
        pseudoTarget = null;
        document.documentElement.removeAttribute("data-tgs-fs-active");
    }
    // Let X's players re-read document.fullscreenElement and update their UI.
    function announceFullscreenChange(nodes) {
        for (const node of new Set(nodes))
            if (node?.isConnected)
                node.dispatchEvent(
                    new Event("fullscreenchange", { bubbles: true }),
                );
    }
    function pseudoTargetFor(video) {
        let node = video;
        for (let i = 0; i < pseudoDepth && node.parentElement; i++)
            node = node.parentElement;
        const valid =
            node === video ||
            (node !== document.body &&
                node !== document.documentElement &&
                node.querySelectorAll("video").length === 1);
        return valid
            ? node
            : video.closest('[data-testid="videoPlayer"]') ||
                  video.parentElement;
    }
    function requestFullscreenFor(target, native, args) {
        const video =
            target instanceof HTMLVideoElement
                ? target
                : target?.querySelector?.("video");
        if (
            !enabled ||
            !autoAdvance ||
            !routeOK() ||
            !video ||
            !collectFeedVideos().includes(video)
        )
            return native.apply(target, args);
        let depth = 0;
        for (let n = video; n && n !== target; n = n.parentElement) depth++;
        pseudoDepth = depth;
        const previous = pseudoTarget;
        showPseudo(target);
        if (realFullscreenElement()) {
            announceFullscreenChange([previous, target]);
            return pageWindow.Promise.resolve();
        }
        pseudoPendingUntil = performance.now() + 3000;
        return native.apply(document.documentElement, args);
    }
    function maintainPseudo() {
        if (!pseudoTarget) return;
        if (!realFullscreenElement()) {
            if (performance.now() < pseudoPendingUntil) return;
            clearPseudo();
            return;
        }
        const video = active?.video;
        if (
            pseudoTarget.isConnected &&
            (!video || !video.isConnected || pseudoTarget.contains(video))
        ) {
            openPseudo(pseudoTarget);
            return;
        }
        // X re-rendered the player (or tracking moved); follow the main video.
        if (video?.isConnected && !transition) {
            const old = pseudoTarget;
            const target = pseudoTargetFor(video);
            showPseudo(target);
            announceFullscreenChange([old, target]);
        }
    }
    (function installFullscreenBridge() {
        // Page-world patch; exportFunction is needed in Firefox sandboxes.
        const expose = (fn) =>
            typeof exportFunction === "function"
                ? exportFunction(fn, pageWindow)
                : fn;
        const define = (proto, name, descriptor) => {
            const d = new pageWindow.Object();
            for (const [key, value] of Object.entries(descriptor))
                d[key] = value;
            pageWindow.Object.defineProperty(proto, name, d);
        };
        try {
            const docProto = pageWindow.Document.prototype;
            const elProto = pageWindow.Element.prototype;
            for (const name of [
                "fullscreenElement",
                "webkitFullscreenElement",
                "webkitCurrentFullScreenElement",
            ]) {
                const original = pageWindow.Object.getOwnPropertyDescriptor(
                    docProto,
                    name,
                );
                if (!original?.get) continue;
                const nativeGet = original.get;
                define(docProto, name, {
                    configurable: true,
                    enumerable: original.enumerable,
                    get: expose(function () {
                        const real = nativeGet.call(this);
                        if (spoofSuspended && real?.nodeName === "HTML")
                            return null;
                        return pseudoTarget?.isConnected &&
                            real?.nodeName === "HTML"
                            ? pseudoTarget
                            : real;
                    }),
                });
            }
            for (const name of [
                "requestFullscreen",
                "webkitRequestFullscreen",
                "webkitRequestFullScreen",
            ]) {
                const original = pageWindow.Object.getOwnPropertyDescriptor(
                    elProto,
                    name,
                );
                if (typeof original?.value !== "function") continue;
                const native = original.value;
                define(elProto, name, {
                    configurable: true,
                    writable: true,
                    enumerable: original.enumerable,
                    value: expose(function (...args) {
                        try {
                            return requestFullscreenFor(this, native, args);
                        } catch {
                            clearPseudo();
                            return native.apply(this, args);
                        }
                    }),
                });
            }
            for (const name of [
                "exitFullscreen",
                "webkitExitFullscreen",
                "webkitCancelFullScreen",
            ]) {
                const original = pageWindow.Object.getOwnPropertyDescriptor(
                    docProto,
                    name,
                );
                if (typeof original?.value !== "function") continue;
                const native = original.value;
                define(docProto, name, {
                    configurable: true,
                    writable: true,
                    enumerable: original.enumerable,
                    value: expose(function (...args) {
                        clearPseudo();
                        return native.apply(this, args);
                    }),
                });
            }
        } catch (error) {
            logAdvance(`Fullscreen bridge unavailable: ${error.name}`);
        }
    })();

    function releaseUnused(used) {
        for (const [el, saved] of sidePlayers)
            if (!used.has(el)) {
                saved.player.pause();
                saved.player.srcObject = null;
                saved.player.removeAttribute("src");
                saved.player.load();
                sidePlayers.delete(el);
            }
    }
    function cancelTransition() {
        if (transition) transition.cancelled = true;
        transition = null;
        if (active) {
            active.advancing = false;
            active.autoStarting = false;
            if (active.playTimer) clearInterval(active.playTimer);
            active.playTimer = null;
        }
    }
    function teardown(preserveTransition = false) {
        if (!preserveTransition) cancelTransition();
        if (active) {
            active.video.removeEventListener("ended", active.onEnded);
            active.video.removeEventListener("timeupdate", active.onTimeUpdate);
            active.video.removeEventListener("seeking", active.onSeeking);
            if (active.playTimer) clearInterval(active.playTimer);
            active.video.loop = active.originalLoop;
            for (const [node, saved] of active.controlStyles) {
                for (const [property, value] of Object.entries(saved)) {
                    if (
                        node.style.getPropertyValue(property) === value.applied
                    ) {
                        if (value.original)
                            node.style.setProperty(
                                property,
                                value.original,
                                value.priority,
                            );
                        else node.style.removeProperty(property);
                    }
                }
            }
            for (const c of active.canvases) c.remove();
            if (
                active.changedPosition &&
                active.host.style.position === "relative"
            )
                active.host.style.position = active.oldPosition;
            active = null;
        }
        releaseUnused(new Set());
    }
    function attach(video, preserveTransition = false) {
        teardown(preserveTransition);
        resumeFullscreen.hidden = true;
        const host = video.parentElement;
        const oldPosition = host.style.position;
        const changedPosition = getComputedStyle(host).position === "static";
        if (changedPosition) host.style.position = "relative";
        const canvases = [0, 1].map(() => {
            const canvas = document.createElement("canvas");
            canvas.dataset.xSidefill = "true";
            canvas.setAttribute("aria-hidden", "true");
            canvas.style.cssText =
                "position:absolute;pointer-events:none;z-index:1;display:none;";
            host.append(canvas);
            return canvas;
        });
        const onEnded = () => advance(video);
        active = {
            video,
            host,
            oldPosition,
            changedPosition,
            canvases,
            originalLoop: video.loop,
            onEnded,
            advancing: false,
            notice: "",
            pinUntil: 0,
            controlStyles: new Map(),
            playerRoot: video.closest('[data-testid="videoPlayer"]') || host,
            article: video.closest('article[data-testid="tweet"]'),
            lastEndCheck: 0,
            completionHandled: false,
            playTimer: null,
            autoStarting: false,
            playClicked: false,
            fullscreenRoot: fullscreenElement() || null,
        };
        active.lastTime = video.currentTime;
        active.maxTime = video.currentTime;
        active.duration = video.duration;
        active.progressed = !video.paused;
        active.manualPause = false;
        active.manualUntil = 0;
        active.resetDetected = false;
        active.trackedSource = video.srcObject || video.currentSrc || video.src;
        active.onTimeUpdate = () => {
            if (active?.video === video) samplePlayback(performance.now());
        };
        active.onSeeking = () => {
            // Seeking is user initiated unless it happens during an advance.
            if (active?.video === video && !active.advancing) {
                const duration = active.duration;
                const automaticReset =
                    Number.isFinite(duration) &&
                    duration > 0 &&
                    active.maxTime >=
                        duration -
                            Math.min(1.25, Math.max(0.25, duration * 0.1)) &&
                    video.currentTime <=
                        Math.min(1.5, Math.max(0.75, duration * 0.03));
                if (automaticReset) return;
                active.manualPause = true;
                active.manualUntil = performance.now() + 1500;
                active.resetDetected = false;
            }
        };
        logAdvance("Tracking main video");
        if (autoAdvance) video.loop = false;
        video.addEventListener("ended", onEnded);
        video.addEventListener("timeupdate", active.onTimeUpdate);
        video.addEventListener("seeking", active.onSeeking);
    }
    function collectMedia() {
        const media = [];
        for (const article of document.querySelectorAll(
            'article[data-testid="tweet"]',
        )) {
            // Exclude avatars, link previews, and quoted posts; retain feed order.
            for (const el of article.querySelectorAll(
                'video, [data-testid="tweetPhoto"] img',
            )) {
                if (
                    el.closest("article") !== article ||
                    el.closest('[data-testid="quoteTweet"]')
                )
                    continue;
                if (
                    el instanceof HTMLImageElement &&
                    el.closest('[data-testid="videoPlayer"]')
                )
                    continue;
                if (portrait(el)) media.push(el);
            }
        }
        return media;
    }
    function collectFeedVideos() {
        const videos = [];
        for (const article of document.querySelectorAll(
            'article[data-testid="tweet"]',
        )) {
            for (const video of article.querySelectorAll("video")) {
                if (
                    video.closest("article") === article &&
                    !video.closest('[data-testid="quoteTweet"]')
                )
                    videos.push(video);
            }
        }
        return videos;
    }
    function rebindReplacement() {
        if (!active || active.video.isConnected) return false;
        const old = active;
        const fullscreenRoot = fullscreenElement();
        const root = old.playerRoot?.isConnected ? old.playerRoot : old.article;
        const replacement =
            (fullscreenRoot instanceof HTMLVideoElement
                ? fullscreenRoot
                : fullscreenRoot?.querySelector("video")) ||
            root?.querySelector("video");
        if (
            !(replacement instanceof HTMLVideoElement) ||
            !replacement.videoWidth ||
            !replacement.videoHeight
        )
            return false;
        const history = {
            maxTime: old.maxTime,
            duration: old.duration,
            progressed: old.progressed,
            manualPause: old.manualPause,
            manualUntil: old.manualUntil,
            resetDetected: old.resetDetected,
            completionHandled: old.completionHandled,
            notice: old.notice,
            pinUntil: old.pinUntil,
            article: old.article,
            advancing: old.advancing,
        };
        attach(replacement);
        Object.assign(active, history);
        active.playerRoot =
            replacement.closest('[data-testid="videoPlayer"]') ||
            replacement.parentElement;
        active.article = history.article?.isConnected
            ? history.article
            : replacement.closest('article[data-testid="tweet"]');
        active.fullscreenRoot = fullscreenRoot || null;
        logAdvance("Rebound to replacement video in the same post");
        return true;
    }
    function syncFullscreen() {
        if (transition) return false;
        const fs = fullscreenElement();
        if (!fs || !active) return false;
        const fsVideo =
            fs instanceof HTMLVideoElement ? fs : fs.querySelector("video");
        if (
            !(fsVideo instanceof HTMLVideoElement) ||
            !fsVideo.videoWidth ||
            !fsVideo.videoHeight ||
            fsVideo === active.video
        )
            return false;
        const old = active;
        const history = {
            maxTime: old.maxTime,
            duration: old.duration,
            progressed: old.progressed,
            manualPause: old.manualPause,
            manualUntil: old.manualUntil,
            resetDetected: old.resetDetected,
            completionHandled: old.completionHandled,
            notice: old.notice,
            pinUntil: performance.now() + 2500,
            advancing: old.advancing,
        };
        attach(fsVideo);
        Object.assign(active, history);
        active.article ||= old.article;
        active.fullscreenRoot = fs;
        logAdvance("Switched tracking to the fullscreen video");
        return true;
    }
    function scan() {
        // During fullscreen exit/promotion X briefly hides the old player. Do not
        // let that transient state select another feed video and cancel the move.
        if (transition || active?.advancing) return;
        syncFullscreen();
        rebindReplacement();
        const media = collectMedia();
        candidates = media;
        // Keep ownership stable while X pauses, rewinds, or swaps its replay UI.
        // Otherwise the nearest paused feed video can replace the real main player
        // and its duration/history, which breaks end detection on long videos.
        if (active?.video.isConnected && visible(active.video)) return;
        if (active?.video.isConnected && performance.now() < active.pinUntil)
            return;
        // A finished/paused main must not lose ownership to a nearby paused video
        // before X inserts its replay overlay (which can arrive a little later).
        if (
            active &&
            !active.completionHandled &&
            (visible(active.playerRoot) ||
                (!active.playerRoot.isConnected &&
                    active.article?.isConnected &&
                    visible(active.article))) &&
            (active.video.paused || !active.video.isConnected) &&
            !collectFeedVideos().some(
                (el) => el !== active.video && !el.paused && visible(el),
            )
        )
            return;
        const videos = collectFeedVideos().filter(visible);
        // Playing media wins, then the player closest to the viewport center.
        videos.sort(
            (a, b) =>
                Number(a.paused) - Number(b.paused) ||
                Math.abs(
                    a.getBoundingClientRect().top +
                        a.getBoundingClientRect().height / 2 -
                        innerHeight / 2,
                ) -
                    Math.abs(
                        b.getBoundingClientRect().top +
                            b.getBoundingClientRect().height / 2 -
                            innerHeight / 2,
                    ),
        );
        const main = videos[0];
        if (!main) teardown();
        else if (active?.video !== main) attach(main);
    }
    async function advance(video) {
        if (
            !enabled ||
            !autoAdvance ||
            !routeOK() ||
            document.hidden ||
            active?.video !== video ||
            active.advancing ||
            active.completionHandled
        )
            return;
        active.completionHandled = true;
        logAdvance("Completion detected; looking for next video");
        candidates = collectMedia();
        const article = active.article;
        const pseudo = Boolean(pseudoTarget?.isConnected);
        if (pseudo) beginHandoff(video);
        let next = findNextVideo(video, article);
        if (!next) {
            // X only creates players near the viewport. Scroll on (hidden behind
            // the player in fullscreen) until the next one exists, rather than
            // stopping and letting X replay the finished video.
            active.advancing = true;
            const state = active;
            for (let attempt = 0; !next && attempt < 8; attempt++) {
                if (!enabled || !autoAdvance || active !== state) break;
                logAdvance("Next video not in the page yet; scrolling on");
                window.scrollBy({
                    top: innerHeight * 0.8,
                    behavior: "instant",
                });
                for (let i = 0; i < 10 && !next; i++) {
                    await delay(60);
                    next = findNextVideo(video, article);
                }
            }
            if (active === state) state.advancing = false;
            if (active !== state) {
                endHandoff();
                return;
            }
        }
        if (!next) {
            endHandoff();
            logAdvance("No next loaded video found");
            // Let checkCompletion retry shortly instead of leaving X's replay screen.
            active.gaveUpAt = performance.now();
            active.notice = "No next loaded video. Scroll to load more posts.";
            releaseUnused(new Set());
            return;
        }
        active.advancing = true;
        logAdvance("Next video found; starting playback");
        const volume = video.volume;
        const muted = video.muted;
        const old = active;
        // Legacy path only: real fullscreen that the bridge could not redirect.
        const wasFullscreen = !pseudo && isXFullscreen(old);
        const move = { phase: pseudo ? "swap" : "exit", cancelled: false };
        transition = move;
        if (!video.paused) video.pause();
        releaseUnused(new Set());
        try {
            if (pseudo) {
                // Stay in fullscreen. Behind the curtain, X believes fullscreen
                // ended, so scrolling to the next post mounts the posts after it
                // normally. Then fullscreen is handed to the next player.
                const frame = () =>
                    new Promise((resolve) => requestAnimationFrame(resolve));
                let nextArticle = next.closest('article[data-testid="tweet"]');
                centerForTransition(nextArticle);
                await frame();
                await delay(60);
                // X may restore its own scroll position when leaving fullscreen.
                if (!next.isConnected)
                    next = findNextVideo(video, article) || next;
                nextArticle = next.closest('article[data-testid="tweet"]');
                centerForTransition(nextArticle);
                if (!validTransition(move) || !next.isConnected) return;
                const nextTarget = pseudoTargetFor(next);
                showPseudo(nextTarget);
                raiseCurtain();
                endHandoff(true);
                logAdvance("Moved fullscreen to next video");
                // X re-renders the player for fullscreen and may swap its <video>.
                // Start playback only after that, so it does not restart mid-play.
                await frame();
                await delay(60);
                const settled = nextTarget.isConnected
                    ? nextTarget.querySelector("video") ||
                      (nextTarget instanceof HTMLVideoElement && nextTarget)
                    : null;
                if (settled && settled !== next) next = settled;
                hideCurtain();
            } else if (wasFullscreen) {
                logAdvance("Leaving fullscreen before switching main video");
                if (realFullscreenElement()) await document.exitFullscreen();
                else findControl(old, "exit")?.click();
                const deadline = performance.now() + 2500;
                while (
                    validTransition(move) &&
                    isXFullscreen(old) &&
                    performance.now() < deadline
                )
                    await delay(80);
                if (!validTransition(move)) return;
                if (isXFullscreen(old)) {
                    old.notice =
                        "X did not exit fullscreen. Use its exit control to continue.";
                    return;
                }
            }
            if (!validTransition(move)) return;
            if (!next.isConnected) {
                old.notice =
                    "The next video was unloaded. Scroll to load it again.";
                return;
            }
            attach(next, true);
            const state = active;
            state.advancing = true;
            state.pinUntil = performance.now() + 14000;
            if (!pseudo) centerForTransition(next);
            next.volume = volume;
            next.muted = muted;
            try {
                if (next.paused && next.currentTime > 0) next.currentTime = 0;
            } catch {
                /* not yet seekable */
            }
            await startMain(state, wasFullscreen, move);
        } catch (error) {
            logAdvance(`Advance failed: ${error.name}`);
            if (validTransition(move) && active)
                active.notice =
                    "Could not switch player. Press play to continue.";
        } finally {
            // Never leave X believing fullscreen ended, or the curtain up.
            if (handoffActive) endHandoff();
            else hideCurtain();
            if (transition === move) {
                transition = null;
                if (active) {
                    active.advancing = false;
                    active.autoStarting = false;
                    active.pinUntil = 0;
                }
            }
            dirty = true;
        }
    }
    function findNextVideo(video, article) {
        const orderedVideos = collectFeedVideos();
        const index = orderedVideos.indexOf(video);
        // X may replace the finished <video> with its replay UI. Keep the post as
        // an ordering anchor so we can still find the next loaded video; if the
        // post itself was unmounted, anything still on/below screen follows it.
        const following =
            index >= 0
                ? orderedVideos.slice(index + 1)
                : orderedVideos.filter((el) =>
                      article?.isConnected
                          ? !article.contains(el) &&
                            article.compareDocumentPosition(el) &
                                Node.DOCUMENT_POSITION_FOLLOWING
                          : el.getBoundingClientRect().bottom > 0,
                  );
        return (
            following.find(
                (el) => el instanceof HTMLVideoElement && el.isConnected,
            ) || null
        );
    }
    function validTransition(move) {
        return (
            transition === move &&
            !move.cancelled &&
            enabled &&
            autoAdvance &&
            routeOK() &&
            !document.hidden
        );
    }
    function centerForTransition(el) {
        if (!(el instanceof Element) || !el.isConnected) return;
        try {
            el.scrollIntoView({
                block: "center",
                inline: "nearest",
                behavior: "instant",
            });
        } catch {
            el.scrollIntoView();
        }
    }
    // X keeps only ~6 posts in the DOM, but its app state holds the whole loaded
    // timeline (order + media). Upcoming side media comes from there, so it does
    // not depend on X having created (or buffered) those players yet.
    let timelineStore = null;
    let timelineCache = { time: -Infinity, value: null };
    function fiberOf(node) {
        const target = node.wrappedJSObject || node;
        const key = pageWindow.Object.keys(target).find((k) =>
            k.startsWith("__reactFiber$"),
        );
        return key ? target[key] : null;
    }
    function timelineData(now) {
        if (now - timelineCache.time < 300) return timelineCache.value;
        let value = null;
        try {
            const cell = document.querySelector('[data-testid="cellInnerDiv"]');
            let fiber = cell && fiberOf(cell);
            let list = null;
            for (let d = 0; fiber && d < 300; d++, fiber = fiber.return) {
                const props = fiber.memoizedProps;
                if (
                    !list &&
                    Array.isArray(props?.list) &&
                    props.list[0]?.data?.entryId
                )
                    list = props.list;
                const store = props?.store || props?.value?.store;
                if (!timelineStore && typeof store?.getState === "function")
                    timelineStore = store;
                if (list && timelineStore) break;
            }
            const tweets =
                timelineStore?.getState()?.entities?.tweets?.entities;
            if (list && tweets) value = { list, tweets };
        } catch {
            /* X internals changed or are inaccessible; use the DOM instead */
        }
        timelineCache = { time: now, value };
        return value;
    }
    function statusId(article) {
        const href = article
            ?.querySelector('a[href*="/status/"] time')
            ?.closest("a")
            ?.getAttribute("href");
        return href?.match(/\/status\/(\d+)/)?.[1] || "";
    }
    function sourceTweet(tweets, id) {
        const tweet = tweets[id];
        const retweet = tweet?.retweeted_status;
        return (
            (retweet && tweets[typeof retweet === "string" ? retweet : retweet.id_str]) ||
            tweet
        );
    }
    function portraitTimelineMedia(tweet) {
        const items = [];
        for (const media of tweet?.extended_entities?.media || []) {
            const { width, height } = media.original_info || {};
            if (!(width > 0 && height > width)) continue;
            if (media.type === "photo") {
                items.push({
                    timeline: true,
                    kind: "image",
                    key: media.media_url_https,
                    url: `${media.media_url_https}?name=small`,
                });
                continue;
            }
            // Side strips do not need the top bitrate; it only loads slower.
            const variants = (media.video_info?.variants || [])
                .filter((v) => v.content_type === "video/mp4" && v.url)
                .sort((a, b) => (a.bitrate || 0) - (b.bitrate || 0));
            const pick =
                variants.filter((v) => (v.bitrate || 0) <= 2200000).pop() ||
                variants[0];
            if (pick)
                items.push({
                    timeline: true,
                    kind: "video",
                    key: pick.url,
                    url: pick.url,
                    poster: media.media_url_https,
                });
        }
        return items;
    }
    function timelineSideItems(state, count, now) {
        const data = timelineData(now);
        const id = statusId(state.article);
        if (!data || !id) return [];
        const idOf = (entry) => entry.data?.content?.id;
        const index = data.list.findIndex(
            (entry) =>
                idOf(entry) === id ||
                sourceTweet(data.tweets, idOf(entry))?.id_str === id,
        );
        if (index < 0) return [];
        const items = [];
        for (const entry of data.list.slice(index + 1)) {
            if (items.length >= count) break;
            const tweetId = idOf(entry);
            if (tweetId)
                items.push(
                    ...portraitTimelineMedia(sourceTweet(data.tweets, tweetId)),
                );
        }
        return items.slice(0, count);
    }
    function sideItems(state) {
        if (mode === "blur" || !portrait(state.video)) return [];
        const count = mode === "different" ? 2 : 1;
        const fromTimeline = timelineSideItems(state, count, performance.now());
        if (fromTimeline.length) return fromTimeline;
        const media = collectMedia();
        const index = media.indexOf(state.video);
        const following =
            index >= 0
                ? media.slice(index + 1)
                : media.filter(
                      (el) =>
                          state.article?.isConnected &&
                          !state.article.contains(el) &&
                          state.article.compareDocumentPosition(el) &
                              Node.DOCUMENT_POSITION_FOLLOWING,
                  );
        return mode === "different"
            ? following.slice(0, 2)
            : following.slice(0, 1);
    }
    async function startMain(state, returnFullscreen, move) {
        // Side videos load in the background (thumbnails show meanwhile); never
        // hold the main video for them.
        state.autoStarting = true;
        state.notice = "";
        move.phase = "starting main";
        const deadline = performance.now() + 9000;
        let lastAttempt = -Infinity;
        let lastTime = state.video.currentTime;
        let stableSince = null;
        let fullscreenTask = null;
        let failure = "";
        let pendingPlay = false;
        let lastCenter = -Infinity;
        let attempts = 0;
        while (
            validTransition(move) &&
            active === state &&
            state.video.isConnected &&
            performance.now() < deadline
        ) {
            const now = performance.now();
            const v = state.video;
            // Exiting X fullscreen can cause its virtualized feed to restore the
            // previous scroll position. Re-center the selected player while the
            // new video is being initialized, then stop touching scroll state.
            if (returnFullscreen && now - lastCenter >= 300) {
                centerForTransition(v);
                lastCenter = now;
            }
            if (v.ended) break;
            if (v.paused) {
                stableSince = null;
                if (!pendingPlay && now - lastAttempt >= 650) {
                    lastAttempt = now;
                    // A direct play() first; clicking X's control as well on the
                    // same attempt can toggle it straight back to paused.
                    if (attempts++ > 0) {
                        revealControls(state);
                        const button = findControl(state, "play");
                        if (button) {
                            button.click();
                            logAdvance("Activated X play control for next video");
                        }
                    }
                    pendingPlay = true;
                    v.play()
                        .catch((error) => {
                            failure = error.name;
                            logAdvance(
                                `Next video play() failed: ${error.name}`,
                            );
                        })
                        .finally(() => {
                            pendingPlay = false;
                        });
                }
            } else if (v.currentTime > lastTime + 0.005) {
                if (stableSince === null) {
                    stableSince = now;
                    logAdvance("Next video playback progressing");
                }
                if (returnFullscreen && !fullscreenTask) {
                    fullscreenTask = requestXFullscreen(state, move);
                }
                // Survive the post-play pause during X's first player initialization.
                if (
                    now - stableSince >= 2500 &&
                    (!returnFullscreen || state.fullscreenAttemptDone)
                )
                    break;
            }
            if (failure === "NotAllowedError" && v.paused) break;
            lastTime = v.currentTime;
            await delay(100);
        }
        if (fullscreenTask) await fullscreenTask;
        if (!validTransition(move) || active !== state) return;
        if (state.video.isConnected && state.video.paused && !state.video.ended)
            state.notice =
                "Auto-play was blocked. Press play on the new main video.";
        state.autoStarting = false;
        state.advancing = false;
        // A short clip can finish during startup; do not discard that completion.
        if (state.video.ended) {
            state.completionHandled = false;
            dirty = true;
        }
    }
    function controlRoot(state) {
        const v = state.video;
        const root = v.closest('[data-testid="videoPlayer"]');
        if (root) return root;
        let parent = v.parentElement;
        while (
            parent?.parentElement &&
            parent.parentElement !== document.body &&
            parent.parentElement.querySelectorAll("video").length === 1
        )
            parent = parent.parentElement;
        return parent || state.host;
    }
    function findControl(state, kind, allowHidden = false) {
        if (!state) return null;
        return (
            [
                ...controlRoot(state).querySelectorAll(
                    'button, [role="button"]',
                ),
            ].find((item) => {
                if (
                    item.disabled ||
                    item.getAttribute("aria-disabled") === "true"
                )
                    return false;
                if (
                    !allowHidden &&
                    (!visible(item) ||
                        getComputedStyle(item).visibility === "hidden")
                )
                    return false;
                const owner = item.closest('[data-testid="videoPlayer"]');
                if (
                    owner &&
                    owner !== state.video.closest('[data-testid="videoPlayer"]')
                )
                    return false;
                const label = (
                    item.getAttribute("aria-label") ||
                    item.title ||
                    ""
                ).trim();
                const testid = item.dataset.testid || "";
                if (kind === "play")
                    return (
                        /^videoPlayerPlayButton$/i.test(testid) ||
                        /^(play( video)?|watch again|replay)$/i.test(label)
                    );
                if (kind === "exit")
                    return (
                        /^videoPlayerExitFullScreenButton$/i.test(testid) ||
                        /^exit full ?screen$/i.test(label)
                    );
                return (
                    !/exit/i.test(label + testid) &&
                    (/^(videoPlayerFullScreenButton|fullscreenButton)$/i.test(
                        testid,
                    ) ||
                        /^(enter )?full ?screen$/i.test(label))
                );
            }) || null
        );
    }
    function revealControls(state) {
        const root = controlRoot(state);
        const r = state.video.getBoundingClientRect();
        const point = {
            bubbles: true,
            clientX: r.left + r.width / 2,
            clientY: r.top + r.height / 2,
        };
        // X exposes the player controls after pointer hover. Dispatch the full
        // family of hover events because different player revisions listen to
        // different ones (mousemove alone is not sufficient in some revisions).
        for (const type of [
            "pointerover",
            "pointermove",
            "mouseover",
            "mousemove",
        ]) {
            const EventType = type.startsWith("pointer")
                ? PointerEvent
                : MouseEvent;
            state.video.dispatchEvent(
                new EventType(type, {
                    ...point,
                    pointerId: 1,
                    pointerType: "mouse",
                }),
            );
            root.dispatchEvent(
                new EventType(type, {
                    ...point,
                    pointerId: 1,
                    pointerType: "mouse",
                }),
            );
        }
    }
    function isXFullscreen(state = active) {
        if (!state) return false;
        const fs = fullscreenElement();
        return (
            Boolean(fs && (fs === state.video || fs.contains(state.video))) ||
            Boolean(findControl(state, "exit"))
        );
    }
    async function requestXFullscreen(state, move) {
        move.phase = "returning fullscreen";
        const deadline = performance.now() + 4500;
        let lastClick = -Infinity;
        let clicks = 0;
        while (
            validTransition(move) &&
            active === state &&
            performance.now() < deadline
        ) {
            if (isXFullscreen(state)) {
                revealControls(state);
                setTimeout(() => {
                    if (active?.video === state.video) revealControls(state);
                }, 180);
                setTimeout(() => {
                    if (active?.video === state.video) revealControls(state);
                }, 500);
                state.fullscreenAttemptDone = true;
                logAdvance("Confirmed X fullscreen for next video");
                return;
            }
            const now = performance.now();
            revealControls(state);
            const button =
                findControl(state, "enter") ||
                findControl(state, "enter", true);
            if (button && clicks < 3 && now - lastClick >= 1000) {
                lastClick = now;
                clicks++;
                button.click();
                logAdvance("Requested selected player fullscreen");
            }
            if (state.fullscreenDenied) break;
            await delay(100);
        }
        state.fullscreenAttemptDone = true;
        if (validTransition(move) && active === state) {
            state.notice =
                "Fullscreen did not open. Click Enter X fullscreen to retry with a user gesture.";
            resumeFullscreen.hidden = false;
            logAdvance("Fullscreen not confirmed; waiting for user gesture");
        }
    }
    function samplePlayback(now) {
        const state = active;
        if (!state) return;
        const video = state.video;
        const time = video.currentTime;
        const source = video.srcObject || video.currentSrc || video.src;
        if (source !== state.trackedSource) {
            state.trackedSource = source;
            state.lastTime = time;
            state.progressed = false;
            state.resetDetected = false;
            state.duration = video.duration;
        }
        if (Number.isFinite(video.duration) && video.duration > 0)
            state.duration = video.duration;
        const duration = state.duration;
        const endWindow = Math.min(1.25, Math.max(0.25, duration * 0.1));
        // X's reset frame is commonly around 0.5 seconds rather than exactly 0.
        const startWindow = Math.min(1.5, Math.max(0.75, duration * 0.03));
        // X can pause and rewind (or loop) without leaving ended=true. Remember
        // the transition itself before a later poll sees only the first frame.
        if (
            state.progressed &&
            !state.manualPause &&
            now >= state.manualUntil &&
            Number.isFinite(duration) &&
            duration > 0 &&
            state.maxTime >= duration - endWindow &&
            time <= startWindow &&
            state.maxTime - time > duration / 2
        ) {
            if (!state.resetDetected) logAdvance("Detected end-to-start reset");
            state.resetDetected = true;
        }
        if (!video.paused && time > state.lastTime + 0.01) {
            state.progressed = true;
            if (now >= state.manualUntil) state.manualPause = false;
        }
        state.lastTime = time;
        state.maxTime = Math.max(state.maxTime, time);
    }
    function checkCompletion(now) {
        // A handled completion that did not advance (nothing loaded yet, or X
        // swapped its player mid-way) would otherwise stay on "Watch again".
        if (
            active?.completionHandled &&
            !active.advancing &&
            !transition &&
            (active.retries || 0) < 3
        ) {
            if (active.gaveUpAt && now - active.gaveUpAt > 1500) {
                active.retries = (active.retries || 0) + 1;
                active.gaveUpAt = 0;
                active.completionHandled = false;
                logAdvance("Retrying advance");
            } else if (
                !active.gaveUpAt &&
                (active.video.paused || !active.video.isConnected)
            )
                active.gaveUpAt = now;
        }
        if (
            !active ||
            !autoAdvance ||
            active.completionHandled ||
            (active.video.isConnected && now - active.lastEndCheck < 200)
        )
            return;
        active.lastEndCheck = now;
        const { video, playerRoot, article } = active;
        const time = video.currentTime;
        samplePlayback(now);
        // Some X player states never deliver an ended event to our listener.
        // Read the media state and X's replay UI as independent completion signals.
        const root = article?.isConnected ? article : playerRoot;
        const replay =
            root.isConnected &&
            [
                ...root.querySelectorAll(
                    'button, [role="button"], [data-testid], span, div',
                ),
            ].some((button) => {
                // X sometimes renders replay as plain text outside videoPlayer, not a button.
                if (
                    button.closest(
                        '[data-testid="tweetText"], [data-testid="quoteTweet"]',
                    )
                )
                    return false;
                const owner = button.closest('[data-testid="videoPlayer"]');
                if (
                    owner &&
                    owner !== playerRoot &&
                    video.isConnected &&
                    owner.querySelector("video")
                )
                    return false;
                const label = (
                    button.getAttribute("aria-label") ||
                    button.textContent ||
                    ""
                )
                    .replace(/\s+/g, " ")
                    .trim();
                const identified =
                    /^videoReplay(Button)?$/.test(
                        button.dataset.testid || "",
                    ) || /^(watch again|replay)$/i.test(label);
                const textLeaf =
                    button.children.length === 0 ||
                    button.matches(
                        'button, [role="button"], [data-testid="videoReplay"], [data-testid="videoReplayButton"]',
                    );
                return (
                    identified &&
                    textLeaf &&
                    visible(button) &&
                    button.getClientRects().length > 0 &&
                    getComputedStyle(button).visibility !== "hidden" &&
                    getComputedStyle(button).opacity !== "0"
                );
            });
        const reachedEnd =
            active.progressed &&
            !active.manualPause &&
            video.paused &&
            Number.isFinite(active.duration) &&
            active.duration > 0 &&
            time >= active.duration - 0.12;
        active.replayDetected = replay;
        // The visible replay screen is authoritative even if X has already reset
        // or restarted the underlying media element behind that screen.
        if (video.ended || reachedEnd || replay || active.resetDetected)
            advance(video);
    }
    function playSide(saved) {
        saved.lastPlay = performance.now();
        saved.player.play().catch((error) => {
            if (![...sidePlayers.values()].includes(saved)) return;
            if (error.name === "NotAllowedError" && !saved.player.muted) {
                saved.audioBlocked = true;
                saved.player.muted = true;
                saved.player.play().catch(() => {});
            }
        });
    }
    function resolveVideoURL(el, now) {
        const direct = el.currentSrc || el.src;
        if (/^https?:/i.test(direct)) return direct;
        const article = el.closest('article[data-testid="tweet"]');
        if (!article) return "";
        const thumbnail =
            el.poster ||
            el.closest('[data-testid="videoPlayer"]')?.querySelector("img")
                ?.src ||
            "";
        const identity = `${direct}|${thumbnail}`;
        const cached = resolvedMedia.get(el);
        if (cached?.identity === identity && now - cached.time < 3000)
            return cached.url;
        // X often exposes only a MediaSource blob on <video>, but the rendered
        // post's React props still contain video_info.variants with playable MP4s.
        // Read only that article's props; never start its player or call private APIs.
        const pageWindow =
            typeof unsafeWindow === "undefined" ? window : unsafeWindow;
        const roots = [];
        for (const element of [
            article,
            ...article.querySelectorAll('[data-testid="videoPlayer"], video'),
        ]) {
            const node = element.wrappedJSObject || element;
            for (const key of pageWindow.Object.keys(node)) {
                if (key.startsWith("__reactProps$")) roots.push(node[key]);
                if (key.startsWith("__reactFiber$")) {
                    let fiber = node[key];
                    for (let i = 0; fiber && i < 16; i++, fiber = fiber.return)
                        roots.push(fiber.memoizedProps);
                }
            }
        }
        const seen = new Set();
        const records = [];
        const queue = roots.map((value) => ({ value, depth: 0 }));
        for (let i = 0; i < queue.length && seen.size < 4000; i++) {
            const { value, depth } = queue[i];
            if (
                !value ||
                typeof value !== "object" ||
                seen.has(value) ||
                value instanceof Node
            )
                continue;
            seen.add(value);
            if (Array.isArray(value.video_info?.variants)) records.push(value);
            if (depth >= 12) continue;
            for (const key of Object.keys(value)) {
                if (
                    [
                        "_owner",
                        "children",
                        "return",
                        "child",
                        "sibling",
                        "stateNode",
                        "alternate",
                    ].includes(key)
                )
                    continue;
                // Avoid evaluating getters in page-owned objects.
                const descriptor = Object.getOwnPropertyDescriptor(value, key);
                if (
                    queue.length < 12000 &&
                    descriptor &&
                    "value" in descriptor &&
                    descriptor.value &&
                    typeof descriptor.value === "object"
                ) {
                    queue.push({ value: descriptor.value, depth: depth + 1 });
                }
            }
        }
        const posterKey = (url) => {
            try {
                return new URL(url).pathname.replace(/\.[a-z0-9]+$/i, "");
            } catch {
                return "";
            }
        };
        const poster = posterKey(thumbnail);
        // Match the thumbnail, rather than assuming the first variant belongs to
        // this player (quoted posts and multi-video posts may have several).
        const matches = records.filter(
            (record) =>
                poster &&
                [record.media_url_https, record.media_url, record.poster].some(
                    (url) => posterKey(url) === poster,
                ),
        );
        const urls = new Map();
        for (const record of matches)
            for (const variant of record.video_info.variants) {
                if (
                    variant.content_type !== "video/mp4" ||
                    typeof variant.url !== "string"
                )
                    continue;
                try {
                    const url = new URL(variant.url);
                    if (
                        url.protocol === "https:" &&
                        url.hostname === "video.twimg.com"
                    )
                        urls.set(url.href, Number(variant.bitrate) || 0);
                } catch {
                    /* malformed media metadata */
                }
            }
        const url = [...urls].sort((a, b) => b[1] - a[1])[0]?.[0] || "";
        resolvedMedia.set(el, { identity, time: now, url });
        return url;
    }
    function sideSource(item, main, now, used) {
        if (item.timeline && item.kind === "image")
            return cachedImage(item.url) || item;
        if (!item.timeline && !(item instanceof HTMLVideoElement)) return item;
        // Side players are keyed by X's element, or by URL for timeline media.
        const el = item.timeline ? item.key : item;
        // Never play, pause, or mute X's other players: their play events can make
        // X's single-player coordinator immediately pause the main video.
        const stream =
            !item.timeline && item.srcObject instanceof MediaStream
                ? item.srcObject
                : null;
        let url = item.timeline ? item.url : "";
        try {
            if (!stream && !item.timeline) url = resolveVideoURL(item, now);
        } catch {
            /* page metadata may be inaccessible */
        }
        const key = stream || url;
        // MediaSource blob URLs cannot safely be attached to a second player.
        if (!stream && !url) return item;
        if (sidePlayers.has(el) && sidePlayers.get(el).key !== key) {
            const keep = new Set(sidePlayers.keys());
            keep.delete(el);
            releaseUnused(keep);
        }
        used.add(el);
        if (!sidePlayers.has(el)) {
            // Keep detached so media events never reach X's document listeners.
            const player = document.createElement("video");
            player.muted = true;
            player.defaultMuted = true;
            player.loop = true;
            player.playsInline = true;
            player.preload = "auto";
            if (stream) player.srcObject = stream;
            else player.src = url;
            sidePlayers.set(el, {
                key,
                player,
                lastPlay: -Infinity,
                audioBlocked: false,
            });
        }
        const saved = sidePlayers.get(el);
        const player = saved.player;
        player.muted = !sideAudio || saved.audioBlocked;
        player.volume = main.volume;
        if (main.paused || main.ended) {
            if (!player.paused) player.pause();
            return player;
        }
        if (player.paused && !player.error && now - saved.lastPlay > 1000) {
            playSide(saved);
        }
        return player;
    }
    function paint(canvas, source, width, height, blur) {
        const [sw, sh] = dimensions(source);
        if (
            !sw ||
            !sh ||
            (source instanceof HTMLVideoElement && source.readyState < 2)
        )
            return false;
        // A capped backing resolution keeps three simultaneous videos reasonable on laptops.
        const scale = Math.min(devicePixelRatio || 1, 1.5, 960 / height);
        const cw = Math.max(1, Math.round(width * scale));
        const ch = Math.max(1, Math.round(height * scale));
        if (canvas.width !== cw || canvas.height !== ch) {
            canvas.width = cw;
            canvas.height = ch;
        }
        const ctx = canvas.getContext("2d");
        // Match the main video's full height. Cover-scaling can crop the top/bottom
        // of narrow side videos, making their contents appear vertically displaced.
        const zoom = blur ? Math.max(cw / sw, ch / sh) * 1.18 : ch / sh;
        ctx.filter = "none";
        ctx.fillStyle = "#000";
        ctx.fillRect(0, 0, cw, ch);
        ctx.filter = blur
            ? `blur(${Math.max(8, cw * 0.08)}px) brightness(0.65)`
            : "none";
        try {
            ctx.drawImage(
                source,
                (cw - sw * zoom) / 2,
                (ch - sh * zoom) / 2,
                sw * zoom,
                sh * zoom,
            );
            return true;
        } catch {
            return false;
        }
    }
    function raiseControls() {
        const { video, host, controlStyles } = active;
        const root = video.closest('[data-testid="videoPlayer"]') || host;
        for (const control of root.querySelectorAll(
            '[role="slider"], [role="progressbar"], button, [role="button"], [data-testid="videoControls"]',
        )) {
            if (control.contains(video)) continue;
            // Raise the separate controls branch at its common ancestor with the video.
            // Raising only a slider inside a lower stacking context is insufficient.
            let branch = control;
            while (
                branch.parentElement &&
                branch.parentElement !== root &&
                !branch.parentElement.contains(video)
            )
                branch = branch.parentElement;
            if (branch.contains(video)) continue;
            const computed = getComputedStyle(branch);
            const changes = {};
            if (computed.position === "static") changes.position = "relative";
            if (!(Number(computed.zIndex) >= 2)) changes["z-index"] = "2";
            for (const [property, applied] of Object.entries(changes)) {
                if (!controlStyles.has(branch)) controlStyles.set(branch, {});
                const saved = controlStyles.get(branch);
                if (!saved[property])
                    saved[property] = {
                        original: branch.style.getPropertyValue(property),
                        priority: branch.style.getPropertyPriority(property),
                        applied,
                    };
                branch.style.setProperty(property, applied);
            }
        }
    }
    function render(now) {
        const { video, host, canvases } = active;
        const r = video.getBoundingClientRect();
        const hr = host.getBoundingClientRect();
        const style = getComputedStyle(video);
        if (!portrait(video)) {
            canvases.forEach((c) => {
                c.style.display = "none";
            });
            releaseUnused(new Set());
            status.textContent =
                active.notice ||
                "Horizontal/square video · auto-play continues in feed order";
            return;
        }
        // X's contained video has centered letterboxing. Do not cover cropped/stretched video.
        if (
            !["contain", "scale-down"].includes(style.objectFit) ||
            style.objectPosition !== "50% 50%"
        ) {
            canvases.forEach((c) => {
                c.style.display = "none";
            });
            if (!active.autoStarting) releaseUnused(new Set());
            status.textContent =
                active.notice ||
                "Waiting for a centered, contained portrait player.";
            return;
        }
        const fit = Math.min(
            r.width / video.videoWidth,
            r.height / video.videoHeight,
            style.objectFit === "scale-down" ? 1 : Infinity,
        );
        const contentWidth = video.videoWidth * fit;
        const contentHeight = video.videoHeight * fit;
        const contentTop = (r.height - contentHeight) / 2;
        const bar = (r.width - contentWidth) / 2;
        if (bar < 3 || r.height < 1) {
            canvases.forEach((c) => {
                c.style.display = "none";
            });
            if (!active.autoStarting) releaseUnused(new Set());
            status.textContent =
                active.notice || "This player has no side bars to fill.";
            return;
        }
        const next = sideItems(active);
        const sources =
            mode === "blur"
                ? [video, video]
                : [next[0], mode === "different" ? next[1] : next[0]];
        const used = new Set();
        let fallback = false;
        let missingMedia = false;
        // Keep full-height side pictures and paint the real controls above them.
        raiseControls();
        sources.forEach((source, i) => {
            const c = canvases[i];
            if (!source) {
                source = video;
                missingMedia = true;
            }
            const original = source;
            if (source !== video) source = sideSource(source, video, now, used);
            const blurred = source === video;
            c.style.left = `${r.left - hr.left - host.clientLeft + host.scrollLeft + (i ? r.width - bar : 0)}px`;
            c.style.top = `${r.top - hr.top - host.clientTop + host.scrollTop + contentTop}px`;
            c.style.width = `${bar}px`;
            c.style.height = `${contentHeight}px`;
            let painted = paint(c, source, bar, contentHeight, blurred);
            if (
                mode !== "blur" &&
                original !== video &&
                (!painted ||
                    (source === original && source instanceof HTMLVideoElement))
            ) {
                // Keep the requested upcoming media visible as a still when it cannot
                // play independently. Do not silently turn a next-media mode into blur.
                if (!painted && !original.timeline)
                    painted = paint(c, original, bar, contentHeight, false);
                if (!painted) {
                    const poster = original.timeline
                        ? cachedImage(original.poster)
                        : original instanceof HTMLVideoElement &&
                          posterImage(original);
                    if (poster)
                        painted = paint(c, poster, bar, contentHeight, false);
                }
                fallback = true;
            }
            if (blurred && mode !== "blur") fallback = true;
            c.style.display = painted ? "block" : "none";
        });
        releaseUnused(used);
        const audioStatus = sideAudio ? "side audio on" : "side videos muted";
        const audioBlocked = [...sidePlayers.values()].some(
            (saved) => saved.audioBlocked,
        );
        status.textContent =
            active.notice ||
            (audioBlocked
                ? "Browser blocked side audio. Toggle side-video audio off and on to retry."
                : missingMedia
                  ? "Missing upcoming media uses the blurred main video. Scroll to load more posts."
                  : fallback
                    ? "Upcoming media is loading or unavailable for independent playback; a still may be shown. Scroll to load more posts."
                    : `Active ·
  ${audioStatus}`);
    }
    // Observe feed additions and src changes without reacting to our own canvas/style updates.
    new MutationObserver((records) => {
        if (
            records.some(
                (r) =>
                    r.type === "attributes" ||
                    [...r.addedNodes, ...r.removedNodes].some(
                        (n) => n.nodeType === 1 && !n.dataset?.xSidefill,
                    ),
            )
        )
            dirty = true;
    }).observe(document.body, {
        subtree: true,
        childList: true,
        attributes: true,
        attributeFilter: ["src", "poster"],
    });
    document.addEventListener(
        "loadedmetadata",
        () => {
            dirty = true;
        },
        true,
    );
    document.addEventListener(
        "play",
        (event) => {
            if (event.target === active?.video) {
                active.notice = "";
                active.completionHandled = false;
                active.manualPause = false;
                active.maxTime = event.target.currentTime;
                active.gaveUpAt = 0;
                // Keep the short retry window alive: X can emit play, then pause the
                // player while its fullscreen/inline controls finish updating.
            }
            dirty = true;
        },
        true,
    );
    document.addEventListener(
        "pointerdown",
        (event) => {
            if (active?.playerRoot.contains(event.target)) {
                cancelTransition();
                active.manualPause = true;
                active.manualUntil = performance.now() + 1500;
                active.resetDetected = false;
                active.autoStarting = false;
                if (active.playTimer) clearInterval(active.playTimer);
                active.playTimer = null;
            }
            const player = event.target.closest?.(
                '[data-testid="videoPlayer"]',
            );
            const video = player?.querySelector("video");
            if (enabled && routeOK() && video && video !== active?.video) {
                attach(video);
                dirty = true;
            }
        },
        true,
    );
    document.addEventListener(
        "keydown",
        (event) => {
            if (
                active &&
                !event.target.matches?.(
                    'input, textarea, [contenteditable="true"]',
                ) &&
                [
                    " ",
                    "k",
                    "K",
                    "j",
                    "J",
                    "l",
                    "L",
                    "ArrowLeft",
                    "ArrowRight",
                    "Home",
                    "End",
                    ..."0123456789",
                ].includes(event.key)
            ) {
                cancelTransition();
                active.manualPause = true;
                active.manualUntil = performance.now() + 1500;
                active.resetDetected = false;
            }
        },
        true,
    );
    // The root no longer uses overflow:hidden (it may stop X updating its feed),
    // so keep the wheel from scrolling the feed behind a fullscreen player.
    window.addEventListener(
        "wheel",
        (event) => {
            if (pseudoTarget && !panel.contains(event.target))
                event.preventDefault();
        },
        { capture: true, passive: false },
    );
    document.addEventListener("visibilitychange", () => {
        if (document.hidden) teardown();
        dirty = true;
    });
    document.addEventListener(
        "fullscreenerror",
        () => {
            if (!realFullscreenElement()) clearPseudo();
            if (active && transition) {
                active.fullscreenDenied = true;
                logAdvance("Browser rejected fullscreen request");
            }
        },
        true,
    );
    document.addEventListener(
        "fullscreenchange",
        () => {
            dirty = true;
            if (realFullscreenElement()) pseudoPendingUntil = 0;
            else clearPseudo();
            requestAnimationFrame(() => {
                if (!transition) {
                    syncFullscreen();
                    rebindReplacement();
                }
                dirty = true;
            });
        },
        true,
    );
    function tick(now) {
        requestAnimationFrame(tick);
        const onPage = routeOK();
        panel.hidden = !onPage;
        maintainPseudo();
        if (!onPage || !enabled || document.hidden) {
            if (active) teardown();
            return;
        }
        checkCompletion(now);
        if ((dirty && now - lastScan > 200) || now - lastScan > 1000) {
            dirty = false;
            lastScan = now;
            scan();
        }
        if (active && autoAdvance && active.video.loop)
            active.video.loop = false;
        if (active && now - lastFrame >= 1000 / 30) {
            lastFrame = now;
            render(now);
        }
    }
    requestAnimationFrame(tick);
})();