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