☰

TwitterGoonScroller

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

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

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.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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);
})();