Fix and improve Infinite Scroll functionality on the modern Display Mode (default in Jellyfin v12)

This commit is contained in:
HighImKevin committed 2026-10-24 15:27:37 -04:00
1 parent a4ca0d5e16
commit 2ad3886b3a
6 files changed
+703 -176

No files matched your search

@@ -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 `
<div class="kefin-toggle-card">
<div class="kefin-toggle-card-text">
<div class="listItemBodyText kefin-toggle-card-label">${label}</div>
<div class="listItemBodyText secondary kefin-toggle-card-desc">${description}</div>
</div>
<input type="checkbox" id="${id}" ${checked ? 'checked' : ''}>
</div>
`;
}
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 `
<div style="max-width: 800px; display: grid; gap: 1em;">
${buildToggleCard(
'infiniteScroll_hidePageControls',
config.hidePageControls === true,
'Hide Page Controls'
)}
${buildToggleCard(
'infiniteScroll_onlyShowTotalItemCount',
config.onlyShowTotalItemCount === true,
'Only Show Total Item Count'
)}
</div>
`;
}
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 = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-infinitescroll-config-btn">Save</button>
`;
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');
})();
+23 -17
View File
@@ -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);
+4
View File
@@ -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,
+1 -1
View File
@@ -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.' },
+428 -154
View File
@@ -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);
}
+56 -4
View File
@@ -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;