// 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 = 12; // 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; } /** * 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'); enhanceHomeScreen(); 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'); enhanceHomeScreen(); }); 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 }); } async function manageBodyClasses() { const homeScreenConfig = await window.KefinHomeScreen.getConfig(); const seasonalSectionGroups = homeScreenConfig.SEASONAL_SECTION_GROUPS || []; const seasonalConfig = homeScreenConfig.SEASONAL_THEME_SETTINGS || {}; const enableSeasonalAnimations = seasonalConfig.enableSeasonalAnimations !== false; const enableSeasonalBackground = seasonalConfig.enableSeasonalBackground !== false; const seasonalThemes = seasonalConfig.seasonalThemes || {}; if (!enableSeasonalBackground && !enableSeasonalAnimations) { LOG('Seasonal background and animations are disabled'); return; } const body = document.body; const currentView = window.KefinTweaksUtils?.getCurrentView ? window.KefinTweaksUtils.getCurrentView() : null; const isHomePage = currentView === 'home' || currentView === 'home.html' || window.location.href.includes('home.html'); // Get the active Seasonal groups by checking if any section in the group is enabled and where the current date falls within the StartDate and EndDate of the section const activeSeasonalGroups = seasonalSectionGroups.filter(sectionGroup => { return sectionGroup.sections.some(s => s.enabled && isInSeasonalPeriod(s.startDate, s.endDate)); }); if (activeSeasonalGroups.length === 0) { LOG('No active seasonal groups found'); return; } let activeSeasonalThemeGroup; if (enableSeasonalBackground && enableSeasonalAnimations) { activeSeasonalThemeGroup = activeSeasonalGroups.find(sectionGroup => { const theme = seasonalThemes[sectionGroup.id] || {}; return theme.backgroundImage && theme.animation; }); } else { activeSeasonalThemeGroup = activeSeasonalGroups.find(sectionGroup => { const theme = seasonalThemes[sectionGroup.id] || {}; return (enableSeasonalBackground && theme.backgroundImage) || (enableSeasonalAnimations && theme.animation); }); } if (!activeSeasonalThemeGroup) { LOG('No seasonal theme found'); return; } document.body.dataset.seasonalTheme = activeSeasonalThemeGroup.id; if (enableSeasonalBackground && !document.body.dataset.seasonalBackground) { document.body.dataset.seasonalBackground = 'true'; // Add the background image as a css variable to the body const backgroundImage = seasonalThemes[activeSeasonalThemeGroup.id]?.backgroundImage; if (backgroundImage) { document.body.style.setProperty('--seasonal-background-image', backgroundImage); } } if (enableSeasonalAnimations && !document.body.dataset.seasonalAnimations) { document.body.dataset.seasonalAnimations = 'true'; // Add the animation script to the head const theme = seasonalThemes[activeSeasonalThemeGroup.id] || {}; const animation = theme.animation; document.body.dataset.seasonalShape = theme.shape || 'snowflake'; document.body.dataset.seasonalAnimationStyle = theme.animationStyle || 'fall'; if (animation) { const animationScript = document.querySelector(`script[src*="${animation}.js"]`); if (!animationScript) { const animationScriptSrc = (window.KefinTweaksConfig.kefinTweaksRoot || '') + `scripts/seasonal/${animation}.js`; const script = document.createElement('script'); script.src = animationScriptSrc; document.head.appendChild(script); } } } } /** * Main Entry Point */ async function enhanceHomeScreen() { LOG('Initializing Home Screen v3...'); mountCreateSectionButton(); // Defer mountCategoryRail until pinned availability is known (avoids first-load tab jump) syncHomeScreenChromeVisibility(); // Reset cache for a new initialization cycle sectionCache.renderedSections = []; sectionCache.fragmentCache = null; sectionCache.isInitialRender = true; sectionCache.jellyfinHasRendered = false; const container = document.querySelector(HOME_SECTIONS_SELECTOR); if (!container) { LOG('Home sections container not found'); connectHomeSectionsReadyObserver(); return; } disconnectHomeSectionsReadyObserver(); // Create Kefin home sections container as a sibling of the Jellyfin sections container /* let kefinHomeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer'); if (!kefinHomeSectionsContainer) { kefinHomeSectionsContainer = document.createElement('div'); kefinHomeSectionsContainer.className = 'sections homeSectionsContainer kefinHomeSectionsContainer'; if (container.parentNode) { container.parentNode.insertBefore(kefinHomeSectionsContainer, container.nextSibling); } else { LOG('Sections container has no parent node; appending Kefin container to body as fallback'); document.body.appendChild(kefinHomeSectionsContainer); } } */ // If Home-category sections already exist, avoid re-running full init. // Discovery-only remounts (e.g. back to Discover) must not skip home render. if (hasHomeCategorySections(container)) { LOG('Home screen already initialized and Jellyfin has rendered'); try { const kefinTweaksHomeSections = container.querySelectorAll('[data-section-id]'); updatePinnedCategoryAvailability( Array.from(kefinTweaksHomeSections).map((el) => ({ id: el.dataset.sectionId, category: el.dataset.category })), null ); refreshHomeCategoryChrome(); } catch (err) { ERR('Failed to sync home category chrome for existing home:', err); } return; } // Fetch display preferences //state.userDisplayPreferences = await fetchDisplayPreferences(); // Load user preferences and apply filtering/ordering const userHomeConfig = window.KefinUserHomeScreenConfig; let filteredHomeSections; let homeScreen; let userDisplayPreferences = null; if (typeof userHomeConfig?.getConfig === 'function') { const userConfig = await userHomeConfig.getConfig(); filteredHomeSections = userConfig.sections || []; homeScreen = userConfig.homeScreen; userDisplayPreferences = await state.getDisplayPrefernces(); //addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen); } else { const mergedHomeSections = await mergeHomeSectionConfigs(); userDisplayPreferences = await state.getDisplayPrefernces(); const customPrefs = userDisplayPreferences?.CustomPrefs || {}; homeScreen = userHomeConfig.parseKefinTweaksHomeScreen(customPrefs); const pinnedSections = userHomeConfig.buildPinnedSectionConfigs(homeScreen); //addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen); const serverSectionsById = new Map(); mergedHomeSections.forEach(section => { if (section?.id) serverSectionsById.set(section.id, section); }); filteredHomeSections = [...mergedHomeSections, ...pinnedSections]; filteredHomeSections = userHomeConfig.applyUserSectionOverrides(filteredHomeSections, homeScreen, { serverSectionsById }); filteredHomeSections = filteredHomeSections.filter(section => section.enabled !== false); } updatePinnedCategoryAvailability(filteredHomeSections, homeScreen); mountCategoryRail(); syncHomeScreenChromeVisibility(); const nextupSectionConfig = filteredHomeSections.find(s => s.id === 'nextUp'); const continueWatchingSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatching'); const continueWatchingAndNextUpSectionConfig = filteredHomeSections.find(s => s.id === 'continueWatchingAndNextUp'); const recentlyAddedSectionConfig = filteredHomeSections.find(s => s.id?.startsWith?.('recently-added-') && s.enabled === true); state.kefinNextUp = nextupSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true; state.kefinContinueWatching = continueWatchingSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true; state.kefinLatestMedia = recentlyAddedSectionConfig?.enabled === true; // Set up mutation observer BEFORE rendering, so we can react when Jellyfin renders home sections /* const handleJellyfinRender = async () => { const jellyHome = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer'); const kefinHome = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer'); if (!jellyHome || !kefinHome) { LOG('Jellyfin render detected but required containers not found; skipping Jellyfin section mirroring'); return; } // Disconnect any pending "wait for children" observers from a previous run sectionCache.pendingJellyfinSectionObservers.forEach(obs => { try { obs.disconnect(); } catch (_) {} }); sectionCache.pendingJellyfinSectionObservers = []; // Remove any previously cloned Jellyfin sections from our container kefinHome.querySelectorAll('[data-jellyfin-section-clone="true"]').forEach(el => el.remove()); // Copy Jellyfin .sectionN containers into the Kefin container (clone now if has children; else observe until children appear if in jellyfinOrders) const jellySections = jellyHome.querySelectorAll('.section0, .section1, .section2, .section3, .section4, .section5, .section6, .section7, .section8'); jellySections.forEach(sectionEl => { const sectionIndex = getSectionIndexFromElement(sectionEl); const homesectionKey = sectionIndex >= 0 ? `homesection${sectionIndex}` : null; if (sectionHasAllItemsContainersPopulated(sectionEl)) { const clone = sectionEl.cloneNode(true); clone.dataset.jellyfinSectionClone = 'true'; kefinHome.appendChild(clone); } else if (homesectionKey && state.jellyfinOrders[homesectionKey] !== undefined) { observeSectionUntilChildrenThenClone(sectionEl, kefinHome); } }); }; */ //let containerForRender = container; /* if (!container.classList.contains('homeSectionsContainer')) { const observer = observeContainerMutations(container, handleJellyfinRender); containerForRender = kefinHomeSectionsContainer; } */ // Set flag to indicate we've started initializing this container //containerForRender.dataset.kefinHomeScreen = 'true'; // Initial render directly into Kefin container (no waiting for Jellyfin) LOG('Rendering Standard/Seasonal Sections (initial render)...'); await renderHomeSections(filteredHomeSections, container, false); LOG('Initializing Discovery Sections...'); setupDiscoveryInteraction(container); try { refreshHomeCategoryChrome(); } catch (err) { ERR('Failed to refresh home category chrome after home render:', err); } /* // If Jellyfin hasn't rendered after 5 seconds, assume it won't and clean up the observer setTimeout(() => { if (!sectionCache.jellyfinHasRendered && observer) { LOG('Jellyfin render timeout - assuming native sections disabled'); sectionCache.jellyfinHasRendered = true; observer.disconnect(); } }, 5000); */ } function getHomeScreenConfig() { const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen; if (!userConfig) { return Config; } const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); const homeSections = mergeHomeSectionConfigs(defaultHomeSections); } /** * Merges default Home Sections with Admin/User Config overrides * Now reads from all 4 groups: HOME, SEASONAL, DISCOVERY, CUSTOM */ async function mergeHomeSectionConfigs(defaultSections) { const kefinSections = await window.KefinHomeScreen.getSections(); return kefinSections.enabledHomeSections; // Return all enabled sections from HOME_SECTION_GROUPS const homeSections = flattenSectionGroups(kefinHomeScreenConfig.HOME_SECTION_GROUPS).filter(s => s.enabled === true); // Return all enabled sections from SEASONAL_SECTION_GROUPS that fall within the current date const seasonalSections = flattenSectionGroups(kefinHomeScreenConfig.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && isInSeasonalPeriod(s.startDate, s.endDate)); // Return all enabled sections from CUSTOM_SECTION_GROUPS that aren't discovery sections and if they have a start/end date that also fall within that date range const customSections = flattenSectionGroups(kefinHomeScreenConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const mergedSections = [...homeSections, ...seasonalSections, ...customSections]; return mergedSections.sort((a, b) => a.order - b.order); /* const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen; const homeScreenConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig; const merged = []; // Helper function to merge sections with user config overrides const mergeSectionWithUserConfig = (section) => { let userSectionConfig = null; if (section.id.includes('.')) { const parts = section.id.split('.'); if (userConfig && userConfig[parts[0]] && userConfig[parts[0]][parts[1]]) { userSectionConfig = userConfig[parts[0]][parts[1]]; } } else if (userConfig) { userSectionConfig = userConfig[section.id]; } if (userSectionConfig) { return { ...section, ...userSectionConfig, id: section.id }; } return section; }; // Process HOME_SECTION_GROUPS (default + admin overrides) const defaultHome = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); const adminHome = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.HOME_SECTION_GROUPS || []) : []; const defaultHomeIds = new Set(defaultHome.map(s => s.id)); // Merge default home sections with user config defaultHome.forEach(section => { merged.push(mergeSectionWithUserConfig(section)); }); // Add admin overrides for home sections (including recently-added) adminHome.forEach(section => { const existingIndex = merged.findIndex(s => s.id === section.id); if (existingIndex !== -1) { // Update existing section merged[existingIndex] = { ...merged[existingIndex], ...section }; } else if (!defaultHomeIds.has(section.id)) { // New section from admin config (e.g., recently-added) merged.push(section); } }); // Process SEASONAL_SECTION_GROUPS (default + admin overrides) const defaultSeasonal = flattenSectionGroups(Config.SEASONAL_SECTION_GROUPS || []); const adminSeasonal = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.SEASONAL_SECTION_GROUPS || []) : []; const defaultSeasonalIds = new Set(defaultSeasonal.map(s => s.id)); // Merge default seasonal sections with user config defaultSeasonal.forEach(section => { merged.push(mergeSectionWithUserConfig(section)); }); // Add admin overrides for seasonal sections adminSeasonal.forEach(section => { const existingIndex = merged.findIndex(s => s.id === section.id); if (existingIndex !== -1) { // Update existing section merged[existingIndex] = { ...merged[existingIndex], ...section }; } else if (!defaultSeasonalIds.has(section.id)) { // New seasonal section from admin config merged.push(section); } }); // Process CUSTOM_SECTION_GROUPS (user-created/imported sections) const customSections = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.CUSTOM_SECTION_GROUPS || []) : []; customSections.forEach(section => { if (section.discoveryEnabled === true) { // Skip if discovery is enabled, the section will be rendered in the discovery sections return; } if (!merged.find(s => s.id === section.id)) { merged.push(section); } }); // Legacy support: Also check old recentlyAddedInLibrary location for backward compatibility if (isRecentlyAddedEnabled() && userConfig) { const libraryIds = Object.keys(userConfig.recentlyAddedInLibrary || {}); const libraries = await window.dataHelper.getLibraries(); libraryIds.forEach(libId => { const libConfig = userConfig.recentlyAddedInLibrary[libId]; const sectionId = `recently-added-${libId}`; // Skip if already added from HOME_SECTION_GROUPS if (merged.find(s => s.id === sectionId)) { return; } if (libConfig && libConfig.enabled !== false) { const library = libraries.find(l => l.Id === libId); if (!library) { LOG(`Library not found: ${libId}`); return; } if (library.CollectionType && library.CollectionType !== 'movies' && library.CollectionType !== 'tvshows' && library.CollectionType !== 'music' && library.CollectionType !== 'homevideos' && library.CollectionType !== 'musicvideos' && library.CollectionType !== 'musicvideos' && library.CollectionType !== 'books') { LOG(`Skipping library: ${library.CollectionType}`); return; } let viewMoreUrl = null; if (library.CollectionType === 'movies') { viewMoreUrl = `#/movies.html?topParentId=${libId}&collectionType=movies&tab=1`; } else if (library.CollectionType === 'tvshows') { viewMoreUrl = `#/tv.html?topParentId=${libId}&collectionType=tvshows&tab=1`; } // Convert to new format with queries array const newSection = { id: sectionId, name: libConfig.name || 'Recently Added', enabled: true, order: libConfig.order || 61, cardFormat: libConfig.cardFormat || 'Poster', jellyfinId: 'latestmedia', viewMoreUrl: viewMoreUrl, queries: [{ path: `/Items/Latest`, queryOptions: { ParentId: libId, SortBy: 'DateCreated', SortOrder: 'Descending', Limit: libConfig.itemLimit || 16 } }] } merged.push(newSection); } }); } // Filter seasonal sections based on date const filteredMerged = merged.filter(section => { if (section.startDate && section.endDate) { if (section.enabled === false) { LOG(`Skipping disabled seasonal section: ${section.id}`); return false; } if (!isInSeasonalPeriod(section.startDate, section.endDate)) { LOG(`Skipping seasonal section (out of date): ${section.id}`); return false; } } return true; }); // Legacy support: Also check old customSections location if (userConfig && userConfig.customSections) { Object.keys(userConfig.customSections).forEach(key => { if (!filteredMerged.find(s => s.id === key)) { filteredMerged.push(userConfig.customSections[key]); } }); } // Legacy support: Also check old seasonal location if (userConfig && userConfig.seasonal?.seasons) { const seasonalSections = userConfig.seasonal.seasons.flatMap(s => s.sections.map(section => ({ ...section, startDate: s.startDate, endDate: s.endDate }))); seasonalSections.forEach(section => { if (!filteredMerged.find(s => s.id === section.id)) { if (section.enabled === false) { LOG(`Skipping disabled seasonal section: ${section.id}`); return; } if (section.startDate && section.endDate) { if (!isInSeasonalPeriod(section.startDate, section.endDate)) { LOG(`Skipping seasonal section (out of date): ${section.id}`); return; } } filteredMerged.push(section); } }); } return filteredMerged; */ } async function getDiscoverySections() { const settings = getDiscoverySettings(); if (!settings || settings.enabled === false) { LOG('Discovery is disabled'); return []; } const page = state.discoveryGroupIndex + 1; const filterByDiscoveryPage = (sections) => (sections || []).filter((section) => { if (section.pageNumber == null) return true; // native discovery templates return Number(section.pageNumber) === page; }); const userHomeScreenConfig = await window.KefinUserHomeScreenConfig.getConfig(); if (userHomeScreenConfig) { return filterByDiscoveryPage(userHomeScreenConfig.enabledDiscoverySections); } const homeScreenConfig = window.KefinHomeScreen.getConfig(); // Get default discovery sections const defaultDiscovery = flattenSectionGroups(Config.DISCOVERY_SECTION_GROUPS || []); // Get admin overrides for discovery sections const adminDiscoverySections = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig && window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS && flattenSectionGroups(window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS); const mappedSections = Object.keys(adminDiscoverySections || {}).map(key => ({ id: key, ...adminDiscoverySections[key] })); // Merge default discovery sections with admin overrides const mergedSections = defaultDiscovery.map(section => { const mappedSection = mappedSections.find(s => s.id === section.id); if (mappedSection) { return { ...section, ...mappedSection, type: section.type }; } return section; }); const isInSeasonalPeriod = (startDate, endDate) => { const currentDate = new Date(); const start = new Date(startDate); const end = new Date(endDate); start.setFullYear(currentDate.getFullYear()); end.setFullYear(currentDate.getFullYear()); return currentDate >= start && currentDate <= end; }; const customGroups = homeScreenConfig?.CUSTOM_SECTION_GROUPS || window.KefinTweaksConfig?.homeScreenConfig?.CUSTOM_SECTION_GROUPS || []; const collect = window.KefinHomeScreen?.collectCustomDiscoverySections; const customDiscoverySections = typeof collect === 'function' ? collect(customGroups, { isSectionActive: (s) => s.enabled === true, isInSeasonalPeriod }) : []; mergedSections.push(...filterByDiscoveryPage(customDiscoverySections)); // Legacy support: Also check old customSections location const legacyCustomSections = window.KefinTweaksConfig?.homeScreen?.customSections || []; if (legacyCustomSections.length > 0) { legacyCustomSections.forEach(section => { if (section.enabled && section.discoveryEnabled) { const legacyPage = section.pageNumber == null ? 1 : Number(section.pageNumber); if (legacyPage !== page) return; const customDedupe = window.sectionHelper?.getDiscoveryState?.()?.renderedDiscoveryIds?.customDiscoverySections; if (customDedupe?.has(section.id)) { return; } mergedSections.push(section); customDedupe?.add(section.id); } }); } return mergedSections; } function getDiscoverySettings() { const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig; if (!userConfig) { return Config.DISCOVERY_SETTINGS; } return { ...Config.DISCOVERY_SETTINGS, ...userConfig.DISCOVERY_SETTINGS }; } /** * Load user home screen preferences */ function loadUserHomeScreenSections(userDisplayPreferences) { try { //if (!state.userDisplayPreferences) { // state.userDisplayPreferences = await fetchDisplayPreferences(); //} const customPrefs = userDisplayPreferences?.CustomPrefs || {}; const kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen || '[]'); return kefinHomeScreen; /* // Create a homesectionN : order value mapping for the custom CSS orders to be applied const jellyfinOrders = {}; for (let i = 0; i <= 8; i++) { const homeSection = customPrefs[`homesection${i}`]; if (!homeSection) { continue; } if (homeSection.toLowerCase() === 'nextup' && state.kefinNextUp) { continue; } if (homeSection.toLowerCase() === 'resume' && state.kefinContinueWatching) { continue; } if (homeSection.toLowerCase() === 'continuewatchingandnextup' && state.kefinNextUp && state.kefinContinueWatching) { continue; } if (homeSection.toLowerCase() === 'latestmedia' && state.kefinLatestMedia) { continue; } // Match to homeSection in kefinHomeScreen with .toLowerCase() id let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase() && s.enabled === true); if (!kefinHomeScreenSection) { // Check if it's "latestmedia" if (homeSection.toLowerCase() === 'latestmedia' && !state.kefinLatestMedia) { kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase().startsWith('recently-added') && s.enabled === true); } } if (kefinHomeScreenSection) { jellyfinOrders[`homesection${i}`] = kefinHomeScreenSection.order; } } state.jellyfinOrders = jellyfinOrders; addUserHomeScreenOrderCSS(jellyfinOrders); return kefinHomeScreen; */ /* if (!Array.isArray(kefinHomeScreen)) { return { enabledSectionIds: new Set(), orderMap: new Map(), jellyfinOrders: {} }; } // Create set of enabled section IDs const enabledSectionIds = new Set(kefinHomeScreen.map(s => s['section-id'])); // Create map of section-id -> order const orderMap = new Map(); kefinHomeScreen.forEach(pref => { if (pref['section-id'] && pref.order !== undefined) { orderMap.set(pref['section-id'], pref.order); } }); // Get Jellyfin section orders (homesectionN -> order mapping) const jellyfinOrders = {}; kefinHomeScreen.forEach(pref => { const sectionId = pref['section-id']; if (sectionId && sectionId.startsWith('homesection') && pref.order !== undefined) { jellyfinOrders[sectionId] = pref.order; } }); return { enabledSectionIds, orderMap, jellyfinOrders }; */ } catch (error) { ERR('Error loading user home screen preferences:', error); return null; } } /** * Add CSS for user-defined section ordering */ function addUserHomeScreenOrderCSS(userDisplayPreferences, homeScreen = null) { const customPrefs = userDisplayPreferences?.CustomPrefs || {}; const parsedHomeScreen = homeScreen || window.KefinUserHomeScreenConfig.parseKefinTweaksHomeScreen(customPrefs); const kefinHomeScreen = window.KefinUserHomeScreenConfig.homeScreenToLegacyArray(parsedHomeScreen); // Create a homesectionN : order value mapping for the custom CSS orders to be applied const jellyfinOrders = {}; for (let i = 0; i <= 8; i++) { const homeSection = customPrefs[`homesection${i}`]; if (!homeSection) { continue; } // Match to homeSection in kefinHomeScreen with .toLowerCase() id let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase() && s.enabled === true); if (!kefinHomeScreenSection) { // Check if it's "latestmedia" if (homeSection.toLowerCase() === 'latestmedia' && !state.kefinLatestMedia) { kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase().startsWith('recently-added') && s.enabled === true); } } if (kefinHomeScreenSection) { jellyfinOrders[`homesection${i}`] = kefinHomeScreenSection.order; } else { jellyfinOrders[`homesection${i}`] = 999; } } // Remove existing style tag if present const existingStyle = document.getElementById('kefin-user-homescreen-order-css'); // Create new style tag const style = document.createElement('style'); style.id = 'kefin-user-homescreen-order-css'; let css = ''; // Add CSS for Jellyfin sections Object.entries(jellyfinOrders).forEach(([homesectionKey, order]) => { // Map homesectionN to class selector const classSelector = `.${homesectionKey.replace('home','')}`; css += `${classSelector} { order: ${order} !important; }\n`; }); // Add display: none to all .homeectionN not in jellyfinOrders for (let i = 0; i <= 8; i++) { const homesectionKey = `homesection${i}`; if (jellyfinOrders[homesectionKey] && isNaN(jellyfinOrders[homesectionKey])) { const classSelector = `.${homesectionKey.replace('home','')}`; css += `${classSelector} { display: none !important; }\n`; } } style.textContent = css; if (existingStyle) { existingStyle.remove(); } document.head.appendChild(style); LOG('User home screen order CSS applied'); } /** * Filter sections based on user preferences */ function filterSectionsByUserPreferences(sections, userPrefs) { const { enabledSectionIds } = userPrefs; // If no user preferences, return all sections if (enabledSectionIds.size === 0) { return sections; } // Filter out disabled sections (not in enabledSectionIds) return sections.filter(section => { const sectionId = section.id; return enabledSectionIds.has(sectionId); }); } /** * Apply user-defined orders to sections */ function applyUserSectionOrders(sections, userPrefs) { const { orderMap } = userPrefs; return sections.map(section => { const sectionId = section.id; const userOrder = orderMap.get(sectionId); if (userOrder !== undefined) { return { ...section, order: userOrder }; } return section; }); } async function renderHomeSections(sections, container, useCache = false) { if (!container) { WARN('renderHomeSections called without a valid container'); return; } if (state.isRenderingHome) { LOG('Home render already in progress, skipping'); return; } state.isRenderingHome = true; try { // Only skip when Home-category (none) sections already exist — discovery-only DOM must still render home if (hasHomeCategorySections(container)) { LOG('Sections already rendered, skipping'); return; } container.dataset.sectionsRendered = 'true'; applyActiveHomeCategory(homeChromeActiveCategory || 'none'); syncHomeScreenChromeVisibility(); const sectionsToRender = []; if (useCache && sectionCache.renderedSections.length > 0) { LOG('Re-rendering home sections from cache'); sectionsToRender.push(...sectionCache.renderedSections); } else { const sortedSections = [...sections].sort((a, b) => { const orderA = (a.order !== undefined && a.order !== null) ? a.order : 99; const orderB = (b.order !== undefined && b.order !== null) ? b.order : 99; return orderA - orderB; }); const sectionPromises = []; for (const sectionConfig of sortedSections) { if (sectionConfig.enabled === false) { LOG(`Skipping disabled section: ${sectionConfig.id}`); continue; } if (sectionConfig.startDate && sectionConfig.endDate) { if (!isInSeasonalPeriod(sectionConfig.startDate, sectionConfig.endDate)) { LOG(`Skipping seasonal section (out of date): ${sectionConfig.id}`); continue; } } const sectionIdAttr = (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') ? CSS.escape(String(sectionConfig.id)) : String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const existingSection = container.querySelector(`[data-section-id="${sectionIdAttr}"]`); if (existingSection) { LOG(`Section already rendered: ${sectionConfig.id}`); continue; } sectionPromises.push(loadSectionForRendering(sectionConfig)); } // Resolve all section promises let sectionResults = await Promise.all(sectionPromises); sectionResults = sectionResults.filter(result => result !== null); sectionsToRender.push(...sectionResults); // Cache the rendered sections for potential re-render after Jellyfin overwrites the container sectionCache.renderedSections = [...sectionsToRender]; } const homeConfig = await window.KefinHomeScreen.getConfig(); const showStaleDataBeforeRefresh = homeConfig?.HOME_SETTINGS?.showStaleDataBeforeRefresh === true; const targetContainer = container; if (hasHomeCategorySections(container)) { LOG('Sections already rendered, skipping'); return; } await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, { waitForContainerClass: 'homeSectionsContainer', showStaleDataBeforeRefresh }); // If MediaBar plugin is in use, call LayoutSync.update() to update the layout if (typeof LayoutSync !== 'undefined' && LayoutSync && typeof LayoutSync.update === 'function') { LayoutSync.update(); } // Allow deferred library-cache crawls to start (movies/series/people scheduleBootstrap) if (!state.homePaintedEventFired) { state.homePaintedEventFired = true; try { document.dispatchEvent(new CustomEvent('kefinTweaksHomePainted')); } catch (_) { /* ignore */ } } // Check if target container is homeSectionsContainer if (!targetContainer.classList.contains('homeSectionsContainer')) { targetContainer.dataset.sectionsPrerendered = 'true'; } } finally { state.isRenderingHome = false; } } function isRecentlyAddedEnabled() { const userConfig = window.KefinTweaksConfig?.homeScreen; return userConfig?.recentlyAddedInLibrary && Object.keys(userConfig.recentlyAddedInLibrary).length > 0; } async function loadSectionForRendering(sectionConfig) { return window.sectionHelper.loadSectionForRendering(sectionConfig); } async function buildDiscoverySectionInstance(template, options = {}) { return window.sectionHelper.buildDiscoverySectionInstance(template, options); } function disconnectDiscoveryInteraction() { removeDiscoveryScrollListeners(); const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); if (loadMoreButton) { loadMoreButton.remove(); } // Remove the discovery loading indicator (exhausted path keeps it via markDiscoveryExhausted) if (state.discoverySectionsRemain !== false) { const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); if (loadingIndicator) { loadingIndicator.remove(); } setDiscoveryPageAttrs({ ready: false, infinite: false, exhausted: false }); } state.discoveryMode = null; state.discoveryGroupRemainder = []; } /** * Ensures discovery buffer is populated (pre-fetches next group) */ async function ensureDiscoveryBuffer() { if (!isDiscoveryAllowedForActiveCategory()) { LOG('Discovery buffer skipped: active category does not allow discovery'); return state.discoveryBuffer; } if (state.ensuringDiscoveryBuffer) { LOG('Already ensuring discovery buffer...'); while (state.ensuringDiscoveryBuffer) { LOG('Waiting for discovery buffer to be populated...'); await new Promise(resolve => setTimeout(resolve, 100)); } return state.discoveryBuffer; } LOG('Ensuring discovery buffer...'); state.ensuringDiscoveryBuffer = true; try { if (state.discoveryBuffer && state.discoveryBuffer.length > 0) { LOG('Discovery buffer already exists...'); return state.discoveryBuffer; } LOG('Fetching next discovery group data...'); let groupSections; if (Array.isArray(state.discoveryGroupRemainder) && state.discoveryGroupRemainder.length > 0) { groupSections = state.discoveryGroupRemainder.splice(0, DISCOVERY_SECTIONS_PER_GROUP); LOG(`Draining discovery remainder (${groupSections.length} sections, ${state.discoveryGroupRemainder.length} left)...`); } else { groupSections = await generateDiscoveryGroup(); } if (!groupSections || groupSections.length === 0) { LOG('No more discovery sections available for buffer.'); state.discoveryBuffer = []; state.discoveryGroupRemainder = []; state.discoverySectionsRemain = false; return state.discoveryBuffer; } state.discoveryBuffer = await Promise.all(groupSections.map(async (sectionConfig) => { const isDynamic = !!(sectionConfig.discoveryType || sectionConfig.source === 'Dynamic'); if (isDynamic) { // Sync stub + lazy resolve; do not await ensureData here return await window.sectionHelper.buildDiscoverySectionPromise(sectionConfig, { order: sectionConfig.order, pairSpotlight: true }); } return await loadSectionForRendering(sectionConfig); })); // Filter hard failures only; progressive stubs that later return empty are removed by cardBuilder enhance state.discoveryBuffer = state.discoveryBuffer.filter(result => result !== null); if (!state.discoveryBuffer.length) { // Only exhaust when this chunk failed AND nothing remains to try next if (!state.discoveryGroupRemainder?.length) { state.discoverySectionsRemain = false; } } return state.discoveryBuffer; } finally { state.ensuringDiscoveryBuffer = false; } } async function setupDiscoveryInteraction(container) { const userConfig = getDiscoverySettings(); if (userConfig && userConfig.enabled === false) { LOG('Discovery sections disabled in config.'); state.discoveryEnabled = false; return; } // Ensure at least 1 discovery section is enabled const discoverySections = await getDiscoverySections(); if (!discoverySections || discoverySections.length === 0 || discoverySections.every(section => section.enabled === false)) { LOG('No discovery sections enabled in config.'); state.discoveryEnabled = false; return; } if (!container) { WARN('setupDiscoveryInteraction called without a valid container'); return; } // getDiscoverySections() above may mark legacy customDiscoverySections; clear only that // so generateDiscoveryGroup can claim them without wiping genre/person/similar dedupe. window.sectionHelper?.getDiscoveryState?.()?.renderedDiscoveryIds?.customDiscoverySections?.clear?.(); state.discoverySectionsRemain = true; state.discoveryNeedsMoreScroll = false; state.discoveryGroupRemainder = []; if (PRE_FETCH_DISCOVERY_DATA) { ensureDiscoveryBuffer(); } await waitForHomeSectionsContainer(container); createDiscoveryLoadingIndicator(container); const useInfiniteScroll = userConfig ? (userConfig.infiniteScroll !== false) : true; if (useInfiniteScroll) { state.discoveryMode = 'infinite'; setDiscoveryPageAttrs({ ready: false, infinite: true, exhausted: false }); setupInfiniteScroll(container); } else { state.discoveryMode = 'chevron'; setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: false }); setupChevronDiscoverMore(container); } } async function renderNextDiscoveryGroup() { const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (!container) return; if (!isDiscoveryAllowedForActiveCategory()) { LOG('Discovery render skipped: active category does not allow discovery'); return; } // Single-flight: set synchronously before any await if (state.isRenderingDiscovery) return; if (state.discoverySectionsRemain === false) return; state.isRenderingDiscovery = true; // Require another user scroll before auto-loading the next group (infinite mode) state.discoveryNeedsMoreScroll = true; container.dataset.loadingDiscovery = 'true'; container.classList.add('loading-discovery'); showDiscoveryLoadingIndicator(); // Chevron mode: nudge scroll so newly appended sections start entering the viewport if (state.discoveryMode === 'chevron') { window.scrollBy({ top: 100, behavior: 'smooth' }); } try { await new Promise(resolve => requestAnimationFrame(resolve)); await new Promise(resolve => setTimeout(resolve, 0)); await ensureDiscoveryBuffer(); let bufferedSections = state.discoveryBuffer; if (!bufferedSections || bufferedSections.length === 0) { LOG('No more discovery sections available.'); markDiscoveryExhausted(); return; } // Stable append: honor pre-assigned section.order after parallel loads bufferedSections = bufferedSections.slice().sort((a, b) => { const ao = Number(a?.config?.order); const bo = Number(b?.config?.order); return (Number.isFinite(ao) ? ao : 0) - (Number.isFinite(bo) ? bo : 0); }); LOG(`Rendering Discovery Group ${state.discoveryGroupIndex + 1} (${bufferedSections.length} sections)...`); const config = await window.KefinHomeScreen.getConfig(); const revealSectionsSequentially = config.DISCOVERY_SETTINGS?.fadeInSections === true; // Sibling spinner stays outside the flex column — no insertBefore parking createDiscoveryLoadingIndicator(container); await window.cardBuilder.renderProgressiveSections(container, bufferedSections, { revealSectionsSequentially, enhanceOnVisible: true, showStaleDataBeforeRefresh: config.HOME_SETTINGS?.showStaleDataBeforeRefresh === true }); createDiscoveryLoadingIndicator(container); container.dataset.loadingDiscovery = 'false'; container.classList.remove('loading-discovery'); // Advance page/group only after the current logical group is fully drained if (!state.discoveryGroupRemainder?.length) { state.discoveryGroupIndex++; } state.discoveryBuffer = null; if (PRE_FETCH_DISCOVERY_DATA) { ensureDiscoveryBuffer(); } } catch (e) { ERR('Error rendering discovery group:', e); state.discoveryBuffer = null; createDiscoveryLoadingIndicator(container); } finally { state.isRenderingDiscovery = false; if (state.discoverySectionsRemain !== false) { hideDiscoveryLoadingIndicator(); container.dataset.loadingDiscovery = 'false'; container.classList.remove('loading-discovery'); } } } /** * Generates a balanced group of discovery sections */ async function generateDiscoveryGroup() { /* if (state.firstDiscoveryBuffer) { const firstBufferConfig = state.firstDiscoveryBuffer; delete state.firstDiscoveryBuffer; localCache.clear('firstDiscoveryBuffer'); firstBufferConfig.forEach(config => { config.order = state.discoverySectionOrder++; }); return firstBufferConfig; } */ /* let discoveryTemplates = flattenSectionGroups(Config.DISCOVERY_SECTION_GROUPS || []); const userDiscoverySectionGroups = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig && window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS; const userDiscoverySections = userDiscoverySectionGroups ? flattenSectionGroups(userDiscoverySectionGroups) : []; if (userDiscoverySections.length > 0) { discoveryTemplates = discoveryTemplates.map(tpl => { const userSection = userDiscoverySections.find(s => s.id === tpl.id); if (userSection) { return { ...tpl, ...userSection }; } return tpl; }); } */ const selectedConfigs = []; let discoveryTemplates = await getDiscoverySections(); if (getDiscoverySettings().randomizeOrder) { discoveryTemplates = [...discoveryTemplates].sort(() => 0.5 - Math.random()); } // Collect enabled templates unresolved — progressive resolve happens in ensureDiscoveryBuffer for (const template of discoveryTemplates) { if (template.enabled === false) continue; selectedConfigs.push(template); } if (getDiscoverySettings().renderSpotlightAboveMatching) { const spotlightGenreSection = selectedConfigs.find((section) => { const id = section.id.split('-')[0]; return id === 'spotlightGenre' || id === 'spotlightGenreSeries'; }); const spotlightNetworkSection = selectedConfigs.find(section => section.id.split('-')[0] === 'spotlightNetwork'); if (spotlightGenreSection) { const spotlightBaseId = spotlightGenreSection.id.split('-')[0]; const genreBaseId = spotlightBaseId === 'spotlightGenreSeries' ? 'genreSeries' : 'genreMovies'; const genreSection = selectedConfigs.find(section => section.id.split('-')[0] === genreBaseId); if (genreSection) { const genreSectionIndex = selectedConfigs.indexOf(genreSection); const spotlightGenreSectionIndex = selectedConfigs.indexOf(spotlightGenreSection); if (genreSectionIndex < spotlightGenreSectionIndex) { selectedConfigs.splice(genreSectionIndex, 1); selectedConfigs.splice(spotlightGenreSectionIndex, 0, genreSection); } else if (genreSectionIndex > spotlightGenreSectionIndex) { selectedConfigs.splice(genreSectionIndex, 1); selectedConfigs.splice(spotlightGenreSectionIndex + 1, 0, genreSection); } } } if (spotlightNetworkSection) { const studioSection = selectedConfigs.find(section => section.id.split('-')[0] === 'studioShows'); if (studioSection) { const studioSectionIndex = selectedConfigs.indexOf(studioSection); const spotlightNetworkSectionIndex = selectedConfigs.indexOf(spotlightNetworkSection); if (studioSectionIndex < spotlightNetworkSectionIndex) { selectedConfigs.splice(studioSectionIndex, 1); selectedConfigs.splice(spotlightNetworkSectionIndex, 0, studioSection); } else if (studioSectionIndex > spotlightNetworkSectionIndex) { selectedConfigs.splice(studioSectionIndex, 1); selectedConfigs.splice(spotlightNetworkSectionIndex + 1, 0, studioSection); } } } } // Apply user prefs keyed by template prefix for non-custom discovery variants try { const userHomeConfig = window.KefinUserHomeScreenConfig; if (userHomeConfig?.applyUserSectionOverrides && selectedConfigs.length) { const prefs = state.getDisplayPrefernces ? await state.getDisplayPrefernces() : null; const homeScreen = userHomeConfig.parseKefinTweaksHomeScreen?.(prefs?.CustomPrefs) || userHomeConfig.createEmptyHomeScreen?.() || { sections: [] }; const serverSectionsById = new Map(); selectedConfigs.forEach((section) => { const storedId = userHomeConfig.getStoredSectionPrefId?.(section) || section.id; if (storedId && !serverSectionsById.has(storedId)) { serverSectionsById.set(storedId, { ...section, id: storedId }); } }); const overridden = userHomeConfig.applyUserSectionOverrides( selectedConfigs, homeScreen, { serverSectionsById } ); selectedConfigs.length = 0; selectedConfigs.push(...overridden.filter(s => s.enabled !== false)); } } catch (e) { WARN('Failed to apply user overrides to discovery sections:', e); } // Order + tag the full logical group, then cap what we resolve this pass. // Leftovers are drained on subsequent ensureDiscoveryBuffer calls. selectedConfigs.forEach((section) => { section.order = state.discoverySectionOrder++; // Mark discovery render set so section roots get data-discovery-section // (templated discovery rows use Genre/Studio/etc. as type, not "discovery"). section.discoverySection = true; }); const capped = selectedConfigs.slice(0, DISCOVERY_SECTIONS_PER_GROUP); state.discoveryGroupRemainder = selectedConfigs.slice(DISCOVERY_SECTIONS_PER_GROUP); //localCache.set('discoveryBuffer', capped); return capped; } function setupInfiniteScroll(container) { const target = container || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (!target) return; removeDiscoveryScrollListeners(); target.dataset.discoveryHandler = 'true'; let lastScrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; const handleScroll = () => { if (!isDiscoveryHomeContext()) return; if (!target.dataset.sectionsRendered) return; if (state.isRenderingDiscovery) return; if (state.discoverySectionsRemain === false) return; const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const delta = Math.abs(scrollTop - lastScrollTop); lastScrollTop = scrollTop; // After a group loads, ignore threshold until the user scrolls again if (state.discoveryNeedsMoreScroll) { if (delta < 2) return; state.discoveryNeedsMoreScroll = false; } const windowHeight = window.innerHeight; const documentHeight = document.documentElement.scrollHeight; const threshold = windowHeight * 1.5; if (scrollTop + windowHeight < documentHeight - threshold) return; if (state.discoveryScrollDebounceTimer) { clearTimeout(state.discoveryScrollDebounceTimer); } state.discoveryScrollDebounceTimer = setTimeout(() => { state.discoveryScrollDebounceTimer = null; if (state.isRenderingDiscovery || state.discoveryNeedsMoreScroll) return; if (!isDiscoveryHomeContext()) return; const st = window.pageYOffset || document.documentElement.scrollTop; const wh = window.innerHeight; const dh = document.documentElement.scrollHeight; if (st + wh >= dh - wh * 1.5) { renderNextDiscoveryGroup(); } }, 200); }; state.infiniteScrollHandler = handleScroll; window.addEventListener('scroll', handleScroll, { passive: true }); LOG('Infinite scroll enabled for discovery (debounced + scroll-gated re-arm).'); } /** * infiniteScroll=false: Discover More + chevron; load when user scrolls again at bottom. */ function setupChevronDiscoverMore(container) { const target = container || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (!target) return; removeDiscoveryScrollListeners(); target.dataset.discoveryHandler = 'true'; createDiscoveryLoadingIndicator(target); hideDiscoveryLoadingIndicator(); let lastTouchY = null; const scheduleLoadFromBottomGesture = () => { if (state.isRenderingDiscovery) return; if (state.discoverySectionsRemain === false) return; if (state.discoveryScrollDebounceTimer) { clearTimeout(state.discoveryScrollDebounceTimer); } state.discoveryScrollDebounceTimer = setTimeout(() => { state.discoveryScrollDebounceTimer = null; if (state.isRenderingDiscovery) return; if (!isDiscoveryHomeContext()) return; LOG('User scrolled at bottom (chevron mode); loading next discovery group...'); renderNextDiscoveryGroup(); }, 200); }; const isAtDocumentBottom = () => { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const windowHeight = window.innerHeight; const documentHeight = document.documentElement.scrollHeight; return scrollTop + windowHeight >= documentHeight - 2; }; const handleWheel = (event) => { if (!isDiscoveryHomeContext()) return; if (!target.dataset.sectionsRendered) return; if (state.isRenderingDiscovery) return; if (event.deltaY <= 0) return; if (!isAtDocumentBottom()) return; scheduleLoadFromBottomGesture(); }; const handleTouchStart = (event) => { const touch = event.touches && event.touches[0]; lastTouchY = touch ? touch.clientY : null; }; const handleTouchMove = (event) => { if (!isDiscoveryHomeContext()) return; if (!target.dataset.sectionsRendered) return; if (state.isRenderingDiscovery) return; const touch = event.touches && event.touches[0]; if (!touch) return; if (lastTouchY == null) { lastTouchY = touch.clientY; return; } const deltaY = touch.clientY - lastTouchY; lastTouchY = touch.clientY; // Upward swipe scrolls content down if (deltaY >= 0) return; if (!isAtDocumentBottom()) return; scheduleLoadFromBottomGesture(); }; state.discoveryWheelHandler = handleWheel; state.discoveryTouchStartHandler = handleTouchStart; state.discoveryTouchMoveHandler = handleTouchMove; window.addEventListener('wheel', handleWheel, { passive: true }); window.addEventListener('touchstart', handleTouchStart, { passive: true }); window.addEventListener('touchmove', handleTouchMove, { passive: true }); LOG('Chevron Discover More enabled for discovery (scroll-again at bottom).'); } function isInSeasonalPeriod(start, end) { const now = new Date(); const currentYear = now.getFullYear(); const startDate = new Date(`${start}-${currentYear}`); let endDate = new Date(`${end}-${currentYear}`); if (endDate < startDate) endDate.setFullYear(currentYear + 1); else if (endDate > startDate) endDate.setFullYear(currentYear); return now >= startDate && now <= endDate; } function isHalloweenPeriod() { return isInSeasonalPeriod('10-01', '10-31'); } function isChristmasPeriod() { return isInSeasonalPeriod('12-01', '12-31'); } function isNewYearsPeriod() { return isInSeasonalPeriod('01-01', '01-05'); } async function isValentinesPeriod() { const homeScreenConfig = await window.KefinHomeScreen.getConfig(); const valentinesConfig = homeScreenConfig.SEASONAL_SECTION_GROUPS.find(s => s.id === 'seasonal-valentines'); if (!valentinesConfig) { return false; } const activeSection = valentinesConfig.sections.find(s => s.enabled); if (!activeSection) { return false; } return isInSeasonalPeriod(activeSection.startDate, activeSection.endDate); } async function getPopularTVNetworks() { if (!StudiosCache) return []; // Get Minimum Series Count from config await StudiosCache.init(); return StudiosCache.getPopularTVNetworks(); } async function refreshHomeSections() { const container = document.querySelector('.homePage:not(.hide) #homeTab .sections'); if (!container) { LOG('refreshHomeSections: container not found'); return; } container.querySelectorAll('[data-section-id]').forEach(el => el.remove()); delete container.dataset.sectionsRendered; delete container.dataset.kefinHomeScreen; sectionCache.renderedSections = []; sectionCache.fragmentCache = null; sectionCache.isInitialRender = true; await enhanceHomeScreen(); } const HOME_CHROME_CREATE_ID = 'kefin-home-create-section-btn'; const HOME_CHROME_CATEGORY_RAIL_ID = 'kefin-home-category-rail'; const HOME_CHROME_CATEGORY_STYLE_ID = 'kefin-home-category-filter-styles'; const HOME_CHROME_CLASSIC_RAIL_CLASSES = 'skinHeader skinHeader-withBackground emby-tabs-slider emby-tab-button'; const HOME_CHROME_MODERN_RAIL_CLASSES = 'MuiPaper-root MuiPaper-elevation MuiPaper-elevation1 MuiAppBar-root MuiAppBar-colorDefault MuiAppBar-positionFixed mui-fixed'; let homeChromeActiveCategory = 'none'; let homeChromeCreateMounted = false; let homeChromeCreateMounting = false; let homeChromeCategoryMounted = false; let homeChromeHasPinnedContent = false; let homeChromeVisibilityObserver = null; let homeChromeRailScrollBound = false; let homeChromeRailLastY = 0; const HOME_CHROME_RAIL_SCROLL_DELTA = 8; const HOME_CHROME_RAIL_MOBILE_MQ = '(max-width: 899px)'; function isHomeCategoryRailMobile() { return typeof window.matchMedia === 'function' ? window.matchMedia(HOME_CHROME_RAIL_MOBILE_MQ).matches : window.innerWidth < 900; } function setHomeCategoryRailScrollHidden(hidden) { const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); if (!rail) return; rail.classList.toggle('is-scroll-hidden', !!hidden); } function clearHomeCategoryRailScrollHidden() { setHomeCategoryRailScrollHidden(false); } function handleHomeCategoryRailScroll() { const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); if (!rail || rail.classList.contains('hide') || !isHomeCategoryRailMobile()) { clearHomeCategoryRailScrollHidden(); return; } const y = window.pageYOffset || document.documentElement.scrollTop || 0; if (y <= HOME_CHROME_RAIL_SCROLL_DELTA) { clearHomeCategoryRailScrollHidden(); homeChromeRailLastY = y; return; } const delta = y - homeChromeRailLastY; if (Math.abs(delta) < HOME_CHROME_RAIL_SCROLL_DELTA) return; if (delta > 0) { setHomeCategoryRailScrollHidden(true); } else { setHomeCategoryRailScrollHidden(false); } homeChromeRailLastY = y; } function syncHomeCategoryRailScrollListener() { const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); const showFilters = getHomeSettings().showCategoryFilters !== false; const shouldListen = !!(rail && !rail.classList.contains('hide') && isHomePageVisible() && showFilters && isHomeCategoryRailMobile()); if (shouldListen) { if (!homeChromeRailScrollBound) { homeChromeRailLastY = window.pageYOffset || document.documentElement.scrollTop || 0; window.addEventListener('scroll', handleHomeCategoryRailScroll, { passive: true }); window.addEventListener('resize', syncHomeCategoryRailScrollListener, { passive: true }); homeChromeRailScrollBound = true; } if (!isHomeCategoryRailMobile()) clearHomeCategoryRailScrollHidden(); return; } if (homeChromeRailScrollBound) { window.removeEventListener('scroll', handleHomeCategoryRailScroll); window.removeEventListener('resize', syncHomeCategoryRailScrollListener); homeChromeRailScrollBound = false; } clearHomeCategoryRailScrollHidden(); } function getHomeSettings() { return window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS || window.KefinHomeConfig2?.HOME_SETTINGS || {}; } function getConfiguredCategories() { const defaults = window.KefinHomeConfig2?.HOME_SETTINGS?.categories || []; const configured = getHomeSettings().categories; if (Array.isArray(configured) && configured.length) return configured; return defaults; } function sectionIsPinnedCategory(section) { const id = String(section?.id || ''); return id.startsWith('pinned-list-') || id.startsWith('pinned-parent-') || section?.category === 'pinned'; } function updatePinnedCategoryAvailability(sections, homeScreen) { const fromSections = (sections || []).some(s => sectionIsPinnedCategory(s) && s.enabled !== false); homeChromeHasPinnedContent = fromSections; } function getRailCategories() { return getConfiguredCategories().filter((cat) => { if (cat?.id === 'pinned') return homeChromeHasPinnedContent; return true; }); } function isModernUIChrome() { return !!document.querySelector('main.MuiBox-root') || localStorage.getItem('layout')?.length === 0; } function getCategoryRailNativeClasses() { const base = 'kefin-home-chrome kefin-home-category-rail'; if (isModernUIChrome()) { return `${base} ${HOME_CHROME_MODERN_RAIL_CLASSES}`; } return `${base} ${HOME_CHROME_CLASSIC_RAIL_CLASSES}`; } function isHomePageVisible() { const active = document.documentElement.getAttribute('data-active-page'); if (active !== null) { return active === 'home'; } return !!document.querySelector('.homePage:not(.hide) #homeTab.is-active'); } function syncHomeScreenChromeVisibility() { const onHome = isHomePageVisible(); const createBtn = document.getElementById(HOME_CHROME_CREATE_ID); if (createBtn) { createBtn.classList.toggle('hide', !onHome); createBtn.setAttribute('aria-hidden', onHome ? 'false' : 'true'); } const showFilters = getHomeSettings().showCategoryFilters !== false; const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); if (rail) { const visible = onHome && showFilters; rail.classList.toggle('hide', !visible); rail.setAttribute('aria-hidden', visible ? 'false' : 'true'); if (!visible) clearHomeCategoryRailScrollHidden(); } if (onHome) { applyActiveHomeCategory(homeChromeActiveCategory || 'none'); } syncHomeCategoryRailScrollListener(); } function ensureHomeChromeVisibilityObserver() { if (homeChromeVisibilityObserver) return; homeChromeVisibilityObserver = new MutationObserver(() => syncHomeScreenChromeVisibility()); homeChromeVisibilityObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); } async function mountCreateSectionButton() { const existingNodes = document.querySelectorAll(`#${HOME_CHROME_CREATE_ID}`); if (existingNodes.length > 1) { existingNodes.forEach((el, idx) => { if (idx > 0) el.remove(); }); } if (homeChromeCreateMounted || homeChromeCreateMounting || document.getElementById(HOME_CHROME_CREATE_ID)) { homeChromeCreateMounted = !!document.getElementById(HOME_CHROME_CREATE_ID); return; } if (getHomeSettings().showCreateSectionButtonOnHome !== true) return; homeChromeCreateMounting = true; try { let isAdmin = false; try { isAdmin = !!(await window.apiHelper?.isAdmin?.()); } catch { return; } if (!isAdmin) return; // Re-check after await — another caller may have mounted meanwhile if (document.getElementById(HOME_CHROME_CREATE_ID)) { homeChromeCreateMounted = true; return; } const wrap = document.createElement('div'); wrap.id = HOME_CHROME_CREATE_ID; wrap.className = 'kefin-home-chrome kefin-home-create-section'; wrap.classList.toggle('hide', !isHomePageVisible()); wrap.setAttribute('aria-hidden', isHomePageVisible() ? 'false' : 'true'); wrap.innerHTML = ` `; wrap.querySelector('button')?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); try { window.KefinHomeScreen?.openNewCustomSection?.({ refreshHomeOnSave: true }); } catch (err) { ERR('Failed to open create section from home:', err); } }); document.body.appendChild(wrap); homeChromeCreateMounted = true; } finally { homeChromeCreateMounting = false; } } function buildCategoryFilterStyles(categories) { const rules = []; rules.push(` .homeSectionsContainer[data-render-categories-separately="true"][data-category="none"] > [data-section-id]:not([data-category="none"]) { display: none !important; } `); (categories || []).forEach((cat) => { const id = String(cat?.id || '').trim(); if (!id || id === 'none') return; const safe = CSS.escape(id); rules.push(` .homeSectionsContainer[data-category="${safe}"] > [data-section-id]:not([data-category="${safe}"]) { display: none !important; } `); }); return rules.join('\n'); } function ensureCategoryFilterStyles() { let styleEl = document.getElementById(HOME_CHROME_CATEGORY_STYLE_ID); if (!styleEl) { styleEl = document.createElement('style'); styleEl.id = HOME_CHROME_CATEGORY_STYLE_ID; document.head.appendChild(styleEl); } styleEl.textContent = buildCategoryFilterStyles(getConfiguredCategories()); } /** * True when at least one Kefin Home-category section (data-category="none") is in the DOM. * Discovery-only remounts must not count as "home already rendered". * @param {ParentNode|null|undefined} root * @returns {boolean} */ function hasHomeCategorySections(root) { const scope = root || document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer'); if (!scope) return false; return !!scope.querySelector('[data-section-id][data-category="none"]'); } /** * Before category CSS hides other sections: pick the target-category section to scroll to. * Last match that has reached the viewport (top <= viewport bottom); if all are still below, use the first. * @param {HTMLElement} container * @param {string} categoryId * @returns {HTMLElement|null} */ function pickHomeCategoryScrollTarget(container, categoryId) { if (!container || !categoryId) return null; const safe = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(categoryId) : String(categoryId).replace(/"/g, '\\"'); const matches = Array.from( container.querySelectorAll(`[data-section-id][data-category="${safe}"]`) ); if (!matches.length) return null; const viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0; let lastSeen = null; for (let i = 0; i < matches.length; i++) { const rect = matches[i].getBoundingClientRect(); if (rect.top <= viewportBottom) lastSeen = matches[i]; else break; } return lastSeen || matches[0]; } function scrollHomeSectionToStart(el) { if (!el) return; try { el.scrollIntoView({ block: 'start', behavior: 'instant' }); } catch (_) { try { el.scrollIntoView({ block: 'start', behavior: 'auto' }); } catch (__) { el.scrollIntoView(true); } } } function applyActiveHomeCategory(categoryId) { const categories = getRailCategories(); const next = categories.some((c) => c.id === categoryId) ? categoryId : 'none'; const prev = homeChromeActiveCategory || 'none'; const categoryChanged = prev !== next; const separate = getHomeSettings().renderCategoriesSeparately === true; const visibleContainer = document.querySelector( '.libraryPage:not(.hide) .homeSectionsContainer, .homePage:not(.hide) .homeSectionsContainer' ); homeChromeActiveCategory = next; document.querySelectorAll('.homeSectionsContainer').forEach((container) => { container.dataset.category = next; container.dataset.renderCategoriesSeparately = separate ? 'true' : 'false'; }); const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); if (rail) { rail.querySelectorAll('.kefin-home-category-item').forEach((item) => { const active = item.dataset.categoryId === next; item.classList.toggle('is-active', active); const btn = item.querySelector('.kefin-home-category-btn'); if (!btn) return; btn.classList.toggle('is-active', active); btn.classList.toggle('emby-tab-button-active', active); btn.setAttribute('aria-pressed', active ? 'true' : 'false'); }); } // Always scroll to the top of the screen, regardless of categoryChanged or visibleContainer if (categoryChanged && visibleContainer) { requestAnimationFrame(() => { window.scrollTo({ top: 0, behavior: 'instant' }); }); } if (next === 'discovery' && isDiscoveryAllowedForActiveCategory()) { const container = visibleContainer; if (container) { const hasDiscovery = !!( container.querySelector('[data-discovery-section="true"], [data-category="discovery"]') ); if (!hasDiscovery) { try { renderNextDiscoveryGroup(); } catch (err) { ERR('Failed to bootstrap discovery group for Discover category:', err); } } } } } function isDiscoveryAllowedForActiveCategory() { const separate = getHomeSettings().renderCategoriesSeparately === true; const active = homeChromeActiveCategory || 'none'; if (separate) return active === 'discovery'; return active === 'none' || active === 'discovery'; } function mountCategoryRail() { if (getHomeSettings().showCategoryFilters === false) { const existing = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); if (existing) existing.remove(); homeChromeCategoryMounted = false; ensureCategoryFilterStyles(); homeChromeActiveCategory = 'none'; applyActiveHomeCategory('none'); syncHomeCategoryRailScrollListener(); return; } if (homeChromeCategoryMounted && document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID)) { ensureCategoryFilterStyles(); applyActiveHomeCategory(homeChromeActiveCategory || 'none'); return; } const categories = getRailCategories(); ensureCategoryFilterStyles(); const desiredCategory = categories.some((c) => c.id === homeChromeActiveCategory) ? homeChromeActiveCategory : 'none'; const outer = document.createElement('div'); outer.id = HOME_CHROME_CATEGORY_RAIL_ID; outer.className = 'sectionTabs'; outer.classList.toggle('hide', !isHomePageVisible()); outer.setAttribute('role', 'toolbar'); outer.setAttribute('aria-label', 'Home categories'); outer.setAttribute('aria-hidden', isHomePageVisible() ? 'false' : 'true'); const inner = document.createElement('div'); inner.className = getCategoryRailNativeClasses(); inner.innerHTML = categories.map((cat) => { const id = escapeHtmlChrome(cat.id); const rawName = typeof cat.name === 'string' ? cat.name.trim() : ''; const name = escapeHtmlChrome(rawName || cat.id); const icon = escapeHtmlChrome(cat.icon || 'label'); const active = desiredCategory === cat.id; const labelHtml = rawName ? `${escapeHtmlChrome(rawName)}` : ''; return `