(function () { // =========== README ========== // Update the message below // incrementversion DISMISSED_KEY const DISMISSED_KEY = 'jf-banner-dismissed-v1'; const BANNER_ID = 'custom-announcement-banner'; const SCRIPT_START = Date.now(); // Edit these to change banner content: const BANNER_HTML = ` `; const BANNER_CSS = ` #custom-announcement-banner { position: fixed; top: 0; left: 0; display: flex; align-items: center; justify-content: space-between; background: #1c3a5c; color: #e0e0e0; font-size: 0.9em; padding: 8px 16px; box-sizing: border-box; width: 100%; z-index: 9999; border-bottom: 1px solid #2d5a8e; } #custom-announcement-banner a { color: #7cb8f0; } #custom-banner-close { background: none; border: none; color: #e0e0e0; cursor: pointer; font-size: 1.1em; padding: 0 4px; flex-shrink: 0; } `; // Jellyfin's own nav chrome has moved before (left drawer -> top app bar in // 12.x) and will likely move again. Rather than hardcoding selectors that // break on every redesign, find whatever is fixed/sticky and anchored near // the top of the viewport at runtime, and push it down (Y) by the banner's // height so the banner never just sits in front of (Z) live nav chrome. const originalMarginTop = new WeakMap(); const offsetElements = new Set(); function isTopBarCandidate(el) { if (el.id === BANNER_ID || el.closest('#' + BANNER_ID)) return false; const style = getComputedStyle(el); if (style.position !== 'fixed' && style.position !== 'sticky') return false; if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return false; const rect = el.getBoundingClientRect(); if (rect.width < window.innerWidth * 0.5) return false; // must span most of the viewport width if (rect.height <= 0 || rect.height > window.innerHeight * 0.6) return false; // skip full-page overlays/modals if (rect.top > 400) return false; // must be anchored near the top of the page return true; } function findTopBars() { return Array.prototype.filter.call(document.body.querySelectorAll('*'), isTopBarCandidate); } function pushDownTopBars(offsetPx) { const candidates = new Set(findTopBars()); // Restore anything we previously offset that's no longer a candidate // (e.g. replaced by the SPA router on navigation). offsetElements.forEach(function (el) { if (!candidates.has(el) || !document.contains(el)) { if (originalMarginTop.has(el)) { el.style.marginTop = originalMarginTop.get(el); } offsetElements.delete(el); } }); candidates.forEach(function (el) { if (!originalMarginTop.has(el)) { originalMarginTop.set(el, el.style.marginTop || ''); } const base = originalMarginTop.get(el); el.style.marginTop = offsetPx > 0 ? 'calc(' + (base || '0px') + ' + ' + offsetPx + 'px)' : base; offsetElements.add(el); }); // Jellyfin's SPA mounts its header asynchronously after DOMContentLoaded, // so an empty result in the first few seconds is expected, not a bug. // Only warn once things have had time to settle. if (offsetPx > 0 && candidates.size === 0 && Date.now() - SCRIPT_START > 4000) { console.warn('[custom-banner] No fixed top navigation elements detected to push down; banner may overlap the menu.'); } } function applyOffset() { const banner = document.getElementById(BANNER_ID); const offset = banner ? banner.offsetHeight : 0; pushDownTopBars(offset); } // Hide the default server-name text next to the top-bar logo icon, leaving // just the (now custom) logo image in place. "icon-transparent" matches the // filename inject-banner.sh replaces for the top-bar logo; keep these two // in sync if that ever changes again. const LOGO_IMG_MATCH = 'icon-transparent'; const LOGO_MAX_SIZE = '44px'; // grow the icon now that the text next to it is gone; fixed // px instead of em, since em resolves against whatever // (possibly tiny/zeroed) font-size the icon wrapper uses function hideLogoText() { const logoImg = document.querySelector('img[src*="' + LOGO_IMG_MATCH + '"]'); if (!logoImg) return; logoImg.style.setProperty('height', LOGO_MAX_SIZE, 'important'); logoImg.style.setProperty('max-height', LOGO_MAX_SIZE, 'important'); logoImg.style.setProperty('width', 'auto', 'important'); logoImg.style.setProperty('max-width', 'none', 'important'); // The image is usually wrapped in its own icon span, so the text label // is a sibling of that wrapper, not of the itself. Walk up to the // whole clickable logo button/link and hide every child that isn't part // of the branch containing the image. const container = logoImg.closest('button, a, [role="button"]') || logoImg.parentElement; if (!container) return; // Strip any fixed size/overflow-clipping the icon wrapper itself might // impose (MUI's startIcon span is normally sized for a small icon font), // which would otherwise clip the enlarged image regardless of its own size. const iconWrap = logoImg.parentElement; if (iconWrap && iconWrap !== container) { iconWrap.style.setProperty('overflow', 'visible', 'important'); iconWrap.style.setProperty('height', 'auto', 'important'); iconWrap.style.setProperty('width', 'auto', 'important'); iconWrap.style.setProperty('max-height', 'none', 'important'); iconWrap.style.setProperty('max-width', 'none', 'important'); } Array.prototype.forEach.call(container.childNodes, function (node) { if (node === logoImg) return; if (node.nodeType === Node.ELEMENT_NODE && node.contains(logoImg)) return; if (node.nodeType === Node.TEXT_NODE) { if (node.textContent.trim()) node.textContent = ''; } else if (node.nodeType === Node.ELEMENT_NODE && node.style.display !== 'none') { node.style.display = 'none'; } }); // The button was sized (min-width, padding) to fit the icon + label // together; with the label gone, shrink it to fit just the icon instead // of leaving the label's dead space behind. It's a flex item in the // toolbar, so flex-grow/flex-basis would otherwise override the explicit // width below and keep reserving the old footprint. container.style.setProperty('flex', '0 0 auto', 'important'); container.style.setProperty('min-width', '0', 'important'); container.style.setProperty('width', 'fit-content', 'important'); // MUI buttons carry their own horizontal padding around the content // (icon + label); the icon span itself now fits tightly (confirmed via // devtools), so any remaining right-side gap is this padding. container.style.setProperty('padding', '0 8px', 'important'); } function isHomePage() { const h = window.location.hash; return h === '' || h === '#' || h === '#/home' || h === '#!' || h === '#!/' || h.startsWith('#/home') || h.startsWith('#!/home'); } function injectBanner() { if (document.getElementById(BANNER_ID)) return; const banner = document.createElement('div'); banner.id = BANNER_ID; banner.innerHTML = BANNER_HTML; document.body.insertBefore(banner, document.body.firstChild); document.getElementById('custom-banner-close').addEventListener('click', function () { sessionStorage.setItem(DISMISSED_KEY, '1'); document.getElementById(BANNER_ID).remove(); applyOffset(); }); applyOffset(); } function removeBanner() { const el = document.getElementById(BANNER_ID); if (el) el.remove(); applyOffset(); } function update() { if (sessionStorage.getItem(DISMISSED_KEY)) { removeBanner(); return; } if (isHomePage()) { injectBanner(); } else { removeBanner(); } } // Inject CSS once const style = document.createElement('style'); style.textContent = BANNER_CSS; document.head.appendChild(style); // Debounce: the SPA's own DOM churn (list virtualization, image loads, etc.) // can fire many mutations per second. We don't need per-frame precision for // nav-bar layout, so coalesce bursts instead of rescanning the whole DOM // on every single mutation. let debounceTimer = null; function scheduleUpdate(delay) { clearTimeout(debounceTimer); debounceTimer = setTimeout(function () { update(); applyOffset(); hideLogoText(); }, delay || 150); } // React to SPA navigation window.addEventListener('hashchange', function () { update(); hideLogoText(); }); window.addEventListener('resize', function () { scheduleUpdate(); }); // Watch for Jellyfin's deferred body rendering (SPA bootstraps after // DOMContentLoaded) and re-run on DOM churn, since the nav bar (and the // logo it contains) can be unmounted/remounted by the SPA router. const observer = new MutationObserver(function () { scheduleUpdate(); }); document.addEventListener('DOMContentLoaded', function () { observer.observe(document.body, { childList: true, subtree: true }); update(); hideLogoText(); }); // Fallback: run immediately if DOMContentLoaded already fired if (document.readyState !== 'loading') { update(); hideLogoText(); } })();