More improvements made to the Enhanced Home Screen: Improved Spotlight rendering and options, Rendering only in native Jellyfin container, batch rendering progressive sections

This commit is contained in:
HighImKevin
2026-02-12 08:01:54 -05:00
parent ce489784f4
commit 98b6e35819
12 changed files with 1530 additions and 386 deletions
+350 -88
View File
@@ -67,7 +67,7 @@
};
const CARD_FORMATS = ['Poster', 'Thumb', 'Backdrop', 'Square', 'Random'];
const SORT_ORDERS = ['Random', 'Name', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber'];
const SORT_ORDERS = ['None', 'Random', 'Name', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber', 'IsFolder', 'SimilarityScore', 'SearchScore', 'DateLastContentAdded', 'SeriesDatePlayed', 'ChildCount'];
const SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending'];
const RENDER_MODE_OPTIONS = [{ value: 'Normal', label: 'Normal' }, { value: 'Spotlight', label: 'Spotlight' }, { value: 'Random', label: 'Random' }];
@@ -75,6 +75,7 @@
let currentConfig = null;
let mainModalInstance = null;
let currentActiveTab = 'settings'; // Track the currently active tab
let currentGlobalSettingsSubTab = 'general'; // Track active sub-tab in Global Settings (general, spotlight, discovery, cache)
/**
* Show toast notification
@@ -459,7 +460,7 @@
/**
* Load configuration from JS Injector and merge with defaults
*/
async function loadConfig() {
function loadConfig() {
try {
// Get existing config from window.KefinTweaksConfig
const existingConfig = (window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig) || {};
@@ -495,11 +496,40 @@
DISCOVERY_SETTINGS: { ...defaults.DISCOVERY_SETTINGS, ...(existingConfig.DISCOVERY_SETTINGS || {}) },
SEASONAL_THEME_SETTINGS: { ...defaults.SEASONAL_THEME_SETTINGS, ...(existingConfig.SEASONAL_THEME_SETTINGS || {}) },
CACHE: { ...defaults.CACHE, ...(existingConfig.CACHE || {}) },
SPOTLIGHT_SETTINGS: { ...defaults.SPOTLIGHT_SETTINGS, ...(existingConfig.SPOTLIGHT_SETTINGS || {}) },
HOME_SETTINGS: { ...defaults.HOME_SETTINGS, ...(existingConfig.HOME_SETTINGS || {}) },
MERGE_NEXT_UP: existingConfig.MERGE_NEXT_UP ?? defaults.MERGE_NEXT_UP ?? false
};
// Add ENABLED_NORMAL_SECTIONS and ENABLED_DISCOVERY_SECTIONS
// ENABLED_NORMAL_SECTIONS is all enabled sections from HOME_SECTION_GROUPS, SEASONAL_SECTION_GROUPS, and CUSTOM_SECTION_GROUPS that aren't discovery sections
// If they have a start/end date the current date must fall within that date range
// ENABLED_DISCOVERY_SECTIONS is all enabled sections from DISCOVERY_SECTION_GROUPS and CUSTOM_SECTION_GROUPS that are discovery sections
// If they have a start/end date the current date must fall within that date range
const isInSeasonalPeriod = (startDate, endDate) => {
const currentDate = new Date();
const start = new Date(startDate);
const end = new Date(endDate);
// Ensure the years are the same as the current date year
start.setFullYear(currentDate.getFullYear());
end.setFullYear(currentDate.getFullYear());
return currentDate >= start && currentDate <= end;
};
const enabledHomeSections = flattenSectionGroups(mergedConfig.HOME_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true));
const enabledSeasonalSections = flattenSectionGroups(mergedConfig.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && isInSeasonalPeriod(s.startDate, s.endDate));
const enabledCustomSections = flattenSectionGroups(mergedConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true));
const enabledDiscoverySections = flattenSectionGroups(mergedConfig.DISCOVERY_SECTION_GROUPS).filter(s => s.enabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true));
const enabledCustomDiscoverySections = flattenSectionGroups(mergedConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true));
mergedConfig.ENABLED_NORMAL_SECTIONS = [...enabledHomeSections, ...enabledSeasonalSections, ...enabledCustomSections];
mergedConfig.ENABLED_DISCOVERY_SECTIONS = [...enabledDiscoverySections, ...enabledCustomDiscoverySections];
// Verify and sync recently-added library sections
await verifyRecentlyAddedInLibraryConfig(mergedConfig);
//await verifyRecentlyAddedInLibraryConfig(mergedConfig);
currentConfig = mergedConfig;
return mergedConfig;
@@ -513,8 +543,12 @@
* Get merged home screen config (exported for use by other scripts)
* @returns {Promise<Object>} Merged configuration object
*/
async function getConfig() {
return await loadConfig();
function getConfig(forceReload = false) {
if (currentConfig && !forceReload) {
return currentConfig;
}
let config = loadConfig();
return config;
}
/**
@@ -1008,6 +1042,14 @@
...(savedHomeScreenConfig.CACHE || {}),
...(config.CACHE || {})
},
SPOTLIGHT_SETTINGS: {
...(savedHomeScreenConfig.SPOTLIGHT_SETTINGS || {}),
...(config.SPOTLIGHT_SETTINGS || {})
},
HOME_SETTINGS: {
...(savedHomeScreenConfig.HOME_SETTINGS || {}),
...(config.HOME_SETTINGS || {})
},
MERGE_NEXT_UP: config.MERGE_NEXT_UP !== undefined ? config.MERGE_NEXT_UP : (savedHomeScreenConfig.MERGE_NEXT_UP !== undefined ? savedHomeScreenConfig.MERGE_NEXT_UP : false)
};
@@ -1459,6 +1501,43 @@
`;
}
/**
* Build global settings sub-navigation HTML (left column, same style as Edit Sections nav)
*/
function buildGlobalSettingsNavigationHTML(activeSubTab = 'general') {
const tabs = [
{ id: 'general', label: 'General Settings' },
{ id: 'spotlight', label: 'Spotlight Settings' },
{ id: 'discovery', label: 'Discovery Settings' },
{ id: 'cache', label: 'Cache Settings' }
];
return `
<div style="display: flex; flex-direction: column; gap: 0.5em;">
${tabs.map(tab => `
<button type="button"
class="global-settings-nav-btn ${activeSubTab === tab.id ? 'active' : ''}"
data-global-settings-tab="${tab.id}"
style="padding: 0.75em 1em; text-align: left; background: ${activeSubTab === tab.id ? 'rgba(0, 164, 220, 0.2)' : 'rgba(255,255,255,0.05)'}; border: 1px solid ${activeSubTab === tab.id ? 'rgba(0, 164, 220, 0.5)' : 'rgba(255,255,255,0.1)'}; border-radius: 4px; color: ${activeSubTab === tab.id ? 'var(--theme-primary-color, #00a4dc)' : 'inherit'}; cursor: pointer; transition: all 0.2s;">
<span class="listItemBodyText" style="font-weight: ${activeSubTab === tab.id ? '500' : '400'};">${tab.label}</span>
</button>
`).join('')}
</div>
`;
}
/**
* Build global settings panel content for the given sub-tab
*/
function buildGlobalSettingsPanelContent(subTab) {
switch (subTab) {
case 'general': return buildGeneralSettingsHTML();
case 'spotlight': return buildSpotlightSettingsHTML();
case 'discovery': return buildDiscoverySettingsHTML();
case 'cache': return buildCacheSettingsHTML();
default: return buildGeneralSettingsHTML();
}
}
/**
* Build group HTML with sections and toggle-all button
*/
@@ -1593,9 +1672,6 @@
// Always use currentConfig as source of truth
if (!currentConfig) return '<div class="listItemBodyText secondary">No configuration loaded.</div>';
// Determine if settings should be expanded (expanded by default unless mobile)
const settingsExpanded = !isMobile();
// Use currentActiveTab to determine which tab/content should be displayed
const activeTab = currentActiveTab || 'settings';
@@ -1620,32 +1696,17 @@
</button>
</div>
<!-- Global Settings Tab -->
<!-- Global Settings Tab (left nav + right content, same layout as Edit Sections) -->
<div id="tab-settings" class="config-tab-content" style="display: ${activeTab === 'settings' ? 'block' : 'none'};">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1em;">
<!-- General Settings -->
<details ${settingsExpanded ? 'open' : ''} style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; margin-bottom: 0.5em;">General Settings</summary>
<div style="padding: 0.75em 0 0 0;">
${buildGeneralSettingsHTML()}
</div>
</details>
<!-- Discovery Settings -->
<details ${settingsExpanded ? 'open' : ''} style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; margin-bottom: 0.5em;">Discovery Settings</summary>
<div style="padding: 0.75em 0 0 0;">
${buildDiscoverySettingsHTML()}
</div>
</details>
<!-- Cache Settings -->
<details ${settingsExpanded ? 'open' : ''} style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; margin-bottom: 0.5em;">Cache Settings</summary>
<div style="padding: 0.75em 0 0 0;">
${buildCacheSettingsHTML()}
</div>
</details>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 1.5em;">
<!-- Left Column: Sub-tab navigation -->
<div id="global-settings-navigation">
${buildGlobalSettingsNavigationHTML(currentGlobalSettingsSubTab)}
</div>
<!-- Right Column: Content for selected sub-tab -->
<div id="global-settings-content">
${buildGlobalSettingsPanelContent(currentGlobalSettingsSubTab)}
</div>
</div>
</div>
@@ -1739,17 +1800,14 @@
function buildGeneralSettingsHTML() {
if (!currentConfig) return '';
const seasonal = currentConfig.SEASONAL_THEME_SETTINGS || {};
const homeSettings = currentConfig.HOME_SETTINGS || {};
return `
<div style="display: grid; gap: 1em;">
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('merge-next-up', currentConfig.MERGE_NEXT_UP === true, 'Merge Next Up with Continue Watching', { includeHiddenCheckbox: true })}
</div>
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('seasonal-enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Enable Seasonal Animations', { includeHiddenCheckbox: true })}
</div>
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('seasonal-enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Enable Seasonal Backgrounds', { includeHiddenCheckbox: true })}
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75em 1.5em;">
${buildToggleSlider('merge-next-up', currentConfig.MERGE_NEXT_UP === true, 'Merge Next Up with Continue Watching', { includeHiddenCheckbox: true })}
${buildToggleSlider('seasonal-enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Enable Seasonal Animations', { includeHiddenCheckbox: true })}
${buildToggleSlider('seasonal-enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Enable Seasonal Backgrounds', { includeHiddenCheckbox: true })}
${buildToggleSlider('home-ensureThumbsForPopularTVNetworks', homeSettings.ensureThumbsForPopularTVNetworks === true, 'Ensure Thumbs for Popular TV Networks', { includeHiddenCheckbox: true })}
${buildToggleSlider('home-fadeInSections', homeSettings.fadeInSections === true, 'Fade in sections sequentially', { includeHiddenCheckbox: true })}
</div>
`;
}
@@ -1762,19 +1820,18 @@
const discovery = currentConfig.DISCOVERY_SETTINGS || {};
return `
<div style="display: grid; gap: 1em;">
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('discovery-enabled', discovery.enabled !== false, 'Enabled', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Group Top Rated and Normal Sections', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order', { includeHiddenCheckbox: true })}
${buildTextInput('discovery-minPeopleAppearances', discovery.minPeopleAppearances || 10, 'Min People Appearances', 'number')}
${buildTextInput('discovery-minGenreMovieCount', discovery.minGenreMovieCount || 50, 'Min Genre Movie Count', 'number')}
${buildTextInput('discovery-defaultItemLimit', discovery.defaultItemLimit || 16, 'Default Item Limit', 'number')}
${buildSelect('discovery-defaultSortOrder', SORT_ORDERS, discovery.defaultSortOrder || 'Random', 'Default Sort Order')}
${buildSelect('discovery-defaultCardFormat', CARD_FORMATS, discovery.defaultCardFormat || 'Poster', 'Default Card Format')}
${buildTextInput('discovery-spotlightDiscoveryChance', discovery.spotlightDiscoveryChance || 0.5, 'Spotlight Discovery Chance (0-1)', 'number')}
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75em 1.5em;">
${buildToggleSlider('discovery-enabled', discovery.enabled !== false, 'Enabled', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Group Top Rated and Normal Sections', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-fadeInSections', discovery.fadeInSections === true, 'Fade in sections sequentially', { includeHiddenCheckbox: true })}
${buildTextInput('discovery-minPeopleAppearances', discovery.minPeopleAppearances || 10, 'Min People Appearances', 'number')}
${buildTextInput('discovery-minGenreMovieCount', discovery.minGenreMovieCount || 50, 'Min Genre Movie Count', 'number')}
${buildTextInput('discovery-defaultItemLimit', discovery.defaultItemLimit || 16, 'Default Item Limit', 'number')}
${buildSelect('discovery-defaultSortOrder', SORT_ORDERS, discovery.defaultSortOrder || 'Random', 'Default Sort Order')}
${buildSelect('discovery-defaultCardFormat', CARD_FORMATS, discovery.defaultCardFormat || 'Poster', 'Default Card Format')}
${buildTextInput('discovery-spotlightDiscoveryChance', discovery.spotlightDiscoveryChance || 0.5, 'Spotlight Discovery Chance (0-1)', 'number')}
</div>
`;
}
@@ -1808,20 +1865,71 @@
const cache = currentConfig.CACHE || {};
return `
<div style="display: grid; gap: 1em;">
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.75em;">TTL values in minutes</div>
${buildTextInput('cache-DEFAULT_TTL', msToMinutes(cache.DEFAULT_TTL || 1800000), 'Default TTL (minutes)', 'number')}
${buildTextInput('cache-VERY_SHORT_TTL', msToMinutes(cache.VERY_SHORT_TTL || 60000), 'Very Short TTL (minutes)', 'number')}
${buildTextInput('cache-SHORT_TTL', msToMinutes(cache.SHORT_TTL || 300000), 'Short TTL (minutes)', 'number')}
${buildTextInput('cache-LONG_TTL', msToMinutes(cache.LONG_TTL || 86400000), 'Long TTL (minutes)', 'number')}
${buildTextInput('cache-STATIC_TTL', msToMinutes(cache.STATIC_TTL || 604800000), 'Static TTL (minutes)', 'number')}
${buildTextInput('cache-DISCOVERY_TTL', msToMinutes(cache.DISCOVERY_TTL || 3600000), 'Discovery TTL (minutes)', 'number')}
</div>
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.5em;">TTL values in minutes</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75em 1.5em;">
${buildTextInput('cache-DEFAULT_TTL', msToMinutes(cache.DEFAULT_TTL || 1800000), 'Default TTL (minutes)', 'number')}
${buildTextInput('cache-VERY_SHORT_TTL', msToMinutes(cache.VERY_SHORT_TTL || 60000), 'Very Short TTL (minutes)', 'number')}
${buildTextInput('cache-SHORT_TTL', msToMinutes(cache.SHORT_TTL || 300000), 'Short TTL (minutes)', 'number')}
${buildTextInput('cache-LONG_TTL', msToMinutes(cache.LONG_TTL || 86400000), 'Long TTL (minutes)', 'number')}
${buildTextInput('cache-STATIC_TTL', msToMinutes(cache.STATIC_TTL || 604800000), 'Static TTL (minutes)', 'number')}
${buildTextInput('cache-DISCOVERY_TTL', msToMinutes(cache.DISCOVERY_TTL || 3600000), 'Discovery TTL (minutes)', 'number')}
</div>
`;
}
/**
* Build spotlight settings HTML (default behavior for spotlight sections)
*/
function buildSpotlightSettingsHTML() {
if (!currentConfig) return '';
const spotlight = currentConfig.SPOTLIGHT_SETTINGS || {};
const defaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {};
return `
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.5em;">Default behavior for spotlight sections on the home screen. Per-section overrides can be set when editing a section.</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75em 1.5em;">
${buildToggleSlider('spotlight-autoPlay', spotlight.autoPlay !== false, 'Auto-play', { includeHiddenCheckbox: true })}
${buildToggleSlider('spotlight-showDots', spotlight.showDots !== false, 'Show dot indicators', { includeHiddenCheckbox: true })}
${buildToggleSlider('spotlight-showNavButtons', spotlight.showNavButtons !== false, 'Show prev/next buttons', { includeHiddenCheckbox: true })}
${buildToggleSlider('spotlight-showClearArt', spotlight.showClearArt === true, 'Show clear art', { includeHiddenCheckbox: true })}
${buildToggleSlider('spotlight-panAnimation', spotlight.panAnimation !== false, 'Pan animation', { includeHiddenCheckbox: true })}
${buildToggleSlider('spotlight-fullScreen', spotlight.fullScreen === true, 'Full screen', { includeHiddenCheckbox: true })}
${buildToggleSlider('spotlight-dualBackdrops', spotlight.dualBackdrops !== false, 'Dual backdrops', { includeHiddenCheckbox: true })}
${buildTextInput('spotlight-interval', spotlight.interval ?? defaults.interval ?? 10000, 'Auto-play interval (ms)', 'number')}
</div>
`;
}
/**
* Build spotlight settings HTML for a section editor (custom or discovery).
* Shown only when Render Mode is Spotlight; call from buildSectionEditorHTML when the editor includes render mode.
* @param {Object} section - Section or discovery config (for spotlightConfig / defaults)
* @param {string} idPrefix - Input id prefix, e.g. 'section-' or 'discovery-'
* @param {boolean} isSpotlight - Initial visibility (true = show)
* @returns {string} HTML for the spotlight options container
*/
function buildSectionEditorSpotlightHTML(section, idPrefix, isSpotlight) {
const globalSpotlight = currentConfig?.SPOTLIGHT_SETTINGS || {};
const spotlightDefaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {};
const sectionSpotlight = section.spotlightConfig || {};
const spotlightOpts = { ...spotlightDefaults, ...globalSpotlight, ...sectionSpotlight };
const containerId = idPrefix + 'spotlight-options-container';
return `
<div id="${containerId}" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em; display: ${isSpotlight ? 'block' : 'none'};">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Spotlight Options</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75em 1.5em;">
${buildToggleSlider(idPrefix + 'spotlight-autoPlay', spotlightOpts.autoPlay !== false, 'Auto-play', { includeHiddenCheckbox: true })}
${buildToggleSlider(idPrefix + 'spotlight-showDots', spotlightOpts.showDots !== false, 'Show dot indicators', { includeHiddenCheckbox: true })}
${buildToggleSlider(idPrefix + 'spotlight-showNavButtons', spotlightOpts.showNavButtons !== false, 'Show prev/next buttons', { includeHiddenCheckbox: true })}
${buildToggleSlider(idPrefix + 'spotlight-showClearArt', spotlightOpts.showClearArt === true, 'Show clear art', { includeHiddenCheckbox: true })}
${buildToggleSlider(idPrefix + 'spotlight-panAnimation', spotlightOpts.panAnimation !== false, 'Pan animation', { includeHiddenCheckbox: true })}
${buildToggleSlider(idPrefix + 'spotlight-fullScreen', spotlightOpts.fullScreen === true, 'Full screen', { includeHiddenCheckbox: true })}
${buildToggleSlider(idPrefix + 'spotlight-dualBackdrops', spotlightOpts.dualBackdrops !== false, 'Dual backdrops', { includeHiddenCheckbox: true })}
${buildTextInput(idPrefix + 'spotlight-interval', spotlightOpts.interval ?? 10000, 'Auto-play interval (ms)', 'number')}
</div>
</div>
`;
}
/**
* Get field configuration for default sections
*/
@@ -1968,6 +2076,11 @@
if (defaultFields.includeFilterByPlayedStatus) {
basicPropertiesHTML += buildFilterByPlayedStatusToggle('section-filterByPlayedStatus', section.queries?.[0]?.queryOptions?.IsUnplayed === true);
}
basicPropertiesHTML += buildSelect('section-renderMode', [
{ value: 'Normal', label: 'Normal' },
{ value: 'Spotlight', label: 'Spotlight' },
{ value: 'Random', label: 'Random' }
], section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal'), 'Render Mode');
} else {
// Custom sections - show full editor in flexbox layout
basicPropertiesHTML += `
@@ -2068,6 +2181,10 @@
}
}
// Spotlight options: only include when this editor has Render Mode (custom sections)
const isSpotlightMode = section.renderMode === 'Spotlight' || section.spotlight;
const spotlightOptionsHTML = buildSectionEditorSpotlightHTML(section, 'section-', isSpotlightMode);
return `
<div style="max-width: 1000px; margin: 0 auto;">
<!-- Enabled Toggle (at the very top) -->
@@ -2080,7 +2197,10 @@
${basicPropertiesHTML}
</div>
${additionalFieldsHTML}
<!-- Spotlight Options (visible only when Render Mode = Spotlight) -->
${spotlightOptionsHTML}
${!isDefaultSection ? `
<!-- Queries Array Editor -->
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div style="display: flex; align-items: center; margin-bottom: 0.75em; gap: 0.5em;">
@@ -2213,7 +2333,7 @@
{ key: 'SortBy', label: 'Sort By', value: queryOptions.SortBy || '', options: SORT_ORDERS },
{ key: 'SortOrder', label: 'Sort Order', value: queryOptions.SortOrder || '', options: SORT_ORDER_DIRECTIONS },
{ key: 'Limit', label: 'Limit', value: queryOptions.Limit || '', type: 'number' },
{ key: 'Filters', label: 'Filters', value: queryOptions.Filters || '' },
{ key: 'Filters', label: 'Filters', value: queryOptions.Filters || '', placeholder: 'IsUnplayed,IsResumable,IsNotFolder' },
{ key: 'SearchTerm', label: 'Search Term', value: queryOptions.SearchTerm || '' }
];
@@ -2242,7 +2362,7 @@
<div style="display: flex; gap: 0.75em; flex-wrap: wrap;">
${commonFields.map(field => `
<div style="flex: 1; min-width: 150px;">
${field.options ? buildSelect(`query-${queryIndex}-${field.key}`, field.options, field.value, field.label) : buildTextInput(`query-${queryIndex}-${field.key}`, field.value, field.label, field.type || 'text')}
${field.options ? buildSelect(`query-${queryIndex}-${field.key}`, field.options, field.value, field.label) : buildTextInput(`query-${queryIndex}-${field.key}`, field.value, field.label, field.type || 'text', field.placeholder || '')}
</div>
`).join('')}
</div>
@@ -2577,6 +2697,8 @@
* Build discovery section editor HTML (simplified)
*/
function buildDiscoveryEditorHTML(section) {
const isDiscoverySpotlight = section.renderMode === 'Spotlight';
const discoverySpotlightHTML = buildSectionEditorSpotlightHTML(section, 'discovery-', isDiscoverySpotlight);
return `
<div style="max-width: 100%;">
<div class="listItemBodyText secondary" style="margin-bottom: 1em; padding: 0.75em; background: rgba(255,255,255,0.05); border-radius: 4px;">
@@ -2588,8 +2710,10 @@
${buildSelect('discovery-sortOrder', SORT_ORDERS, section.sortOrder || 'Random', 'Sort Order')}
${section.sortOrderDirection ? buildSelect('discovery-sortOrderDirection', SORT_ORDER_DIRECTIONS, section.sortOrderDirection || 'Ascending', 'Sort Order Direction') : ''}
${buildSelect('discovery-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format')}
${buildTextInput('discovery-ttl', section.ttl !== undefined ? section.ttl : '', 'TTL (ms)', 'number')}
${buildTextInput('discovery-ttl', section.ttl !== undefined ? section.ttl : '', 'TTL (ms)', 'number')}
${buildSelect('discovery-renderMode', RENDER_MODE_OPTIONS, section.renderMode || 'Normal', 'Render Mode')}
<!-- Spotlight Options (visible only when Render Mode = Spotlight) -->
${discoverySpotlightHTML}
</div>
`;
}
@@ -2636,6 +2760,22 @@
discovery.minimumItems = parseInt(minimumItems, 10);
}
}
// Spotlight options (saved when Render Mode = Spotlight)
if (renderMode === 'Spotlight') {
discovery.spotlightConfig = {
autoPlay: dialog.querySelector('#discovery-spotlight-autoPlay')?.checked !== false,
interval: parseInt(dialog.querySelector('#discovery-spotlight-interval')?.value || '10000', 10),
showDots: dialog.querySelector('#discovery-spotlight-showDots')?.checked !== false,
showNavButtons: dialog.querySelector('#discovery-spotlight-showNavButtons')?.checked !== false,
showClearArt: dialog.querySelector('#discovery-spotlight-showClearArt')?.checked === true,
panAnimation: dialog.querySelector('#discovery-spotlight-panAnimation')?.checked !== false,
fullScreen: dialog.querySelector('#discovery-spotlight-fullScreen')?.checked === true,
dualBackdrops: dialog.querySelector('#discovery-spotlight-dualBackdrops')?.checked !== false
};
} else {
delete discovery.spotlightConfig;
}
return discovery;
}
@@ -2724,6 +2864,24 @@
section.endDate = dialog.querySelector('#section-endDate')?.value || section.endDate;
}
const renderMode = dialog.querySelector('#section-renderMode')?.value || 'Normal';
if (renderMode) {
section.renderMode = renderMode;
if (renderMode === 'Spotlight') {
section.spotlightConfig = {
autoPlay: dialog.querySelector('#section-spotlight-autoPlay')?.checked !== false,
interval: parseInt(dialog.querySelector('#section-spotlight-interval')?.value || '10000', 10),
showDots: dialog.querySelector('#section-spotlight-showDots')?.checked !== false,
showNavButtons: dialog.querySelector('#section-spotlight-showNavButtons')?.checked !== false,
showClearArt: dialog.querySelector('#section-spotlight-showClearArt')?.checked === true,
panAnimation: dialog.querySelector('#section-spotlight-panAnimation')?.checked !== false,
fullScreen: dialog.querySelector('#section-spotlight-fullScreen')?.checked === true,
dualBackdrops: dialog.querySelector('#section-spotlight-dualBackdrops')?.checked !== false
};
}
}
return section;
}
@@ -2862,7 +3020,7 @@
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
if (badges.length > 0) {
query.queryOptions.Genres = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean).join(',');
query.queryOptions.Genres = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean);
}
}
}
@@ -2871,7 +3029,7 @@
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
if (badges.length > 0) {
query.queryOptions.Tags = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean).join(',');
query.queryOptions.Tags = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean);
}
}
}
@@ -2963,6 +3121,22 @@
if (startDate) section.startDate = startDate;
if (endDate) section.endDate = endDate;
// Spotlight options (saved to spotlightConfig when Render Mode = Spotlight)
if (renderMode === 'Spotlight') {
section.spotlightConfig = {
autoPlay: dialog.querySelector('#section-spotlight-autoPlay')?.checked !== false,
interval: parseInt(dialog.querySelector('#section-spotlight-interval')?.value || '10000', 10),
showDots: dialog.querySelector('#section-spotlight-showDots')?.checked !== false,
showNavButtons: dialog.querySelector('#section-spotlight-showNavButtons')?.checked !== false,
showClearArt: dialog.querySelector('#section-spotlight-showClearArt')?.checked === true,
panAnimation: dialog.querySelector('#section-spotlight-panAnimation')?.checked !== false,
fullScreen: dialog.querySelector('#section-spotlight-fullScreen')?.checked === true,
dualBackdrops: dialog.querySelector('#section-spotlight-dualBackdrops')?.checked !== false
};
} else {
delete section.spotlightConfig;
}
// Multi-query sorting
if (section.queries.length > 1) {
const sortBy = dialog.querySelector('#section-sortBy')?.value;
@@ -2995,7 +3169,8 @@
defaultCardFormat: dialog.querySelector('#discovery-defaultCardFormat')?.value || 'Poster',
spotlightDiscoveryChance: parseFloat(dialog.querySelector('#discovery-spotlightDiscoveryChance')?.value || '0.5'),
renderSpotlightAboveMatching: dialog.querySelector('#discovery-renderSpotlightAboveMatching')?.checked === true,
randomizeOrder: dialog.querySelector('#discovery-randomizeOrder')?.checked === true
randomizeOrder: dialog.querySelector('#discovery-randomizeOrder')?.checked === true,
fadeInSections: dialog.querySelector('#discovery-fadeInSections')?.checked === true
};
const seasonal = {
@@ -3018,12 +3193,30 @@
FORCE_REFRESH_TTL: 0
};
const spotlight = {
autoPlay: dialog.querySelector('#spotlight-autoPlay')?.checked !== false,
interval: parseInt(dialog.querySelector('#spotlight-interval')?.value || '10000', 10),
showDots: dialog.querySelector('#spotlight-showDots')?.checked !== false,
showNavButtons: dialog.querySelector('#spotlight-showNavButtons')?.checked !== false,
showClearArt: dialog.querySelector('#spotlight-showClearArt')?.checked === true,
panAnimation: dialog.querySelector('#spotlight-panAnimation')?.checked !== false,
fullScreen: dialog.querySelector('#spotlight-fullScreen')?.checked === true,
dualBackdrops: dialog.querySelector('#spotlight-dualBackdrops')?.checked !== false
};
const homeSettings = {
fadeInSections: dialog.querySelector('#home-fadeInSections')?.checked === true,
ensureThumbsForPopularTVNetworks: dialog.querySelector('#home-ensureThumbsForPopularTVNetworks')?.checked === true
};
const mergeNextUp = dialog.querySelector('#merge-next-up')?.checked === true;
return {
DISCOVERY_SETTINGS: discovery,
SEASONAL_THEME_SETTINGS: seasonal,
CACHE: cache,
SPOTLIGHT_SETTINGS: spotlight,
HOME_SETTINGS: homeSettings,
MERGE_NEXT_UP: mergeNextUp
};
}
@@ -3093,6 +3286,25 @@
});
}
// Delegated toggle-slider handler for section editor (Spotlight Options, Discovery Enabled, etc.)
// Single listener on content root so it works when controls are re-rendered and is not re-added
const contentRoot = modalInstance.dialogContent;
if (contentRoot) {
contentRoot.addEventListener('click', (e) => {
const btn = e.target.closest('.toggle-slider');
if (!btn || !contentRoot.contains(btn)) return;
const checkboxId = btn.dataset.checkboxId;
if (!checkboxId) return;
e.stopPropagation();
const checkbox = document.getElementById(checkboxId);
if (checkbox && contentRoot.contains(checkbox)) {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
updateToggleSliderUI(btn, checkbox.checked);
}
});
}
const updateSectionInConfig = (sectionData) => {
const found = findSectionInAllGroups(currentConfig, sectionData.id);
@@ -3399,15 +3611,29 @@
});
}
// Render Mode dropdown - show/hide Card Format
// Render Mode dropdown - show/hide Card Format and Spotlight Options (section editor)
const renderModeSelect = modalInstance.dialogContent.querySelector('#section-renderMode');
const cardFormatContainer = modalInstance.dialogContent.querySelector('#section-cardFormat-container');
if (renderModeSelect && cardFormatContainer) {
const updateCardFormatVisibility = () => {
cardFormatContainer.style.display = renderModeSelect.value === 'Spotlight' ? 'none' : 'block';
const spotlightOptionsContainer = modalInstance.dialogContent.querySelector('#section-spotlight-options-container');
if (renderModeSelect) {
const updateRenderModeDependentVisibility = () => {
const isSpotlight = renderModeSelect.value === 'Spotlight';
if (cardFormatContainer) cardFormatContainer.style.display = isSpotlight ? 'none' : 'block';
if (spotlightOptionsContainer) spotlightOptionsContainer.style.display = isSpotlight ? 'block' : 'none';
};
renderModeSelect.addEventListener('change', updateCardFormatVisibility);
updateCardFormatVisibility(); // Set initial state
renderModeSelect.addEventListener('change', updateRenderModeDependentVisibility);
updateRenderModeDependentVisibility(); // Set initial state
}
// Render Mode dropdown - show/hide Spotlight Options (discovery editor)
const discoveryRenderModeSelect = modalInstance.dialogContent.querySelector('#discovery-renderMode');
const discoverySpotlightOptionsContainer = modalInstance.dialogContent.querySelector('#discovery-spotlight-options-container');
if (discoveryRenderModeSelect && discoverySpotlightOptionsContainer) {
const updateDiscoverySpotlightVisibility = () => {
discoverySpotlightOptionsContainer.style.display = discoveryRenderModeSelect.value === 'Spotlight' ? 'block' : 'none';
};
discoveryRenderModeSelect.addEventListener('change', updateDiscoverySpotlightVisibility);
updateDiscoverySpotlightVisibility(); // Set initial state
}
// Section Group dropdown - show/hide new group input
@@ -4020,6 +4246,36 @@
contentContainer.innerHTML = buildSectionContentHTML(sectionType);
}
});
// Global Settings sub-tab navigation (left nav in Global Settings tab)
dialog.addEventListener('click', (e) => {
const btn = e.target.closest('.global-settings-nav-btn');
if (!btn) return;
const subTab = btn.dataset.globalSettingsTab;
if (!subTab) return;
currentGlobalSettingsSubTab = subTab;
dialog.querySelectorAll('.global-settings-nav-btn').forEach(b => {
b.classList.remove('active');
b.style.background = 'rgba(255,255,255,0.05)';
b.style.borderColor = 'rgba(255,255,255,0.1)';
b.style.color = 'inherit';
const label = b.querySelector('.listItemBodyText');
if (label) label.style.fontWeight = '400';
});
btn.classList.add('active');
btn.style.background = 'rgba(0, 164, 220, 0.2)';
btn.style.borderColor = 'rgba(0, 164, 220, 0.5)';
btn.style.color = 'var(--theme-primary-color, #00a4dc)';
const label = btn.querySelector('.listItemBodyText');
if (label) label.style.fontWeight = '500';
const contentContainer = dialog.querySelector('#global-settings-content');
if (contentContainer) {
contentContainer.innerHTML = buildGlobalSettingsPanelContent(subTab);
}
});
/* dialog.querySelectorAll('.section-type-nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
const sectionType = btn.dataset.sectionType;
@@ -5348,6 +5604,8 @@
// Load config
const config = await loadConfig();
await verifyRecentlyAddedInLibraryConfig(config);
// Build content
const content = document.createElement('div');
content.innerHTML = buildMainConfigHTML();
@@ -5466,8 +5724,7 @@
// Build query URL using shared builder (handles minAge/maxAge conversion)
if (query.dataSource) {
// Data source - skip for preview (would need special handling)
showToast('Data source queries cannot be previewed');
continue;
queryUrl = null;
} else if (window.apiHelper && window.apiHelper.buildQueryFromSection) {
// Use shared builder for consistency
queryUrl = window.apiHelper.buildQueryFromSection(query, userId, serverUrl);
@@ -5480,15 +5737,20 @@
}
}
if (queryUrl && typeof queryUrl === 'string') {
try {
const result = await apiHelper.getQuery(queryUrl, { useCache: false });
const items = result.Items || result || [];
allItems = allItems.concat(items);
} catch (error) {
console.error('[Preview] Error fetching query:', error);
showToast(`Error fetching query: ${error.message}`);
try {
let items = [];
if (query.dataSource) {
const response = await window.apiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}, false);
items = response.Items || response || [];
}
else if (queryUrl && typeof queryUrl === 'string') {
const result = await apiHelper.getQuery(queryUrl, { useCache: false });
items = result.Items || result || [];
}
allItems = allItems.concat(items);
} catch (error) {
console.error('[Preview] Error fetching query:', error);
showToast(`Error fetching query: ${error.message}`);
}
}
+27 -4
View File
@@ -81,7 +81,7 @@
const isEnabled = enabledBaseIds.has(baseId);
// Get order and enabled status from kefinHomeScreen if available
let order = index;
let order = 0;
let enabledFromPrefs = isEnabled; // Default to homesectionN value
if (Array.isArray(kefinHomeScreen) && kefinHomeScreen.length > 0) {
@@ -366,13 +366,23 @@
// Add non-mapped Jellyfin sections
sections.forEach(section => {
if (section.enabled !== false && (section.isJellyfin || isJellyfinSection(section.id) || section.mapsToJellyfin)) {
if (section.enabled === true && (section.isJellyfin || isJellyfinSection(section.id) || section.mapsToJellyfin)) {
const baseId = section.mapsToJellyfin || section.jellyfinBaseId || section.id;
sectionsForHomesectionN.push({
jellyfinBaseId: baseId,
order: section.order || 0
});
}
if (section.enabled === true && section.id === 'continueWatchingAndNextUp') {
sectionsForHomesectionN.push({
jellyfinBaseId: 'resume',
order: section.order || 0
});
sectionsForHomesectionN.push({
jellyfinBaseId: 'nextup',
order: section.order || 0
});
}
});
// Ensure each section has a unique jellyfinBaseId
@@ -505,7 +515,12 @@
// Filter out mapped Jellyfin sections if their KefinTweaks equivalent exists
const kefinSectionIds = new Set(kefinSections.map(s => s.id));
const hasRecentlyAdded = kefinSections.some(s => s.id && s.id.startsWith('recently-added-'));
const adminKefinSections = window.KefinHomeScreen.getConfig().ENABLED_NORMAL_SECTIONS;
const isKefinNextUpEnabled = adminKefinSections.some(s => s.id && s.id === 'nextUp' && s.enabled === true);
const isKefinContinueWatchingEnabled = adminKefinSections.some(s => s.id && s.id === 'continueWatching' && s.enabled === true);
const isKefinMergeNextUpEnabled = adminKefinSections.some(s => s.id && s.id === 'continueWatchingAndNextUp' && s.enabled === true);
const isKefinRecentlyAddedEnabled = adminKefinSections.some(s => s.id && s.id.startsWith('recently-added-') && s.enabled === true);
jellyfinSections = jellyfinSections.filter(jellyfinSection => {
const baseId = jellyfinSection.jellyfinBaseId || jellyfinSection.id;
@@ -513,7 +528,15 @@
// For latestmedia, check if any recently-added-* section exists
if (baseId === 'latestmedia') {
return !hasRecentlyAdded; // Remove if any recently-added exists
return !isKefinRecentlyAddedEnabled; // Remove if any recently-added exists
}
if (baseId === 'nextup') {
return !isKefinNextUpEnabled && !isKefinMergeNextUpEnabled;
}
if (baseId === 'resume') {
return !isKefinContinueWatchingEnabled && !isKefinMergeNextUpEnabled;
}
// For other mapped sections, check if the mapped KefinTweaks section exists
+63 -9
View File
@@ -227,6 +227,10 @@
return `${serverAddress}/Users/${userId}/Items?${params.toString()}`;
}
const state = {
cachedServerVersion: null
}
/**
* API Helper functions for Jellyfin operations
@@ -545,6 +549,14 @@
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie');
} else if (libraryItem.CollectionType === 'tvshows') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Series' || item === 'Season' || item === 'Episode');
} else if (libraryItem.CollectionType === 'boxsets') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'BoxSet');
} else if (libraryItem.CollectionType === 'playlists') {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Playlist');
} else if (libraryItem.CollectionType === 'homevideos') {
//itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Video');
} else if (!libraryItem.CollectionType) {
itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie' || item === 'Series' || item === 'Season' || item === 'Episode' || item === 'Video');
}
}
@@ -784,7 +796,7 @@
* @param {string} serverUrl - Server URL
* @returns {string|Object} - Query URL string or dataSource marker object
*/
buildQueryFromSection: function(query, userId, serverUrl) {
buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false) {
ensureApiClient();
const queryOptions = { ...(query.queryOptions || {}) };
@@ -818,7 +830,7 @@
}
// Standard /Items query
return this.buildStandardQuery(queryOptions, userId, serverUrl);
return this.buildStandardQuery(queryOptions, userId, serverUrl, isSpotlight);
},
/**
@@ -826,18 +838,22 @@
* @param {Object} queryOptions - Query options
* @param {string} userId - User ID
* @param {string} serverUrl - Server URL
* @param {boolean} isSpotlight - Whether the query is for a spotlight section
* @returns {string} - Query URL
*/
buildStandardQuery: function(queryOptions, userId, serverUrl) {
buildStandardQuery: function(queryOptions, userId, serverUrl, isSpotlight = false) {
// Fields that require pipe delimiter instead of comma
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
const params = new URLSearchParams({
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData',
ImageTypeLimit: 1,
UserId: userId
});
if (isSpotlight) {
params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags');
}
// Map all queryOptions to params
Object.entries(queryOptions || {}).forEach(([key, value]) => {
@@ -896,9 +912,6 @@
if (!params.has('Fields')) {
params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,MediaSourceCount,UserData');
}
if (!params.has('ImageTypeLimit')) {
params.set('ImageTypeLimit', '1');
}
if (!params.has('EnableTotalRecordCount')) {
params.set('EnableTotalRecordCount', 'false');
}
@@ -933,7 +946,7 @@
}
// Filter studios/networks with no thumb
if (cacheName === 'StudiosCache') {
if (cacheName === 'StudiosCache' && window.KefinHomeScreem?.getConfig()?.HOME_SETTINGS?.ensureThumbsForPopularTVNetworks === true) {
data = data.filter(item => item.ImageTags?.Thumb);
}
@@ -1069,7 +1082,48 @@
};
},
isAdmin: isAdmin
isAdmin: isAdmin,
/**
* Get the current Jellyfin version from ApiClient
* Polls every 500ms for up to 5 seconds in the background if not immediately available
* @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable
*/
getJellyfinVersion: async function() {
try {
// If we have a cached value, return it
if (state.cachedServerVersion !== null) {
return state.cachedServerVersion;
}
if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) {
return null;
}
if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
state.cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion);
return state.cachedServerVersion;
}
// Check the server version instead of app version
if (!window.ApiClient._serverVersion) {
// Wait 10s to see if it becomes ready, check every 500ms
const startTime = Date.now();
while (Date.now() - startTime < 10000) {
if (window.ApiClient._serverVersion) {
break;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
state.cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion);
return state.cachedServerVersion;
} catch (error) {
WARN('Error getting server version:', error);
return null;
}
}
};
const GENRE_TTL = 24 * 60 * 60 * 1000; // 24 hours
+320 -10
View File
@@ -31,6 +31,145 @@
transition: opacity 0.8s ease-in-out;
}
/* Dual-backdrop background with masked crossfade */
.spotlight-background-dual {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
}
.spotlight-background-layer {
position: absolute;
top: 0;
bottom: 0;
overflow: hidden;
}
.spotlight-background-layer img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.spotlight-background-layer img.animate {
filter: blur(0);
transform: scale(1);
transition: filter 0.3s ease-in-out, transform 0.3s ease-in-out;
}
.spotlight-background-layer-left img.animate {
animation: kenBurnsZoomOut 20s ease-out forwards;
}
.spotlight-background-layer-right img.animate {
animation: kenBurnsZoomIn 20s ease-out forwards;
}
/* Single backdrop (when dual backdrops disabled or only one image) - same pan animation */
.spotlight-background-single {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
}
.spotlight-background-single img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.spotlight-background-single img.animate {
animation: kenBurnsZoomIn 20s ease-out forwards;
}
@keyframes kenBurnsZoomIn {
from {
transform: scale(1) translate3d(0, 0, 0);
}
to {
transform: scale(1.12) translate3d(-1%, -1%, 0);
}
}
@keyframes kenBurnsZoomOut {
from {
transform: scale(1.15) translate3d(-1%, -1%, 0);
}
to {
transform: scale(1.02) translate3d(0, 0, 0);
}
}
@keyframes slowPanRight {
from {
transform: scale(1.1) translate3d(-3%, 0, 0);
}
to {
transform: scale(1.1) translate3d(3%, 0, 0);
}
}
@keyframes slowPanLeft {
from {
transform: scale(1.1) translate3d(3%, 0, 0);
}
to {
transform: scale(1.1) translate3d(-3%, 0, 0);
}
}
/* Layer masks for elegant crossfade - overlap in center (45-55%) */
.spotlight-background-layer-left {
left: 0;
width: 55%;
-webkit-mask-image: linear-gradient(
to right,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 1) 75%,
rgba(0, 0, 0, 0.5) 90%,
rgba(0, 0, 0, 0) 100%
);
mask-image: linear-gradient(
to right,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 1) 75%,
rgba(0, 0, 0, 0.5) 90%,
rgba(0, 0, 0, 0) 100%
);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.spotlight-background-layer-right {
right: 0;
width: 55%;
-webkit-mask-image: linear-gradient(
to right,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.5) 10%,
rgba(0, 0, 0, 1) 25%,
rgba(0, 0, 0, 1) 100%
);
mask-image: linear-gradient(
to right,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.5) 10%,
rgba(0, 0, 0, 1) 25%,
rgba(0, 0, 0, 1) 100%
);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.spotlight-item[data-index="0"] {
opacity: 1;
}
@@ -563,37 +702,208 @@
display: initial !important;
}
/* Full-screen spotlight: negative margins for overlap, content inset, top/bottom fade.
Banner has NO padding so the background image overflows edge-to-edge; only content is inset. */
.spotlight-section[data-full-screen="true"] {
--spotlight-fullscreen-vertical: 200px;
--spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%);
padding: 0;
width: 100%;
}
.spotlight-section[data-full-screen="true"] .spotlight-banner-container {
border-radius: 0;
height: 70vh;
margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) / 2));
margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) / 2));
}
.spotlight-section[data-full-screen="true"] .spotlight-items-container {
.spotlight-section[data-full-screen="true"] .spotlight-banner-container {
border-radius: 0;
height: 90vh !important;
/* No padding: background fills full size so image extends edge-to-edge and into overlap zone */
position: relative;
/* Wider mask so backdrop fades gradually at top/bottom (no visible solid edge during animation) */
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black 12%,
black 88%,
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 12%,
black 88%,
transparent 100%
);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-position: 0 0;
mask-position: 0 0;
}
/* Dark gradient overlays at top and bottom (like left-side overlay): graceful fade and better visibility when content overlaps */
.spotlight-section[data-full-screen="true"] .spotlight-banner-container::before,
.spotlight-section[data-full-screen="true"] .spotlight-banner-container::after {
content: '';
position: absolute;
left: 0;
right: 0;
width: 100%;
height: 22%;
pointer-events: none;
z-index: 2;
}
.spotlight-section[data-full-screen="true"] .spotlight-banner-container::before {
top: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.5) 40%,
transparent 100%
);
}
.spotlight-section[data-full-screen="true"] .spotlight-banner-container::after {
bottom: 0;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.5) 40%,
transparent 100%
);
}
.spotlight-section[data-full-screen="true"] .spotlight-items-container,
.spotlight-section[data-full-screen="true"] .spotlight-item {
/* Full bleed: fill banner so background extends to edges (and past negative margin) */
left: 0;
right: 0;
width: 100%;
}
/* Overlay stays left: 0 so the dark gradient still covers the image; only content is inset via padding.
z-index above top/bottom gradient pseudo-elements so title and buttons stay readable. */
.spotlight-section[data-full-screen="true"] .spotlight-overlay {
left: 0;
z-index: 3;
padding: calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal);
}
.spotlight-section[data-full-screen="true"] .spotlight-overlay.has-title {
padding: 70px 80px 20px 80px;
padding: calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal) calc(var(--spotlight-fullscreen-vertical) + 50px) var(--spotlight-fullscreen-horizontal);
}
.spotlight-section[data-full-screen="true"] .spotlight-section-title {
left: 80px;
left: var(--spotlight-fullscreen-horizontal);
top: var(--spotlight-fullscreen-vertical);
}
.spotlight-section[data-full-screen="true"] .spotlight-item-logo {
width: auto;
max-width: none;
height: 40%;
margin-bottom: 40px;
}
.spotlight-section[data-full-screen="true"] .spotlight-clearart {
right: 80px;
right: var(--spotlight-fullscreen-horizontal);
bottom: var(--spotlight-fullscreen-vertical);
}
.spotlight-section[data-full-screen="true"] .spotlight-nav-container {
right: 80px;
right: var(--spotlight-fullscreen-horizontal);
top: var(--spotlight-fullscreen-vertical);
}
.spotlight-section[data-full-screen="true"] .spotlight-dots {
bottom: var(--spotlight-fullscreen-vertical);
}
.spotlight-section[data-full-screen="true"] img {
object-position: center -35%;
}
.spotlight-section[data-full-screen="true"] .spotlight-clearart {
display: none;
}
:has(.spotlight-section[data-full-screen="true"]) .homeSectionsContainer > *:not(.spotlight-section) {
z-index: 1;
}
.spotlight-section[data-full-screen="true"] .spotlight-tagline {
max-height: 150px !important;
-webkit-line-clamp: 6;
font-style: normal;
font-size: 1.05em;
}
/* Full-screen: modern stream-site metadata styles */
.spotlight-section[data-full-screen="true"] .metadata-container {
gap: 10px;
}
.spotlight-section[data-full-screen="true"] .spotlight-metadata-row {
font-size: 15px;
gap: 10px;
}
/* Pill-style for year, duration, genres, director/writer/series, end time */
.spotlight-section[data-full-screen="true"] .spotlight-metadata-row > span {
padding: 5px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.spotlight-section[data-full-screen="true"] .spotlight-end-time {
display: inline-flex;
align-items: center;
gap: 6px;
}
.spotlight-section[data-full-screen="true"] .spotlight-end-time .material-icons {
font-size: 1.1em;
opacity: 0.9;
}
.spotlight-section[data-full-screen="true"] .spotlight-genre-link {
display: inline-block;
padding: 5px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: background 0.2s, border-color 0.2s;
}
.spotlight-section[data-full-screen="true"] .spotlight-genre-link:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.35);
}
/* Director/writer/series info: pill-style container */
.spotlight-section[data-full-screen="true"] .spotlight-truncated-list:has(>*) {
font-size: 15px;
padding: 6px 14px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
display: inline-block;
max-width: fit-content;
}
.spotlight-section a:not(:hover) {
text-decoration: none !important;
}
.spotlight-section[data-full-screen="true"] span.spotlight-end-time::after {
content: 'schedule';
position: absolute;
left: 10px;
font-family: 'Material Icons';
top: calc(50% + 1px);
transform: translateY(-50%);
}
.spotlight-section[data-full-screen="true"] span.spotlight-end-time {
padding-left: 30px !important;
position: relative;
}
+406 -79
View File
@@ -8,6 +8,10 @@
const state = {
useEpisodeImages: false,
};
// Global toggle for progressive section fade-in.
// Developers can set this to false to disable the behavior.
const FADE_IN_SECTIONS = true;
/**
* Helper function to shuffle array (Fisher-Yates)
@@ -272,6 +276,20 @@
processed = processed.filter(item => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0);
}
// Check for SortBy ChildCount and use ChildCount to sort the items
// ChildCount = ChildCount + SeriesCount + EpisodeCount + AlbumCount + ArtistCount + ProgramCount + MovieCount + SongCount
if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'ChildCount') {
processed = processed.sort((a, b) => {
const childCountA = (a.ChildCount || 0) + (a.SeriesCount || 0) + (a.EpisodeCount || 0) + (a.AlbumCount || 0) + (a.ArtistCount || 0) + (a.ProgramCount || 0) + (a.MovieCount || 0) + (a.SongCount || 0);
const childCountB = (b.ChildCount || 0) + (b.SeriesCount || 0) + (b.EpisodeCount || 0) + (b.AlbumCount || 0) + (b.ArtistCount || 0) + (b.ProgramCount || 0) + (b.MovieCount || 0) + (b.SongCount || 0);
if (sectionConfig.queries?.[0]?.queryOptions?.SortOrder === 'Descending') {
return childCountB - childCountA;
}
return childCountA - childCountB;
});
}
const limit = sectionConfig.itemLimit || sectionConfig.queries?.[0]?.queryOptions?.Limit || 0;
// Apply local limits for non-API sources
@@ -310,7 +328,7 @@
queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}, false);
} else {
// Build and execute query
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl);
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight');
if (typeof queryUrl === 'string') {
// Standard query - use useCache: false to bypass cache
@@ -471,7 +489,9 @@
// Replace the section element
sectionElement.replaceWith(content);
checkSectionOverflow(content, true);
setTimeout(() => checkSectionOverflow(content, true), 150);
// Add refresh button to new content
const sectionTitleContainer = content.querySelector('.sectionTitleContainer');
let newButton = null;
@@ -641,6 +661,8 @@
// Replace content of skeleton container with real content
// This preserves the container element itself and its layout properties (like order)
skeletonContaineElement.replaceWith(realContainer);
checkSectionOverflow(realContainer, true);
setTimeout(() => checkSectionOverflow(realContainer, true), 150);
}).catch(error => {
console.error('[KefinTweaks CardBuilder] Error loading items for progressive enhancement:', error);
// Optionally remove skeleton on error or show error state
@@ -660,7 +682,7 @@
* @param {Object} options - Configuration options
*/
renderProgressiveSections: async function(container, sections, options = {}) {
const { revealSectionsSequentially = false } = options;
const { revealSectionsSequentially = false, waitForContainerClass = null } = options;
const renderProgressiveSectionsStartTime = performance.now();
@@ -708,6 +730,7 @@
content.setAttribute('data-section-id', sectionConfig.id);
content.style.order = sectionConfig.order;
content.dataset.order = sectionConfig.order;
// Add refresh button to rendered section
const sectionTitleContainer = content.querySelector('.sectionTitleContainer');
@@ -742,7 +765,6 @@
}
sectionElement.setAttribute('data-section-id', sectionConfig.id);
sectionElement.style.order = sectionConfig.order;
sectionElements.push(sectionElement);
//container.appendChild(sectionElement);
// set section element content-visibility to hidden
@@ -753,13 +775,44 @@
sectionElement.style.display = 'none';
}
sectionElements.push(sectionElement);
fragment.appendChild(sectionElement);
const sectionEndTime = performance.now();
const sectionDuration = sectionEndTime - sectionStartTime;
console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
}
container.appendChild(fragment);
// Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds
// Append the container to the page for now invisibly so that Jellyfin handles enhancing it with the typical Scroller functionality
// Finally when the container has the class specified in waitForContainerClass, we can move the pre-rendered sections into the container
if (waitForContainerClass && !container.classList.contains(waitForContainerClass)) {
// Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds
console.log(`Waiting for container to have class ${waitForContainerClass}...`);
const maxAttempts = 100;
let attempts = 0;
while (!container.classList.contains(waitForContainerClass) && attempts < maxAttempts) {
attempts++;
await new Promise(resolve => setTimeout(resolve, 100));
if (container.classList.contains(waitForContainerClass)) {
break;
}
}
if (!container.classList.contains(waitForContainerClass)) {
console.log(`Container did not have class ${waitForContainerClass} after ${maxAttempts} attempts, continuing...`);
}
}
// Batch append sections to avoid Jellyfin emby-scroller processing
// all at once (causes render lag when many sections are added)
const BATCH_SIZE = 4;
const sectionNodes = Array.from(fragment.childNodes);
for (let i = 0; i < sectionNodes.length; i += BATCH_SIZE) {
const batch = sectionNodes.slice(i, i + BATCH_SIZE);
batch.forEach(node => container.appendChild(node));
if (i + BATCH_SIZE < sectionNodes.length) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
/* for (const sectionElement of sectionElements) {
sectionElement.style.contentVisibility = 'hidden';
@@ -921,7 +974,9 @@
}
sectionElement.replaceWith(content);
checkSectionOverflow(content, true);
setTimeout(() => checkSectionOverflow(content, true), 150);
// If revealSectionsSequentially is enabled and section hasn't been revealed yet, re-observe it
if (revealSectionsSequentially && content.classList.contains('cardbuilder-section-reveal') && !content.classList.contains('in-viewport')) {
observeSectionForReveal(content);
@@ -1013,8 +1068,8 @@
content.setAttribute(attr.name, attr.value);
});
skeleton.replaceWith(content);
// Update skeleton inner HTML with the content
//skeleton.innerHTML = content.innerHTML;
checkSectionOverflow(content, true);
setTimeout(() => checkSectionOverflow(content, true), 150);
} else {
skeleton.remove();
}
@@ -1304,8 +1359,12 @@
} else if (cardFormat === 'portrait' || cardFormat === 'poster') {
if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else {
imageUrl = item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
} else if (item.PrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
}
} else if (cardFormat === 'thumb') {
@@ -1628,9 +1687,10 @@
* @param {string} title - Title for the spotlight section
* @param {Object} options - Options for the spotlight carousel
* @param {boolean} options.autoPlay - Auto-cycle through items (default: true)
* @param {number} options.interval - Auto-play interval in ms (default: 5000)
* @param {number} options.interval - Auto-play interval in ms (default: 10000)
* @param {boolean} options.showDots - Show dot indicators (default: true)
* @param {boolean} options.showNavButtons - Show prev/next buttons (default: true)
* @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen)
* @returns {HTMLElement} - The constructed spotlight container
*/
function createSpotlightSection(items, title, options = {}) {
@@ -1639,12 +1699,16 @@
}
const {
autoPlay = true,
interval = 5000,
autoPlay = true,
interval = 10000,
showDots = true,
showNavButtons = true,
showClearArt = false,
viewMoreUrl = null
panAnimation = true,
fullScreen = false,
dualBackdrops = true,
viewMoreUrl = null,
pauseOnHover = !fullScreen // Default: don't pause on hover in fullscreen (continue cycling)
} = options;
const serverId = ApiClient.serverId();
@@ -1652,6 +1716,8 @@
let currentIndex = 0;
let autoPlayTimer = null;
let isPaused = false;
let isVisible = true; // updated by Intersection Observer; start true so initial startAutoPlay runs
let currentSlideAnimationComplete = false;
// Create main container
const container = document.createElement('div');
@@ -1672,14 +1738,6 @@
titleLink.textContent = title;
titleLink.title = 'See All';
titleLink.style.textDecoration = 'none';
const cssStyle = document.createElement('style');
cssStyle.textContent = `
.spotlight-title-link:hover {
text-decoration: underline !important;
}
`;
titleLink.appendChild(cssStyle);
// Handle both URL and function
if (typeof viewMoreUrl === 'function') {
@@ -1710,6 +1768,8 @@
const itemsContainer = document.createElement('div');
itemsContainer.className = 'spotlight-items-container';
const isMobileLayout = document.documentElement.classList.contains('mobile-layout');
// Create items
items.forEach((item, index) => {
// Get item type early for use throughout
@@ -1728,17 +1788,101 @@
let imageUrl = '';
if (item.Type === 'Episode' && item.ParentBackdropImageTags?.[0]) {
// Series backdrop image
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.BackdropImageTags?.[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ImageTags.Primary}`;
// Build list of available backdrop sources for this item
let backdropSources = [];
if (item.Type === 'Episode' && Array.isArray(item.ParentBackdropImageTags) && item.ParentBackdropImageTags.length) {
backdropSources = item.ParentBackdropImageTags.map(tag => ({
itemId: item.ParentBackdropItemId,
tag
}));
} else if (Array.isArray(item.BackdropImageTags) && item.BackdropImageTags.length) {
backdropSources = item.BackdropImageTags.map(tag => ({
itemId: item.Id,
tag
}));
}
if (imageUrl) {
itemDiv.style.backgroundImage = `url("${imageUrl}")`;
// Set backdrop width based on client width either 1920 or 1280, 640 is the minimum
const clientWidth = window.innerWidth;
let backdropWidth = 1920;
if (clientWidth < 1280) {
backdropWidth = 1280;
} else if (clientWidth < 640) {
backdropWidth = 640;
}
// On non-mobile layouts, if we have more than one backdrop, render two side-by-side
if (!isMobileLayout && backdropSources.length > 1 && dualBackdrops) {
const firstBackdropIndex = Math.floor(Math.random() * backdropSources.length);
let secondBackdropIndex = Math.floor(Math.random() * backdropSources.length);
if (secondBackdropIndex === firstBackdropIndex) {
// Get another random backdrop index that is not the same as the first one
secondBackdropIndex = (secondBackdropIndex + 1) % backdropSources.length;
}
// Pick two distinct random backdrops
const first = backdropSources[firstBackdropIndex];
const second = backdropSources[secondBackdropIndex];
const firstUrl = `${serverAddress}/Items/${first.itemId}/Images/Backdrop/${firstBackdropIndex}?fillWidth=${backdropWidth}&quality=96&tag=${first.tag}`;
const secondUrl = `${serverAddress}/Items/${second.itemId}/Images/Backdrop/${secondBackdropIndex}?fillWidth=${backdropWidth}&quality=96&tag=${second.tag}`;
const dualBackgroundContainer = document.createElement('div');
dualBackgroundContainer.className = 'spotlight-background-dual';
const leftLayer = document.createElement('div');
leftLayer.className = 'spotlight-background-layer spotlight-background-layer-left';
const leftImg = document.createElement('img');
leftImg.setAttribute('data-src', firstUrl);
leftImg.alt = '';
leftImg.draggable = false;
if (index === 0) {
leftImg.src = firstUrl;
leftImg.loading = 'eager';
}
leftLayer.appendChild(leftImg);
const rightLayer = document.createElement('div');
rightLayer.className = 'spotlight-background-layer spotlight-background-layer-right';
const rightImg = document.createElement('img');
rightImg.setAttribute('data-src', secondUrl);
rightImg.alt = '';
rightImg.draggable = false;
if (index === 0) {
rightImg.src = secondUrl;
rightImg.loading = 'eager';
}
rightLayer.appendChild(rightImg);
dualBackgroundContainer.appendChild(leftLayer);
dualBackgroundContainer.appendChild(rightLayer);
itemDiv.appendChild(dualBackgroundContainer);
} else {
// Fallback: single backdrop or primary image, including mobile layouts
if (backdropSources.length) {
const first = backdropSources[0];
imageUrl = `${serverAddress}/Items/${first.itemId}/Images/Backdrop?fillWidth=${backdropWidth}&quality=96&tag=${first.tag}`;
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillWidth=${backdropWidth}&quality=96&tag=${item.ImageTags.Primary}`;
}
if (imageUrl) {
// Use img in a layer (like dual) so pan animation can be applied
const singleBackdropContainer = document.createElement('div');
singleBackdropContainer.className = 'spotlight-background-single';
const singleImg = document.createElement('img');
singleImg.setAttribute('data-src', imageUrl);
singleImg.alt = '';
singleImg.draggable = false;
if (index === 0) {
singleImg.src = imageUrl;
singleImg.loading = 'eager';
}
singleBackdropContainer.appendChild(singleImg);
itemDiv.appendChild(singleBackdropContainer);
}
}
// Create left overlay (40-50% width, dark semi-transparent)
@@ -1752,10 +1896,11 @@
if (hasLogo) {
// Use logo image instead of text title
const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=200&quality=96&tag=${item.ImageTags.Logo}`;
const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=${fullScreen ? '300' : '200'}&quality=96&tag=${item.ImageTags.Logo}`;
titleEl = document.createElement('div');
titleEl.className = 'spotlight-item-logo';
titleEl.style.backgroundImage = `url("${logoUrl}")`;
titleEl.setAttribute('data-background-url', logoUrl);
if (index === 0) titleEl.style.backgroundImage = `url("${logoUrl}")`;
titleEl.alt = item.Name || 'Unknown';
} else {
// Use text title as fallback
@@ -1799,13 +1944,14 @@
const endTime = new Date(now.getTime() + runtimeMs);
const endTimeEl = document.createElement('span');
endTimeEl.className = 'spotlight-end-time';
endTimeEl.textContent = `Ends at ${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
endTimeEl.textContent = `${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
metadataRow.appendChild(endTimeEl);
}
// Genres (on same line) - make clickable if IDs are available
if (item.Genres && item.Genres.length > 0) {
const genresContainer = document.createElement('span');
const genreFragment = document.createDocumentFragment();
if (item.GenreItems && item.GenreItems.length > 0) {
item.GenreItems.forEach((genreItem, index) => {
@@ -1820,20 +1966,13 @@
genreLink.addEventListener('click', (e) => {
e.stopPropagation();
});
if (index > 0) {
const comma = document.createElement('span');
comma.textContent = ', ';
genresContainer.appendChild(comma);
}
genresContainer.appendChild(genreLink);
genreFragment.appendChild(genreLink);
}
});
}
if (genresContainer.children.length > 0) {
metadataRow.appendChild(genresContainer);
}
metadataRow.appendChild(genreFragment);
}
// Helper function to create truncated list that expands on ellipsis hover
@@ -2038,10 +2177,14 @@
}
} else {
// For Movies, show Tagline (if available)
if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) {
taglineEl.textContent = item.Taglines[0]; // Show first tagline
} else if (item.Overview) {
if (item.Overview && fullScreen) {
taglineEl.textContent = item.Overview;
} else {
if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) {
taglineEl.textContent = item.Taglines[0]; // Show first tagline
} else if (item.Overview) {
taglineEl.textContent = item.Overview;
}
}
}
@@ -2228,7 +2371,8 @@
const clearArtUrl = `${serverAddress}/Items/${item.Id}/Images/Art?fillHeight=300&quality=96&tag=${item.ImageTags.Art}`;
const clearArtEl = document.createElement('img');
clearArtEl.className = 'spotlight-clearart';
clearArtEl.src = clearArtUrl;
clearArtEl.setAttribute('data-src', clearArtUrl);
if (index === 0) clearArtEl.src = clearArtUrl;
clearArtEl.alt = item.Name || 'Unknown';
itemDiv.appendChild(clearArtEl);
}
@@ -2242,6 +2386,12 @@
});
bannerContainer.appendChild(itemsContainer);
// Apply initial pan animation to first slide (helper also sets up completion tracking)
if (panAnimation && items.length > 0) {
const firstItem = bannerContainer.querySelector('.spotlight-item[data-index="0"]');
if (firstItem) applyPanAnimationToSlide(firstItem);
}
// Navigation buttons (top right)
if (showNavButtons && items.length > 1) {
@@ -2320,9 +2470,54 @@
bannerContainer.appendChild(dotsContainer);
}
// Load images for a slide (and optionally preload next). Only sets src/backgroundImage from data-* so browser loads on demand.
function ensureSlideImagesLoaded(slideIndex) {
if (slideIndex < 0 || slideIndex >= items.length) return;
const itemEl = bannerContainer.querySelector(`.spotlight-item[data-index="${slideIndex}"]`);
if (!itemEl) return;
itemEl.querySelectorAll('img[data-src]').forEach(img => {
const url = img.getAttribute('data-src');
if (url && !img.src) img.src = url;
});
const bgUrl = itemEl.getAttribute('data-background-url');
if (bgUrl && !itemEl.style.backgroundImage) itemEl.style.backgroundImage = `url("${bgUrl}")`;
const logoEl = itemEl.querySelector('.spotlight-item-logo[data-background-url]');
if (logoEl) {
const logoUrl = logoEl.getAttribute('data-background-url');
if (logoUrl && !logoEl.style.backgroundImage) logoEl.style.backgroundImage = `url("${logoUrl}")`;
}
}
// Apply pan animation to a slide and track when it completes (for re-trigger on viewport re-entry)
function applyPanAnimationToSlide(slideElement) {
if (!slideElement || !panAnimation) return;
const imgs = slideElement.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img');
if (!imgs.length) return;
currentSlideAnimationComplete = false;
let finishedCount = 0;
const checkAllDone = () => {
finishedCount++;
if (finishedCount >= imgs.length) currentSlideAnimationComplete = true;
};
imgs.forEach(img => {
img.classList.remove('animate');
void img.offsetWidth; // force reflow to restart animation
img.classList.add('animate');
const handler = (e) => {
if (e.animationName === 'kenBurnsZoomOut' || e.animationName === 'kenBurnsZoomIn') {
img.removeEventListener('animationend', handler);
checkAllDone();
}
};
img.addEventListener('animationend', handler);
});
}
// Go to item function (fade transition)
function goToItem(index, resetTimer = true) {
if (index === currentIndex) return;
ensureSlideImagesLoaded(index);
ensureSlideImagesLoaded((index + 1) % items.length);
const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
const nextItem = bannerContainer.querySelector(`.spotlight-item[data-index="${index}"]`);
@@ -2330,6 +2525,7 @@
if (currentItem && nextItem) {
currentItem.style.opacity = '0';
nextItem.style.opacity = '1';
applyPanAnimationToSlide(nextItem);
}
currentIndex = index;
@@ -2354,7 +2550,7 @@
}
}
// Auto-play function
// Auto-play function (only runs when section is visible in viewport)
function startAutoPlay() {
// Always clear any existing timer first
if (autoPlayTimer) {
@@ -2362,7 +2558,8 @@
autoPlayTimer = null;
}
if (!autoPlay || items.length <= 1 || isPaused) return;
if (!autoPlay || items.length <= 1 || isPaused || !isVisible) return;
if (pauseOnHover && isHovering) return;
autoPlayTimer = setInterval(() => {
// Don't reset timer when auto-advancing (to maintain interval)
@@ -2370,11 +2567,11 @@
}, interval);
}
// Pause on hover - prevent auto-cycling when hovering
// Pause on hover - prevent auto-cycling when hovering (skipped when pauseOnHover is false, e.g. fullscreen)
let isHovering = false;
bannerContainer.addEventListener('mouseenter', () => {
isHovering = true;
if (autoPlayTimer) {
if (pauseOnHover && autoPlayTimer) {
clearInterval(autoPlayTimer);
autoPlayTimer = null;
}
@@ -2382,17 +2579,83 @@
bannerContainer.addEventListener('mouseleave', () => {
isHovering = false;
// Only restart if auto-play is enabled, not paused, and not hovering
if (autoPlay && !isPaused && !isHovering) {
// Only restart if auto-play is enabled, not paused, not hovering, and section is visible
if (autoPlay && !isPaused && !isHovering && isVisible) {
startAutoPlay();
}
});
// Start auto-play
// Only cycle when spotlight is in view: stop when off-screen to avoid loading images unnecessarily
const visibilityObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) return;
const wasVisible = isVisible;
isVisible = entry.isIntersecting;
if (!isVisible) {
if (autoPlayTimer) {
clearInterval(autoPlayTimer);
autoPlayTimer = null;
}
} else {
// Re-entering viewport: advance slide (don't restart animation)
if (!wasVisible && autoPlay && !isPaused && !(pauseOnHover && isHovering) && items.length > 1) {
const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
if (panAnimation && currentItem) {
if (currentSlideAnimationComplete) {
// Animation done - cycle to next card immediately
goToItem((currentIndex + 1) % items.length, true);
} else {
// Animation still playing - schedule cycle for when it finishes + interval
const imgs = currentItem.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img');
if (imgs.length > 0) {
let finishedCount = 0;
const checkAllDone = () => {
finishedCount++;
if (finishedCount >= imgs.length) {
currentSlideAnimationComplete = true;
setTimeout(() => {
if (autoPlay && !isPaused && isVisible && items.length > 1) {
goToItem((currentIndex + 1) % items.length, true);
}
}, interval);
}
};
imgs.forEach(img => {
const handler = (e) => {
if (e.animationName === 'kenBurnsZoomOut' || e.animationName === 'kenBurnsZoomIn') {
img.removeEventListener('animationend', handler);
checkAllDone();
}
};
img.addEventListener('animationend', handler);
});
} else {
startAutoPlay();
}
}
} else {
startAutoPlay();
}
} else if (autoPlay && !isPaused && !(pauseOnHover && isHovering)) {
startAutoPlay();
}
}
},
{ root: null, rootMargin: '0px', threshold: 0 }
);
visibilityObserver.observe(container);
// Start auto-play (will no-op if container not yet in DOM or not visible once observer runs)
if (autoPlay) {
startAutoPlay();
}
// Preload next slide soon after mount so transition has no pop
if (items.length > 1) {
requestAnimationFrame(() => ensureSlideImagesLoaded(1));
}
// Touch swipe handling
let touchStartX = 0;
let touchStartY = 0;
@@ -2445,10 +2708,30 @@
}, { passive: true });
container.appendChild(bannerContainer);
if (fullScreen) {
container.dataset.fullScreen = 'true';
}
return container;
}
/**
* Check if a section's items overflow the scroller and set data-expandable for show-all button visibility.
* Can be called for any .emby-scroller-container that contains .itemsContainer (e.g. when section is detected in DOM).
* @param {HTMLElement} verticalSection - Section element (emby-scroller-container)
* @param {boolean} forceUpdate - If true, update even when data-expandable is already set (e.g. on resize)
*/
function checkSectionOverflow(verticalSection, forceUpdate = false) {
if (!verticalSection || verticalSection.nodeType !== 1) return;
const itemsContainer = verticalSection.querySelector('.itemsContainer');
const scroller = verticalSection.querySelector('.emby-scroller');
if (!itemsContainer || !scroller) return;
if (!forceUpdate && verticalSection.hasAttribute('data-expandable')) return;
const hasOverflow = itemsContainer.scrollWidth > scroller.clientWidth;
verticalSection.setAttribute('data-expandable', hasOverflow ? 'true' : 'false');
}
/**
* Creates a scrollable container with horizontal scrolling functionality
* @param {Array} items - Array of Jellyfin item objects
@@ -2812,18 +3095,6 @@
console.log(`[KefinTweaks CardBuilder] Time to switch view: ${performanceTime3}ms`);
});
// Check if items container overflows the scroller width
// Only sets data-expandable if it doesn't already exist (one-time check)
function checkOverflow(forceUpdate = false) {
// Only update if data-expandable doesn't exist, or if forceUpdate is true (window resize)
if (!forceUpdate && verticalSection.hasAttribute('data-expandable')) {
return; // Already set, don't update
}
const hasOverflow = itemsContainer.scrollWidth > scroller.clientWidth;
verticalSection.setAttribute('data-expandable', hasOverflow ? 'true' : 'false');
}
// Track window size to detect actual window resize events
let lastWindowWidth = window.innerWidth;
let lastWindowHeight = window.innerHeight;
@@ -2842,7 +3113,7 @@
// Debounce window resize checks
clearTimeout(windowResizeTimer);
windowResizeTimer = setTimeout(() => {
checkOverflow(true); // Force update on window resize
checkSectionOverflow(verticalSection, true); // Force update on window resize
}, 100); // 100ms debounce
}
}
@@ -2850,13 +3121,7 @@
// Listen for window resize events
window.addEventListener('resize', handleWindowResize);
// Initial overflow check after cards are rendered
// Use requestAnimationFrame to ensure layout is complete, then a small delay for images
requestAnimationFrame(() => {
setTimeout(() => {
checkOverflow(false); // Initial check - only set if doesn't exist
}, 200); // Allow time for images to load and affect card dimensions
});
// Initial overflow check is done when section is detected in the DOM by overflowMutationObserver
// Assemble the section
verticalSection.appendChild(sectionTitleContainer);
@@ -3118,6 +3383,9 @@
// Smart Lazy Image Loading with Global Observers
let lazyImageObserver = null;
let lazyMutationObserver = null;
// Overflow check: run checkSectionOverflow when .emby-scroller-container with .itemsContainer appears in DOM
let overflowMutationObserver = null;
/**
* Initialize the global IntersectionObserver for lazy loading images
@@ -3186,7 +3454,46 @@
});
});
}
/**
* Collect section elements (.emby-scroller-container containing .itemsContainer) from an added node
* @param {Node} node - Added node (element or fragment)
* @returns {HTMLElement[]} - Deduplicated section elements to run overflow check on
*/
function getScrollerSectionsForOverflowCheck(node) {
if (!node || node.nodeType !== 1 || !node.querySelectorAll) return [];
const sections = [];
const selfMatch = node.classList && node.classList.contains('emby-scroller-container') && node.querySelector('.itemsContainer');
if (selfMatch) sections.push(node);
const inner = node.querySelectorAll('.emby-scroller-container');
inner.forEach(el => {
if (el !== node && el.querySelector('.itemsContainer')) sections.push(el);
});
return sections;
}
/**
* Initialize the MutationObserver that runs checkSectionOverflow when sections appear in the DOM
*/
function initOverflowMutationObserver() {
if (overflowMutationObserver) return;
overflowMutationObserver = new MutationObserver((mutations) => {
const sectionsToCheck = new Set();
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
getScrollerSectionsForOverflowCheck(node).forEach(section => sectionsToCheck.add(section));
});
});
sectionsToCheck.forEach(section => {
requestAnimationFrame(() => {
checkSectionOverflow(section, false);
// Re-check after a short delay so images/layout can affect dimensions
setTimeout(() => checkSectionOverflow(section, true), 150);
});
});
});
}
/**
* Initialize the smart lazy loading system
* Scans existing elements and starts both observers
@@ -3199,6 +3506,7 @@
// Initialize observers
initLazyImageObserver();
initLazyMutationObserver();
initOverflowMutationObserver();
// Scan existing elements with data-src
const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]');
@@ -3206,12 +3514,23 @@
lazyImageObserver.observe(img);
});
// Run overflow check on any sections already in the DOM
document.querySelectorAll('.emby-scroller-container').forEach(section => {
if (section.querySelector('.itemsContainer')) {
requestAnimationFrame(() => checkSectionOverflow(section, false));
}
});
// Start watching for new elements
if (document.body) {
lazyMutationObserver.observe(document.body, {
childList: true,
subtree: true
});
overflowMutationObserver.observe(document.body, {
childList: true,
subtree: true
});
} else {
// If body isn't ready, wait for DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
@@ -3221,6 +3540,12 @@
subtree: true
});
}
if (overflowMutationObserver && document.body) {
overflowMutationObserver.observe(document.body, {
childList: true,
subtree: true
});
}
});
}
}
@@ -3282,7 +3607,9 @@
}
function initialize() {
state.useEpisodeImages = window.userHelper.useEpisodeImages();
if (!state.useEpisodeImages) {
state.useEpisodeImages = window.userHelper.useEpisodeImages();
}
}
initialize();
+3 -2
View File
@@ -1198,7 +1198,8 @@
{ key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' },
{ key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' },
{ key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' },
{ key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' }
{ key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' },
{ key: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' }
];
return scriptNames.map(script => {
@@ -4821,7 +4822,7 @@
content: `
<div class="listItemBodyText" style="margin-bottom: 1em;">
Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration.
${canUseClipboard ? '' : ' Clipboard access is not available on this connection, so manual copying is required.'}
${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'}
</div>
<textarea id="exportConfigTextarea" class="fld emby-textarea" rows="20"
readonly
+1 -1
View File
@@ -260,7 +260,7 @@
targetTabButton = await pollForElement(() => document.querySelector(`.headerTabs button[data-index="${currentTabIndex}"]`), 50, 100);
}
if (targetTabButton.classList.contains('emby-tab-button-active')) {
if (targetTabButton && targetTabButton.classList.contains('emby-tab-button-active')) {
LOG('Target tab is already active');
return;
}
+4
View File
@@ -270,4 +270,8 @@ div#discovery-loading-indicator:not(.show)::after {
.pageTabContent:has(.kefinHomeSectionsContainer[data-kefin-home-screen]) .homeSectionsContainer:not([data-kefin-home-screen]) {
display: none !important;
}
.homeSectionsContainer > * {
content-visibility: auto;
}
+169 -135
View File
@@ -336,7 +336,7 @@
if (nextupSectionConfig && nextupSectionConfig.enabled && nextupSectionConfig.queries) {
for (const query of nextupSectionConfig.queries) {
if (query.path || !query.dataSource) {
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress());
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), nextupSectionConfig.renderMode === 'Spotlight');
if (queryUrl && typeof queryUrl === 'string') {
ApiHelper.invalidateCache(queryUrl);
}
@@ -348,7 +348,7 @@
if (continueWatchingSectionConfig && continueWatchingSectionConfig.enabled && continueWatchingSectionConfig.queries) {
for (const query of continueWatchingSectionConfig.queries) {
if (query.path || !query.dataSource) {
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress());
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingSectionConfig.renderMode === 'Spotlight');
if (queryUrl && typeof queryUrl === 'string') {
ApiHelper.invalidateCache(queryUrl);
}
@@ -360,7 +360,7 @@
if (continueWatchingAndNextUpSectionConfig && continueWatchingAndNextUpSectionConfig.enabled && continueWatchingAndNextUpSectionConfig.queries) {
for (const query of continueWatchingAndNextUpSectionConfig.queries) {
if (query.path || !query.dataSource) {
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress());
const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress(), continueWatchingAndNextUpSectionConfig.renderMode === 'Spotlight');
if (queryUrl && typeof queryUrl === 'string') {
ApiHelper.invalidateCache(queryUrl);
}
@@ -374,6 +374,7 @@
* Main Entry Point
*/
async function enhanceHomeScreen() {
LOG('Login detected, continuing with home screen initialization');
LOG('Initializing Home Screen v3...');
performanceTimer.loadTimeStart = performance.now();
performanceMetrics.initialRenderStart = performance.now();
@@ -399,7 +400,7 @@
}
// Create Kefin home sections container as a sibling of the Jellyfin sections container
let kefinHomeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer');
/* let kefinHomeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer');
if (!kefinHomeSectionsContainer) {
kefinHomeSectionsContainer = document.createElement('div');
kefinHomeSectionsContainer.className = 'sections homeSectionsContainer kefinHomeSectionsContainer';
@@ -409,10 +410,10 @@
LOG('Sections container has no parent node; appending Kefin container to body as fallback');
document.body.appendChild(kefinHomeSectionsContainer);
}
}
} */
// If we've already initialized and Jellyfin has completed its render, avoid re-running
if (container.dataset.kefinHomeScreen || kefinHomeSectionsContainer.dataset.kefinHomeScreen) {
if (container.dataset.kefinHomeScreen) {
LOG('Home screen already initialized and Jellyfin has rendered');
return;
}
@@ -429,8 +430,9 @@
performanceStartTime = performance.now();
// Merge configs - flatten groups first
const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []);
const mergedHomeSections = await mergeHomeSectionConfigs(defaultHomeSections);
//const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []);
//const mergedHomeSections = await mergeHomeSectionConfigs(defaultHomeSections);
const mergedHomeSections = await mergeHomeSectionConfigs();
performanceEndTime = performance.now();
performanceDuration = performanceEndTime - performanceStartTime;
@@ -443,8 +445,8 @@
const continueWatchingSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatching');
const continueWatchingAndNextUpSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatchingAndNextUp');
const recentlyAddedSectionConfig = mergedHomeSections.find(s => s.id.startsWith('recently-added-') && s.enabled === true);
state.kefinNextUp = nextupSectionConfig?.enabled === true;
state.kefinContinueWatching = continueWatchingSectionConfig?.enabled === true;
state.kefinNextUp = nextupSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true;
state.kefinContinueWatching = continueWatchingSectionConfig?.enabled === true || continueWatchingAndNextUpSectionConfig?.enabled === true;
state.kefinLatestMedia = recentlyAddedSectionConfig?.enabled === true;
// Setup event listener for UserDataChanged
@@ -461,7 +463,9 @@
// Load user preferences and apply filtering/ordering
performanceStartTime = performance.now();
let userHomeScreenSections = await loadUserHomeScreenSections();
const userDisplayPreferences = await state.getDisplayPrefernces();
let userHomeScreenSections = loadUserHomeScreenSections(userDisplayPreferences);
addUserHomeScreenOrderCSS(userDisplayPreferences);
let filteredHomeSections = mergedHomeSections.map(section => {
const userSection = userHomeScreenSections.find(s => s.id === section.id);
@@ -477,7 +481,7 @@
LOG(`Home Screen v3 Load user home screen sections initialization time: ${performanceDuration.toFixed(2)}ms`);
// Set up mutation observer BEFORE rendering, so we can react when Jellyfin renders home sections
const handleJellyfinRender = async () => {
/* const handleJellyfinRender = async () => {
const jellyHome = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer');
const kefinHome = document.querySelector('.homePage:not(.hide) #homeTab .kefinHomeSectionsContainer');
@@ -512,22 +516,22 @@
if (performanceMetrics.jellyfinDetected) {
LOG(`Home Screen v3 Jellyfin section mirroring duration: ${(performanceMetrics.recoveryRenderEnd - performanceMetrics.jellyfinDetected).toFixed(2)}ms`);
}
};
}; */
let containerForRender = container;
//let containerForRender = container;
if (!container.classList.contains('homeSectionsContainer')) {
/* if (!container.classList.contains('homeSectionsContainer')) {
const observer = observeContainerMutations(container, handleJellyfinRender);
containerForRender = kefinHomeSectionsContainer;
}
} */
// Set flag to indicate we've started initializing this container
containerForRender.dataset.kefinHomeScreen = 'true';
//containerForRender.dataset.kefinHomeScreen = 'true';
// Initial render directly into Kefin container (no waiting for Jellyfin)
performanceStartTime = performance.now();
LOG('Rendering Standard/Seasonal Sections (initial render)...');
await renderHomeSections(filteredHomeSections, containerForRender, false);
await renderHomeSections(filteredHomeSections, container, false);
performanceEndTime = performance.now();
performanceDuration = performanceEndTime - performanceStartTime;
LOG(`Home Screen v3 Render home sections (initial) initialization time: ${performanceDuration.toFixed(2)}ms`);
@@ -538,7 +542,7 @@
LOG(`Home Screen v3 load time initialization: ${(performanceTimer.loadTimeEnd - performanceTimer.loadTimeStart).toFixed(2)}ms`);
LOG('Initializing Discovery Sections...');
await setupDiscoveryInteraction(containerForRender);
setupDiscoveryInteraction(container);
/* // If Jellyfin hasn't rendered after 5 seconds, assume it won't and clean up the observer
setTimeout(() => {
@@ -565,7 +569,27 @@
* Now reads from all 4 groups: HOME, SEASONAL, DISCOVERY, CUSTOM
*/
async function mergeHomeSectionConfigs(defaultSections) {
const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen;
const performanceStartTime = performance.now();
const kefinHomeScreenConfig = await window.KefinHomeScreen.getConfig();
const performanceEndTime = performance.now();
const performanceDuration = performanceEndTime - performanceStartTime;
LOG(`Home Screen v3 Get kefin home screen config initialization time: ${performanceDuration.toFixed(2)}ms`);
return kefinHomeScreenConfig.ENABLED_NORMAL_SECTIONS;
// Return all enabled sections from HOME_SECTION_GROUPS
const homeSections = flattenSectionGroups(kefinHomeScreenConfig.HOME_SECTION_GROUPS).filter(s => s.enabled === true);
// Return all enabled sections from SEASONAL_SECTION_GROUPS that fall within the current date
const seasonalSections = flattenSectionGroups(kefinHomeScreenConfig.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && isInSeasonalPeriod(s.startDate, s.endDate));
// Return all enabled sections from CUSTOM_SECTION_GROUPS that aren't discovery sections and if they have a start/end date that also fall within that date range
const customSections = flattenSectionGroups(kefinHomeScreenConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true));
const mergedSections = [...homeSections, ...seasonalSections, ...customSections];
return mergedSections.sort((a, b) => a.order - b.order);
/* const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen;
const homeScreenConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig;
const merged = [];
@@ -715,7 +739,7 @@
}
// Handle MergeNextUp setting - check if continueWatchingAndNextUp should be enabled
const mergeNextUp = getMergeNextUpSetting();
const mergeNextUp = await getMergeNextUpSetting();
if (mergeNextUp) {
// Find and disable nextUp and continueWatching sections when merged
const nextUpIndex = merged.findIndex(s => s.id === 'nextUp');
@@ -788,7 +812,7 @@
});
}
return filteredMerged;
return filteredMerged; */
}
function getDiscoverySections() {
@@ -871,17 +895,18 @@
/**
* Load user home screen preferences
*/
async function loadUserHomeScreenSections() {
function loadUserHomeScreenSections(userDisplayPreferences) {
try {
//if (!state.userDisplayPreferences) {
// state.userDisplayPreferences = await fetchDisplayPreferences();
//}
const userDisplayPreferences = await state.getDisplayPrefernces();
const customPrefs = userDisplayPreferences?.CustomPrefs || {};
const kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen || '[]');
// Create a homesectionN : order value mapping for the custom CSS orders to be applied
return kefinHomeScreen;
/* // Create a homesectionN : order value mapping for the custom CSS orders to be applied
const jellyfinOrders = {};
for (let i = 0; i <= 8; i++) {
const homeSection = customPrefs[`homesection${i}`];
@@ -895,17 +920,20 @@
if (homeSection.toLowerCase() === 'resume' && state.kefinContinueWatching) {
continue;
}
if (homeSection.toLowerCase() === 'continuewatchingandnextup' && state.kefinNextUp && state.kefinContinueWatching) {
continue;
}
if (homeSection.toLowerCase() === 'latestmedia' && state.kefinLatestMedia) {
continue;
}
// Match to homeSection in kefinHomeScreen with .toLowerCase() id
let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase());
let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase() && s.enabled === true);
if (!kefinHomeScreenSection) {
// Check if it's "latestmedia"
if (homeSection.toLowerCase() === 'latestmedia' && !state.kefinLatestMedia) {
kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase().startsWith('recently-added'));
kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase().startsWith('recently-added') && s.enabled === true);
}
}
@@ -918,7 +946,7 @@
addUserHomeScreenOrderCSS(jellyfinOrders);
return kefinHomeScreen;
return kefinHomeScreen; */
/* if (!Array.isArray(kefinHomeScreen)) {
return { enabledSectionIds: new Set(), orderMap: new Map(), jellyfinOrders: {} };
@@ -954,7 +982,45 @@
/**
* Add CSS for user-defined section ordering
*/
function addUserHomeScreenOrderCSS(jellyfinOrders) {
function addUserHomeScreenOrderCSS(userDisplayPreferences) {
const customPrefs = userDisplayPreferences?.CustomPrefs || {};
const kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen || '[]');
// Create a homesectionN : order value mapping for the custom CSS orders to be applied
const jellyfinOrders = {};
for (let i = 0; i <= 8; i++) {
const homeSection = customPrefs[`homesection${i}`];
if (!homeSection) {
continue;
}
if (homeSection.toLowerCase() === 'nextup' && state.kefinNextUp) {
continue;
}
if (homeSection.toLowerCase() === 'resume' && state.kefinContinueWatching) {
continue;
}
if (homeSection.toLowerCase() === 'latestmedia' && state.kefinLatestMedia) {
continue;
}
// Match to homeSection in kefinHomeScreen with .toLowerCase() id
let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase() && s.enabled === true);
if (!kefinHomeScreenSection) {
// Check if it's "latestmedia"
if (homeSection.toLowerCase() === 'latestmedia' && !state.kefinLatestMedia) {
kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase().startsWith('recently-added') && s.enabled === true);
}
}
if (kefinHomeScreenSection) {
jellyfinOrders[`homesection${i}`] = kefinHomeScreenSection.order;
} else {
jellyfinOrders[`homesection${i}`] = 0;
}
}
// Remove existing style tag if present
const existingStyle = document.getElementById('kefin-user-homescreen-order-css');
@@ -974,7 +1040,7 @@
// Add display: none to all .homeectionN not in jellyfinOrders
for (let i = 0; i <= 8; i++) {
const homesectionKey = `homesection${i}`;
if (!jellyfinOrders[homesectionKey]) {
if (isNaN(jellyfinOrders[homesectionKey])) {
const classSelector = `.${homesectionKey.replace('home','')}`;
css += `${classSelector} { display: none !important; }\n`;
}
@@ -1061,6 +1127,8 @@
let performanceDuration;
const performanceTimes = [];
const mergeNextUp = await getMergeNextUpSetting();
for (const sectionConfig of sortedSections) {
const loopStartTime = performance.now();
if (sectionConfig.enabled === false) {
@@ -1077,19 +1145,19 @@
const existingSection = container.querySelector(`[data-section-id="${sectionConfig.id}"]`);
if (existingSection && !useCache) {
if (existingSection) {
LOG(`Section already rendered: ${sectionConfig.id}`);
continue;
}
// Check merge setting for continueWatchingAndNextUp
if (sectionConfig.id === 'continueWatchingAndNextUp' && !getMergeNextUpSetting()) {
if (sectionConfig.id === 'continueWatchingAndNextUp' && !mergeNextUp) {
LOG(`Skipping continueWatchingAndNextUp: merge not enabled`);
continue;
}
// Skip individual sections when merge is enabled
if (getMergeNextUpSetting() &&
if (mergeNextUp &&
(sectionConfig.id === 'continueWatching' || sectionConfig.id === 'nextUp')) {
LOG(`Skipping ${sectionConfig.id}: merge enabled, using continueWatchingAndNextUp`);
continue;
@@ -1121,13 +1189,13 @@
LOG(`Home Screen v3 Resolve all section promises initialization time: ${performanceDuration.toFixed(2)}ms`);
// Cache the rendered sections for potential re-render after Jellyfin overwrites the container
sectionCache.renderedSections = sectionsToRender.slice();
sectionCache.renderedSections = [...sectionsToRender];
}
const targetContainer = container;
const renderStartTime = performance.now();
await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender);
await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, { waitForContainerClass: 'homeSectionsContainer' });
const renderEndTime = performance.now();
const renderDuration = renderEndTime - renderStartTime;
LOG(`Home Screen v3 Render progressive sections initialization time: ${renderDuration.toFixed(2)}ms`);
@@ -1169,14 +1237,13 @@
return date.toLocaleDateString('en-US', options);
}
function getMergeNextUpSetting() {
const userConfig = window.KefinTweaksConfig?.homeScreen;
const defaultConfig = Config?.MERGE_NEXT_UP;
if (userConfig?.mergeNextUp !== undefined) {
return userConfig.mergeNextUp;
async function getMergeNextUpSetting() {
const config = await window.KefinHomeScreen.getConfig();
const mergeNextUp = config.MERGE_NEXT_UP;
if (mergeNextUp !== undefined) {
return mergeNextUp;
}
return defaultConfig !== false;
return false;
}
let _displayPreferencesPromise = null;
@@ -1185,73 +1252,17 @@
try {
const { promise, cached } = await window.userHelper.getUserDisplayPreferences();
return await promise;
/* if (state.userDisplayPreferences) {
return state.userDisplayPreferences;
}
const { promise, cached } = await window.userHelper.getUserDisplayPreferences();
state.userDisplayPreferences = cached;
_displayPreferencesPromise = promise;
_displayPreferencesPromise.then(data => {
state.userDisplayPreferences = data;
});
return state.userDisplayPreferences; */
} catch (e) {
ERR('Failed to fetch display preferences:', e);
return null;
}
}
async function removeConflictingSection() {
return; // Disabled - kept for compatibility
}
function isNextUpEnabled() {
const userConfig = window.KefinTweaksConfig?.homeScreen;
const homeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []);
const defaultConfig = homeSections.find(s => s.id === 'nextUp');
if (userConfig?.nextUp) {
return userConfig.nextUp.enabled !== false;
}
return defaultConfig?.enabled !== false;
}
function isContinueWatchingEnabled() {
const userConfig = window.KefinTweaksConfig?.homeScreen;
const homeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []);
const defaultConfig = homeSections.find(s => s.id === 'continueWatching');
if (userConfig?.continueWatching) {
return userConfig.continueWatching.enabled !== false;
}
return defaultConfig?.enabled !== false;
}
function isRecentlyAddedEnabled() {
const userConfig = window.KefinTweaksConfig?.homeScreen;
return userConfig?.recentlyAddedInLibrary && Object.keys(userConfig.recentlyAddedInLibrary).length > 0;
}
/**
* DEPRECATED: Use ApiHelper.buildQueryFromSection() instead
* Kept for backward compatibility
*/
function buildQueryFromConfig(query, userId, serverUrl) {
return ApiHelper.buildQueryFromSection(query, userId, serverUrl);
}
/**
* DEPRECATED: Use ApiHelper.buildCustomEndpoint() instead
* Kept for backward compatibility
*/
function buildCustomEndpoint(path, queryOptions, userId) {
return ApiHelper.buildCustomEndpoint(path, queryOptions, userId, ApiClient.serverAddress());
}
/**
* Refactored: Load section for rendering using queries array
*/
@@ -1296,10 +1307,8 @@
// Check renderMode first (preferred), fallback to spotlight boolean for backward compatibility
const shouldRenderSpotlight = sectionConfig.renderMode === 'Spotlight' || sectionConfig.renderMode === 'Random' || sectionConfig.spotlight === true;
if (shouldRenderSpotlight) {
const spotlightConfig = { ...Config.DISCOVERY_SETTINGS.spotlight, ...window.KefinTweaksConfig?.homeScreen?.spotlight };
sectionConfig.spotlightConfig = {
...spotlightConfig
};
const spotlightConfig = { ...window.KefinHomeScreen.getConfig().SPOTLIGHT_SETTINGS, ...sectionConfig.spotlightConfig };
sectionConfig.spotlightConfig = spotlightConfig;
// Set renderMode if not already set (for backward compatibility)
if (!sectionConfig.renderMode && sectionConfig.spotlight) {
sectionConfig.renderMode = 'Spotlight';
@@ -1325,7 +1334,7 @@
queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {});
} else {
// Build and execute query
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl);
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight');
if (typeof queryUrl === 'string') {
// Standard query
@@ -1614,13 +1623,13 @@
const person = await getRandomPerson(config.personType);
return person ? {
id: person.id,
name: person.name,
id: person.Id,
name: person.Name,
metadata: {
Person: person.name,
Actor: person.name,
Director: person.name,
Writer: person.name
Person: person.Name,
Actor: person.Name,
Director: person.Name,
Writer: person.Name
}
} : null;
@@ -1781,6 +1790,8 @@
ensureDiscoveryBuffer();
}
await waitForHomeSectionsContainer(container);
createDiscoveryLoadingIndicator(container);
const useInfiniteScroll = userConfig ? (userConfig.infiniteScroll !== false) : true;
@@ -1813,7 +1824,9 @@
LOG(`Rendering Discovery Group ${state.discoveryGroupIndex + 1} (${bufferedSections.length} sections)...`);
await window.cardBuilder.renderProgressiveSections(container, bufferedSections);
const config = await window.KefinHomeScreen.getConfig();
const revealSectionsSequentially = config.DISCOVERY_SETTINGS?.fadeInSections === true;
await window.cardBuilder.renderProgressiveSections(container, bufferedSections, { revealSectionsSequentially });
if (!getDiscoverySettings().infiniteScroll) {
setupLoadMoreButton(container);
@@ -1992,7 +2005,12 @@
// Add spotlight fields if needed
if (instanceConfig.spotlight || instanceConfig.renderMode === 'Spotlight') {
queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres';
queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres,ParentBackdropImageTags';
const spotlightConfig = instanceConfig.spotlightConfig || {};
const adminSpotlightConfig = window.KefinHomeScreen.getConfig()?.SPOTLIGHT_SETTINGS || {};
instanceConfig.spotlightConfig = { ...adminSpotlightConfig, ...spotlightConfig };
} else {
queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData';
}
@@ -2058,7 +2076,10 @@
return selectedConfigs;
}
function setupInfiniteScroll(container) {
function setupInfiniteScroll() {
const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
if (!container) return;
if (container.dataset.discoveryHandler) return;
container.dataset.discoveryHandler = 'true';
@@ -2221,16 +2242,26 @@
}
async function getRandomPersonFromHistory(personType, sourceType) {
let sourceItems = [];
let sourceItems = [];//window.apiHelper.getQuery(`${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`, { useCache: true, ttl: Config.CACHE.LONG_TTL });
let queryUrl = null;
if (sourceType === 'watched-recent') {
sourceItems = localCache.get('movies') || [];
if (sourceItems.length === 0) return null;
queryUrl = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending&Limit=5`;
/* sourceItems = localCache.get('movies') || [];
if (sourceItems.length === 0) return null; */
sourceItems = sourceItems.sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate)).slice(0, 5);
} else if (sourceType === 'watched') {
sourceItems = localCache.get('movies') || [];
if (sourceItems.length === 0) return null;
sourceItems = sourceItems.sort(() => 0.5 - Math.random()).slice(0, 20);
queryUrl = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&SortBy=Random&SortOrder=Descending`;
/* sourceItems = localCache.get('movies') || [];
if (sourceItems.length === 0) return null;
sourceItems = sourceItems.sort(() => 0.5 - Math.random()).slice(0, 20); */
}
if (queryUrl) {
const response = await window.apiHelper.getQuery(queryUrl, { useCache: true, ttl: Config.CACHE.LONG_TTL });
// Only check the cached data, ignore the promise
sourceItems = response?.data?.Items || response?.data || [];
}
if (!sourceItems.length) return null;
@@ -2243,19 +2274,19 @@
const candidates = item.People.filter(p => p.Type === personType);
if (!candidates.length) continue;
const person = candidates[Math.floor(Math.random() * candidates.length)];
let trackedSet = null;
if (personType === 'Director') trackedSet = state.renderedDiscoveryIds.directors;
else if (personType === 'Actor') trackedSet = state.renderedDiscoveryIds.actors;
else if (personType === 'Writer') trackedSet = state.renderedDiscoveryIds.writers;
if (trackedSet && !trackedSet.has(person.Id)) {
trackedSet.add(person.Id);
return {
person: { id: person.Id, name: person.Name },
sourceItem: item
};
for (const person of candidates) {
let trackedSet = null;
if (personType === 'Director') trackedSet = state.renderedDiscoveryIds.directors;
else if (personType === 'Actor') trackedSet = state.renderedDiscoveryIds.actors;
else if (personType === 'Writer') trackedSet = state.renderedDiscoveryIds.writers;
if (trackedSet && !trackedSet.has(person.Id)) {
trackedSet.add(person.Id);
return {
person: { id: person.Id, name: person.Name },
sourceItem: item
};
}
}
}
@@ -2264,7 +2295,6 @@
async function getRandomPerson(type) {
if (!PeopleCache) return null;
PeopleCache.init();
const peopleData = await PeopleCache.getTopPeople();
if (!peopleData) return null;
@@ -2285,11 +2315,13 @@
if (!list || !list.length || !trackedSet) return null;
const valid = list.filter(p => !trackedSet.has(p.id));
const valid = list.filter(p => !trackedSet.has(p.Id));
if (!valid.length) return null;
const selected = valid[Math.floor(Math.random() * valid.length)];
trackedSet.add(selected.id);
if (selected && selected.Id) {
trackedSet.add(selected.Id);
}
return selected;
}
@@ -2340,10 +2372,12 @@
window.homeScreen3 = { init: enhanceHomeScreen };
//enhanceHomeScreen();
if (window.KefinTweaksUtils && typeof window.KefinTweaksUtils.onViewPage === 'function') {
window.KefinTweaksUtils.onViewPage((view, element, hash) => {
// Get selected tab from hash
const hashParams = hash.includes('?') ? hash.split('?')[1] : '';
const hashParams = hash.includes('?') ? hash.split('?')[1] : '';
const urlParams = new URLSearchParams(hashParams);
const currentTab = urlParams.get('tab');
const currentTabIndex = currentTab ? parseInt(currentTab, 10) : 0;
+126 -1
View File
@@ -84,8 +84,133 @@
"type": "custom"
}
]
},
{
"name": "Genre Sections",
"author": "HighImKevin",
"sections": [
{
"name": "Popular Movie Genres",
"enabled": true,
"order": 22,
"discoveryEnabled": false,
"renderMode": "Normal",
"cardFormat": "Thumb",
"queries": [
{
"path": "/Genres",
"queryOptions": {
"IncludeItemTypes": [
"Movie"
],
"SortBy": "ChildCount",
"SortOrder": "Descending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1768961093582",
"ttl": 86400000,
"type": "custom"
},
{
"name": "Popular TV Genres",
"enabled": true,
"order": 100,
"discoveryEnabled": false,
"renderMode": "Normal",
"cardFormat": "Thumb",
"queries": [
{
"path": "/Genres",
"queryOptions": {
"IncludeItemTypes": [
"Series"
],
"SortBy": "ChildCount",
"SortOrder": "Descending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1770808132827",
"ttl": 86400000,
"type": "custom"
}
]
},
{
"name": "Prolific People [Discovery]",
"author": "HighImKevin",
"sections": [
{
"name": "Prolific Writers",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"dataSource": "PeopleCache.getTopWriters",
"queryOptions": {
"SortBy": "Random",
"SortOrder": "Ascending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1770810264918",
"type": "custom"
},
{
"name": "Prolific Directors",
"enabled": true,
"order": 100,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"dataSource": "PeopleCache.getTopDirectors",
"queryOptions": {
"SortBy": "Random",
"SortOrder": "Ascending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1770810234128",
"type": "custom"
},
{
"name": "Prolific Actors",
"enabled": true,
"order": 250,
"discoveryEnabled": true,
"renderMode": "Normal",
"cardFormat": "Poster",
"queries": [
{
"dataSource": "PeopleCache.getTopActors",
"queryOptions": {
"SortBy": "Random",
"SortOrder": "Ascending",
"Limit": 20
}
}
],
"flattenSeries": false,
"id": "custom-1768973523242",
"type": "custom"
}
]
}
// Add more collections here
// Add more collections here
];
// Expose to window
+16 -10
View File
@@ -502,7 +502,7 @@
startDate: '02-07',
endDate: '02-14',
order: 51,
cardFormat: 'Poster',
cardFormat: 'Thumb',
queries: [
{
queryOptions: {
@@ -808,14 +808,8 @@
defaultCardFormat: "Poster",
spotlightDiscoveryChance: 0.5,
renderSpotlightAboveMatching: false,
spotlight: {
autoPlay: true,
interval: 5000,
showDots: true,
showNavButtons: true,
showClearArt: true
},
randomizeOrder: false
randomizeOrder: false,
fadeInSections: false,
}
const SEASONAL_THEME_SETTINGS = {
@@ -843,6 +837,17 @@
}
const HOME_SETTINGS = {
minimumSeriesForPopularTVNetworks: 10,
fadeInSections: false,
ensureThumbsForPopularTVNetworks: false,
}
const SPOTLIGHT_SETTINGS = {
autoPlay: true,
interval: 10000,
showDots: true,
showNavButtons: true,
showClearArt: false,
panAnimation: true
}
// Expose configuration
@@ -856,7 +861,8 @@
MERGE_NEXT_UP: false,
HOME_SETTINGS: HOME_SETTINGS,
DISCOVERY_SETTINGS: DISCOVERY_SETTINGS,
SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS
SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS,
SPOTLIGHT_SETTINGS: SPOTLIGHT_SETTINGS
};
console.log('[KefinTweaks HomeConfig2] Configuration loaded');
+45 -47
View File
@@ -14,6 +14,7 @@
let moviesTopPeople = null;
let isInitializing = false;
let isComplete = false;
let isFetchingTopPeople = false;
/**
* Extracts necessary item data for caching (similar to watchlist format)
@@ -52,8 +53,7 @@
const key = person.Id;
if (!peopleMap.has(key)) {
peopleMap.set(key, {
id: person.Id,
name: person.Name,
...person,
directorCount: 0,
writerCount: 0,
actorCount: 0,
@@ -110,10 +110,8 @@
const topDirectors = allPeople
.filter(person => person.directorCount >= minAppearances)
.sort((a, b) => b.directorCount - a.directorCount)
.slice(0, 100)
.map(person => ({
id: person.id,
name: person.name,
...person,
count: person.directorCount,
items: person.directorItems || []
}));
@@ -121,10 +119,8 @@
const topWriters = allPeople
.filter(person => person.writerCount >= minAppearances)
.sort((a, b) => b.writerCount - a.writerCount)
.slice(0, 100)
.map(person => ({
id: person.id,
name: person.name,
...person,
count: person.writerCount,
items: person.writerItems || []
}));
@@ -132,10 +128,8 @@
const topActors = allPeople
.filter(person => person.actorCount >= minAppearances)
.sort((a, b) => b.actorCount - a.actorCount)
.slice(0, 100)
.map(person => ({
id: person.id,
name: person.name,
...person,
count: person.actorCount,
items: person.actorItems || []
}));
@@ -155,14 +149,16 @@
* Fetches and processes top people data from all movies using pagination
*/
async function fetchTopPeople() {
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
const userId = apiClient.getCurrentUserId();
const indexedDBCache = window.IndexedDBCache;
if (isFetchingTopPeople) return;
isFetchingTopPeople = true;
try {
LOG('Starting paginated fetch of movies for people data processing...');
const apiClient = window.ApiClient;
const serverUrl = apiClient.serverAddress();
const token = apiClient.accessToken();
const userId = apiClient.getCurrentUserId();
const indexedDBCache = window.IndexedDBCache;
let startIndex = 0;
const limit = 500;
@@ -181,17 +177,11 @@
}
while (hasMoreData) {
const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`;
const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=People,UserData&Limit=${limit}&StartIndex=${startIndex}`;
LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`);
const response = await fetch(url, {
headers: { 'Authorization': `MediaBrowser Token="${token}"` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const data = await window.apiHelper.getQuery(url);
const movies = data.Items || [];
if (movies.length === 0) {
@@ -237,6 +227,8 @@
} catch (err) {
ERR('Failed to fetch top people data:', err);
return null;
} finally {
isFetchingTopPeople = false;
}
}
@@ -246,39 +238,45 @@
async function initialize() {
if (isInitializing) return;
if (moviesTopPeople !== null && isComplete) {
return;
}
const indexedDBCache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
// Check for valid complete filtered data
const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId);
if (validComplete) {
const cachedData = await indexedDBCache.get('movies_top_people', userId);
if (cachedData && cachedData.isComplete) {
moviesTopPeople = cachedData;
isComplete = true;
LOG('Loaded complete top people data from IndexedDB');
return;
}
}
// Initialize background fetch
isInitializing = true;
try {
await fetchTopPeople();
if (moviesTopPeople !== null && isComplete) {
return;
}
const indexedDBCache = window.IndexedDBCache;
const userId = window.ApiClient.getCurrentUserId();
// Check for valid complete filtered data
const validComplete = await indexedDBCache.isCacheValid('movies_top_people', userId);
if (validComplete) {
const cachedData = await indexedDBCache.get('movies_top_people', userId);
if (cachedData && cachedData.isComplete) {
moviesTopPeople = cachedData;
isComplete = true;
LOG('Loaded complete top people data from IndexedDB');
return;
}
}
fetchTopPeople();
} finally {
isInitializing = false;
}
}
async function getTopPeople() {
if (!moviesTopPeople) {
await initialize();
LOG('Getting top people data');
try {
if (!moviesTopPeople) {
await initialize();
}
return moviesTopPeople;
} catch (err) {
ERR('Failed to get top people data:', err);
return null;
}
return moviesTopPeople;
}
async function getTopActors() {