225 lines
8.3 KiB
JavaScript
225 lines
8.3 KiB
JavaScript
(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 = `
|
|
<span class="banner-text">
|
|
<strong>News:</strong>
|
|
<a href="https://jellyfin.org/downloads/clients/" target="_blank" rel="noopener">
|
|
Get the official app for your device
|
|
</a>
|
|
|
|
|
<a href="https://google.com/" target="_blank" rel="noopener">
|
|
Submit requests
|
|
</a>
|
|
</span>
|
|
<button id="custom-banner-close" aria-label="Dismiss banner">✕</button>
|
|
`;
|
|
|
|
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 = '2.75em'; // grow the icon now that the text next to it is gone
|
|
|
|
function hideLogoText() {
|
|
const logoImg = document.querySelector('img[src*="' + LOGO_IMG_MATCH + '"]');
|
|
if (!logoImg) return;
|
|
|
|
logoImg.style.setProperty('max-height', LOGO_MAX_SIZE, 'important');
|
|
logoImg.style.setProperty('max-width', LOGO_MAX_SIZE, 'important');
|
|
|
|
// The image is usually wrapped in its own icon span, so the text label
|
|
// is a sibling of that wrapper, not of the <img> 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;
|
|
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.
|
|
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(); }
|
|
})();
|