Ctrl K
ComunidadGuías…

Jellyfin-VideoOSD-Tweaks-Candy-Core-v1.js

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

Archivo original ↗Preguntar a KiBot
/*
 * Jellyfin-VideoOSD-Tweaks-Candy-Core-v1.js
 *
 * Control script for the VideoOSD Tweaks and Candy Jellyfin plugin. It is
 * not usable on its own: everything it does (hiding vanilla OSD elements,
 * rebuilding the title, ordering vanilla and custom OSD buttons) is driven
 * by the plugin's server-side configuration, read from the endpoint
 * VideoOSDTweaksCandy/ClientConfiguration. Without the plugin there is no
 * configuration and the script changes nothing. Unlike the other 8 mods
 * of this project it has no standalone defaults, since it has no feature
 * of its own to fall back to.
 *
 * It does not watch the whole document. Jellyfin dispatches bubbling
 * "pageshow"/"pagehide" events on each page's root element on every
 * navigation (src/components/Page.tsx, the same events pageClassOn() and
 * pageIdOn() are built on). The script reacts to those for #videoOsdPage
 * and, while the video page is shown, observes only that page's subtree.
 * A document-wide observer fires constantly during heavy DOM activity
 * (e.g. while the admin Dashboard loads) and can make the whole UI
 * unresponsive.
 */

