Improve onViewPage handler and Custom Page handling

This commit is contained in:
HighImKevin
2026-09-25 05:01:40 -04:00
parent dd041f9acb
commit f1178f82e9
+251 -78
View File
@@ -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<string, { hrefPath: string, title: string, contentHtml: string, pageEl: HTMLElement|null, safeTitle: string }>} */
/** @type {Map<string, { hrefPath: string, title: string, contentHtml: string, pageEl: HTMLElement|null, safeTitle: string, onShow: Function|null }>} */
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');
})();