From 98b6e35819784e86849f6dbd4dd03b2b486e5f6f Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Thu, 12 Feb 2026 08:01:54 -0500 Subject: [PATCH] More improvements made to the Enhanced Home Screen: Improved Spotlight rendering and options, Rendering only in native Jellyfin container, batch rendering progressive sections --- configuration/homeScreen-configuration.js | 438 ++++++++++++---- .../homeScreen-user-configuration.js | 31 +- scripts/apiHelper.js | 72 ++- scripts/cardBuilder.css | 330 +++++++++++- scripts/cardBuilder.js | 485 +++++++++++++++--- scripts/configuration.js | 5 +- scripts/headerTabs.js | 2 +- scripts/homeScreen.css | 4 + scripts/homeScreen3.js | 304 ++++++----- scripts/homeScreenConfig-community.js | 127 ++++- scripts/homeScreenConfig2.js | 26 +- scripts/peopleCache.js | 92 ++-- 12 files changed, 1530 insertions(+), 386 deletions(-) diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index 3164378..4dd4c5e 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -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} 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 ` +
+ ${tabs.map(tab => ` + + `).join('')} +
+ `; + } + + /** + * 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 '
No configuration loaded.
'; - // 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 @@ - +
-
- -
- General Settings -
- ${buildGeneralSettingsHTML()} -
-
- - -
- Discovery Settings -
- ${buildDiscoverySettingsHTML()} -
-
- - -
- Cache Settings -
- ${buildCacheSettingsHTML()} -
-
+
+ +
+ ${buildGlobalSettingsNavigationHTML(currentGlobalSettingsSubTab)} +
+ +
+ ${buildGlobalSettingsPanelContent(currentGlobalSettingsSubTab)} +
@@ -1739,17 +1800,14 @@ function buildGeneralSettingsHTML() { if (!currentConfig) return ''; const seasonal = currentConfig.SEASONAL_THEME_SETTINGS || {}; + const homeSettings = currentConfig.HOME_SETTINGS || {}; return ` -
-
- ${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('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 })}
`; } @@ -1762,19 +1820,18 @@ const discovery = currentConfig.DISCOVERY_SETTINGS || {}; return ` -
-
- ${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')} -
+
+ ${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')}
`; } @@ -1808,20 +1865,71 @@ const cache = currentConfig.CACHE || {}; return ` -
-
-
TTL values in minutes
- ${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')} -
+
TTL values in minutes
+
+ ${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')}
`; } + /** + * 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 ` +
Default behavior for spotlight sections on the home screen. Per-section overrides can be set when editing a section.
+
+ ${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')} +
+ `; + } + + /** + * 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 ` +
+
Spotlight Options
+
+ ${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')} +
+
+ `; + } + /** * 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 `
@@ -2080,7 +2197,10 @@ ${basicPropertiesHTML}
${additionalFieldsHTML} + + ${spotlightOptionsHTML} ${!isDefaultSection ? ` +
@@ -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 @@
${commonFields.map(field => `
- ${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 || '')}
`).join('')}
@@ -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 `
@@ -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')} + + ${discoverySpotlightHTML}
`; } @@ -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}`); } } diff --git a/configuration/homeScreen-user-configuration.js b/configuration/homeScreen-user-configuration.js index a124036..27122aa 100644 --- a/configuration/homeScreen-user-configuration.js +++ b/configuration/homeScreen-user-configuration.js @@ -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 diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index 3a822c0..b6cc005 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -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 diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index db5f077..ce5ca24 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -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; +} \ No newline at end of file diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 5929a8a..1cdb3e6 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -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(); diff --git a/scripts/configuration.js b/scripts/configuration.js index 39c87a2..6918b19 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -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: `
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.'}