// KefinTweaks Infinite Scroll // Adds infinite scrolling to media library pages (Movies, TV, Music) // Requires: cardBuilder.js module to be loaded before this script (function () { const RETRY_ATTEMPTS = 3; const RETRY_DELAY = 1000; // Common logging functions const LOG = (...args) => console.log('[KefinTweaks InfiniteScroll]', ...args); const WARN = (...args) => console.warn('[KefinTweaks InfiniteScroll]', ...args); const ERR = (...args) => console.error('[KefinTweaks InfiniteScroll]', ...args); LOG('Initializing...'); // Supported pages (same as headerTabs) const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html', 'tv', 'movies', 'music', 'mixed']; // Media type configurations - Read from centralized config or use defaults const MEDIA_CONFIGS = { 'movies.html': { includeItemTypes: 'Movie', context: 'movies', sortBy: 'SortName,ProductionYear' }, 'tv.html': { includeItemTypes: 'Series', context: 'tvshows', sortBy: 'SortName,ProductionYear' }, 'music.html': { includeItemTypes: 'Music', context: 'music', sortBy: 'SortName,ProductionYear' }, 'movies': { includeItemTypes: 'Movie', context: 'movies', sortBy: 'SortName,ProductionYear' }, 'tv': { includeItemTypes: 'Series', context: 'tvshows', sortBy: 'SortName,ProductionYear' }, 'music': { includeItemTypes: 'Music', context: 'music', sortBy: 'SortName,ProductionYear' }, 'mixed': { includeItemTypes: null, context: 'mixed', sortBy: 'SortName,ProductionYear' } }; const MODERN_LIBRARY_PAGE_IDS = { movies: 'moviesPage', tv: 'tvshowsPage', music: 'musicPage', mixed: 'mixed' }; let userId = null; const limit = 100; let currentPage = null; let currentTab = 0; let startIndex = 0; let loading = false; let hasMore = true; let totalRecordCount = null; // Track total items available from API let retryCount = 0; // scrollState removed - not needed let scrollTimeout = null; let isScrollTriggered = false; // Prevent multiple triggers let isRequestInProgress = false; // Blocking flag to prevent multiple simultaneous requests let hasInitialized = false; // Prevent multiple initializations let isInitializing = false; // Prevent concurrent initializations // # is not a normal sort prefix. Track when that bucket is finished so the next // page can continue at A without reusing the # offset. let alphaRangeKey = ''; let hashBucketCount = null; // Create loading indicator element function createLoadingIndicator() { const loadingDiv = document.createElement('div'); loadingDiv.className = 'infinite-scroll-loading'; loadingDiv.id = 'infinite-scroll-loading-indicator'; loadingDiv.innerHTML = `
Loading more content... `; return loadingDiv; } // Show loading indicator function showLoadingIndicator() { let loadingIndicator = document.getElementById('infinite-scroll-loading-indicator'); if (!loadingIndicator) { loadingIndicator = createLoadingIndicator(); // Find the container and append the loading indicator as a sibling const container = getContainer(); if (container && container.parentNode) { container.parentNode.insertBefore(loadingIndicator, container.nextSibling); } else { // Fallback: append to body document.body.appendChild(loadingIndicator); } } // Show with animation setTimeout(() => { loadingIndicator.classList.add('show'); }, 10); LOG('Loading indicator shown'); } // Hide loading indicator function hideLoadingIndicator() { const loadingIndicator = document.getElementById('infinite-scroll-loading-indicator'); if (loadingIndicator) { loadingIndicator.classList.remove('show'); // Remove from DOM after animation setTimeout(() => { if (loadingIndicator.parentNode) { loadingIndicator.parentNode.removeChild(loadingIndicator); } }, 300); LOG('Loading indicator hidden'); } } const COUNT_GROUPED_NUMBER = String.raw`\d{1,3}(?:[,\s\u00A0\u202F]\d{3})*|\d+`; const COUNT_RANGE_PATTERN = new RegExp( `^(${COUNT_GROUPED_NUMBER})\\s*-\\s*(${COUNT_GROUPED_NUMBER})\\s+of\\s+(${COUNT_GROUPED_NUMBER})\\s*$` ); function formatItemCount(value) { if (value == null || value === '?') return value == null ? '' : '?'; const digits = String(value).replace(/[,\s\u00A0\u202F]/g, ''); const count = Number(digits); if (!Number.isFinite(count)) return String(value); return count.toLocaleString(); } const MODERN_PAGING_PAGES = ['tv', 'movies', 'music', 'mixed']; let pagingObserver = null; let pagingApplyScheduled = false; function isInfiniteScrollEnabled() { return window.KefinTweaksConfig?.scripts?.infiniteScroll !== false; } function getInfiniteScrollOptions() { const options = window.KefinTweaksConfig?.infiniteScroll; if (!options || typeof options !== 'object' || Array.isArray(options)) { return { hidePageControls: false, onlyShowTotalItemCount: false, showFollowingAlphaLetters: false }; } return { hidePageControls: options.hidePageControls === true, onlyShowTotalItemCount: options.onlyShowTotalItemCount === true, showFollowingAlphaLetters: options.showFollowingAlphaLetters === true }; } function isMainLibraryPagingPage() { const page = document.documentElement.dataset.activePage || ''; return MODERN_PAGING_PAGES.includes(page); } function modernPagingHeader() { if (!isMainLibraryPagingPage()) return null; return document.querySelector('header.MuiAppBar-root'); } function pageControlHideCss() { if (!getInfiniteScrollOptions().hidePageControls) return ''; const modernButtons = MODERN_PAGING_PAGES .map((page) => [ `html[data-active-page="${page}"] header.MuiAppBar-root .MuiToolbar-root button[title="Previous"]`, `html[data-active-page="${page}"] header.MuiAppBar-root .MuiToolbar-root button[title="Next"]` ].join(',\n')) .join(',\n'); return ` .libraryPage:not(.hide) .paging .listPaging .btnPreviousPage, .libraryPage:not(.hide) .paging .listPaging .btnNextPage, ${modernButtons} { display: none !important; } `; } // Add CSS to hide paging controls when infinite scroll is active function addInfiniteScrollCSS() { if (!isInfiniteScrollEnabled()) { LOG('Infinite scroll disabled in configuration, skipping CSS addition'); return; } const styleId = 'infinite-scroll-paging-hide'; let style = document.getElementById(styleId); if (!style) { style = document.createElement('style'); style.id = styleId; document.head.appendChild(style); } style.textContent = ` ${pageControlHideCss()} /* Infinite scroll loading indicator */ .infinite-scroll-loading { display: flex; flex-direction: column; gap: 10px; justify-content: center; align-items: center; padding: 20px; margin: 20px 0; border-radius: 8px; font-size: 14px; color: #666; opacity: 0; transition: opacity 0.3s ease; } .infinite-scroll-loading.show { opacity: 1; } .infinite-scroll-loading .spinner { width: 20px; height: 20px; border: 2px solid #f3f3f3; border-top: 2px solid #3498db; border-radius: 50%; animation: infinite-scroll-spin 1s linear infinite; margin-right: 10px; } @keyframes infinite-scroll-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; LOG('Added CSS to hide paging controls for infinite scroll'); } function countLabelScopes() { const scopes = []; const view = getLibraryViewRoot(); if (view) scopes.push(view); const libraryPage = document.querySelector('.libraryPage:not(.hide)'); if (libraryPage && !scopes.includes(libraryPage)) scopes.push(libraryPage); const header = modernPagingHeader(); if (header && !scopes.includes(header)) scopes.push(header); return scopes; } function isPagingCountLabel(el) { if (!el) return false; if (el.matches('.listPaging > span')) return true; if (!el.classList.contains('MuiChip-label')) return false; return COUNT_RANGE_PATTERN.test(String(el.textContent || '').trim()) || el.dataset.kefinTotalCount === 'true'; } function forEachCountLabel(callback) { const seen = new Set(); countLabelScopes().forEach((scope) => { scope.querySelectorAll('.listPaging > span, .MuiChip-label').forEach((el) => { if (seen.has(el) || !isPagingCountLabel(el)) return; seen.add(el); callback(el); }); }); } function totalFromLabel(el) { const match = String(el?.textContent || '').trim().match(COUNT_RANGE_PATTERN); if (match) return match[3]; if (el?.dataset?.kefinTotalCount === 'true' && totalRecordCount != null) { return String(totalRecordCount); } return null; } function renderCountLabel(el, shownCount, total) { if (!el) return; if (getInfiniteScrollOptions().onlyShowTotalItemCount) { const raw = total == null || total === '?' ? totalFromLabel(el) : total; const next = raw == null || raw === '?' ? raw : formatItemCount(raw); if (!next) return; el.dataset.kefinTotalCount = 'true'; if (el.textContent !== next) el.textContent = next; return; } if (shownCount == null || total == null) return; delete el.dataset.kefinTotalCount; el.textContent = `1-${formatItemCount(shownCount)} of ${formatItemCount(total)}`; } function applyTotalCountLabels() { if (!isInfiniteScrollEnabled() || !getInfiniteScrollOptions().onlyShowTotalItemCount) return; if (!getLibraryViewRoot() && !modernPagingHeader()) return; forEachCountLabel((el) => { const total = totalFromLabel(el); if (!total) return; renderCountLabel(el, null, total); }); } function scheduleApplyTotalCountLabels() { if (pagingApplyScheduled) return; pagingApplyScheduled = true; requestAnimationFrame(() => { pagingApplyScheduled = false; applyTotalCountLabels(); }); } function disconnectPagingObserver() { if (pagingObserver) { pagingObserver.disconnect(); pagingObserver = null; } } function syncPagingPresentation() { addInfiniteScrollCSS(); disconnectPagingObserver(); if (!isInfiniteScrollEnabled() || !getInfiniteScrollOptions().onlyShowTotalItemCount) return; const root = getLibraryViewRoot() || document.querySelector('.libraryPage:not(.hide)'); const header = modernPagingHeader(); if (!root && !header) return; applyTotalCountLabels(); pagingObserver = new MutationObserver(() => { scheduleApplyTotalCountLabels(); }); const observe = { subtree: true, childList: true, characterData: true }; if (root) pagingObserver.observe(root, observe); if (header && header !== root) pagingObserver.observe(header, observe); } // Wait for ApiClient to be available function waitForApiClient() { return new Promise((resolve) => { if (typeof ApiClient !== 'undefined' && ApiClient._serverInfo && ApiClient._serverInfo.UserId) { LOG('ApiClient ready, userId:', ApiClient._serverInfo.UserId); resolve(); } else { LOG('Waiting for ApiClient...'); setTimeout(() => waitForApiClient().then(resolve), 100); } }); } // Wait for cardBuilder to be available function waitForCardBuilder() { return new Promise((resolve) => { if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) { LOG('cardBuilder ready'); resolve(); } else { LOG('Waiting for cardBuilder...'); setTimeout(() => waitForCardBuilder().then(resolve), 100); } }); } // Page detection function (same as headerTabs) function getCurrentPage() { const hash = window.location.hash; const hashMatches = hash.match(/#\/([^?]+)/g); if (!hashMatches || hashMatches.length === 0) { LOG('No hash matches found for:', hash); return null; } for (const match of hashMatches) { const pageFromHash = match.replace('#/', ''); if (SUPPORTED_PAGES.includes(pageFromHash)) { return pageFromHash; } } return null; } // Get current tab index from URL function getCurrentTab() { const hash = window.location.hash; let searchParams = ''; if (hash.includes('?')) { searchParams = hash.split('?')[1]; } else { searchParams = window.location.search.substring(1); } const urlParams = new URLSearchParams(searchParams); const tabParam = urlParams.get('tab'); return tabParam ? parseInt(tabParam, 10) : 0; } // State persistence functions - REMOVED // We don't need to store scroll state as it can cause issues with infinite scroll behavior function getParentId() { const match = window.location.hash.match(/topParentId=([^&]+)/); return match ? match[1] : null; } // Get current sort settings from Jellyfin's localStorage keys function getCurrentSortSettings() { const userId = ApiClient.getCurrentUserId(); const parentId = getParentId(); const page = getCurrentPage(); if (!userId || !parentId || !page) { LOG('Missing required IDs for sort detection:', { userId, parentId, page }); return null; } // Map page to media type const mediaTypeMap = { 'movies.html': 'movies', 'movies': 'movies', 'tv.html': 'series', 'tv': 'series' }; const mediaType = mediaTypeMap[page]; if (!mediaType) { LOG('Unsupported page for sort detection:', page); return null; } // Build localStorage keys const storageKey = `${userId}-${parentId}-${mediaType}`; const filterKey = `${userId}-${parentId}-${mediaType}-filter`; LOG('Looking for sort settings in localStorage keys:', { storageKey, filterKey }); try { // Get main sort settings const sortData = JSON.parse(localStorage.getItem(storageKey) || '{}'); const filterData = JSON.parse(localStorage.getItem(filterKey) || '{}'); LOG('Found sort data:', sortData); LOG('Found filter data:', filterData); return { sortBy: sortData.SortBy || 'SortName,ProductionYear', sortOrder: sortData.SortOrder || 'Ascending', filters: filterData.Filters || '', years: filterData.Years || '', genres: filterData.Genres || '', tags: filterData.Tags || '', nameStartsWith: sortData.NameStartsWith || '' }; } catch (error) { ERR('Error parsing sort settings from localStorage:', error); return null; } } function isModernLibraryUi() { return window.KefinTweaksUtils?.isModernUI?.() === true; } function getLibraryViewRoot() { const page = getCurrentPage(); if (!page) return null; if (isModernLibraryUi()) { if (getCurrentTab() !== 0) return null; const pageId = MODERN_LIBRARY_PAGE_IDS[page]; if (!pageId) return null; return document.getElementById(pageId); } const libraryPage = document.querySelector(".libraryPage:not(.hide)"); if (!libraryPage) return null; if (page === 'tv.html' || page === 'tv') { return libraryPage.querySelector("#seriesTab.is-active"); } if (page === 'movies.html' || page === 'movies') { return libraryPage.querySelector("#moviesTab.is-active"); } return null; } function readSelectedAlphaPickerValue(root) { if (!root) return null; const selectedButton = root.querySelector(".alphaPickerButton-selected, .alphaPicker-fixed-right [aria-pressed='true']"); if (!selectedButton) return null; const dataValue = selectedButton.getAttribute('data-value') || selectedButton.value; return dataValue ? dataValue : null; } // Selected letter from the side alpha picker. Jellyfin stores it as NameStartsWith. function getCurrentAlphaPicker() { const view = getLibraryViewRoot(); const fromView = readSelectedAlphaPickerValue(view); if (fromView) { LOG('Found alpha picker selection:', fromView); return { value: fromView, sawPicker: true }; } const libraryPage = document.querySelector(".libraryPage:not(.hide)"); const fromPage = readSelectedAlphaPickerValue(libraryPage); if (fromPage) { LOG('Found alpha picker selection:', fromPage); return { value: fromPage, sawPicker: true }; } const sawPicker = [view, libraryPage].some((scope) => scope?.querySelector(".alphaPickerButton, .alphaPickerRow")); return { value: null, sawPicker }; } function primarySortField(sortBy) { return String(sortBy || '').split(',')[0].trim().toLowerCase(); } function isSortNameSort(sortBy) { const field = primarySortField(sortBy); return field === 'sortname' || field === 'name'; } function isDescendingSort(sortOrder) { return String(sortOrder || '').toLowerCase() === 'descending'; } function nextAlphaLetter(letter) { const value = String(letter || '').trim().toUpperCase(); if (value.length !== 1 || value < 'A' || value > 'Y') return null; return String.fromCharCode(value.charCodeAt(0) + 1); } function normalizeAlphaLetter(letter) { const value = String(letter || '').trim(); if (value === '#') return '#'; if (/^[a-zA-Z]$/.test(value)) return value.toUpperCase(); return value; } /** * Widen a selected alpha-picker letter across the rest of a SortName list. * Ascending uses NameStartsWithOrGreater. Descending uses NameLessThan on the * next letter, and Z drops the letter filter because the descending library * already starts there. # stays an exact bucket until it is exhausted. */ function resolveAlphaQuery(nameStartsWith, sortBy, sortOrder, displayedCount) { const letter = normalizeAlphaLetter(nameStartsWith); const identity = [letter, sortBy, sortOrder, getParentId(), getCurrentPage(), getCurrentTab()].join('|'); if (identity !== alphaRangeKey) { alphaRangeKey = identity; hashBucketCount = null; } if (!letter || !isSortNameSort(sortBy) || !getInfiniteScrollOptions().showFollowingAlphaLetters) { return letter ? { mode: 'exact', startIndex: displayedCount, nameStartsWith: letter } : { mode: 'none', startIndex: displayedCount }; } const descending = isDescendingSort(sortOrder); if (letter === '#') { if (!descending && hashBucketCount != null) { return { mode: 'after-hash', startIndex: Math.max(0, displayedCount - hashBucketCount), nameStartsWithOrGreater: 'A' }; } return { mode: descending ? 'exact' : 'hash', startIndex: displayedCount, nameStartsWith: '#' }; } if (!/^[A-Z]$/.test(letter)) { return { mode: 'exact', startIndex: displayedCount, nameStartsWith: letter }; } if (!descending) { return { mode: 'or-greater', startIndex: displayedCount, nameStartsWithOrGreater: letter }; } const next = nextAlphaLetter(letter); if (!next) { return { mode: 'unbounded', startIndex: displayedCount }; } return { mode: 'less-than', startIndex: displayedCount, nameLessThan: next }; } function applyAlphaQuery(apiParams, query) { delete apiParams.NameStartsWith; delete apiParams.NameStartsWithOrGreater; delete apiParams.NameLessThan; if (query.nameStartsWith) apiParams.NameStartsWith = query.nameStartsWith; if (query.nameStartsWithOrGreater) apiParams.NameStartsWithOrGreater = query.nameStartsWithOrGreater; if (query.nameLessThan) apiParams.NameLessThan = query.nameLessThan; apiParams.StartIndex = query.startIndex; } // Grid cards are direct children. A modern list wraps every row, so count those rows. function renderedItemCount(container) { if (!container) return 0; const listHost = listRowHost(container); if (listHost) return listHost.querySelectorAll(':scope > .listItem').length; return container.children.length; } // Get current start index based on existing items count function getCurrentStartIndex() { const container = getContainer(); if (!container) return 0; const existingItemsCount = renderedItemCount(container); LOG('Current start index based on existing items:', existingItemsCount); return existingItemsCount; } // Update pagination display to show current range function updatePaginationDisplay() { const container = getContainer(); if (!container) return; const currentItemsCount = renderedItemCount(container); const totalCount = totalRecordCount || '?'; const showTotalOnly = getInfiniteScrollOptions().onlyShowTotalItemCount; forEachCountLabel((el) => { if (showTotalOnly) { const total = totalCount !== '?' ? totalCount : totalFromLabel(el); renderCountLabel(el, currentItemsCount, total); return; } if (el.matches('.listPaging > span')) { renderCountLabel(el, currentItemsCount, totalCount); } }); LOG('Updated pagination display:', { currentItemsCount, totalCount, showTotalOnly }); } function getContainer() { const activeTab = getLibraryViewRoot(); if (!activeTab) return null; const container = activeTab.querySelector(".itemsContainer"); if (!container) return null; return container; } /** * Infer card format from existing cards in the items container. * @param {Element} container * @returns {string|null} 'backdrop'|'square'|'banner'|'portrait'|null */ function inferCardFormatFromContainer(container) { if (!container) return null; const existing = container.querySelector('.card'); if (!existing) return null; if (existing.classList.contains('backdropCard') || existing.classList.contains('overflowBackdropCard')) { return 'backdrop'; } if (existing.classList.contains('squareCard') || existing.classList.contains('overflowSquareCard')) { return 'square'; } if (existing.classList.contains('bannerCard') || existing.classList.contains('overflowBannerCard')) { return 'banner'; } if (existing.classList.contains('portraitCard') || existing.classList.contains('overflowPortraitCard')) { return 'portrait'; } return null; } function listRowHost(container) { if (!container) return null; if (container.querySelector(':scope > .listItem')) return container; const nested = container.querySelector('.listItem'); if (nested && nested.parentElement && container.contains(nested)) return nested.parentElement; if (container.classList.contains('vertical-list')) return container; return null; } function inferCardDisplayFromContainer(container) { const card = container && container.querySelector('.card'); const cardBox = card && card.querySelector('.cardBox'); if (!card || !cardBox) return null; const showTitle = Array.from(card.querySelectorAll('.cardText-first')).some((el) => !el.closest('.innerCardFooter')); const showYear = !!card.querySelector('.cardText-secondary') || Array.from(card.querySelectorAll('.innerCardFooter .cardText')).some((el) => !el.querySelector('.itemProgressBar')); return { showTitle: showTitle, showYear: showYear, visualCardBox: cardBox.classList.contains('visualCardBox') }; } async function loadMore() { LOG('LoadMore called:', { loading, hasMore, isRequestInProgress, isScrollTriggered }); if (loading || !hasMore || isRequestInProgress) { LOG('LoadMore blocked:', { loading, hasMore, isRequestInProgress }); return; } const container = getContainer(); if (!container) { LOG('LoadMore blocked: no eligible items container'); return; } if (container.children.length === 0) { LOG('LoadMore blocked: No items in container yet (page still loading)'); return; } LOG('Starting loadMore request...'); loading = true; isRequestInProgress = true; // Show loading indicator showLoadingIndicator(); // Wait for ApiClient to be ready if (!userId) { LOG('Waiting for ApiClient before loading...'); await waitForApiClient(); } // Wait for cardBuilder to be ready await waitForCardBuilder(); const page = getCurrentPage(); const tab = getCurrentTab(); if (!page) { WARN("No supported page detected"); loading = false; isRequestInProgress = false; hideLoadingIndicator(); return; } const parentId = getParentId(); if (!parentId) { WARN("No topParentId found in URL"); loading = false; isRequestInProgress = false; hideLoadingIndicator(); return; } const mediaConfig = MEDIA_CONFIGS[page]; if (!mediaConfig) { WARN("No media configuration found for page:", page); loading = false; isRequestInProgress = false; hideLoadingIndicator(); return; } // Double-check ApiClient is available if (typeof ApiClient === 'undefined') { ERR('ApiClient is not available'); loading = false; isRequestInProgress = false; hideLoadingIndicator(); return; } // Get current start index based on existing items startIndex = getCurrentStartIndex(); // Get current sort settings from localStorage const sortSettings = getCurrentSortSettings(); let apiParams = { SortBy: mediaConfig.sortBy, // fallback SortOrder: "Ascending", // fallback Recursive: true, Fields: "PrimaryImageAspectRatio,MediaSourceCount", ImageTypeLimit: 1, EnableImageTypes: "Primary,Backdrop,Banner,Thumb", ParentId: parentId, StartIndex: startIndex, Limit: limit }; if (mediaConfig.includeItemTypes) { apiParams.IncludeItemTypes = mediaConfig.includeItemTypes; } // Apply detected sort settings if available if (sortSettings) { LOG('Using detected sort settings:', sortSettings); apiParams.SortBy = sortSettings.sortBy; apiParams.SortOrder = sortSettings.sortOrder; // Add filter parameters if they exist if (sortSettings.filters) { apiParams.Filters = sortSettings.filters; } if (sortSettings.years) { apiParams.Years = sortSettings.years; } if (sortSettings.genres) { apiParams.Genres = sortSettings.genres; } if (sortSettings.tags) { apiParams.Tags = sortSettings.tags; } } else { LOG('Using fallback sort settings'); } // Widen the selected letter across the rest of a SortName list. const alphaPicker = getCurrentAlphaPicker(); const nameStartsWith = alphaPicker.value || (!alphaPicker.sawPicker && sortSettings?.nameStartsWith) || ''; let alphaQuery = resolveAlphaQuery(nameStartsWith, apiParams.SortBy, apiParams.SortOrder, startIndex); applyAlphaQuery(apiParams, alphaQuery); LOG('Alpha query:', alphaQuery); try { let fetchAnotherRange = true; while (fetchAnotherRange) { fetchAnotherRange = false; const url = ApiClient.getUrl("Users/" + ApiClient.getCurrentUserId() + "/Items", apiParams); LOG('Loading more items:', { page, tab, startIndex: alphaQuery.startIndex, limit, url, mode: alphaQuery.mode }); const result = await ApiClient.getJSON(url); const items = result.Items || []; const queryOffset = alphaQuery.startIndex + items.length; if (items.length > 0) { const container = getContainer(); if (container) { if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.buildCard) { ERR('cardBuilder not available or buildCard method missing'); loading = false; isRequestInProgress = false; hideLoadingIndicator(); return; } LOG('Building cards:', { itemCount: items.length, context: mediaConfig.context, container: container }); const listHost = listRowHost(container); if (listHost && typeof window.cardBuilder.createListItemCard === 'function') { const indexBase = listHost.querySelectorAll(':scope > .listItem').length; LOG('Appending list rows:', { itemCount: items.length, indexBase: indexBase }); items.forEach((item, index) => { const row = window.cardBuilder.createListItemCard(item); row.setAttribute('data-index', indexBase + index); listHost.appendChild(row); }); } else { const cardFormat = inferCardFormatFromContainer(container); const cardDisplay = inferCardDisplayFromContainer(container); const indexBase = container.children.length; LOG('Inferred card format from container:', cardFormat, cardDisplay); items.forEach((item, index) => { const card = cardDisplay ? window.cardBuilder.buildCard(item, false, cardFormat, null, false, null, cardDisplay) : window.cardBuilder.buildCard(item, false, cardFormat); card.setAttribute('data-index', indexBase + index); container.appendChild(card); }); } if (typeof window.cardBuilder.registerLazyImagesIn === 'function') { window.cardBuilder.registerLazyImagesIn(container); } LOG('Cards built successfully'); } } // # is its own bucket. Once it is exhausted, continue at A in this same load. if (alphaQuery.mode === 'hash') { const hashDone = items.length === 0 || (result.TotalRecordCount != null && queryOffset >= result.TotalRecordCount); if (hashDone) { hashBucketCount = queryOffset; alphaQuery = { mode: 'after-hash', startIndex: 0, nameStartsWithOrGreater: 'A' }; applyAlphaQuery(apiParams, alphaQuery); fetchAnotherRange = true; LOG('Hash bucket exhausted, continuing from A'); continue; } } if (result.TotalRecordCount !== undefined) { if (alphaQuery.mode === 'after-hash' && hashBucketCount != null) { totalRecordCount = hashBucketCount + result.TotalRecordCount; hasMore = queryOffset < result.TotalRecordCount; } else { totalRecordCount = result.TotalRecordCount; hasMore = queryOffset < totalRecordCount; } LOG('hasMore updated based on total count:', { queryOffset, totalRecordCount, hasMore, mode: alphaQuery.mode }); } else { totalRecordCount = null; hasMore = items.length >= limit; LOG('hasMore updated using fallback logic:', { itemsLength: items.length, limit, hasMore }); } if (!items.length) { hasMore = false; LOG('No more items to load (empty result)'); } startIndex = queryOffset; updatePaginationDisplay(); LOG('Loaded items successfully:', { loaded: items.length, startIndex, totalRecordCount, hasMore, mode: alphaQuery.mode }); } retryCount = 0; } catch (err) { ERR("Infinite scroll load error", err); // Retry logic if (retryCount < RETRY_ATTEMPTS) { retryCount++; WARN(`Retrying loadMore (attempt ${retryCount}/${RETRY_ATTEMPTS})`); setTimeout(() => { loading = false; isRequestInProgress = false; loadMore(); }, RETRY_DELAY * retryCount); return; } else { ERR("Max retry attempts reached, giving up"); hasMore = false; } } finally { loading = false; isRequestInProgress = false; // Hide loading indicator hideLoadingIndicator(); } } function setupScrollWatcher() { // Remove any existing scroll listener window.removeEventListener("scroll", handleScroll); // Add throttled scroll listener window.addEventListener("scroll", handleScroll); LOG('Scroll watcher setup complete'); } function handleScroll() { // Throttle scroll events - only check every 100ms if (scrollTimeout) { clearTimeout(scrollTimeout); } scrollTimeout = setTimeout(() => { checkScrollPosition(); }, 100); } function checkScrollPosition() { // Don't trigger if already loading, no more items, or request in progress if (loading || !hasMore || isScrollTriggered || isRequestInProgress) { return; } // Don't trigger when not on a supported page const container = getContainer(); if (!container) { return; } // Don't trigger if there are no items in the container (page still loading) const existingItemsCount = container.children.length; if (existingItemsCount === 0) { LOG('Scroll check blocked: No items in container yet (page still loading)'); return; } const scrollTop = window.scrollY || document.documentElement.scrollTop; const windowHeight = window.innerHeight; const documentHeight = document.documentElement.scrollHeight; // Calculate distance from bottom - trigger when one viewport height away const distanceFromBottom = documentHeight - (scrollTop + windowHeight); const triggerThreshold = windowHeight; // One viewport height from bottom if (distanceFromBottom <= triggerThreshold) { isScrollTriggered = true; LOG('Triggering loadMore from scroll (one viewport height from bottom)'); loadMore().finally(() => { // Reset trigger flag after load completes setTimeout(() => { isScrollTriggered = false; LOG('Scroll trigger flag reset'); }, 1000); // Wait 1 second before allowing next trigger }); } } // Reset state when navigating to a new library or tab function reset() { const page = getCurrentPage(); const tab = getCurrentTab(); LOG('Resetting infinite scroll state:', { page, tab }); // startIndex will be calculated dynamically based on existing items hasMore = true; totalRecordCount = null; alphaRangeKey = ''; hashBucketCount = null; loading = false; retryCount = 0; isScrollTriggered = false; isRequestInProgress = false; // Scroll state restoration removed - not needed // Clear container if switching pages/tabs if (currentPage !== page || currentTab !== tab) { const container = getContainer(); if (container) { //container.innerHTML = ''; LOG('[REMOVED] Cleared container for new page/tab'); } hasInitialized = false; // Reset initialization flag for new page/tab isInitializing = false; // Reset initializing flag for new page/tab } currentPage = page; currentTab = tab; } // Check if we should initialize infinite scroll function shouldInitialize() { if (hasInitialized) return false; const page = getCurrentPage(); const container = getContainer(); if (!page) { return false; } if (!container) { LOG('No active tab container found, waiting...'); return false; } if (page === 'tv.html' || page === 'tv' || page === 'movies.html' || page === 'movies') { return true; } if (isModernLibraryUi() && (page === 'music' || page === 'mixed')) { return true; } return false; } // Initialize infinite scroll async function initialize() { if (hasInitialized || isInitializing) { return; } LOG('Initializing infinite scroll'); isInitializing = true; hasInitialized = true; try { // Add CSS to hide paging controls addInfiniteScrollCSS(); // Wait for ApiClient to be ready if (!userId) { LOG('Waiting for ApiClient before initialization...'); await waitForApiClient(); } // Wait for cardBuilder to be ready await waitForCardBuilder(); reset(); //loadMore(); // load first page setupScrollWatcher(); syncPagingPresentation(); } finally { isInitializing = false; } } // Scroll state loading removed - not needed function tryInitializeFromView() { if (shouldInitialize()) { initialize().catch(err => ERR('Initialization failed:', err)); return true; } return false; } function armLibraryPageInit() { if (isInfiniteScrollEnabled()) syncPagingPresentation(); if (tryInitializeFromView()) return; const utils = window.KefinTweaksUtils; if (utils?.pollUntil) { utils.pollUntil(() => (tryInitializeFromView() ? true : null), { intervalMs: 200, timeoutMs: 15000 }); return; } // Fallback brief poll let tries = 0; const timer = setInterval(() => { tries += 1; if (tryInitializeFromView() || tries >= 75) clearInterval(timer); }, 200); } function startInfiniteScrollHook() { const utils = window.KefinTweaksUtils; if (!utils?.onViewPage) { setTimeout(startInfiniteScrollHook, 500); return; } utils.onViewPage(() => { armLibraryPageInit(); }, { pages: SUPPORTED_PAGES }); armLibraryPageInit(); } startInfiniteScrollHook(); // Cleanup function function cleanup() { window.removeEventListener("scroll", handleScroll); disconnectPagingObserver(); if (scrollTimeout) { clearTimeout(scrollTimeout); } // Remove the CSS that hides paging controls const styleElement = document.getElementById('infinite-scroll-paging-hide'); if (styleElement) { styleElement.remove(); LOG('Removed CSS for paging controls'); } // Hide and remove loading indicator hideLoadingIndicator(); LOG('Infinite scroll cleanup complete'); } // Cleanup on page unload window.addEventListener('beforeunload', cleanup); LOG('Initialized successfully'); })();