(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 = `
News:
Get the official app for your device
|
Submit requests
`;
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');
}
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(); }
})();