diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index 9199bc2..77963d2 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -238,6 +238,53 @@ } } + /** + * Whether today falls in an MM-DD (or M-D) seasonal window. + * Supports year-wrap when end < start (e.g. 12-20 … 01-05). + * @param {string} startDate + * @param {string} endDate + * @returns {boolean} + */ + function isInSeasonalPeriod(startDate, endDate) { + if (startDate == null || endDate == null || startDate === '' || endDate === '') { + return false; + } + const parseMonthDay = (value) => { + const parts = String(value).trim().split(/[-/.]/).map((p) => parseInt(p, 10)); + if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null; + let month; + let day; + // YYYY-MM-DD → take month/day + if (parts.length >= 3 && parts[0] > 31) { + month = parts[1]; + day = parts[2]; + } else { + month = parts[0]; + day = parts[1]; + } + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return { month, day }; + }; + const startMd = parseMonthDay(startDate); + const endMd = parseMonthDay(endDate); + if (!startMd || !endMd) return false; + + const now = new Date(); + const year = now.getFullYear(); + const start = new Date(year, startMd.month - 1, startMd.day, 0, 0, 0, 0); + let end = new Date(year, endMd.month - 1, endMd.day, 23, 59, 59, 999); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return false; + + if (end < start) { + if (now >= start) { + end.setFullYear(year + 1); + } else { + start.setFullYear(year - 1); + } + } + return now >= start && now <= end; + } + /** * Collect discovery-enabled sections from custom groups, stamping pageNumber * per group (1..N among discovery sections only, in group order). @@ -1435,18 +1482,6 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; // 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)); @@ -1490,18 +1525,6 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; const config = getConfig(); - 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(config.HOME_SECTION_GROUPS).filter(s => (s.enabled || s.userConfigurable || s.userConfigurable === undefined) && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const enabledSeasonalSections = flattenSectionGroups(config.SEASONAL_SECTION_GROUPS).filter(s => (s.enabled || s.userConfigurable || s.userConfigurable === undefined) && s.discoveryEnabled !== true && isInSeasonalPeriod(s.startDate, s.endDate)); const enabledCustomSections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS).filter(s => (s.enabled || s.userConfigurable || s.userConfigurable === undefined) && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); @@ -5751,6 +5774,7 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`; // Export getConfig for use by other scripts window.KefinHomeScreen = window.KefinHomeScreen || {}; + window.KefinHomeScreen.isInSeasonalPeriod = isInSeasonalPeriod; window.KefinHomeScreen.getConfig = getConfig; window.KefinHomeScreen.getSections = getSections; window.KefinHomeScreen.fetchSectionPreviewItems = fetchSectionPreviewItems; diff --git a/configuration/homeScreen-user-configuration.js b/configuration/homeScreen-user-configuration.js index a73c77c..661422e 100644 --- a/configuration/homeScreen-user-configuration.js +++ b/configuration/homeScreen-user-configuration.js @@ -201,19 +201,21 @@ } function appendBoolPrefField(fields, value, serverValue) { + // Persist defined bools even when equal to server default so user choice survives reload. if (value === undefined) { fields.push(''); return; } - const normalized = value === true; - const serverNormalized = serverValue === true; - fields.push(normalized === serverNormalized ? '' : (normalized ? 'true' : 'false')); + fields.push(value === true ? 'true' : 'false'); } function appendPrefField(fields, value, serverValue) { - const normalized = value == null ? '' : String(value); - const serverNormalized = serverValue == null ? '' : String(serverValue); - fields.push(normalized !== '' && normalized !== serverNormalized ? normalized : ''); + // Persist defined overrides even when equal to server default so user choice survives reload. + if (value == null || value === '') { + fields.push(''); + return; + } + fields.push(String(value)); } function isCustomSectionForPref(section) { @@ -946,9 +948,28 @@ }; } + function resolveSpotlightPresentationLocal(options = {}) { + if (typeof window !== 'undefined' && window.cardBuilder?.resolveSpotlightPresentation) { + return window.cardBuilder.resolveSpotlightPresentation({ + ...options, + capForViewport: false + }); + } + const fullScreen = options.fullScreen === true; + const layout = options.spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border'); + const size = options.spotlightSize ?? (fullScreen ? 'full' : 'normal'); + let tileCount = options.tileCount; + if (tileCount == null || tileCount < 1 || tileCount > 3) { + tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3; + } + tileCount = Math.max(1, Math.min(3, Math.floor(tileCount))); + return { layout, size, tileCount }; + } + function getServerSectionDefaults(section) { if (!section) return {}; const spotlight = section.spotlightConfig || {}; + const resolved = resolveSpotlightPresentationLocal(spotlight); const id = String(section.id || ''); const isPinned = id.startsWith(PINNED_LIST_ID_PREFIX) || id.startsWith(PINNED_PARENT_ID_PREFIX); return { @@ -963,9 +984,9 @@ cardTitlePosition: section.cardTitlePosition || '', borderStyle: section.borderStyle || '', borderColor: section.borderColor || '', - spotlightLayout: spotlight.spotlightLayout ?? 'Border', - spotlightSize: spotlight.spotlightSize ?? 'normal', - spotlightTileCount: parseInt(spotlight.tileCount, 10) || 1, + spotlightLayout: resolved.layout, + spotlightSize: resolved.size, + spotlightTileCount: resolved.tileCount, itemsLayout: 'row', useGaplessCards: false, cardTitleCapitalization: section.cardTitleCapitalization || 'normal', diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 7b5da13..3cc898c 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -134,6 +134,22 @@ mask-position: 0 0; } +.spotlight-section[data-size="large"] .spotlight-background-single, +.spotlight-section[data-size="large"] .spotlight-background-dual { + height: 140%; + transform: translateY(-10%); + -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 4), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); + mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 4), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); +} + +.spotlight-section[data-size="full"] .spotlight-background-single, +.spotlight-section[data-size="full"] .spotlight-background-dual { + height: 125%; + transform: translateY(-5%); + -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); + mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% ); +} + .spotlight-background-single img { display: block; width: 100%; @@ -844,17 +860,21 @@ max-width: 400px; min-width: 300px; opacity: 0; + visibility: hidden; pointer-events: none; - transition: opacity 0.2s ease; + transition: opacity 0.2s ease, visibility 0s linear 0.2s; transition-delay: 0s; z-index: 1000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); word-wrap: break-word; } -.spotlight-info-button:hover .spotlight-overview-tooltip { +/* Show only while hovering the info icon glyph — not the button/tooltip footprint */ +.spotlight-info-button:has(.material-icons:hover) .spotlight-overview-tooltip { opacity: 1; - transition-delay: 0.5s; + visibility: visible; + pointer-events: none; + transition-delay: 0.5s, 0.5s; } /* Hover overview only on Normal desktop spotlights */ @@ -864,7 +884,7 @@ display: none !important; } -.spotlight-section:not(.hide)[data-section-id] .spotlight-nav-container { +.spotlight-section:not(.hide)[data-section-id] > .spotlight-banner-container > .spotlight-nav-container { position: absolute !important; display: flex; gap: 8px; @@ -1677,17 +1697,17 @@ } /* Tagline at bottom center above navigation dots (all spotlights) - always 1 line max */ -.spotlight-tagline { +.spotlight-section:not(.hide) > .spotlight-banner-container .spotlight-tagline { position: absolute !important; - left: 50%; - transform: translateX(-50%); - width: fit-content; + left: 50% !important; + transform: translateX(-50%) !important; + width: fit-content !important; max-width: min(85%, 700px); - text-align: center; + text-align: center !important; font-style: italic; font-size: 1.05em; - margin: 0; - padding: 8px 16px; + margin: 0 !important; + padding: 0 !important; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; @@ -1695,7 +1715,7 @@ border-radius: 6px; z-index: 4; pointer-events: none; - line-height: 1.4; + height: unset !important; } .spotlight-section:not(.hide) .spotlight-tagline p { font-weight: initial !important; @@ -1913,6 +1933,10 @@ align-items: center; } +.spotlight-metadata-row .emby-tab-button { + color: #ececec; +} + :where(.spotlight-section-title.headerTabs.sectionTabs), :where(.spotlight-section .headerTabs.sectionTabs), :where(.spotlight-nav-container.headerTabs.sectionTabs) { diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 97bb821..be8d201 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -703,7 +703,7 @@ const pos = getScrollerPosition(scroller); const maxPosition = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)); - leftButton.disabled = pos === 0; + leftButton.disabled = pos <= 20; rightButton.disabled = pos >= maxPosition; } @@ -1562,6 +1562,7 @@ ...sectionConfig, cardFormat: finalCardFormat, }); + unobserveSkeletonShimmer(sectionElement); sectionElement.dataset.refreshing = 'false'; ensureSectionRevealObservation(sectionElement); return sectionElement; @@ -1919,6 +1920,7 @@ ...sectionConfig, cardFormat: finalCardFormat, }); + unobserveSkeletonShimmer(sectionElement); sectionElement.dataset.refreshing = 'false'; ensureSectionRevealObservation(sectionElement); const newButton = sectionElement.querySelector('.section-refresh-button'); @@ -2122,6 +2124,7 @@ ensureCardBorders: ensureCardBorders, applyItemsLayoutState: applyItemsLayoutState, resolveItemsLayout: resolveItemsLayout, + resolveSpotlightPresentation: resolveSpotlightPresentation, resolveUseGaplessCards: resolveUseGaplessCards, invalidateLastRowPadding: invalidateLastRowPadding, invalidateCardRadiusCache: invalidateCardRadiusCache, @@ -3931,7 +3934,8 @@ buttonContainer.appendChild(moreButton); - if (item.Type !== 'CollectionFolder' && item.Type !== 'Folder' && item.Type !== 'UserView' && item.LocationType !== 'Virtual') { + const NON_PLAYABLE_TYPES = ['CollectionFolder', 'Folder', 'UserView', 'Virtual', 'Studio']; + if (!NON_PLAYABLE_TYPES.includes(item.Type) && item.LocationType !== 'Virtual') { cardOverlayContainer.appendChild(playButton); } cardOverlayContainer.appendChild(buttonContainer); @@ -4189,6 +4193,37 @@ * @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen) * @returns {HTMLElement} - The constructed spotlight container */ + /** + * Resolve spotlight layout/size/tileCount from options (incl. legacy fullScreen). + * Shared by live create, skeleton create, and configure/defaults callers. + * @param {Object} [options] + * @param {boolean} [options.capForViewport=true] - Cap tileCount by viewport width + * @returns {{ layout: string, size: string, tileCount: number }} + */ + function resolveSpotlightPresentation(options = {}) { + const fullScreen = options.fullScreen === true; + const layout = options.spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border'); + const size = options.spotlightSize ?? (fullScreen ? 'full' : 'normal'); + let tileCount = options.tileCount; + if (tileCount == null || tileCount < 1 || tileCount > 3) { + tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3; + } + tileCount = Math.max(1, Math.min(3, Math.floor(tileCount))); + if (options.capForViewport !== false) { + const vw = window.innerWidth || 0; + if (vw < 900) tileCount = Math.min(tileCount, 1); + else if (vw < 1600) tileCount = Math.min(tileCount, 2); + } + return { layout, size, tileCount }; + } + + /** + * Creates a Netflix-style slim banner carousel (spotlight) + * @param {Array} itemsInput - Array of Jellyfin item objects + * @param {string} title - Title for the spotlight section + * @param {Object} options - Options for the spotlight carousel + * @returns {HTMLElement} - The constructed spotlight container + */ function createSpotlightSection(itemsInput, title, options = {}) { let items = Array.isArray(itemsInput) ? itemsInput.slice() : []; if (!items.length) { @@ -4203,8 +4238,6 @@ showNavButtons = true, showClearArt = false, panAnimation = true, - spotlightLayout, - spotlightSize, tileCount: tileCountOpt, cycleBackdrops = false, cycleBackdropsTime, @@ -4220,19 +4253,10 @@ /** Build DOM only — no timers/observers (for harvesting slides during in-place refresh). */ inert = false, } = options; - // Backward compat: derive layout/size from fullScreen if new keys missing - const fullScreen = options.fullScreen === true; - const layout = spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border'); - const size = spotlightSize ?? (fullScreen ? 'full' : 'normal'); - let tileCount = tileCountOpt; - if (tileCount == null || tileCount < 1 || tileCount > 3) { - tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3; - } - tileCount = Math.max(1, Math.min(3, Math.floor(tileCount))); - // Cap multi-tile layouts on narrower viewports - const vw = window.innerWidth || 0; - if (vw < 900) tileCount = Math.min(tileCount, 1); - else if (vw < 1600) tileCount = Math.min(tileCount, 2); + const { layout, size, tileCount } = resolveSpotlightPresentation({ + ...options, + tileCount: tileCountOpt, + }); let backdropsCount = backdropsCountOpt; if (backdropsCount == null && cycleBackdropsTime > 0 && interval > 0) { backdropsCount = Math.max(1, Math.round(interval / cycleBackdropsTime)); @@ -7180,9 +7204,8 @@ * @returns {HTMLElement} - Skeleton spotlight container */ function createSkeletonSpotlightSection(title, options = {}) { - const { viewMoreUrl = null, spotlightLayout, spotlightSize, fullScreen, discoveryPending = false, caption = null } = options; - const layout = spotlightLayout ?? (fullScreen === true ? 'Borderless' : 'Border'); - const size = spotlightSize ?? (fullScreen === true ? 'full' : 'normal'); + const { viewMoreUrl = null, discoveryPending = false, caption = null } = options; + const { layout, size } = resolveSpotlightPresentation(options); const captionText = caption && String(caption).trim(); const useSkeletonTitle = discoveryPending === true; @@ -7790,7 +7813,7 @@ } else { maxScroll = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)); } - leftButton.disabled = scrollX <= 0; + leftButton.disabled = scrollX <= 20; rightButton.disabled = scrollX >= maxScroll - 1; } @@ -8245,11 +8268,26 @@ if (scroller.classList.contains('scrollX')) { scroller.scrollTo({ left: clamped, behavior: animate ? 'smooth' : 'auto' }); setStoredScrollPosition(scroller, clamped); - return; + } else { + scroller.style.transition = animate ? 'transform 400ms ease-out' : 'none'; + scroller.style.transform = `translateX(-${clamped}px)`; + setStoredScrollPosition(scroller, clamped); + } + + const section = scroller.closest('.emby-scroller-container'); + if (section) { + if (animate) { + const onTransitionEnd = () => { + scroller.removeEventListener('transitionend', onTransitionEnd); + applyScrollButtonState(section); + }; + scroller.addEventListener('transitionend', onTransitionEnd); + // Native scrollX has no transitionend — refresh after a frame too + requestAnimationFrame(() => applyScrollButtonState(section)); + } else { + applyScrollButtonState(section); + } } - scroller.style.transition = animate ? 'transform 400ms ease-out' : 'none'; - scroller.style.transform = `translateX(-${clamped}px)`; - setStoredScrollPosition(scroller, clamped); } /** diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index 90af9d4..2d427c7 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -533,14 +533,31 @@ html:not([data-active-page="home"]) #kefin-home-create-section-btn { height: auto !important; } +.sections.homeSectionsContainer[data-category="discovery"] { + position: relative; +} + .sections.homeSectionsContainer[data-category="discovery"]::before { - content: "\E87A\00a0 Discover Something To Watch"; - font-family: "Material Icons", inherit; + content: "\E87A"; + font-family: "Material Icons"; padding-left: max(env(safe-area-inset-left), 3.3%); font-size: 1.5rem; margin: 0; padding-bottom: .2em; padding-top: 1em; + display: block; + line-height: 1.2; +} + +.sections.homeSectionsContainer[data-category="discovery"]::after { + content: "Discover Something To Watch"; + font-family: inherit; + font-size: 1.5rem; + position: absolute; + left: calc(max(env(safe-area-inset-left), 3.3%) + 1.7em); + top: 1em; + line-height: 1.2; + pointer-events: none; } html:not([data-active-page="home"]) .snowverlay { diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index e1a4837..bb30181 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -875,14 +875,7 @@ return section; }); - const isInSeasonalPeriod = (startDate, endDate) => { - const currentDate = new Date(); - const start = new Date(startDate); - const end = new Date(endDate); - start.setFullYear(currentDate.getFullYear()); - end.setFullYear(currentDate.getFullYear()); - return currentDate >= start && currentDate <= end; - }; + const isInSeasonalPeriodLocal = (startDate, endDate) => isInSeasonalPeriod(startDate, endDate); const customGroups = homeScreenConfig?.CUSTOM_SECTION_GROUPS || window.KefinTweaksConfig?.homeScreenConfig?.CUSTOM_SECTION_GROUPS @@ -891,7 +884,7 @@ const customDiscoverySections = typeof collect === 'function' ? collect(customGroups, { isSectionActive: (s) => s.enabled === true, - isInSeasonalPeriod + isInSeasonalPeriod: isInSeasonalPeriodLocal }) : []; @@ -1521,6 +1514,7 @@ // Collect enabled templates unresolved — progressive resolve happens in ensureDiscoveryBuffer for (const template of discoveryTemplates) { if (template.enabled === false) continue; + if (window.sectionHelper?.isEmptyDiscoverySourceTemplate?.(template)) continue; selectedConfigs.push(template); } @@ -1746,14 +1740,43 @@ } function isInSeasonalPeriod(start, end) { + if (typeof window.KefinHomeScreen?.isInSeasonalPeriod === 'function' + && window.KefinHomeScreen.isInSeasonalPeriod !== isInSeasonalPeriod) { + return window.KefinHomeScreen.isInSeasonalPeriod(start, end); + } + if (start == null || end == null || start === '' || end === '') return false; + const parseMonthDay = (value) => { + const parts = String(value).trim().split(/[-/.]/).map((p) => parseInt(p, 10)); + if (parts.length < 2 || parts.some((n) => !Number.isFinite(n))) return null; + let month; + let day; + if (parts.length >= 3 && parts[0] > 31) { + month = parts[1]; + day = parts[2]; + } else { + month = parts[0]; + day = parts[1]; + } + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return { month, day }; + }; + const startMd = parseMonthDay(start); + const endMd = parseMonthDay(end); + if (!startMd || !endMd) return false; + const now = new Date(); - const currentYear = now.getFullYear(); - const startDate = new Date(`${start}-${currentYear}`); - - let endDate = new Date(`${end}-${currentYear}`); - if (endDate < startDate) endDate.setFullYear(currentYear + 1); - else if (endDate > startDate) endDate.setFullYear(currentYear); + const year = now.getFullYear(); + const startDate = new Date(year, startMd.month - 1, startMd.day, 0, 0, 0, 0); + let endDate = new Date(year, endMd.month - 1, endMd.day, 23, 59, 59, 999); + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return false; + if (endDate < startDate) { + if (now >= startDate) { + endDate.setFullYear(year + 1); + } else { + startDate.setFullYear(year - 1); + } + } return now >= startDate && now <= endDate; } diff --git a/scripts/homeScreenSectionConfigure.js b/scripts/homeScreenSectionConfigure.js index f6a5267..fd45136 100644 --- a/scripts/homeScreenSectionConfigure.js +++ b/scripts/homeScreenSectionConfigure.js @@ -378,19 +378,47 @@ } function sectionHasUserOverrides(entry, serverDefaults) { - const api = getConfigApi(); const cfg = entry?.effectiveConfig; const defaults = serverDefaults || entry?.serverDefaults || {}; - if (!api?.serializeSectionPref || !cfg) return false; - const prefStr = api.serializeSectionPref( - resolveStoredSectionPrefId(entry.sectionId, cfg), - cfg.enabled !== false, - buildPrefOverrides(cfg), - defaults - ); - const parts = String(prefStr || '').split(';'); - if (parts[1] === 'false') return true; - return parts.slice(2).some((part) => part !== ''); + if (!cfg) return false; + if (cfg.enabled === false) return true; + + const overrides = buildPrefOverrides(cfg); + const norm = (v) => (v == null || v === '' ? '' : String(v)); + const boolEq = (a, b) => (a === true) === (b === true); + + const layout = overrides.itemsLayout === 'grid' || overrides.itemsLayout === 'row' + ? overrides.itemsLayout + : (overrides.gridExpanded === true ? 'grid' : 'row'); + const serverLayout = defaults.itemsLayout === 'grid' || defaults.itemsLayout === 'row' + ? defaults.itemsLayout + : (defaults.gridExpanded === true ? 'grid' : 'row'); + + if (norm(overrides.renderMode) !== norm(defaults.renderMode || 'Normal') + && norm(overrides.renderMode) !== '') return true; + if (norm(overrides.order) !== norm(defaults.order)) return true; + if (norm(overrides.ttl) !== norm(defaults.ttl)) return true; + if (norm(overrides.cardFormat) !== norm(defaults.cardFormat)) return true; + { + const anim = overrides.animationEnabled !== false; + const serverAnim = (defaults.animationEnabled ?? defaults.panAnimation) !== false; + if (anim !== serverAnim) return true; + } + if (!boolEq(overrides.hideName === true, defaults.hideName === true)) return true; + if (!boolEq(overrides.hideCardTitles === true, defaults.hideCardTitles === true)) return true; + if (norm(overrides.cardTitlePosition) !== norm(defaults.cardTitlePosition)) return true; + if (norm(overrides.borderStyle) !== norm(defaults.borderStyle)) return true; + if (norm(overrides.borderColor) !== norm(defaults.borderColor)) return true; + if (norm(overrides.spotlightLayout) !== norm(defaults.spotlightLayout)) return true; + if (norm(overrides.spotlightSize) !== norm(defaults.spotlightSize)) return true; + if (norm(overrides.spotlightTileCount) !== norm(defaults.spotlightTileCount)) return true; + if (layout !== serverLayout) return true; + if (!boolEq(overrides.useGaplessCards === true, defaults.useGaplessCards === true)) return true; + if (norm(overrides.cardTitleCapitalization || 'normal') !== norm(defaults.cardTitleCapitalization || 'normal')) return true; + if (norm(overrides.cardTitleFontFamily || 'default') !== norm(defaults.cardTitleFontFamily || 'default')) return true; + if (norm(overrides.cardTitleFontSize || 'normal') !== norm(defaults.cardTitleFontSize || 'normal')) return true; + if (norm(overrides.cardTitleColor) !== norm(defaults.cardTitleColor)) return true; + return false; } function applyServerDefaultsToConfig(cfg, defaults) { @@ -776,17 +804,41 @@ return isSpotlightSection(entry?.effectiveConfig); } + function resolveSpotlightPresentationLocal(options = {}) { + if (window.cardBuilder?.resolveSpotlightPresentation) { + return window.cardBuilder.resolveSpotlightPresentation({ + ...options, + capForViewport: false + }); + } + const fullScreen = options.fullScreen === true; + const layout = options.spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border'); + const size = options.spotlightSize ?? (fullScreen ? 'full' : 'normal'); + let tileCount = options.tileCount; + if (tileCount == null || tileCount < 1 || tileCount > 3) { + tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3; + } + tileCount = Math.max(1, Math.min(3, Math.floor(tileCount))); + return { layout, size, tileCount }; + } + // A section that was never spotlight has no spotlightConfig, so the popover controls and // the renderer would otherwise fall back to different defaults. function buildSpotlightConfigSeed(existingConfig, serverDefaults = {}) { const existing = existingConfig || {}; const admin = window.KefinHomeScreen?.getConfig?.()?.SPOTLIGHT_SETTINGS || {}; + const merged = { ...admin, ...existing }; + const resolved = resolveSpotlightPresentationLocal({ + ...merged, + spotlightLayout: existing.spotlightLayout ?? admin.spotlightLayout ?? serverDefaults.spotlightLayout, + spotlightSize: existing.spotlightSize ?? admin.spotlightSize ?? serverDefaults.spotlightSize, + tileCount: existing.tileCount ?? admin.tileCount ?? serverDefaults.spotlightTileCount + }); return { - ...admin, - ...existing, - spotlightLayout: existing.spotlightLayout ?? admin.spotlightLayout ?? serverDefaults.spotlightLayout ?? 'Border', - spotlightSize: existing.spotlightSize ?? admin.spotlightSize ?? serverDefaults.spotlightSize ?? 'normal', - tileCount: existing.tileCount ?? admin.tileCount ?? serverDefaults.spotlightTileCount ?? 1 + ...merged, + spotlightLayout: resolved.layout, + spotlightSize: resolved.size, + tileCount: resolved.tileCount }; } @@ -864,15 +916,10 @@ if (shouldRenderAsSpotlight(entry)) { const spotlight = cfg.spotlightConfig || {}; - if (spotlight.spotlightLayout) { - el.dataset.layout = spotlight.spotlightLayout; - } - if (spotlight.spotlightSize) { - el.dataset.size = spotlight.spotlightSize; - } - if (spotlight.tileCount != null) { - el.dataset.tileCount = String(spotlight.tileCount); - } + const resolved = resolveSpotlightPresentationLocal(spotlight); + el.dataset.layout = resolved.layout; + el.dataset.size = resolved.size; + el.dataset.tileCount = String(resolved.tileCount); el.querySelectorAll('.spotlight-overlay').forEach(overlay => { const hasVisibleTitle = cfg.hideName !== true && !!el.querySelector('.spotlight-section-title'); @@ -2313,8 +2360,9 @@ : ''} `; - const spotlightLayout = spotlight.spotlightLayout || 'Border'; - const spotlightTileCount = normalizeSpotlightTileCount(spotlight.tileCount); + const resolvedSpotlight = resolveSpotlightPresentationLocal(spotlight); + const spotlightLayout = resolvedSpotlight.layout; + const spotlightTileCount = normalizeSpotlightTileCount(resolvedSpotlight.tileCount); const spotlightNextTileCount = nextSpotlightTileCount(spotlightTileCount); const spotlightTilesIcon = spotlightNextTileCount === 1 ? 'looks_one' @@ -2575,13 +2623,11 @@ const spotlightLayoutBtn = popover.querySelector('.kefin-section-spotlight-layout-btn'); if (spotlightLayoutBtn) { - syncSpotlightLayoutButton( - spotlightLayoutBtn, - entry.effectiveConfig.spotlightConfig?.spotlightLayout || 'Border' - ); + const seedLayout = resolveSpotlightPresentationLocal(entry.effectiveConfig.spotlightConfig || {}).layout; + syncSpotlightLayoutButton(spotlightLayoutBtn, seedLayout); spotlightLayoutBtn.addEventListener('click', (e) => { e.stopPropagation(); - const current = entry.effectiveConfig.spotlightConfig?.spotlightLayout || 'Border'; + const current = resolveSpotlightPresentationLocal(entry.effectiveConfig.spotlightConfig || {}).layout; const next = current === 'Borderless' ? 'Border' : 'Borderless'; entry.effectiveConfig.spotlightConfig = { ...(entry.effectiveConfig.spotlightConfig || {}), @@ -2595,8 +2641,9 @@ const spotlightSizeBtn = popover.querySelector('.kefin-section-spotlight-size-btn'); spotlightSizeBtn?.addEventListener('click', (e) => { e.stopPropagation(); + const seedSize = resolveSpotlightPresentationLocal(entry.effectiveConfig.spotlightConfig || {}).size; openOptionMenu(spotlightSizeBtn, getSpotlightSizeOptions(), { - activeValue: entry.effectiveConfig.spotlightConfig?.spotlightSize || 'normal', + activeValue: seedSize, onSelect: (value) => { entry.effectiveConfig.spotlightConfig = { ...(entry.effectiveConfig.spotlightConfig || {}), @@ -2609,14 +2656,12 @@ const spotlightTilesBtn = popover.querySelector('.kefin-section-spotlight-tiles-btn'); if (spotlightTilesBtn) { - syncSpotlightTilesButton( - spotlightTilesBtn, - entry.effectiveConfig.spotlightConfig?.tileCount || 1 - ); + const seedTileCount = resolveSpotlightPresentationLocal(entry.effectiveConfig.spotlightConfig || {}).tileCount; + syncSpotlightTilesButton(spotlightTilesBtn, seedTileCount); spotlightTilesBtn.addEventListener('click', (e) => { e.stopPropagation(); const current = normalizeSpotlightTileCount( - entry.effectiveConfig.spotlightConfig?.tileCount + resolveSpotlightPresentationLocal(entry.effectiveConfig.spotlightConfig || {}).tileCount ); const next = nextSpotlightTileCount(current); entry.effectiveConfig.spotlightConfig = { diff --git a/scripts/sectionHelper.js b/scripts/sectionHelper.js index c336282..48b4bbc 100644 --- a/scripts/sectionHelper.js +++ b/scripts/sectionHelper.js @@ -22,6 +22,8 @@ watchlist: new Set(), customDiscoverySections: new Set() }, + /** Session-scoped: discovery template ids whose source pool resolved empty (not incomplete cache). */ + emptyDiscoverySourceTemplates: new Set(), currentDiscoveryGenre: null, currentDiscoveryStudio: null, // Map of pairKey -> Promise resolving to dynamicResult for spotlight pairing @@ -51,11 +53,35 @@ Object.keys(ids).forEach((key) => { if (ids[key] instanceof Set) ids[key].clear(); }); + discoveryState.emptyDiscoverySourceTemplates.clear(); discoveryState.currentDiscoveryGenre = null; discoveryState.currentDiscoveryStudio = null; discoveryState.pairResolvePromises.clear(); } + function getDiscoveryTemplateExclusionKey(configOrId) { + if (configOrId == null) return ''; + if (typeof configOrId === 'string') { + const id = configOrId; + const stamped = id.replace(/-\d{10,}-[a-z0-9]+$/i, ''); + return stamped || id.split('-')[0] || id; + } + if (configOrId.discoveryTemplateId) return String(configOrId.discoveryTemplateId); + return getDiscoveryTemplateExclusionKey(String(configOrId.id || '')); + } + + function markEmptyDiscoverySourceTemplate(configOrId) { + const key = getDiscoveryTemplateExclusionKey(configOrId); + if (!key) return; + discoveryState.emptyDiscoverySourceTemplates.add(key); + LOG(`Excluding empty discovery source template for session: ${key}`); + } + + function isEmptyDiscoverySourceTemplate(configOrId) { + const key = getDiscoveryTemplateExclusionKey(configOrId); + return !!key && discoveryState.emptyDiscoverySourceTemplates.has(key); + } + /** * One-shot: whether this section's dependent Movies/Series/People cache is already complete. * Incomplete → skip (do not poll/wait). Missing LibraryCache → treat as ready. @@ -672,7 +698,8 @@ queries: [] })) === true) { LOG(`Discovery section ${config.id}: skipping pool fetch — incomplete library cache`); - return []; + // null = incomplete cache (do not permanently exclude template) + return null; } const cfg = Config(); @@ -803,7 +830,12 @@ } const pool = await fetchDiscoveryPool(config); - if (!pool.length) return null; + // Incomplete library cache — skip this attempt without excluding the template + if (pool == null) return null; + if (!pool.length) { + markEmptyDiscoverySourceTemplate(config); + return null; + } if (discoveryType === 'Genre') { const settings = getDiscoverySettings(); @@ -915,6 +947,7 @@ const instanceConfig = { ...template, id: options.stubId || `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + discoveryTemplateId: template.id, type: 'discovery', }; if (options.stubOrder != null) { @@ -1134,6 +1167,7 @@ const config = { ...template, id: uniqueId, + discoveryTemplateId: template.id, type: 'discovery', discoveryPending: true, order: order != null ? order : template.order, @@ -1207,7 +1241,10 @@ resolveViewMoreUrl, getDiscoverySettings, getDiscoveryState: () => discoveryState, - resetDiscoveryDedupe + resetDiscoveryDedupe, + getDiscoveryTemplateExclusionKey, + markEmptyDiscoverySourceTemplate, + isEmptyDiscoverySourceTemplate }; LOG('sectionHelper ready');