diff --git a/scripts/backdropLeakFix.js b/scripts/backdropLeakFix.js index 752f216..d5d673c 100644 --- a/scripts/backdropLeakFix.js +++ b/scripts/backdropLeakFix.js @@ -9,15 +9,11 @@ LOG('Script loaded - JS Injector mode'); - // Step 1: watch for .backdropContainer to appear - const waitForContainer = new MutationObserver(() => { - const container = document.querySelector('.backdropContainer'); - if (!container) return; + function attachPruneObserver(container) { + if (!container || container.dataset.kefinBackdropPrune === 'true') return; + container.dataset.kefinBackdropPrune = 'true'; LOG('Found .backdropContainer, attaching prune observer'); - waitForContainer.disconnect(); - - // Step 2: prune children as needed const pruneObserver = new MutationObserver(() => { LOG('Mutation detected, checking children count:', container.children.length); while (container.children.length > 2) { @@ -25,16 +21,35 @@ container.removeChild(container.firstElementChild); } }); - pruneObserver.observe(container, { childList: true }); LOG('Prune observer is now active'); - }); + } - waitForContainer.observe(document.body, { - childList: true, - subtree: true - }); + function startWait() { + const utils = window.KefinTweaksUtils; + const existing = document.querySelector('.backdropContainer'); + if (existing) { + attachPruneObserver(existing); + return; + } + if (utils?.waitForElement) { + utils.waitForElement('.backdropContainer', { timeout: 60000, quiet: true }).then((container) => { + if (container) attachPruneObserver(container); + else WARN_MISSING(); + }); + return; + } + + // Fallback if utils not ready yet + setTimeout(startWait, 500); + } + + function WARN_MISSING() { + console.warn('[KefinTweaks BackdropLeakFix] .backdropContainer not found within timeout'); + } + + startWait(); LOG('Waiting for .backdropContainer to appear...'); LOG('Backdrop leak fix functionality initialized'); })(); diff --git a/scripts/configuration.js b/scripts/configuration.js index b56dfdc..4af3773 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -6199,13 +6199,9 @@ return; } - if (typeof MutationObserver === 'undefined' || !document.body) { - console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)'); - return; - } - + const utils = window.KefinTweaksUtils; let scheduled = false; - appUserMenuConfigObserver = new MutationObserver(() => { + const onMutations = () => { if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) { return; } @@ -6215,8 +6211,19 @@ scheduled = false; addConfigButtonToAppUserMenu(); }); - }); + }; + if (utils?.onBodyMutation) { + appUserMenuConfigObserver = utils.onBodyMutation('config-appUserMenu', onMutations); + return; + } + + if (typeof MutationObserver === 'undefined' || !document.body) { + console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)'); + return; + } + + appUserMenuConfigObserver = new MutationObserver(onMutations); appUserMenuConfigObserver.observe(document.body, { childList: true, subtree: true diff --git a/scripts/dashboardButtonFix.js b/scripts/dashboardButtonFix.js index e0e8235..3c00381 100644 --- a/scripts/dashboardButtonFix.js +++ b/scripts/dashboardButtonFix.js @@ -4,34 +4,64 @@ // This script sends you to home.html in these scenarios instead of leaving the app (function () { - function attachHandler() { - const btn = document.querySelector('button.MuiButtonBase-root'); - if (!btn) return; + 'use strict'; - if (btn._backCheckAttached) return; + function attachHandler(btn) { + if (!btn || btn._backCheckAttached) return true; btn._backCheckAttached = true; btn.addEventListener('click', function (e) { if (window.history.state?.idx === 0) { - // Stop normal handlers e.stopImmediatePropagation(); e.preventDefault(); - console.log("[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page."); + console.log('[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page.'); - // Changes this to the page you'd like to return to by default const homeUrl = ApiClient.serverVersion().split('.')[1] > 10 ? 'home' : 'home.html'; Dashboard.navigate(`/${homeUrl}`); } }, true); - - observer.disconnect(); + return true; } - // Observe DOM in case button is recreated - const observer = new MutationObserver(attachHandler); - observer.observe(document.body, { childList: true, subtree: true }); + function tryAttach() { + const btn = document.querySelector('button.MuiButtonBase-root'); + return attachHandler(btn); + } - // Initial run - attachHandler(); + function armForDashboard() { + if (tryAttach()) return; + + const utils = window.KefinTweaksUtils; + if (utils?.waitForElement) { + utils.waitForElement('button.MuiButtonBase-root', { + timeout: 15000, + quiet: true, + predicate: (el) => !el._backCheckAttached + }).then((btn) => { + if (btn) attachHandler(btn); + }); + return; + } + if (utils?.pollUntil) { + utils.pollUntil(() => (tryAttach() ? true : null), { intervalMs: 200, timeoutMs: 15000 }); + return; + } + setTimeout(armForDashboard, 500); + } + + function start() { + const utils = window.KefinTweaksUtils; + if (!utils?.onViewPage) { + setTimeout(start, 500); + return; + } + utils.onViewPage(() => { + armForDashboard(); + }, { pages: ['dashboard', 'configurationpage'] }); + // Also try immediately (direct load) + armForDashboard(); + } + + start(); })(); diff --git a/scripts/exclusiveElsewhere.js b/scripts/exclusiveElsewhere.js index 384abfe..df563b4 100644 --- a/scripts/exclusiveElsewhere.js +++ b/scripts/exclusiveElsewhere.js @@ -30,29 +30,70 @@ const config = window.KefinTweaksConfig?.exclusiveElsewhere || { hideServerName: false // Default fallback }; - - const observer = new MutationObserver(() => { + + let pollTimer = null; + + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function tryApplyExclusive() { const elsewhereContainer = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div'); if (elsewhereContainer && elsewhereContainer.children && elsewhereContainer.children.length > 2) { - return; + return true; } - const serverName = config.hideServerName ? '' : ApiClient.serverName(); - + const serverName = config.hideServerName ? '' : (typeof ApiClient !== 'undefined' ? ApiClient.serverName() : ''); const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a'); if (link && !link.classList.contains('exclusive')) { LOG('Modifying elsewhere link to show exclusive branding'); link.innerHTML = `Only available on ${serverName}`; - link.classList.add("exclusive"); - link.title = "Exclusive"; + link.classList.add('exclusive'); + link.title = 'Exclusive'; link.disable = true; + return true; } - }); + return false; + } - observer.observe(document.body, { - childList: true, - subtree: true - }); - - LOG('Exclusive elsewhere functionality initialized'); -})(); \ No newline at end of file + function armItemDetailPoll() { + stopPoll(); + if (tryApplyExclusive()) return; + + const started = Date.now(); + const TTL_MS = 15000; + pollTimer = setInterval(() => { + if (tryApplyExclusive() || Date.now() - started >= TTL_MS) { + stopPoll(); + } + }, 300); + } + + function start() { + const utils = window.KefinTweaksUtils; + if (!utils?.onViewPage) { + setTimeout(start, 500); + return; + } + + utils.onViewPage((view) => { + stopPoll(); + // item / details views + if (view === 'item' || view === 'details' || /item\.html|details/i.test(window.location.hash || '')) { + armItemDetailPoll(); + } + }); + + // Direct load on item page + if (document.querySelector('.itemDetailPage:not(.hide)')) { + armItemDetailPoll(); + } + + LOG('Exclusive elsewhere functionality initialized (onViewPage + TTL poll)'); + } + + start(); +})(); diff --git a/scripts/hamburgerMenu.js b/scripts/hamburgerMenu.js index 1313977..63a359e 100644 --- a/scripts/hamburgerMenu.js +++ b/scripts/hamburgerMenu.js @@ -701,30 +701,37 @@ return isAppVersionAtLeast12(); } - function ensureToolbarObserver() { - if (toolbarObserver || typeof MutationObserver === 'undefined' || !document.body) return; - let scheduled = false; - toolbarObserver = new MutationObserver(() => { - if (scheduled) return; - scheduled = true; - requestAnimationFrame(() => { - scheduled = false; - ensureHamburgerButton(); - }); - }); - toolbarObserver.observe(document.body, { childList: true, subtree: true }); - } - - function start() { - ensureStyles(); + function armToolbarPoll() { ensureHamburgerButton(); - ensureToolbarObserver(); - // Retry briefly for late toolbar mount let tries = 0; const poll = setInterval(() => { tries += 1; if (ensureHamburgerButton() || tries >= 40) clearInterval(poll); }, 250); + } + + function start() { + ensureStyles(); + armToolbarPoll(); + + const utils = window.KefinTweaksUtils; + if (utils?.onViewPage) { + // Toolbar remounts across navigations — short poll per view, no permanent body MO + utils.onViewPage(() => { + armToolbarPoll(); + }); + } else if (utils?.onBodyMutation) { + let scheduled = false; + toolbarObserver = utils.onBodyMutation('hamburger-toolbar', () => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + ensureHamburgerButton(); + }); + }); + } + LOG('Desktop hamburger menu initialized'); } diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index 8c08b1f..841daa7 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -380,20 +380,16 @@ } const HOME_SECTIONS_SELECTOR = '.homePage:not(.hide) #homeTab.is-active .sections'; - let homeSectionsReadyObserver = null; + let homeSectionsReadyWaitGeneration = 0; function disconnectHomeSectionsReadyObserver() { - if (!homeSectionsReadyObserver) return; - homeSectionsReadyObserver.disconnect(); - homeSectionsReadyObserver = null; - LOG('Disconnected home sections ready observer'); + // Cancels in-flight waitForElement/poll by bumping generation + homeSectionsReadyWaitGeneration += 1; + LOG('Cancelled home sections ready wait'); } function connectHomeSectionsReadyObserver() { - if (homeSectionsReadyObserver) { - LOG('Home sections ready observer already connected'); - return; - } + const generation = ++homeSectionsReadyWaitGeneration; const existing = document.querySelector(HOME_SECTIONS_SELECTOR); if (existing) { @@ -402,68 +398,34 @@ return; } - if (typeof MutationObserver === 'undefined') { - WARN('MutationObserver unavailable; cannot wait for home sections container'); - return; - } - - homeSectionsReadyObserver = new MutationObserver(() => { + const onFound = () => { + if (generation !== homeSectionsReadyWaitGeneration) return; if (!document.querySelector(HOME_SECTIONS_SELECTOR)) return; LOG('Home sections container appeared; enhancing'); enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }); - }); - homeSectionsReadyObserver.observe(document.documentElement, { - childList: true, - subtree: true - }); - LOG('Connected home sections ready observer'); - } + }; - /** - * Get section index N from a .sectionN element (e.g. section0 -> 0). - * Returns -1 if no matching class. - */ - function getSectionIndexFromElement(sectionEl) { - const sectionClass = Array.from(sectionEl.classList || []).find(c => /^section\d+$/.test(c)); - if (!sectionClass) return -1; - const match = sectionClass.match(/^section(\d+)$/); - return match ? parseInt(match[1], 10) : -1; - } - - /** - * True when the section is ready to clone: either there are no .itemsContainer descendants, - * or every .itemsContainer in the section has at least one child (items have been rendered). - */ - function sectionHasAllItemsContainersPopulated(sectionEl) { - const itemsContainers = sectionEl.querySelectorAll ? sectionEl.querySelectorAll('.itemsContainer') : []; - if (itemsContainers.length === 0) return false; - return Array.from(itemsContainers).every(container => container.children.length > 0); - } - - /** - * Observe a Jellyfin section node until every .itemsContainer in it has children, then clone and append to kefinHome once. - * Observer is stored in sectionCache.pendingJellyfinSectionObservers so handleJellyfinRender can disconnect it on re-run. - */ - function observeSectionUntilChildrenThenClone(sectionEl, kefinHome) { - if (!sectionEl || typeof MutationObserver === 'undefined') return; - const observer = new MutationObserver(() => { - if (!sectionHasAllItemsContainersPopulated(sectionEl)) return; - const target = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer'); - if (!target) { - observer.disconnect(); - const idx = sectionCache.pendingJellyfinSectionObservers.indexOf(observer); - if (idx !== -1) sectionCache.pendingJellyfinSectionObservers.splice(idx, 1); - return; - } - const clone = sectionEl.cloneNode(true); - clone.dataset.jellyfinSectionClone = 'true'; - target.appendChild(clone); - observer.disconnect(); - const idx = sectionCache.pendingJellyfinSectionObservers.indexOf(observer); - if (idx !== -1) sectionCache.pendingJellyfinSectionObservers.splice(idx, 1); - }); - sectionCache.pendingJellyfinSectionObservers.push(observer); - observer.observe(sectionEl, { childList: true, subtree: true }); + const utils = window.KefinTweaksUtils; + if (utils?.waitForElement) { + LOG('Waiting for home sections via waitForElement'); + utils.waitForElement(HOME_SECTIONS_SELECTOR, { timeout: 20000, quiet: true }).then((el) => { + if (el) onFound(); + }); + return; + } + if (utils?.pollUntil) { + LOG('Waiting for home sections via pollUntil'); + utils.pollUntil( + () => (generation === homeSectionsReadyWaitGeneration + ? document.querySelector(HOME_SECTIONS_SELECTOR) + : true), + { intervalMs: 200, timeoutMs: 20000 } + ).then((el) => { + if (el && el !== true) onFound(); + }); + return; + } + WARN('KefinTweaksUtils wait helpers unavailable; cannot wait for home sections container'); } function mulberry32(seed) { @@ -2177,15 +2139,36 @@ syncCategoryRailTvFocus(); } + function disconnectHomeChromeVisibilityObserver() { + if (!homeChromeVisibilityObserver) return; + homeChromeVisibilityObserver.disconnect(); + homeChromeVisibilityObserver = null; + } + function ensureHomeChromeVisibilityObserver() { - if (homeChromeVisibilityObserver) return; + // Attributes+class needed for tab hide/show — dedicated MO, page-scoped when possible. + // Disconnect off home so we do not keep a body-wide attributes+subtree watcher alive. + if (!isHomePageVisible()) { + disconnectHomeChromeVisibilityObserver(); + return; + } + + const homePage = document.querySelector('.homePage:not(.hide)') || document.querySelector('.homePage'); + const target = homePage || document.documentElement; + const config = homePage + ? { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] } + : { attributes: true, attributeFilter: ['data-active-page', 'class'] }; + + if (homeChromeVisibilityObserver) { + const sameTarget = homeChromeVisibilityObserver.__kefinTarget === target; + const sameMode = homeChromeVisibilityObserver.__kefinNarrow === !homePage; + if (sameTarget && sameMode) return; + disconnectHomeChromeVisibilityObserver(); + } homeChromeVisibilityObserver = new MutationObserver(() => syncHomeScreenChromeVisibility()); - homeChromeVisibilityObserver.observe(document.body, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['class'] - }); + homeChromeVisibilityObserver.__kefinTarget = target; + homeChromeVisibilityObserver.__kefinNarrow = !homePage; + homeChromeVisibilityObserver.observe(target, config); } async function mountCreateSectionButton() { @@ -2810,6 +2793,7 @@ if (next.type === 'chrome') { syncHomeScreenChromeVisibility(); + ensureHomeChromeVisibilityObserver(); return; } @@ -2822,6 +2806,7 @@ if (currentTabIndex !== 0) { syncHomeScreenChromeVisibility(); + ensureHomeChromeVisibilityObserver(); return; } @@ -2830,6 +2815,7 @@ }); await enhanceHomeScreen(); syncHomeScreenChromeVisibility(); + ensureHomeChromeVisibilityObserver(); } }) .catch((err) => { diff --git a/scripts/homeScreenPin.js b/scripts/homeScreenPin.js index 6306934..59e04c4 100644 --- a/scripts/homeScreenPin.js +++ b/scripts/homeScreenPin.js @@ -862,8 +862,9 @@ document.addEventListener('contextmenu', captureFromEvent, true); - const observer = new MutationObserver((mutations) => { - mutations.forEach(mutation => { + const onMutations = (mutations) => { + for (const mutation of mutations) { + if (mutation.type !== 'childList') continue; mutation.addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE) return; const sheets = node.matches?.('.actionSheetContent') @@ -871,9 +872,16 @@ : [...(node.querySelectorAll?.('.actionSheetContent') || [])]; sheets.forEach(injectPinActionSheetItem); }); - }); - }); - observer.observe(document.body, { childList: true, subtree: true }); + } + }; + + const utils = window.KefinTweaksUtils; + if (utils?.onBodyMutation) { + utils.onBodyMutation('homeScreenPin-actionSheet', onMutations); + } else if (typeof MutationObserver !== 'undefined' && document.body) { + const observer = new MutationObserver(onMutations); + observer.observe(document.body, { childList: true, subtree: true }); + } } function isPinnedSectionId(sectionId) { @@ -1245,8 +1253,9 @@ document.querySelectorAll('.cardOverlayContainer').forEach(processOverlay); document.querySelectorAll('[data-section-id^="pinned-list-"] .card[data-id]').forEach(processCard); - const observer = new MutationObserver((mutations) => { - mutations.forEach(mutation => { + const onMutations = (mutations) => { + for (const mutation of mutations) { + if (mutation.type !== 'childList') continue; mutation.addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.classList?.contains('cardOverlayContainer')) processOverlay(node); @@ -1254,9 +1263,16 @@ if (node.classList?.contains('card')) processCard(node); node.querySelectorAll?.('.card[data-id]').forEach(processCard); }); - }); - }); - observer.observe(document.body, { childList: true, subtree: true }); + } + }; + + const utils = window.KefinTweaksUtils; + if (utils?.onBodyMutation) { + utils.onBodyMutation('homeScreenPin-pinnedCards', onMutations); + } else if (typeof MutationObserver !== 'undefined' && document.body) { + const observer = new MutationObserver(onMutations); + observer.observe(document.body, { childList: true, subtree: true }); + } } function isPinnedParentSection(sectionId) { diff --git a/scripts/infiniteScroll.js b/scripts/infiniteScroll.js index be7a9d3..c6c22e5 100644 --- a/scripts/infiniteScroll.js +++ b/scripts/infiniteScroll.js @@ -787,21 +787,46 @@ } // Scroll state loading removed - not needed - - // Wait for page to mount - const observer = new MutationObserver(() => { + + function tryInitializeFromView() { if (shouldInitialize()) { initialize().catch(err => ERR('Initialization failed:', err)); + return true; } - }); - observer.observe(document.body, { childList: true, subtree: true }); - - // Also try to initialize immediately if page is already loaded - if (document.readyState === 'complete') { - setTimeout(() => { - initialize().catch(err => ERR('Initial initialization failed:', err)); - }, 100); + return false; } + + function armLibraryPageInit() { + if (tryInitializeFromView()) return; + const utils = window.KefinTweaksUtils; + if (utils?.pollUntil) { + utils.pollUntil(() => (tryInitializeFromView() ? true : null), { + intervalMs: 200, + timeoutMs: 15000 + }); + return; + } + // Fallback brief poll + let tries = 0; + const timer = setInterval(() => { + tries += 1; + if (tryInitializeFromView() || tries >= 75) clearInterval(timer); + }, 200); + } + + function startInfiniteScrollHook() { + const utils = window.KefinTweaksUtils; + if (!utils?.onViewPage) { + setTimeout(startInfiniteScrollHook, 500); + return; + } + utils.onViewPage(() => { + armLibraryPageInit(); + }, { pages: SUPPORTED_PAGES }); + armLibraryPageInit(); + } + + startInfiniteScrollHook(); // Cleanup function function cleanup() { diff --git a/scripts/playlist.js b/scripts/playlist.js index 7b3faf2..2a22737 100644 --- a/scripts/playlist.js +++ b/scripts/playlist.js @@ -1242,30 +1242,28 @@ } function setupPlaylistCardPlayObserver() { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type !== 'childList') return; - mutation.addedNodes.forEach((node) => { - if (node.nodeType !== Node.ELEMENT_NODE) return; + const processNode = (node) => { + if (!node || node.nodeType !== Node.ELEMENT_NODE) return; + if (node.classList?.contains('cardOverlayContainer')) { + overridePlaylistCardPlayButton(node); + } + node.querySelectorAll?.('.cardOverlayContainer').forEach(overridePlaylistCardPlayButton); + }; - if (node.classList && node.classList.contains('cardOverlayContainer')) { - overridePlaylistCardPlayButton(node); - } + const onMutations = (mutations) => { + for (const mutation of mutations) { + if (mutation.type !== 'childList') continue; + mutation.addedNodes.forEach(processNode); + } + }; - const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer'); - if (overlayContainers && overlayContainers.length > 0) { - overlayContainers.forEach((overlayContainer) => { - overridePlaylistCardPlayButton(overlayContainer); - }); - } - }); - }); - }); - - observer.observe(document.body, { - childList: true, - subtree: true - }); + const utils = window.KefinTweaksUtils; + if (utils?.onBodyMutation) { + utils.onBodyMutation('playlist-cardPlay', onMutations); + } else if (typeof MutationObserver !== 'undefined' && document.body) { + const observer = new MutationObserver(onMutations); + observer.observe(document.body, { childList: true, subtree: true }); + } processExistingPlaylistCardOverlays(); } diff --git a/scripts/removeContinue.js b/scripts/removeContinue.js index b3453a9..afb1955 100644 --- a/scripts/removeContinue.js +++ b/scripts/removeContinue.js @@ -237,50 +237,47 @@ }); } - // Set up MutationObserver to watch for new overlay containers + // Shared body observer: inject when card overlays appear function setupRemoveContinueButtonObserver() { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'childList') { - // Check for added nodes - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - // Check if the added node is an overlay container - if (node.classList && node.classList.contains('cardOverlayContainer')) { - const buttonContainer = node.querySelector('.cardOverlayButton-br'); - if (buttonContainer) { - addRemoveContinueButton(node); - } - } - - // Check for overlay containers within the added node - const overlayContainers = node.querySelectorAll && node.querySelectorAll('.cardOverlayContainer'); - if (overlayContainers && overlayContainers.length > 0) { - overlayContainers.forEach((overlayContainer) => { - const buttonContainer = overlayContainer.querySelector('.cardOverlayButton-br'); - if (buttonContainer) { - addRemoveContinueButton(overlayContainer); - } - }); - } - } - }); + const processNode = (node) => { + if (!node || node.nodeType !== Node.ELEMENT_NODE) return; + if (node.classList?.contains('cardOverlayContainer')) { + if (node.querySelector('.cardOverlayButton-br')) addRemoveContinueButton(node); + } + node.querySelectorAll?.('.cardOverlayContainer').forEach((overlayContainer) => { + if (overlayContainer.querySelector('.cardOverlayButton-br')) { + addRemoveContinueButton(overlayContainer); } }); - }); - - // Start observing - observer.observe(document.body, { - childList: true, - subtree: true - }); - - // Process any existing overlay containers + }; + + const onMutations = (mutations) => { + for (const mutation of mutations) { + if (mutation.type !== 'childList') continue; + mutation.addedNodes.forEach(processNode); + } + }; + + const utils = window.KefinTweaksUtils; + if (utils?.onBodyMutation) { + utils.onBodyMutation('removeContinue-overlays', onMutations); + } else if (typeof MutationObserver !== 'undefined' && document.body) { + const observer = new MutationObserver(onMutations); + observer.observe(document.body, { childList: true, subtree: true }); + } + processExistingOverlayContainers(); } - // Initialize the observer - setupRemoveContinueButtonObserver(); + function startRemoveContinue() { + if (!window.KefinTweaksUtils?.onBodyMutation && !document.body) { + setTimeout(startRemoveContinue, 500); + return; + } + setupRemoveContinueButtonObserver(); + } + + startRemoveContinue(); // Debug function for troubleshooting (available in console) window.debugRemoveContinueButtons = function() { diff --git a/scripts/skinManager.js b/scripts/skinManager.js index da2134d..e8c20f6 100644 --- a/scripts/skinManager.js +++ b/scripts/skinManager.js @@ -1341,7 +1341,11 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; dialog.style.position = 'absolute'; dialog.style.minWidth = '280px'; dialog.style.pointerEvents = 'auto'; - dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; + if (typeof window.ModalSystem?.applyOpenAnimation === 'function') { + window.ModalSystem.applyOpenAnimation(dialog); + } else { + dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; + } // Create dialog content const dialogContent = document.createElement('div'); diff --git a/scripts/utils.js b/scripts/utils.js index a73cdb4..55fc81d 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -1711,12 +1711,15 @@ function disconnectCustomMenuLinkObserver() { if (!customMenuLinkObserver) return; - customMenuLinkObserver.disconnect(); + if (typeof customMenuLinkObserver.disconnect === 'function') { + customMenuLinkObserver.disconnect(); + } + offBodyMutation('customMenuLinks'); customMenuLinkObserver = null; } /** - * Persistent MO: any custom-menu host remount → idempotent reapplyAll. + * Shared body subscriber: any custom-menu host remount → idempotent reapplyAll. * No host is assumed stable (drawer, prefs, #app-user-menu, top-nav). */ function ensureCustomMenuLinkObserver() { @@ -1724,7 +1727,7 @@ disconnectCustomMenuLinkObserver(); return; } - if (customMenuLinkObserver || typeof MutationObserver === 'undefined' || !document.body) { + if (customMenuLinkObserver) { return; } @@ -1737,7 +1740,7 @@ reapplyAllCustomMenuLinks(); }); }; - customMenuLinkObserver = new MutationObserver((mutations) => { + const onMutations = (mutations) => { if (customMenuLinkReapplyDepth > 0) return; if (!customMenuLinkRegistry.length) { disconnectCustomMenuLinkObserver(); @@ -1750,12 +1753,9 @@ } if (!mutations.some(mutationShouldTriggerCustomMenuReapply)) return; scheduleCustomMenuReapply(); - }); + }; - customMenuLinkObserver.observe(document.body, { - childList: true, - subtree: true - }); + customMenuLinkObserver = onBodyMutation('customMenuLinks', onMutations); } /** @@ -3051,6 +3051,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; customPageTitleGuardObserver.disconnect(); customPageTitleGuardObserver = null; } + offBodyMutation('customPageTitle-discover'); activeCustomPageTitle = null; } @@ -3087,21 +3088,29 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; 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) => { + // Narrow watch: page title nodes only (no body/reactRoot subtree) + const observePageTitleEl = (el) => { + if (!el || el.dataset?.kefinTitleGuardObserved === 'true') return; + if (el.dataset) el.dataset.kefinTitleGuardObserved = 'true'; customPageTitleGuardObserver.observe(el, { childList: true, characterData: true, subtree: true }); + }; + + document.querySelectorAll('h3.pageTitle').forEach(observePageTitleEl); + + // When new h3.pageTitle nodes mount, attach narrow observers via shared body MO + onBodyMutation('customPageTitle-discover', (mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (node.matches?.('h3.pageTitle')) observePageTitleEl(node); + node.querySelectorAll?.('h3.pageTitle').forEach(observePageTitleEl); + } + } + syncIfNeeded(); }); } @@ -3313,6 +3322,384 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; }; } + // ── Shared DOM observation (body MO multiplexer) ───────────────────────── + // Inventory / migration waves (body + childList + subtree unless noted): + // Wave A (oneshot / onViewPage+TTL): infiniteScroll, dashboardButtonFix, + // backdropLeakFix wait, homeSectionsReadyObserver, exclusiveElsewhere + // Wave B (shared body — overlays): removeContinue, homeScreenPin×2, playlist card play + // Wave C (shared body — chrome): customMenuLinkObserver, hamburger toolbar, config user-menu + // Wave D (scoped / attributes): watchlist empty (page-scoped attrs), discovery→shared, + // slides wait→waitForElement, homeChromeVisibility (home-rooted + off-home disconnect), + // pageTitleGuard (title + h3.pageTitle only; discover via shared) + // Extra: updoot SPA reinit → onViewPage (was body childList+subtree) + // Prefer onViewPage + waitForElement/poll for one-shots; shared body only for ongoing inject. + // Measure: KefinTweaksUtils.debugBodyObservers() — expect one shared body MO + listed subscribers. + + const bodySubscribers = new Map(); + /** @type {MutationObserver|null} */ + let bodyObserver = null; + /** @type {Map} */ + const managedObservers = new Map(); + /** @type {MutationRecord[]} */ + let pendingBodyMutations = []; + let bodyFlushScheduled = false; + let bodyFlushGeneration = 0; + /** @type {Function[]} */ + let paintQueue = []; + let paintScheduled = false; + let waitForElementSeq = 0; + + const PLAYER_NOISE_SELECTOR = + '.osdPositionText, .osdDurationText, .startTimeText, .endTimeText, .endsAtText, .videoSubtitlesInner, .videoSecondarySubtitlesInner'; + + function runPaintQueue() { + paintScheduled = false; + const batch = paintQueue; + paintQueue = []; + for (const fn of batch) { + try { + fn(); + } catch (err) { + ERR('Error in afterNextPaint callback:', err); + } + } + } + + /** + * Run fn after the next paint (shared rAF + timeout fallback for hidden tabs). + * @param {Function} fn + */ + function afterNextPaint(fn) { + paintQueue.push(fn); + if (paintScheduled) return; + paintScheduled = true; + let done = false; + const run = () => { + if (done) return; + done = true; + clearTimeout(fallback); + runPaintQueue(); + }; + const fallback = setTimeout(run, 250); + requestAnimationFrame(() => setTimeout(run, 0)); + } + + function isStructuralChange(record) { + if (record.addedNodes.length === 0 && record.removedNodes.length === 0) return false; + const target = record.target; + return !(target.nodeType === 1 && /** @type {Element} */ (target).closest(PLAYER_NOISE_SELECTOR)); + } + + function flushPendingBodyMutations() { + bodyFlushScheduled = false; + const batch = pendingBodyMutations; + pendingBodyMutations = []; + if (batch.length === 0) return; + for (const [id, sub] of Array.from(bodySubscribers)) { + if (bodySubscribers.get(id) !== sub) continue; + if (sub.priority > 0) continue; + try { + sub.callback(batch); + } catch (err) { + ERR(`Error in body observer subscriber "${id}":`, err); + } + } + } + + function resortBodySubscribers() { + const sorted = [...bodySubscribers.entries()].sort((a, b) => b[1].priority - a[1].priority); + bodySubscribers.clear(); + for (const [id, sub] of sorted) { + bodySubscribers.set(id, sub); + } + } + + function ensureBodyObserver() { + if (bodyObserver || typeof MutationObserver === 'undefined' || !document.body) return; + bodyObserver = new MutationObserver((mutations) => { + let hasStructuralChange = false; + for (let i = 0; i < mutations.length; i++) { + if (isStructuralChange(mutations[i])) { + hasStructuralChange = true; + break; + } + } + if (!hasStructuralChange) return; + + for (const [id, sub] of Array.from(bodySubscribers)) { + if (bodySubscribers.get(id) !== sub) continue; + if (sub.priority <= 0) break; + try { + sub.callback(mutations); + } catch (err) { + ERR(`Error in body observer subscriber "${id}":`, err); + } + } + + for (let i = 0; i < mutations.length; i++) pendingBodyMutations.push(mutations[i]); + if (!bodyFlushScheduled) { + bodyFlushScheduled = true; + const generation = bodyFlushGeneration; + afterNextPaint(() => { + if (generation === bodyFlushGeneration) flushPendingBodyMutations(); + }); + } + }); + bodyObserver.observe(document.body, { childList: true, subtree: true }); + LOG('Shared body observer started'); + } + + function stopBodyObserverIfEmpty() { + if (bodyObserver && bodySubscribers.size === 0) { + bodyObserver.disconnect(); + bodyObserver = null; + bodyFlushGeneration++; + bodyFlushScheduled = false; + pendingBodyMutations = []; + LOG('Shared body observer stopped (no subscribers)'); + } + } + + /** + * Register a callback on the shared document.body MutationObserver. + * @param {string} id + * @param {Function} callback - (mutations: MutationRecord[]) => void + * @param {{ priority?: number }} [options] + * @returns {{ unsubscribe: Function, disconnect: Function }} + */ + function onBodyMutation(id, callback, options = {}) { + if (typeof callback !== 'function') { + ERR('onBodyMutation callback must be a function'); + return { unsubscribe() {}, disconnect() {} }; + } + const priority = typeof options.priority === 'number' ? options.priority : 0; + if (bodySubscribers.has(id)) { + WARN(`Replacing body observer subscriber: ${id}`); + } + const subscription = { callback, priority }; + bodySubscribers.set(id, subscription); + resortBodySubscribers(); + ensureBodyObserver(); + const cleanup = () => { + if (bodySubscribers.get(id) !== subscription) return; + bodySubscribers.delete(id); + stopBodyObserverIfEmpty(); + }; + return { unsubscribe: cleanup, disconnect: cleanup }; + } + + function offBodyMutation(id) { + const removed = bodySubscribers.delete(id); + if (removed) stopBodyObserverIfEmpty(); + return removed; + } + + /** + * Create a managed observer. Body + childList+subtree (no attributes/characterData) + * is routed to the shared multiplexer. + * @param {string} id + * @param {MutationCallback} callback + * @param {Node} target + * @param {MutationObserverInit} config + * @returns {{ disconnect: Function, unsubscribe: Function, observe: Function, takeRecords: Function }} + */ + function createManagedObserver(id, callback, target, config) { + if (managedObservers.has(id)) { + managedObservers.get(id).disconnect(); + managedObservers.delete(id); + } + + const isBodyTarget = + target === document.body || target === document.documentElement || target === document; + const isSubtreeWatch = !!(config && config.childList && config.subtree); + const isSimpleChildList = + isSubtreeWatch && !config.attributes && !config.attributeFilter && !config.characterData; + + if (isBodyTarget && isSimpleChildList) { + const handle = onBodyMutation(id, callback); + const cleanup = () => { + handle.disconnect(); + managedObservers.delete(id); + }; + const proxy = { + disconnect: cleanup, + unsubscribe: cleanup, + observe() {}, + takeRecords() { + return []; + } + }; + managedObservers.set(id, proxy); + return proxy; + } + + if (typeof MutationObserver === 'undefined' || !target) { + const noop = { disconnect() {}, unsubscribe() {}, observe() {}, takeRecords() { return []; } }; + managedObservers.set(id, noop); + return noop; + } + + const observer = new MutationObserver(callback); + observer.observe(target, config); + const wrap = { + disconnect() { + observer.disconnect(); + managedObservers.delete(id); + }, + unsubscribe() { + wrap.disconnect(); + }, + observe(t, c) { + observer.observe(t, c); + }, + takeRecords() { + return observer.takeRecords(); + } + }; + managedObservers.set(id, wrap); + return wrap; + } + + function disconnectManagedObserver(id) { + if (bodySubscribers.has(id)) { + offBodyMutation(id); + managedObservers.delete(id); + return true; + } + if (managedObservers.has(id)) { + managedObservers.get(id).disconnect(); + managedObservers.delete(id); + return true; + } + return false; + } + + /** + * Wait for an element (uses shared body observer for re-checks). + * @param {string} selector + * @param {number|{ timeout?: number, root?: ParentNode, predicate?: (el: Element) => boolean, quiet?: boolean }} [options] + * @returns {Promise} + */ + function waitForElement(selector, options = {}) { + const opts = typeof options === 'number' ? { timeout: options } : options || {}; + const timeout = opts.timeout ?? 10000; + const root = opts.root || document; + const predicate = typeof opts.predicate === 'function' ? opts.predicate : null; + + const find = () => { + if (!predicate) return root.querySelector(selector); + for (const el of Array.from(root.querySelectorAll(selector))) { + try { + if (predicate(el)) return el; + } catch (_) { + /* treat throw as non-match */ + } + } + return null; + }; + + return new Promise((resolve) => { + const existing = find(); + if (existing) { + resolve(existing); + return; + } + + const observerId = `wait-${selector}-${++waitForElementSeq}`; + let timeoutId = null; + const handle = onBodyMutation(observerId, () => { + const element = find(); + if (element) { + if (timeoutId) clearTimeout(timeoutId); + handle.unsubscribe(); + resolve(element); + } + }); + + timeoutId = setTimeout(() => { + handle.unsubscribe(); + if (!opts.quiet) { + WARN(`Timeout waiting for element: ${selector}`); + } + resolve(null); + }, timeout); + }); + } + + /** + * Poll until fn returns truthy or timeout. Useful when onViewPage fires before DOM settles. + * @param {() => *} fn + * @param {{ intervalMs?: number, timeoutMs?: number }} [options] + * @returns {Promise<*>} + */ + function pollUntil(fn, options = {}) { + const intervalMs = options.intervalMs ?? 100; + const timeoutMs = options.timeoutMs ?? 10000; + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + try { + const value = fn(); + if (value) { + resolve(value); + return; + } + } catch (err) { + ERR('pollUntil callback error:', err); + } + if (Date.now() - start >= timeoutMs) { + resolve(null); + return; + } + setTimeout(tick, intervalMs); + }; + tick(); + }); + } + + function getBodySubscriberCount() { + return bodySubscribers.size; + } + + function listBodySubscriberIds() { + return Array.from(bodySubscribers.keys()); + } + + function getManagedObserverCount() { + return managedObservers.size; + } + + /** + * Debug: log shared-body subscriber ids + managed dedicated observer count. + * Steady-state (utils loaded, overlays/chrome subscribed) should show one live + * body childList+subtree MutationObserver with these typical ids: + * customMenuLinks, removeContinue-overlays, homeScreenPin-actionSheet, + * homeScreenPin-pinnedCards, playlist-cardPlay, config-appUserMenu, + * watchlist-overlay-discovery, (+ customPageTitle-discover when a custom page is active) + */ + function debugBodyObservers() { + const ids = listBodySubscriberIds(); + LOG('Shared body MO active:', !!bodyObserver, '| subscribers:', ids.length, ids); + LOG('Managed dedicated observers:', getManagedObserverCount()); + return { bodyObserverActive: !!bodyObserver, subscribers: ids, managed: getManagedObserverCount() }; + } + + window.addEventListener('beforeunload', () => { + managedObservers.forEach((obs) => { + try { + obs.disconnect(); + } catch (_) {} + }); + managedObservers.clear(); + bodySubscribers.clear(); + if (bodyObserver) { + bodyObserver.disconnect(); + bodyObserver = null; + } + bodyFlushGeneration++; + pendingBodyMutations = []; + }); + // Expose utilities to global scope window.KefinTweaksUtils = { onViewPage, @@ -3342,7 +3729,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; waitForApiClient, waitForLogin, isModernUI, - getWatchlistUrl + getWatchlistUrl, + onBodyMutation, + offBodyMutation, + createManagedObserver, + disconnectManagedObserver, + waitForElement, + pollUntil, + afterNextPaint, + getBodySubscriberCount, + listBodySubscriberIds, + getManagedObserverCount, + debugBodyObservers }; ensureNavigationChromeHandler(); diff --git a/scripts/watchlist.js b/scripts/watchlist.js index fe9ed40..9867b45 100644 --- a/scripts/watchlist.js +++ b/scripts/watchlist.js @@ -7153,7 +7153,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } } - // Set up observer to watch for watchlist section mount (custom page / library page) + // Page-scoped observer for watchlist section remount (attributes+class required) function setupWatchlistSectionObserver() { function isVisibleLibraryOrCustomPage(el) { return el && @@ -7170,34 +7170,17 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: return isVisibleLibraryOrCustomPage(node); } - const observer = new MutationObserver((mutations) => { - let shouldCheck = false; - mutations.forEach((mutation) => { - if (mutation.type === 'childList') { - mutation.addedNodes.forEach((node) => { - if (nodeMayContainWatchlist(node)) shouldCheck = true; - }); - } - if (mutation.type === 'attributes' && mutation.attributeName === 'class') { - const target = mutation.target; - if (isVisibleLibraryOrCustomPage(target)) shouldCheck = true; - } - }); - if (shouldCheck) { - checkForEmptyWatchlist(); - } - }); + let emptyWatchlistObserver = null; - observer.observe(document.body, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['class'] - }); + function disconnectEmptyWatchlistObserver() { + if (!emptyWatchlistObserver) return; + emptyWatchlistObserver.disconnect(); + emptyWatchlistObserver = null; + } function checkForEmptyWatchlist() { const onWatchlistPage = window.location.pathname.includes(window.KefinTweaksUtils._watchlistUrl) || window.location.hash.includes(window.KefinTweaksUtils._watchlistUrl); - + if (!onWatchlistPage) { return; } @@ -7216,10 +7199,60 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }); } - // In case #/watchlist is already visible - checkForEmptyWatchlist(); - } + function armEmptyWatchlistObserver() { + const onWatchlistPage = window.location.pathname.includes(window.KefinTweaksUtils._watchlistUrl) || window.location.hash.includes(window.KefinTweaksUtils._watchlistUrl); + if (!onWatchlistPage) { + disconnectEmptyWatchlistObserver(); + return; + } + checkForEmptyWatchlist(); + + const pageRoot = + document.querySelector('.libraryPage:not(.hide)') || + document.querySelector('.customPage:not(.hide)') || + document.querySelector('.homePage:not(.hide)'); + const target = pageRoot || document.body; + + if (emptyWatchlistObserver && emptyWatchlistObserver.__kefinTarget === target) { + return; + } + disconnectEmptyWatchlistObserver(); + + emptyWatchlistObserver = new MutationObserver((mutations) => { + let shouldCheck = false; + for (const mutation of mutations) { + if (mutation.type === 'childList') { + for (const node of mutation.addedNodes) { + if (nodeMayContainWatchlist(node)) { + shouldCheck = true; + break; + } + } + } else if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + if (isVisibleLibraryOrCustomPage(mutation.target)) shouldCheck = true; + } + if (shouldCheck) break; + } + if (shouldCheck) checkForEmptyWatchlist(); + }); + emptyWatchlistObserver.__kefinTarget = target; + emptyWatchlistObserver.observe(target, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'] + }); + } + + const utils = window.KefinTweaksUtils; + if (utils?.onViewPage) { + utils.onViewPage(() => { + armEmptyWatchlistObserver(); + }); + } + armEmptyWatchlistObserver(); + } // Initialize the watchlist section observer setupWatchlistSectionObserver(); @@ -7808,7 +7841,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: root.querySelectorAll?.('.itemsContainer').forEach(attachItemsContainerObserver); } - const discoveryObserver = new MutationObserver((mutations) => { + function onDiscoveryMutations(mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; @@ -7820,12 +7853,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: discoverItemsContainers(node); } } - }); + } - discoveryObserver.observe(document.body, { - childList: true, - subtree: true - }); + const utils = window.KefinTweaksUtils; + if (utils?.onBodyMutation) { + utils.onBodyMutation('watchlist-overlay-discovery', onDiscoveryMutations); + } else if (typeof MutationObserver !== 'undefined' && document.body) { + const discoveryObserver = new MutationObserver(onDiscoveryMutations); + discoveryObserver.observe(document.body, { childList: true, subtree: true }); + } discoverItemsContainers(document.body); processExistingOverlayContainers(); @@ -8065,72 +8101,57 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Function to monitor slides container for MediaBar Plugin function monitorSlidesContainer() { - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'childList') { - // Check for added nodes - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - // Check if the added node is a slide - if (node.classList && node.classList.contains('slide')) { - addSlideWatchlistButton(node); - } - - // Check for slides within the added node - const slides = node.querySelectorAll && node.querySelectorAll('.slide'); - if (slides && slides.length > 0) { - slides.forEach((slide) => { - addSlideWatchlistButton(slide); - }); - } - } + const slidesObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type !== 'childList') continue; + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (node.classList?.contains('slide')) { + addSlideWatchlistButton(node); + } + node.querySelectorAll?.('.slide')?.forEach((slide) => { + addSlideWatchlistButton(slide); }); } - }); + } }); - // Check if slides container already exists - const slidesContainer = document.querySelector('#slides-container'); - if (slidesContainer) { - // Process existing slides - const existingSlides = slidesContainer.querySelectorAll('.slide'); - existingSlides.forEach((slide) => { + function bindSlidesContainer(slidesContainer) { + if (!slidesContainer) return; + slidesContainer.querySelectorAll('.slide').forEach((slide) => { addSlideWatchlistButton(slide); }); - - // Start observing the slides container - observer.observe(slidesContainer, { + slidesObserver.observe(slidesContainer, { childList: true, subtree: true }); } - // Also watch for slides container to be added - const containerObserver = new MutationObserver(() => { - const slidesContainer = document.querySelector('#slides-container'); - if (slidesContainer) { - // Process existing slides - const existingSlides = slidesContainer.querySelectorAll('.slide'); - existingSlides.forEach((slide) => { - addSlideWatchlistButton(slide); - }); + const existing = document.querySelector('#slides-container'); + if (existing) { + bindSlidesContainer(existing); + return; + } - // Start observing the slides container - observer.observe(slidesContainer, { - childList: true, - subtree: true - }); + const utils = window.KefinTweaksUtils; + if (utils?.waitForElement) { + utils.waitForElement('#slides-container', { timeout: 60000, quiet: true }).then((el) => { + bindSlidesContainer(el); + }); + return; + } - // Disconnect the container observer since we've found it - containerObserver.disconnect(); + // Fallback: short TTL poll if shared wait helper unavailable + const start = Date.now(); + const pollId = setInterval(() => { + const el = document.querySelector('#slides-container'); + if (el) { + clearInterval(pollId); + bindSlidesContainer(el); + } else if (Date.now() - start > 60000) { + clearInterval(pollId); } - }); - - // Start observing the entire document body for the slides container - containerObserver.observe(document.body, { - childList: true, - subtree: true - }); + }, 250); } // Initialize the slides container observer