// KefinTweaks Utilities // Provides standardized functionality for hooking into Emby.Page.onViewShow // while maintaining original Jellyfin functionality (function() { 'use strict'; // Common logging function const LOG = (...args) => console.log('[KefinTweaks Utils]', ...args); const WARN = (...args) => console.warn('[KefinTweaks Utils]', ...args); const ERR = (...args) => console.error('[KefinTweaks Utils]', ...args); LOG('Initializing'); // Store the original onViewShow function let originalOnViewShow = null; const state = { previousHash: null, previousHref: null, hookAttempts: 0, maxHookAttempts: 60, historyBridgeInstalled: false, embyViewHookInstalled: false, } /** * Notify onViewPage handlers (shared by Emby onViewShow + History bridge). * No cross-source dedupe: history and Emby each always invoke handlers for the same * navigation. Handlers may run 2–3 times (pushState/hashchange/onViewShow), sometimes * before the new page DOM is visible; they must be idempotent. On a repeat call for the * same URL, previousHash often equals the current hash. */ function notifyViewChange(view, element) { const hash = window.location.hash; const href = window.location.href; const previousHash = state.previousHash; notifyHandlers(view ?? getCurrentView(), element || document, hash, previousHash); state.previousHash = hash; state.previousHref = href; } function installHistoryViewBridge() { if (state.historyBridgeInstalled) return; state.historyBridgeInstalled = true; const fire = () => { notifyViewChange(getCurrentView(), document); }; const wrap = (method) => { const orig = history[method]; if (typeof orig !== 'function' || orig.__kefinViewBridge) return; function wrapped(...args) { const ret = orig.apply(this, args); queueMicrotask(fire); return ret; } wrapped.__kefinViewBridge = true; history[method] = wrapped; }; wrap('pushState'); wrap('replaceState'); window.addEventListener('popstate', fire); window.addEventListener('hashchange', fire); LOG('Installed History API view bridge (pushState/replaceState/popstate/hashchange)'); } // Initialize the utils by hooking into Emby.Page.onViewShow async function initialize() { installHistoryViewBridge(); // Store the original onViewShow if it exists if (window.Emby && window.Emby.Page && window.Emby.Page.onViewShow && !state.embyViewHookInstalled) { originalOnViewShow = window.Emby.Page.onViewShow; LOG('Stored original Emby.Page.onViewShow'); window.Emby.Page.onViewShow = function (...args) { if (originalOnViewShow) { try { originalOnViewShow.apply(this, args); } catch (err) { ERR('Error in original onViewShow handler:', err); } } const view = getCurrentView() ?? args[0]; notifyViewChange(view, args[1]); }; state.embyViewHookInstalled = true; LOG('Hooked into Emby.Page.onViewShow'); } else if (!window.Emby || !window.Emby.Page) { WARN('Emby.Page.onViewShow not found - using History bridge; retrying Emby hook'); state.hookAttempts++; if (state.hookAttempts < state.maxHookAttempts) { setTimeout(initialize, 1000); } else { WARN('Failed to hook into Emby.Page.onViewShow after ' + state.maxHookAttempts + ' attempts'); } } } /** * Build MediaBrowser Authorization header * @returns {string} - Authorization header */ function getAuthHeader() { const token = ApiClient.accessToken(); const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web'; const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser'); const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : ''; const version = ApiClient._appVersion || ApiClient._serverVersion || ''; const parts = [ `Client="${encodeURIComponent(client)}"`, `Device="${encodeURIComponent(device)}"`, `DeviceId="${encodeURIComponent(deviceId)}"`, `Version="${encodeURIComponent(version)}"`, `Token="${encodeURIComponent(token)}"` ]; return `MediaBrowser ${parts.join(', ')}`; } // Array to store registered handlers const handlers = []; // Cache item per page view to avoid multiple fetches let cachedItem = null; let cachedItemId = null; let fetchInProgress = null; // Track the promise of an in-progress fetch let fetchItemId = null; // Track which item ID is currently being fetched /** * Register a callback to be called when page view changes * @param {Function} callback - Function to call when page view changes * @param {Object} options - Options for the handler */ function onViewPage(callback, options = {}) { if (typeof callback !== 'function') { ERR('Callback must be a function'); return; } const handlerConfig = { callback, options: { pages: [], // Specific pages to watch (empty = all pages) ...options } }; handlers.push(handlerConfig); LOG(`Registered onViewPage handler (total: ${handlers.length})`); // Test if this is causing issues const currentView = getCurrentView(); if (currentView && shouldCallHandler(handlerConfig, currentView)) { try { // Pass the promise (not awaited) for consistency with notifyHandlers const itemPromise = getCurrentItem(); callback(currentView, document, window.location.hash, itemPromise); } catch (err) { ERR('Error in immediate handler call:', err); } } // Return a function to unregister this handler return () => { const index = handlers.indexOf(handlerConfig); if (index !== -1) { handlers.splice(index, 1); LOG(`Unregistered onViewPage handler (remaining: ${handlers.length})`); } }; } /** * Get item ID from URL parameters * Works with both hash-based routing and traditional query strings * @returns {string|null} Item ID or null if not found */ function getItemIdFromUrl() { try { const match = window.location.href.match(/[\?&]id=([^&]+)/); return match ? match[1] : null; } catch { return null; } } /** * Fetch item by ID * @param {string} itemId - The item ID * @returns {Promise} The item object or null if not found */ async function fetchItemById(itemId) { if (!itemId || !window.ApiClient || !window.ApiClient.getItem || !window.ApiClient.getCurrentUserId || !window.ApiClient._loggedIn) { return null; } try { const userId = window.ApiClient.getCurrentUserId(); const item = await window.ApiClient.getItem(userId, itemId); return item; } catch (error) { WARN('Error fetching item:', error); return null; } } /** * Get current item from URL, using cache if available * @returns {Promise} The item object or null if not found */ async function getCurrentItem() { const itemId = getItemIdFromUrl(); // If no item ID, return null if (!itemId) { return null; } // If we have a cached item for this ID, return it if (cachedItem && cachedItemId === itemId) { return cachedItem; } // If a fetch is already in progress for this item, wait for it if (fetchInProgress && fetchItemId === itemId) { LOG('Fetch already in progress for this item, waiting for it to complete'); return await fetchInProgress; } // Start a new fetch and store the promise fetchItemId = itemId; fetchInProgress = (async () => { try { LOG('Fetching item:', itemId); const item = await fetchItemById(itemId); cachedItem = item; cachedItemId = itemId; return item; } finally { // Clear the in-progress flag when done (only if still for this item) if (fetchItemId === itemId) { fetchInProgress = null; fetchItemId = null; } } })(); LOG('Fetch in progress for item:', itemId); return await fetchInProgress; } /** * Notify all registered handlers of a view change * @param {string} view - The view name * @param {Element} element - The view element */ function notifyHandlers(view, element, hash, previousHash) { // Clear cache when view changes to ensure fresh data const currentItemId = getItemIdFromUrl(); if (cachedItemId !== currentItemId) { LOG('Clearing cache for new item:', currentItemId); cachedItem = null; cachedItemId = null; fetchInProgress = null; // Clear any in-progress fetch for previous item fetchItemId = null; } // Get the item promise once - will be shared across all handlers // The promise is cached/fetched by getCurrentItem(), ensuring only one API call const itemPromise = getCurrentItem(); handlers.forEach((config) => { if (shouldCallHandler(config, view)) { try { // Pass the promise as third parameter - handlers can await if needed config.callback(view, element, hash, itemPromise, previousHash); } catch (err) { ERR('Error in onViewPage handler:', err); } } }); } /** * Check if a handler should be called for a given view * @param {Object} config - Handler configuration * @param {string} view - The view name * @returns {boolean} Whether the handler should be called */ function shouldCallHandler(config, view) { const { pages, triggerOnSameHash = true } = config.options; if (state.previousHash === window.location.hash && !triggerOnSameHash) { return false; } // If no specific pages are specified, call for all pages if (pages.length === 0) return true; // Get current URL hash to determine actual page const currentUrl = window.location.hash; // Check if the current URL matches any of the specified pages return pages.some(page => { if (typeof page === 'string') { // Check if URL hash contains the page pattern return currentUrl.includes(`/${page}.html`) || currentUrl.includes(`/${page}`) || currentUrl.includes(page); } else if (page instanceof RegExp) { return page.test(currentUrl); } return false; }); } /** * Get the current view name * @returns {string|null} Current view name or null if not available */ function getCurrentView() { const hash = window.location.hash; if (hash) { // Extract page name from hash - match part after #/ and before ? or / // This handles both .html pages (old) and pages without .html (Jellyfin 10.11+) const pageMatch = hash.match(/#\/([^\/\?]+)/); if (pageMatch) { return pageMatch[1]; } } return null; } /** * Get the number of registered handlers * @returns {number} Number of registered handlers */ function getHandlerCount() { return handlers.length; } /** * Clear all handlers */ function clearHandlers() { handlers.length = 0; LOG('Cleared all onViewPage handlers'); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } // ----- Custom menu links (placements: side / top main|right / userMenu) ----- const customMenuLinkRegistry = []; let customMenuLinkObserver = null; let customMenuLinkReapplyDepth = 0; const FAVORITES_HREF = '#/home?tab=1'; const DEFAULT_CUSTOM_MENU_SELECTOR = '.customMenuOptions'; const ADMIN_MENU_SELECTOR = '.adminMenuOptions'; const USER_MENU_SELECTOR = '.userMenuOptions'; const KEFIN_CUSTOM_MENU_OWNED_SEL = [ '[data-kefin-custom-menu-link]', '[data-kefin-custom-menu-top-link]', '[data-kefin-custom-menu-top-right]', '[data-kefin-custom-menu-more]', '[data-kefin-custom-overflow-menu]', '.kefin-custom-menu-top-divider' ].join(', '); function withCustomMenuReapplySuppressed(fn) { customMenuLinkReapplyDepth += 1; try { return fn(); } finally { customMenuLinkReapplyDepth -= 1; } } function findByDataAttr(root, attr, value) { if (!root) return null; return Array.from(root.querySelectorAll(`[${attr}]`)).find( (el) => el.getAttribute(attr) === value ) || null; } function customMenuLinkKey(name, url, action) { const n = String(name || '').trim(); const u = String(url || '').trim(); if (u) return `${n}||${u}`; const a = String(action || '').trim(); return `${n}||action:${a}`; } function entryKey(entry) { return customMenuLinkKey(entry?.name, entry?.url, entry?.action); } function normalizeTopNavigation(value) { const v = String(value || 'none').toLowerCase().trim(); if (v === 'main' || v === 'right') return v; return 'none'; } function parseVersionParts(version) { return String(version || '') .split(/[.+-]/) .filter(Boolean) .map((p) => { const n = parseInt(p, 10); return Number.isFinite(n) ? n : 0; }); } function isJellyfinWebAtLeast12() { const version = window.ApiClient?._appVersion; if (version) { const parts = parseVersionParts(version); const major = parts[0] ?? 0; return major >= 12; } return !!( document.querySelector('.MuiDrawer-paper') || document.getElementById('app-user-menu') || document.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`) ); } function resolveWindowAction(path) { const parts = String(path || '').split('.').filter(Boolean); let cur = window; for (const part of parts) { if (cur == null) return null; cur = cur[part]; } return typeof cur === 'function' ? cur : null; } function getCustomMenuLinkHref(entry) { if (entry?.action && String(entry.action).trim()) return '#'; return String(entry?.url || '').trim() || '#'; } function bindCustomMenuLinkActivation(el, entry) { if (!el || el.__kefinCmlBound) return; const actionPath = entry?.action && String(entry.action).trim(); if (!actionPath) return; el.__kefinCmlBound = true; el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const fn = resolveWindowAction(actionPath); if (!fn) { WARN(`Custom menu link action not found or not a function: ${actionPath}`); return; } try { fn.call(null, e); } catch (err) { ERR(`Error running custom menu link action ${actionPath}:`, err); } }); } function parseCustomMenuLinkOrder(value) { if (value == null || value === '') return undefined; const n = Number(value); return Number.isFinite(n) ? n : undefined; } function parseCustomMenuLinkOptions(fifthArg) { if (fifthArg && typeof fifthArg === 'object' && !Array.isArray(fifthArg)) { const action = fifthArg.action != null ? String(fifthArg.action).trim() : ''; let containerSelector = DEFAULT_CUSTOM_MENU_SELECTOR; if (fifthArg.containerSelector) { containerSelector = fifthArg.containerSelector; } else if (fifthArg.isAdminLink) { containerSelector = ADMIN_MENU_SELECTOR; } else if (fifthArg.isUserLink) { containerSelector = USER_MENU_SELECTOR; } return { containerSelector: containerSelector, collapsed: !!fifthArg.collapsed, sideMenu: fifthArg.sideMenu !== false, topNavigation: normalizeTopNavigation(fifthArg.topNavigation), userMenu: !!fifthArg.userMenu, isAdminLink: !!fifthArg.isAdminLink, isUserLink: !!fifthArg.isUserLink, action: action || '', order: parseCustomMenuLinkOrder(fifthArg.order) }; } return { containerSelector: typeof fifthArg === 'string' ? fifthArg : DEFAULT_CUSTOM_MENU_SELECTOR, collapsed: false, sideMenu: true, topNavigation: 'none', userMenu: false, isAdminLink: false, isUserLink: false, action: '', order: undefined }; } async function isCurrentUserAdmin() { try { if (window.apiHelper && typeof window.apiHelper.isAdmin === 'function') { return !!(await window.apiHelper.isAdmin()); } if (window.ApiClient && typeof window.ApiClient.getCurrentUser === 'function') { const user = await window.ApiClient.getCurrentUser(); return !!(user && user.Policy && user.Policy.IsAdministrator === true); } } catch (err) { WARN('Could not determine admin status for custom menu link:', err); } return false; } function upsertCustomMenuLinkRegistry(entry) { const key = entryKey(entry); const idx = customMenuLinkRegistry.findIndex(e => entryKey(e) === key); if (idx >= 0) { customMenuLinkRegistry[idx] = entry; } else { customMenuLinkRegistry.push(entry); } } function findFavoritesDrawerLi() { const drawer = document.querySelector('.MuiDrawer-paper'); if (!drawer) return null; const fav = drawer.querySelector(`a[href="${FAVORITES_HREF}"]`); if (!fav) return null; return fav.closest('li') || fav.parentElement; } function findTopNavStack() { const fav = document.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`); if (!fav) return null; return fav.closest('.MuiStack-root'); } function applyOpenInNewTab(anchor, openInNewTab) { if (!openInNewTab) return; anchor.setAttribute('rel', 'noopener noreferrer'); anchor.setAttribute('target', '_blank'); } function getNativeTopNavAnchor(stack) { if (!stack) return null; const fav = stack.querySelector(`a[href="${FAVORITES_HREF}"]`); if (fav && !fav.hasAttribute('data-kefin-custom-menu-top-link')) return fav; return Array.from(stack.querySelectorAll(':scope > a')).find( (a) => !a.hasAttribute('data-kefin-custom-menu-top-link') && !a.hasAttribute('data-kefin-custom-menu-more') ) || null; } function sanitizeClonedNavItem(root) { if (!root) return; const clear = (el) => { if (!el || !el.classList) return; el.classList.remove('Mui-selected', 'Mui-focusVisible', 'selected'); el.removeAttribute('aria-current'); el.removeAttribute('aria-selected'); }; clear(root); root.querySelectorAll('.Mui-selected, .Mui-focusVisible, [aria-current], [aria-selected]') .forEach(clear); } function applyMaterialIcon(root, iconName) { const icon = iconName || 'link'; const existingMaterial = root.querySelector('.material-icons'); if (existingMaterial) { existingMaterial.textContent = icon; return true; } const svg = root.querySelector('svg'); if (svg) { const span = document.createElement('span'); const svgClass = svg.getAttribute('class') || ''; span.className = svgClass; if (!span.classList.contains('material-icons')) { span.classList.add('material-icons'); } span.setAttribute('aria-hidden', svg.getAttribute('aria-hidden') || 'true'); span.textContent = icon; svg.replaceWith(span); return true; } return false; } function applyAnchorLabel(anchor, name) { const primary = anchor.querySelector( '.MuiListItemText-primary, .MuiTypography-root, .navMenuOptionText' ); if (primary) { primary.textContent = name; return; } const textNodes = []; const walker = document.createTreeWalker(anchor, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const parent = node.parentElement; if (!parent) continue; if (parent.closest('svg, .material-icons')) continue; if (!String(node.textContent || '').trim()) continue; textNodes.push(node); } if (!textNodes.length) { anchor.appendChild(document.createTextNode(name)); return; } textNodes[textNodes.length - 1].textContent = name; for (let i = 0; i < textNodes.length - 1; i++) { textNodes[i].textContent = ''; } } function retargetClonedAnchor(anchor, entry, dataAttr, key) { if (!anchor || !entry) return false; sanitizeClonedNavItem(anchor); anchor.href = getCustomMenuLinkHref(entry); anchor.setAttribute(dataAttr, key); anchor.removeAttribute('target'); anchor.removeAttribute('rel'); if (!entry.action) { applyOpenInNewTab(anchor, entry.openInNewTab); } applyMaterialIcon(anchor, entry.icon); applyAnchorLabel(anchor, entry.name); bindCustomMenuLinkActivation(anchor, entry); return true; } /** * Clone Favorites drawer
  • and retarget for a custom link. * @returns {HTMLElement|null} */ function createMuiDrawerListItem(entry, referenceLi) { if (!referenceLi || !entry) return null; const referenceAnchor = referenceLi.querySelector('a'); if (!referenceAnchor) return null; const key = entryKey(entry); const li = referenceLi.cloneNode(true); sanitizeClonedNavItem(li); li.setAttribute('data-kefin-custom-menu-link', key); const a = li.querySelector('a'); if (!retargetClonedAnchor(a, entry, 'data-kefin-custom-menu-link', key)) { return null; } return li; } function injectIntoV12Drawer(entry) { const favLi = findFavoritesDrawerLi(); if (!favLi || !favLi.parentElement) return false; const key = entryKey(entry); if (findByDataAttr(favLi.parentElement, 'data-kefin-custom-menu-link', key)) { return true; } let insertAfter = favLi; let next = favLi.nextElementSibling; while (next && next.querySelector?.('[data-kefin-custom-menu-link]')) { insertAfter = next; next = next.nextElementSibling; } const li = createMuiDrawerListItem(entry, favLi); if (!li) return false; insertAfter.insertAdjacentElement('afterend', li); return true; } /** * Clone a native top-nav and retarget for a custom link. * @returns {HTMLElement|null} */ function createMuiTopNavButton(entry, referenceAnchor) { if (!referenceAnchor || !entry) return null; const key = entryKey(entry); const a = referenceAnchor.cloneNode(true); if (!retargetClonedAnchor(a, entry, 'data-kefin-custom-menu-top-link', key)) { return null; } a.classList.add('kefin-custom-menu-top-link'); return a; } function ensureTopNavDivider(stack) { let divider = stack.querySelector('[data-kefin-custom-menu-top-divider]'); if (divider) return divider; divider = document.createElement('div'); divider.className = 'kefin-custom-menu-top-divider'; divider.setAttribute('data-kefin-custom-menu-top-divider', 'true'); divider.setAttribute('role', 'separator'); divider.setAttribute('aria-hidden', 'true'); const anchors = Array.from(stack.querySelectorAll(':scope > a')).filter( a => !a.hasAttribute('data-kefin-custom-menu-top-link') && !a.hasAttribute('data-kefin-custom-menu-more') ); const lastNative = anchors[anchors.length - 1]; if (lastNative) { lastNative.insertAdjacentElement('afterend', divider); } else { stack.appendChild(divider); } return divider; } const USER_VIEW_OVERFLOW_MENU_ID = 'user-view-overflow-menu'; function findNativeTopNavMoreButton(stack) { if (!stack) return null; const byAria = Array.from( stack.querySelectorAll(`button[aria-controls="${USER_VIEW_OVERFLOW_MENU_ID}"]`) ).find((btn) => !btn.hasAttribute('data-kefin-custom-menu-more')); if (byAria) return byAria; return Array.from(stack.querySelectorAll(':scope > button')).find( (btn) => !btn.hasAttribute('data-kefin-custom-menu-more') && /more/i.test((btn.textContent || '').trim()) ) || null; } function findTopNavMoreButton(stack) { return findNativeTopNavMoreButton(stack) || stack?.querySelector('[data-kefin-custom-menu-more]') || null; } function getUserViewOverflowMenu() { return document.getElementById(USER_VIEW_OVERFLOW_MENU_ID); } function getUserViewOverflowMenuList(menuRoot = getUserViewOverflowMenu()) { if (!menuRoot) return null; return menuRoot.querySelector('ul[role="menu"]'); } function isUserViewOverflowMenuOpen(menuRoot = getUserViewOverflowMenu()) { return !!(menuRoot && !menuRoot.classList.contains('MuiModal-hidden')); } function closeUserViewOverflowMenu() { const menu = getUserViewOverflowMenu(); if (!menu) return; menu.classList.add('MuiModal-hidden'); const kefinMore = document.querySelector('[data-kefin-custom-menu-more]'); if (kefinMore) kefinMore.setAttribute('aria-expanded', 'false'); } function openUserViewOverflowMenu(anchorBtn) { const menu = ensureUserViewOverflowMenu(); if (!menu) return; menu.classList.remove('MuiModal-hidden'); const paper = menu.querySelector('.MuiPopover-paper, .MuiMenu-paper'); if (paper && anchorBtn) { const rect = anchorBtn.getBoundingClientRect(); paper.style.top = `${Math.round(rect.bottom + 4)}px`; paper.style.left = `${Math.round(rect.left)}px`; paper.style.transformOrigin = '0px 0px 0px'; } if (anchorBtn?.hasAttribute('data-kefin-custom-menu-more')) { anchorBtn.setAttribute('aria-expanded', 'true'); } } function ensureUserViewOverflowMenu() { let menu = getUserViewOverflowMenu(); if (menu) return menu; menu = document.createElement('div'); menu.id = USER_VIEW_OVERFLOW_MENU_ID; menu.setAttribute('role', 'presentation'); menu.className = 'MuiPopover-root MuiMenu-root MuiModal-root MuiModal-hidden'; menu.setAttribute('data-kefin-custom-overflow-menu', 'true'); const backdrop = document.createElement('div'); backdrop.setAttribute('aria-hidden', 'true'); backdrop.className = 'MuiBackdrop-root MuiBackdrop-invisible MuiModal-backdrop'; 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-rounded MuiPaper-elevation8 MuiPopover-paper MuiMenu-paper'; paper.tabIndex = -1; const list = document.createElement('ul'); list.className = 'MuiList-root MuiList-padding MuiMenu-list'; list.setAttribute('role', 'menu'); list.tabIndex = -1; paper.appendChild(list); const sentinelEnd = document.createElement('div'); sentinelEnd.tabIndex = 0; sentinelEnd.setAttribute('data-testid', 'sentinelEnd'); menu.appendChild(backdrop); menu.appendChild(sentinelStart); menu.appendChild(paper); menu.appendChild(sentinelEnd); document.body.appendChild(menu); backdrop.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); closeUserViewOverflowMenu(); }); return menu; } function bindKefinMoreButtonToggle(moreBtn) { if (!moreBtn || moreBtn.__kefinMoreToggleBound) return; moreBtn.__kefinMoreToggleBound = true; moreBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const menu = ensureUserViewOverflowMenu(); if (isUserViewOverflowMenuOpen(menu)) { closeUserViewOverflowMenu(); } else { openUserViewOverflowMenu(moreBtn); } }); if (!window.__kefinCustomMenuMoreDocClickBound) { window.__kefinCustomMenuMoreDocClickBound = true; document.addEventListener('click', (e) => { const menu = getUserViewOverflowMenu(); if (!menu || !menu.hasAttribute('data-kefin-custom-overflow-menu')) return; if (!isUserViewOverflowMenuOpen(menu)) return; const btn = document.querySelector('[data-kefin-custom-menu-more]'); if (menu.contains(e.target) || (btn && btn.contains(e.target))) return; closeUserViewOverflowMenu(); }); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; const menu = getUserViewOverflowMenu(); if (!menu || !menu.hasAttribute('data-kefin-custom-overflow-menu')) return; if (!isUserViewOverflowMenuOpen(menu)) return; closeUserViewOverflowMenu(); }); } } function createKefinTopNavMoreButton(stack) { // Prefer class list from Favorites top-nav link (includes emotion css-* tokens). const favAnchor = (stack && stack.querySelector(`a[href="${FAVORITES_HREF}"]`)) || document.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`) || getNativeTopNavAnchor(stack); const moreBtn = document.createElement('button'); moreBtn.type = 'button'; moreBtn.tabIndex = 0; if (favAnchor?.className) { moreBtn.className = favAnchor.className; sanitizeClonedNavItem(moreBtn); } else { moreBtn.className = 'MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textInherit MuiButton-sizeMedium MuiButton-textSizeMedium MuiButton-colorInherit'; } moreBtn.setAttribute('data-kefin-custom-menu-more', 'true'); moreBtn.setAttribute('aria-controls', USER_VIEW_OVERFLOW_MENU_ID); moreBtn.setAttribute('aria-haspopup', 'true'); moreBtn.setAttribute('aria-expanded', 'false'); moreBtn.appendChild(document.createTextNode('More')); const endIcon = document.createElement('span'); endIcon.className = 'MuiButton-icon MuiButton-endIcon MuiButton-iconSizeMedium'; endIcon.innerHTML = ''; moreBtn.appendChild(endIcon); const ripple = document.createElement('span'); ripple.className = 'MuiTouchRipple-root'; moreBtn.appendChild(ripple); return moreBtn; } function removeLegacyKefinMorePopover() { const legacy = document.getElementById('kefin-custom-menu-more-popover'); if (legacy) legacy.remove(); } function ensureTopNavMoreButton(stack, divider) { removeLegacyKefinMorePopover(); ensureCustomMenuLinkStyles(); const nativeMore = findNativeTopNavMoreButton(stack); if (nativeMore) { // Native React More owns open/close and usually mounts the menu on demand. return nativeMore; } ensureUserViewOverflowMenu(); let moreBtn = stack.querySelector('[data-kefin-custom-menu-more]'); if (moreBtn) { bindKefinMoreButtonToggle(moreBtn); return moreBtn; } moreBtn = createKefinTopNavMoreButton(stack); bindKefinMoreButtonToggle(moreBtn); if (divider) { divider.insertAdjacentElement('beforebegin', moreBtn); } else { stack.appendChild(moreBtn); } return moreBtn; } function createOverflowMenuItem(entry, menuList, stack) { const key = entryKey(entry); const nativeItem = menuList.querySelector('a[role="menuitem"]:not([data-kefin-custom-menu-top-link])'); if (nativeItem) { const item = nativeItem.cloneNode(true); if (!retargetClonedAnchor(item, entry, 'data-kefin-custom-menu-top-link', key)) { return null; } item.addEventListener('click', () => { closeUserViewOverflowMenu(); }); return item; } const stackRef = getNativeTopNavAnchor(stack); if (stackRef) { const item = stackRef.cloneNode(true); if (!retargetClonedAnchor(item, entry, 'data-kefin-custom-menu-top-link', key)) { return null; } item.setAttribute('role', 'menuitem'); item.classList.add('MuiMenuItem-root', 'MuiMenuItem-gutters'); item.addEventListener('click', () => { closeUserViewOverflowMenu(); }); return item; } const item = document.createElement('a'); item.className = 'MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters'; item.setAttribute('role', 'menuitem'); item.tabIndex = -1; item.href = getCustomMenuLinkHref(entry); item.setAttribute('data-kefin-custom-menu-top-link', key); if (!entry.action) { applyOpenInNewTab(item, entry.openInNewTab); } const icon = document.createElement('div'); icon.className = 'MuiListItemIcon-root'; const iconSpan = document.createElement('span'); iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium'; iconSpan.setAttribute('aria-hidden', 'true'); iconSpan.textContent = entry.icon || 'link'; icon.appendChild(iconSpan); const text = document.createElement('div'); text.className = 'MuiListItemText-root'; const span = document.createElement('span'); span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary'; span.textContent = entry.name; text.appendChild(span); item.appendChild(icon); item.appendChild(text); bindCustomMenuLinkActivation(item, entry); item.addEventListener('click', () => { closeUserViewOverflowMenu(); }); return item; } function insertByMainOrder(parent, el, order) { const idx = Math.max(0, Math.floor(order)); const siblings = Array.from(parent.children).filter((c) => c !== el); const ref = siblings[idx] || null; if (el.parentNode === parent && el.nextSibling === ref) return; if (ref) parent.insertBefore(el, ref); else parent.appendChild(el); } function insertByRightOrder(parent, el, order) { const n = Math.max(0, Math.floor(order)); const siblings = Array.from(parent.children).filter((c) => c !== el); let ref = null; if (isModernUI()) { if (n === 0) { ref = null; // append → last } else { const idx = Math.max(0, siblings.length - n); ref = siblings[idx] || null; } } else { // Legacy headerRight: last two children are cast/user controls — keep them rightmost const reserved = 2; const idx = siblings.length - reserved - n; if (idx <= 0) { ref = siblings[0] || null; } else { ref = siblings[idx] || siblings[0] || null; } } if (el.parentNode === parent && el.nextSibling === ref) return; if (ref) parent.insertBefore(el, ref); else parent.appendChild(el); } function injectIntoTopNavV12(entry) { const stack = findTopNavStack(); if (!stack) return false; const key = entryKey(entry); const hasOrder = Number.isFinite(entry.order); const divider = ensureTopNavDivider(stack); if (entry.collapsed) { ensureTopNavMoreButton(stack, divider); let menu = getUserViewOverflowMenu(); if (!menu) { // Create shell only when we own the More button; otherwise wait for React. if (stack.querySelector('[data-kefin-custom-menu-more]')) { menu = ensureUserViewOverflowMenu(); } else { return false; } } const menuList = getUserViewOverflowMenuList(menu); if (!menuList) return false; if (findByDataAttr(menuList, 'data-kefin-custom-menu-top-link', key) || findByDataAttr(menu, 'data-kefin-custom-menu-top-link', key)) { return true; } const item = createOverflowMenuItem(entry, menuList, stack); if (!item) return false; menuList.appendChild(item); return true; } if (findByDataAttr(stack, 'data-kefin-custom-menu-top-link', key)) { return true; } const btn = createMuiTopNavButton(entry, getNativeTopNavAnchor(stack)); if (!btn) return false; if (hasOrder) { insertByMainOrder(stack, btn, entry.order); return true; } const moreBtn = findTopNavMoreButton(stack); if (moreBtn) { moreBtn.insertAdjacentElement('beforebegin', btn); } else { let insertAfter = divider; let next = divider.nextElementSibling; while (next && next.hasAttribute?.('data-kefin-custom-menu-top-link')) { insertAfter = next; next = next.nextElementSibling; } insertAfter.insertAdjacentElement('afterend', btn); } return true; } function findLegacyTabsSlider() { return document.querySelector('.headerTabs.sectionTabs .emby-tabs-slider'); } function removeLegacyTopNavCustomTabLinks() { document.querySelectorAll( '.headerTabs.sectionTabs [data-kefin-custom-menu-top-link], .emby-tabs-slider > [data-kefin-custom-menu-top-link]' ).forEach((el) => el.remove()); } function getNativeLegacyTabButton(slider) { if (!slider) return null; return Array.from(slider.querySelectorAll('.emby-tab-button')).find( (btn) => !btn.hasAttribute('data-kefin-custom-menu-top-link') ) || null; } function injectIntoTopNavLegacyTabs(entry) { if (!document.querySelector('.homePage:not(.hide)')) { removeLegacyTopNavCustomTabLinks(); return false; } const slider = findLegacyTabsSlider(); if (!slider) return false; const key = entryKey(entry); if (findByDataAttr(slider, 'data-kefin-custom-menu-top-link', key)) { return true; } const reference = getNativeLegacyTabButton(slider); if (!reference) return false; const btn = reference.cloneNode(true); sanitizeClonedNavItem(btn); btn.classList.remove('emby-tab-button-active'); btn.removeAttribute('href'); btn.setAttribute('data-kefin-custom-menu-top-link', key); btn.classList.add('kefin-custom-menu-top-link'); let nextIndex = slider.children.length; let maxIndex = -1; for (const child of slider.children) { const v = parseInt(child.getAttribute('data-index'), 10); if (Number.isFinite(v) && v > maxIndex) maxIndex = v; } if (maxIndex >= 0) nextIndex = maxIndex + 1; btn.setAttribute('data-index', String(nextIndex)); const foreground = btn.querySelector('.emby-button-foreground') || btn; if (foreground === btn) { btn.textContent = entry.name; } else { foreground.textContent = entry.name; } if (btn.tagName === 'A') { btn.href = getCustomMenuLinkHref(entry); if (!entry.action) { applyOpenInNewTab(btn, entry.openInNewTab); } bindCustomMenuLinkActivation(btn, entry); } else { bindCustomMenuLinkActivation(btn, entry); if (!entry.action) { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const href = String(entry.url || '').trim(); if (!href) return; if (entry.openInNewTab) { window.open(href, '_blank', 'noopener,noreferrer'); } else if (href.startsWith('#')) { window.location.hash = href.slice(1); } else { window.location.href = href; } }); } } if (Number.isFinite(entry.order)) { insertByMainOrder(slider, btn, entry.order); } else { slider.appendChild(btn); } return true; } function isModernUI() { return !!document.querySelector('main.MuiBox-root') || (localStorage.getItem('layout')?.length === 0 && isJellyfinWebAtLeast12()); } function injectIntoTopNavMain(entry) { if (isModernUI()) { return injectIntoTopNavV12(entry); } return injectIntoTopNavLegacyTabs(entry); } function injectIntoTopNavRightLegacy(entry) { const headerRight = document.querySelector('.headerRight'); if (!headerRight) return false; const key = entryKey(entry); if (findByDataAttr(headerRight, 'data-kefin-custom-menu-top-right', key)) { return true; } const button = document.createElement('button'); button.type = 'button'; button.setAttribute('is', 'paper-icon-button-light'); button.className = 'headerButton headerButtonRight paper-icon-button-light kefin-custom-menu-top-right'; button.title = entry.name; button.setAttribute('aria-label', entry.name); button.setAttribute('data-kefin-custom-menu-top-right', key); const iconSpan = document.createElement('span'); iconSpan.className = `material-icons ${entry.icon || 'link'}`; iconSpan.setAttribute('aria-hidden', 'true'); button.appendChild(iconSpan); bindCustomMenuLinkActivation(button, entry); if (!entry.action) { button.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const href = String(entry.url || '').trim(); if (!href) return; if (entry.openInNewTab) { window.open(href, '_blank', 'noopener,noreferrer'); } else if (href.startsWith('#')) { window.location.hash = href.slice(1); } else { window.location.href = href; } }); } const searchButton = headerRight.querySelector('.headerSearchButton'); if (Number.isFinite(entry.order)) { insertByRightOrder(headerRight, button, entry.order); } else if (searchButton) { if (button.parentNode === headerRight && button.nextSibling === searchButton) { return true; } headerRight.insertBefore(button, searchButton); } else if (button.parentNode !== headerRight || headerRight.lastElementChild !== button) { headerRight.appendChild(button); } return true; } function injectIntoTopNavRightV12(entry) { const search = document.querySelector('.headerRight a[href*="#/search"]') || document.querySelector('.MuiToolbar-root a[href*="#/search"]') || document.querySelector('a[href*="#/search"]'); if (!search || !search.parentElement) return false; const parent = search.parentElement; const key = entryKey(entry); if (findByDataAttr(parent, 'data-kefin-custom-menu-top-right', key) || findByDataAttr(document, 'data-kefin-custom-menu-top-right', key)) { return true; } let el; if (search.tagName === 'A' || search.querySelector?.('a')) { const ref = search.tagName === 'A' ? search : search.querySelector('a'); el = ref.cloneNode(true); sanitizeClonedNavItem(el); el.href = getCustomMenuLinkHref(entry); el.setAttribute('data-kefin-custom-menu-top-right', key); el.classList.add('kefin-custom-menu-top-right'); el.title = entry.name; el.setAttribute('aria-label', entry.name); el.removeAttribute('target'); el.removeAttribute('rel'); if (!entry.action) { applyOpenInNewTab(el, entry.openInNewTab); } applyMaterialIcon(el, entry.icon || 'link'); bindCustomMenuLinkActivation(el, entry); } else { el = document.createElement('button'); el.type = 'button'; el.className = search.className || ''; el.classList.add('kefin-custom-menu-top-right'); el.title = entry.name; el.setAttribute('aria-label', entry.name); el.setAttribute('data-kefin-custom-menu-top-right', key); const iconSpan = document.createElement('span'); iconSpan.className = 'material-icons'; iconSpan.setAttribute('aria-hidden', 'true'); iconSpan.textContent = entry.icon || 'link'; el.appendChild(iconSpan); bindCustomMenuLinkActivation(el, entry); if (!entry.action) { el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const href = String(entry.url || '').trim(); if (!href) return; if (entry.openInNewTab) { window.open(href, '_blank', 'noopener,noreferrer'); } else if (href.startsWith('#')) { window.location.hash = href.slice(1); } else { window.location.href = href; } }); } } if (Number.isFinite(entry.order)) { insertByRightOrder(parent, el, entry.order); } else if (el.parentNode !== parent || el !== parent.firstElementChild) { // Default: as first child of the parent element parent.insertBefore(el, parent.firstChild); } return true; } function injectIntoTopNavRight(entry) { if (isModernUI()) { return injectIntoTopNavRightV12(entry); } return injectIntoTopNavRightLegacy(entry); } function closeAppUserMenu() { const menu = document.getElementById('app-user-menu'); if (!menu) return; const backdrop = menu.querySelector('.MuiBackdrop-root, .MuiModal-backdrop'); if (backdrop) { backdrop.click(); return; } document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true })); } function getNativeUserMenuAnchor(menuList) { if (!menuList) return null; return Array.from(menuList.querySelectorAll('a[role="menuitem"]')).find( (a) => !a.hasAttribute('data-kefin-custom-user-menu-link') && !a.hasAttribute('data-kefintweaks-user-menu-config-button') ) || null; } function createAppUserMenuLink(entry, referenceAnchor) { if (!entry || !referenceAnchor) return null; const key = entryKey(entry); const link = referenceAnchor.cloneNode(true); if (!retargetClonedAnchor(link, entry, 'data-kefin-custom-user-menu-link', key)) { return null; } link.addEventListener('click', () => { closeAppUserMenu(); }); return link; } function getUserMenuDividers(menuList) { if (!menuList) return []; return Array.from(menuList.children).filter( (el) => el.matches && (el.matches('hr') || el.classList.contains('MuiDivider-root')) ); } function injectIntoUserMenuV12(entry) { const appUserMenu = document.getElementById('app-user-menu'); if (!appUserMenu) return false; const menuList = appUserMenu.querySelector('ul[role="menu"]'); if (!menuList) return false; const key = entryKey(entry); const existing = findByDataAttr(menuList, 'data-kefin-custom-user-menu-link', key); if (existing) { // Upgrade hand-built items that lack MUI emotion classes if (/\bcss-/.test(existing.className)) { return true; } existing.remove(); } const dividers = getUserMenuDividers(menuList); let insertBefore = null; let insertAfter = null; if (entry.isAdminLink) { // Admin section: before the second divider (fallback: last divider / Dashboard-adjacent) insertBefore = dividers[1] || dividers[dividers.length - 1] || null; if (!insertBefore) { const dashboardLink = menuList.querySelector('a[href="#/dashboard"]') || menuList.querySelector('a[href*="#/dashboard"]'); if (!dashboardLink) return false; let divider = dashboardLink.nextElementSibling; while (divider && !(divider.matches('hr') || divider.classList.contains('MuiDivider-root'))) { divider = divider.nextElementSibling; } insertBefore = divider; } } else if (entry.isUserLink) { // User section: after the last divider insertAfter = dividers[dividers.length - 1] || null; if (!insertAfter) { const signOutButton = menuList.querySelector('a[href="#/signout"]') || menuList.querySelector('a[href*="#/signout"]'); if (!signOutButton) return false; insertAfter = signOutButton.previousElementSibling; } } else { // User section: before the first divider insertBefore = dividers[0] || null; } if (!insertBefore && !insertAfter) return false; const reference = getNativeUserMenuAnchor(menuList) || menuList.querySelector('a[role="menuitem"]'); const link = createAppUserMenuLink(entry, reference); if (!link) return false; if (insertAfter) { insertAfter.after(link); } else { menuList.insertBefore(link, insertBefore); } return true; } function findPreferencesAdminSection() { return document.querySelector('.userPreferencesPage:not(.hide) .adminSection'); } function findPreferencesDefaultSection() { const page = document.querySelector('.userPreferencesPage:not(.hide)') || document.querySelector('.userPreferencesPage'); if (!page) return null; const withUsername = Array.from(page.querySelectorAll('.verticalSection')).find( (section) => section.querySelector('.headerUsername') ); return withUsername || null; } function findPreferencesUserSection() { return document.querySelector('.userPreferencesPage:not(.hide) .userSection'); } function createPreferencesUserMenuLink(entry) { const key = entryKey(entry); const link = document.createElement('a'); link.setAttribute('data-ripple', 'false'); link.href = getCustomMenuLinkHref(entry); link.style.cssText = 'display:block;padding:0;margin:0'; link.className = 'listItem-border emby-button'; link.setAttribute('data-kefin-custom-user-menu-link', key); if (!entry.action) { applyOpenInNewTab(link, entry.openInNewTab); } const iconName = entry.icon || 'link'; link.innerHTML = `
    `; const titleEl = link.querySelector('.listItemBodyText'); if (titleEl) titleEl.textContent = entry.name; bindCustomMenuLinkActivation(link, entry); return link; } function injectIntoUserMenuLegacy(entry) { const section = entry.isUserLink ? findPreferencesUserSection() : entry.isAdminLink ? findPreferencesAdminSection() : findPreferencesDefaultSection(); if (!section) return false; const key = entryKey(entry); if (findByDataAttr(section, 'data-kefin-custom-user-menu-link', key)) { return true; } const link = createPreferencesUserMenuLink(entry); const header = section.querySelector('.headerUsername'); if (header) { header.after(link); } else { section.appendChild(link); } return true; } function injectIntoUserMenu(entry) { if (isModernUI()) { return injectIntoUserMenuV12(entry); } return injectIntoUserMenuLegacy(entry); } function injectSideMenuOnly(entry) { if (!entry || entry.sideMenu === false) return false; const drawerOk = injectIntoV12Drawer(entry); const legacyContainer = document.querySelector(entry.containerSelector || DEFAULT_CUSTOM_MENU_SELECTOR); const legacyOk = legacyContainer ? addLinkToContainer(legacyContainer, entry) : false; return drawerOk || legacyOk; } function injectUserMenuOnly(entry) { if (!entry?.userMenu) return false; return injectIntoUserMenu(entry); } function injectTopNavOnly(entry) { if (!entry) return false; if (entry.topNavigation === 'main') return injectIntoTopNavMain(entry); if (entry.topNavigation === 'right') return injectIntoTopNavRight(entry); return false; } function injectOneCustomMenuLink(entry) { let anyOk = false; if (entry.sideMenu !== false) { anyOk = injectSideMenuOnly(entry) || anyOk; } if (entry.topNavigation === 'main' || entry.topNavigation === 'right') { anyOk = injectTopNavOnly(entry) || anyOk; } if (entry.userMenu) { anyOk = injectUserMenuOnly(entry) || anyOk; } if (entry.topNavigation === 'main') { syncCustomTopNavActiveState(); } return anyOk; } function reapplyAllCustomMenuLinks() { withCustomMenuReapplySuppressed(() => { if (!document.querySelector('.homePage:not(.hide)')) { removeLegacyTopNavCustomTabLinks(); } customMenuLinkRegistry.forEach((entry) => { try { injectOneCustomMenuLink(entry); } catch (err) { ERR('Error re-applying custom menu link:', entry?.name, err); } }); syncCustomTopNavActiveState(); }); } function parseNavHashParts(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 customTopNavHrefMatchScore(href, currentHash) { if (!href || /^https?:/i.test(href)) return -1; const targetHash = href.startsWith('#') ? href : `#${href}`; const current = parseNavHashParts(currentHash); const target = parseNavHashParts(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; } let score = 100 + targetHash.length; score += [...target.params.keys()].length * 10; if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) { return -1; } return score; } function resolveCustomTopNavHref(el) { if (!el) return ''; const attrHref = el.getAttribute('href'); if (attrHref) return attrHref; const key = el.getAttribute('data-kefin-custom-menu-top-link'); if (!key) return ''; const entry = customMenuLinkRegistry.find((e) => entryKey(e) === key); return entry ? getCustomMenuLinkHref(entry) : ''; } function syncCustomTopNavActiveState() { const links = Array.from(document.querySelectorAll('[data-kefin-custom-menu-top-link]')); if (!links.length) return; const currentHash = window.location.hash || '#/'; const normalizedHash = currentHash.startsWith('#') ? currentHash : `#${currentHash}`; let bestScore = -1; const winners = []; links.forEach((el) => { el.classList.remove('active'); const score = customTopNavHrefMatchScore(resolveCustomTopNavHref(el), normalizedHash); if (score < 0) return; if (score > bestScore) { bestScore = score; winners.length = 0; winners.push(el); } else if (score === bestScore) { winners.push(el); } }); winners.forEach((el) => el.classList.add('active')); } let customTopNavActiveSyncBound = false; function ensureCustomTopNavActiveSync() { if (customTopNavActiveSyncBound) return; customTopNavActiveSyncBound = true; window.addEventListener('hashchange', syncCustomTopNavActiveState); onViewPage(() => { syncCustomTopNavActiveState(); }, { pages: [] }); syncCustomTopNavActiveState(); } const ANY_CUSTOM_MENU_HOST_SEL = [ // top nav '.skinHeader', '.headerTabs', '.headerRight', '.MuiToolbar-root', '.MuiStack-root', // side '.MuiDrawer-paper', '.mainDrawer', '.navDrawer', '.customMenuOptions', '.adminMenuOptions', '.userMenuOptions', // user menus (modern + legacy) '#app-user-menu', '.userPreferencesPage', '.userPreferencesPage .userSection', '.userPreferencesPage .adminSection', '.userPreferencesPage .verticalSection' ].join(', '); function isKefinCustomMenuOwnedNode(node) { if (!node) return false; const el = node.nodeType === 1 ? node : node.parentElement; if (!el || typeof el.closest !== 'function') return false; if (el.matches?.(KEFIN_CUSTOM_MENU_OWNED_SEL)) return true; return !!el.closest(KEFIN_CUSTOM_MENU_OWNED_SEL); } function addedNodeMatchesHost(node, hostSel) { if (!node || node.nodeType !== 1) return false; return !!(node.matches?.(hostSel) || node.querySelector?.(hostSel)); } function mutationShouldTriggerCustomMenuReapply(mutation) { const addedEls = Array.from(mutation.addedNodes).filter((n) => n.nodeType === 1); if (!addedEls.length) return false; if (addedEls.every((n) => isKefinCustomMenuOwnedNode(n))) return false; return addedEls.some((n) => addedNodeMatchesHost(n, ANY_CUSTOM_MENU_HOST_SEL)); } function disconnectCustomMenuLinkObserver() { if (!customMenuLinkObserver) return; customMenuLinkObserver.disconnect(); customMenuLinkObserver = null; } /** * Persistent MO: any custom-menu host remount → idempotent reapplyAll. * No host is assumed stable (drawer, prefs, #app-user-menu, top-nav). */ function ensureCustomMenuLinkObserver() { if (!customMenuLinkRegistry.length) { disconnectCustomMenuLinkObserver(); return; } if (customMenuLinkObserver || typeof MutationObserver === 'undefined' || !document.body) { return; } let scheduled = false; customMenuLinkObserver = new MutationObserver((mutations) => { if (customMenuLinkReapplyDepth > 0) return; if (!customMenuLinkRegistry.length) { disconnectCustomMenuLinkObserver(); return; } if (!mutations.some(mutationShouldTriggerCustomMenuReapply)) return; if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; reapplyAllCustomMenuLinks(); }); }); customMenuLinkObserver.observe(document.body, { childList: true, subtree: true }); } /** * Inject top-nav custom menu link styles once. */ function ensureCustomMenuLinkStyles() { if (!document.getElementById('kefin-custom-menu-links-styles')) { const style = document.createElement('style'); style.id = 'kefin-custom-menu-links-styles'; style.textContent = ` .kefin-custom-menu-top-divider { width: 1px; align-self: stretch; margin: 0.5em 0.35em; flex-shrink: 0; background: currentColor; opacity: 0.25; height: 30px; align-self: center; } .kefin-custom-menu-top-divider:not(:has(+ *)) { display: none; } .kefin-custom-menu-top-link { text-transform: none; white-space: nowrap; } [data-kefin-custom-menu-top-link].active { display: inline-flex; -moz-box-align: center; align-items: center; -moz-box-pack: center; justify-content: center; position: relative; box-sizing: border-box; outline: 0; margin: 0; cursor: pointer; user-select: none; vertical-align: middle; appearance: none; text-decoration: none; font-family: "Noto Sans", sans-serif; font-weight: 500; font-size: 0.875rem; line-height: 1.75; text-transform: none; min-width: 64px; border: 0; border-radius: var(--jf-shape-borderRadius); padding: 6px 8px; color: var(--variant-textColor); background-color: var(--variant-textBg); --variant-textColor: var(--jf-palette-primary-main); --variant-outlinedColor: var(--jf-palette-primary-main); --variant-outlinedBorder: rgba(var(--jf-palette-primary-mainChannel) / 0.5); --variant-containedColor: var(--jf-palette-primary-contrastText); --variant-containedBg: var(--jf-palette-primary-main); transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1), border-color 250ms cubic-bezier(0.4, 0, 0.2, 1); } .userPreferencesPage .readOnlyContent > * { display: flex; flex-direction: column; } .userPreferencesPage .readOnlyContent > *:not(.userSection) > :not([data-kefin-custom-user-menu-link]) { order: -1; } `; (document.head || document.documentElement).appendChild(style); } // Explicit More-button styles (native emotion classes unavailable when More is absent) if (!document.getElementById('kefin-custom-menu-more-styles')) { const moreStyle = document.createElement('style'); moreStyle.id = 'kefin-custom-menu-more-styles'; moreStyle.textContent = ` button[data-kefin-custom-menu-more].MuiButtonBase-root { display: inline-flex; -moz-box-align: center; align-items: center; -moz-box-pack: center; justify-content: center; position: relative; box-sizing: border-box; outline: 0; margin: 0; cursor: pointer; user-select: none; vertical-align: middle; appearance: none; text-decoration: none; font-family: "Noto Sans", sans-serif; font-weight: 500; font-size: 0.875rem; line-height: 1.75; text-transform: none; min-width: 64px; border: 0; border-radius: var(--jf-shape-borderRadius); padding: 6px 8px; background-color: var(--variant-textBg); color: inherit; --variant-containedBg: var(--jf-palette-Button-inheritContainedBg); transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1), border-color 250ms cubic-bezier(0.4, 0, 0.2, 1); } button[data-kefin-custom-menu-more] .MuiButton-icon { display: inherit; margin-right: -4px; margin-left: 8px; } button[data-kefin-custom-menu-more] .MuiTouchRipple-root { overflow: hidden; pointer-events: none; position: absolute; z-index: 0; inset: 0; border-radius: inherit; } button[data-kefin-custom-menu-more] .MuiSvgIcon-root { user-select: none; width: 1em; height: 1em; display: inline-block; flex-shrink: 0; transition: fill 300ms cubic-bezier(0.4, 0, 0.2, 1); fill: currentcolor; font-size: 20px; } `; (document.head || document.documentElement).appendChild(moreStyle); } // Kefin-owned overflow menu styles (only when we created #user-view-overflow-menu) if (!document.getElementById('kefin-custom-overflow-menu-styles')) { const overflowStyle = document.createElement('style'); overflowStyle.id = 'kefin-custom-overflow-menu-styles'; overflowStyle.textContent = ` #user-view-overflow-menu[data-kefin-custom-overflow-menu] { position: fixed; z-index: var(--jf-zIndex-modal); inset: 0; } #user-view-overflow-menu[data-kefin-custom-overflow-menu].MuiModal-hidden { visibility: hidden; pointer-events: none; } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiBackdrop-root { position: fixed; display: flex; -moz-box-align: center; align-items: center; -moz-box-pack: center; justify-content: center; inset: 0; background-color: transparent; z-index: -1; } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiPaper-root { background-color: var(--jf-palette-background-paper); color: var(--jf-palette-text-primary); border-radius: 4px; box-shadow: var(--Paper-shadow); background-image: var(--Paper-overlay); position: absolute; overflow: hidden auto; min-width: 16px; min-height: 16px; max-width: calc(100% - 32px); outline: 0; max-height: calc(100% - 96px); --Paper-shadow: var(--jf-shadows-8); --Paper-overlay: var(--jf-overlays-8); opacity: 0; transform: scale(0.75, 0.5625); transition: opacity 299ms cubic-bezier(0.4, 0, 0.2, 1), transform 199ms cubic-bezier(0.4, 0, 0.2, 1) 100ms; transform-origin: 0 0 0; visibility: hidden; } #user-view-overflow-menu[data-kefin-custom-overflow-menu]:not(.MuiModal-hidden) .MuiPaper-root { opacity: 1; visibility: visible; transform: none; transition: opacity 299ms cubic-bezier(0.4, 0, 0.2, 1), transform 199ms cubic-bezier(0.4, 0, 0.2, 1); } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiList-root { list-style: none; margin: 0; padding: 8px 0; position: relative; outline: 0; } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiButtonBase-root { background-color: transparent; outline: 0; border: 0; margin: 0; border-radius: 0; cursor: pointer; user-select: none; vertical-align: middle; appearance: none; color: inherit; font-family: "Noto Sans", sans-serif; font-weight: 400; font-size: 1rem; line-height: 1.5; display: flex; -moz-box-pack: start; justify-content: flex-start; -moz-box-align: center; align-items: center; position: relative; text-decoration: none; min-height: 48px; padding: 6px 16px; box-sizing: border-box; white-space: nowrap; } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiListItemIcon-root { color: var(--jf-palette-action-active); flex-shrink: 0; display: inline-flex; } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiSvgIcon-root, #user-view-overflow-menu[data-kefin-custom-overflow-menu] .material-icons.MuiSvgIcon-root { user-select: none; width: 1em; height: 1em; display: inline-block; flex-shrink: 0; transition: fill 300ms cubic-bezier(0.4, 0, 0.2, 1); fill: currentcolor; font-size: 1.5rem; } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiTypography-root { margin: 0; font-family: "Noto Sans", sans-serif; font-weight: 400; font-size: 1rem; line-height: 1.5; } #user-view-overflow-menu[data-kefin-custom-overflow-menu] .MuiTouchRipple-root { overflow: hidden; pointer-events: none; position: absolute; z-index: 0; inset: 0; border-radius: inherit; } `; (document.head || document.documentElement).appendChild(overflowStyle); } removeLegacyKefinMorePopover(); } /** * Add a custom menu link with per-link placements. * @param {string} name - Display name for the menu item * @param {string} icon - Material icon name * @param {string} [url] - URL to navigate to (optional when options.action is set) * @param {boolean} openInNewTab - Whether to open in new tab (default: false) * @param {string|Object} [containerSelectorOrOptions] - Legacy selector string, or options: * { containerSelector, collapsed, sideMenu, topNavigation, userMenu, isAdminLink, isUserLink, action, order } * @returns {Promise} */ async function addCustomMenuLink(name, icon, url, openInNewTab = false, containerSelectorOrOptions = DEFAULT_CUSTOM_MENU_SELECTOR) { ensureCustomMenuLinkStyles(); ensureCustomTopNavActiveSync(); const options = parseCustomMenuLinkOptions(containerSelectorOrOptions); const trimmedUrl = url != null ? String(url).trim() : ''; const action = options.action || ''; if (!name || (!trimmedUrl && !action)) { WARN('addCustomMenuLink requires name and either url or action'); return false; } if (options.isAdminLink) { const admin = await isCurrentUserAdmin(); if (!admin) { LOG(`Skipping admin-only custom menu link (not admin): ${name}`); return false; } } let containerSelector = DEFAULT_CUSTOM_MENU_SELECTOR; if (options.containerSelector) { containerSelector = options.containerSelector; } else if (options.isAdminLink) { containerSelector = ADMIN_MENU_SELECTOR; } else if (options.isUserLink) { containerSelector = USER_MENU_SELECTOR; } const entry = { name, icon: icon || 'link', url: trimmedUrl, action, openInNewTab: !!openInNewTab, collapsed: !!options.collapsed, sideMenu: options.sideMenu !== false, topNavigation: options.topNavigation || 'none', userMenu: !!options.userMenu, isAdminLink: !!options.isAdminLink, isUserLink: !!options.isUserLink, order: options.order, containerSelector: containerSelector }; // Explicit legacy selector (e.g. Configure → .adminMenuOptions): side-only path if (typeof containerSelectorOrOptions === 'string' && containerSelectorOrOptions !== DEFAULT_CUSTOM_MENU_SELECTOR) { entry.sideMenu = true; entry.topNavigation = 'none'; entry.userMenu = false; } upsertCustomMenuLinkRegistry(entry); const ok = withCustomMenuReapplySuppressed(() => !!injectOneCustomMenuLink(entry)); ensureCustomMenuLinkObserver(); return ok; } /** * Add the link to the existing container * @param {Element} container - The custom menu options container * @param {Object} entry - Link entry * @returns {boolean} - True if successfully added */ function addLinkToContainer(container, entry) { try { const name = entry.name; const icon = entry.icon || 'link'; const key = entryKey(entry); if (findByDataAttr(container, 'data-kefin-custom-menu-link', key)) { return true; } const existingByText = Array.from(container.querySelectorAll('.navMenuOptionText')) .find(el => el.textContent === name); if (existingByText) { return true; } const link = document.createElement('a'); link.className = 'emby-button navMenuOption lnkMediaFolder'; link.dataset.name = String(name).toLowerCase().replace(/\s+/g, '-'); const href = getCustomMenuLinkHref(entry); link.href = href; link.setAttribute('data-kefin-custom-menu-link', key); if (!entry.action) { applyOpenInNewTab(link, entry.openInNewTab); if (isNativeJellyfinMenuHref(href)) { link.setAttribute('is', 'emby-linkbutton'); } } const iconSpan = document.createElement('span'); iconSpan.className = `material-icons navMenuOptionIcon ${icon}`; iconSpan.setAttribute('aria-hidden', 'true'); const textSpan = document.createElement('span'); textSpan.className = 'navMenuOptionText'; textSpan.textContent = name; link.appendChild(iconSpan); link.appendChild(textSpan); bindCustomMenuLinkActivation(link, entry); container.appendChild(link); LOG(`Successfully added custom menu link: ${name}`); return true; } catch (err) { ERR(`Error adding custom menu link ${entry?.name}:`, err); return false; } } /** * Waits for ApiClient to be available * @param {number} maxWaitMs - Maximum time to wait in milliseconds (default: 10000 = 10 seconds) * @param {number} checkInterval - Interval between checks in milliseconds (default: 100) * @returns {Promise} - ApiClient object or null if timeout */ async function waitForApiClient(maxWaitMs = 5000, checkInterval = 100) { const startTime = Date.now(); while (Date.now() - startTime < maxWaitMs) { await new Promise(resolve => setTimeout(resolve, checkInterval)); if (window.ApiClient) { return window.ApiClient; } } return null; } /** * Waits for user to be logged in * @param {number} maxWaitMs - Maximum time to wait in milliseconds (default: 10000 = 10 seconds) * @param {number} checkInterval - Interval between checks in milliseconds (default: 500) * @returns {Promise} - True if logged in, false if timeout */ async function waitForLogin(maxWaitMs = 10000, checkInterval = 500) { const startTime = Date.now(); while (Date.now() - startTime < maxWaitMs) { if (window.ApiClient && window.ApiClient._loggedIn && window.ApiClient.accessToken && window.ApiClient.serverAddress) { try { const token = window.ApiClient.accessToken(); if (token) { LOG('User is logged in'); return true; } } catch (e) { // Token not available yet } } await new Promise(resolve => setTimeout(resolve, checkInterval)); } WARN('Timeout waiting for user login'); return false; } // Page-lifetime cache: normalized name/guid key → plugin Id const pluginIdCache = new Map(); const JS_INJECTOR_ALIASES = ['JavaScript Injector', 'JS Injector']; function normalizePluginKey(value) { return String(value || '').trim().toLowerCase(); } function looksLikeGuid(value) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim()); } /** * Clear cached plugin id(s). Omit argument to clear all. * @param {string|string[]} [nameOrGuid] */ function clearPluginIdCache(nameOrGuid) { if (nameOrGuid == null) { pluginIdCache.clear(); return; } const keys = Array.isArray(nameOrGuid) ? nameOrGuid : [nameOrGuid]; const idsToClear = new Set(); for (const key of keys) { const normalized = normalizePluginKey(key); const cachedId = pluginIdCache.get(normalized); pluginIdCache.delete(normalized); if (cachedId) idsToClear.add(cachedId); if (looksLikeGuid(key)) idsToClear.add(String(key).trim()); } if (idsToClear.size === 0) return; for (const [k, v] of [...pluginIdCache.entries()]) { if (idsToClear.has(v) || idsToClear.has(k)) { pluginIdCache.delete(k); } } for (const id of idsToClear) { pluginIdCache.delete(normalizePluginKey(id)); } } /** * Resolve a Jellyfin plugin Id by name or GUID (cached for the page lifetime). * @param {string|string[]} nameOrGuid - Plugin name, GUID, or alias list tried in order * @returns {Promise} */ async function resolvePluginId(nameOrGuid) { const aliases = (Array.isArray(nameOrGuid) ? nameOrGuid : [nameOrGuid]) .filter(a => a != null && String(a).trim() !== ''); if (aliases.length === 0) { return null; } for (const alias of aliases) { const cached = pluginIdCache.get(normalizePluginKey(alias)); if (cached) return cached; } const api = window.apiHelper; if (!api || typeof api.getPlugins !== 'function') { throw new Error('apiHelper.getPlugins is not available'); } const pluginsData = await api.getPlugins(); const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); for (const alias of aliases) { const normalized = normalizePluginKey(alias); const isGuid = looksLikeGuid(alias); const plugin = pluginsList.find(p => { if (isGuid) return normalizePluginKey(p.Id) === normalized; return normalizePluginKey(p.Name) === normalized; }); if (plugin && plugin.Id) { pluginIdCache.set(normalizePluginKey(plugin.Id), plugin.Id); for (const a of aliases) { pluginIdCache.set(normalizePluginKey(a), plugin.Id); } return plugin.Id; } } return null; } async function fetchPluginConfigurationResponse(pluginId) { const server = ApiClient._serverAddress; return fetch(`${server}/Plugins/${pluginId}/Configuration`, { headers: { 'Authorization': getAuthHeader() } }); } /** * GET /Plugins/{id}/Configuration for a plugin resolved by name or GUID. * On 404, clears cached id(s), re-resolves once, and retries once. * @param {string|string[]} nameOrGuid * @returns {Promise} */ async function getPluginConfiguration(nameOrGuid) { let pluginId = await resolvePluginId(nameOrGuid); if (!pluginId) { throw new Error('Plugin not found'); } let response = await fetchPluginConfigurationResponse(pluginId); if (response.status === 404) { clearPluginIdCache(nameOrGuid); clearPluginIdCache(pluginId); pluginId = await resolvePluginId(nameOrGuid); if (!pluginId) { throw new Error('Plugin not found after cache clear'); } response = await fetchPluginConfigurationResponse(pluginId); } if (!response.ok) { throw new Error(`Failed to get plugin config: ${response.status} ${response.statusText}`); } return response.json(); } /** * Saves the current KefinTweaksConfig back to JS Injector plugin * @param {Object} config - Optional config object to save. If not provided, uses window.KefinTweaksConfig * @param {Object} options - Options for saving * @param {boolean} options.waitForLogin - Whether to wait for login if not logged in (default: true) * @param {number} options.loginWaitTime - Maximum time to wait for login in ms (default: 10000) * @returns {Promise} - Success status */ async function saveConfigToJavaScriptInjector(config = null, options = {}) { try { const { waitForLogin: shouldWaitForLogin = true, loginWaitTime = 10000 } = options; // Check if ApiClient is available if (!window.ApiClient) { if (shouldWaitForLogin) { LOG('ApiClient not available, waiting for initialization...'); const apiClient = await waitForApiClient(loginWaitTime); if (!apiClient) { WARN('ApiClient not available after waiting, cannot save config'); return false; } } else { throw new Error('ApiClient not available'); } } // Check if user is logged in const isLoggedIn = window.ApiClient._loggedIn && window.ApiClient.accessToken && window.ApiClient.serverAddress; if (!isLoggedIn) { if (shouldWaitForLogin) { LOG('User not logged in, waiting for login...'); const loggedIn = await waitForLogin(loginWaitTime); if (!loggedIn) { WARN('User not logged in after waiting, cannot save config'); return false; } } else { throw new Error('User not logged in'); } } // Use provided config or fall back to window.KefinTweaksConfig const configToSave = config || window.KefinTweaksConfig; if (!configToSave) { throw new Error('No config provided and window.KefinTweaksConfig is not available'); } const pluginId = await resolvePluginId(JS_INJECTOR_ALIASES); if (!pluginId) { WARN('JavaScript Injector plugin not found, cannot save config'); return false; } const injectorConfig = await getPluginConfiguration(pluginId); const server = ApiClient._serverAddress; const configUrl = `${server}/Plugins/${pluginId}/Configuration`; // Ensure CustomJavaScripts array exists if (!injectorConfig.CustomJavaScripts) { injectorConfig.CustomJavaScripts = []; } // Create the script content const scriptContent = `// KefinTweaks Configuration // This file is automatically generated by KefinTweaks Configuration UI // Do not edit manually unless you know what you're doing window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; // Build / refresh KefinTweaks-injector preload entry (same POST) let injectorScriptContent = null; try { const resolvedOrSymbolic = configToSave.kefinTweaksRootResolved || configToSave.kefinTweaksRoot || ''; const root = resolvedOrSymbolic.endsWith('/') ? resolvedOrSymbolic : resolvedOrSymbolic + '/'; if (root && root !== '/') { if (!window.KefinTweaksLoader) { await new Promise((resolve, reject) => { const url = `${root}scripts/kefinTweaks-loader.js`; const existing = document.querySelector(`script[src="${url}"]`); if (existing && window.KefinTweaksLoader) { resolve(); return; } const script = document.createElement('script'); script.src = url; script.async = false; script.onload = () => resolve(); script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js')); document.head.appendChild(script); }); } const Loader = window.KefinTweaksLoader; if (Loader) { Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS); const major = await window.KefinTweaks.getJellyfinMajorVersion(); const planRoot = Loader.getResolvedKefinRoot ? Loader.getResolvedKefinRoot(configToSave) : root; const plan = Loader.buildLoadPlan(configToSave, major, { root: planRoot, configOnly: configToSave.enabled === false }); injectorScriptContent = Loader.buildInjectorScript(plan); LOG(`Built KefinTweaks-injector (${plan.assets.length} assets, major=${major})`); } } } catch (loaderErr) { WARN('Could not build KefinTweaks-injector entry:', loaderErr); } injectorConfig.CustomJavaScripts = (window.KefinTweaksLoader ? window.KefinTweaksLoader.upsertInjectorEntries(injectorConfig.CustomJavaScripts, { configScriptContent: scriptContent, injectorScriptContent: injectorScriptContent }) : (() => { // Fallback: Config-only upsert if loader unavailable const list = injectorConfig.CustomJavaScripts || []; const idx = list.findIndex((s) => s.Name === 'KefinTweaks-Config'); const entry = { Name: 'KefinTweaks-Config', Script: scriptContent, Enabled: true, RequiresAuthentication: false }; if (idx !== -1) list[idx] = { ...list[idx], ...entry }; else list.push(entry); return list; })()); // Save the updated configuration const saveResponse = await fetch(configUrl, { method: 'POST', headers: { 'Authorization': getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify(injectorConfig) }); if (!saveResponse.ok) { throw new Error(`Failed to save plugin config: ${saveResponse.statusText}`); } LOG('Successfully saved config (+ injector preload) to JS Injector plugin'); return true; } catch (err) { ERR('Error saving config to JS Injector:', err); return false; } } const KEFIN_CONFIG_ENTRY_NAME = 'KefinTweaks-Config'; const KEFIN_CONFIG_SCRIPT_MARKER = 'window.KefinTweaksConfig = {'; const DEFAULT_BACKUP_KEEP_COUNT = 5; function isKefinConfigScriptEntry(entry) { return !!(entry?.Script && String(entry.Script).includes(KEFIN_CONFIG_SCRIPT_MARKER)); } function parseConfigFromScript(script) { if (!script || typeof script !== 'string') return null; const prefix = 'window.KefinTweaksConfig = '; const idx = script.indexOf(prefix); if (idx < 0) return null; let jsonText = script.slice(idx + prefix.length).trim(); if (jsonText.endsWith(';')) jsonText = jsonText.slice(0, -1).trim(); try { return JSON.parse(jsonText); } catch (e) { WARN('Failed to parse KefinTweaksConfig from script:', e); return null; } } function parseBackupTimestamp(name, script) { const createdMatch = String(script || '').match(/Created:\s*(\d{4}-\d{2}-\d{2}T[^\s\n]+)/i); if (createdMatch) { const d = new Date(createdMatch[1]); if (!Number.isNaN(d.getTime())) return d; } const nameMatch = String(name || '').match(/(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})/); if (nameMatch) { const iso = nameMatch[1].replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3') + 'Z'; const d = new Date(iso); if (!Number.isNaN(d.getTime())) return d; } return null; } function buildConfigBackupScript(config, reason) { const reasonLine = reason ? `// Reason: ${reason}\n` : ''; return `// KefinTweaks Configuration Backup ${reasonLine}// Created: ${new Date().toISOString()} // Do not edit manually window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } function makeBackupName(kind = 'manual') { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); if (kind === 'scheduled') { return `KefinTweaks-Config-ScheduledBackup-${timestamp}`; } return `KefinTweaks-Config-Backup-${timestamp}`; } function getBackupKindFromName(name) { const n = String(name || ''); if (n.includes('ScheduledBackup')) return 'scheduled'; if (n.includes('SkinConflictsRemoved')) return 'skin-conflict'; return 'manual'; } function getBackupKindLabel(kind) { if (kind === 'scheduled') return 'Scheduled'; if (kind === 'skin-conflict') return 'Skin conflict'; return 'Manual'; } async function fetchInjectorConfigForBackups() { const pluginId = await resolvePluginId(JS_INJECTOR_ALIASES); if (!pluginId) throw new Error('JavaScript Injector plugin not found'); const injectorConfig = await getPluginConfiguration(pluginId); if (!injectorConfig.CustomJavaScripts) injectorConfig.CustomJavaScripts = []; return { pluginId, injectorConfig }; } async function postInjectorConfig(pluginId, injectorConfig) { const server = ApiClient._serverAddress; const configUrl = `${server}/Plugins/${pluginId}/Configuration`; const saveResponse = await fetch(configUrl, { method: 'POST', headers: { Authorization: getAuthHeader(), 'Content-Type': 'application/json' }, body: JSON.stringify(injectorConfig) }); if (!saveResponse.ok) { throw new Error(`Failed to save injector config: ${saveResponse.statusText}`); } return true; } async function listKefinConfigBackups() { try { const { injectorConfig } = await fetchInjectorConfigForBackups(); const backups = (injectorConfig.CustomJavaScripts || []) .filter((entry) => isKefinConfigScriptEntry(entry) && entry.Name !== KEFIN_CONFIG_ENTRY_NAME) .map((entry) => { const date = parseBackupTimestamp(entry.Name, entry.Script); const kind = getBackupKindFromName(entry.Name); return { name: entry.Name, date, dateLabel: date ? date.toLocaleString() : 'Unknown date', kind, kindLabel: getBackupKindLabel(kind), enabled: entry.Enabled === true, script: entry.Script }; }); backups.sort((a, b) => { const at = a.date ? a.date.getTime() : 0; const bt = b.date ? b.date.getTime() : 0; return bt - at; }); return backups; } catch (err) { ERR('Error listing config backups:', err); return []; } } async function createKefinConfigBackup(config = null, options = {}) { const { reason = 'manual', kind } = options; try { const configToBackup = config || window.KefinTweaksConfig; if (!configToBackup) throw new Error('No config available to backup'); const backupKind = kind || (reason === 'scheduled-daily' ? 'scheduled' : 'manual'); const { pluginId, injectorConfig } = await fetchInjectorConfigForBackups(); const backupName = makeBackupName(backupKind); injectorConfig.CustomJavaScripts.push({ Name: backupName, Script: buildConfigBackupScript(configToBackup, reason), Enabled: false, RequiresAuthentication: false }); await postInjectorConfig(pluginId, injectorConfig); LOG(`Created config backup: ${backupName}`); return { ok: true, name: backupName }; } catch (err) { ERR('Error creating config backup:', err); return { ok: false, error: err }; } } async function deleteKefinConfigBackup(name) { return deleteKefinConfigBackups(name ? [name] : []); } async function deleteKefinConfigBackups(names) { try { const nameList = (Array.isArray(names) ? names : []) .map((n) => String(n || '').trim()) .filter((n) => n && n !== KEFIN_CONFIG_ENTRY_NAME); if (!nameList.length) { throw new Error('No backups selected'); } const removeSet = new Set(nameList); const { pluginId, injectorConfig } = await fetchInjectorConfigForBackups(); const before = injectorConfig.CustomJavaScripts.length; injectorConfig.CustomJavaScripts = injectorConfig.CustomJavaScripts.filter( (entry) => !removeSet.has(entry.Name) ); const removed = before - injectorConfig.CustomJavaScripts.length; if (removed === 0) { throw new Error('No matching backups found to delete'); } await postInjectorConfig(pluginId, injectorConfig); LOG(`Deleted ${removed} config backup(s)`); return { ok: true, removed }; } catch (err) { ERR('Error deleting config backup(s):', err); return { ok: false, removed: 0, error: err }; } } async function pruneKefinConfigBackups(keepCount = DEFAULT_BACKUP_KEEP_COUNT) { try { const keep = Math.max(1, Math.floor(Number(keepCount) || DEFAULT_BACKUP_KEEP_COUNT)); const { pluginId, injectorConfig } = await fetchInjectorConfigForBackups(); const backups = (injectorConfig.CustomJavaScripts || []) .filter((entry) => isKefinConfigScriptEntry(entry) && entry.Name !== KEFIN_CONFIG_ENTRY_NAME) .map((entry) => ({ entry, date: parseBackupTimestamp(entry.Name, entry.Script) })); backups.sort((a, b) => { const at = a.date ? a.date.getTime() : 0; const bt = b.date ? b.date.getTime() : 0; return bt - at; }); if (backups.length <= keep) return { pruned: 0 }; const toRemove = new Set(backups.slice(keep).map((b) => b.entry.Name)); injectorConfig.CustomJavaScripts = injectorConfig.CustomJavaScripts.filter( (entry) => !toRemove.has(entry.Name) ); await postInjectorConfig(pluginId, injectorConfig); LOG(`Pruned ${toRemove.size} config backup(s); kept ${keep}`); return { pruned: toRemove.size }; } catch (err) { ERR('Error pruning config backups:', err); return { pruned: 0, error: err }; } } async function restoreKefinConfigBackup(name, options = {}) { const { backupCurrentFirst = false } = options; try { if (!name || name === KEFIN_CONFIG_ENTRY_NAME) { throw new Error('Invalid backup name'); } const { injectorConfig } = await fetchInjectorConfigForBackups(); const entry = (injectorConfig.CustomJavaScripts || []).find((e) => e.Name === name); if (!entry || !isKefinConfigScriptEntry(entry)) { throw new Error(`Backup not found: ${name}`); } const restoredConfig = parseConfigFromScript(entry.Script); if (!restoredConfig || typeof restoredConfig !== 'object') { throw new Error('Backup script does not contain a valid KefinTweaksConfig'); } if (backupCurrentFirst) { const current = window.KefinTweaksConfig || null; if (current) { const backupResult = await createKefinConfigBackup(current, { reason: 'before-restore' }); if (!backupResult.ok) { throw new Error('Failed to backup current config before restore'); } } } window.KefinTweaksConfig = restoredConfig; const ok = await saveConfigToJavaScriptInjector(restoredConfig); if (!ok) throw new Error('Failed to save restored config'); LOG(`Restored config from backup: ${name}`); return true; } catch (err) { ERR('Error restoring config backup:', err); return false; } } function normalizeBackupsSettings(config) { const raw = config?.backups && typeof config.backups === 'object' ? config.backups : {}; return { scheduledEnabled: raw.scheduledEnabled === true, keepCount: Math.max(1, Math.floor(Number(raw.keepCount) || DEFAULT_BACKUP_KEEP_COUNT)), lastScheduledAt: raw.lastScheduledAt || null }; } function sameCalendarDay(isoA, dateB = new Date()) { if (!isoA) return false; const a = new Date(isoA); if (Number.isNaN(a.getTime())) return false; return ( a.getUTCFullYear() === dateB.getUTCFullYear() && a.getUTCMonth() === dateB.getUTCMonth() && a.getUTCDate() === dateB.getUTCDate() ); } async function maybeRunScheduledBackup(config = null) { try { const cfg = config || window.KefinTweaksConfig; if (!cfg) return { ran: false, reason: 'no-config' }; const settings = normalizeBackupsSettings(cfg); if (!settings.scheduledEnabled) return { ran: false, reason: 'disabled' }; if (sameCalendarDay(settings.lastScheduledAt)) { return { ran: false, reason: 'already-today' }; } const created = await createKefinConfigBackup(cfg, { reason: 'scheduled-daily', kind: 'scheduled' }); if (!created.ok) return { ran: false, reason: 'create-failed' }; await pruneKefinConfigBackups(settings.keepCount); cfg.backups = { ...settings, lastScheduledAt: new Date().toISOString() }; window.KefinTweaksConfig = cfg; await saveConfigToJavaScriptInjector(cfg); LOG('Scheduled daily config backup completed'); return { ran: true, name: created.name }; } catch (err) { ERR('Error running scheduled backup:', err); return { ran: false, reason: 'error', error: err }; } } // Once per page load: run scheduled backup after login if enabled (async () => { try { const loggedIn = await waitForLogin(20000); if (!loggedIn || !window.KefinTweaksConfig) return; await maybeRunScheduledBackup(window.KefinTweaksConfig); } catch (e) { /* ignore */ } })(); let _watchlistTabIndex = null; async function fetchWatchlistTabIndex() { // Fetch the tab index as we do in addCustomMenuLink try { const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, { method: "GET", headers: { "Content-Type": "application/json", "Authorization": getAuthHeader(), }, }); const data = await response.json(); let tabIndex = null; data.forEach((tab, index) => { if (tab.ContentHtml.indexOf('sections watchlist') !== -1) { tabIndex = index + 2; } }); return tabIndex; } catch (err) { WARN('CustomTabs plugin not found.'); return null; } } async function getWatchlistUrl() { if (window.KefinTweaksUtils._watchlistUrl) { return window.KefinTweaksUtils._watchlistUrl; } const isModernUI = window.KefinTweaksUtils.isModernUI(); const watchlistTabIndex = await getWatchlistTabIndex() || null; let watchlistUrl = '#/watchlist'; if (watchlistTabIndex !== null && watchlistTabIndex !== undefined) { watchlistUrl = `#/home?tab=${watchlistTabIndex}`; } window.KefinTweaksUtils._watchlistUrl = watchlistUrl; return watchlistUrl; } /** * Get watchlist tab index, fetching if not yet set * @returns {number|null} The watchlist tab index or null if not found */ async function getWatchlistTabIndex() { if (_watchlistTabIndex !== null) { return _watchlistTabIndex; } _watchlistTabIndex = await fetchWatchlistTabIndex(); return _watchlistTabIndex; } // ----- Custom pages (hash routes → .customPage in skinBody) ----- const DISALLOWED_CUSTOM_PAGE_SEGMENTS = new Set([ 'home', 'shows', 'movies', 'list', 'playlists', 'collections', 'books', 'livetv', 'music' ]); /** @type {Map} */ const customPageRegistry = new Map(); let customPageViewHandlerUnregister = null; let customPageHashChangeBound = false; let navigationChromeHandlerInstalled = false; /** @type {string|null} */ let activeCustomPageTitle = null; let customPageTitleGuardObserver = null; let customPageTitleGuardApplying = false; let fallbackReadyTimer = null; const FALLBACK_READY_MS = 3000; /** @type {string|null} */ let activePageWatchlistUrl = null; function normalizeCustomPageHref(href) { let raw = String(href || '').trim(); if (!raw) return null; if (!raw.startsWith('#')) raw = `#${raw}`; if (!raw.startsWith('#/')) raw = `#/${raw.slice(1).replace(/^\//, '')}`; // Path only (no query) const path = raw.split('?')[0]; // Collapse trailing slash except "#/" const normalized = path.length > 2 && path.endsWith('/') ? path.slice(0, -1) : path; return normalized; } function getCustomPagePathFromHash(hash = window.location.hash) { const h = String(hash || '').split('?')[0]; if (!h.startsWith('#/')) return null; const normalized = h.length > 2 && h.endsWith('/') ? h.slice(0, -1) : h; return normalized; } function getCustomPageSegment(hrefPath) { const match = String(hrefPath || '').match(/^#\/([^\/\?]+)/); return match ? match[1].toLowerCase() : null; } function isNativeJellyfinMenuHref(url) { const path = normalizeCustomPageHref(url); let segment = getCustomPageSegment(path); if (!segment) return false; if (segment.endsWith('.html')) segment = segment.slice(0, -5); return DISALLOWED_CUSTOM_PAGE_SEGMENTS.has(segment); } function slugifyCustomPageTitle(title) { const slug = String(title || 'page') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return slug || 'page'; } /** Re-apply plugin SoT custom-page CSS (window.KefinTweaksCustomPageStyles). No local CSS blob. */ function ensureCustomPageStyles() { const css = window.KefinTweaksCustomPageStyles; if (typeof css !== 'string' || !css) return; let style = document.getElementById('kefin-custom-page-styles'); if (!style) { style = document.createElement('style'); style.id = 'kefin-custom-page-styles'; (document.head || document.documentElement).appendChild(style); } style.textContent = css; } function syncCustomPageActiveClass(active) { const root = document.getElementById('reactRoot') || document.documentElement; if (!root) return; root.classList.toggle('kefin-custom-page-active', !!active); } function clearFallbackReadyTimer() { if (fallbackReadyTimer != null) { clearTimeout(fallbackReadyTimer); fallbackReadyTimer = null; } } function setFallbackReadyAttr(ready) { const fallbackPage = document.getElementById('fallbackPage'); if (!fallbackPage) return; if (ready) { fallbackPage.setAttribute('data-kefin-fallback-ready', 'true'); } else { fallbackPage.removeAttribute('data-kefin-fallback-ready'); } } /** Reset attr on nav; if no custom page active, show native fallback after 3s. */ function syncFallbackPageReadyState(customPageActive) { clearFallbackReadyTimer(); setFallbackReadyAttr(false); if (customPageActive) return; fallbackReadyTimer = setTimeout(() => { fallbackReadyTimer = null; const root = document.getElementById('reactRoot') || document.documentElement; if (root && !root.classList.contains('kefin-custom-page-active')) { setFallbackReadyAttr(true); } }, FALLBACK_READY_MS); } function updateActivePage() { const hash = String(window.location.hash || ''); let watchlistUrl = activePageWatchlistUrl || window.KefinTweaksUtils?._watchlistUrl || null; if (!watchlistUrl) { getWatchlistUrl().then((url) => { activePageWatchlistUrl = url; updateActivePage(); }).catch(() => { /* ignore */ }); } let page = ''; const tabMatch = hash.match(/[?&]tab=(\d+)/); const tabIndex = tabMatch ? tabMatch[1] : null; if (hash.includes('/home') && tabIndex === '1') { page = 'favorites'; } else if (watchlistUrl && hash.includes(watchlistUrl)) { page = 'watchlist'; } else if (hash === '#/' || hash === '#' || hash === '' || (hash.includes('#/home') && (tabIndex === 0 || tabIndex === null))) { page = 'home'; } else if (hash.includes('#/tv')) { page = 'tv'; } else if (hash.includes('#/movies')) { page = 'movies'; } else if (hash.includes('#/playlists')) { page = 'playlists'; } else if (hash.includes('#/collections')) { page = 'collections'; } else if (hash.includes('#/music')) { page = 'music'; } else if (hash.includes('#/games')) { page = 'games'; } if (document.documentElement.dataset.activePage !== page) { document.documentElement.dataset.activePage = page; } } function emitCustomPageShow(entry, page) { try { if (typeof entry.onShow === 'function') { entry.onShow(page); } } catch (err) { WARN('custom page onShow failed', entry.hrefPath, err); } try { document.dispatchEvent(new CustomEvent('kefin:custompage-show', { detail: { hrefPath: entry.hrefPath, title: entry.title, page } })); } catch (err) { WARN('kefin:custompage-show dispatch failed', err); } } function findCustomPageHost() { const candidates = Array.from(document.querySelectorAll('.skinBody:not(.mainAnimatedPages)')); const withVisiblePage = candidates.find((body) => body.querySelector('.page:not(.hide)')); return withVisiblePage || candidates[0] || null; } function applyCustomPageTitles(title) { const t = String(title || '').trim(); if (!t) return; customPageTitleGuardApplying = true; try { try { document.title = t; } catch (_) { /* ignore */ } document.querySelectorAll('h3.pageTitle').forEach((el) => { if (el.textContent !== t) el.textContent = t; }); } finally { customPageTitleGuardApplying = false; } } function customPageTitlesNeedSync(expected) { if (!expected) return false; if (document.title !== expected) return true; const pageTitles = document.querySelectorAll('h3.pageTitle'); for (const el of pageTitles) { if (el.textContent !== expected) return true; } return false; } function stopCustomPageTitleGuard() { if (customPageTitleGuardObserver) { customPageTitleGuardObserver.disconnect(); customPageTitleGuardObserver = null; } activeCustomPageTitle = null; } function ensureCustomPageTitleGuard(title) { const t = String(title || '').trim(); if (!t) { stopCustomPageTitleGuard(); return; } activeCustomPageTitle = t; applyCustomPageTitles(t); if (customPageTitleGuardObserver) return; const syncIfNeeded = () => { if (customPageTitleGuardApplying || !activeCustomPageTitle) return; if (customPageTitlesNeedSync(activeCustomPageTitle)) { applyCustomPageTitles(activeCustomPageTitle); } }; customPageTitleGuardObserver = new MutationObserver(() => { syncIfNeeded(); }); const titleEl = document.querySelector('head > title') || (() => { const el = document.createElement('title'); (document.head || document.documentElement).appendChild(el); return el; })(); customPageTitleGuardObserver.observe(titleEl, { childList: true, characterData: true, subtree: true }); // childList only: Jellyfin typically replaces pageTitle text via textContent (childList) const root = document.getElementById('reactRoot') || document.body || document.documentElement; if (root) { customPageTitleGuardObserver.observe(root, { childList: true, subtree: true }); } document.querySelectorAll('h3.pageTitle').forEach((el) => { customPageTitleGuardObserver.observe(el, { childList: true, characterData: true, subtree: true }); }); } function reviveScriptsInRoot(root) { if (!root || root.getAttribute('data-kefin-scripts-revived') === 'true') return; const scripts = Array.from(root.querySelectorAll('script')); scripts.forEach((oldScript) => { const neo = document.createElement('script'); Array.from(oldScript.attributes).forEach((attr) => { neo.setAttribute(attr.name, attr.value); }); if (!oldScript.src) { neo.textContent = oldScript.textContent; } oldScript.parentNode.replaceChild(neo, oldScript); }); root.setAttribute('data-kefin-scripts-revived', 'true'); } function ensureCustomPageElement(entry) { if (entry.pageEl && document.body.contains(entry.pageEl)) { return entry.pageEl; } const existing = document.getElementById(`customPage-${entry.safeTitle}`); if (existing) { entry.pageEl = existing; return existing; } const host = findCustomPageHost(); if (!host) return null; const page = document.createElement('div'); page.id = `customPage-${entry.safeTitle}`; page.setAttribute('data-role', 'page'); page.className = 'page customPage mainAnimatedPage libraryPage hide'; page.setAttribute('data-title', entry.title); page.setAttribute('data-backbutton', 'true'); page.setAttribute('data-menubutton', 'false'); page.setAttribute('data-kefin-custom-page', entry.hrefPath); const padded = document.createElement('div'); padded.className = 'padded-left padded-right'; padded.innerHTML = entry.contentHtml || ''; page.appendChild(padded); host.appendChild(page); // innerHTML does not execute scripts — re-create them in order once reviveScriptsInRoot(padded); entry.pageEl = page; return page; } function showCustomPageEntry(entry) { ensureCustomPageStyles(); const page = ensureCustomPageElement(entry); if (!page) return false; page.classList.remove('hide'); page.setAttribute('data-title', entry.title); ensureCustomPageTitleGuard(entry.title); syncCustomPageActiveClass(true); emitCustomPageShow(entry, page); return true; } function hideCustomPageEntry(entry) { let page = entry.pageEl; if (!page || !document.body.contains(page)) { page = document.getElementById(`customPage-${entry.safeTitle}`); } if (!page) { page = Array.from(document.querySelectorAll('[data-kefin-custom-page]')).find( (el) => el.getAttribute('data-kefin-custom-page') === entry.hrefPath ) || null; } if (!page) return; page.classList.add('hide'); entry.pageEl = page; } function syncAllCustomPages() { const currentPath = getCustomPagePathFromHash(); let matched = null; customPageRegistry.forEach((entry) => { if (currentPath && currentPath === entry.hrefPath) { matched = entry; showCustomPageEntry(entry); } else { hideCustomPageEntry(entry); } }); syncCustomPageActiveClass(!!matched); if (!matched) { stopCustomPageTitleGuard(); } syncFallbackPageReadyState(!!matched); } function syncCustomPagesAndTopNavActive() { syncAllCustomPages(); syncCustomTopNavActiveState(); } function ensureNavigationChromeHandler() { if (navigationChromeHandlerInstalled) return; navigationChromeHandlerInstalled = true; const sync = () => { updateActivePage(); syncCustomPagesAndTopNavActive(); }; onViewPage(() => { sync(); }, { pages: [] }); window.addEventListener('hashchange', sync); customPageHashChangeBound = true; sync(); LOG('Navigation chrome handler installed (active-page + custom pages + fallback)'); } function ensureCustomPageViewHandler() { ensureNavigationChromeHandler(); } /** * Register a custom hash-route page rendered into .skinBody. * @param {string} href - e.g. "#/watchlist" (exact path match; query ignored) * @param {string} title - document / page title * @param {string} contentHtml - HTML string for the padded content area * @param {function(HTMLElement): void} [onShow] - called each time the page is shown * @returns {Function|null} unregister function, or null if rejected */ function addCustomPage(href, title, contentHtml, onShow) { const hrefPath = normalizeCustomPageHref(href); if (!hrefPath) { WARN('addCustomPage: invalid href'); return null; } const segment = getCustomPageSegment(hrefPath); if (!segment || DISALLOWED_CUSTOM_PAGE_SEGMENTS.has(segment)) { WARN(`addCustomPage: disallowed route "${hrefPath}"`); return null; } if (contentHtml == null || typeof contentHtml !== 'string') { WARN('addCustomPage: contentHtml must be a string'); return null; } const pageTitle = String(title || segment).trim() || segment; const safeTitle = slugifyCustomPageTitle(pageTitle); const onShowFn = typeof onShow === 'function' ? onShow : null; const existing = customPageRegistry.get(hrefPath); if (existing) { existing.title = pageTitle; existing.contentHtml = contentHtml; existing.safeTitle = safeTitle; if (onShowFn) existing.onShow = onShowFn; // Do not rewrite innerHTML on update if page already mounted (JS may have rendered into it) if (existing.pageEl) { existing.pageEl.setAttribute('data-title', pageTitle); } ensureCustomPageViewHandler(); syncAllCustomPages(); return () => { const entry = customPageRegistry.get(hrefPath); if (!entry) return; hideCustomPageEntry(entry); customPageRegistry.delete(hrefPath); if (customPageRegistry.size === 0) { syncCustomPageActiveClass(false); stopCustomPageTitleGuard(); syncFallbackPageReadyState(false); } else { syncAllCustomPages(); } }; } const entry = { hrefPath, title: pageTitle, contentHtml, pageEl: null, safeTitle, onShow: onShowFn }; customPageRegistry.set(hrefPath, entry); ensureCustomPageStyles(); ensureCustomPageViewHandler(); syncAllCustomPages(); LOG(`Registered custom page: ${hrefPath} ("${pageTitle}")`); return () => { const current = customPageRegistry.get(hrefPath); if (!current) return; hideCustomPageEntry(current); customPageRegistry.delete(hrefPath); if (customPageRegistry.size === 0) { syncCustomPageActiveClass(false); stopCustomPageTitleGuard(); syncFallbackPageReadyState(false); } else { syncAllCustomPages(); } }; } // Expose utilities to global scope window.KefinTweaksUtils = { onViewPage, notifyHandlers, getCurrentView, getHandlerCount, clearHandlers, addCustomMenuLink, ensureCustomMenuLinkStyles, addCustomPage, updateActivePage, saveConfigToJavaScriptInjector, resolvePluginId, getPluginConfiguration, clearPluginIdCache, listKefinConfigBackups, createKefinConfigBackup, deleteKefinConfigBackup, deleteKefinConfigBackups, restoreKefinConfigBackup, pruneKefinConfigBackups, maybeRunScheduledBackup, normalizeBackupsSettings, KEFIN_CONFIG_ENTRY_NAME, KEFIN_CONFIG_SCRIPT_MARKER, getWatchlistTabIndex, waitForApiClient, waitForLogin, isModernUI, getWatchlistUrl }; ensureNavigationChromeHandler(); LOG('Initialized successfully'); LOG('Available at window.KefinTweaksUtils'); })();