diff --git a/scripts/utils.js b/scripts/utils.js index 50eb5b4..5d509ed 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -16,41 +16,78 @@ let originalOnViewShow = null; const state = { previousHash: null, + previousHref: null, hookAttempts: 0, maxHookAttempts: 60, + historyBridgeInstalled: false, + embyViewHookInstalled: false, } - + + /** + * Notify onViewPage handlers once per location change (shared by Emby hook + History bridge). + */ + function notifyViewChange(view, element) { + const hash = window.location.hash; + const href = window.location.href; + if (hash === state.previousHash && href === state.previousHref) return; + 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) { + if (window.Emby && window.Emby.Page && window.Emby.Page.onViewShow && !state.embyViewHookInstalled) { originalOnViewShow = window.Emby.Page.onViewShow; LOG('Stored original Emby.Page.onViewShow'); - } - - // Override onViewShow to maintain original functionality - if (window.Emby && window.Emby.Page) { + window.Emby.Page.onViewShow = function (...args) { - // Call original handler if it exists if (originalOnViewShow) { try { - originalOnViewShow.apply(this, ...args); + originalOnViewShow.apply(this, args); } catch (err) { ERR('Error in original onViewShow handler:', err); } } const view = getCurrentView() ?? args[0]; - - // Call our registered handlers - notifyHandlers(view, args[1], window.location.hash, state.previousHash); - state.previousHash = window.location.hash; + notifyViewChange(view, args[1]); }; - + state.embyViewHookInstalled = true; LOG('Hooked into Emby.Page.onViewShow'); - } else { - WARN('Emby.Page.onViewShow not found - utils may not work correctly'); - // Retry in 1 second + } 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); @@ -1040,8 +1077,13 @@ } function findLegacyTabsSlider() { - return document.querySelector('.headerTabs.sectionTabs .emby-tabs-slider') - || document.querySelector('.emby-tabs-slider'); + 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) { @@ -1052,6 +1094,11 @@ } function injectIntoTopNavLegacyTabs(entry) { + if (!document.querySelector('.homePage:not(.hide)')) { + removeLegacyTopNavCustomTabLinks(); + return false; + } + const slider = findLegacyTabsSlider(); if (!slider) return false; @@ -1466,6 +1513,9 @@ } function reapplyAllCustomMenuLinks() { + if (!document.querySelector('.homePage:not(.hide)')) { + removeLegacyTopNavCustomTabLinks(); + } customMenuLinkRegistry.forEach((entry) => { try { injectOneCustomMenuLink(entry); @@ -2355,21 +2405,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; return _watchlistTabIndex; } - // Check if the tab index is already stored in local storage - const storedTabIndex = localStorage.getItem(`kefinTweaks_watchlistTabIndex_${ApiClient.serverId()}`); - if (storedTabIndex) { - _watchlistTabIndex = Number(storedTabIndex); - LOG('Loaded watchlist tab index from local storage:', _watchlistTabIndex); - - // Fetch tab in the background in case it has changed - fetchWatchlistTabIndex(); - return _watchlistTabIndex; - } - _watchlistTabIndex = await fetchWatchlistTabIndex(); - - // Save to local storage - localStorage.setItem(`kefinTweaks_watchlistTabIndex_${ApiClient.serverId()}`, _watchlistTabIndex); return _watchlistTabIndex; } @@ -2377,14 +2413,19 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; const DISALLOWED_CUSTOM_PAGE_SEGMENTS = new Set([ 'home', 'shows', 'movies', 'list', 'playlists', 'collections', 'books', 'livetv', 'music' ]); - /** @type {Map} */ + /** @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(); @@ -2427,53 +2468,179 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; return slug || 'page'; } - /** Idempotent fallback if plugin did not early-inject #kefin-custom-page-styles. */ - function ensureCustomPageStyles() { - if (document.getElementById('kefin-custom-page-styles')) return; - const style = document.createElement('style'); - style.id = 'kefin-custom-page-styles'; - style.textContent = ` -#reactRoot .skinBody:has(.customPage:not(.hide)) #fallbackPage { + const CUSTOM_PAGE_STYLES_CSS = ` +#reactRoot.kefin-custom-page-active .skinBody #fallbackPage { display: none; } -#reactRoot:not(:has(.skinBody .customPage:not(.hide))) .pageTitle { - display: none !important; +#reactRoot:not(.kefin-custom-page-active) .pageTitle { + display: none !important; } -#reactRoot .skinBody:not(:has(.customPage:not(.hide))) #fallbackPage > * { - display: none; +#reactRoot:not(.kefin-custom-page-active):not([data-kefin-fallback-ready]) .skinBody #fallbackPage > * { + display: none; } -#reactRoot:not(:has(.skinBody .customPage:not(.hide))) #fallbackPage::after { - content:''; +#reactRoot:not(.kefin-custom-page-active):not([data-kefin-fallback-ready]) #fallbackPage::after { + content: ''; display: inline-block; width: 20px; height: 20px; border: 3px solid #f3f3f3; border-top: 3px solid #4ecdc4; border-radius: 50%; - animation: spin 1s linear infinite; + animation: spin 1s linear infinite; position: relative; left: 50%; transform: translateX(-50%); top: 1em; } -#reactRoot:has(.customPage.mainAnimatedPage:not(.hide)) .backdropImage { - background: none !important; +#reactRoot[data-kefin-fallback-ready] #fallbackPage::after { + display: none !important; + content: none !important; } +#reactRoot.kefin-custom-page-active .backdropImage { + background: none !important; +} + + header.MuiPaper-root + main.MuiBox-root .customPage { margin-top: 3rem; } + +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :nth-child(n+2), +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon img { + display: none; +} +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon::after { + content: '\\e88a'; + font-family: 'Material Icons'; + font-size: 1.2rem; +} +.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child { + font-size: 0px; +} +main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] { + padding-top: 1rem !important; +} +.layout-mobile :not(main.MuiBox-root) > .skinBody > .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] { + padding-top: 5rem !important; +} `; - (document.head || document.documentElement).appendChild(style); + + /** Always refresh textContent so early-inject :has() rules get replaced on JMP. */ + function ensureCustomPageStyles() { + 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 = CUSTOM_PAGE_STYLES_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 root = document.getElementById('reactRoot') || document.documentElement; + if (!root) return; + if (ready) { + root.setAttribute('data-kefin-fallback-ready', 'true'); + } else { + root.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 homeTabMatch = hash.match(/[?&]tab=(\d+)/); + const homeTab = homeTabMatch ? homeTabMatch[1] : null; + + if (hash.includes('/home') && homeTab === '1') { + page = 'favorites'; + } else if (watchlistUrl && hash.includes(watchlistUrl)) { + page = 'watchlist'; + } else if (hash.includes('#/home') || hash === '#/' || hash === '#' || hash === '') { + 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'; + } + + 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() { - return document.querySelector('.skinBody:not(.mainAnimatedPages):has(.page:not(.hide))') - || document.querySelector('.skinBody:not(.mainAnimatedPages)'); + 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) { @@ -2619,6 +2786,8 @@ header.MuiPaper-root + main.MuiBox-root .customPage { page.classList.remove('hide'); page.setAttribute('data-title', entry.title); ensureCustomPageTitleGuard(entry.title); + syncCustomPageActiveClass(true); + emitCustomPageShow(entry, page); return true; } @@ -2648,9 +2817,11 @@ header.MuiPaper-root + main.MuiBox-root .customPage { hideCustomPageEntry(entry); } }); + syncCustomPageActiveClass(!!matched); if (!matched) { stopCustomPageTitleGuard(); } + syncFallbackPageReadyState(!!matched); } function syncCustomPagesAndTopNavActive() { @@ -2658,24 +2829,24 @@ header.MuiPaper-root + main.MuiBox-root .customPage { syncCustomTopNavActiveState(); } - function ensureCustomPageViewHandler() { - if (customPageViewHandlerUnregister) return; - // onViewShow misses custom→custom hash changes (no real Jellyfin view); - // hashchange covers those; both are fine when they double-fire. - const unregView = onViewPage(() => { + function ensureNavigationChromeHandler() { + if (navigationChromeHandlerInstalled) return; + navigationChromeHandlerInstalled = true; + const sync = () => { + updateActivePage(); syncCustomPagesAndTopNavActive(); - }, { pages: [] }); - if (!customPageHashChangeBound) { - window.addEventListener('hashchange', syncCustomPagesAndTopNavActive); - customPageHashChangeBound = true; - } - customPageViewHandlerUnregister = () => { - if (typeof unregView === 'function') unregView(); - if (customPageHashChangeBound) { - window.removeEventListener('hashchange', syncCustomPagesAndTopNavActive); - customPageHashChangeBound = false; - } }; + onViewPage(() => { + sync(); + }, { pages: [] }); + window.addEventListener('hashchange', sync); + customPageHashChangeBound = true; + sync(); + LOG('Navigation chrome handler installed (active-page + custom pages + fallback)'); + } + + function ensureCustomPageViewHandler() { + ensureNavigationChromeHandler(); } /** @@ -2683,9 +2854,10 @@ header.MuiPaper-root + main.MuiBox-root .customPage { * @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) { + function addCustomPage(href, title, contentHtml, onShow) { const hrefPath = normalizeCustomPageHref(href); if (!hrefPath) { WARN('addCustomPage: invalid href'); @@ -2705,12 +2877,14 @@ header.MuiPaper-root + main.MuiBox-root .customPage { 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); @@ -2723,11 +2897,9 @@ header.MuiPaper-root + main.MuiBox-root .customPage { hideCustomPageEntry(entry); customPageRegistry.delete(hrefPath); if (customPageRegistry.size === 0) { + syncCustomPageActiveClass(false); stopCustomPageTitleGuard(); - if (customPageViewHandlerUnregister) { - customPageViewHandlerUnregister(); - customPageViewHandlerUnregister = null; - } + syncFallbackPageReadyState(false); } else { syncAllCustomPages(); } @@ -2739,7 +2911,8 @@ header.MuiPaper-root + main.MuiBox-root .customPage { title: pageTitle, contentHtml, pageEl: null, - safeTitle + safeTitle, + onShow: onShowFn }; customPageRegistry.set(hrefPath, entry); ensureCustomPageStyles(); @@ -2754,11 +2927,9 @@ header.MuiPaper-root + main.MuiBox-root .customPage { hideCustomPageEntry(current); customPageRegistry.delete(hrefPath); if (customPageRegistry.size === 0) { + syncCustomPageActiveClass(false); stopCustomPageTitleGuard(); - if (customPageViewHandlerUnregister) { - customPageViewHandlerUnregister(); - customPageViewHandlerUnregister = null; - } + syncFallbackPageReadyState(false); } else { syncAllCustomPages(); } @@ -2775,6 +2946,7 @@ header.MuiPaper-root + main.MuiBox-root .customPage { addCustomMenuLink, ensureCustomMenuLinkStyles, addCustomPage, + updateActivePage, saveConfigToJavaScriptInjector, resolvePluginId, getPluginConfiguration, @@ -2786,6 +2958,7 @@ header.MuiPaper-root + main.MuiBox-root .customPage { getWatchlistUrl }; + ensureNavigationChromeHandler(); LOG('Initialized successfully'); LOG('Available at window.KefinTweaksUtils'); })();