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;