// KefinTweaks Desktop Hamburger Menu (Jellyfin v12) // Exposes a desktop Open Menu button + left drawer matching the native mobile nav. // Requires: utils.js (function () { 'use strict'; const LOG = (...args) => console.log('[KefinTweaks HamburgerMenu]', ...args); const WARN = (...args) => console.warn('[KefinTweaks HamburgerMenu]', ...args); const ERR = (...args) => console.error('[KefinTweaks HamburgerMenu]', ...args); const FAVORITES_HREF = '#/home?tab=1'; const STYLE_ID = 'kefin-hamburger-menu-styles'; const BTN_ATTR = 'data-kefin-desktop-hamburger'; const DRAWER_ATTR = 'data-kefin-desktop-drawer'; const FALLBACK_BTN_CLASS = 'MuiButtonBase-root MuiIconButton-root MuiIconButton-colorInherit MuiIconButton-sizeLarge css-i2hxb6'; const FALLBACK_SVG_CLASS = 'MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-iguwhy'; const FALLBACK_LI_CLASS = 'MuiListItem-root MuiListItem-gutters css-1ohqk82'; const FALLBACK_A_CLASS = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp'; const FALLBACK_ICON_WRAP = 'MuiListItemIcon-root css-5pks8q'; const FALLBACK_TEXT_WRAP = 'MuiListItemText-root css-t3p1a1'; const FALLBACK_TEXT_PRIMARY = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-pl8nxc'; const FALLBACK_BRAND_ICON_WRAP = 'MuiListItemIcon-root css-c9a5tz'; const FALLBACK_BRAND_TEXT_WRAP = 'MuiListItemText-root MuiListItemText-multiline css-9ac84v'; const FALLBACK_BRAND_PRIMARY = 'MuiTypography-root MuiTypography-h6 MuiListItemText-primary css-1y7q0ly'; const FALLBACK_BRAND_SECONDARY = 'MuiTypography-root MuiTypography-body2 MuiListItemText-secondary css-xxlzys'; let toolbarObserver = null; let openDrawerEl = null; let escapeHandler = null; let viewsCache = null; let viewsCacheAt = 0; LOG('Script loaded'); function ensureStyles() { let style = document.getElementById(STYLE_ID); if (!style) { style = document.createElement('style'); style.id = STYLE_ID; (document.head || document.documentElement).appendChild(style); } style.textContent = ` [${BTN_ATTR}] { position: fixed !important; left: 8px; top: 1px; z-index: 1200; } @media (max-width: 899px) { [${BTN_ATTR}] { display: none !important; } } .MuiDrawer-root[${DRAWER_ATTR}] { z-index: 1300; position: fixed; inset: 0px; z-index: var(--jf-zIndex-drawer); } .MuiDrawer-root[${DRAWER_ATTR}] .MuiBackdrop-root { opacity: 0; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1); } .MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiBackdrop-root { opacity: 1; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1); position: fixed; display: flex; -moz-box-align: center; align-items: center; -moz-box-pack: center; justify-content: center; inset: 0px; background-color: rgba(0, 0, 0, 0.5); z-index: -1; } :where(.MuiDrawer-root[${DRAWER_ATTR}]) .MuiDrawer-paper { transform: translateX(-100%); transition: transform 225ms cubic-bezier(0, 0, 0.2, 1); outline: 0px; background-color: var(--jf-palette-background-paper); color: var(--jf-palette-text-primary); box-shadow: var(--Paper-shadow); background-image: var(--Paper-overlay); overflow-y: auto; display: flex; flex-direction: column; height: 100%; width: 280px; max-width: 100%; flex: 1 0 auto; z-index: var(--jf-zIndex-drawer); position: fixed; top: 0px; left: 0px; } .MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiDrawer-paper { transform: none; } [${DRAWER_ATTR}] .MuiListItem-root.MuiListItem-gutters.css-1ohqk82 { display: flex; -moz-box-pack: start; justify-content: flex-start; -moz-box-align: center; align-items: center; position: relative; text-decoration: none; width: 100%; box-sizing: border-box; text-align: left; } [${DRAWER_ATTR}] .MuiList-root { list-style: none; margin: 0px; padding-right: 0px; padding-left: 0px; position: relative; padding-bottom: 8px; padding-top: calc(0 * var(--jf-spacing)); } [${DRAWER_ATTR}] .MuiTypography-root { margin: 0px; font-family: "Noto Sans", sans-serif; font-weight: 400; font-size: 1rem; line-height: 1.5; color: var(--jf-palette-text-primary); } [${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters { background-color: transparent; outline: 0px; border: 0px; margin: 0px; border-radius: 0px; cursor: pointer; user-select: none; vertical-align: middle; appearance: none; color: inherit; display: flex; -moz-box-flex: 1; flex-grow: 1; -moz-box-pack: start; justify-content: flex-start; -moz-box-align: center; align-items: center; position: relative; text-decoration: none; min-width: 0px; box-sizing: border-box; text-align: left; padding: 8px 16px; transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); } [${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.Mui-selected { background-color: var(--jf-palette-action-selected, rgba(255, 255, 255, 0.08)); } [${DRAWER_ATTR}] .MuiButtonBase-root:first-child { color: var(--jf-palette-action-active); flex-shrink: 0; display: inline-flex; min-width: 36px; } [${DRAWER_ATTR}] .MuiBox-root { height: 2.5rem; } [${DRAWER_ATTR}] .MuiListItemIcon-root:last-child, [${DRAWER_ATTR}] .MuiListItemIcon-root.css-c9a5tz { color: var(--jf-palette-action-active); flex-shrink: 0; display: inline-flex; min-width: 56px; } [${DRAWER_ATTR}] a:hover { background: #4e4e4e !important; } `; } function findDesktopToolbar() { const toolbars = document.querySelectorAll('.MuiToolbar-root.MuiToolbar-dense'); for (const toolbar of toolbars) { if (toolbar.querySelector(`:scope > button[aria-label="Open Menu"]`)) continue; if (toolbar.querySelector(`:scope > [${BTN_ATTR}]`)) return toolbar; const hasFavorites = toolbar.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`); if (hasFavorites) return toolbar; } return null; } function findNativeOpenMenuButton() { return document.querySelector('button[aria-label="Open Menu"]:not([data-kefin-desktop-hamburger])'); } function menuIconSvg(className) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', className || FALLBACK_SVG_CLASS); svg.setAttribute('focusable', 'false'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('data-testid', 'MenuIcon'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z'); svg.appendChild(path); return svg; } function materialSvg(testId, pathD, className) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', className || FALLBACK_SVG_CLASS); svg.setAttribute('focusable', 'false'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('viewBox', '0 0 24 24'); if (testId) svg.setAttribute('data-testid', testId); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', pathD); svg.appendChild(path); return svg; } const ICONS = { home: { testId: 'HomeIcon', d: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z' }, favorite: { testId: 'FavoriteIcon', d: 'm12 21.35-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54z' }, tv: { testId: 'TvIcon', d: 'M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2m0 14H3V5h18z' }, folder: { testId: 'FolderIcon', d: 'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8z' }, queue: { testId: 'QueueIcon', d: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-1 9h-4v4h-2v-4H9V9h4V5h2v4h4z' }, movie: { testId: 'MovieIcon', d: 'm18 4 2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4zm-6.75 11.25L10 18l-1.25-2.75L6 14l2.75-1.25L10 10l1.25 2.75L14 14z' }, music: { testId: 'LibraryMusicIcon', d: 'M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-2 5h-3v5.5c0 1.38-1.12 2.5-2.5 2.5S10 13.88 10 12.5s1.12-2.5 2.5-2.5c.57 0 1.08.19 1.5.5V5h4zM2 6v14c0 1.1.9 2 2 2h14v-2H4V6z' }, livetv: { testId: 'LiveTvIcon', d: 'M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3c-1.1 0-2 .89-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.11-.9-2-2-2m0 14H3V8h18zM9 10v8l7-4z' } }; function createHamburgerButton() { const native = findNativeOpenMenuButton(); let btn; if (native) { btn = native.cloneNode(true); btn.querySelectorAll('.MuiTouchRipple-root').forEach((el) => el.remove()); } else { btn = document.createElement('button'); btn.type = 'button'; btn.className = FALLBACK_BTN_CLASS; btn.tabIndex = 0; btn.setAttribute('aria-label', 'Open Menu'); btn.appendChild(menuIconSvg()); } btn.setAttribute(BTN_ATTR, 'true'); btn.type = 'button'; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openDrawer(); }); return btn; } function ensureHamburgerButton() { ensureStyles(); const toolbar = findDesktopToolbar(); if (!toolbar) return false; let btn = toolbar.querySelector(`[${BTN_ATTR}]`); if (btn) return true; btn = createHamburgerButton(); toolbar.insertBefore(btn, toolbar.firstChild); return true; } function getServerDisplayName() { try { const brand = document.querySelector( `.MuiToolbar-root .MuiStack-root > a[href="#/"], .MuiToolbar-root .MuiStack-root > a[href="#/home"]` ); if (brand) { const text = Array.from(brand.childNodes) .filter((n) => n.nodeType === Node.TEXT_NODE) .map((n) => n.textContent.trim()) .filter(Boolean) .join(' '); if (text) return text; } } catch (_) { /* ignore */ } try { return window.ApiClient?.serverName?.() || window.ApiClient?._serverInfo?.Name || 'Jellyfin'; } catch (_) { return 'Jellyfin'; } } function getServerVersionLabel() { try { const v = window.ApiClient?._serverVersion || window.ApiClient?.serverVersion?.(); if (v) { const m = String(v).match(/^(\d+\.\d+)/); return m ? m[1] : String(v); } } catch (_) { /* ignore */ } return ''; } function getBrandIconSrc() { const img = document.querySelector( `.MuiToolbar-root .MuiStack-root a[href="#/"] img, .MuiToolbar-root .MuiStack-root a[href="#/home"] img` ); return img?.getAttribute('src') || null; } function libraryHref(item) { const serverId = window.ApiClient?.serverId?.() || ''; const id = item.Id; const type = String(item.CollectionType || '').toLowerCase(); switch (type) { case 'movies': return `#/movies?topParentId=${id}&collectionType=movies`; case 'tvshows': return `#/tv?topParentId=${id}&collectionType=tvshows`; case 'music': return `#/music?topParentId=${id}&collectionType=music`; case 'playlists': return `#/playlists?topParentId=${id}&collectionType=playlists`; case 'livetv': return `#/livetv?collectionType=livetv`; case 'boxsets': return `#/list?parentId=${id}&serverId=${serverId}`; default: return `#/list?parentId=${id}&serverId=${serverId}`; } } function libraryIconKey(item) { switch (String(item.CollectionType || '').toLowerCase()) { case 'movies': return 'movie'; case 'tvshows': return 'tv'; case 'music': return 'music'; case 'playlists': return 'queue'; case 'livetv': return 'livetv'; default: return 'folder'; } } async function fetchUserViews() { const now = Date.now(); if (viewsCache && now - viewsCacheAt < 60000) return viewsCache; if (!window.ApiClient?.getUserViews) { WARN('ApiClient.getUserViews not available'); return []; } try { const userId = window.ApiClient.getCurrentUserId(); const result = await window.ApiClient.getUserViews({}, userId); viewsCache = Array.isArray(result?.Items) ? result.Items : []; viewsCacheAt = now; return viewsCache; } catch (e) { ERR('Failed to fetch UserViews', e); return []; } } function getCurrentNavHash() { const hash = window.location.hash || '#/'; return hash.startsWith('#') ? hash : `#${hash}`; } function parseHashParts(hash) { const raw = String(hash || '#/').replace(/^#/, '') || '/'; const qIndex = raw.indexOf('?'); const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || '/'; const query = qIndex >= 0 ? raw.slice(qIndex + 1) : ''; const params = new URLSearchParams(query); return { path: path.startsWith('/') ? path : `/${path}`, params }; } function hrefMatchScore(href, currentHash) { if (!href || /^https?:/i.test(href)) return -1; const targetHash = href.startsWith('#') ? href : `#${href}`; const current = parseHashParts(currentHash); const target = parseHashParts(targetHash); if ( targetHash === currentHash || decodeURIComponent(targetHash) === decodeURIComponent(currentHash) ) { return 1000 + targetHash.length; } if (target.path !== current.path) return -1; for (const [key, value] of target.params.entries()) { if (current.params.get(key) !== value) return -1; } // Prefer more specific links (more query params / longer href) let score = 100 + targetHash.length; score += [...target.params.keys()].length * 10; // Home without tab should not win over Favorites (?tab=1) if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) { return -1; } return score; } function applySelectedState(drawerRoot) { if (!drawerRoot) return; const currentHash = getCurrentNavHash(); const links = Array.from(drawerRoot.querySelectorAll('.MuiDrawer-paper a[href]')); let best = null; let bestScore = -1; links.forEach((a) => { a.classList.remove('Mui-selected'); a.removeAttribute('aria-current'); const score = hrefMatchScore(a.getAttribute('href') || '', currentHash); if (score > bestScore) { bestScore = score; best = a; } }); if (best && bestScore >= 0) { best.classList.add('Mui-selected'); best.setAttribute('aria-current', 'page'); } } function createListItem({ href, label, iconKey, brand, secondary, imgSrc }) { const li = document.createElement('li'); li.className = FALLBACK_LI_CLASS; const a = document.createElement('a'); a.className = FALLBACK_A_CLASS; a.tabIndex = 0; a.href = href; const iconWrap = document.createElement('div'); iconWrap.className = brand ? FALLBACK_BRAND_ICON_WRAP : FALLBACK_ICON_WRAP; if (imgSrc) { const img = document.createElement('img'); img.className = 'MuiBox-root css-1j3tjfb'; img.src = imgSrc; img.alt = ''; iconWrap.appendChild(img); } else if (iconKey && ICONS[iconKey]) { iconWrap.appendChild(materialSvg(ICONS[iconKey].testId, ICONS[iconKey].d)); } const textWrap = document.createElement('div'); if (brand) { textWrap.className = FALLBACK_BRAND_TEXT_WRAP; const h6 = document.createElement('h6'); h6.className = FALLBACK_BRAND_PRIMARY; h6.textContent = label; textWrap.appendChild(h6); if (secondary) { const p = document.createElement('p'); p.className = FALLBACK_BRAND_SECONDARY; p.textContent = secondary; textWrap.appendChild(p); } } else { textWrap.className = FALLBACK_TEXT_WRAP; const span = document.createElement('span'); span.className = FALLBACK_TEXT_PRIMARY; span.textContent = label; textWrap.appendChild(span); } a.appendChild(iconWrap); a.appendChild(textWrap); a.addEventListener('click', () => { // Allow navigation; close drawer shortly after setTimeout(closeDrawer, 0); }); li.appendChild(a); return li; } function buildDrawerShell(mainList, librariesList) { const root = document.createElement('div'); root.setAttribute('role', 'presentation'); root.className = 'MuiDrawer-root MuiDrawer-anchorLeft MuiDrawer-modal MuiModal-root css-k3j2mn'; root.setAttribute(DRAWER_ATTR, 'true'); const backdrop = document.createElement('div'); backdrop.setAttribute('aria-hidden', 'true'); backdrop.className = 'MuiBackdrop-root MuiModal-backdrop css-14dl35y'; backdrop.addEventListener('click', closeDrawer); const sentinelStart = document.createElement('div'); sentinelStart.tabIndex = 0; sentinelStart.setAttribute('data-testid', 'sentinelStart'); const paper = document.createElement('div'); paper.className = 'MuiPaper-root MuiPaper-elevation MuiPaper-elevation16 MuiDrawer-paper MuiDrawer-paperAnchorLeft css-epnusv'; paper.tabIndex = -1; paper.style.setProperty('--Paper-shadow', 'var(--jf-shadows-16)'); paper.style.setProperty('--Paper-overlay', 'var(--jf-overlays-16)'); const box = document.createElement('div'); box.className = 'MuiBox-root css-0'; box.setAttribute('role', 'presentation'); box.appendChild(mainList); const hr = document.createElement('hr'); hr.className = 'MuiDivider-root MuiDivider-fullWidth css-14093q0'; box.appendChild(hr); box.appendChild(librariesList); paper.appendChild(box); const sentinelEnd = document.createElement('div'); sentinelEnd.tabIndex = 0; sentinelEnd.setAttribute('data-testid', 'sentinelEnd'); root.appendChild(backdrop); root.appendChild(sentinelStart); root.appendChild(paper); root.appendChild(sentinelEnd); return root; } async function buildDrawer() { const mainList = document.createElement('ul'); mainList.className = 'MuiList-root MuiList-padding css-hbss32'; const serverName = getServerDisplayName(); const version = getServerVersionLabel(); const brandSrc = getBrandIconSrc(); mainList.appendChild( createListItem({ href: '#/', label: serverName, brand: true, secondary: version || undefined, imgSrc: brandSrc }) ); mainList.appendChild( createListItem({ href: '#/home', label: 'Home', iconKey: 'home' }) ); mainList.appendChild( createListItem({ href: FAVORITES_HREF, label: 'Favorites', iconKey: 'favorite' }) ); const librariesList = document.createElement('ul'); librariesList.className = 'MuiList-root MuiList-padding MuiList-subheader css-q9gyaw'; librariesList.setAttribute('aria-labelledby', 'libraries-subheader'); const subheader = document.createElement('div'); subheader.className = 'MuiListSubheader-root MuiListSubheader-gutters MuiListSubheader-sticky css-18qy2xf'; subheader.id = 'libraries-subheader'; subheader.textContent = 'Libraries'; librariesList.appendChild(subheader); const views = await fetchUserViews(); views.forEach((item) => { if (!item?.Id || !item?.Name) return; librariesList.appendChild( createListItem({ href: libraryHref(item), label: item.Name, iconKey: libraryIconKey(item) }) ); }); return buildDrawerShell(mainList, librariesList); } function closeDrawer() { if (escapeHandler) { document.removeEventListener('keydown', escapeHandler); escapeHandler = null; } const drawer = openDrawerEl; openDrawerEl = null; document.body.style.removeProperty('overflow'); if (!drawer) return; drawer.removeAttribute('data-kefin-drawer-open'); const paper = drawer.querySelector('.MuiDrawer-paper'); let finished = false; const finish = () => { if (finished) return; finished = true; drawer.remove(); }; if (paper) { paper.addEventListener('transitionend', (e) => { if (e.target === paper && e.propertyName === 'transform') finish(); }, { once: true }); } setTimeout(finish, 280); } async function openDrawer() { if (openDrawerEl) { closeDrawer(); return; } try { const drawer = await buildDrawer(); drawer.removeAttribute('data-kefin-drawer-open'); document.body.appendChild(drawer); openDrawerEl = drawer; escapeHandler = (e) => { if (e.key === 'Escape') closeDrawer(); }; document.addEventListener('keydown', escapeHandler); applySelectedState(drawer); // Force closed paint, then open so translateX animates drawer.offsetWidth; requestAnimationFrame(() => { drawer.setAttribute('data-kefin-drawer-open', 'true'); applySelectedState(drawer); setTimeout(() => applySelectedState(drawer), 100); }); } catch (e) { ERR('Failed to open drawer', e); closeDrawer(); } } function parseVersionParts(version) { return String(version || '') .split(/[.+-]/) .filter(Boolean) .map((p) => { const n = parseInt(p, 10); return Number.isFinite(n) ? n : 0; }); } /** @returns {boolean|null} true if >= 12.0.0, false if older, null if unknown */ function isAppVersionAtLeast12() { const version = window.ApiClient?._appVersion; if (!version) return null; const parts = parseVersionParts(version); const major = parts[0] ?? 0; const minor = parts[1] ?? 0; const patch = parts[2] ?? 0; if (major > 12) return true; if (major < 12) return false; if (minor > 0) return true; if (minor < 0) return false; return patch >= 0; } async function waitForAppVersion(timeoutMs = 10000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const ready = isAppVersionAtLeast12(); if (ready !== null) return ready; await new Promise((r) => setTimeout(r, 250)); } return isAppVersionAtLeast12(); } function ensureToolbarObserver() { if (toolbarObserver || typeof MutationObserver === 'undefined' || !document.body) return; let scheduled = false; toolbarObserver = new MutationObserver(() => { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; ensureHamburgerButton(); }); }); toolbarObserver.observe(document.body, { childList: true, subtree: true }); } function start() { ensureStyles(); ensureHamburgerButton(); ensureToolbarObserver(); // Retry briefly for late toolbar mount let tries = 0; const poll = setInterval(() => { tries += 1; if (ensureHamburgerButton() || tries >= 40) clearInterval(poll); }, 250); LOG('Desktop hamburger menu initialized'); } async function boot() { const ok = await waitForAppVersion(); // v12+ modern desktop UI only if (ok !== true) { LOG( 'Skipping desktop hamburger menu; requires ApiClient._appVersion >= 12.0.0', window.ApiClient?._appVersion || '(unavailable)' ); return; } start(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { boot(); }); } else { boot(); } })();