mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Fix and improve Infinite Scroll functionality on the modern Display Mode (default in Jellyfin v12)
This commit is contained in:
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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user