// 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'; /** How many viewports of content to keep below the fold before idling infinite discovery. */ const DISCOVERY_LEAD_VIEWPORTS = 1.5; /** Max consecutive auto-fill groups in one wave (empty/tiny sections). */ const DISCOVERY_LEAD_FILL_MAX_GROUPS = 8; /** Consecutive all-empty discovery groups before marking exhausted. */ const DISCOVERY_EMPTY_GROUP_STREAK_MAX = 6; /** Settle delay after paint before counting surviving sections (enhance may remove empties). */ const DISCOVERY_EMPTY_GROUP_SETTLE_MS = 400; // 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, /** Consecutive auto-fill loads while lead margin is short (reset when lead OK or user scroll loads). */ discoveryLeadFillCount: 0, /** Consecutive discovery groups where every section enhanced to empty / was removed. */ discoveryEmptyGroupStreak: 0, 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 last child of the sections container (keeps height during load; TV Down focus) 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 loadingEl = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); // Migrate legacy div indicator to a real button (Jellyfin TV spatial nav targets buttons) if (loadingEl && loadingEl.tagName !== 'BUTTON') { const replacement = document.createElement('button'); replacement.type = 'button'; replacement.id = loadingEl.id; replacement.className = loadingEl.className; replacement.innerHTML = loadingEl.innerHTML || '
'; loadingEl.replaceWith(replacement); loadingEl = replacement; } if (loadingEl) { if (container) { container.appendChild(loadingEl); } bindDiscoveryLoadMoreControl(loadingEl); syncDiscoveryLoadMoreFocus(loadingEl); return loadingEl; } if (!container) { WARN('Discovery loading indicator: sections container not found'); return null; } loadingEl = document.createElement('button'); loadingEl.type = 'button'; loadingEl.className = 'discovery-loading-indicator'; loadingEl.id = 'discovery-loading-indicator'; loadingEl.innerHTML = `
`; container.appendChild(loadingEl); LOG('Created discovery loading indicator as last child of sections container'); bindDiscoveryLoadMoreControl(loadingEl); syncDiscoveryLoadMoreFocus(loadingEl); return loadingEl; } function removeDiscoveryLoadingIndicator() { document.querySelectorAll('#discovery-loading-indicator').forEach((el) => el.remove()); } function isDiscoveryLoadMoreInteractive() { if (state.discoveryMode !== 'chevron') return false; if (state.discoverySectionsRemain === false) return false; if (state.isRenderingDiscovery) return false; const page = getDiscoveryHomePageEl(); if (page?.dataset?.discoveryExhausted === 'true') return false; const indicator = document.querySelector('#discovery-loading-indicator'); if (indicator?.classList.contains('show')) return false; return true; } function syncDiscoveryLoadMoreFocus(indicator) { const el = indicator || document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator') || document.querySelector('#discovery-loading-indicator'); if (!el) return; const tv = document.documentElement.classList.contains('layout-tv'); const interactive = isDiscoveryLoadMoreInteractive(); if (tv && interactive) { el.disabled = false; el.removeAttribute('disabled'); el.setAttribute('tabindex', '0'); el.setAttribute('aria-label', 'Discover More'); // emby-button: Jellyfin TV spatial nav treats plain controls as Tab-only otherwise el.classList.add('emby-button'); el.classList.add('show-focus'); el.classList.add('discovery-load-more-focusable'); } else { el.setAttribute('tabindex', '-1'); el.classList.remove('emby-button'); el.classList.remove('show-focus'); el.classList.remove('discovery-load-more-focusable'); if (state.discoverySectionsRemain === false || getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') { el.setAttribute('aria-label', "We've run out of suggestions!"); } else { el.removeAttribute('aria-label'); } } } function activateDiscoveryLoadMore() { if (!isDiscoveryLoadMoreInteractive()) return false; if (!isDiscoveryHomeContext()) return false; LOG('Discover More activated; loading next discovery group...'); renderNextDiscoveryGroup(); return true; } function bindDiscoveryLoadMoreControl(indicator) { if (!indicator || indicator.dataset.loadMoreBound === '1') return; indicator.dataset.loadMoreBound = '1'; indicator.addEventListener('click', (e) => { if (!isDiscoveryLoadMoreInteractive()) return; e.preventDefault(); e.stopPropagation(); activateDiscoveryLoadMore(); }); indicator.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; if (!isDiscoveryLoadMoreInteractive()) return; e.preventDefault(); e.stopPropagation(); activateDiscoveryLoadMore(); }); } 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; syncDiscoveryLoadMoreFocus(); } 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'; syncDiscoveryLoadMoreFocus(loadingIndicator); } 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'; } syncDiscoveryLoadMoreFocus(loadingIndicator); } 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() { if (state.discoverySectionsRemain === false && getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') { return; } state.discoverySectionsRemain = false; state.discoveryBuffer = null; state.discoveryGroupRemainder = []; state.discoveryEmptyGroupStreak = 0; state.discoveryLeadFillCount = 0; 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 = ''; syncDiscoveryLoadMoreFocus(loadingIndicator); } LOG("Discovery exhausted — We've run out of suggestions!"); } /** * After a group paints, wait for enhance/empty dismiss, then update empty-group streak. * @param {Element} container * @param {Array<{ config?: { id?: string } }>} bufferedSections */ function scheduleDiscoveryEmptyGroupStreakCheck(container, bufferedSections) { const ids = (bufferedSections || []) .map((s) => s?.config?.id) .filter((id) => id != null) .map((id) => String(id)); if (!ids.length || !container) return; requestAnimationFrame(() => { requestAnimationFrame(() => { setTimeout(() => { if (state.discoverySectionsRemain === false) return; if (!isDiscoveryHomeContext()) return; const survivors = ids.filter((id) => { const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(id) : id.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const el = container.querySelector(`[data-section-id="${escaped}"]`); return !!(el && el.isConnected); }); if (survivors.length === 0) { state.discoveryEmptyGroupStreak += 1; LOG('Discovery group left no surviving sections', { streak: state.discoveryEmptyGroupStreak, groupSize: ids.length, }); if (state.discoveryEmptyGroupStreak >= DISCOVERY_EMPTY_GROUP_STREAK_MAX) { markDiscoveryExhausted(); } } else { state.discoveryEmptyGroupStreak = 0; } }, DISCOVERY_EMPTY_GROUP_SETTLE_MS); }); }); } // 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 homeSectionsReadyWaitGeneration = 0; function disconnectHomeSectionsReadyObserver() { // Cancels in-flight waitForElement/poll by bumping generation homeSectionsReadyWaitGeneration += 1; LOG('Cancelled home sections ready wait'); } function connectHomeSectionsReadyObserver() { const generation = ++homeSectionsReadyWaitGeneration; const existing = document.querySelector(HOME_SECTIONS_SELECTOR); if (existing) { LOG('Home sections container already present; enhancing'); enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' }); return; } 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 || '' }); }; 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) { 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