mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user