From 2ad3886b3a7ae6de13920725ef2a7a001f48df40 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Sat, 24 Oct 2026 15:27:37 -0400 Subject: [PATCH] Fix and improve Infinite Scroll functionality on the modern Display Mode (default in Jellyfin v12) --- configuration/infiniteScroll-configuration.js | 191 ++++++ injector.js | 40 +- kefinTweaks-default-config.js | 4 + scripts/configuration.js | 2 +- scripts/infiniteScroll.js | 582 +++++++++++++----- scripts/kefinTweaks-loader.js | 60 +- 6 files changed, 703 insertions(+), 176 deletions(-) create mode 100644 configuration/infiniteScroll-configuration.js diff --git a/configuration/infiniteScroll-configuration.js b/configuration/infiniteScroll-configuration.js new file mode 100644 index 0000000..8bf9cb9 --- /dev/null +++ b/configuration/infiniteScroll-configuration.js @@ -0,0 +1,191 @@ +// KefinTweaks Infinite Scroll Configuration UI + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks InfiniteScroll Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks InfiniteScroll Config]', ...args); + + const MODAL_ID = 'kefin-infinitescroll-config'; + + function defaultOptions() { + return { + hidePageControls: false, + onlyShowTotalItemCount: false + }; + } + + async function loadConfig() { + const config = window.KefinTweaksConfig?.infiniteScroll; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return defaultOptions(); + } + return { + hidePageControls: config.hidePageControls === true, + onlyShowTotalItemCount: config.onlyShowTotalItemCount === true + }; + } + + async function saveConfig(infiniteScrollConfig) { + try { + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + window.KefinTweaksConfig.infiniteScroll = infiniteScrollConfig; + + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } + + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + const TOGGLE_DESCRIPTIONS = { + infiniteScroll_hidePageControls: { + on: 'Left and Right page buttons will be hidden on Library pages with infinite scroll support', + off: 'Left and Right page buttons are unchanged on Library pages.' + }, + infiniteScroll_onlyShowTotalItemCount: { + on: 'The "1 of 100" text will be hidden in favor of just showing the total item count of the library', + off: 'The paginated item count is unchanged.' + } + }; + + function toggleDescription(id, checked) { + const entry = TOGGLE_DESCRIPTIONS[id]; + if (!entry) return ''; + return checked ? entry.on : entry.off; + } + + function buildToggleCard(id, checked, label) { + const description = toggleDescription(id, checked); + if (typeof window.KefinTweaksUI?.buildToggleCard === 'function') { + return window.KefinTweaksUI.buildToggleCard(id, checked, label, description); + } + return ` +
+
+
${label}
+
${description}
+
+ +
+ `; + } + + function syncToggleDescriptions(root) { + if (!root) return; + Object.keys(TOGGLE_DESCRIPTIONS).forEach((id) => { + const checkbox = root.querySelector('#' + id); + const desc = checkbox?.closest('.kefin-toggle-card')?.querySelector('.kefin-toggle-card-desc'); + if (!checkbox || !desc) return; + desc.textContent = toggleDescription(id, checkbox.checked); + }); + } + + function buildConfigHTML(config) { + return ` +
+ ${buildToggleCard( + 'infiniteScroll_hidePageControls', + config.hidePageControls === true, + 'Hide Page Controls' + )} + ${buildToggleCard( + 'infiniteScroll_onlyShowTotalItemCount', + config.onlyShowTotalItemCount === true, + 'Only Show Total Item Count' + )} +
+ `; + } + + function collectConfig() { + return { + hidePageControls: document.getElementById('infiniteScroll_hidePageControls')?.checked === true, + onlyShowTotalItemCount: document.getElementById('infiniteScroll_onlyShowTotalItemCount')?.checked === true + }; + } + + async function openConfigModal() { + try { + const config = await loadConfig(); + + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: MODAL_ID, + title: 'Infinite Scroll Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + dialogStyle: window.innerWidth >= 900 + ? { maxWidth: '90vw', width: '500px', height: 'auto' } + : undefined, + onOpen: (modal) => { + const root = modal.dialogContent || content; + window.KefinTweaksUI?.bindToggleCards?.(root); + syncToggleDescriptions(root); + root.addEventListener('change', (e) => { + if (e.target?.id && TOGGLE_DESCRIPTIONS[e.target.id]) { + syncToggleDescriptions(root); + } + }); + const saveBtn = modal.dialogFooter.querySelector('#save-infinitescroll-config-btn'); + if (!saveBtn) return; + saveBtn.addEventListener('click', async () => { + const infiniteScrollConfig = collectConfig(); + const success = await saveConfig(infiniteScrollConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Infinite Scroll configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + }); + + LOG('Infinite Scroll configuration modal opened'); + } catch (error) { + ERR('Error opening infinite scroll config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.infiniteScroll = { openConfigModal }; + + console.log('[KefinTweaks InfiniteScroll Config] Script loaded'); +})(); diff --git a/injector.js b/injector.js index ac9fc47..8341529 100644 --- a/injector.js +++ b/injector.js @@ -627,11 +627,18 @@ dependencies: ['thumbnailScrubber-configuration'], description: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card for 2+ seconds' }, + { + name: 'infiniteScroll-configuration', + script: '../configuration/infiniteScroll-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Infinite scroll configuration for KefinTweaks' + }, { name: 'infiniteScroll', script: 'infiniteScroll.js', css: null, - dependencies: ['cardBuilder'], + dependencies: ['cardBuilder', 'infiniteScroll-configuration'], description: 'Adds infinite scroll functionality to media library pages' }, { @@ -1198,7 +1205,7 @@ } async function loadConfigurationJS() { - const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'watchlist-configuration', 'userManager-configuration', 'apiHelper', 'sectionHelper']; + const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'watchlist-configuration', 'userManager-configuration', 'infiniteScroll-configuration', 'apiHelper', 'sectionHelper']; for (const depName of configDependencyNames) { const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName); if (depScript) { @@ -1255,23 +1262,22 @@ }); if (bakeAlreadyAttached) { - // Belt-and-suspenders: never dual-inject when bake already ran. - // Tip re-pin / bake rewrite is owned by kefinTweaks-plugin tip-sync path. - console.log('[KefinTweaks Injector] Bake already attached; skipping applyAssetsToDocument (stamp=', plan.stamp, ')'); - return; + console.log('[KefinTweaks Injector] Bake already attached; gap-filling missing assets (stamp=', plan.stamp, ')'); + } else { + console.log('[KefinTweaks Injector] Applying ordered assets:', plan.assets.length, 'stamp=', plan.stamp); } - - console.log('[KefinTweaks Injector] Applying ordered assets:', plan.assets.length, 'stamp=', plan.stamp); Loader.applyAssetsToDocument(plan.assets); - document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', { - detail: { - loadedScripts: plan.scriptNames, - stamp: plan.stamp, - timestamp: new Date().toISOString(), - live: true - } - })); - console.log('[KefinTweaks Injector] Live ordered inject complete'); + if (!bakeAlreadyAttached) { + document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', { + detail: { + loadedScripts: plan.scriptNames, + stamp: plan.stamp, + timestamp: new Date().toISOString(), + live: true + } + })); + console.log('[KefinTweaks Injector] Live ordered inject complete'); + } if (typeof Loader.syncKefinTweaksInjector === 'function') { const syncResult = await Loader.syncKefinTweaksInjector(plan); diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js index 6b36803..806d222 100644 --- a/kefinTweaks-default-config.js +++ b/kefinTweaks-default-config.js @@ -41,6 +41,10 @@ window.KefinTweaksDefaultConfig = { "editions": ["Theatrical", "Director's Cut", "Extended Edition", "Fan Edit", "__none__"], "showVersionBeforeEdition": false }, + "infiniteScroll": { + "hidePageControls": false, + "onlyShowTotalItemCount": false + }, "thumbnailScrubber": { "PreviewOnHover": false, "UseVideoStream": true, diff --git a/scripts/configuration.js b/scripts/configuration.js index 3327380..266de45 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -1222,7 +1222,7 @@ // Build script toggle switches function buildScriptToggles(scripts) { // Features that have configuration options (not just toggles) - const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', 'watchlist', 'userManager']); + const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', 'watchlist', 'userManager', 'infiniteScroll']); const scriptNames = [ { key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' }, diff --git a/scripts/infiniteScroll.js b/scripts/infiniteScroll.js index c6c22e5..64d5551 100644 --- a/scripts/infiniteScroll.js +++ b/scripts/infiniteScroll.js @@ -14,7 +14,7 @@ LOG('Initializing...'); // Supported pages (same as headerTabs) - const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html', 'tv', 'movies', 'music']; + 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 = { @@ -47,8 +47,20 @@ 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; @@ -66,6 +78,10 @@ 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() { @@ -121,27 +137,70 @@ } } + 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_LIBRARY_PAGE_SELECTOR = '#moviesPage, #tvshowsPage, #musicPage, #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 }; + } + return { + hidePageControls: options.hidePageControls === true, + onlyShowTotalItemCount: options.onlyShowTotalItemCount === true + }; + } + + function pageControlHideCss() { + if (!getInfiniteScrollOptions().hidePageControls) return ''; + const modernButtons = MODERN_LIBRARY_PAGE_SELECTOR + .split(',') + .map((page) => `${page.trim()} button[title="Previous"], ${page.trim()} button[title="Next"]`) + .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() { - // Check if infinite scroll is enabled in configuration - const isEnabled = window.KefinTweaksConfig?.scripts?.infiniteScroll !== false; - if (!isEnabled) { + if (!isInfiniteScrollEnabled()) { LOG('Infinite scroll disabled in configuration, skipping CSS addition'); return; } - + const styleId = 'infinite-scroll-paging-hide'; - - // Check if style already exists - if (document.getElementById(styleId)) { - return; + let style = document.getElementById(styleId); + if (!style) { + style = document.createElement('style'); + style.id = styleId; + document.head.appendChild(style); } - - const style = document.createElement('style'); - style.id = styleId; + style.textContent = ` - - + ${pageControlHideCss()} + /* Infinite scroll loading indicator */ .infinite-scroll-loading { display: flex; @@ -178,10 +237,100 @@ } `; - document.head.appendChild(style); 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); + 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()) 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)'); + if (!root) return; + applyTotalCountLabels(); + pagingObserver = new MutationObserver(() => { + scheduleApplyTotalCountLabels(); + }); + pagingObserver.observe(root, { subtree: true, childList: true, characterData: true }); + } + // Wait for ApiClient to be available function waitForApiClient() { return new Promise((resolve) => { @@ -265,7 +414,9 @@ // Map page to media type const mediaTypeMap = { 'movies.html': 'movies', - 'tv.html': 'series' + 'movies': 'movies', + 'tv.html': 'series', + 'tv': 'series' }; const mediaType = mediaTypeMap[page]; @@ -295,7 +446,7 @@ years: filterData.Years || '', genres: filterData.Genres || '', tags: filterData.Tags || '', - // Add other filter properties as needed + nameStartsWith: sortData.NameStartsWith || '' }; } catch (error) { ERR('Error parsing sort settings from localStorage:', error); @@ -303,30 +454,138 @@ } } - // Get current alpha picker selection - function getCurrentAlphaPicker() { + function isModernLibraryUi() { + return window.KefinTweaksUtils?.isModernUI?.() === true; + } + + function getLibraryViewRoot() { const page = getCurrentPage(); if (!page) return null; - - let activeTab = null; - if (page === 'tv.html') { - activeTab = document.querySelector("#seriesTab.is-active"); - } else if (page === 'movies.html') { - activeTab = document.querySelector("#moviesTab.is-active"); + + if (isModernLibraryUi()) { + if (getCurrentTab() !== 0) return null; + const pageId = MODERN_LIBRARY_PAGE_IDS[page]; + if (!pageId) return null; + return document.getElementById(pageId); } - - if (!activeTab) return null; - - const alphaPickerRow = activeTab.querySelector(".alphaPickerRow"); - if (!alphaPickerRow) return null; - - const selectedButton = alphaPickerRow.querySelector(".alphaPickerButton-selected"); + + 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'); - LOG('Found alpha picker selection:', dataValue); - - return dataValue; + 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)) { + 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; } // Get current start index based on existing items count @@ -341,50 +600,34 @@ } // Update pagination display to show current range - function updatePaginationDisplay() { - const listPagings = document.querySelectorAll(".page:not(.hide) .pageTabContent.is-active .listPaging"); - if (!listPagings.length) return; - - listPagings.forEach(listPaging => { - const span = listPaging.querySelector("span"); - if (!span) return; - - const container = getContainer(); - if (!container) return; - - const currentItemsCount = container.children.length; - const totalCount = totalRecordCount || '?'; - - // Update the display to show current range - span.textContent = `1-${currentItemsCount} of ${totalCount}`; - LOG('Updated pagination display:', { currentItemsCount, totalCount }); + function updatePaginationDisplay() { + const container = getContainer(); + if (!container) return; + + const currentItemsCount = container.children.length; + 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 libraryPage = document.querySelector(".libraryPage:not(.hide)"); - if (!libraryPage) return null; - - // Check if we're on the correct active tab - const page = getCurrentPage(); - if (!page) return null; - - let activeTab = null; - if (page === 'tv.html' || page === 'tv') { - activeTab = libraryPage.querySelector("#seriesTab.is-active") || document.querySelector("#tvshowsPage:not(.hide)"); - } else if (page === 'movies.html' || page === 'movies') { - activeTab = libraryPage.querySelector("#moviesTab.is-active") || document.querySelector("#moviesPage:not(.hide)"); - } - - if (!activeTab) { - return null; - } - + const activeTab = getLibraryViewRoot(); + if (!activeTab) return null; + const container = activeTab.querySelector(".itemsContainer"); - if (!container) { - return null; - } - + if (!container) return null; + return container; } @@ -421,9 +664,12 @@ return; } - // Don't load more if there are no items in the container (page still loading) const container = getContainer(); - if (container && container.children.length === 0) { + 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; } @@ -490,7 +736,6 @@ let apiParams = { SortBy: mediaConfig.sortBy, // fallback SortOrder: "Ascending", // fallback - IncludeItemTypes: mediaConfig.includeItemTypes, Recursive: true, Fields: "PrimaryImageAspectRatio,MediaSourceCount", ImageTypeLimit: 1, @@ -499,6 +744,9 @@ StartIndex: startIndex, Limit: limit }; + if (mediaConfig.includeItemTypes) { + apiParams.IncludeItemTypes = mediaConfig.includeItemTypes; + } // Apply detected sort settings if available if (sortSettings) { @@ -523,95 +771,113 @@ LOG('Using fallback sort settings'); } - // Add alpha picker support + // Widen the selected letter across the rest of a SortName list. const alphaPicker = getCurrentAlphaPicker(); - if (alphaPicker) { - apiParams.NameStartsWith = alphaPicker; - LOG('Added alpha picker filter:', alphaPicker); - } - - const url = ApiClient.getUrl("Users/" + ApiClient.getCurrentUserId() + "/Items", apiParams); - - LOG('Loading more items:', { page, tab, startIndex, limit, url }); + 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 { - const result = await ApiClient.getJSON(url); + 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 }); - // Update total record count from API response - if (result.TotalRecordCount !== undefined) { - totalRecordCount = result.TotalRecordCount; - LOG('Updated total record count from API:', totalRecordCount); - } + const result = await ApiClient.getJSON(url); + const items = result.Items || []; + const queryOffset = alphaQuery.startIndex + items.length; - if (result.Items && result.Items.length > 0) { - const container = getContainer(); - if (container) { - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.buildCard) { - ERR('cardBuilder not available or buildCard method missing'); - loading = false; - isRequestInProgress = false; - hideLoadingIndicator(); - return; + 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 cardFormat = inferCardFormatFromContainer(container); + const indexBase = container.children.length; + LOG('Inferred card format from container:', cardFormat); + items.forEach((item, index) => { + const card = 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'); } - - LOG('Building cards:', { - itemCount: result.Items.length, - context: mediaConfig.context, - container: container - }); - - // Build each card individually and append to container - const cardFormat = inferCardFormatFromContainer(container); - LOG('Inferred card format from container:', cardFormat); - result.Items.forEach((item, index) => { - const card = window.cardBuilder.buildCard(item, false, cardFormat); - card.setAttribute('data-index', startIndex + index); - container.appendChild(card); - }); - if (typeof window.cardBuilder.registerLazyImagesIn === 'function') { - window.cardBuilder.registerLazyImagesIn(container); - } - - LOG('Cards built successfully'); } - startIndex += result.Items.length; - - // Update pagination display - updatePaginationDisplay(); - - // Update hasMore based on total record count - if (totalRecordCount !== null) { - hasMore = startIndex < totalRecordCount; - LOG('hasMore updated based on total count:', { - startIndex, - totalRecordCount, - hasMore + + // # 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 { - // Fallback to old logic if no total count available - hasMore = result.Items.length >= limit; - LOG('hasMore updated using fallback logic:', { - itemsLength: result.Items.length, - limit, - hasMore + totalRecordCount = null; + hasMore = items.length >= limit; + LOG('hasMore updated using fallback logic:', { + itemsLength: items.length, + limit, + hasMore }); } - - // Scroll state persistence removed - not needed - - LOG('Loaded items successfully:', { - loaded: result.Items.length, - startIndex, + + 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 + hasMore, + mode: alphaQuery.mode }); - } else { - hasMore = false; - LOG('No more items to load (empty result)'); } - - // Reset retry count on success + retryCount = 0; } catch (err) { @@ -710,6 +976,8 @@ // startIndex will be calculated dynamically based on existing items hasMore = true; totalRecordCount = null; + alphaRangeKey = ''; + hashBucketCount = null; loading = false; retryCount = 0; isScrollTriggered = false; @@ -751,6 +1019,9 @@ if (page === 'tv.html' || page === 'tv' || page === 'movies.html' || page === 'movies') { return true; } + if (isModernLibraryUi() && (page === 'music' || page === 'mixed')) { + return true; + } return false; } @@ -781,6 +1052,7 @@ reset(); //loadMore(); // load first page setupScrollWatcher(); + syncPagingPresentation(); } finally { isInitializing = false; } @@ -797,6 +1069,7 @@ } function armLibraryPageInit() { + if (isInfiniteScrollEnabled()) syncPagingPresentation(); if (tryInitializeFromView()) return; const utils = window.KefinTweaksUtils; if (utils?.pollUntil) { @@ -831,6 +1104,7 @@ // Cleanup function function cleanup() { window.removeEventListener("scroll", handleScroll); + disconnectPagingObserver(); if (scrollTimeout) { clearTimeout(scrollTimeout); } diff --git a/scripts/kefinTweaks-loader.js b/scripts/kefinTweaks-loader.js index dfd8879..eb54bd1 100644 --- a/scripts/kefinTweaks-loader.js +++ b/scripts/kefinTweaks-loader.js @@ -466,11 +466,18 @@ dependencies: ['thumbnailScrubber-configuration'], description: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card for 2+ seconds' }, + { + name: 'infiniteScroll-configuration', + script: '../configuration/infiniteScroll-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Infinite scroll configuration for KefinTweaks' + }, { name: 'infiniteScroll', script: 'infiniteScroll.js', css: null, - dependencies: ['cardBuilder'], + dependencies: ['cardBuilder', 'infiniteScroll-configuration'], description: 'Adds infinite scroll functionality to media library pages' }, { @@ -544,7 +551,8 @@ 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', - 'watchTogether-configuration', 'watchlist-configuration', 'userManager-configuration', 'apiHelper', + 'watchTogether-configuration', 'watchlist-configuration', 'userManager-configuration', + 'infiniteScroll-configuration', 'apiHelper', 'sectionHelper' ]; @@ -920,18 +928,62 @@ return window.KefinTweaks; } + function stripUrlQuery(value) { + return String(value || '').split('#')[0].split('?')[0]; + } + + /** + * Identity for a Kefin asset across roots. scripts/foo.js and + * configuration/foo.js match whether the tag came from experimental or dev. + * Bare names live under scripts/. Absolute third-party URLs are not rewritten. + */ + function kefinAssetPathKey(value) { + const raw = stripUrlQuery(value).replace(/\\/g, '/'); + if (!raw || /^https?:\/\//i.test(raw)) { + if (!raw) return ''; + let path = raw; + try { path = new URL(raw).pathname; } catch (e) { return ''; } + return kefinAssetPathKey(path); + } + const configAt = raw.lastIndexOf('/configuration/'); + const scriptsAt = raw.lastIndexOf('/scripts/'); + if (configAt >= 0 && configAt >= scriptsAt) return raw.slice(configAt + 1); + if (scriptsAt >= 0) return raw.slice(scriptsAt + 1); + const configRel = raw.lastIndexOf('configuration/'); + if (configRel >= 0) return raw.slice(configRel); + const base = raw.split('/').filter(Boolean).pop(); + if (!base || base.indexOf('.') < 0) return ''; + return 'scripts/' + base; + } + + function assetAlreadyOnPage(asset) { + if (!asset || !asset.url) return false; + const attr = asset.kind === 'css' ? 'href' : 'src'; + const selector = asset.kind === 'css' ? 'link[href]' : 'script[src]'; + const exact = stripUrlQuery(asset.url); + const hasRelativePath = asset.path && !/^https?:\/\//i.test(asset.path); + const wantedKey = hasRelativePath ? kefinAssetPathKey(asset.path) : ''; + const nodes = document.querySelectorAll(selector); + for (let i = 0; i < nodes.length; i++) { + const current = nodes[i].getAttribute(attr); + if (!current) continue; + if (current === asset.url || stripUrlQuery(current) === exact) return true; + if (wantedKey && kefinAssetPathKey(current) === wantedKey) return true; + } + return false; + } + function applyAssetsToDocument(assets) { (assets || []).forEach((a) => { if (!a || !a.url) return; + if (assetAlreadyOnPage(a)) return; if (a.kind === 'css') { - if (document.querySelector('link[href="' + a.url + '"]')) return; const l = document.createElement('link'); l.rel = 'stylesheet'; l.type = 'text/css'; l.href = a.url; document.head.appendChild(l); } else { - if (document.querySelector('script[src="' + a.url + '"]')) return; const s = document.createElement('script'); s.src = a.url; s.async = false;