Files

738 lines
25 KiB
JavaScript

// KefinTweaks Desktop Hamburger Menu
// Exposes a desktop Open Menu button + left drawer matching the native mobile nav.
// Runs when the modern UI shell is present. 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}] {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: var(--jf-zIndex-drawer, 1300);
}
.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;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: -1;
}
.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, 1300);
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 isModernUI() {
return window.KefinTweaksUtils?.isModernUI?.() === true;
}
async function waitForModernUI(timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (isModernUI()) return true;
await new Promise((r) => setTimeout(r, 250));
}
return isModernUI();
}
function armToolbarPoll() {
ensureHamburgerButton();
let tries = 0;
const poll = setInterval(() => {
tries += 1;
if (ensureHamburgerButton() || tries >= 40) clearInterval(poll);
}, 250);
}
function start() {
ensureStyles();
armToolbarPoll();
const utils = window.KefinTweaksUtils;
if (utils?.onViewPage) {
// Toolbar remounts across navigations — short poll per view, no permanent body MO
utils.onViewPage(() => {
armToolbarPoll();
});
} else if (utils?.onBodyMutation) {
let scheduled = false;
toolbarObserver = utils.onBodyMutation('hamburger-toolbar', () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
ensureHamburgerButton();
});
});
}
LOG('Desktop hamburger menu initialized');
}
async function boot() {
const ok = await waitForModernUI();
if (!ok) {
LOG('Skipping desktop hamburger menu; modern UI not detected');
return;
}
start();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
boot();
});
} else {
boot();
}
})();