(function () {
    'use strict';

    const OSD_PAGE_ID = 'videoOsdPage';

    // Jellyfin is a single-page app and this script runs once, at the first
    // index.html parse, which can be before window.ApiClient exists. So the
    // fetch retries every 250ms for up to 30 seconds instead of giving up
    // after the first failed attempt.
    async function fetchPluginConfig() {
        const maxAttempts = 120;
        const delayMs = 250;
        let failures = 0;
        for (let attempt = 0; attempt < maxAttempts; attempt++) {
            // No ApiClient yet (jellyfin-web creates it once a server is
            // known, e.g. after the server selection page): wait without
            // using up an attempt, like the not-logged-in case below.
            if (!window.ApiClient) attempt--;
            if (window.ApiClient && typeof ApiClient.getJSON === 'function') {
                // Not logged in yet (e.g. still on the login page): every
                // request would only fail with 401, so wait without using up
                // an attempt.
                if (typeof ApiClient.accessToken === 'function' && !ApiClient.accessToken()) {
                    attempt--;
                    await new Promise(function (resolve) { setTimeout(resolve, delayMs); });
                    continue;
                }
                try {
                    // The plugin's own endpoint, readable for every signed-in user.
                    const config = await ApiClient.getJSON(ApiClient.getUrl('VideoOSDTweaksCandy/ClientConfiguration'));
                    if (config) return config;
                    throw new Error('empty configuration');
                } catch (err) {
                    // 403: no access; 404: plugin not installed. Retrying
                    // can't change either, so give up instead of sending up
                    // to 120 failing requests.
                    if (err && (err.status === 403 || err.status === 404)) return null;
                    // Server error (5xx), network error or empty answer: at
                    // most 3 retries, 0.5 / 1 / 2 s apart, then give up until
                    // the next fetch.
                    if (++failures > 3) return null;
                    await new Promise(function (resolve) { setTimeout(resolve, delayMs * Math.pow(2, failures)); });
                    continue;
                }
            }
            await new Promise(function (resolve) { setTimeout(resolve, delayMs); });
        }
        return null;
    }

    // ============================================================
    // SHARED HIDE MECHANISM
    // ============================================================
    const FORCE_HIDE_CLASS = 'jvosd-tc-force-hide';
    const STYLE_ID = 'jvosd-tc-core-style';

    function ensureCoreStyle() {
        if (document.getElementById(STYLE_ID)) return;
        const style = document.createElement('style');
        style.id = STYLE_ID;
        // Title separators use margins, not spaces in the text: .pageTitle
        // is "display: inline-flex", and leading/trailing white space of a
        // flex item's text collapses, so " - " would render as "-".
        // Volume: the slider stays in its native ".volumeButtons" wrapper
        // (see applyBottomRightOrder()), so the native flex-grow and the
        // 43em narrow-window hide keep working. Only the wrapper's
        // asymmetric native margin ("0 1em 0 0.29em", made for its one
        // fixed position) is replaced with the uniform 0.29em of every
        // button-sized neighbor, so it spaces evenly at any sorted
        // position. The direct-child selector matches only this wrapper.
        // Mute below 43em: Jellyfin hides .volumeButtons (mute + slider)
        // there (videoosd.scss); applyBottomRightOrder() moves mute out of
        // that wrapper, so it is hidden here the same way.
        // Jellyfin 12.1, modern (MUI) layout: the video header is a React
        // toolbar (.videoOsd-appBar), the legacy header elements are
        // rendered but hidden. React owns those nodes, so they are never
        // changed directly: these rules act only while applyMuiHeader()
        // sets the matching flag on <html>. Buttons are found by their MUI
        // icon (data-testid), independent of the UI language. 10.10.7 and
        // the legacy layouts have no .videoOsd-appBar, so nothing matches.
        // [data-jvosd-mui-bar] = the same MUI video toolbar in the 10.10.7
        // "experimental" layout, which has no class of its own (getMuiBar()).
        // The buttons carry [data-jvosd-mui-btn] (markMuiButtons()), so the
        // rules need neither the has- nor the is- pseudo-class (older TV /
        // browser engines).
        // [data-jvosd-pre-hide-*] cover the frames between the header's
        // mount and the first markMuiButtons() (React paints it before the
        // OSD page's "pageshow"): set from the config alone (applyMuiPreHides()),
        // they hide the icons wherever a MUI toolbar sits directly in a Box;
        // in both versions only the video header does (the app toolbar sits
        // in the AppBar header; jellyfin-web 10.10.7 / 12.1 sources).
        style.textContent = `.${FORCE_HIDE_CLASS} { display: none !important; }
.jvosd-tc-title-sep { margin: 0 0.35em; }
.jvosd-tc-title-year-sep { margin-left: 0.35em; }
.videoOsdBottom .buttons > .volumeButtons { margin: 0 0.29em; }
@media all and (max-width: 43em) { #videoOsdPage .videoOsdBottom .buttonMute { display: none !important; } }
html[data-jvosd-mui-hide-back] [data-jvosd-mui-btn="back"] { display: none !important; }
html[data-jvosd-mui-hide-sync] [data-jvosd-mui-btn="sync"] { display: none !important; }
html[data-jvosd-mui-hide-cast] [data-jvosd-mui-btn="cast"] { display: none !important; }
html[data-jvosd-mui-cast-first] [data-jvosd-mui-btn="cast"] { order: -1; }
html[data-jvosd-pre-hide-back] .MuiBox-root > .MuiToolbar-root svg[data-testid="ArrowBackIcon"],
html[data-jvosd-pre-hide-sync] .MuiBox-root > .MuiToolbar-root svg[data-testid="GroupsIcon"],
html[data-jvosd-pre-hide-cast] .MuiBox-root > .MuiToolbar-root svg[data-testid="CastIcon"],
html[data-jvosd-pre-hide-cast] .MuiBox-root > .MuiToolbar-root svg[data-testid="CastConnectedIcon"] { visibility: hidden !important; }
html[data-jvosd-mui-own-title] .videoOsd-appBar > .MuiTypography-root:not(.jvosd-tc-mui-title),
html[data-jvosd-mui-own-title] [data-jvosd-mui-bar] > .MuiTypography-root:not(.jvosd-tc-mui-title) { display: none !important; }`;
        document.head.appendChild(style);
    }

    function setHidden(selector, hidden) {
        document.querySelectorAll(selector).forEach(function (el) {
            el.classList.toggle(FORCE_HIDE_CLASS, !!hidden);
        });
    }

    // Jellyfin keeps up to 3 pages in the DOM (viewContainer.js,
    // pageContainerCount = 3, slots reused round-robin), and a playback
    // started from a non-video page puts a fresh #videoOsdPage into the
    // next slot. A hidden #videoOsdPage from an earlier playback can still
    // sit in an earlier slot, where getElementById() (first match) would
    // find it. The live OSD page is the one without the "hide" class (the
    // shown view has none by the time "pageshow" fires). All OSD lookups
    // go through getActiveOsdPage(), never through a document-wide query.
    //
    // leavingOsdPage is set only during the synchronous re-apply in the
    // "pagehide" handler: when leaving to a React page (search, user
    // profile, quick connect), Page.tsx -> viewManager.hideView()
    // dispatches "pagehide" before it adds "hide" to the OSD page, so the
    // page being left would otherwise still count as the live OSD.
    let leavingOsdPage = null;

    function getActiveOsdPage() {
        const pages = document.querySelectorAll('#' + OSD_PAGE_ID);
        for (let i = 0; i < pages.length; i++) {
            if (pages[i] !== leavingOsdPage && !pages[i].classList.contains('hide')) return pages[i];
        }
        return null;
    }

    function isVideoOsdActive() {
        return !!getActiveOsdPage();
    }

    // ============================================================
    // VANILLA HIDE/SHOW -- elements inside the video OSD page. Scoped to
    // "#videoOsdPage ": most of these class names are reused elsewhere
    // (photo slideshow, item detail pages).
    // ============================================================
    function applyOsdInternalHides(config) {
        setHidden('#videoOsdPage .btnPause', config.HidePlayPauseButton);
        setHidden('#videoOsdPage .btnRewind, #videoOsdPage .btnFastForward', config.HideRewindFastForward);
        setHidden('#videoOsdPage .btnPreviousChapter, #videoOsdPage .btnNextChapter', config.HideChapterButtons);
        setHidden('#videoOsdPage .btnPreviousTrack, #videoOsdPage .btnNextTrack', config.HideTrackButtons);
        setHidden('#videoOsdPage .btnRecord', config.HideRecordButton);
        // Only the inner ".endsAtText" is hidden, not ".osdTimeText":
        // ".osdTimeText" carries "margin-right: auto", the flex spacer that
        // pushes the following buttons (Favorite, Subtitles, Audio, Volume,
        // Settings, ...) to the right edge. Hiding it would collapse that
        // group to the left.
        setHidden('#videoOsdPage .osdTimeText .endsAtText', config.HideEndsAtInfo);

        setHidden('#videoOsdPage .btnUserRating', config.HideFavoriteButton);
        setHidden('#videoOsdPage .btnSubtitles', config.HideSubtitlesButton);
        setHidden('#videoOsdPage .btnAudio', config.HideAudioButton);
        setHidden('#videoOsdPage .buttonMute', config.HideMuteButton);
        setHidden('#videoOsdPage .osdVolumeSliderContainer', config.HideVolumeSlider);
        setHidden('#videoOsdPage .btnVideoOsdSettings', config.HideSettingsButton);
        setHidden('#videoOsdPage .btnPip', config.HidePictureInPictureButton);
        setHidden('#videoOsdPage .btnFullscreen', config.HideFullscreenButton);
        setHidden('#videoOsdPage .btnAirPlay', config.HideAirPlayButton);
    }

    // ============================================================
    // VANILLA HIDE/SHOW -- global header elements (Back/Title/SyncPlay/
    // Cast). These live in the shared AppHeader, a sibling of
    // #videoOsdPage used on every page, so they are hidden only while
    // the video OSD is active.
    // ============================================================
    function applyHeaderButtonHides(config) {
        const active = isVideoOsdActive();
        setHidden('.headerBackButton', active && config.HideBackButton);
        setHidden('.headerSyncButton', active && config.HideSyncPlayButton);
        setHidden('.headerCastButton', active && config.HideCastButton);
    }

    // ============================================================
    // TITLE RECONSTRUCTION
    // ============================================================
    const TITLE_ID = 'pageTitle';
    const RAW_TEXT_MARKER_ATTR = 'data-jvosdTcRawText';

    // Year digits use \p{Nd} (with the "u" flag), not \d: Jellyfin
    // renders the year via toLocaleString() in the current locale, which
    // yields native digits for e.g. fa/bn/mr/ne ("(۲۰۰۸)"), and \d
    // only matches ASCII digits. The episode name part is optional:
    // Jellyfin omits it for episodes without a name ("Show - S1:E2").
    const EPISODE_TITLE_REGEX = /^(.*?)\s-\sS(\d+):E(\d+)(?:-(\d+))?(?:\s-\s(.*?))?(?:\s\((\p{Nd}{4})\))?$/u;
    const PLAIN_TITLE_REGEX = /^(.*?)(?:\s\((\p{Nd}{4})\))?$/u;

    function parseTitleSync(rawText, itemInfo) {
        const episodeMatch = rawText.match(EPISODE_TITLE_REGEX);
        if (episodeMatch) {
            return {
                kind: 'episode',
                seriesName: episodeMatch[1],
                season: episodeMatch[2],
                episode: episodeMatch[3],
                episodeEnd: episodeMatch[4] || null,
                episodeName: episodeMatch[5] || '',
                year: episodeMatch[6] || null
            };
        }

        const plainMatch = rawText.match(PLAIN_TITLE_REGEX);

        // Episodes Jellyfin titles without "S1:E2": specials ("Show -
        // Special - Name", the middle label is localized, e.g. "Extra" in
        // German) and episodes without an index number ("Show - Name").
        // Parsing the localized label is not possible, so the known
        // series and episode names of the playing item are used to split
        // the title instead; the middle part is kept verbatim as the
        // season/episode part.
        if (plainMatch && itemInfo && itemInfo.kind === 'episode' && itemInfo.seriesName && itemInfo.name) {
            const body = plainMatch[1];
            const prefix = itemInfo.seriesName + ' - ';
            // The special label ("Extra - {0}") is joined to the episode
            // name with " - " or, in some translations, " – " (en dash;
            // German since Jellyfin 12.1).
            const suffixes = [' - ' + itemInfo.name, ' \u2013 ' + itemInfo.name];
            let sxeText = null;
            if (body === prefix + itemInfo.name) {
                sxeText = '';
            } else if (body.startsWith(prefix)) {
                const suffix = suffixes.find(function (s) {
                    return body.endsWith(s) && body.length > prefix.length + s.length;
                });
                if (suffix) sxeText = body.slice(prefix.length, body.length - suffix.length);
            }
            if (sxeText !== null) {
                return {
                    kind: 'episode',
                    seriesName: itemInfo.seriesName,
                    sxeText: sxeText,
                    episodeName: itemInfo.name,
                    year: plainMatch[2] || null
                };
            }
        }

        return {
            kind: 'plain',
            name: plainMatch ? plainMatch[1] : rawText,
            year: plainMatch ? (plainMatch[2] || null) : null
        };
    }

    let cachedItemInfo = null;
    let cachedItemInfoName = null;

    async function getNowPlayingItemInfo() {
        if (!(window.ApiClient && window.ApiClient.getSessions)) return null;
        try {
            const sessions = await ApiClient.getSessions({ deviceId: ApiClient.deviceId() });
            const session =
                sessions.find(function (s) { return s.NowPlayingItem && s.PlayState; }) ||
                sessions.find(function (s) { return s.NowPlayingItem; });
            const item = session && session.NowPlayingItem;
            if (!item) return null;

            const itemName = item.Id || item.Name || 'unknown';
            if (cachedItemInfo && cachedItemInfoName === itemName) {
                return cachedItemInfo;
            }

            let kind = 'video';
            if (item.Type === 'Movie') kind = 'movie';
            else if (item.Type === 'Episode') kind = 'episode';

            cachedItemInfo = {
                kind: kind,
                id: item.Id || null,
                originalTitle: item.OriginalTitle || null,
                name: item.Name || null,
                seriesName: item.SeriesName || null,
                type: item.Type || null
            };
            cachedItemInfoName = itemName;
            return cachedItemInfo;
        } catch (err) {
            return null;
        }
    }

    // The year is not joined with the " - " separator: Jellyfin's format
    // is "Name (2008)", with a plain space, never "Name - (2008)".
    function renderTitleParts(el, orderedParts, yearText) {
        const visible = orderedParts.filter(function (p) { return p.text; });
        el.innerHTML = '';
        visible.forEach(function (p, idx) {
            if (idx > 0) {
                const sep = document.createElement('span');
                sep.className = 'jvosd-tc-title-sep';
                sep.textContent = '-';
                el.appendChild(sep);
            }
            const span = document.createElement('span');
            span.className = 'jvosd-tc-title-' + p.key;
            span.textContent = p.text;
            el.appendChild(span);
        });

        if (yearText) {
            const yearSpan = document.createElement('span');
            yearSpan.className = 'jvosd-tc-title-year jvosd-tc-title-year-sep';
            yearSpan.textContent = yearText;
            el.appendChild(yearSpan);
        }
    }

    function getEpisodeTitleOrder(config) {
        const DEFAULT_ORDER = ['series', 'sxe', 'title'];
        const raw = config.TopLeftOrder;
        if (typeof raw !== 'string' || !raw) return DEFAULT_ORDER;
        const requested = raw.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
        const known = requested.filter(function (k) { return DEFAULT_ORDER.includes(k); });
        const missing = DEFAULT_ORDER.filter(function (k) { return !known.includes(k); });
        return known.concat(missing);
    }

    // With every title setting at its default, the title is left alone:
    // Jellyfin's native rendering is already right, and rebuilding it into
    // our own spans would only add risk.
    function needsTitleIntervention(config) {
        if (config.HideTitleBar) return true;
        if (config.HideSeriesTitle || config.HideSeasonEpisodeNumber || config.HideEpisodeTitle) return true;
        if (config.HideYearMovies || config.HideYearEpisodes || config.HideYearVideos) return true;
        if (config.ShowOriginalTitleMovies) return true;
        if (typeof config.TopLeftOrder === 'string' && config.TopLeftOrder && config.TopLeftOrder !== 'series,sxe,title') return true;
        return false;
    }

    function applyTitleDisplay(config, itemInfo) {
        const el = document.querySelector('h3.' + TITLE_ID);

        // h3.pageTitle is the shared header title on every page, so it is
        // only touched while the video OSD is active. On leaving, the hide
        // class (HideTitleBar) is removed here: Jellyfin never removes it,
        // and the title (including the home page logo, rendered through
        // the same element) would stay hidden until a reload. The render
        // cache is dropped too, so the next OSD visit renders fresh.
        if (!isVideoOsdActive()) {
            if (el) {
                if (el.classList.contains(FORCE_HIDE_CLASS)) el.classList.remove(FORCE_HIDE_CLASS);
                // Jellyfin doesn't reset the title on every page, so our
                // rebuilt text could stay behind and be read as native text
                // on the next video start: put Jellyfin's own text back.
                const marker = el.getAttribute(RAW_TEXT_MARKER_ATTR);
                if (marker && marker === el.textContent && el.dataset.jvosdTcSourceText !== undefined) {
                    el.textContent = el.dataset.jvosdTcSourceText;
                }
                delete el.dataset.jvosdTcLastRenderSignature;
                el.removeAttribute(RAW_TEXT_MARKER_ATTR);
                delete el.dataset.jvosdTcSourceText;
            }
            titleRawTextForItemInfo = null;
            return;
        }

        if (!el) return;

        if (!needsTitleIntervention(config)) {
            // Nothing configured needs our own rendering at all. If an
            // earlier config change left our span structure in place
            // (its cached raw text still matches, i.e. Jellyfin hasn't
            // re-set the title since), restore plain native text and
            // clear our own bookkeeping, so a later real intervention
            // starts from a clean slate rather than an already-rebuilt
            // one.
            if (el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent && el.dataset.jvosdTcSourceText) {
                el.textContent = el.dataset.jvosdTcSourceText;
                el.removeAttribute(RAW_TEXT_MARKER_ATTR);
                delete el.dataset.jvosdTcSourceText;
            }
            el.classList.remove(FORCE_HIDE_CLASS);
            return;
        }

        // el.textContent is either Jellyfin's fresh text (if it just set
        // the title) or the text of our own rebuilt spans (if nothing has
        // set it since our last render). The marker tells the two apart,
        // since our own output must not be parsed as raw text.
        const rawText = el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent
            ? el.dataset.jvosdTcSourceText
            : el.textContent;

        if (!rawText) return;

        // The next item of a queue (next episode, playlist) plays without
        // any navigation (appRouter.show() is a no-op for the path already
        // shown), so no "pageshow" brings new item info. A new native
        // title text means a new item: drop the old info (the title falls
        // back to the parsed kind until the fresh info arrives) and fetch
        // it again.
        if (rawText !== titleRawTextForItemInfo) {
            const firstTitle = titleRawTextForItemInfo === null;
            titleRawTextForItemInfo = rawText;
            if (!firstTitle) {
                currentItemInfo = null;
                itemInfo = null;
                refreshItemInfoAndReapply();
            }
        }

        // Skip the rebuild when nothing changed: Jellyfin's time display
        // updates roughly every 700ms during playback and touches
        // innerHTML, which osdObserver (childList) picks up.
        // The render signature is kept apart from RAW_TEXT_MARKER_ATTR,
        // which must keep telling our text from Jellyfin's. It includes
        // itemInfo, because itemInfo arrives asynchronously after a first
        // render with the same rawText, and every title setting, so a
        // changed setting re-renders the same title. The early return also
        // requires that what is on screen is still our own render:
        // Jellyfin re-sets the same title text when the same item is
        // replayed or an audio/subtitle track changes, and that text has
        // to be rebuilt again.
        const itemInfoSignature = ((itemInfo && itemInfo.kind) || '') + '\u0000' + ((itemInfo && itemInfo.originalTitle) || '') + '\u0000' + ((itemInfo && itemInfo.seriesName) || '') + '\u0000' + ((itemInfo && itemInfo.name) || '');
        const configSignature = [
            config.HideTitleBar, config.HideSeriesTitle, config.HideSeasonEpisodeNumber, config.HideEpisodeTitle,
            config.HideYearMovies, config.HideYearEpisodes, config.HideYearVideos, config.ShowOriginalTitleMovies,
            config.TopLeftOrder
        ].map(function (v) { return String(v); }).join('\u0000');
        const renderSignature = rawText + '\u0001' + itemInfoSignature + '\u0001' + configSignature;
        const stillOurRender = config.HideTitleBar
            ? el.classList.contains(FORCE_HIDE_CLASS)
            : el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent;
        if (stillOurRender && el.dataset.jvosdTcLastRenderSignature === renderSignature) return;
        el.dataset.jvosdTcLastRenderSignature = renderSignature;

        if (config.HideTitleBar) {
            if (!el.classList.contains(FORCE_HIDE_CLASS)) el.classList.add(FORCE_HIDE_CLASS);
            return;
        }
        el.classList.remove(FORCE_HIDE_CLASS);

        const parsed = parseTitleSync(rawText, itemInfo);
        const kind = (itemInfo && itemInfo.kind) || (parsed.kind === 'episode' ? 'episode' : null);

        const includeYear = kind === 'movie' ? !config.HideYearMovies
            : kind === 'episode' ? !config.HideYearEpisodes
                : kind === 'video' ? !config.HideYearVideos
                    : true;

        const yearText = (includeYear && parsed.year) ? ('(' + parsed.year + ')') : '';

        let orderedParts;

        if (parsed.kind === 'episode') {
            const order = getEpisodeTitleOrder(config);
            const partsByKey = {
                series: { key: 'series', text: config.HideSeriesTitle ? '' : parsed.seriesName },
                sxe: { key: 'sxe', text: config.HideSeasonEpisodeNumber ? '' : (typeof parsed.sxeText === 'string' ? parsed.sxeText : ('S' + parsed.season + ':E' + parsed.episode + (parsed.episodeEnd ? '-' + parsed.episodeEnd : ''))) },
                title: { key: 'title', text: config.HideEpisodeTitle ? '' : parsed.episodeName }
            };
            orderedParts = order.map(function (k) { return partsByKey[k]; });
        } else {
            // ShowOriginalTitleMovies replaces the title with the original
            // title; the normal title stays when there is none or it is
            // identical.
            const displayName = (kind === 'movie' && config.ShowOriginalTitleMovies && itemInfo && itemInfo.originalTitle && itemInfo.originalTitle !== parsed.name)
                ? itemInfo.originalTitle
                : parsed.name;
            orderedParts = [{ key: 'name', text: displayName }];
        }

        renderTitleParts(el, orderedParts, yearText);

        el.dataset.jvosdTcSourceText = rawText;
        el.setAttribute(RAW_TEXT_MARKER_ATTR, el.textContent);
    }

    // ============================================================
    // ZONE ORDERING
    // ============================================================
    // Moves the elements listed in orderCsv (matched by idAttr) into that
    // sequence, at the container's front or directly after anchor. With
    // an anchor, everything up to it stays untouched (Bottom-Left also
    // holds 7 native transport buttons that are in no order list).
    // insertBefore() always produces a childList mutation, even for an
    // element already in place, and these containers sit inside the
    // subtree osdObserver watches, so nothing is moved when the order is
    // already correct; otherwise every apply would re-trigger the
    // observer in an endless loop.
    function applyOrder(container, orderCsv, idAttr, anchor) {
        if (!container || typeof orderCsv !== 'string' || !orderCsv) return;
        const order = orderCsv.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
        if (!order.length) return;

        const targetEls = order
            .map(function (id) { return container.querySelector('[' + idAttr + '="' + CSS.escape(id) + '"]'); })
            .filter(Boolean);
        if (!targetEls.length) return;

        const validAnchor = (anchor && anchor.parentNode === container) ? anchor : null;

        // Compare element nodes only: whitespace text nodes between tags
        // would offset the positions, the "already correct" check would
        // never match, and every pass would move nodes again and
        // re-trigger the observer.
        const firstSlot = validAnchor ? validAnchor.nextSibling : container.firstChild;
        const currentFromSlot = [];
        for (let node = firstSlot; node; node = node.nextSibling) {
            if (node.nodeType === 1) currentFromSlot.push(node);
        }
        const alreadyCorrect = targetEls.every(function (el, idx) { return currentFromSlot[idx] === el; });
        if (alreadyCorrect) return;

        // In reverse, each one inserted right after the anchor (or at the
        // container's front). The insertion point is read fresh on every
        // iteration, so each element lands before the one placed in the
        // previous iteration, building up the final sequence.
        targetEls.slice().reverse().forEach(function (el) {
            const insertBeforeNode = validAnchor ? validAnchor.nextSibling : container.firstChild;
            container.insertBefore(el, insertBeforeNode);
        });
    }

    // With only two items, ordering is a swap or nothing. ".headerRight"
    // also holds other native elements around sync and cast
    // (headerSelectedPlayer, headerAudioPlayerButton, headerSearchButton,
    // headerUserButton), so only these two are swapped relative to each
    // other and nothing else is moved.
    function applyTopRightOrder(config) {
        const container = document.querySelector('.headerRight');
        if (!container) return;

        const sync = container.querySelector('.headerSyncButton');
        const cast = container.querySelector('.headerCastButton');
        if (!sync || !cast) return;

        const orderCsv = config.TopRightOrder;
        if (typeof orderCsv !== 'string' || !orderCsv) return;

        const order = orderCsv.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
        const castIdx = order.indexOf('cast');
        const syncIdx = order.indexOf('sync');
        if (castIdx === -1 || syncIdx === -1) return;
        const wantCastFirst = castIdx < syncIdx;

        const children = Array.prototype.slice.call(container.children);
        const castCurrentlyFirst = children.indexOf(cast) < children.indexOf(sync);

        if (wantCastFirst === castCurrentlyFirst) return;

        // A real swap: a plain insertBefore(cast, sync) would also displace
        // whatever sits between the two (headerSelectedPlayer,
        // headerAudioPlayerButton). Each element is moved before the
        // other's original next sibling, so anything in between stays put.
        const syncNext = sync.nextSibling;
        const castNext = cast.nextSibling;
        container.insertBefore(sync, castNext);
        container.insertBefore(cast, syncNext);
    }

    // ============================================================
    // JELLYFIN 12.1 MODERN (MUI) VIDEO HEADER
    // ============================================================
    // Same settings as the legacy header above, applied through the CSS
    // flags of ensureCoreStyle(). The title is rendered into the (hidden)
    // legacy h3.pageTitle by applyTitleDisplay() exactly as on 10.10.7 and
    // mirrored into an own element next to the React title, which is
    // hidden; the React title's text is never written to (React would
    // overwrite it on the next title change).
    const MUI_TITLE_CLASS = 'jvosd-tc-mui-title';

    function setRootFlag(name, on) {
        const root = document.documentElement;
        if (on) {
            if (!root.hasAttribute(name)) root.setAttribute(name, '');
        } else if (root.hasAttribute(name)) {
            root.removeAttribute(name);
        }
    }

    // Config-only flags for the first frames of the MUI video header (see
    // ensureCoreStyle()); independent of the OSD state, they match nothing
    // outside the video header.
    function applyMuiPreHides(config) {
        setRootFlag('data-jvosd-pre-hide-back', !!(config && config.HideBackButton));
        setRootFlag('data-jvosd-pre-hide-sync', !!(config && config.HideSyncPlayButton));
        setRootFlag('data-jvosd-pre-hide-cast', !!(config && config.HideCastButton));
    }

    // The MUI video toolbar: 12.1 modern gives it the class videoOsd-appBar;
    // the 10.10.7 "experimental" layout renders the same toolbar (same
    // icons) without any class and without a title; it is recognised by
    // those icons (back / SyncPlay / cast) and by having no drawer menu
    // button (that would be an app toolbar), and gets our own attribute
    // (React leaves attributes it does not manage alone). The legacy
    // layouts have no MUI toolbar at all.
    const MUI_BAR_ATTR = 'data-jvosd-mui-bar';
    const MUI_BAR_ICONS = 'svg[data-testid="ArrowBackIcon"], svg[data-testid="GroupsIcon"], ' +
        'svg[data-testid="CastIcon"], svg[data-testid="CastConnectedIcon"]';

    function getMuiBar() {
        const bar = document.querySelector('.videoOsd-appBar');
        if (bar) return bar;
        const toolbar = Array.prototype.find.call(document.querySelectorAll('.MuiToolbar-root'), function (t) {
            return !t.closest('.dialogContainer') &&
                !!t.querySelector(MUI_BAR_ICONS) &&
                !t.querySelector('svg[data-testid="MenuIcon"]');
        });
        if (toolbar && !toolbar.hasAttribute(MUI_BAR_ATTR)) toolbar.setAttribute(MUI_BAR_ATTR, '');
        return toolbar || null;
    }

    // Marks the header buttons by their MUI icon (independent of the UI
    // language): back, SyncPlay, cast — for the cast-connected state the
    // Box around its button. React re-creates a button on some state
    // changes; the observers call this again and the new node is marked.
    const MUI_BTN_ATTR = 'data-jvosd-mui-btn';

    function markMuiButtons(bar) {
        const marks = [
            ['back', 'button', 'ArrowBackIcon'],
            ['sync', 'button', 'GroupsIcon'],
            ['cast', 'button', 'CastIcon'],
            ['cast', '.MuiBox-root', 'CastConnectedIcon']
        ];
        marks.forEach(function (m) {
            Array.prototype.forEach.call(bar.querySelectorAll('svg[data-testid="' + m[2] + '"]'), function (svg) {
                let el = svg.closest('button');
                if (el && m[1] !== 'button') el = el.parentElement && el.parentElement.closest(m[1]);
                if (el && bar.contains(el) && el !== bar && el.getAttribute(MUI_BTN_ATTR) !== m[0]) {
                    el.setAttribute(MUI_BTN_ATTR, m[0]);
                }
            });
        });
    }

    function applyMuiHeader(config) {
        const bar = isVideoOsdActive() ? getMuiBar() : null;
        const on = !!bar;
        if (bar) markMuiButtons(bar);

        setRootFlag('data-jvosd-mui-hide-back', on && config.HideBackButton);
        setRootFlag('data-jvosd-mui-hide-sync', on && config.HideSyncPlayButton);
        setRootFlag('data-jvosd-mui-hide-cast', on && config.HideCastButton);

        let castFirst = false;
        if (typeof config.TopRightOrder === 'string' && config.TopRightOrder) {
            const order = config.TopRightOrder.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
            const castIdx = order.indexOf('cast');
            const syncIdx = order.indexOf('sync');
            castFirst = castIdx !== -1 && syncIdx !== -1 && castIdx < syncIdx;
        }
        setRootFlag('data-jvosd-mui-cast-first', on && castFirst);

        const ownTitle = on && needsTitleIntervention(config);

        const existing = document.querySelectorAll('.' + MUI_TITLE_CLASS);
        const typo = bar && bar.querySelector(':scope > .MuiTypography-root:not(.' + MUI_TITLE_CLASS + ')');
        const source = document.querySelector('h3.' + TITLE_ID);
        if (!ownTitle || config.HideTitleBar || !typo || !source) {
            existing.forEach(function (el) { el.remove(); });
            // The React title is hidden only when nothing is to be shown
            // (HideTitleBar) or the own title replaces it, never while the
            // mirror can't be built yet (no title at all).
            setRootFlag('data-jvosd-mui-own-title', ownTitle && !!config.HideTitleBar);
            return;
        }
        setRootFlag('data-jvosd-mui-own-title', true);

        let mirror = existing[0];
        if (!mirror || mirror.parentNode !== bar || mirror.previousElementSibling !== typo) {
            existing.forEach(function (el) { el.remove(); });
            mirror = document.createElement(typo.tagName.toLowerCase());
            typo.after(mirror);
        }
        const cls = typo.className + ' ' + MUI_TITLE_CLASS;
        if (mirror.className !== cls) mirror.className = cls;
        if (mirror.innerHTML !== source.innerHTML) mirror.innerHTML = source.innerHTML;
    }

    function applyBottomLeftOrder(config) {
        const osdPage = getActiveOsdPage();
        if (!osdPage) return;
        const container = osdPage.querySelector('.videoOsdBottom .buttons.focuscontainer-x > div[dir="ltr"]');
        if (!container) return;

        const idMap = {
            abloop: '#btnAbLoop',
            speed: '.jfb-speed-step-container',
            framebyframe: '.jfb-frame-step-container'
        };
        // setAttribute() triggers a MutationObserver callback even for an
        // unchanged value, so the tag is only written when it differs;
        // otherwise every applyAll() pass would re-trigger osdObserver in
        // an endless loop.
        Object.keys(idMap).forEach(function (id) {
            const el = container.querySelector(idMap[id]);
            if (el && el.getAttribute('data-jvosd-order-id') !== id) el.setAttribute('data-jvosd-order-id', id);
        });

        // Default order when none is configured: the three addon scripts
        // insert themselves independently, so their raw order is a race.
        // applyOrder() enforces it regardless of insertion timing.
        const orderCsv = (typeof config.BottomLeftOrder === 'string' && config.BottomLeftOrder)
            ? config.BottomLeftOrder
            : 'abloop,speed,framebyframe';

        // The custom buttons go after the last native transport control
        // (NextTrack, or FastForward when NextTrack isn't present), not to
        // the front of the container ahead of Play/Pause/Rewind etc.
        const anchor = container.querySelector('.btnNextTrack') || container.querySelector('.btnFastForward');

        applyOrder(container, orderCsv, 'data-jvosd-order-id', anchor);

        applyCustomGapSpacing(container, config);
    }

    // The Centered Gap behaves like vanilla spacing: every native button
    // has the same 0.29em per side, so every gap a custom addon takes part
    // in grows by exactly 1x the configured value, never 2x between two
    // adjacent addons (as margins on both sides of each element would
    // give). That needs the actual neighbors after sorting, which only
    // this script knows, so the gap is applied here and not in the addon
    // scripts (their applySpacing() sets only the native 0.29em baseline;
    // without the plugin there is no gap setting). Runs on every
    // applyAll() pass, so a reorder re-derives the sides at once.
    // Per addon: its RIGHT side carries the gap only when another element
    // follows in this container, so the last custom keeps the native
    // distance to the "Ends at" text (its 1em margin-left plus our 0.29em
    // = the native 1.29em) at any gap value; anything placed after it
    // later (another addon or plugin button) gets the gap automatically.
    // Its LEFT side carries the gap only when the left neighbor is not
    // one of our addons (that neighbor's right side already has it).
    // Result: vanilla|addon, addon|vanilla and addon|addon all grow by
    // exactly 1x, and the group's outer edges stay native.
    // ABLoop is a bare button (its 0.29em baseline is in its own margins,
    // so the gap is added to 0.29), while Speed/Frame are fixed-width
    // containers whose 0.29em comes from the inner buttons (their
    // container margin carries only the gap, cleared at 0). Margins are
    // only written on a real change: inline style writes fire
    // MutationObservers even for identical values, and the addon
    // scripts' observers watch document.body.
    function applyCustomGapSpacing(container, config) {
        const NATIVE_EM = 0.29;

        function effectiveGap(flagKey, valueKey) {
            return config[flagKey]
                ? (Number(config[valueKey]) || 0)
                : (Number(config.GeneralCenteredGap) || 0);
        }

        const items = [
            {
                el: container.querySelector('#btnAbLoop'),
                gap: effectiveGap('ABLoopIndividualCenteredGapOverride', 'ABLoopCenteredGapValue'),
                bareButton: true
            },
            {
                el: container.querySelector('.jfb-speed-step-container'),
                gap: effectiveGap('SpeedIndividualCenteredGapOverride', 'SpeedCenteredGapValue'),
                bareButton: false
            },
            {
                el: container.querySelector('.jfb-frame-step-container'),
                gap: effectiveGap('FrameByFrameIndividualCenteredGapOverride', 'FrameByFrameCenteredGapValue'),
                bareButton: false
            }
        ].filter(function (i) { return !!i.el; });
        if (!items.length) return;

        const customEls = items.map(function (i) { return i.el; });

        items.forEach(function (i) {
            const prev = i.el.previousElementSibling;
            const next = i.el.nextElementSibling;
            const leftExtra = (prev && customEls.indexOf(prev) === -1) ? i.gap : 0;
            const rightExtra = next ? i.gap : 0;

            let ml, mr;
            if (i.bareButton) {
                ml = (NATIVE_EM + leftExtra) + 'em';
                mr = (NATIVE_EM + rightExtra) + 'em';
            } else {
                ml = leftExtra > 0 ? leftExtra + 'em' : '';
                mr = rightExtra > 0 ? rightExtra + 'em' : '';
            }
            if (i.el.style.marginLeft !== ml) i.el.style.marginLeft = ml;
            if (i.el.style.marginRight !== mr) i.el.style.marginRight = mr;
        });
    }

    // This zone is sorted with flexbox "order", never by moving nodes.
    // Jellyfin's emby-slider is not move-safe (src/elements/emby-slider/
    // emby-slider.js): its detachedCallback nulls backgroundLower/
    // backgroundUpper (the refs behind the blue track fill), and its
    // attachedCallback returns early on re-attach (data-embyslider="true")
    // without restoring them. Any insertBefore() is a detach plus a
    // re-attach, so a single move of the slider freezes its fill for good.
    // ".videoOsdBottom .buttons" is a flex container, and "order" changes
    // the visual sequence without any element leaving the DOM.
    // Consequences:
    // - ".volumeButtons" stays intact with the slider inside (native
    //   flex-grow, 43em narrow-window hide and hide-mouse-idle-tv keep
    //   working); the wrapper itself is the sortable "volumeslider".
    //   Its margin is evened out in ensureCoreStyle().
    // - Only .buttonMute leaves the wrapper, once, so it can be sorted on
    //   its own. Plain emby buttons are move-safe (their attachedCallback
    //   re-binds on every re-attach, see emby-ratingbutton/emby-button).
    //   It gets hide-mouse-idle-tv re-applied, since that sat on the
    //   wrapper.
    // - Untagged siblings (the left transport group and ".osdTimeText")
    //   keep the default order 0 and so always render before everything
    //   numbered from 1: the "Ends at" spacer stays first without any
    //   anchor logic.
    // - Known edge: an element another plugin appends here also has
    //   order 0 and shows before our numbered items; unknown elements
    //   are deliberately not styled.
    // - Known trade-off: keyboard Tab order follows the DOM (the vanilla
    //   sequence), not the sorted visual sequence.
    // - Loop safety: style.order writes are attribute mutations, which
    //   osdObserver ignores (attributeFilter: ['class']) and the addon
    //   scripts' childList observers never see; all writes are
    //   change-guarded, so a settled state produces no mutations.
    function applyBottomRightOrder(config) {
        // Scoped to the live OSD page (see getActiveOsdPage()): a bare
        // document-wide '.btnUserRating' also matches the favorite button
        // of a cached item details page (itemDetails/index.html), which
        // would be sorted instead whenever that page sits in an earlier
        // slot.
        const osdPage = getActiveOsdPage();
        const favBtn = osdPage && osdPage.querySelector('.videoOsdBottom .btnUserRating');
        const container = favBtn && favBtn.parentNode;
        // html[data-jvosd-order-br]: this zone is sorted here right now.
        // The VisoMaster Bridge plugin reads it and then leaves its own
        // button where it is.
        setRootFlag('data-jvosd-order-br', !!container);
        if (!container) return;

        // Mute moves out of the wrapper exactly once (guarded: only
        // while it still sits inside), gaining independent sortability;
        // the wrapper-provided TV auto-hide is re-applied directly.
        const wrapper = container.querySelector('.volumeButtons');
        if (wrapper) {
            const muteInWrapper = wrapper.querySelector('.buttonMute');
            if (muteInWrapper) {
                wrapper.insertAdjacentElement('afterend', muteInWrapper);
            }
        }
        const mute = container.querySelector('.buttonMute');
        if (mute && !mute.classList.contains('hide-mouse-idle-tv')) mute.classList.add('hide-mouse-idle-tv');

        // "volumeslider" = the native ".volumeButtons" wrapper (the slider;
        // mute is moved out of it above). Present in 10.10.7 and 12.x.
        const idMap = {
            favorite: '.btnUserRating',
            episodepreview: '#popupPreviewButton',
            subtitles: '.btnSubtitles',
            audio: '.btnAudio',
            mute: '.buttonMute',
            volumeslider: '.volumeButtons',
            settings: '.btnVideoOsdSettings',
            pip: '.btnPip',
            fullscreen: '.btnFullscreen',
            airplay: '.btnAirPlay',
            download: '.btnDownload',
            screenshot: '.btnScreenshot',
            // VisoMaster Bridge plugin's button (inserted by its own script)
            visomaster: '.btnVisoMaster'
        };

        // Default order when none is configured: download, screenshot and
        // episodepreview are inserted by separate scripts, so their raw
        // insertion order would be a race.
        const orderCsv = (typeof config.BottomRightOrder === 'string' && config.BottomRightOrder)
            ? config.BottomRightOrder
            : 'screenshot,download,visomaster,favorite,episodepreview,subtitles,audio,mute,volumeslider,settings,airplay,pip,fullscreen';
        const order = orderCsv.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
        // A list saved before VisoMaster Bridge was installed has no
        // "visomaster": place it left of favorite (its own default),
        // never at order 0 in front of everything.
        if (order.indexOf('visomaster') === -1) {
            const favIdx = order.indexOf('favorite');
            order.splice(favIdx === -1 ? order.length : favIdx, 0, 'visomaster');
        }

        Object.keys(idMap).forEach(function (id) {
            const el = container.querySelector(idMap[id]);
            if (!el) return;
            if (el.getAttribute('data-jvosd-order-id') !== id) el.setAttribute('data-jvosd-order-id', id);
            const idx = order.indexOf(id);
            const val = idx === -1 ? '' : String(idx + 1);
            if (el.style.order !== val) el.style.order = val;
        });
    }

    // ============================================================
    // ORCHESTRATION
    // ============================================================
    let currentConfig = null;
    let currentItemInfo = null;
    // Native title text the current item info belongs to (see
    // applyTitleDisplay()); null = no title seen yet on this OSD visit.
    let titleRawTextForItemInfo = null;
    let itemInfoRequestSeq = 0;

    function applyAll() {
        if (!currentConfig) return;
        ensureCoreStyle();
        applyOsdInternalHides(currentConfig);
        applyHeaderButtonHides(currentConfig);
        applyTitleDisplay(currentConfig, currentItemInfo);
        applyTopRightOrder(currentConfig);
        applyMuiPreHides(currentConfig);
        applyMuiHeader(currentConfig);
        applyBottomLeftOrder(currentConfig);
        applyBottomRightOrder(currentConfig);
    }

    // Jellyfin's own title text currently on screen (not our rebuilt one).
    function getNativeTitleText() {
        const el = document.querySelector('h3.' + TITLE_ID);
        if (!el) return '';
        return el.getAttribute(RAW_TEXT_MARKER_ATTR) === el.textContent
            ? (el.dataset.jvosdTcSourceText || '')
            : el.textContent;
    }

    // Jellyfin sets the favorite button's data-id to the current item in
    // the same step as the title, so the item Id is the reliable check
    // (a name check alone would accept "Toy Story" for "Toy Story 2").
    // Items that can't be rated have no data-id; the name check is used
    // then.
    function itemInfoMatchesOsd(info, titleText) {
        const osdPage = getActiveOsdPage();
        const ratingBtn = osdPage && osdPage.querySelector('.btnUserRating');
        const liveId = ratingBtn && ratingBtn.getAttribute('data-id');
        if (liveId && info.id) return info.id === liveId;
        return !info.name || info.type === 'TvChannel' || titleText.includes(info.name);
    }

    // The server's session can still report the previous item for a
    // moment after the next one started, so the fetched info is only
    // used once it matches the item on screen (retried briefly). A newer
    // request supersedes an older one.
    async function refreshItemInfoAndReapply() {
        const seq = ++itemInfoRequestSeq;
        for (let attempt = 0; attempt < 8; attempt++) {
            const info = await getNowPlayingItemInfo();
            if (seq !== itemInfoRequestSeq) return;
            // No title yet (Jellyfin sets it right after the page shows):
            // retry rather than accept info that can't be checked. Live TV
            // titles show the program, not the channel item's name, so the
            // name check can't apply there.
            const titleText = getNativeTitleText();
            if (info && titleText && itemInfoMatchesOsd(info, titleText)) {
                currentItemInfo = info;
                applyAll();
                return;
            }
            await new Promise(function (resolve) { setTimeout(resolve, 500); });
            if (seq !== itemInfoRequestSeq) return;
        }
    }

    // osdObserver watches only the live #videoOsdPage subtree and exists
    // only while the video page is shown (started on "pageshow",
    // disconnected on "pagehide").
    let osdObserver = null;
    let observedOsdPage = null;

    function startOsdObserver() {
        const osdPage = getActiveOsdPage();
        if (!osdPage) return;
        if (osdObserver && observedOsdPage === osdPage) {
            startMuiObserver();
            return;
        }
        stopOsdObserver();
        observedOsdPage = osdPage;
        osdObserver = new MutationObserver(function () {
            applyAll();
        });
        osdObserver.observe(osdPage, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['class']
        });
        startMuiObserver();
    }

    function stopOsdObserver() {
        stopMuiObserver();
        if (!osdObserver) return;
        osdObserver.disconnect();
        osdObserver = null;
        observedOsdPage = null;
    }

    // Jellyfin 12.1 modern: the MUI video header and the legacy title it is
    // mirrored from live OUTSIDE #videoOsdPage, so the observer above never
    // sees them. When the header mounts or the title arrives after the
    // config (slow devices), nothing inside the OSD page changes on a paused
    // video and the header settings would stay unapplied. This observer
    // watches the header box (or, until it exists, the body for its
    // arrival) and the legacy title while the OSD is active; applyMuiHeader()
    // only writes on a difference, so its own changes settle at once.
    const MUI_WAIT_MS = 5000;
    let muiObserver = null;
    let muiObserverTarget = null;
    let muiObserverTimer = null;

    function startMuiObserver() {
        if (!isVideoOsdActive()) return;
        const bar = getMuiBar();
        const target = bar ? (bar.closest('.osdHeader') || bar.parentNode) : document.body;
        if (muiObserver && muiObserverTarget === target) return;
        stopMuiObserver();
        muiObserverTarget = target;
        muiObserver = new MutationObserver(function () {
            if (!currentConfig) return;
            if (!bar && getMuiBar()) {
                // The header has mounted: narrow the watch to it.
                startMuiObserver();
            }
            applyTitleDisplay(currentConfig, currentItemInfo);
            applyMuiHeader(currentConfig);
        });
        muiObserver.observe(target, { childList: true, subtree: true, characterData: !!bar });
        const title = document.querySelector('h3.' + TITLE_ID);
        if (bar && title) muiObserver.observe(title, { childList: true, subtree: true, characterData: true });
        if (!bar) {
            // Legacy layouts and 10.10.7 never mount this header: stop
            // waiting for it after a few seconds.
            muiObserverTimer = setTimeout(function () {
                if (muiObserverTarget === document.body) stopMuiObserver();
            }, MUI_WAIT_MS);
        }
    }

    function stopMuiObserver() {
        if (muiObserverTimer) {
            clearTimeout(muiObserverTimer);
            muiObserverTimer = null;
        }
        if (!muiObserver) return;
        muiObserver.disconnect();
        muiObserver = null;
        muiObserverTarget = null;
    }

    // Config cache, so the layout is right from the first frame. The fetch
    // is a network round trip that resolves in a later task, so without
    // the cache the vanilla arrangement is briefly visible and then
    // re-shuffles. Jellyfin makes the page visible and dispatches
    // 'pageshow' in one synchronous chain (viewContainer.js
    // "classList.remove('hide')" -> viewManager.js
    // "dispatchEvent('pageshow')"), and browsers paint only after the
    // current task and its microtasks have run, so whatever the pageshow
    // handler applies synchronously is on screen from the first painted
    // frame. Stale-while-revalidate: the cached copy is applied at once,
    // the fetch (on every video start) refreshes the cache and
    // re-applies; since every write in applyAll() is change-guarded, an
    // identical config causes no mutations. Trade-off: the first video
    // after a settings change briefly shows the cached arrangement, then
    // corrects. Storage access is try/catch-guarded; without usable
    // localStorage (or with a corrupt entry) this falls back to
    // fetch-only. The plugin config is server-wide and localStorage is
    // per origin (per server), so one shared cache is always correct.
    const CONFIG_CACHE_KEY = 'jvosd-tc-config-cache';

    function readCachedConfig() {
        try {
            const raw = localStorage.getItem(CONFIG_CACHE_KEY);
            return raw ? JSON.parse(raw) : null;
        } catch (e) {
            return null;
        }
    }

    function writeCachedConfig(cfg) {
        try {
            localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(cfg));
        } catch (e) {
            /* storage unavailable or full: degrade to fetch-only */
        }
    }

    function onVideoOsdShow() {
        // Item info always belongs to one item; a new OSD visit starts
        // without it (the fetch below brings the current one).
        currentItemInfo = null;
        titleRawTextForItemInfo = null;
        itemInfoRequestSeq++;
        // Synchronous cache pass first: it runs to completion inside the
        // pageshow dispatch, i.e. before the first paint (see the comment
        // above CONFIG_CACHE_KEY). applyAll() depends on nothing
        // asynchronous (no ApiClient use), and startOsdObserver() is
        // idempotent, so the fetch path below calling both again is
        // harmless.
        const cached = readCachedConfig();
        if (cached) {
            currentConfig = cached;
            applyAll();
            startOsdObserver();
        }

        // Fetch fresh on every OSD visit, so settings saved in the admin
        // panel apply without a full page reload (the single-page app keeps
        // running). fetchPluginConfig() resolves almost at once here, since
        // ApiClient exists by now. It also refreshes the cache used above.
        fetchPluginConfig().then(function (pluginConfig) {
            if (!pluginConfig) {
                // No fresh config (no access, plugin missing or server
                // error). A cached config applied above still needs the
                // current item's info.
                if (currentConfig) refreshItemInfoAndReapply();
                return;
            }
            writeCachedConfig(pluginConfig);
            currentConfig = pluginConfig;
            applyAll();
            refreshItemInfoAndReapply();
            startOsdObserver();
        }).catch(function (err) {
            console.error('[VideoOSD Tweaks and Candy] Core init failed:', err);
        });
    }

    function onVideoOsdHide(page) {
        stopOsdObserver();
        // Re-run once more so the header elements (Back/Title/Sync/Cast)
        // correctly un-hide again now that we've left the video page,
        // isVideoOsdActive() inside applyHeaderButtonHides()/
        // applyTitleDisplay() picks up the new state on its own.
        // The page being left is excluded explicitly, it may not carry
        // "hide" yet (see leavingOsdPage).
        leavingOsdPage = page;
        try {
            applyAll();
        } finally {
            leavingOsdPage = null;
        }
    }

    document.addEventListener('pageshow', function (e) {
        if (e.target && e.target.id === OSD_PAGE_ID) {
            onVideoOsdShow();
        }
    });

    document.addEventListener('pagehide', function (e) {
        if (e.target && e.target.id === OSD_PAGE_ID) {
            onVideoOsdHide(e.target);
        }
    });

    // Catches the case where the video OSD was already active by the
    // time this script's listeners above got attached (e.g. a page
    // refresh while a video was already playing), so its own earlier
    // "pageshow" event (which fired before we were listening yet) wasn't
    // missed. onVideoOsdShow() does its own fresh config fetch, no need
    // to duplicate that here.
    if (isVideoOsdActive()) {
        onVideoOsdShow();
    } else {
        // Before the first video: the header can mount before "pageshow".
        // The cached config covers a returning browser at once; one fetch
        // per page load covers the first visit and a config saved since (the
        // same endpoint the OSD visit uses). Signed out (login page) nothing
        // is sent and no timer runs: the fetch waits for the first view
        // change ("viewshow", both versions' viewManager) or hash change
        // with a token.
        ensureCoreStyle();
        applyMuiPreHides(readCachedConfig());
        let preHideFetchStarted = false;
        const startPreHideFetch = function () {
            if (preHideFetchStarted) return;
            if (!window.ApiClient || typeof ApiClient.accessToken !== 'function' || !ApiClient.accessToken()) return;
            preHideFetchStarted = true;
            document.removeEventListener('viewshow', startPreHideFetch, true);
            window.removeEventListener('hashchange', startPreHideFetch);
            fetchPluginConfig().then(function (pluginConfig) {
                if (!pluginConfig) return;
                writeCachedConfig(pluginConfig);
                applyMuiPreHides(pluginConfig);
            }).catch(function () { /* the OSD visit fetches again */ });
        };
        startPreHideFetch();
        if (!preHideFetchStarted) {
            document.addEventListener('viewshow', startPreHideFetch, true);
            window.addEventListener('hashchange', startPreHideFetch);
        }
    }
})();