Ctrl K
ComunidadGuías…

Jellyfin-Autoscroll-Button-v1.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
(function () {
    'use strict';

    /* jfcompat 1.1 - one script for Jellyfin web 10.10.7 and 12.1 (1.1: layout
     * setting scheme of 10.11 = 10.10, isModernLayoutModel).
     * Paste this block unchanged at the top of a script (inside its IIFE).
     * It is pure: no side effects at load, no globals except window.jfcompat
     * (set only when absent, for console checks; scripts use the local const).
     * Rule: on 10.10.7 every answer equals what the scripts computed before. */
    const jfcompat = (function () {
        'use strict';
        const VERSION = '1.1';

        // ---------- version ----------
        // The web client ships with the server, so the server version decides.
        // ApiClient.appVersion() is not used: inside Jellyfin Media Player or the
        // Android app NativeShell replaces it with the app's own number
        // (apphost.js 10.10.7:417-419, 12.1:399-401).
        // Before ApiClient knows the server, <html data-theme> is a 12.x-only hint
        // (12.1 scripts/themeManager.js:46; 10.10.7 never sets it).
        function serverVersion() {
            try {
                const api = window.ApiClient;
                const v = api && typeof api.serverVersion === 'function' && api.serverVersion();
                if (v) {
                    const [major, minor] = String(v).split('.').map(Number);
                    return { major: major, minor: minor || 0, raw: String(v) };
                }
            } catch (e) { /* ignore */ }
            return null;
        }
        // New model (>= 10.11): routes without .html, no Trailers tab on the
        // Movies pages. Audited 2026-10-02 against web 10.11.11 (appRouter.js:404,
        // moviesrecommended.js:229-241, apps/experimental/routes/movies/index.tsx:46-51).
        // The layout setting is NOT part of it: 10.11 still has the 10.10 scheme,
        // see isModernLayoutModel().
        function isNewModel() {
            const v = serverVersion();
            if (v) return v.major > 10 || (v.major === 10 && v.minor >= 11);
            return document.documentElement.hasAttribute('data-theme');
        }

        // Layout setting scheme of 12.x: modern by default, 'desktop-legacy' /
        // 'mobile-legacy' / 'tv' classic (constants/layoutMode.ts, apphost.js
        // 12.0:185-186). 10.10 and 10.11 instead: classic by default, MUI only for
        // 'experimental' (layoutManager.js identical in 10.10.7 and 10.11.11,
        // RootAppRouter.tsx 10.11.11:21-22). Without a server version the 12.x
        // hint of isNewModel() decides (10.11 sets data-theme too; the DOM check
        // in getLayout() comes first anyway).
        function isModernLayoutModel() {
            const v = serverVersion();
            if (v) return v.major >= 12;
            return isNewModel();
        }

        // ---------- routes ----------
        // getRoute(): { name, params } with '#!' and '.html' removed, so one name
        // fits both: home, movies, tv, list, search, details, video, music, livetv ...
        function getRoute() {
            const h = window.location.hash || '';
            const m = /^#!?\/([^?]*)(?:\?(.*))?$/.exec(h);
            const name = m ? m[1].replace(/\.html$/i, '').toLowerCase() : '';
            return { name: name, params: new URLSearchParams(m && m[2] ? m[2] : '') };
        }
        function isRoute() {
            const n = getRoute().name;
            for (let i = 0; i < arguments.length; i++) if (arguments[i] === n) return true;
            return false;
        }
        // routeUrl('list', {parentId}) -> '#/list.html?...' on 10.10.7, '#/list?...' on 12.1.
        // details and video never had '.html' (10.10.7 appRouter.js:447,472).
        const NO_SUFFIX = ['details', 'video', ''];
        function routeUrl(name, params) {
            const q = params ? new URLSearchParams(params).toString() : '';
            const suffix = (!isNewModel() && NO_SUFFIX.indexOf(name) < 0) ? '.html' : '';
            return '#/' + name + suffix + (q ? '?' + q : '');
        }
        // Navigate inside the app (no reload). Emby.Page.show strips '#' and '!'
        // in both versions (appRouter.js 10.10.7:516, 12.1:552).
        function go(name, params) {
            const url = routeUrl(name, params);
            if (window.Emby && window.Emby.Page && typeof window.Emby.Page.show === 'function') {
                window.Emby.Page.show(url.slice(1));
            } else {
                window.location.hash = url;
            }
        }

        // ---------- layout ----------
        // 10.10.7: MUI only when localStorage.layout === 'experimental' (RootAppRouter.tsx:19-20).
        // 12.1:    classic only for desktop-legacy | mobile-legacy | tv
        //          (constants/layoutMode.ts, layoutManager.js:41); everything else,
        //          including a stale 'experimental', is MUI.
        // Both versions pick the layout once per page load, so the DOM answer is cached.
        const LEGACY_12 = ['desktop-legacy', 'mobile-legacy', 'tv'];
        const MUI_SEARCH = '.MuiAppBar-root a[href^="#/search"]';
        let cachedLayout = null;
        function getLayout() {
            if (cachedLayout) return cachedLayout;
            // 1) what the page shows (not on the video route: 12.1 draws an osdHeader there;
            //    not on dashboard pages: there the classic header is hidden in both layouts)
            if (getRoute().name !== 'video') {
                if (document.querySelector(MUI_SEARCH)) return (cachedLayout = 'mui');
                const sk = document.querySelector('.skinHeader:not(.osdHeader)');
                // .skinHeader is position:fixed, so offsetParent is always null; a
                // display:none ancestor (AppHeader isHidden) leaves it without client rects.
                if (sk && sk.getClientRects().length > 0 && sk.querySelector('.headerRight')) return (cachedLayout = 'classic');
            }
            // 2) the setting, read the way each version reads it (not cached)
            let v = '';
            try { v = localStorage.getItem('layout') || ''; } catch (e) { /* ignore */ }
            if (isModernLayoutModel()) return LEGACY_12.indexOf(v) >= 0 ? 'classic' : 'mui';
            return v === 'experimental' ? 'mui' : 'classic';
        }
        function isMui() { return getLayout() === 'mui'; }

        // ---------- header ----------
        // MUI: the search link sits in the right-hand button box with SyncPlay and
        // RemotePlay (components/toolbar/AppToolbar.tsx:84-85, both versions).
        // Its href is '#/search.html' on 10.10.7 and '#/search' on 12.1.
        // Classic: only a SHOWN header counts. 12.1 keeps the hidden classic header
        // in the DOM in the modern layout (AppHeader.tsx:20), and both versions hide
        // it on dashboard pages; a button placed there would never be seen.
        function isShown(el) { return !!el && el.getClientRects().length > 0; }
        function getSearchLink() {
            if (isMui()) return document.querySelector(MUI_SEARCH);
            const b = document.querySelector('.skinHeader:not(.osdHeader) .headerRight .headerSearchButton');
            return isShown(b) ? b : null;
        }
        function getHeaderBox() {
            if (isMui()) { const a = document.querySelector(MUI_SEARCH); return a ? a.parentElement : null; }
            const box = document.querySelector('.skinHeader:not(.osdHeader) .headerRight');
            return isShown(box) ? box : null;
        }
        // cb(box | null) whenever the box may have changed (the MUI toolbar unmounts
        // on /video and has no buttons on public pages). setTimeout, not rAF, so it
        // also runs in background tabs.
        function onHeaderBoxChange(cb) {
            let queued = false;
            const run = function () { queued = false; cb(getHeaderBox()); };
            const start = function () {
                if (!document.body) { setTimeout(start, 200); return; }
                run();
                new MutationObserver(function () {
                    if (!queued) { queued = true; setTimeout(run, 50); }
                }).observe(document.body, { childList: true, subtree: true });
            };
            start();
        }

        // ---------- theme ----------
        function getThemeId() {
            const d = document.documentElement.getAttribute('data-theme');          // 12.1
            if (d) return d;
            const link = document.querySelector('link[href*="themes/"][href$="theme.css"]'); // both
            const m = link && /themes\/([^/]+)\/theme\.css/.exec(link.getAttribute('href') || '');
            return m ? m[1] : 'dark';
        }
        // MUI IconButton (color inherit) hover = action.active at action.hoverOpacity.
        // 12.1 exposes it as CSS variables (themes/index.ts, prefix 'jf'); the
        // fallbacks are the 10.10.7 values (dark 8 % white, light/appletv 4 % black).
        function getMuiHoverColor() {
            const old = /^(light|appletv)$/.test(getThemeId()) ? 'rgba(0, 0, 0, 0.04)' : 'rgba(255, 255, 255, 0.08)';
            if (!document.documentElement.hasAttribute('data-theme')) return old;
            const cs = getComputedStyle(document.documentElement);
            const ch = cs.getPropertyValue('--jf-palette-action-activeChannel').trim();
            const op = cs.getPropertyValue('--jf-palette-action-hoverOpacity').trim();
            return (ch && op) ? 'rgba(' + ch + ' / ' + op + ')' : old;
        }

        // ---------- auth for raw fetch ----------
        // 12.1 ignores X-Emby-Token, X-MediaBrowser-Token, X-Emby-Authorization and
        // ?api_key= unless EnableLegacyAuthorization (AuthorizationContext.cs:93-110).
        // The Authorization header and ?ApiKey= work in both versions.
        function accessToken() {
            try {
                const api = window.ApiClient;
                const t = api && typeof api.accessToken === 'function' && api.accessToken();
                if (t) return t;
            } catch (e) { /* ignore */ }
            try {
                const c = JSON.parse(localStorage.getItem('jellyfin_credentials') || '{}');
                const s = (c.Servers || []).find(function (x) { return x.AccessToken; });
                return s ? s.AccessToken : null;
            } catch (e) { return null; }
        }
        function authHeaders(extra) {
            const h = Object.assign({}, extra || {});
            const t = accessToken();
            if (t) h.Authorization = 'MediaBrowser Token="' + t + '"';
            return h;
        }
        // Adds ?ApiKey=<token> to a URL that cannot carry a header (img src, download link).
        function withApiKey(url) {
            const t = accessToken();
            if (!t) return url;
            return url + (url.indexOf('?') < 0 ? '?' : '&') + 'ApiKey=' + encodeURIComponent(t);
        }

        // ---------- pages ----------
        // React library pages in the 12.1 modern layout (apps/modern/routes/asyncRoutes/user.ts).
        // In 10.10.7 'experimental' some of these were React too; live-check before reuse there.
        const REACT_LIBRARY_ROUTES = ['movies', 'tv', 'music', 'livetv', 'boxsets', 'homevideos',
            'musicvideos', 'mixed', 'books', 'playlists', 'home'];
        function isReactLibraryPage() {
            return isNewModel() && isMui() && REACT_LIBRARY_ROUTES.indexOf(getRoute().name) >= 0;
        }

        // ---------- video OSD ----------
        // Legacy view in both (10.10.7 controllers/playback/video/index.html:30;
        // 12.1 apps/legacy/controllers/playback/video/index.html:30).
        function getOsdBar() {
            return document.querySelector('.videoOsdBottom .osdControls .buttons');
        }

        const api = {
            VERSION: VERSION, serverVersion: serverVersion, isNewModel: isNewModel,
            getRoute: getRoute, isRoute: isRoute, routeUrl: routeUrl, go: go,
            getLayout: getLayout, isMui: isMui,
            getSearchLink: getSearchLink, getHeaderBox: getHeaderBox, onHeaderBoxChange: onHeaderBoxChange,
            getThemeId: getThemeId, getMuiHoverColor: getMuiHoverColor,
            accessToken: accessToken, authHeaders: authHeaders, withApiKey: withApiKey,
            isReactLibraryPage: isReactLibraryPage, getOsdBar: getOsdBar
        };
        if (!window.jfcompat) window.jfcompat = api;
        return api;
    })();
    /* end jfcompat 1.1 */

    // Run only on Windows browsers. The Xbox app reports "Windows NT 10.0;
    // ... Xbox" but runs full screen in TV layout without a mouse: not wanted.
    const isXbox = /Xbox/i.test(navigator.userAgent);
    const isWindows = !isXbox && (navigator.userAgent.includes('Windows') || navigator.platform.includes('Win'));
    if (!isWindows) return;

    const ICON_CLASS = 'material-icons';
    const BUTTON_ID = 'jf-scroll-btn';

    let scrolling = false;
    let speedIndex = 0;
    const speeds = [0.03, 0.06, 0.2];

    const delayStates = [0, 1, 3, 5, 10]; // Triple-Click Delay Optionen
    let currentDelayIndex = 0;
    const bottomDelay = 3000; // 3 Sekunden unten

    let clickCount = 0;
    let clickTimer = null;

    let lastPage = window.location.href;

    let delayTopPending = true;

    // Each start gets a new id; a loop whose id is no longer current (or
    // that finds scrolling off) ends after its pause instead of going on.
    let loopId = 0;

    // Inline SVG icons instead of relying on the "Material Symbols Outlined"
    // icon font loaded from fonts.googleapis.com: whenever that font couldn't
    // load (blocked, offline, privacy extensions) or hadn't loaded yet, the
    // ligature text (e.g. "arrow_circle_down") was shown instead of the icon. The
    // paths are the glyphs of the exact font file fonts.googleapis.com
    // serves for this family, drawn at the same 24px, so the icons look
    // exactly as before, and the font no longer needs to be requested.
    const ICON_PATHS = {
        arrow_circle_down: 'M480 -320 640 -480 584 -536 520 -472V-640H440V-472L376 -536L320 -480ZM480 -80Q397 -80 324.0 -111.5Q251 -143 197.0 -197.0Q143 -251 111.5 -324.0Q80 -397 80 -480Q80 -563 111.5 -636.0Q143 -709 197.0 -763.0Q251 -817 324.0 -848.5Q397 -880 480 -880Q563 -880 636.0 -848.5Q709 -817 763.0 -763.0Q817 -709 848.5 -636.0Q880 -563 880 -480Q880 -397 848.5 -324.0Q817 -251 763.0 -197.0Q709 -143 636.0 -111.5Q563 -80 480 -80ZM480 -160Q614 -160 707.0 -253.0Q800 -346 800 -480Q800 -614 707.0 -707.0Q614 -800 480 -800Q346 -800 253.0 -707.0Q160 -614 160 -480Q160 -346 253.0 -253.0Q346 -160 480 -160ZM480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Z',
        pause: 'M520 -200V-760H760V-200ZM200 -200V-760H440V-200ZM600 -280H680V-680H600ZM280 -280H360V-680H280ZM280 -680V-280ZM600 -680V-280Z',
        counter_1: 'M480 -80Q397 -80 324.0 -111.5Q251 -143 197.0 -197.0Q143 -251 111.5 -324.0Q80 -397 80 -480Q80 -563 111.5 -636.0Q143 -709 197.0 -763.0Q251 -817 324.0 -848.5Q397 -880 480 -880Q563 -880 636.0 -848.5Q709 -817 763.0 -763.0Q817 -709 848.5 -636.0Q880 -563 880 -480Q880 -397 848.5 -324.0Q817 -251 763.0 -197.0Q709 -143 636.0 -111.5Q563 -80 480 -80ZM480 -160Q614 -160 707.0 -253.0Q800 -346 800 -480Q800 -614 707.0 -707.0Q614 -800 480 -800Q346 -800 253.0 -707.0Q160 -614 160 -480Q160 -346 253.0 -253.0Q346 -160 480 -160ZM480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480ZM460 -280H540V-680H380V-600H460Z',
        counter_2: 'M480 -80Q397 -80 324.0 -111.5Q251 -143 197.0 -197.0Q143 -251 111.5 -324.0Q80 -397 80 -480Q80 -563 111.5 -636.0Q143 -709 197.0 -763.0Q251 -817 324.0 -848.5Q397 -880 480 -880Q563 -880 636.0 -848.5Q709 -817 763.0 -763.0Q817 -709 848.5 -636.0Q880 -563 880 -480Q880 -397 848.5 -324.0Q817 -251 763.0 -197.0Q709 -143 636.0 -111.5Q563 -80 480 -80ZM480 -160Q614 -160 707.0 -253.0Q800 -346 800 -480Q800 -614 707.0 -707.0Q614 -800 480 -800Q346 -800 253.0 -707.0Q160 -614 160 -480Q160 -346 253.0 -253.0Q346 -160 480 -160ZM480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480ZM360 -280H600V-360H440V-440Q440 -440 440.0 -440.0Q440 -440 440 -440H520Q553 -440 576.5 -463.5Q600 -487 600 -520V-600Q600 -633 576.5 -656.5Q553 -680 520 -680H360V-600H520Q520 -600 520.0 -600.0Q520 -600 520 -600V-520Q520 -520 520.0 -520.0Q520 -520 520 -520H440Q407 -520 383.5 -496.5Q360 -473 360 -440Z',
        counter_3: 'M480 -80Q397 -80 324.0 -111.5Q251 -143 197.0 -197.0Q143 -251 111.5 -324.0Q80 -397 80 -480Q80 -563 111.5 -636.0Q143 -709 197.0 -763.0Q251 -817 324.0 -848.5Q397 -880 480 -880Q563 -880 636.0 -848.5Q709 -817 763.0 -763.0Q817 -709 848.5 -636.0Q880 -563 880 -480Q880 -397 848.5 -324.0Q817 -251 763.0 -197.0Q709 -143 636.0 -111.5Q563 -80 480 -80ZM480 -160Q614 -160 707.0 -253.0Q800 -346 800 -480Q800 -614 707.0 -707.0Q614 -800 480 -800Q346 -800 253.0 -707.0Q160 -614 160 -480Q160 -346 253.0 -253.0Q346 -160 480 -160ZM480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480Q480 -480 480.0 -480.0Q480 -480 480 -480ZM360 -280H520Q553 -280 576.5 -303.5Q600 -327 600 -360V-420Q600 -446 583.0 -463.0Q566 -480 540 -480Q566 -480 583.0 -497.0Q600 -514 600 -540V-600Q600 -633 576.5 -656.5Q553 -680 520 -680H360V-600H520Q520 -600 520.0 -600.0Q520 -600 520 -600V-520Q520 -520 520.0 -520.0Q520 -520 520 -520H440V-440H520Q520 -440 520.0 -440.0Q520 -440 520 -440V-360Q520 -360 520.0 -360.0Q520 -360 520 -360H360Z'
    };

    // Sized 1em so the icon follows the font size Jellyfin gives its own
    // header icons (.paper-icon-button-light > .material-icons).
    function iconSvg(name) {
        return '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true" style="display:block"><path d="' + ICON_PATHS[name] + '"/></svg>';
    }

    function injectStyle() {
        if (document.getElementById('jf-scroll-style')) return;
        const style = document.createElement('style');
        style.id = 'jf-scroll-style';
        style.textContent = `
            /* Overlaid on the icon, so the round button keeps its shape
               while the delay number is shown. */
            #${BUTTON_ID} .timer-display {
                position:absolute;
                inset:0;
                display:flex;
                align-items:center;
                justify-content:center;
                font-weight:bold;
                font-size:14px;
                z-index:2;
                pointer-events:none;
            }
            #${BUTTON_ID} .timer-display:empty {
                display:none;
            }
        `;
        document.head.appendChild(style);
    }

    function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

    // The element that really scrolls: the document on every page except
    // the Live TV guide, whose program list scrolls inside its own
    // .guideVerticalScroller (components/guide/tvguide.template.html:20).
    function getScrollContainer() {
        const guide = document.querySelector('.guideVerticalScroller');
        if (guide && guide.getClientRects().length > 0 && guide.scrollHeight > guide.clientHeight) return guide;
        return document.documentElement;
    }

    async function startScroll() {
        const id = ++loopId;
        const alive = () => scrolling && id === loopId;
        // Sub-pixel steps (0.48 / 0.96 px): where the engine drops such a step
        // (scrollTop unchanged), the missed distance is carried into the next
        // step, at most 1 px, so speed 1 cannot stall. Wherever a step moves
        // the page (fractional or device-pixel scroll), carry stays 0 and the
        // motion is exactly as before.
        let carry = 0;
        while (alive()) {
            // Looked up each step: the guide renders after the page change
            scrollContainer = getScrollContainer();
            if (scrollContainer.scrollTop === 0 && delayTopPending) {
                delayTopPending = false;
                if (delayStates[currentDelayIndex] > 0) {
                    await sleep(delayStates[currentDelayIndex] * 1000);
                    if (!alive()) return;
                }
            }

            const step = speeds[speedIndex] * 16;
            const before = scrollContainer.scrollTop;
            scrollContainer.scrollTop = before + step + carry;
            carry = scrollContainer.scrollTop !== before ? 0 : Math.min(1, carry + step);

            // 1 px tolerance: at 125 % / 150 % zoom the last scroll position
            // can stay a fraction of a pixel short of the bottom.
            if (scrollContainer.scrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - 1) {
                await sleep(bottomDelay);
                // Stopped (or restarted) during the pause: stay at the bottom
                if (!alive()) return;
                scrollContainer.scrollTop = 0;
                delayTopPending = true;
                carry = 0;
            }

            await sleep(16);
        }
    }

    let scrollContainer = null;

    // The one button element; kept here so a button taken out of the MUI
    // bar (public pages) comes back with its state instead of a new one.
    let buttonEl = null;

    // Stops a running scroll and shows the start icon again (used where the
    // button cannot be reached: video player, dashboard, pages without header).
    function stopScrolling() {
        if (!scrolling) return;
        scrolling = false;
        loopId++;
        const icon = buttonEl && buttonEl.querySelector('.' + ICON_CLASS);
        if (icon) icon.innerHTML = iconSvg('arrow_circle_down');
    }

    function buildButton() {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.id = BUTTON_ID;
        btn.title = 'Autoscroll';

        const icon = document.createElement('span');
        icon.className = ICON_CLASS;
        icon.setAttribute('aria-hidden', 'true');
        icon.innerHTML = iconSvg(scrolling ? 'pause' : 'arrow_circle_down');
        btn.appendChild(icon);

        const display = document.createElement('span');
        display.className = 'timer-display';
        btn.appendChild(display);

        btn.addEventListener('click', () => {
            clickCount++;
            if (clickTimer) clearTimeout(clickTimer);

            clickTimer = setTimeout(() => {
                if (clickCount === 1) {
                    scrolling = !scrolling;
                    icon.innerHTML = iconSvg(scrolling ? 'pause' : 'arrow_circle_down');
                    if (scrolling) startScroll();
                } else if (clickCount === 2) {
                    speedIndex = (speedIndex + 1) % speeds.length;
                    icon.innerHTML = iconSvg(`counter_${speedIndex + 1}`);
                    setTimeout(() => {
                        icon.innerHTML = iconSvg(scrolling ? 'pause' : 'arrow_circle_down');
                    }, 500);
                } else if (clickCount === 3) {
                    currentDelayIndex = (currentDelayIndex + 1) % delayStates.length;
                    display.textContent = delayStates[currentDelayIndex];
                    setTimeout(() => { display.textContent = ''; }, 500);
                }
                clickCount = 0;
            }, 250);
        });
        buttonEl = btn;
        return btn;
    }

    function watchPageChanges() {
        setInterval(() => {
            if (window.location.href !== lastPage) {
                lastPage = window.location.href;
                if (scrolling) {
                    scrollContainer = getScrollContainer();
                    scrollContainer.scrollTop = 0;
                    delayTopPending = true;
                }
            }
        }, 200);
    }

    /**********************
     * HEADER LAYOUTS
     **********************/
    // Classic header (.headerRight) or the MUI toolbar: Experimental layout
    // in 10.10.x, the default ("modern") layout in 12.x. jfcompat decides
    // from what the page shows, so the choice is made each time the header
    // box may have changed, not once at load (12.x keeps a hidden classic
    // header in the DOM, and its version is not known that early).

    // Left-to-right order of the custom header buttons (Random, Autoscroll,
    // Fullscreen, Cinema), so they line up the same in both layouts no
    // matter which script runs first.
    const HEADER_BUTTON_ORDER = ['randomMovieButton', 'jf-scroll-btn', 'jf-fullscreen-btn', 'jf-cinema-btn', 'jf-destroy-btn'];

    // In the classic header Random sits in its own wrapper div.
    function headerButtonRank(el) {
        return el.id === 'randomMovieButtonContainer' ? 0 : HEADER_BUTTON_ORDER.indexOf(el.id);
    }

    // Puts el into box right before the first element that belongs after
    // it: a Jellyfin button or a custom button later in the order.
    // Returns true when it had to move el.
    function placeInOrder(box, el) {
        const myRank = headerButtonRank(el);
        let ref = null;
        for (const child of box.children) {
            if (child === el) continue;
            const rank = headerButtonRank(child);
            if (rank === -1 || rank > myRank) { ref = child; break; }
        }
        if (el.parentElement !== box || el.nextElementSibling !== ref) { box.insertBefore(el, ref); return true; }
        return false;
    }

    // MUI bar: re-order when something moved in front of the button, but at
    // most REORDER_MAX times per REORDER_WINDOW_MS. After that only a missing
    // button is placed again, so a foreign script that also puts itself
    // first on every DOM change cannot start an endless insert loop.
    const REORDER_MAX = 10;
    const REORDER_WINDOW_MS = 10000;
    let reorderTimes = [];
    function placeInOrderCapped(box, el) {
        const inBox = el.parentElement === box;
        if (inBox) {
            const now = Date.now();
            reorderTimes = reorderTimes.filter(t => now - t < REORDER_WINDOW_MS);
            if (reorderTimes.length >= REORDER_MAX) return;
        }
        if (placeInOrder(box, el) && inBox) reorderTimes.push(Date.now());
    }

    // The MUI hover colour needs a style read; it is read again only when
    // the theme changes. On 12.x the value counts only once it came from the
    // theme's CSS variables (form 'rgba(r g b / a)'); a read made before the
    // theme stylesheet applied returns the fallback and is retried.
    function setMuiHover(btn) {
        const theme = jfcompat.getThemeId();
        if (btn.getAttribute('data-jf-mui-theme') === theme) return;
        const color = jfcompat.getMuiHoverColor();
        btn.style.setProperty('--jf-mui-hover', color);
        if (!document.documentElement.hasAttribute('data-theme') || color.indexOf(' / ') >= 0) {
            btn.setAttribute('data-jf-mui-theme', theme);
        }
    }

    // Same box, padding, icon size, colour and hover transition as MUI's
    // <IconButton size="large" color="inherit"> (@mui/material 5.16.7):
    // 12px padding around a 1.5rem icon (SvgIcon 'medium'), round, icon
    // keeps the toolbar colour.
    function muiButtonCss(id) {
        return `
            #${id}.jf-mui-header-btn {
                display:inline-flex; align-items:center; justify-content:center;
                position:relative; box-sizing:border-box; flex:0 0 auto;
                padding:12px; margin:0; border:0; border-radius:50%;
                background-color:transparent; color:inherit; font-size:1.75rem;
                cursor:pointer; outline:0; vertical-align:middle;
                -webkit-tap-highlight-color:transparent;
                transition:background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
            }
            #${id}.jf-mui-header-btn > .material-icons { font-size:1.5rem; line-height:1; }
            @media (hover: hover) {
                #${id}.jf-mui-header-btn:hover { background-color:var(--jf-mui-hover, rgba(255, 255, 255, 0.08)); }
            }
        `;
    }

    function injectMuiStyle(styleId, buttonId) {
        if (document.getElementById(styleId)) return;
        const style = document.createElement('style');
        style.id = styleId;
        style.textContent = muiButtonCss(buttonId);
        document.head.appendChild(style);
    }

    // Called whenever the header box may have changed (the MUI toolbar
    // unmounts on the video route and has no buttons on the login/server
    // pages); jfcompat batches the DOM changes with a short timer.
    function placeButton(box) {
        // No header (video player, dashboard): the button cannot be reached
        // there, so a running scroll is stopped instead of going on unseen.
        if (!box || jfcompat.isRoute('video')) stopScrolling();
        if (!box) {
            // MUI keeps the same toolbar box on the login/server pages but
            // renders no buttons there; ours is a foreign node and would stay.
            if (jfcompat.isMui() && buttonEl && buttonEl.parentElement) buttonEl.remove();
            return;
        }
        let btn = document.getElementById(BUTTON_ID) || buttonEl;
        if (!btn) btn = buildButton();
        if (jfcompat.isMui()) {
            injectMuiStyle('jf-scroll-mui-style', BUTTON_ID);
            btn.className = 'jf-mui-header-btn';
            setMuiHover(btn);
        } else {
            // Same classes as Jellyfin's own header buttons (SyncPlay, Cast,
            // Search), so size, round hover/active highlight and colour come
            // from Jellyfin's stylesheet and the active theme, 1:1.
            btn.className = 'headerButton headerButtonRight paper-icon-button-light';
        }
        // Classic header: place once, as on 10.10.7; re-order only when the
        // button is not in the box (new header, layout switch). Re-ordering on
        // every DOM change would fight other header scripts that move themselves.
        if (!jfcompat.isMui() && btn.parentElement === box) return;
        // After Random, before Fullscreen, Cinema and Jellyfin's buttons.
        if (jfcompat.isMui()) placeInOrderCapped(box, btn);
        else placeInOrder(box, btn);
    }

    injectStyle();
    watchPageChanges();
    jfcompat.onHeaderBoxChange(placeButton);
})();