Move to single global document observer where possible and relevant

This commit is contained in:
HighImKevin
2026-10-12 04:44:40 -04:00
parent 5d34fd09d9
commit 34bb37ff27
13 changed files with 874 additions and 329 deletions
+28 -13
View File
@@ -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');
})();
+14 -7
View File
@@ -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
+44 -14
View File
@@ -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();
})();
+56 -15
View File
@@ -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');
})();
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();
})();
+25 -18
View File
@@ -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');
}
+60 -74
View File
@@ -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) => {
+26 -10
View File
@@ -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) {
+36 -11
View File
@@ -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() {
+20 -22
View File
@@ -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();
}
+35 -38
View File
@@ -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() {
+5 -1
View File
@@ -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');
+418 -20
View File
@@ -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<string, { disconnect: Function }>} */
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<Element|null>}
*/
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();
+107 -86
View File
@@ -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