// KefinTweaks Home Screen v3 (Config-Driven & Streamlined) (function() { 'use strict'; const LOG = (...args) => console.log('[KefinTweaks HomeScreen3]', ...args); const WARN = (...args) => console.warn('[KefinTweaks HomeScreen3]', ...args); const ERR = (...args) => console.error('[KefinTweaks HomeScreen3]', ...args); // Configuration const PRE_FETCH_DISCOVERY_DATA = false; // If true, fetches first discovery group immediately. If false, waits for scroll/click. /** Max sections resolved/rendered per discovery group (infinite/chevron pages). */ const DISCOVERY_SECTIONS_PER_GROUP = 100; /** IntersectionObserver rootMargin for discovery section progressive enhance (batches + any discovery enhance). */ const DISCOVERY_ENHANCE_ROOT_MARGIN = '30% 0px 30% 0px'; // Dependencies const ApiHelper = window.apiHelper; const Config = window.KefinHomeConfig2; // Updated to use Config2 const PeopleCache = window.PeopleCache; const StudiosCache = window.StudiosCache; if (!Config) { ERR('Configuration not found! Ensure homeScreenConfig2.js is loaded.'); return; } /** * Hide native Jellyfin .sectionN rows only once homeScreen3 has successfully * started (so clients that cannot parse this file keep the native home). * Persist for the session via a single injected stylesheet. */ function ensureNativeHomeSectionsHidden() { const styleId = 'kefin-hide-native-homesections'; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = [ '.section0:not([data-page]),', '.section1:not([data-page]),', '.section2:not([data-page]),', '.section3:not([data-page]),', '.section4:not([data-page]),', '.section5:not([data-page]),', '.section6:not([data-page]),', '.section7:not([data-page]),', '.section8:not([data-page]),', '.section9:not([data-page]) {', ' display: none !important;', '}' ].join('\n'); (document.head || document.documentElement).appendChild(style); } ensureNativeHomeSectionsHidden(); /** * Flatten section groups into a flat array of sections * @param {Array} groups - Array of HomeScreenSectionGroup objects * @returns {Array} Flat array of all sections */ function flattenSectionGroups(groups) { if (!Array.isArray(groups)) return []; const flattened = []; groups.forEach(group => { if (group && Array.isArray(group.sections)) { flattened.push(...group.sections); } }); return flattened; } // State const state = { renderedSections: new Set(), renderedDiscoveryIds: { genres: new Set(), genresSeries: new Set(), seriesStudios: new Set(), seriesStudiosTopRated: new Set(), collections: new Set(), people: new Set(), similar: new Set(), watchlist: new Set(), customDiscoverySections: new Set() }, currentDiscoveryGenre: null, currentDiscoveryStudio: null, isPeopleCacheComplete: false, discoveryGroupIndex: 0, isRenderingDiscovery: false, discoveryEnabled: true, discoveryBufferPromise: null, discoveryBuffer: [], /** Unresolved section configs left after capping a discovery group render. */ discoveryGroupRemainder: [], discoverySectionOrder: 100000000, discoverySectionsRemain: true, ensuringDiscoveryBuffer: false, discoveryNeedsMoreScroll: false, discoveryMode: null, // 'infinite' | 'chevron' infiniteScrollHandler: null, discoveryWheelHandler: null, discoveryTouchStartHandler: null, discoveryTouchMoveHandler: null, discoveryScrollDebounceTimer: null, userDisplayPreferences: null, firstDiscoveryBuffer: null, getDisplayPrefernces: null, kefinNextUp: false, kefinContinueWatching: false, kefinLatestMedia: false, jellyfinOrders: {}, homePaintedEventFired: false, isRenderingHome: false }; async function fetchDisplayPreferences() { if (!window.userHelper?.getUserDisplayPreferences) { WARN('userHelper.getUserDisplayPreferences unavailable'); return null; } const { promise, cached } = await window.userHelper.getUserDisplayPreferences({ cacheOnly: true }); const prefs = cached || await promise; state.userDisplayPreferences = prefs; return prefs; } state.getDisplayPrefernces = fetchDisplayPreferences; // Cache used to support re-rendering our sections after Jellyfin overwrites the container const sectionCache = { renderedSections: [], // Array of section configs + results fragmentCache: null, // Reserved for future use if we cache DOM fragments isInitialRender: true, // Track if this is first render for the current view jellyfinHasRendered: false, // Track if Jellyfin has rendered native home sections pendingJellyfinSectionObservers: [] }; // Create loading indicator as next sibling of the sections container (outside flex column) function createDiscoveryLoadingIndicator(container) { // Prefer the provided container, but fall back to the standard sections container if (!container) { container = document.querySelector('.homePage:not(.hide) #homeTab .sections') || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); } let loadingDiv = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (loadingDiv) { if (container && loadingDiv.previousElementSibling !== container) { // Place as next sibling of the sections container if (container.nextSibling) { container.parentNode.insertBefore(loadingDiv, container.nextSibling); } else if (container.parentNode) { container.parentNode.appendChild(loadingDiv); } } return loadingDiv; } if (!container || !container.parentNode) { WARN('Discovery loading indicator: sections container not found'); return null; } loadingDiv = document.createElement('div'); loadingDiv.className = 'discovery-loading-indicator'; loadingDiv.id = 'discovery-loading-indicator'; loadingDiv.innerHTML = `
`; if (container.nextSibling) { container.parentNode.insertBefore(loadingDiv, container.nextSibling); } else { container.parentNode.appendChild(loadingDiv); } LOG('Created discovery loading indicator as sibling of sections container'); return loadingDiv; } function removeDiscoveryLoadingIndicator() { document.querySelectorAll('#discovery-loading-indicator').forEach((el) => el.remove()); } function getDiscoveryHomePageEl() { return document.querySelector('#indexPage') || document.querySelector('.homePage:not(.hide)') || document.querySelector('.libraryPage:not(.hide)'); } function setDiscoveryPageAttrs({ ready = false, infinite = false, exhausted = false } = {}) { const page = getDiscoveryHomePageEl(); if (!page) return; if (ready) page.dataset.discoveryReady = 'true'; else delete page.dataset.discoveryReady; if (infinite) page.dataset.infiniteScroll = 'true'; else delete page.dataset.infiniteScroll; if (exhausted) page.dataset.discoveryExhausted = 'true'; else delete page.dataset.discoveryExhausted; } function showDiscoveryLoadingIndicator() { const loadingIndicator = createDiscoveryLoadingIndicator( document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer') ); if (!loadingIndicator) return; loadingIndicator.classList.add('show'); loadingIndicator.style.visibility = 'visible'; loadingIndicator.style.display = 'flex'; } function hideDiscoveryLoadingIndicator() { const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (!loadingIndicator) return; loadingIndicator.classList.remove('show'); // Chevron / exhausted idle UI is shown via data-discovery-ready CSS if (state.discoveryMode === 'chevron' || state.discoverySectionsRemain === false) { loadingIndicator.style.visibility = ''; loadingIndicator.style.display = ''; } else { loadingIndicator.style.visibility = 'hidden'; loadingIndicator.style.display = 'none'; } } function isDiscoveryHomeContext() { const currentView = window.KefinTweaksUtils?.getCurrentView(); const isHomePage = currentView === 'home' || currentView === 'home.html'; if (!isHomePage) return false; const activeTab = document.querySelector('.headerTabs .emby-tab-button-active'); if (!activeTab || activeTab.getAttribute('data-index') !== '0') return false; return true; } function removeDiscoveryScrollListeners() { if (state.discoveryScrollDebounceTimer) { clearTimeout(state.discoveryScrollDebounceTimer); state.discoveryScrollDebounceTimer = null; } if (state.infiniteScrollHandler) { window.removeEventListener('scroll', state.infiniteScrollHandler); state.infiniteScrollHandler = null; } if (state.discoveryWheelHandler) { window.removeEventListener('wheel', state.discoveryWheelHandler); state.discoveryWheelHandler = null; } if (state.discoveryTouchStartHandler) { window.removeEventListener('touchstart', state.discoveryTouchStartHandler); state.discoveryTouchStartHandler = null; } if (state.discoveryTouchMoveHandler) { window.removeEventListener('touchmove', state.discoveryTouchMoveHandler); state.discoveryTouchMoveHandler = null; } const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (container) delete container.dataset.discoveryHandler; } function markDiscoveryExhausted() { state.discoverySectionsRemain = false; state.discoveryBuffer = null; state.discoveryGroupRemainder = []; removeDiscoveryScrollListeners(); setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: true }); const loadingIndicator = createDiscoveryLoadingIndicator( document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer') ); if (loadingIndicator) { loadingIndicator.classList.remove('show'); loadingIndicator.style.visibility = ''; loadingIndicator.style.display = ''; } LOG('Discovery exhausted — No More Content to Discover'); } // Legacy helper kept for compatibility – no longer used in the main flow async function waitForHomeSectionsContainer() { const maxAttempts = 50; let attempts = 0; let homeSectionsContainer = null; while (attempts < maxAttempts && !homeSectionsContainer) { homeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer'); if (homeSectionsContainer) { break; } attempts++; await new Promise(resolve => setTimeout(resolve, 50)); } if (!homeSectionsContainer) { LOG('Home sections container not found'); return false; } return homeSectionsContainer; } const HOME_SECTIONS_SELECTOR = '.homePage:not(.hide) #homeTab.is-active .sections'; let homeSectionsReadyObserver = null; function disconnectHomeSectionsReadyObserver() { if (!homeSectionsReadyObserver) return; homeSectionsReadyObserver.disconnect(); homeSectionsReadyObserver = null; LOG('Disconnected home sections ready observer'); } function connectHomeSectionsReadyObserver() { if (homeSectionsReadyObserver) { LOG('Home sections ready observer already connected'); return; } const existing = document.querySelector(HOME_SECTIONS_SELECTOR); if (existing) { LOG('Home sections container already present; enhancing'); enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }); return; } if (typeof MutationObserver === 'undefined') { WARN('MutationObserver unavailable; cannot wait for home sections container'); return; } homeSectionsReadyObserver = new MutationObserver(() => { 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 }); } function mulberry32(seed) { let t = seed >>> 0; return () => { t += 0x6D2B79F5; let r = Math.imul(t ^ (t >>> 15), 1 | t); r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } /** * Dense procedural blood edge for Halloween header (mostly filled, tiny gaps, * uneven height up to 10px). Returns a CSS url(...) data SVG. */ function generateHalloweenBloodSplatter({ width = 1400, height = 10, dropletCount = 420, clusterCount = 5, gapCount = 4, seed = (Date.now() ^ (Math.random() * 1e9)) >>> 0, } = {}) { const rand = mulberry32(seed); const clusters = Array.from({ length: clusterCount }, () => ({ x: rand() * width, r: 60 + rand() * 120, weight: 1.4 + rand() * 1.8, tall: rand() < 0.55, })); const gaps = Array.from({ length: gapCount }, () => ({ x: rand() * width, r: 14 + rand() * 28, })); const inGap = (x) => gaps.some((g) => Math.abs(x - g.x) < g.r); const sampleCluster = (x) => { let boost = 0; let tall = false; for (const c of clusters) { const d = Math.abs(x - c.x) / c.r; if (d < 1) { const w = c.weight * (1 - d) * (1 - d); boost += w; if (c.tall && w > 0.35) tall = true; } } return { boost, tall }; }; const drops = []; let attempts = 0; while (drops.length < dropletCount && attempts < dropletCount * 16) { attempts += 1; const x = rand() * width; if (inGap(x)) continue; const { boost, tall } = sampleCluster(x); const accept = 0.82 + Math.min(0.18, boost * 0.08); if (rand() > accept) continue; let maxRise = 3.5 + rand() * 2.5; if (tall) maxRise = height * (0.75 + rand() * 0.25); else if (rand() < 0.06) maxRise = height * (0.7 + rand() * 0.3); const y = height - rand() * maxRise; const maxR = tall ? (0.9 + rand() * 1.6) : (0.45 + rand() * 1.1); const r = 0.3 + rand() * maxR; const fill = rand() < 0.3 ? '#990000' : (rand() < 0.55 ? '#8b0000' : '#7a0000'); if (rand() < 0.16) { drops.push( ``, ); } else { drops.push(``); } } const svg = `` + drops.join('') + ``; return { seed, cssUrl: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`, }; } const HALLOWEEN_BLOOD_STYLE_ID = 'kefin-halloween-blood-styles'; function clearHalloweenBloodInlinePollution(body = document.body) { if (!body) return; body.style.removeProperty('--halloween-blood-band'); body.style.removeProperty('--halloween-blood-opacity'); body.style.removeProperty('--halloween-blood-splatter'); delete body.dataset.halloweenBloodSeed; try { sessionStorage.removeItem('kefin-halloween-blood-seed'); } catch (_) { /* private mode */ } } /** Inject procedural blood SVG via a