Improve resolution of Spotlight styles and ensure Seasonal sections load correctly

This commit is contained in:
HighImKevin
2026-10-01 09:22:46 -04:00
parent c7ffe6b595
commit 7fdfaf73f1
8 changed files with 357 additions and 128 deletions
+48 -24
View File
@@ -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;
+30 -9
View File
@@ -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',
+36 -12
View File
@@ -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) {
+63 -25
View File
@@ -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);
}
/**
+19 -2
View File
@@ -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 {
+38 -15
View File
@@ -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;
}
+83 -38
View File
@@ -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 = {
+40 -3
View File
@@ -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');