Files
KefinTweaks/scripts/cardBuilder.js
T

10695 lines
480 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Jellyfin Card Builder
// This module provides a main entry point function to build Jellyfin cards
// Usage: window.cardBuilder.buildCard(jellyfinItem)
(function () {
'use strict';
// Global toggle for progressive section fade-in.
// Developers can set this to false to disable the behavior.
const FADE_IN_SECTIONS = true;
// Detect flex gap after DOM is ready (JMP/Qt often lacks flex gap).
// ensureNoFlexGapClass is a function declaration (hoisted in this IIFE).
function scheduleFlexGapDetect() {
if (!document.body) {
document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true });
return;
}
ensureNoFlexGapClass();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true });
} else {
scheduleFlexGapDetect();
}
// Sliding window: render current slide + 4 prev + 4 next (9 total). As user navigates, remove/add slides.
const SPOTLIGHT_WINDOW_PREV = 4;
const SPOTLIGHT_WINDOW_NEXT = 4;
const state = {
defaultCardBackgroundNumber: 1,
useBlurhash: true,
watchlist: false,
};
/** In-memory scroller metrics (avoid CSS var style writes for JS-only caches). */
const scrollerMetricsByEl = new WeakMap();
const cardPaddingByEl = new WeakMap();
function getScrollerMetrics(scroller) {
if (!scroller) return null;
let m = scrollerMetricsByEl.get(scroller);
if (!m) {
m = { scrollX: 0, pad: null, cardPad: null, maxScroll: null };
scrollerMetricsByEl.set(scroller, m);
}
return m;
}
function invalidateScrollerMetrics(scroller) {
if (!scroller) return;
scrollerMetricsByEl.delete(scroller);
}
function getSpotlightWindowIndices(currentIndex, totalLength) {
if (totalLength <= 9) {
return Array.from({ length: totalLength }, (_, i) => i);
}
const indices = new Set();
for (let i = -SPOTLIGHT_WINDOW_PREV; i <= SPOTLIGHT_WINDOW_NEXT; i++) {
const idx = (currentIndex + i + totalLength) % totalLength;
indices.add(idx);
}
return Array.from(indices);
}
/**
* Helper function to shuffle array (Fisher-Yates)
*/
function shuffle(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/** Element.replaceChildren polyfill (missing in JMP Qt5 WebEngine / Chromium ~83). */
function replaceChildren(node, ...nodes) {
if (!node) return;
if (typeof node.replaceChildren === 'function') {
node.replaceChildren(...nodes);
return;
}
while (node.firstChild) node.removeChild(node.firstChild);
for (const child of nodes) {
if (child == null) continue;
node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
}
}
/** Cream deco border styles that need injected child pieces on `.cardBorder`. */
const CARD_BORDER_DECO_CHILDREN = {
triple: ['cardBorder-tall', 'cardBorder-mid', 'cardBorder-wide'],
stairstep: [
'cardBorder-outer-left',
'cardBorder-outer-right',
'cardBorder-mid-left',
'cardBorder-mid-right',
'cardBorder-mid-top',
'cardBorder-mid-bottom',
'cardBorder-inner-top',
'cardBorder-inner-bottom',
],
square: [
'cardBorder-inner',
'cardBorder-square-left-top',
'cardBorder-square-left-bottom',
'cardBorder-square-right-top',
'cardBorder-square-right-bottom',
'cardBorder-tall',
'cardBorder-wide',
],
diamond: [
'cardBorder-square-left-top',
'cardBorder-square-left-bottom',
'cardBorder-square-right-top',
'cardBorder-square-right-bottom',
'cardBorder-tall-outer',
'cardBorder-tall-inner',
'cardBorder-wide-outer',
'cardBorder-wide-inner',
],
corner: [
'cardBorder-corner cardBorder-corner-tl',
'cardBorder-corner cardBorder-corner-tr',
'cardBorder-corner cardBorder-corner-bl',
'cardBorder-corner cardBorder-corner-br',
],
'picture-frame': ['cardBorder-pf-inner'],
};
/** Legacy art-deco / slim-fancy / vintage-wide prefs map onto current equivalents. */
const CARD_BORDER_ALIASES = {
'art-deco': 'stairstep',
'art-deco-2': 'square',
'slim-fancy': 'double',
'vintage-wide': 'vintage',
};
const CARD_BORDER_CSS_ONLY = new Set(['fancy', 'slim', 'double', 'modern-poster', 'film-reel', 'profile', 'wood', 'art-gallery']);
function normalizeCardBorderStyle(style) {
if (!style) return '';
return CARD_BORDER_ALIASES[style] || style;
}
function getCardBorderAssetUrl(filename) {
const root = String(window.KefinTweaksConfig?.kefinTweaksRoot || '').replace(/\/$/, '');
return `${root}/pages/images/${filename}`;
}
function getRetroPosterMarqueeLabel(item) {
if (item?.PremiereDate) {
const premiere = new Date(item.PremiereDate);
if (!Number.isNaN(premiere.getTime()) && premiere.getTime() > Date.now()) {
return 'COMING SOON';
}
}
return 'NOW PLAYING';
}
function createCardBorderHost() {
const host = document.createElement('div');
host.className = 'cardBorder';
host.setAttribute('aria-hidden', 'true');
return host;
}
function ensureRetroPosterMarquee(scalable) {
if (!scalable) return;
const card = scalable.closest('.card');
let marquee = scalable.querySelector(':scope > .retroPosterMarquee');
if (!marquee) {
marquee = document.createElement('div');
marquee.className = 'retroPosterMarquee';
marquee.setAttribute('aria-hidden', 'true');
const label = document.createElement('span');
label.className = 'retroPosterMarquee-label';
marquee.appendChild(label);
const border = scalable.querySelector(':scope > .cardBorder');
if (border) scalable.insertBefore(marquee, border);
else scalable.appendChild(marquee);
}
const labelEl = marquee.querySelector('.retroPosterMarquee-label');
if (labelEl) {
labelEl.textContent = card?.dataset.retroMarquee || 'NOW PLAYING';
}
}
function removeRetroPosterMarquee(scalable) {
scalable?.querySelector(':scope > .retroPosterMarquee')?.remove();
}
function fillVintageBorder(host) {
host.style.setProperty('--v-corner-deco', `url("${getCardBorderAssetUrl("corner-decoration.svg")}")`);
const outer = document.createElement('div');
outer.className = 'cardBorder-v-outer';
const mid = document.createElement('div');
mid.className = 'cardBorder-v-mid';
const inner = document.createElement('div');
inner.className = 'cardBorder-v-inner';
['tl', 'tr', 'br', 'bl'].forEach((pos) => {
const corner = document.createElement('div');
corner.className = `cardBorder-v-corner cardBorder-v-corner-${pos}`;
inner.appendChild(corner);
});
mid.appendChild(inner);
outer.appendChild(mid);
host.appendChild(outer);
}
const SVG_NS = 'http://www.w3.org/2000/svg';
/** Target px between bulb centers along each edge. */
const RP_BULB_PITCH = 16;
const RP_BULB_LAYERS = [
{ cls: 'cardBorder-rp-bulb-glow', r: 5.5 },
{ cls: 'cardBorder-rp-bulb-mid', r: 3.5 },
{ cls: 'cardBorder-rp-bulb-core', r: 1.75 },
];
const RP_DEFAULT_THIN = 2;
const RP_DEFAULT_BAND = 16;
/** Cards to sync bulbs for per animation frame (keeps style switches snappy). */
const RP_SYNC_BATCH = 3;
let rpBulbSyncQueue = [];
let rpBulbSyncRaf = 0;
function disconnectRetroPosterBulbs(host) {
if (!host) return;
if (host._rpRoRaf) {
cancelAnimationFrame(host._rpRoRaf);
host._rpRoRaf = 0;
}
if (host._rpBulbRo) {
host._rpBulbRo.disconnect();
host._rpBulbRo = null;
}
host._rpBulbPending = false;
host._rpBulbW = null;
host._rpBulbH = null;
if (rpBulbSyncQueue.length) {
rpBulbSyncQueue = rpBulbSyncQueue.filter((svg) => svg?.parentElement !== host);
}
}
function getRetroPosterBandMetrics(sectionEl) {
if (sectionEl?._rpBandMetrics) return sectionEl._rpBandMetrics;
let thin = RP_DEFAULT_THIN;
let band = RP_DEFAULT_BAND;
try {
const cs = getComputedStyle(sectionEl || document.documentElement);
const t = parseFloat(cs.getPropertyValue('--rp-thin'));
const b = parseFloat(cs.getPropertyValue('--rp-band'));
if (Number.isFinite(t) && t > 0) thin = t;
if (Number.isFinite(b) && b > 0) band = b;
} catch {
/* keep defaults */
}
const metrics = { thin, band };
if (sectionEl) sectionEl._rpBandMetrics = metrics;
return metrics;
}
function enqueueRetroPosterBulbSync(svg) {
if (!svg?.isConnected) return;
const host = svg.parentElement;
if (!host || host._rpBulbPending) return;
host._rpBulbPending = true;
rpBulbSyncQueue.push(svg);
if (!rpBulbSyncRaf) {
rpBulbSyncRaf = requestAnimationFrame(flushRetroPosterBulbSyncQueue);
}
}
function flushRetroPosterBulbSyncQueue() {
rpBulbSyncRaf = 0;
let n = 0;
while (n < RP_SYNC_BATCH && rpBulbSyncQueue.length) {
const svg = rpBulbSyncQueue.shift();
const host = svg?.parentElement;
if (host) host._rpBulbPending = false;
if (svg?.isConnected && host?.dataset?.style === 'retro-poster') {
syncRetroPosterBulbs(svg);
}
n += 1;
}
if (rpBulbSyncQueue.length) {
rpBulbSyncRaf = requestAnimationFrame(flushRetroPosterBulbSyncQueue);
}
}
function syncRetroPosterBulbs(svg) {
const host = svg?.parentElement;
if (!host) return;
const w = host.clientWidth;
const h = host.clientHeight;
if (w < 8 || h < 8) return;
// Skip rebuild when size unchanged and bulbs already exist
if (host._rpBulbW === w && host._rpBulbH === h && svg.querySelector('circle')) return;
host._rpBulbW = w;
host._rpBulbH = h;
const sectionEl = host.closest('[data-border], [data-border-style]');
const { thin, band } = getRetroPosterBandMetrics(sectionEl);
const mid = thin + band / 2;
const x1 = mid;
const y1 = mid;
const x2 = w - mid;
const y2 = h - mid;
const rw = Math.max(0, x2 - x1);
const rh = Math.max(0, y2 - y1);
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
/* Independent edge spacing so each side has a corner bulb + even steps between. */
const kW = Math.max(1, Math.round(rw / RP_BULB_PITCH));
const kH = Math.max(1, Math.round(rh / RP_BULB_PITCH));
const sW = rw / kW;
const sH = rh / kH;
const points = [
[x1, y1],
[x2, y1],
[x2, y2],
[x1, y2],
];
for (let i = 1; i < kW; i++) {
points.push([x1 + i * sW, y1], [x1 + i * sW, y2]);
}
for (let i = 1; i < kH; i++) {
points.push([x1, y1 + i * sH], [x2, y1 + i * sH]);
}
RP_BULB_LAYERS.forEach(({ cls, r }) => {
const g = svg.querySelector(`g.${cls}`);
if (!g) return;
const frag = document.createDocumentFragment();
points.forEach(([cx, cy]) => {
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', String(cx));
circle.setAttribute('cy', String(cy));
circle.setAttribute('r', String(r));
frag.appendChild(circle);
});
replaceChildren(g, frag);
});
}
function fillRetroPosterBorder(host) {
disconnectRetroPosterBulbs(host);
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'cardBorder-rp-bulbs');
svg.setAttribute('aria-hidden', 'true');
RP_BULB_LAYERS.forEach(({ cls }) => {
const g = document.createElementNS(SVG_NS, 'g');
g.setAttribute('class', cls);
svg.appendChild(g);
});
host.appendChild(svg);
// Coalesce RO storms; do not sync synchronously on attach (causes border-switch hitch).
const ro = new ResizeObserver(() => {
if (host._rpRoRaf) return;
host._rpRoRaf = requestAnimationFrame(() => {
host._rpRoRaf = 0;
host._rpBulbW = null;
host._rpBulbH = null;
enqueueRetroPosterBulbSync(svg);
});
});
ro.observe(host);
host._rpBulbRo = ro;
enqueueRetroPosterBulbSync(svg);
}
function fillCardBorder(host, style) {
if (!host) return;
const next = normalizeCardBorderStyle(style);
const isVintage = next === 'vintage';
const isRetro = next === 'retro-poster';
const needsChildren = next && !CARD_BORDER_CSS_ONLY.has(next) && (isVintage || isRetro || !!CARD_BORDER_DECO_CHILDREN[next]);
if (host.dataset.style === next) {
const vintageNeedsRefresh = isVintage && !host.style.getPropertyValue('--v-corner-deco');
if (!vintageNeedsRefresh && (!needsChildren || host.childElementCount > 0)) return;
}
disconnectRetroPosterBulbs(host);
host.dataset.style = next;
replaceChildren(host);
if (!needsChildren) return;
if (isVintage) {
fillVintageBorder(host);
return;
}
if (isRetro) {
fillRetroPosterBorder(host);
return;
}
for (const cls of CARD_BORDER_DECO_CHILDREN[next]) {
const piece = document.createElement('div');
piece.className = cls;
host.appendChild(piece);
}
}
/**
* Sync `.cardBorder` hosts under a section from its data-border / data-border-style.
* Creates hosts when missing; removes them when border is cleared.
*/
function ensureCardBorders(sectionEl) {
if (!sectionEl) return;
const raw = sectionEl.dataset.border || sectionEl.dataset.borderStyle || '';
const style = normalizeCardBorderStyle(raw);
if (style && style !== raw) {
sectionEl.dataset.border = style;
sectionEl.dataset.borderStyle = style;
}
// Refresh cached band metrics when (re)applying so CSS vars stay current
delete sectionEl._rpBandMetrics;
if (style === 'wood') {
sectionEl.style.setProperty('--wood-vert-img', `url("${getCardBorderAssetUrl("wood.png")}")`);
} else {
sectionEl.style.removeProperty('--wood-vert-img');
}
const cards = sectionEl.querySelectorAll('.card .cardScalable');
cards.forEach((scalable) => {
let host = scalable.querySelector(':scope > .cardBorder');
if (!style) {
if (host) {
disconnectRetroPosterBulbs(host);
host.remove();
}
removeRetroPosterMarquee(scalable);
return;
}
if (!host) {
host = createCardBorderHost();
scalable.appendChild(host);
}
fillCardBorder(host, style);
if (style === 'retro-poster') ensureRetroPosterMarquee(scalable);
else removeRetroPosterMarquee(scalable);
});
}
/** Skin-wide card corner radius; measured once and exposed as --kefin-card-radius. */
let cachedCardRadius = null;
const SQUARE_CARD_BORDER_STYLES = new Set([
'retro-poster',
'modern-poster',
'picture-frame',
'art-gallery',
'wood',
'profile',
'film-reel',
]);
function sectionHasSquareCardBorder(el) {
const section = el?.closest?.('[data-border], [data-border-style]');
if (!section) return false;
const style = section.dataset.borderStyle || section.dataset.border || '';
return SQUARE_CARD_BORDER_STYLES.has(style);
}
function getCardRadius() {
if (cachedCardRadius != null) return cachedCardRadius;
const candidates = document.querySelectorAll('.card .cardScalable, .card .cardContent, .card');
let sample = null;
for (const el of candidates) {
if (!sectionHasSquareCardBorder(el)) {
sample = el;
break;
}
}
if (!sample) return '0px';
const style = getComputedStyle(sample);
let radius = (style.borderBottomLeftRadius || style.borderRadius || '').trim();
if (!radius || radius === '0' || radius === '0px') {
const themeRound = (
style.getPropertyValue('--theme-roundness') ||
getComputedStyle(document.documentElement).getPropertyValue('--theme-roundness') ||
''
).trim();
radius = themeRound || radius || '0px';
}
cachedCardRadius = radius;
return cachedCardRadius;
}
function ensureCardRadiusCssVar() {
document.documentElement.style.setProperty('--kefin-card-radius', getCardRadius());
}
function invalidateCardRadiusCache() {
cachedCardRadius = null;
document.documentElement.style.removeProperty('--kefin-card-radius');
}
function applySectionPresentationAttrs(element, sectionConfig, options = {}) {
if (!element || !sectionConfig) return;
const forceBorders = options.forceBorders === true;
if (sectionConfig.sectionCssClass) {
sectionConfig.sectionCssClass
.split(/\s+/)
.filter(Boolean)
.forEach((cls) => element.classList.add(cls));
}
const desiredBorderRaw = sectionConfig.borderStyle || '';
const desiredBorder = normalizeCardBorderStyle(desiredBorderRaw) || '';
const currentBorder = normalizeCardBorderStyle(element.dataset.border || element.dataset.borderStyle || '') || '';
const borderChanged = desiredBorder !== currentBorder;
const desiredBorderColor = (sectionConfig.borderColor && String(sectionConfig.borderColor).trim()) || '';
const currentBorderColor = (element.style.getPropertyValue('--kefin-card-border-color') || '').trim();
const borderColorChanged = desiredBorderColor !== currentBorderColor;
const desiredTitleColor = (sectionConfig.cardTitleColor && String(sectionConfig.cardTitleColor).trim()) || '';
const currentTitleColor = (element.style.getPropertyValue('--kefin-card-title-color') || '').trim();
const titleColorChanged = desiredTitleColor !== currentTitleColor;
const desiredHideName = sectionConfig.hideName === true || sectionConfig.hideName === 'true';
const currentHideName = element.dataset.hideSectionName === 'true';
const hideNameChanged = desiredHideName !== currentHideName;
const desiredTitleVisibility = sectionConfig.cardTitleVisibility || (sectionConfig.hideCardTitles === true ? 'hidden' : '');
const currentTitleVisibility = element.dataset.cardTitleVisibility || '';
const titleVisChanged =
(desiredTitleVisibility && desiredTitleVisibility !== 'visible' ? desiredTitleVisibility : '') !== currentTitleVisibility;
const desiredTitlePosition = sectionConfig.cardTitlePosition || '';
const titlePosChanged = (element.dataset.cardTitlePosition || '') !== desiredTitlePosition;
const desiredCap = sectionConfig.cardTitleCapitalization && sectionConfig.cardTitleCapitalization !== 'normal'
? sectionConfig.cardTitleCapitalization
: '';
const capChanged = (element.dataset.cardTitleCapitalization || '') !== desiredCap;
const desiredFontFamily =
sectionConfig.cardTitleFontFamily && sectionConfig.cardTitleFontFamily !== 'default'
? sectionConfig.cardTitleFontFamily
: '';
const fontFamilyChanged = (element.dataset.fontFamily || '') !== desiredFontFamily;
const desiredFontSize =
sectionConfig.cardTitleFontSize && sectionConfig.cardTitleFontSize !== 'normal' ? sectionConfig.cardTitleFontSize : '';
const fontSizeChanged = (element.dataset.fontSize || '') !== desiredFontSize;
const desiredType = sectionConfig.type || '';
const typeChanged = (element.getAttribute('data-section-type') || '') !== desiredType;
const desiredCategory = resolveSectionCategory(sectionConfig) || '';
const categoryChanged = (element.getAttribute('data-category') || '') !== desiredCategory;
const sectionType = String(sectionConfig.type || '').toLowerCase();
const isCustomSection =
sectionConfig.isCustom === true ||
sectionType === 'custom' ||
sectionType === 'custom-discovery' ||
String(sectionConfig.id || '').startsWith('custom');
const isDiscoverySection =
sectionConfig.discoveryEnabled === true ||
sectionConfig.discoverySection === true ||
sectionType === 'discovery' ||
sectionType === 'custom-discovery' ||
!!sectionConfig.discoveryType;
const customChanged = (element.dataset.customSection === 'true') !== isCustomSection;
const discoveryChanged = (element.dataset.discoverySection === 'true') !== isDiscoverySection;
const itemsLayout = resolveItemsLayout(sectionConfig);
const gapless = resolveUseGaplessCards(sectionConfig);
const itemsContainer = element.querySelector('.itemsContainer');
const layoutChanged =
!itemsContainer ||
itemsContainer.getAttribute('data-layout') !== itemsLayout ||
(itemsContainer.getAttribute('data-gapless') === 'true') !== gapless;
const presentationChanged =
borderChanged ||
borderColorChanged ||
titleColorChanged ||
hideNameChanged ||
titleVisChanged ||
titlePosChanged ||
capChanged ||
fontFamilyChanged ||
fontSizeChanged ||
typeChanged ||
categoryChanged ||
customChanged ||
discoveryChanged ||
layoutChanged;
if (!presentationChanged && !forceBorders) return;
if (borderChanged) {
if (desiredBorder) {
element.dataset.border = desiredBorder;
element.dataset.borderStyle = desiredBorder;
} else {
delete element.dataset.border;
delete element.dataset.borderStyle;
}
}
if (borderColorChanged) {
if (desiredBorderColor) element.style.setProperty('--kefin-card-border-color', desiredBorderColor);
else element.style.removeProperty('--kefin-card-border-color');
}
if (titleColorChanged) {
if (desiredTitleColor) element.style.setProperty('--kefin-card-title-color', desiredTitleColor);
else element.style.removeProperty('--kefin-card-title-color');
}
if (hideNameChanged) {
if (desiredHideName) element.dataset.hideSectionName = 'true';
else delete element.dataset.hideSectionName;
}
delete element.dataset.hideName;
if (titleVisChanged) {
if (desiredTitleVisibility && desiredTitleVisibility !== 'visible') {
element.dataset.cardTitleVisibility = desiredTitleVisibility;
} else {
delete element.dataset.cardTitleVisibility;
}
}
if (titlePosChanged) {
if (desiredTitlePosition) element.dataset.cardTitlePosition = desiredTitlePosition;
else delete element.dataset.cardTitlePosition;
}
if (capChanged) {
if (desiredCap) element.dataset.cardTitleCapitalization = desiredCap;
else delete element.dataset.cardTitleCapitalization;
}
if (fontFamilyChanged) {
if (desiredFontFamily) element.dataset.fontFamily = desiredFontFamily;
else delete element.dataset.fontFamily;
}
if (fontSizeChanged) {
if (desiredFontSize) element.dataset.fontSize = desiredFontSize;
else delete element.dataset.fontSize;
}
if (layoutChanged) {
applyItemsLayoutState(element, itemsLayout, gapless);
}
if (typeChanged) {
if (desiredType) element.setAttribute('data-section-type', desiredType);
else element.removeAttribute('data-section-type');
}
if (categoryChanged) {
if (desiredCategory) element.setAttribute('data-category', desiredCategory);
else element.removeAttribute('data-category');
}
if (customChanged) {
if (isCustomSection) element.dataset.customSection = 'true';
else delete element.dataset.customSection;
}
if (discoveryChanged) {
if (isDiscoverySection) element.dataset.discoverySection = 'true';
else delete element.dataset.discoverySection;
}
if (borderChanged || forceBorders) {
ensureCardBorders(element);
}
}
function resolveSectionCategory(sectionConfig) {
if (!sectionConfig) return 'none';
const id = String(sectionConfig.id || '');
if (id.startsWith('pinned-list-') || id.startsWith('pinned-parent-')) return 'pinned';
const sectionType = String(sectionConfig.type || '').toLowerCase();
const isDiscovery =
sectionConfig.discoveryEnabled === true ||
sectionConfig.discoverySection === true ||
sectionType === 'discovery' ||
sectionType === 'custom-discovery' ||
!!sectionConfig.discoveryType;
if (isDiscovery) return 'discovery';
return sectionConfig.category || 'none';
}
/**
* Updates the scroll buttons for a scrollable container
* @param {HTMLElement} scroller - The scroller element
*/
function updateScrollableContainerScrollButtons(scroller) {
const verticalSection = scroller.parentElement;
if (!verticalSection) return;
const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons');
if (!scrollButtons) return;
const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
const rightButton = scrollButtons.querySelector('button[data-direction="right"]');
if (!leftButton || !rightButton) return;
const pos = getScrollerPosition(scroller);
const maxPosition = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
leftButton.disabled = pos <= 20;
rightButton.disabled = pos >= maxPosition;
scrollButtons.classList.toggle('hide', leftButton.disabled && rightButton.disabled);
}
/**
* Sorts items based on sort order and direction
* @param {Array} items - Array of Jellyfin items
* @param {string} sortOrder - Sort order: Random, ReleaseDate, CriticRating, CommunityRating, SortTitle, DateAdded
* @param {string} sortOrderDirection - Direction: Ascending or Descending
* @returns {Array} - Sorted array
*/
function sortItems(items, sortOrder = 'Random', sortOrderDirection = 'Ascending') {
if (!items || items.length === 0) return items;
const ascending = sortOrderDirection === 'Ascending';
const sorted = [...items];
switch (sortOrder) {
case 'Random':
return shuffle(sorted);
case 'ReleaseDate':
return sorted.sort((a, b) => {
const dateA = new Date(a.PremiereDate || a.ProductionYear || 0);
const dateB = new Date(b.PremiereDate || b.ProductionYear || 0);
return ascending ? dateA - dateB : dateB - dateA;
});
case 'CriticRating':
return sorted.sort((a, b) => {
const ratingA = a.CriticRating || 0;
const ratingB = b.CriticRating || 0;
return ascending ? ratingA - ratingB : ratingB - ratingA;
});
case 'CommunityRating':
return sorted.sort((a, b) => {
const ratingA = a.CommunityRating || 0;
const ratingB = b.CommunityRating || 0;
return ascending ? ratingA - ratingB : ratingB - ratingA;
});
case 'SortTitle':
return sorted.sort((a, b) => {
const titleA = (a.SortTitle || a.Name || '').toLowerCase();
const titleB = (b.SortTitle || b.Name || '').toLowerCase();
return ascending ? titleA.localeCompare(titleB) : titleB.localeCompare(titleA);
});
case 'DateAdded':
return sorted.sort((a, b) => {
const dateA = new Date(a.DateCreated || 0);
const dateB = new Date(b.DateCreated || 0);
return ascending ? dateA - dateB : dateB - dateA;
});
default:
return sorted;
}
}
/**
* Picks a random card format for a section
* @returns {string} - Random card format: 'portrait', 'thumb'
*/
function getRandomCardFormat() {
const formats = ['portrait', 'thumb'];
return formats[Math.floor(Math.random() * formats.length)];
}
/**
* Map section cardFormat / data-card-format to JF grid column shape.
* Used for CSS grid column tables (no data-grid-shape attribute).
* @param {string|null|undefined} cardFormat
* @returns {'portrait'|'square'|'backdrop'|'banner'}
*/
function resolveGridShape(cardFormat) {
const f = String(cardFormat || '')
.trim()
.toLowerCase();
if (f === 'banner') return 'banner';
if (f === 'square' || f === 'sqaure' || f === 'disc') return 'square';
if (
f === 'backdrop' ||
f === 'thumb' ||
f === 'series thumb' ||
f === 'logo' ||
f === 'clear art' ||
f === 'button'
) {
return 'backdrop';
}
// Poster, Series Poster, portrait, Random-resolved concrete formats, default
return 'portrait';
}
function sectionHasRandomQuerySort(sectionConfig) {
// Treat as random if any query has an undefined/null/non-existent SortBy without a path
if (String(sectionConfig?.sortBy || '').toLowerCase() === 'random') {
return true;
}
if (sectionConfig?.queries?.some((q) => !q.queryOptions?.SortBy && !q.path)) {
return true;
}
return (sectionConfig?.queries || []).some((q) => String(q.queryOptions?.SortBy || '').toLowerCase() === 'random');
}
function flattenSeriesEpisodes(items) {
if (!items || items.length === 0) return items;
const seenSeries = new Set();
const result = [];
for (const item of items) {
if (item.Type === 'Episode') {
// Only keep the first episode seen for a given SeriesId
if (item.SeriesId && !seenSeries.has(item.SeriesId)) {
seenSeries.add(item.SeriesId);
result.push(item);
}
} else {
// Keep non-Episode items as-is, in order
result.push(item);
}
}
return result;
}
function formatAirDate(premiereDate) {
if (!premiereDate) return '';
// Ignore timezone offset
const date = new Date(premiereDate.replace('Z', ''));
// Use format: Tues Jan 4
const options = { weekday: 'short', month: 'short', day: 'numeric' };
return date.toLocaleDateString('en-US', options);
}
function countAvailableArtwork(item) {
let count = 0;
const tags = item?.ImageTags;
if (tags && typeof tags === 'object') {
for (const value of Object.values(tags)) {
if (value) count++;
}
}
if (item?.SeriesPrimaryImageTag) count++;
if (item?.ParentBackdropImageTags?.length) count++;
if (item?.ParentThumbImageTag) count++;
if (item?.ParentLogoImageTag) count++;
return count;
}
function getProviderKeys(item) {
const ids = item?.ProviderIds;
if (!ids) return [];
return [ids.Imdb, ids.Tmdb, ids.Tvdb].filter(Boolean);
}
function deduplicateItems(items) {
const deduplicated = [];
const idToIndex = new Map();
for (const item of items) {
const keys = getProviderKeys(item);
if (keys.length === 0) {
deduplicated.push(item);
continue;
}
let existingIndex = -1;
for (const key of keys) {
if (idToIndex.has(key)) {
existingIndex = idToIndex.get(key);
break;
}
}
if (existingIndex === -1) {
const index = deduplicated.length;
deduplicated.push(item);
for (const key of keys) {
idToIndex.set(key, index);
}
continue;
}
const existing = deduplicated[existingIndex];
if (countAvailableArtwork(item) > countAvailableArtwork(existing)) {
deduplicated[existingIndex] = item;
}
for (const key of keys) {
idToIndex.set(key, existingIndex);
}
}
return deduplicated;
}
/**
* Merges NextUp and ContinueWatching items chronologically by LastPlayedDate
* Preserves NextUp's internal order and assigns LastPlayedDate to items without it
* @param {Array} nextUpItems - Array of Episode items from NextUp endpoint (already ordered)
* @param {Array} continueWatchingItems - Array of Movie items from Continue Watching endpoint
* @returns {Array} - Merged array sorted by LastPlayedDate (descending)
*/
function mergeNextUpAndContinueWatching(nextUpItems, continueWatchingItems) {
if (!nextUpItems || nextUpItems.length === 0) {
return continueWatchingItems || [];
}
if (!continueWatchingItems || continueWatchingItems.length === 0) {
return nextUpItems || [];
}
// 1. Assign LastPlayedDate to NextUp items without it
// Items without LastPlayedDate inherit the date from the next item that has one
const processedNextUp = [...nextUpItems];
for (let i = processedNextUp.length - 1; i >= 0; i--) {
const item = processedNextUp[i];
if (!item.UserData || !item.UserData.LastPlayedDate) {
// Look ahead to find next item with LastPlayedDate
for (let j = i + 1; j < processedNextUp.length; j++) {
if (processedNextUp[j].UserData && processedNextUp[j].UserData.LastPlayedDate) {
// Assign the date from the next item
if (!item.UserData) item.UserData = {};
item.UserData.LastPlayedDate = processedNextUp[j].UserData.LastPlayedDate;
break;
}
}
// If no date found ahead, assign null (will be placed at end)
if (!item.UserData || !item.UserData.LastPlayedDate) {
if (!item.UserData) item.UserData = {};
item.UserData.LastPlayedDate = null;
}
}
}
// 2. Sort ContinueWatching items by LastPlayedDate (descending)
const sortedContinueWatching = [...continueWatchingItems, ...processedNextUp].sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0;
const dateB = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0;
return dateB - dateA; // Descending
});
return sortedContinueWatching;
}
function postProcessItemsByQuery(sectionConfig, queryResults) {
let processed = [];
for (let i = 0; i < queryResults.length; i++) {
const queryConfig = sectionConfig.queries[i];
const queryResult = queryResults[i];
const items = queryResult?.Items || queryResult || [];
processed.push(...postProcessItems(queryConfig, items));
}
if (sectionConfig.id === 'continueWatchingAndNextUp') {
// Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them
const movies = processed.filter((item) => item.Type === 'Movie');
const episodes = processed.filter((item) => item.Type !== 'Movie');
processed = mergeNextUpAndContinueWatching(episodes, movies);
}
return processed;
}
function postProcessItems(sectionConfig, itemsData) {
let processed = itemsData?.Items || itemsData || [];
//Check if imageTypes is used in the query to ensure only items with these image types are included
const imageTypes = sectionConfig.queries?.[0]?.queryOptions?.ImageTypes;
if (imageTypes && Array.isArray(imageTypes)) {
// Filter out items which do not have any of the image types
processed = processed.filter((item) => {
const itemImageTypes = imageTypes.filter(
(imageType) => item.ImageTags?.[imageType] || (imageType === 'Backdrop' && item.BackdropImageTags?.[0]),
);
return itemImageTypes.length > 0;
});
}
// When ParentId is on the query (e.g. Popular Genres per library), assign it to each item for genre card links
const queryParentId = sectionConfig.queries?.[0]?.ParentId;
if (queryParentId && Array.isArray(processed)) {
processed.forEach((item) => {
item.ParentId = queryParentId;
});
}
// Check if the IsUnplayed filter is set on any of the queries
const isUnplayedFilter = sectionConfig.queries?.some(
(query) => query.queryOptions?.IsUnplayed === true || query.queryOptions?.Filters?.includes('IsUnplayed'),
);
if (isUnplayedFilter) {
processed = processed.filter((item) => item.UserData?.Played === false);
}
// Check if the IsPlayed filter is set on any of the queries
const isPlayedFilter = sectionConfig.queries?.some(
(query) => query.queryOptions?.IsPlayed === true || query.queryOptions?.Filters?.includes('IsPlayed'),
);
if (isPlayedFilter) {
processed = processed.filter((item) => item.UserData?.Played === true);
}
// Check if the IsResumable filter is set on any of the queries
const isResumableFilter = sectionConfig.queries?.some(
(query) => query.queryOptions?.IsResumable === true || query.queryOptions?.Filters?.includes('IsResumable'),
);
if (isResumableFilter) {
processed = processed.filter(
(item) => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage !== 100 && item.UserData?.PlayedPercentage > 0,
);
}
// Check if the MinPlayCount filter is set on any of the queries
const minPlayCount = sectionConfig.minPlayCount || sectionConfig.queries?.[0]?.queryOptions?.MinPlayCount || 0;
if (minPlayCount > 0) {
processed = processed.filter((item) => item.UserData?.PlayCount && item.UserData?.PlayCount >= minPlayCount);
}
// Check if the MaxPlayCount filter is set on any of the queries
const maxPlayCount = sectionConfig.maxPlayCount || sectionConfig.queries?.[0]?.queryOptions?.MaxPlayCount || 0;
if (maxPlayCount > 0) {
processed = processed.filter((item) => item.UserData?.PlayCount && item.UserData?.PlayCount <= maxPlayCount);
}
// Flatten Series Episodes
if (sectionConfig.flattenSeries === true) {
processed = flattenSeriesEpisodes(processed);
}
if (sectionConfig.type === 'series-episodes' && Array.isArray(processed)) {
processed = [...processed].sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
}
/* if (sectionConfig.id === 'popularTVNetworks') {
// Sort items randomly
processed = shuffle(processed);
} */
if (sectionConfig.id === 'continueWatchingAndNextUp') {
// Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them
const movies = processed.filter((item) => item.Type === 'Movie');
const episodes = processed.filter((item) => item.Type !== 'Movie');
processed = mergeNextUpAndContinueWatching(episodes, movies);
}
if (sectionConfig.id === 'upcoming') {
// Remove items which aired before today
const today = new Date();
today.setHours(0, 0, 0, 0);
processed = processed.filter((item) => {
if (!item.PremiereDate || item.LocationType !== 'Virtual') return false;
const premiereDate = new Date(item.PremiereDate);
premiereDate.setHours(0, 0, 0, 0);
return premiereDate >= today;
});
}
// Add Custom Secondary Text for Recently Released Movies
if (
sectionConfig.id === 'recentlyReleased.movies' ||
sectionConfig.id === 'upcoming' ||
sectionConfig.id === 'recentlyReleased.episodes'
) {
processed.forEach((item) => {
if (item.PremiereDate) {
item.CustomFooterText = formatAirDate(item.PremiereDate);
}
});
}
// Sort items by UserData.PlayedPercentage if PlayedPercentage is set as the SortBy
if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'PlayedPercentage') {
processed = processed.sort((a, b) => {
const playedPercentageA = a.UserData?.PlayedPercentage || 0;
const playedPercentageB = b.UserData?.PlayedPercentage || 0;
return playedPercentageB - playedPercentageA;
});
// Remove any items which have no UserData.PlayedPercentage
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;
if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'Random' || sectionConfig.sortBy === 'Random') {
if (limit > 0 && sectionConfig.limitBeforeSort === true) {
processed = processed.slice(0, limit);
}
processed = shuffle(processed);
}
// Apply local limits for non-API sources
if (limit > 0) {
processed = processed.slice(0, limit);
}
// Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids
processed = deduplicateItems(processed);
return processed;
}
function getQueryDisplayName(query, index) {
const trimmed = query?.name?.trim?.();
if (trimmed) return trimmed;
return `Query ${index + 1}`;
}
function resolveActiveQueryIndex(sectionConfig, options = {}) {
const queries = sectionConfig?.queries || [];
if (queries.length <= 1) return 0;
const stored = sectionConfig._selectedQueryIndex;
if (Number.isInteger(stored) && stored >= 0 && stored < queries.length) {
return stored;
}
if (options.initialize !== false) {
let index = 0;
if (sectionConfig.useRandomQuery === true) {
index = Math.floor(Math.random() * queries.length);
}
sectionConfig._selectedQueryIndex = index;
return index;
}
return 0;
}
function resolveQueriesToLoad(sectionConfig, options = {}) {
const queries = sectionConfig?.queries || [];
if (queries.length <= 1) return queries;
if (sectionConfig.useMultiQueryPicker === true) {
const index = resolveActiveQueryIndex(sectionConfig, options);
return [queries[index]];
}
if (sectionConfig.useRandomQuery === true) {
const randomIndex = Math.floor(Math.random() * queries.length);
return [queries[randomIndex]];
}
return queries;
}
function getMultiQueryPickerButtonLabel(sectionConfig) {
const customLabel = sectionConfig?.multiQueryPickerLabel?.trim?.();
if (customLabel) return customLabel;
const queries = sectionConfig?.queries || [];
if (queries.length <= 1) return getQueryDisplayName(queries[0], 0);
const index = resolveActiveQueryIndex(sectionConfig, { initialize: false });
return getQueryDisplayName(queries[index], index);
}
function getSectionTitleForMultiQuery(sectionConfig) {
if (
sectionConfig?.useMultiQueryPicker === true &&
sectionConfig?.useQueryNamesForSection === true &&
(sectionConfig?.queries?.length || 0) > 1
) {
const index = resolveActiveQueryIndex(sectionConfig, { initialize: true });
return getQueryDisplayName(sectionConfig.queries[index], index);
}
return sectionConfig?.name || '';
}
function getActiveViewMoreUrl(sectionConfig) {
const queries = sectionConfig?.queries || [];
if (sectionConfig?.useMultiQueryPicker === true && queries.length > 1) {
const index = resolveActiveQueryIndex(sectionConfig, { initialize: true });
const queryUrl = queries[index]?.viewMoreUrl;
if (queryUrl) return queryUrl;
}
return sectionConfig?.viewMoreUrl || null;
}
function getSectionCaption(sectionConfig) {
return sectionConfig?.caption?.trim() || null;
}
function getSectionCaptionUrl(sectionConfig) {
return sectionConfig?.captionUrl || null;
}
function updateSectionTitleText(sectionElement, titleText, captionText) {
if (!sectionElement) return;
if (titleText != null) {
const titleEl =
sectionElement.querySelector('.sectionTitleContainer .sectionTitle.sectionTitle-cards') ||
sectionElement.querySelector('.spotlight-section-title .emby-tab-button');
if (titleEl) titleEl.textContent = titleText;
}
if (captionText != null) {
const captionEl = sectionElement.querySelector('.sectionTitle.sectionCaption');
if (captionEl) captionEl.textContent = captionText;
}
}
function updateSectionViewMoreLink(sectionElement, url) {
if (!sectionElement) return;
const titleLink =
sectionElement.querySelector('a.sectionTitle-link') ||
sectionElement.querySelector('.spotlight-section-title a.emby-tab-button');
if (!titleLink) return;
if (url) {
titleLink.href = url;
return;
}
titleLink.removeAttribute('href');
}
/**
* Rebuild title/caption/view-more under an existing sectionTitleContainer without
* wiping .section-controls. Used for discovery-pending → resolved and enhance.
*/
function rebuildSectionTitleInPlace(sectionElement, sectionConfig) {
if (!sectionElement || !sectionConfig) return;
const titleContainer = sectionElement.querySelector('.sectionTitleContainer');
if (!titleContainer) {
// Spotlight titles live elsewhere — update text/link only
const title = getSectionTitleForMultiQuery(sectionConfig);
const viewMore = getActiveViewMoreUrl(sectionConfig);
updateSectionTitleText(sectionElement, title, getSectionCaption(sectionConfig));
updateSectionViewMoreLink(sectionElement, viewMore);
return;
}
const controls = titleContainer.querySelector('.section-controls');
Array.from(titleContainer.children).forEach((child) => {
if (child.classList?.contains('section-controls')) return;
// Also keep controls nested under sectionTitle-wrapper
if (child.querySelector?.(':scope > .section-controls')) {
const nested = child.querySelector(':scope > .section-controls');
if (nested) titleContainer.appendChild(nested);
}
child.remove();
});
appendSectionTitleContent(titleContainer, {
title: getSectionTitleForMultiQuery(sectionConfig),
caption: getSectionCaption(sectionConfig),
captionUrl: getSectionCaptionUrl(sectionConfig),
viewMoreUrl: getActiveViewMoreUrl(sectionConfig),
discoveryPending: false,
});
if (controls) {
const wrapper = titleContainer.querySelector(':scope > .sectionTitle-wrapper');
(wrapper || titleContainer).appendChild(controls);
}
}
function resolveSectionQueryTtl(sectionConfig) {
if (Number(sectionConfig?.ttl) >= 0) return Number(sectionConfig.ttl);
const fromConfig2 = window.KefinHomeConfig2?.CACHE?.DEFAULT_TTL;
if (Number(fromConfig2) >= 0) return Number(fromConfig2);
const fromConfig = window.KefinHomeConfig?.CACHE?.DEFAULT_TTL;
if (Number(fromConfig) >= 0) return Number(fromConfig);
return 60 * 60 * 1000;
}
function extractItemsFromQueryPayload(data) {
if (!data) return [];
if (Array.isArray(data)) return data;
if (Array.isArray(data.Items)) return data.Items;
return [];
}
/**
* Cache-aware load of the active query set (multi-query picker switches).
* Respects section TTL — does not force-bypass cache.
* @param {Object} sectionConfig
* @returns {Promise<{ items: Array, canPaintCached: boolean, itemsPromise: Promise<Array> }>}
*/
async function loadActiveSectionQueryItems(sectionConfig) {
if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) {
console.warn(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} has no queries array`);
return {
items: [],
canPaintCached: false,
itemsPromise: Promise.resolve([]),
};
}
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const ApiHelper = window.apiHelper;
const results = [];
const queriesToLoad = resolveQueriesToLoad(sectionConfig, { initialize: false });
const sectionTtl = resolveSectionQueryTtl(sectionConfig);
for (const query of queriesToLoad) {
let queryResult;
if (query.dataSource) {
queryResult = await ApiHelper.fetchFromDataSource(
query.dataSource,
query.queryOptions || {},
true,
query.dataSourceOptions,
);
} else {
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', {
sectionType: sectionConfig.type,
});
if (typeof queryUrl === 'string') {
queryResult = await ApiHelper.getQuery(queryUrl, {
useCache: true,
ttl: sectionTtl,
});
} else {
console.warn(`[KefinTweaks CardBuilder] Invalid query URL for section ${sectionConfig.id}`);
continue;
}
}
results.push(queryResult);
}
const resolveFreshItems = async () => {
if (results.length > 1) {
const merged = ApiHelper.mergeMultiQueryResults(results, sectionConfig);
// Kick SWR so _refreshPromise is attached when cache is stale
const raw =
typeof merged.result?.ensureData === 'function'
? await merged.result.ensureData()
: await (merged.result?.dataPromise ?? merged.result?.data);
const hasRefresh = results.some((r) => r && r._refreshPromise);
if (hasRefresh) {
const allData = await Promise.all(
results.map((r) => r._refreshPromise || Promise.resolve(r.data)),
);
const itemsByFreshQuery = [];
allData.forEach((data) => {
if (data?.Items) itemsByFreshQuery.push(data.Items);
else if (Array.isArray(data)) itemsByFreshQuery.push(data);
});
return postProcessItemsByQuery(sectionConfig, itemsByFreshQuery);
}
return extractItemsFromQueryPayload(raw);
}
if (results.length === 1) {
const qr = results[0];
// Kick SWR so _refreshPromise is attached when cache is stale
const raw =
typeof qr.ensureData === 'function'
? await qr.ensureData()
: await (qr.dataPromise ?? qr.data);
if (qr._refreshPromise) {
const fresh = await qr._refreshPromise;
return postProcessItems(sectionConfig, extractItemsFromQueryPayload(fresh));
}
return postProcessItems(sectionConfig, extractItemsFromQueryPayload(raw));
}
return [];
};
if (results.length > 1) {
const merged = ApiHelper.mergeMultiQueryResults(results, sectionConfig);
const syncItems = extractItemsFromQueryPayload(merged.result?.data);
// Multi-merge is usable only when every source reported a fresh cache hit.
const canPaintCached = results.every((r) => r && r.isStale === false && r.data != null);
return {
items: canPaintCached ? syncItems : [],
canPaintCached,
itemsPromise: canPaintCached ? Promise.resolve(syncItems) : resolveFreshItems(),
};
}
if (results.length === 1) {
const qr = results[0];
const syncItems = postProcessItems(sectionConfig, extractItemsFromQueryPayload(qr?.data));
// Strict: only paint when apiHelper reported a non-stale cache hit with a payload.
const canPaintCached = qr?.isStale === false && qr?.data != null;
return {
items: canPaintCached ? syncItems : [],
canPaintCached,
itemsPromise: canPaintCached ? Promise.resolve(syncItems) : resolveFreshItems(),
};
}
return {
items: [],
canPaintCached: false,
itemsPromise: Promise.resolve([]),
};
}
/**
* Refresh section queries by re-executing them without cache
* @param {Object} sectionConfig - Section configuration with queries array
* @returns {Promise<Array>} - Processed items array
*/
async function refreshSectionQueries(sectionConfig) {
if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) {
console.warn(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} has no queries array`);
return [];
}
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
const ApiHelper = window.apiHelper;
const results = [];
const queriesToLoad = resolveQueriesToLoad(sectionConfig, { initialize: false });
// Process each query in the active set
for (const query of queriesToLoad) {
let queryResult;
if (query.dataSource) {
// Handle cache-based data sources
queryResult = await ApiHelper.fetchFromDataSource(
query.dataSource,
query.queryOptions || {},
false,
query.dataSourceOptions,
);
} else {
// Build and execute query
const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', {
sectionType: sectionConfig.type,
});
if (typeof queryUrl === 'string') {
// Standard query - use useCache: false to bypass cache
// When useCache is false, getQuery returns a Promise<data> directly
const data = await ApiHelper.getQuery(queryUrl, {
useCache: false,
});
// Wrap in expected format for consistency
queryResult = {
data: data,
dataPromise: Promise.resolve(data),
isStalePromise: Promise.resolve(false),
};
} else {
console.warn(`[KefinTweaks CardBuilder] Invalid query URL for section ${sectionConfig.id}`);
continue;
}
}
results.push(queryResult);
}
// If multiple queries, merge results using section-level sortBy/sortOrder
let finalResult;
if (results.length > 1) {
finalResult = ApiHelper.mergeMultiQueryResults(results, sectionConfig);
// Extract items from merged result
const items = finalResult.result?.data?.Items || finalResult.result?.data || [];
return items;
} else if (results.length === 1) {
// Single query result
const data = results[0].data || results[0];
const items = data?.Items || data || [];
return postProcessItems(sectionConfig, items);
}
return [];
}
/**
* Toggle data-refreshing (drives refresh-button spin CSS). Manual refresh only —
* do not set true during enhance / stale revalidate / multi-query switch.
* @param {HTMLElement|null|undefined} el
* @param {boolean} refreshing
*/
function setSectionRefreshing(el, refreshing) {
if (!el?.dataset) return;
el.dataset.refreshing = refreshing ? 'true' : 'false';
}
/**
* Paper-icon button with Material Icon on a child span (native Jellyfin pattern).
* Keep material-icons off the button — paper-icon-button-light sets font-family: inherit.
*/
function createPaperIconButton({ className = '', icon = '', title = '', ariaLabel = '', showFocus = false } = {}) {
const button = document.createElement('button');
button.type = 'button';
button.className = `${className} paper-icon-button-light`.trim();
if (showFocus) button.classList.add('show-focus');
if (title) button.title = title;
if (ariaLabel || title) button.setAttribute('aria-label', ariaLabel || title);
const iconSpan = document.createElement('span');
iconSpan.className = `material-icons ${icon}`.trim();
iconSpan.setAttribute('aria-hidden', 'true');
button.appendChild(iconSpan);
return button;
}
/**
* Show checkmark icon temporarily, then revert to refresh icon
* @param {HTMLElement} button - The refresh button element
*/
async function showRefreshComplete(button) {
// Wait a brief moment for the spinning animation to complete smoothly
// This ensures the transition from spinning to checkmark looks natural
await new Promise((resolve) => requestAnimationFrame(resolve));
const icon = button.querySelector('.material-icons') || button;
// Change to checkmark icon (state classes on button for CSS; glyph on icon span)
button.classList.remove('refresh', 'icon-fade-out');
button.classList.add('check_circle', 'icon-fade-in');
icon.classList.remove('refresh', 'icon-fade-out');
icon.classList.add('check_circle', 'icon-fade-in');
await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => setTimeout(resolve, 0));
// Wait for fade in to complete
await new Promise((resolve) => setTimeout(resolve, 200));
button.classList.remove('icon-fade-in');
icon.classList.remove('icon-fade-in');
// After 1.5 seconds, fade out checkmark and fade in refresh icon
setTimeout(async () => {
// Fade out checkmark
button.classList.add('icon-fade-out');
icon.classList.add('icon-fade-out');
await new Promise((resolve) => setTimeout(resolve, 200));
// Change to refresh icon
button.classList.remove('check_circle', 'icon-fade-out');
button.classList.add('refresh', 'icon-fade-in');
icon.classList.remove('check_circle', 'icon-fade-out');
icon.classList.add('refresh', 'icon-fade-in');
// Wait for fade in to complete
await new Promise((resolve) => setTimeout(resolve, 200));
button.classList.remove('icon-fade-in');
icon.classList.remove('icon-fade-in');
}, 1500);
}
/** After an empty section is removed, let discovery infinite-scroll refill lead margin. */
function notifyDiscoveryLeadFillAfterSectionRemove() {
window.homeScreen3?.scheduleMaybeFillDiscoveryViewport?.();
}
async function replaceSectionContent(sectionConfig, sectionElement, freshItems) {
if (freshItems.length === 0) {
sectionElement.remove();
notifyDiscoveryLeadFillAfterSectionRemove();
return null;
}
let finalCardFormat = sectionConfig.cardFormat;
const currentCardFormat = sectionElement.getAttribute('data-card-format');
if (currentCardFormat) {
finalCardFormat = currentCardFormat;
}
const displayTitle = getSectionTitleForMultiQuery(sectionConfig);
const activeViewMoreUrl = getActiveViewMoreUrl(sectionConfig);
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
const refreshed = refreshSpotlight(sectionElement, freshItems, {
name: displayTitle,
viewMoreUrl: activeViewMoreUrl,
spotlightConfig: sectionConfig.spotlightConfig,
dataItems: [],
keepCurrentSlide: false,
sectionConfig,
});
const content = refreshed.content;
ensureCardBorders(content);
setSectionRefreshing(content, false);
attachSectionControlButtons(sectionConfig, content, freshItems);
ensureSectionRevealObservation(content);
return content;
}
// In-place card refresh: keep section shell + sticky is=emby-itemscontainer
rebuildSectionTitleInPlace(sectionElement, {
...sectionConfig,
name: displayTitle,
viewMoreUrl: activeViewMoreUrl,
});
// Ensure title text reflects multi-query resolution even if config.name lags
updateSectionTitleText(sectionElement, displayTitle, getSectionCaption(sectionConfig));
updateSectionViewMoreLink(sectionElement, activeViewMoreUrl);
if (!sectionElement.querySelector('.itemsContainer')) {
const content = createSectionScrollableContainer(
freshItems,
displayTitle,
activeViewMoreUrl,
sectionConfig.overflowCard,
finalCardFormat,
sectionConfig,
);
content.style.cssText = sectionElement.style.cssText;
content.className = sectionElement.className;
Array.from(sectionElement.attributes).forEach((attr) => {
content.setAttribute(attr.name, attr.value);
});
stripCopiedSkeletonSectionClasses(content);
unobserveSkeletonShimmer(sectionElement);
sectionElement.replaceWith(content);
ensureCardBorders(content);
attachSectionControlButtons(sectionConfig, content, freshItems);
setSectionRefreshing(content, false);
registerLazyImagesInSection(content);
ensureSectionRevealObservation(content);
return content;
}
replaceItemsContainerContents(sectionElement, freshItems, {
...sectionConfig,
cardFormat: finalCardFormat,
});
unobserveSkeletonShimmer(sectionElement);
setSectionRefreshing(sectionElement, false);
ensureSectionRevealObservation(sectionElement);
return sectionElement;
}
/**
* Immediately swap section body to skeleton cards while a query fetch is in flight.
* @returns {HTMLElement} The skeleton section element now in the DOM
*/
function showSectionQuerySkeletons(sectionConfig, sectionElement) {
let finalCardFormat = sectionConfig.cardFormat;
const currentCardFormat = sectionElement.getAttribute('data-card-format');
if (currentCardFormat) {
finalCardFormat = currentCardFormat;
}
if (finalCardFormat === 'random' || finalCardFormat === 'Random') {
finalCardFormat = getRandomCardFormat();
}
const displayTitle = getSectionTitleForMultiQuery(sectionConfig);
const activeViewMoreUrl = getActiveViewMoreUrl(sectionConfig);
let skeleton;
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
const spotlightSettings = { ...(sectionConfig.spotlightConfig || {}) };
if (activeViewMoreUrl) spotlightSettings.viewMoreUrl = activeViewMoreUrl;
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
spotlightSettings.caption = sectionConfig.caption;
skeleton = createSkeletonSpotlightSection(displayTitle, spotlightSettings);
} else {
skeleton = createProgressivelyEnhancedScrollableContainer(
displayTitle,
activeViewMoreUrl,
finalCardFormat,
sectionConfig.overflowCard,
sectionConfig,
);
}
skeleton.style.cssText = sectionElement.style.cssText;
skeleton.className = sectionElement.className;
Array.from(sectionElement.attributes).forEach((attr) => {
skeleton.setAttribute(attr.name, attr.value);
});
if (finalCardFormat) {
skeleton.setAttribute('data-card-format', finalCardFormat);
}
applySectionPresentationAttrs(skeleton, sectionConfig);
// Preserve data-refreshing from the replaced section if already set (manual refresh);
// do not force true here — multi-query / enhance must not spin the button.
const oldItemsContainer = sectionElement.querySelector('.itemsContainer');
const layoutFromDom = oldItemsContainer?.getAttribute('data-layout');
const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
const gapless = oldItemsContainer
? oldItemsContainer.getAttribute('data-gapless') === 'true'
: resolveUseGaplessCards(sectionConfig);
sectionElement.replaceWith(skeleton);
applyItemsLayoutState(skeleton, layout, gapless);
attachSectionControlButtons(sectionConfig, skeleton, []);
ensureSectionRevealObservation(skeleton);
observeSkeletonShimmer(skeleton);
return skeleton;
}
function attachMultiQueryPicker(sectionConfig, sectionElement, titleContainer) {
const queries = sectionConfig?.queries || [];
if (queries.length <= 1 || sectionConfig.useMultiQueryPicker !== true) {
titleContainer.querySelectorAll('.multi-query-picker-button').forEach((btn) => btn.remove());
return;
}
titleContainer.querySelectorAll('.multi-query-picker-button').forEach((btn) => btn.remove());
resolveActiveQueryIndex(sectionConfig, { initialize: true });
updateSectionTitleText(sectionElement, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
updateSectionViewMoreLink(sectionElement, getActiveViewMoreUrl(sectionConfig));
const selectButton = document.createElement('button');
selectButton.type = 'button';
selectButton.className = 'emby-button raised multi-query-picker-button';
if (isTvLayout()) selectButton.classList.add('show-focus');
selectButton.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;';
selectButton.setAttribute('aria-label', 'Select query');
const controlsMount = titleContainer.querySelector('.section-controls');
const titleLink = titleContainer.querySelector('a.sectionTitle-link');
const sectionTitle = titleContainer.querySelector('.sectionTitle');
// controls may live under .sectionTitle-wrapper when a caption is present
if (controlsMount?.parentNode) {
controlsMount.parentNode.insertBefore(selectButton, controlsMount);
} else if (titleLink?.parentNode) {
titleLink.parentNode.insertBefore(selectButton, titleLink.nextSibling);
} else if (sectionTitle?.parentNode) {
sectionTitle.parentNode.insertBefore(selectButton, sectionTitle.nextSibling);
} else {
titleContainer.appendChild(selectButton);
}
const MULTI_QUERY_MODAL_ID = 'kefin-multi-query-picker';
const multiQueryPopoverCss = `
.kefin-multi-query-modal {
background-color: rgba(0, 0, 0, 0.95) !important;
z-index: 1001;
min-width: 12rem;
max-width: min(90vw, 320px);
max-height: min(60vh, 420px);
overflow: hidden;
padding: 0 !important;
min-width: initial !important;
}
.kefin-multi-query-modal [data-name="kefin-modal-content"] {
overflow-y: auto !important;
max-height: min(60vh, 420px);
}
.kefinTweaks-popover.multiQueryPopover {
margin-top: 0 !important;
display: grid;
}
`;
let multiQueryStyleEl = document.getElementById('kefinTweaks-multiQueryPopover-style');
if (!multiQueryStyleEl) {
multiQueryStyleEl = document.createElement('style');
multiQueryStyleEl.id = 'kefinTweaks-multiQueryPopover-style';
document.head.appendChild(multiQueryStyleEl);
}
multiQueryStyleEl.textContent = multiQueryPopoverCss;
selectButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (!window.ModalSystem?.create) return;
if (window.ModalSystem.isOpen?.(MULTI_QUERY_MODAL_ID)) {
window.ModalSystem.close(MULTI_QUERY_MODAL_ID);
return;
}
const list = document.createElement('div');
list.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup';
const selectedIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false });
let selectedItemElement = null;
const selectQuery = async (index) => {
window.ModalSystem.close(MULTI_QUERY_MODAL_ID);
if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) {
return;
}
sectionConfig._selectedQueryIndex = index;
// Clear cached section URL so resolve uses the newly selected query
sectionConfig.viewMoreUrl = null;
const sectionIdAttr =
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(String(sectionConfig.id))
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
let activeSectionEl =
document.querySelector(
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
) || sectionElement;
const livePicker = activeSectionEl.querySelector('.multi-query-picker-button');
if (livePicker) {
livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
}
updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
try {
let viewMoreUrl = getActiveViewMoreUrl(sectionConfig);
if (!viewMoreUrl && typeof window.sectionHelper?.resolveViewMoreUrl === 'function') {
viewMoreUrl = await window.sectionHelper.resolveViewMoreUrl(sectionConfig);
}
if (viewMoreUrl) {
sectionConfig.viewMoreUrl = viewMoreUrl;
}
updateSectionViewMoreLink(activeSectionEl, viewMoreUrl || null);
const loaded = await loadActiveSectionQueryItems(sectionConfig);
let newContent = null;
if (loaded.canPaintCached) {
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items);
} else {
activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl);
const freshItems = await loaded.itemsPromise;
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems);
}
if (typeof sectionConfig._onMultiQueryChange === 'function') {
sectionConfig._onMultiQueryChange(index, sectionConfig, newContent);
}
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error switching query:', error);
const live =
document.querySelector(
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
) || activeSectionEl;
setSectionRefreshing(live, false);
}
};
queries.forEach((query, index) => {
const itemElement = document.createElement('button');
itemElement.type = 'button';
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem emby-button button-flat';
if (index === selectedIndex) {
itemElement.classList.add('selected', 'is-active');
selectedItemElement = itemElement;
}
itemElement.textContent = getQueryDisplayName(query, index);
itemElement.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
selectQuery(index);
});
list.appendChild(itemElement);
});
window.ModalSystem.create({
id: MULTI_QUERY_MODAL_ID,
title: null,
content: list,
footer: null,
showCloseButton: false,
closeOnBackdrop: true,
closeOnEscape: true,
fixedSize: false,
returnFocus: selectButton,
initialFocus: selectedItemElement,
anchor: selectButton,
dialogClassName: 'kefin-multi-query-modal',
// Match breadcrumb season popover: page scrolls unless the list itself overflows
lockPageScroll: false,
onOpen: (modal) => {
if (modal.backdrop) modal.backdrop.style.background = 'transparent';
// Document-absolute shell via ModalSystem.positionNearAnchor.
if (modal.dialogContent) {
modal.dialogContent.style.padding = '0';
modal.dialogContent.style.overflowY = 'auto';
modal.dialogContent.style.minHeight = '0';
modal.dialogContent.style.flex = '0 1 auto';
modal.dialogContent.style.overscrollBehavior = 'auto';
}
if (modal.dialog) {
modal.dialog.style.overscrollBehavior = 'auto';
}
if (selectedItemElement) {
requestAnimationFrame(() => {
selectedItemElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
}
},
});
});
}
function ensureSectionControlsMount(titleContainer) {
const wrapper = titleContainer.querySelector(':scope > .sectionTitle-wrapper');
const mountParent = wrapper || titleContainer;
let controls = mountParent.querySelector(':scope > .section-controls');
if (!controls) {
controls = titleContainer.querySelector(':scope > .section-controls');
}
if (!controls) {
controls = document.createElement('div');
controls.className = 'section-controls';
const inline = document.createElement('div');
inline.className = 'section-controls-inline';
const moreButton = createPaperIconButton({
className: 'section-controls-more',
icon: 'more_vert',
title: 'More',
showFocus: isTvLayout(),
});
controls.appendChild(inline);
controls.appendChild(moreButton);
mountParent.appendChild(controls);
} else if (controls.parentNode !== mountParent) {
mountParent.appendChild(controls);
}
const inline = controls.querySelector('.section-controls-inline');
// Controls-only group must not trap L/R — title + controls share one row (below).
inline?.classList.remove('focuscontainer-x');
// TV: title link + section controls in one focuscontainer-x (spotlight uses headerContainer).
if (isTvLayout() && titleContainer.classList.contains('sectionTitleContainer')) {
mountParent.classList.add('focuscontainer-x');
}
return {
controls,
inline,
moreButton: controls.querySelector('.section-controls-more'),
};
}
function normalizeStaticSectionItems(sectionConfig) {
const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || '';
const serverId = typeof ApiClient !== 'undefined' && typeof ApiClient.serverId === 'function' ? ApiClient.serverId() : '';
const normalizeTemplate = (value) =>
(value || '').replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot).replace(/\$\{serverId\}/g, serverId);
const visibleItems = (sectionConfig.items || []).filter((item) => item && item.hidden !== true);
return visibleItems.map((item, index) => ({
Name: item.Name,
Id: item.Id || 'static-' + sectionConfig.id + '-' + index,
Type: item.Type || 'Folder',
posterUrl: normalizeTemplate(item.posterUrl),
thumbUrl: normalizeTemplate(item.thumbUrl),
squareUrl: normalizeTemplate(item.squareUrl),
imageUrl: normalizeTemplate(item.imageUrl),
cardUrl: normalizeTemplate(item.cardUrl),
backdropUrl: normalizeTemplate(item.backdropUrl),
bannerUrl: normalizeTemplate(item.bannerUrl),
logoUrl: normalizeTemplate(item.logoUrl),
CustomFooterText: item.cardFooter || undefined,
}));
}
/**
* Refresh section content in place (used by section control registry).
* @returns {Promise<HTMLElement|null>} refresh button on the new section, if any
*/
async function executeSectionRefresh(sectionConfig, sectionElement) {
setSectionRefreshing(sectionElement, true);
try {
const hasStaticItems = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
let freshItems;
if (hasStaticItems) {
if (!sectionHasRandomQuerySort(sectionConfig)) {
setSectionRefreshing(sectionElement, false);
const refreshButton = sectionElement.querySelector('.section-refresh-button');
if (refreshButton) {
showRefreshComplete(refreshButton);
}
return refreshButton || null;
}
freshItems = postProcessItems(sectionConfig, normalizeStaticSectionItems(sectionConfig));
if (freshItems.length === 0) {
setSectionRefreshing(sectionElement, false);
return null;
}
} else {
freshItems = await refreshSectionQueries(sectionConfig);
if (freshItems.length === 0) {
sectionElement.remove();
notifyDiscoveryLeadFillAfterSectionRemove();
return null;
}
}
let finalCardFormat = sectionConfig.cardFormat;
const currentCardFormat = sectionElement.getAttribute('data-card-format');
if (currentCardFormat) {
finalCardFormat = currentCardFormat;
}
let content = null;
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
const refreshed = refreshSpotlight(sectionElement, freshItems, {
name: getSectionTitleForMultiQuery(sectionConfig),
viewMoreUrl: getActiveViewMoreUrl(sectionConfig),
spotlightConfig: sectionConfig.spotlightConfig,
dataItems: [],
keepCurrentSlide: false,
sectionConfig,
});
content = refreshed.content;
ensureCardBorders(content);
setSectionRefreshing(content, false);
const newButton = attachSectionControlButtons(sectionConfig, content, freshItems);
if (newButton) {
showRefreshComplete(newButton);
}
return newButton;
}
// In-place card refresh: keep section shell + sticky is=emby-itemscontainer
if (sectionElement.querySelector('.itemsContainer')) {
rebuildSectionTitleInPlace(sectionElement, sectionConfig);
updateSectionTitleText(sectionElement, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
updateSectionViewMoreLink(sectionElement, getActiveViewMoreUrl(sectionConfig));
replaceItemsContainerContents(sectionElement, freshItems, {
...sectionConfig,
cardFormat: finalCardFormat,
});
unobserveSkeletonShimmer(sectionElement);
setSectionRefreshing(sectionElement, false);
ensureSectionRevealObservation(sectionElement);
const newButton = sectionElement.querySelector('.section-refresh-button');
if (newButton) {
showRefreshComplete(newButton);
}
return newButton;
}
content = createSectionScrollableContainer(
freshItems,
getSectionTitleForMultiQuery(sectionConfig),
getActiveViewMoreUrl(sectionConfig),
sectionConfig.overflowCard,
finalCardFormat,
sectionConfig,
);
content.style.cssText = sectionElement.style.cssText;
content.className = sectionElement.className;
Array.from(sectionElement.attributes).forEach((attr) => {
content.setAttribute(attr.name, attr.value);
});
invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller'));
stripCopiedSkeletonSectionClasses(content);
unobserveSkeletonShimmer(sectionElement);
sectionElement.replaceWith(content);
applyItemsLayoutState(content, resolveItemsLayout(sectionConfig), resolveUseGaplessCards(sectionConfig));
ensureCardBorders(content);
const newButton = attachSectionControlButtons(sectionConfig, content, freshItems);
setSectionRefreshing(content, false);
registerLazyImagesInSection(content);
ensureSectionRevealObservation(content);
if (newButton) {
showRefreshComplete(newButton);
}
return newButton;
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error refreshing section:', error);
setSectionRefreshing(sectionElement, false);
return null;
}
}
function attachSectionControlButtons(sectionConfig, sectionElement, cachedItems) {
const titleContainer =
sectionElement.querySelector('.sectionTitleContainer') || sectionElement.querySelector('.spotlight-section-title-container');
if (!titleContainer) return null;
titleContainer
.querySelectorAll(':scope > .show-all-button, :scope > .section-refresh-button, :scope > .section-configure-button')
.forEach((el) => el.remove());
ensureSectionControlsMount(titleContainer);
const definitions = getSectionControlDefinitions(sectionConfig, sectionElement, cachedItems);
const refreshButton = renderSectionControls(sectionConfig, sectionElement, titleContainer, definitions);
const itemsContainer = sectionElement.querySelector('.itemsContainer');
const layout = itemsContainer?.getAttribute('data-layout') || resolveItemsLayout(sectionConfig);
const showAllBtn = titleContainer.querySelector('.show-all-button');
if (showAllBtn) {
const resolved = layout === 'grid' ? layout : 'row';
showAllBtn.title = ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row;
showAllBtn.setAttribute('aria-label', showAllBtn.title);
setShowAllIcon(showAllBtn, nextItemsLayout(resolved));
}
attachMultiQueryPicker(sectionConfig, sectionElement, titleContainer);
return refreshButton;
}
let cardUserDataListenerStarted = false;
let cardLibraryChangedListenerStarted = false;
let pendingLibraryUpdatedIds = new Set();
let libraryUpdatedFlushTimer = null;
let libraryUpdatedFlushRunning = false;
const LIBRARY_UPDATED_DEBOUNCE_MS = 750;
const LIVE_SYNC_REBUILD_BATCH_SIZE = 8;
// Match apiHelper DEFAULT_ITEM_FIELDS so rebuilt cards keep aspect/overlays/indicators.
const LIVE_SYNC_ITEM_FIELDS =
'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount';
function scheduleLibraryUpdatedFlush() {
if (libraryUpdatedFlushTimer) clearTimeout(libraryUpdatedFlushTimer);
libraryUpdatedFlushTimer = setTimeout(() => {
void flushVisibleCardLibraryUpdates();
}, LIBRARY_UPDATED_DEBOUNCE_MS);
}
function yieldToMainThread() {
return new Promise((resolve) => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve());
} else {
setTimeout(resolve, 0);
}
});
}
function escapeCssIdentForCardSync(value) {
return typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(String(value))
: String(value).replace(/["\\]/g, '\\$&');
}
function getPrimaryUserIdForCardSync() {
return typeof ApiClient !== 'undefined' && typeof ApiClient.getCurrentUserId === 'function' ? ApiClient.getCurrentUserId() : null;
}
function isPrimaryUserDataChange(userId) {
const primaryId = getPrimaryUserIdForCardSync();
if (!primaryId) return false;
if (!userId) return true;
return userId === primaryId;
}
/**
* Keep data-positionticks on visible cards aligned with primary-user playstate.
* Driven by UserDataChanged (same event Jellyfin uses for progress bars) — not DOM observation.
*/
function updateCardResumeAttributes(itemId, userData) {
if (!itemId || !userData) return;
const cards = findVisibleCardsForItemId(itemId);
if (!cards.length) return;
const ticks = userData.PlaybackPositionTicks;
cards.forEach((card) => {
if (userData.Played === true) {
card.removeAttribute('data-positionticks');
return;
}
if (typeof ticks === 'number' && ticks > 0) {
card.setAttribute('data-positionticks', String(ticks));
} else {
card.removeAttribute('data-positionticks');
}
});
}
function updateCardWatchlistButtons(itemId, userData) {
if (!state.watchlist) return;
if (!itemId || !userData || !('Likes' in userData)) return;
const isLiked = userData.Likes === true;
const likedHelper = window.KefinWatchlistLikedIds;
if (likedHelper?.set) {
likedHelper.set(itemId, isLiked);
}
itemIdDomVariants(itemId).forEach((variant) => {
const escapedId = escapeCssIdentForCardSync(variant);
document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => {
button.dataset.active = isLiked ? 'true' : 'false';
button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
});
});
}
function updateCardFavoriteButtons(itemId, userData) {
if (!itemId || !userData || !('IsFavorite' in userData)) return;
const isFavorite = userData.IsFavorite === true;
itemIdDomVariants(itemId).forEach((variant) => {
const escapedId = escapeCssIdentForCardSync(variant);
document.querySelectorAll(
`[is="emby-ratingbutton"][data-id="${escapedId}"], button[data-isfavorite][data-id="${escapedId}"]`
).forEach((button) => {
button.setAttribute('data-isfavorite', isFavorite ? 'true' : 'false');
button.title = isFavorite ? 'Remove from favorites' : 'Add to favorites';
});
});
}
function applyUserDataEntriesToCards(entries, userId) {
if (!isPrimaryUserDataChange(userId)) return;
entries.forEach((entry) => {
if (entry?.ItemId) {
updateCardResumeAttributes(entry.ItemId, entry);
updateCardWatchlistButtons(entry.ItemId, entry);
updateCardFavoriteButtons(entry.ItemId, entry);
}
});
}
function startCardUserDataListener() {
if (cardUserDataListenerStarted) return;
if (!window.websocketHelper?.listen) {
setTimeout(startCardUserDataListener, 1000);
return;
}
window.websocketHelper.listen('UserDataChanged', (msg) => {
const userId = msg?.Data?.UserId;
const entries = msg?.Data?.UserDataList;
if (!Array.isArray(entries) || !entries.length) return;
applyUserDataEntriesToCards(entries, userId);
});
cardUserDataListenerStarted = true;
console.log('[KefinTweaks CardBuilder] Listening for UserDataChanged to sync card resume attributes');
}
function liveSyncLog(...args) {
console.log('[KefinTweaks CardBuilder][LiveSync]', ...args);
}
function liveSyncWarn(...args) {
console.warn('[KefinTweaks CardBuilder][LiveSync]', ...args);
}
/** Strip GUID dashes for comparison (server LibraryChanged often uses "N" format). */
function normalizeItemIdKey(id) {
return String(id || '')
.replace(/-/g, '')
.toLowerCase();
}
/** Candidate data-id strings for DOM queries (dashed + undashed). */
function itemIdDomVariants(id) {
const raw = String(id || '').trim();
if (!raw) return [];
const variants = new Set([raw]);
const naked = normalizeItemIdKey(raw);
if (/^[0-9a-f]{32}$/.test(naked)) {
variants.add(naked);
variants.add(
`${naked.slice(0, 8)}-${naked.slice(8, 12)}-${naked.slice(12, 16)}-${naked.slice(16, 20)}-${naked.slice(20)}`,
);
}
return Array.from(variants);
}
function findVisibleCardsForItemId(itemId) {
if (!itemId) return [];
const found = new Set();
itemIdDomVariants(itemId).forEach((variant) => {
const escapedId = escapeCssIdentForCardSync(variant);
document.querySelectorAll(`.card[data-id="${escapedId}"]:not(.skeleton-card)`).forEach((card) => {
found.add(card);
});
});
return Array.from(found);
}
function resolveCardRebuildArgs(card) {
const sectionEl = card?.closest?.('[data-section-id]');
const sectionId = sectionEl?.getAttribute('data-section-id') || sectionEl?.dataset?.sectionId || null;
const runtimeCfg = sectionId ? window.KefinHomeScreenSectionRuntime?.get?.(sectionId)?.effectiveConfig : null;
const cardFormat = sectionEl?.getAttribute('data-card-format') || runtimeCfg?.cardFormat || null;
let overflowCard;
if (runtimeCfg && typeof runtimeCfg.overflowCard === 'boolean') {
overflowCard = runtimeCfg.overflowCard;
} else if (/\boverflow(?:Portrait|Backdrop|Square|Banner)Card\b/.test(card?.className || '')) {
overflowCard = true;
} else if (/\b(?:portrait|backdrop|square|banner)Card\b/.test(card?.className || '')) {
overflowCard = false;
} else {
// Home rows default to overflow sizing.
overflowCard = true;
}
const useParentCard = !!runtimeCfg?.useParentCard;
const borderStyle =
runtimeCfg?.borderStyle || sectionEl?.dataset?.borderStyle || sectionEl?.dataset?.border || null;
return { cardFormat, overflowCard, useParentCard, borderStyle, sectionId };
}
/** Image tags the card builder may put in data-src (any format / fallback path). */
function collectItemImageTagsForLiveSync(item) {
const tags = new Set();
const add = (t) => {
if (t != null && t !== '') tags.add(String(t));
};
const it = item?.ImageTags;
if (it) {
add(it.Primary);
add(it.Thumb);
add(it.Logo);
add(it.Banner);
add(it.Art);
add(it.Disc);
}
add(item?.BackdropImageTags?.[0]);
add(item?.PrimaryImageTag);
add(item?.SeriesPrimaryImageTag);
add(item?.SeriesThumbImageTag);
add(item?.ParentThumbImageTag);
add(item?.ParentBackdropImageTags?.[0]);
add(item?.ParentPrimaryImageTag);
add(item?.ParentLogoImageTag);
return tags;
}
function extractTagFromImageUrl(url) {
if (!url) return '';
try {
const m = String(url).match(/[?&]tag=([^&]*)/i);
return m?.[1] ? decodeURIComponent(m[1]) : '';
} catch (e) {
return '';
}
}
/**
* Skip DOM replace when painted card signals already match the fresh item.
* Compares name, image tag in data-src, resume ticks, played / unplayed indicators.
*/
function cardNeedsLiveSyncRebuild(oldCard, freshItem) {
if (!oldCard || !freshItem) return false;
const img =
oldCard.querySelector?.('.cardImageContainer[data-src]') ||
oldCard.querySelector?.('.cardImageContainer') ||
null;
const dataSrc = img?.getAttribute('data-src') || '';
const paintedTag = extractTagFromImageUrl(dataSrc);
const paintedName = (img?.getAttribute('aria-label') || '').trim();
const paintedTicks = oldCard.getAttribute('data-positionticks') || '';
const paintedPlayed = !!oldCard.querySelector?.('.playedIndicator');
const paintedUnplayed = (oldCard.querySelector?.('.countIndicator.indicator')?.textContent || '').trim();
const paintedMediaSources = (oldCard.querySelector?.('.mediaSourceIndicator')?.textContent || '').trim();
const expectedName = String(freshItem.Name || 'Unknown').trim();
const nameChanged = paintedName !== expectedName && paintedName !== String(freshItem.Name || '').trim();
const freshTags = collectItemImageTagsForLiveSync(freshItem);
const hadImage = !!dataSrc;
const hasImageTags = freshTags.size > 0;
let imageChanged = false;
if (paintedTag) {
// Painted tag no longer on the item → artwork changed for this card.
imageChanged = !freshTags.has(paintedTag);
} else if (!hadImage && hasImageTags) {
imageChanged = true;
} else if (hadImage && !hasImageTags) {
imageChanged = true;
}
const freshPlayed = freshItem.UserData?.Played === true;
const freshTicks =
!freshPlayed && typeof freshItem.UserData?.PlaybackPositionTicks === 'number' && freshItem.UserData.PlaybackPositionTicks > 0
? String(freshItem.UserData.PlaybackPositionTicks)
: '';
const ticksChanged = paintedTicks !== freshTicks;
const playedChanged = paintedPlayed !== freshPlayed;
const freshUnplayed =
freshItem.UserData?.UnplayedItemCount && freshItem.UserData.UnplayedItemCount > 0
? String(freshItem.UserData.UnplayedItemCount)
: '';
const unplayedChanged = paintedUnplayed !== freshUnplayed;
const freshMediaSources =
typeof freshItem.MediaSourceCount === 'number' && freshItem.MediaSourceCount > 1
? String(freshItem.MediaSourceCount)
: '';
const mediaSourcesChanged = paintedMediaSources !== freshMediaSources;
const needs = nameChanged || imageChanged || ticksChanged || playedChanged || unplayedChanged || mediaSourcesChanged;
if (!needs) {
liveSyncLog('Skipping rebuild — no significant painted changes', {
itemId: freshItem.Id,
name: expectedName,
paintedTag,
});
}
return needs;
}
function replaceVisibleCardWithFreshItem(oldCard, freshItem) {
if (!oldCard || !freshItem) return false;
if (!cardNeedsLiveSyncRebuild(oldCard, freshItem)) return false;
const { cardFormat, overflowCard, useParentCard, borderStyle, sectionId } = resolveCardRebuildArgs(oldCard);
liveSyncLog('Rebuilding card', {
itemId: freshItem.Id,
name: freshItem.Name,
sectionId,
cardFormat,
overflowCard,
useParentCard,
borderStyle,
imagePrimaryTag: freshItem.ImageTags?.Primary || null,
});
const next = createCardElement(
freshItem,
overflowCard,
cardFormat,
freshItem.cardFooter || freshItem.CustomFooterText || null,
useParentCard,
borderStyle,
);
const index = oldCard.getAttribute('data-index');
if (index != null) next.setAttribute('data-index', index);
const hadFocus = document.activeElement === oldCard;
oldCard.replaceWith(next);
// New card has data-src + .lazy but is not in any IntersectionObserver yet.
registerLazyImagesIn(next);
if (hadFocus && typeof next.focus === 'function') {
try {
next.focus({ preventScroll: true });
} catch (e) {
next.focus();
}
}
return true;
}
async function flushVisibleCardLibraryUpdates() {
libraryUpdatedFlushTimer = null;
if (libraryUpdatedFlushRunning) {
// Keep pending ids; schedule one follow-up after the in-flight flush
if (pendingLibraryUpdatedIds.size) {
liveSyncLog('Flush already running — deferring follow-up', {
pendingSize: pendingLibraryUpdatedIds.size,
});
scheduleLibraryUpdatedFlush();
}
return;
}
const ids = Array.from(pendingLibraryUpdatedIds);
pendingLibraryUpdatedIds.clear();
liveSyncLog('Flush start', { pendingIds: ids });
if (!ids.length) return;
libraryUpdatedFlushRunning = true;
try {
const matchCounts = {};
const idsToFetch = ids.filter((id) => {
const count = findVisibleCardsForItemId(id).length;
matchCounts[id] = count;
return count > 0;
});
liveSyncLog('Visible card matches', matchCounts);
if (!idsToFetch.length) {
liveSyncLog('No on-page cards for updated ids — skipping fetch');
return;
}
if (typeof ApiClient === 'undefined' || typeof ApiClient.getItems !== 'function') {
liveSyncWarn('ApiClient.getItems unavailable — cannot refresh cards');
return;
}
const userId = ApiClient.getCurrentUserId?.();
liveSyncLog('Fetching items', { userId, idsToFetch, fields: LIVE_SYNC_ITEM_FIELDS });
const response = await ApiClient.getItems(userId, {
Ids: idsToFetch.join(','),
Recursive: false,
Fields: LIVE_SYNC_ITEM_FIELDS,
});
const itemsById = new Map();
(response?.Items || []).forEach((item) => {
if (!item?.Id) return;
itemsById.set(normalizeItemIdKey(item.Id), item);
});
liveSyncLog('Fetch result', {
requested: idsToFetch.length,
returned: itemsById.size,
itemSummaries: Array.from(itemsById.values()).map((item) => ({
id: item.Id,
name: item.Name,
primaryTag: item.ImageTags?.Primary || null,
})),
});
// Flatten id → cards into a rebuild work list, then process in rAF batches
const work = [];
idsToFetch.forEach((id) => {
const freshItem = itemsById.get(normalizeItemIdKey(id));
if (!freshItem) {
liveSyncWarn('Item missing from getItems response', id);
return;
}
const cards = findVisibleCardsForItemId(id);
liveSyncLog('Replacing cards for item', { id, cardCount: cards.length });
cards.forEach((card) => {
work.push({ card, freshItem });
});
});
let rebuilt = 0;
let skipped = 0;
for (let i = 0; i < work.length; i += LIVE_SYNC_REBUILD_BATCH_SIZE) {
if (i > 0) await yieldToMainThread();
const batch = work.slice(i, i + LIVE_SYNC_REBUILD_BATCH_SIZE);
batch.forEach(({ card, freshItem }) => {
if (replaceVisibleCardWithFreshItem(card, freshItem)) rebuilt += 1;
else skipped += 1;
});
}
liveSyncLog('Flush complete', { rebuilt, skipped, workItems: work.length });
} catch (err) {
console.error('[KefinTweaks CardBuilder][LiveSync] Visible card live sync failed:', err);
} finally {
libraryUpdatedFlushRunning = false;
// Ids queued during this flush — schedule another pass
if (pendingLibraryUpdatedIds.size) {
scheduleLibraryUpdatedFlush();
}
}
}
function queueLibraryUpdatedItemIds(ids, source = 'unknown') {
if (!Array.isArray(ids) || !ids.length) return;
const before = pendingLibraryUpdatedIds.size;
ids.forEach((id) => {
if (id == null || id === '') return;
pendingLibraryUpdatedIds.add(String(id));
});
liveSyncLog('Queued item ids', {
source,
incoming: ids.map(String),
pendingSizeBefore: before,
pendingSizeAfter: pendingLibraryUpdatedIds.size,
debounceMs: LIBRARY_UPDATED_DEBOUNCE_MS,
flushRunning: libraryUpdatedFlushRunning,
});
scheduleLibraryUpdatedFlush();
}
function handleLibraryChangedMessage(msg) {
const data = msg?.Data ?? msg;
const updated = data?.ItemsUpdated;
const dataKeys = data && typeof data === 'object' ? Object.keys(data) : [];
liveSyncLog('LibraryChanged received', {
deliveryMode: window.websocketHelper?.getDeliveryMode?.() || null,
messageType: msg?.MessageType,
dataKeys,
itemsUpdatedType: Array.isArray(updated) ? 'array' : typeof updated,
itemsUpdatedCount: Array.isArray(updated) ? updated.length : null,
itemsUpdated: Array.isArray(updated) ? updated : updated,
itemsAdded: data?.ItemsAdded,
itemsRemoved: data?.ItemsRemoved,
});
if (!Array.isArray(updated) || !updated.length) {
liveSyncLog('Ignoring LibraryChanged — ItemsUpdated empty or not an array');
return;
}
queueLibraryUpdatedItemIds(updated, 'LibraryChanged.ItemsUpdated');
}
function startCardLibraryChangedListener() {
if (cardLibraryChangedListenerStarted) return;
if (!window.websocketHelper?.listen) {
liveSyncLog('websocketHelper not ready — retrying listener in 1s');
setTimeout(startCardLibraryChangedListener, 1000);
return;
}
window.websocketHelper.listen('LibraryChanged', handleLibraryChangedMessage);
cardLibraryChangedListenerStarted = true;
liveSyncLog('Listening for LibraryChanged to sync visible card metadata', {
deliveryMode: window.websocketHelper?.getDeliveryMode?.() || 'pending',
});
}
let cardEditorCloseLiveSyncStarted = false;
const editorDialogDirty = new WeakMap();
function isImageEditorDialog(dlg) {
if (!dlg?.querySelector) return false;
return !!(dlg.querySelector('.imageEditorCard') || dlg.querySelector('#images') || dlg.querySelector('#backdrops'));
}
function isMetadataEditorDialog(dlg) {
if (!dlg?.querySelector) return false;
if (isImageEditorDialog(dlg)) return false;
return !!(
dlg.querySelector('.metadataEditor') ||
dlg.querySelector('#metadataEditor') ||
dlg.querySelector('.metadataEditorContent') ||
dlg.querySelector('form.metadataEditorForm') ||
dlg.querySelector('.btnSaveMetadata, #btnSaveMetadata')
);
}
function resolveEditorDialogItemId(dlg) {
if (!dlg) return null;
const fromCard = dlg.querySelector?.('.imageEditorCard[data-id], [data-id][data-imagetype], [data-itemid]');
const attrId =
fromCard?.getAttribute('data-id') ||
fromCard?.getAttribute('data-itemid') ||
dlg.getAttribute?.('data-itemid') ||
dlg.getAttribute?.('data-id') ||
dlg.dataset?.itemId ||
null;
if (attrId) return attrId;
try {
const hash = String(window.location.hash || '');
const match = hash.match(/[?&]id=([^&]+)/i);
if (match?.[1]) return decodeURIComponent(match[1]);
} catch (e) {
// ignore
}
return null;
}
function markEditorDialogDirty(dlg) {
if (!dlg) return;
editorDialogDirty.set(dlg, true);
}
function handleEditorDialogClose(dlg) {
if (!dlg) return;
const isImage = isImageEditorDialog(dlg);
const isMeta = isMetadataEditorDialog(dlg);
if (!isImage && !isMeta) return;
const dirty = editorDialogDirty.get(dlg) === true;
// Mirror imageeditor hasChanges: only rebuild when the dialog was dirtied while open.
if (!dirty) {
liveSyncLog('Editor closed without changes — skip rebuild', {
kind: isImage ? 'image' : 'metadata',
});
return;
}
const itemId = resolveEditorDialogItemId(dlg);
if (!itemId) {
liveSyncWarn('Editor closed but could not resolve item id');
return;
}
liveSyncLog('Editor closed — queueing live sync', {
itemId,
kind: isImage ? 'image' : 'metadata',
dirty,
});
queueLibraryUpdatedItemIds([itemId], isImage ? 'imageEditor.close' : 'metadataEditor.close');
}
function watchEditorDialog(dlg) {
if (!dlg || dlg.__kefinLiveSyncWatched) return;
if (!isImageEditorDialog(dlg) && !isMetadataEditorDialog(dlg)) return;
dlg.__kefinLiveSyncWatched = true;
editorDialogDirty.set(dlg, false);
if (isImageEditorDialog(dlg)) {
// imageeditor.js mutates #images during initial reload — ignore until settled.
let armed = false;
const armTimer = setTimeout(() => {
armed = true;
editorDialogDirty.set(dlg, false);
}, 800);
dlg.__kefinLiveSyncArmTimer = armTimer;
const mo = new MutationObserver(() => {
if (!armed) return;
markEditorDialogDirty(dlg);
});
const images = dlg.querySelector('#images') || dlg.querySelector('#backdrops') || dlg;
try {
mo.observe(images, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-src', 'style', 'src'],
});
} catch (e) {
mo.observe(dlg, { childList: true, subtree: true });
}
dlg.__kefinLiveSyncMo = mo;
} else if (isMetadataEditorDialog(dlg)) {
const onFieldEdit = () => markEditorDialogDirty(dlg);
dlg.addEventListener('input', onFieldEdit, true);
dlg.addEventListener('change', onFieldEdit, true);
dlg.__kefinLiveSyncFieldEdit = onFieldEdit;
}
const onClose = () => {
if (dlg.__kefinLiveSyncArmTimer) {
clearTimeout(dlg.__kefinLiveSyncArmTimer);
}
if (dlg.__kefinLiveSyncFieldEdit) {
dlg.removeEventListener('input', dlg.__kefinLiveSyncFieldEdit, true);
dlg.removeEventListener('change', dlg.__kefinLiveSyncFieldEdit, true);
}
try {
dlg.__kefinLiveSyncMo?.disconnect?.();
} catch (e) {
// ignore
}
handleEditorDialogClose(dlg);
dlg.removeEventListener('close', onClose);
};
dlg.addEventListener('close', onClose);
liveSyncLog('Watching editor dialog for close', {
image: isImageEditorDialog(dlg),
metadata: isMetadataEditorDialog(dlg),
itemId: resolveEditorDialogItemId(dlg),
});
}
function startCardEditorCloseLiveSync() {
if (cardEditorCloseLiveSyncStarted) return;
cardEditorCloseLiveSyncStarted = true;
document.querySelectorAll('.formDialog').forEach((dlg) => watchEditorDialog(dlg));
const bodyObserver = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
if (node.matches?.('.formDialog')) watchEditorDialog(node);
node.querySelectorAll?.('.formDialog').forEach((dlg) => watchEditorDialog(dlg));
});
});
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
liveSyncLog('Editor-close live sync observer started');
}
// Main card builder object
const cardBuilder = {
/**
* Post-processes items for a section
* @param {Object} sectionConfig - Section configuration
* @param {Array} itemsData - Array of Jellyfin items
* @returns {Array} - Post-processed array of Jellyfin items
*/
postProcessItems: postProcessItems,
postProcessItemsByQuery: postProcessItemsByQuery,
getQueryDisplayName: getQueryDisplayName,
resolveActiveQueryIndex: resolveActiveQueryIndex,
resolveQueriesToLoad: resolveQueriesToLoad,
getSectionTitleForMultiQuery: getSectionTitleForMultiQuery,
getActiveViewMoreUrl: getActiveViewMoreUrl,
refreshSectionQueries: refreshSectionQueries,
loadActiveSectionQueryItems: loadActiveSectionQueryItems,
createSectionScrollableContainer: createSectionScrollableContainer,
applySectionPresentationAttrs: applySectionPresentationAttrs,
attachSectionControlButtons: attachSectionControlButtons,
ensureCardBorders: ensureCardBorders,
applyItemsLayoutState: applyItemsLayoutState,
resolveItemsLayout: resolveItemsLayout,
resolveGridShape: resolveGridShape,
resolveSpotlightPresentation: resolveSpotlightPresentation,
resolveUseGaplessCards: resolveUseGaplessCards,
invalidateCardRadiusCache: invalidateCardRadiusCache,
ensureCardRadiusCssVar: ensureCardRadiusCssVar,
getScrollerPosition: getScrollerPosition,
setScrollerPosition: setScrollerPosition,
scrollToCardIndex: scrollToCardIndex,
invalidateScrollerMetrics: invalidateScrollerMetrics,
getItemMaterialIcon: getItemMaterialIcon,
createLibraryButtonElement: createLibraryButtonElement,
/**
* Main entry point function to build a Jellyfin card
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square'
* @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes)
* @param {boolean} useParentCard - When true, the parent item will be rendered as the card image and link
* @param {string|null} borderStyle - Section card border style (e.g. 'retro-poster')
* @returns {HTMLElement} - The constructed card element
*/
buildCard: function (
item,
overflowCard = false,
cardFormat = null,
customFooterText = null,
useParentCard = false,
borderStyle = null,
) {
return createCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
},
/** Push-register lazy images under an attached root (parallel native path). */
registerLazyImagesIn: registerLazyImagesIn,
registerLazyImagesInSection: registerLazyImagesInSection,
/**
* Renders a scrollable container with cards
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the scrollable container
* @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random'
* @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded'
* @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
* @returns {HTMLElement} - The constructed scrollable container
*/
renderCards: function (
items,
title,
viewMoreUrl = null,
overflowCard = false,
cardFormat = null,
sortOrder = null,
sortOrderDirection = 'Ascending',
) {
let finalCardFormat = cardFormat;
if (cardFormat === 'random' || cardFormat === 'Random') {
finalCardFormat = getRandomCardFormat();
}
let sortedItems = items;
if (sortOrder && sortOrder !== 'Random') {
sortedItems = sortItems(items, sortOrder, sortOrderDirection);
} else if (sortOrder === 'Random') {
sortedItems = shuffle([...items]);
}
const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
return container;
},
renderSpotlightSection: function (items, title, options = {}) {
return createSpotlightSection(items, title, options);
},
/**
* Renders a scrollable container with cards from item IDs
* @param {Array} itemIds - Array of Jellyfin item IDs
* @param {string} title - Title for the scrollable container
* @param {string} viewMoreUrl - Optional URL to make title clickable
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random'
* @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded'
* @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
* @returns {Promise<HTMLElement>} - The constructed scrollable container
*/
/**
* Renders cards progressively with skeleton loading state
* @param {Promise<Array>} itemsPromise - Promise that resolves to array of Jellyfin items
* @param {string} title - Title for the scrollable container
* @param {string|Function} viewMoreUrl - Optional URL to make title clickable
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random'
* @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded'
* @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
* @returns {HTMLElement} - The constructed scrollable container (initially with skeletons)
*/
renderProgressivelyEnhancedCards: async function (
itemsPromise,
title,
viewMoreUrl = null,
overflowCard = false,
cardFormat = null,
sortOrder = null,
sortOrderDirection = 'Ascending',
minimumItems = -1,
) {
// Handle Random card format - Must resolve ONCE for both skeleton and real cards
let resolvedFormat = cardFormat;
if (cardFormat === 'random' || cardFormat === 'Random') {
resolvedFormat = getRandomCardFormat();
}
// Check if promise is already fulfilled
if ((await promiseState(itemsPromise)) === 'fulfilled') {
// Resolve the promise and get the items
const items = await itemsPromise;
// Replace the skeleton with the real cards
const container = createScrollableContainer(items, title, viewMoreUrl, overflowCard, resolvedFormat);
return container;
}
// Create skeleton container immediately using the resolved format
const skeletonContainer = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard);
const randomId = Math.random().toString(36).substring(2, 15);
skeletonContainer.setAttribute('data-skeleton-id', randomId);
// When promise resolves, replace skeletons with real cards
itemsPromise
.then(async (items) => {
// Remove the skeleton container if it exists
let skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
// Let's try to find the skeleton 20 times every 100ms
for (let i = 0; i < 20; i++) {
skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
if (skeletonContaineElement) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
if (!items || items.length === 0) {
unobserveSkeletonShimmer(skeletonContaineElement);
skeletonContaineElement.remove();
return;
}
if (items.length < minimumItems) {
unobserveSkeletonShimmer(skeletonContaineElement);
skeletonContaineElement.remove();
return;
}
// Sort items if needed
let sortedItems = items;
if (sortOrder && sortOrder !== 'Random') {
sortedItems = sortItems(items, sortOrder, sortOrderDirection);
} else if (sortOrder === 'Random') {
sortedItems = shuffle([...items]);
}
// Generate real cards container (off-DOM) using the SAME resolved format
const realContainer = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat);
// Copy critical attributes to preserve layout/order
if (skeletonContaineElement.style.order) {
realContainer.style.order = skeletonContainer.style.order;
}
if (skeletonContaineElement.hasAttribute('data-section-id')) {
realContainer.setAttribute('data-section-id', skeletonContainer.getAttribute('data-section-id'));
}
if (skeletonContaineElement.dataset.border) {
realContainer.dataset.border = skeletonContaineElement.dataset.border;
realContainer.dataset.borderStyle =
skeletonContaineElement.dataset.borderStyle || skeletonContaineElement.dataset.border;
}
// Replace content of skeleton container with real content
// This preserves the container element itself and its layout properties (like order)
unobserveSkeletonShimmer(skeletonContaineElement);
skeletonContaineElement.replaceWith(realContainer);
ensureCardBorders(realContainer);
})
.catch((error) => {
console.error('[KefinTweaks CardBuilder] Error loading items for progressive enhancement:', error);
// Optionally remove skeleton on error or show error state
if (skeletonContaineElement) {
unobserveSkeletonShimmer(skeletonContaineElement);
skeletonContaineElement.remove();
}
});
return skeletonContainer;
},
/**
* Renders a progressive section (Cards or Spotlight)
* @param {HTMLElement} container - The parent container to append the section to
* @param {Array|Promise} itemsOrPromises - Data array or Promise resolving to items
* @param {string} title - Section title
* @param {Object} options - Configuration options
*/
renderProgressiveSections: async function (container, sections, options = {}) {
const {
revealSectionsSequentially = false,
waitForContainerClass = null,
enhanceOnVisible = false,
enhanceRootMargin = '30% 0px 30% 0px',
showStaleDataBeforeRefresh = true,
insertBefore = null,
} = options;
const renderProgressiveSectionsStartTime = performance.now();
const sectionEnhancementTargets = [];
const fragment = document.createDocumentFragment();
for (const sectionPromise of sections) {
const sectionPromiseStartTime = performance.now();
const section = await sectionPromise;
const sectionPromiseEndTime = performance.now();
const sectionPromiseDuration = sectionPromiseEndTime - sectionPromiseStartTime;
console.log(`Section promise initialization time: ${sectionPromiseDuration.toFixed(2)}ms`);
if (!section) continue;
const sectionStartTime = performance.now();
const sectionConfig = section.config;
// Skip if the section is not enabled
if (!sectionConfig.enabled) {
console.log(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} is not enabled, skipping...`);
continue;
}
// Skip if this section id is already in the container (overlapping home renders)
const sectionIdAttr =
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(String(sectionConfig.id))
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
if (container.querySelector(`[data-section-id="${sectionIdAttr}"]`)) {
console.log(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} already exists in the container, skipping...`);
continue;
}
// Handle Random card format - pick one format for entire section
let finalCardFormat = sectionConfig.cardFormat;
if (finalCardFormat === 'random' || finalCardFormat === 'Random') {
finalCardFormat = getRandomCardFormat();
}
let dataItems = section.result?.data?.Items ?? section.result?.data;
const isRandomQuerySort = sectionHasRandomQuerySort(sectionConfig);
// If the section has items, it is a static query
const isStaticQuery = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
const renderCachedData =
(isStaticQuery ||
(!section.result?.isStale && !isRandomQuerySort) ||
(showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'))) &&
dataItems.length > 0;
// Paint cached cards when valid, or when stale display is opted in
if (renderCachedData) {
let content = null;
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
content = createSpotlightSection(dataItems, getSectionTitleForMultiQuery(sectionConfig), {
viewMoreUrl: getActiveViewMoreUrl(sectionConfig),
progressiveEnhancement: true,
...sectionConfig.spotlightConfig,
});
} else {
// Stale cached paint may still refresh — defer scroll-button register until final apply
const registerScrollButtons = isStaticQuery || section.result?.isStale !== true;
content = createScrollableContainer(
dataItems,
getSectionTitleForMultiQuery(sectionConfig),
getActiveViewMoreUrl(sectionConfig),
sectionConfig.overflowCard,
finalCardFormat,
sectionConfig,
registerScrollButtons,
);
}
content.setAttribute('data-section-id', sectionConfig.id);
content.style.order = sectionConfig.order;
content.dataset.order = sectionConfig.order;
if (sectionConfig.hideCardFooter === true) {
content.dataset.hideCardFooter = 'true';
}
applySectionPresentationAttrs(content, sectionConfig);
// Add section control buttons to rendered section
attachSectionControlButtons(sectionConfig, content, dataItems);
sectionEnhancementTargets.push({ element: content, section });
//container.appendChild(content);
//content.style.contentVisibility = 'hidden';
if (revealSectionsSequentially) {
content.classList.add('cardbuilder-section-reveal');
}
fragment.appendChild(content);
const sectionEndTime = performance.now();
const sectionDuration = sectionEndTime - sectionStartTime;
console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
continue;
}
// Otherwise, we need to create a skeleton section
let sectionElement = null;
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
const spotlightSettings = sectionConfig.spotlightConfig ?? {};
if (getActiveViewMoreUrl(sectionConfig)) {
spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig);
}
spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
spotlightSettings.caption = sectionConfig.caption;
sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings);
} else {
sectionElement = createProgressivelyEnhancedScrollableContainer(
getSectionTitleForMultiQuery(sectionConfig),
getActiveViewMoreUrl(sectionConfig),
finalCardFormat,
sectionConfig.overflowCard,
sectionConfig,
);
}
sectionElement.setAttribute('data-section-id', sectionConfig.id);
sectionElement.style.order = sectionConfig.order;
sectionElement.dataset.order = sectionConfig.order;
if (sectionConfig.hideCardFooter === true) {
sectionElement.dataset.hideCardFooter = 'true';
}
applySectionPresentationAttrs(sectionElement, sectionConfig);
//container.appendChild(sectionElement);
// set section element content-visibility to hidden
//sectionElement.style.contentVisibility = 'hidden';
if (revealSectionsSequentially) {
sectionElement.classList.add('cardbuilder-section-reveal');
}
sectionEnhancementTargets.push({ element: sectionElement, section });
fragment.appendChild(sectionElement);
const sectionEndTime = performance.now();
const sectionDuration = sectionEndTime - sectionStartTime;
console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
}
// 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...`);
}
}
// Append in batches so style/layout work is spread across frames.
// Register lazy images per section .itemsContainer only (never the home container).
const BATCH_SIZE = 2;
const sectionNodes = Array.from(fragment.childNodes);
for (let i = 0; i < sectionNodes.length; i += BATCH_SIZE) {
const batch = sectionNodes.slice(i, i + BATCH_SIZE);
const batchFragment = document.createDocumentFragment();
batch.forEach((node) => batchFragment.appendChild(node));
if (insertBefore && insertBefore.parentNode === container) {
container.insertBefore(batchFragment, insertBefore);
} else {
container.appendChild(batchFragment);
}
batch.forEach((sectionEl) => {
if (sectionEl?.nodeType === 1) registerLazyImagesInSection(sectionEl);
});
if (i + BATCH_SIZE < sectionNodes.length) {
await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
// Set up section reveal animations if enabled (opacity/transform only — no display toggle)
if (revealSectionsSequentially) {
initializeSectionRevealObserver();
sectionEnhancementTargets.forEach(({ element }) => {
observeSectionForReveal(element);
});
}
const enhanceOptions = {
enhanceOnVisible,
enhanceRootMargin,
revealSectionsSequentially,
};
for (let index = 0; index < sectionEnhancementTargets.length; index++) {
const sectionStartTime = performance.now();
const { element: sectionElement, section } = sectionEnhancementTargets[index];
if (Array.isArray(section.config.items) && section.config.items.length > 0) {
continue;
}
scheduleSectionProgressiveEnhancement(sectionElement, section, enhanceOptions);
const sectionEndTime = performance.now();
const sectionDuration = sectionEndTime - sectionStartTime;
console.log(`Section ${index} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
}
const renderProgressiveSectionsEndTime = performance.now();
const renderProgressiveSectionsDuration = renderProgressiveSectionsEndTime - renderProgressiveSectionsStartTime;
console.log(`Render progressive sections initialization time: ${renderProgressiveSectionsDuration.toFixed(2)}ms`);
},
/**
* Unified function to render progressive sections (Cards or Spotlight)
* @param {HTMLElement} container - The parent container to append the section to
* @param {Array|Promise} itemsOrPromise - Data array or Promise resolving to items
* @param {string} title - Section title
* @param {Object} options - Configuration options
*/
renderProgressiveCards: function (container, itemsOrPromise, title, options = {}) {
const {
spotlight = false,
viewMoreUrl = null,
cardFormat = null,
overflowCard = false,
sortOrder = null,
sortOrderDirection = 'Ascending',
sectionId = null,
order = null,
expectedItemType = null,
minimumItems = 1,
} = options;
// Handle Random card format once
let resolvedFormat = cardFormat;
if (cardFormat === 'random' || cardFormat === 'Random') {
resolvedFormat = getRandomCardFormat();
}
// Function to setup attributes
const setupElement = (el) => {
if (sectionId) el.setAttribute('data-section-id', sectionId);
if (order !== null) el.style.order = order;
// Stamp resolved format for consistency on updates
if (resolvedFormat && !spotlight) el.setAttribute('data-card-format', resolvedFormat);
};
// Helper to render final content
const renderContent = (items) => {
if (!items || items.length < minimumItems) return null;
// Sort items
let sortedItems = items;
if (sortOrder && sortOrder !== 'Random') {
sortedItems = sortItems(items, sortOrder, sortOrderDirection);
} else if (sortOrder === 'Random') {
sortedItems = shuffle([...items]);
}
if (spotlight) {
return createSpotlightSection(sortedItems, title, { viewMoreUrl });
} else {
return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat);
}
};
// Handle Promise
if (itemsOrPromise && typeof itemsOrPromise.then === 'function') {
// Render Skeleton
let skeleton;
if (spotlight) {
skeleton = createSkeletonSpotlightSection(title, { viewMoreUrl });
} else {
skeleton = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard);
}
setupElement(skeleton);
container.appendChild(skeleton);
// Handle Resolution
itemsOrPromise
.then((items) => {
const content = renderContent(items);
if (content) {
setupElement(content);
// Copy of any existing data attributes to the new content
Array.from(skeleton.attributes).forEach((attr) => {
if (attr.name === 'class') return;
content.setAttribute(attr.name, attr.value);
});
stripCopiedSkeletonSectionClasses(content);
unobserveSkeletonShimmer(skeleton);
skeleton.replaceWith(content);
ensureCardBorders(content);
ensureSectionRevealObservation(content);
} else {
unobserveSkeletonShimmer(skeleton);
skeleton.remove();
}
})
.catch((err) => {
console.error('[CardBuilder] Error loading items:', err);
unobserveSkeletonShimmer(skeleton);
skeleton.remove();
});
return skeleton;
}
// Handle Direct Array
else {
const items = Array.isArray(itemsOrPromise) ? itemsOrPromise : [];
const content = renderContent(items);
if (content) {
setupElement(content);
container.appendChild(content);
return content;
}
}
},
renderCardsFromIds: async function (
itemIds,
title,
viewMoreUrl = null,
overflowCard = false,
cardFormat = null,
sortOrder = null,
sortOrderDirection = 'Ascending',
) {
const LOG = (...args) => console.log('[KefinTweaks CardBuilder]', ...args);
if (!itemIds || itemIds.length === 0) {
LOG(`No item IDs provided for section: ${title}`);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
try {
// Fetch all items at once
const response = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
Ids: itemIds.join(','),
Recursive: false,
});
const items = response.Items;
LOG(`Fetched ${items.length} items for section: ${title}`);
// Handle Random card format - pick one format for entire section
let finalCardFormat = cardFormat;
if (cardFormat === 'random' || cardFormat === 'Random') {
finalCardFormat = getRandomCardFormat();
}
// Sort items if sortOrder is provided
let sortedItems = items;
if (sortOrder && sortOrder !== 'Random') {
sortedItems = sortItems(items, sortOrder, sortOrderDirection);
} else if (sortOrder === 'Random') {
sortedItems = shuffle([...items]);
}
const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
return container;
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error fetching items:', error);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
},
/**
* Renders a spotlight section (Netflix-style slim banner carousel)
* @param {Array|Promise<Array>} items - Array of Jellyfin item objects or Promise resolving to array
* @param {string} title - Title for the spotlight section
* @param {Object} options - Options for the spotlight carousel
* @param {boolean} options.progressiveEnhancement - If true and items is a Promise, render skeleton first
* @returns {HTMLElement} - The constructed spotlight container
*/
renderSpotlightSection: function (items, title, options = {}) {
const { progressiveEnhancement = false } = options;
// If progressive enhancement is enabled and items is a Promise
if (progressiveEnhancement && items && typeof items.then === 'function') {
// Create skeleton spotlight section immediately
const skeletonSpotlightContainer = createSkeletonSpotlightSection(title, options);
// Add identifying data-id
const randomId = Math.random().toString(36).substring(2, 15);
skeletonSpotlightContainer.setAttribute('data-skeleton-id', randomId);
// When promise resolves, replace skeleton with real spotlight
items
.then(async (items) => {
// Remove the skeleton container if it exists
let skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
// Let's try to find the skeleton 20 times every 100ms
for (let i = 0; i < 20; i++) {
skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
if (skeletonContainerElement) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
// Generate real spotlight section
const realContainer = createSpotlightSection(items, title, options);
// Copy critical attributes to preserve layout/order
if (skeletonContainerElement.style.order) {
realContainer.style.order = skeletonContainer.style.order;
}
if (skeletonContainerElement.hasAttribute('data-section-id')) {
realContainer.setAttribute('data-section-id', skeletonContainerElement.getAttribute('data-section-id'));
}
// Replace skeleton container with real container
unobserveSkeletonShimmer(skeletonContainerElement);
skeletonContainerElement.replaceWith(realContainer);
})
.catch((error) => {
console.error('[KefinTweaks CardBuilder] Error loading items for spotlight progressive enhancement:', error);
// Optionally remove skeleton on error or show error state
unobserveSkeletonShimmer(skeletonSpotlightContainer);
skeletonSpotlightContainer.remove();
});
return skeletonSpotlightContainer;
}
// Normal rendering (items is already an array)
const container = createSpotlightSection(items, title, options);
return container;
},
/**
* Sorts items based on sort order and direction (exposed for use in homeScreen)
* @param {Array} items - Array of Jellyfin items
* @param {string} sortOrder - Sort order
* @param {string} sortOrderDirection - Direction
* @returns {Array} - Sorted array
*/
sortItems: function (items, sortOrder, sortOrderDirection) {
return sortItems(items, sortOrder, sortOrderDirection);
},
updateCardResumeAttributes: updateCardResumeAttributes,
};
/**
* Blurhash base83 character set (must match official blurhash package: includes comma and period)
* https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts
*/
const BLURHASH_B83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~';
function blurhashDecode83(str, from, length) {
if (from + length > str.length) return NaN;
let value = 0;
for (let i = 0; i < length; i++) {
const idx = BLURHASH_B83.indexOf(str[from + i]);
if (idx < 0) return NaN;
value = value * 83 + idx;
}
return value;
}
function blurhashSignPow(val, exp) {
const sign = val < 0 ? -1 : 1;
return sign * Math.pow(Math.abs(val), exp);
}
function blurhashLinearToSrgb(value) {
const v = Math.max(0, Math.min(1, value));
return v <= 0.0031308 ? Math.trunc(v * 12.92 * 255 + 0.5) : Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
}
function blurhashSrgbToLinear(value) {
const v = value / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
/**
* Decode a blurhash string to RGBA pixel data (Uint8ClampedArray).
* Matches the official blurhash package (same as Jellyfin's worker uses).
* Layout: 1 char size, 1 char quantised max, 4 chars DC (24-bit), then 2 chars per AC (base-19 packed).
*/
function blurhashDecode(blurhash, width, height) {
if (!blurhash || blurhash.length < 6) return null;
try {
const sizeVal = blurhashDecode83(blurhash, 0, 1);
if (isNaN(sizeVal)) return null;
const numX = (sizeVal % 9) + 1;
const numY = Math.floor(sizeVal / 9) + 1;
const expectedLen = 4 + 2 * numX * numY;
if (blurhash.length !== expectedLen) return null;
const quantisedMax = blurhashDecode83(blurhash, 1, 1);
if (isNaN(quantisedMax)) return null;
const maxVal = (quantisedMax + 1) / 166;
const components = [];
for (let i = 0; i < numX * numY; i++) {
if (i === 0) {
const dc = blurhashDecode83(blurhash, 2, 4);
if (isNaN(dc)) return null;
const intR = dc >> 16;
const intG = (dc >> 8) & 255;
const intB = dc & 255;
components.push([blurhashSrgbToLinear(intR), blurhashSrgbToLinear(intG), blurhashSrgbToLinear(intB)]);
} else {
const acVal = blurhashDecode83(blurhash, 4 + i * 2, 2);
if (isNaN(acVal)) return null;
const quantR = Math.floor(acVal / (19 * 19));
const quantG = Math.floor(acVal / 19) % 19;
const quantB = acVal % 19;
components.push([
blurhashSignPow((quantR - 9) / 9, 2) * maxVal,
blurhashSignPow((quantG - 9) / 9, 2) * maxVal,
blurhashSignPow((quantB - 9) / 9, 2) * maxVal,
]);
}
}
const pixels = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0,
g = 0,
b = 0;
for (let j = 0; j < numY; j++) {
const basisY = Math.cos((Math.PI * y * j) / height);
for (let i = 0; i < numX; i++) {
const basis = Math.cos((Math.PI * x * i) / width) * basisY;
const color = components[i + j * numX];
r += color[0] * basis;
g += color[1] * basis;
b += color[2] * basis;
}
}
const off = (y * width + x) * 4;
pixels[off] = blurhashLinearToSrgb(r);
pixels[off + 1] = blurhashLinearToSrgb(g);
pixels[off + 2] = blurhashLinearToSrgb(b);
pixels[off + 3] = 255;
}
}
return pixels;
} catch (e) {
return null;
}
}
/**
* Draw decoded blurhash into an existing canvas (e.g. 20x20).
* @param {HTMLCanvasElement} canvas - Canvas to draw into
* @param {string} blurhashString - Blurhash string
* @returns {boolean} - True if drawn successfully
*/
function drawBlurhashToCanvas(canvas, blurhashString) {
if (!canvas || !blurhashString) return false;
const w = canvas.width || 20;
const h = canvas.height || 20;
const cacheKey = blurhashCacheKey(blurhashString, w, h);
let pixels = blurhashCacheGet(cacheKey);
if (!pixels) {
pixels = blurhashDecode(blurhashString, w, h);
if (pixels) blurhashCacheSet(cacheKey, pixels);
}
if (!pixels) return false;
return applyBlurhashPixelsToCanvas(canvas, pixels);
}
// --- Deferred blurhash: cache + worker + near-viewport filler ---
const BLURHASH_CACHE_MAX = 200;
const blurhashPixelCache = new Map();
let blurhashFillObserver = null;
let blurhashWorker = null;
let blurhashWorkerReqId = 0;
const blurhashWorkerPending = new Map();
const blurhashIdleQueue = [];
let blurhashIdleScheduled = false;
function blurhashCacheKey(blurhash, width, height) {
return blurhash + '|' + width + 'x' + height;
}
function blurhashCacheGet(key) {
if (!blurhashPixelCache.has(key)) return null;
const val = blurhashPixelCache.get(key);
blurhashPixelCache.delete(key);
blurhashPixelCache.set(key, val);
return val;
}
function blurhashCacheSet(key, pixels) {
if (blurhashPixelCache.has(key)) blurhashPixelCache.delete(key);
blurhashPixelCache.set(key, pixels);
while (blurhashPixelCache.size > BLURHASH_CACHE_MAX) {
const oldest = blurhashPixelCache.keys().next().value;
blurhashPixelCache.delete(oldest);
}
}
function shouldSkipBlurhashDecode() {
if (!state.useBlurhash) return true;
try {
if (navigator.connection && navigator.connection.saveData === true) return true;
} catch (e) {
/* ignore */
}
try {
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
} catch (e) {
/* ignore */
}
return false;
}
function getBlurhashFillRootMargin() {
return isMobileLayout() ? '120px' : '200px';
}
/**
* Put pixels on canvas and show blurhash until the real image loads.
* @returns {boolean}
*/
function applyBlurhashPixelsToCanvas(canvas, pixels) {
if (!canvas || !pixels || !canvas.isConnected) return false;
const w = canvas.width || 20;
const h = canvas.height || 20;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return false;
const imageData = ctx.createImageData(w, h);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
canvas.setAttribute('data-blurhash-painted', 'true');
canvas.removeAttribute('data-blurhash-pending');
const cardImageContainer = canvas.nextElementSibling;
const imageAlreadyLoaded =
cardImageContainer && cardImageContainer.classList && cardImageContainer.classList.contains('lazy-loaded');
if (imageAlreadyLoaded) {
canvas.classList.add('lazy-hidden');
return true;
}
canvas.classList.remove('lazy-hidden');
if (cardImageContainer && cardImageContainer.classList.contains('cardImageContainer')) {
cardImageContainer.classList.add('lazy-hidden');
}
return true;
}
function getBlurhashWorkerSource() {
return [
'const BLURHASH_B83 = ' + JSON.stringify(BLURHASH_B83) + ';',
blurhashDecode83.toString(),
blurhashSignPow.toString(),
blurhashLinearToSrgb.toString(),
blurhashSrgbToLinear.toString(),
blurhashDecode.toString(),
'self.onmessage = function(e) {',
' var data = e.data || {};',
' var id = data.id;',
' try {',
' var pixels = blurhashDecode(data.blurhash, data.width || 20, data.height || 20);',
' if (!pixels) { self.postMessage({ id: id, ok: false }); return; }',
' self.postMessage({ id: id, ok: true, pixels: pixels.buffer }, [pixels.buffer]);',
' } catch (err) {',
' self.postMessage({ id: id, ok: false, error: String(err && err.message || err) });',
' }',
'};',
].join('\n');
}
function ensureBlurhashWorker() {
if (blurhashWorker) return blurhashWorker;
if (typeof Worker === 'undefined') return null;
try {
const blob = new Blob([getBlurhashWorkerSource()], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
worker.onmessage = (e) => {
const data = e.data || {};
const pending = blurhashWorkerPending.get(data.id);
blurhashWorkerPending.delete(data.id);
if (!pending) return;
if (!data.ok || !data.pixels) {
pending.reject(new Error(data.error || 'Blurhash worker decode failed'));
return;
}
pending.resolve(new Uint8ClampedArray(data.pixels));
};
worker.onerror = (err) => {
console.warn('[KefinTweaks CardBuilder] Blurhash worker error:', err);
blurhashWorkerPending.forEach((p) => p.reject(err));
blurhashWorkerPending.clear();
try {
worker.terminate();
} catch (e) {
/* ignore */
}
try {
URL.revokeObjectURL(url);
} catch (e) {
/* ignore */
}
blurhashWorker = null;
};
blurhashWorker = worker;
return worker;
} catch (e) {
console.warn('[KefinTweaks CardBuilder] Could not create blurhash worker:', e);
return null;
}
}
function decodeBlurhashInWorker(blurhash, width, height) {
return new Promise((resolve, reject) => {
const worker = ensureBlurhashWorker();
if (!worker) {
reject(new Error('Blurhash worker unavailable'));
return;
}
const id = ++blurhashWorkerReqId;
blurhashWorkerPending.set(id, { resolve, reject });
try {
worker.postMessage({ id, blurhash, width, height });
} catch (e) {
blurhashWorkerPending.delete(id);
reject(e);
}
});
}
function scheduleIdleBlurhashDrain() {
if (blurhashIdleScheduled) return;
blurhashIdleScheduled = true;
const run = (deadline) => {
blurhashIdleScheduled = false;
let n = 0;
while (blurhashIdleQueue.length && n < 4) {
if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() <= 0 && n > 0) {
break;
}
const job = blurhashIdleQueue.shift();
n++;
if (!job || !job.canvas || !job.canvas.isConnected) continue;
if (!job.canvas.hasAttribute('data-blurhash-pending')) continue;
const cacheKey = blurhashCacheKey(job.blurhash, job.w, job.h);
let pixels = blurhashCacheGet(cacheKey);
if (!pixels) {
pixels = blurhashDecode(job.blurhash, job.w, job.h);
if (pixels) blurhashCacheSet(cacheKey, pixels);
}
if (pixels) applyBlurhashPixelsToCanvas(job.canvas, pixels);
else job.canvas.removeAttribute('data-blurhash-pending');
}
if (blurhashIdleQueue.length) scheduleIdleBlurhashDrain();
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(run, { timeout: 500 });
} else {
requestAnimationFrame(() => run({ timeRemaining: () => 5 }));
}
}
function enqueueIdleBlurhashDecode(canvas, blurhash, width, height) {
blurhashIdleQueue.push({ canvas, blurhash, w: width, h: height });
scheduleIdleBlurhashDrain();
}
async function fillBlurhashCanvas(canvas) {
if (!canvas || !canvas.isConnected) return;
const blurhash = canvas.getAttribute('data-blurhash-pending');
if (!blurhash) return;
if (shouldSkipBlurhashDecode()) {
canvas.removeAttribute('data-blurhash-pending');
canvas.classList.add('lazy-hidden');
return;
}
const w = canvas.width || 20;
const h = canvas.height || 20;
const cacheKey = blurhashCacheKey(blurhash, w, h);
const cached = blurhashCacheGet(cacheKey);
if (cached) {
applyBlurhashPixelsToCanvas(canvas, cached);
return;
}
try {
const pixels = await decodeBlurhashInWorker(blurhash, w, h);
if (!canvas.isConnected || canvas.getAttribute('data-blurhash-pending') !== blurhash) return;
blurhashCacheSet(cacheKey, pixels);
applyBlurhashPixelsToCanvas(canvas, pixels);
} catch (e) {
enqueueIdleBlurhashDecode(canvas, blurhash, w, h);
}
}
function observeBlurhashCanvas(canvas) {
if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return;
if (shouldSkipBlurhashDecode()) {
canvas.removeAttribute('data-blurhash-pending');
canvas.classList.add('lazy-hidden');
return;
}
initBlurhashFillObserver();
if (blurhashFillObserver) {
blurhashFillObserver.observe(canvas);
} else {
fillBlurhashCanvas(canvas);
}
}
function initBlurhashFillObserver() {
if (blurhashFillObserver) return;
if (typeof IntersectionObserver === 'undefined') return;
blurhashFillObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const canvas = entry.target;
blurhashFillObserver.unobserve(canvas);
fillBlurhashCanvas(canvas);
});
},
{
threshold: 0,
rootMargin: getBlurhashFillRootMargin(),
},
);
}
function observePendingBlurhashInNode(node) {
if (!node || node.nodeType !== 1) return;
if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) {
observeBlurhashCanvas(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
}
}
/**
* Get blurhash string for the card image when the image is from the current item.
* Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes).
* @param {Object} item - Jellyfin item
* @param {string|null} cardFormat - 'backdrop'|'square'|'portrait'|'poster'|'thumb'|'series thumb'|'series poster'|null
* @returns {string|undefined} - Blurhash string or undefined
*/
function getBlurhashForCard(item, cardFormat) {
const hashes = item.ImageBlurHashes;
if (!hashes || typeof hashes !== 'object') return undefined;
cardFormat = cardFormat?.toLowerCase() || null;
if (cardFormat === 'backdrop') {
if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (cardFormat === 'square') {
if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
return undefined;
}
if (cardFormat === 'portrait' || cardFormat === 'poster') {
if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) return undefined;
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
return undefined;
}
if (cardFormat === 'series poster') {
if (item.SeriesPrimaryImageTag) return undefined;
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (cardFormat === 'series thumb') {
if (item.ParentThumbImageTag) return undefined;
if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) return undefined;
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (cardFormat === 'thumb') {
if (item.Type === 'Episode') {
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
return undefined;
}
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
return undefined;
}
if (item.IsJellyseerr) return undefined;
if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
if (item.BackdropImageTags && item.BackdropImageTags.length > 0) return hashes.Backdrop?.[item.BackdropImageTags[0]];
return undefined;
}
function useEpisodeImagesInNextUpAndResume(userId) {
return localStorage.getItem(`${userId}-useEpisodeImagesInNextUpAndResume`) === 'true';
}
function getItemMaterialIcon(item) {
const itemType = item?.Type;
if (itemType === 'CollectionFolder' || item?.CollectionType) {
switch ((item.CollectionType || '').toLowerCase()) {
case 'tvshows':
return 'tv';
case 'movies':
return 'movie';
case 'boxsets':
return 'video_library';
case 'playlists':
return 'queue';
case 'books':
return 'book';
case 'music':
return 'music_note';
case 'homevideos':
return 'photo';
case 'folders':
return 'folder';
case 'livetv':
return 'live_tv';
default:
return 'folder';
}
}
if (itemType === 'Movie') return 'movie';
if (itemType === 'Series' || itemType === 'Season') return 'tv';
if (itemType === 'Episode' || itemType === 'Video') return 'play_circle';
if (itemType === 'TvChannel' || itemType === 'Program') return 'live_tv';
if (itemType === 'Playlist') return 'queue';
if (itemType === 'BoxSet') return 'video_library';
if (itemType === 'Book') return 'book';
if (itemType === 'Person' || itemType === 'MusicArtist' || itemType === 'Artist') return 'person';
if (itemType === 'MusicAlbum') return 'album';
if (itemType === 'Audio') return 'music_note';
return 'folder';
}
function buildCollectionFolderHref(item, serverId, options = {}) {
const { useHtmlExtension = false } = options;
if (!item?.Id || (item.Type !== 'CollectionFolder' && !item.CollectionType)) return null;
const id = item.Id;
const ext = useHtmlExtension ? '.html' : '';
switch ((item.CollectionType || '').toLowerCase()) {
case 'movies':
return `#/movies${ext}?topParentId=${id}&collectionType=movies`;
case 'tvshows':
return `#/tv${ext}?topParentId=${id}&collectionType=tvshows`;
case 'music':
return `#/music${ext}?topParentId=${id}&collectionType=music`;
case 'livetv':
return `#/livetv${ext}?collectionType=livetv`;
default:
return `#/list${ext}?parentId=${id}&serverId=${serverId}`;
}
}
function getCollectionFolderUrl(item, serverId) {
return buildCollectionFolderHref(item, serverId, { useHtmlExtension: false });
}
function createLibraryButtonElement(item) {
const serverId = ApiClient.serverId();
const icon = getItemMaterialIcon(item);
const href = item.cardUrl || getCollectionFolderUrl(item, serverId) || `#/details?id=${item.Id}&serverId=${serverId}`;
const link = document.createElement('a');
link.setAttribute('is', 'emby-linkbutton');
link.href = href;
link.className = 'raised homeLibraryButton emby-button';
if (item.Id) {
link.setAttribute('data-id', item.Id);
}
const iconSpan = document.createElement('span');
iconSpan.className = `material-icons homeLibraryIcon ${icon}`;
iconSpan.setAttribute('aria-hidden', 'true');
const textSpan = document.createElement('span');
textSpan.className = 'homeLibraryText';
textSpan.textContent = item.Name || '';
link.appendChild(iconSpan);
link.appendChild(textSpan);
return link;
}
/**
* Hi-res image request for a card format: ~2× the classic fillHeight/fillWidth
* sizes (backdrop 267×474, square 297, banner 100×540, portrait 446×297).
* @param {string|null} cardFormat
* @returns {string} query params (no leading ?)
*/
function getDoubledAspectRatioImageParams(cardFormat) {
const format = (cardFormat || '').toLowerCase();
let width;
let height;
switch (format) {
case 'backdrop':
case 'thumb':
case 'series thumb':
case 'logo':
case 'clear art':
// fillHeight=267&fillWidth=474 → ×2
width = 948;
height = 534;
break;
case 'square':
case 'disc':
// fillHeight=297&fillWidth=297 → ×2
width = 594;
height = 594;
break;
case 'banner':
// fillHeight=100&fillWidth=540 → ×2
width = 1080;
height = 200;
break;
case 'portrait':
case 'poster':
case 'series poster':
default:
// fillHeight=446&fillWidth=297 → ×2
width = 594;
height = 892;
break;
}
return `fillHeight=${height}&fillWidth=${width}`;
}
/**
* Creates a Jellyfin card element from an item
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', 'series thumb', 'series poster'
* @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes)
* @param {boolean} useParentCard - When true, the parent item will be rendered as the card image and link
* @param {string|null} borderStyle - Section card border style; used to gate retro marquee attrs
* @returns {HTMLElement} - The constructed card element
*/
function createJellyfinCardElement(
item,
overflowCard = false,
cardFormat = null,
customFooterText = null,
useParentCard = false,
borderStyle = null,
cardImageParams = null
) {
cardFormat = cardFormat?.toLowerCase() || null;
if (cardFormat === 'button') {
return createLibraryButtonElement(item);
}
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
const itemId = item.Id || '';
const itemType = item.Type || 'Folder';
const isCustomCard = !!(item.cardUrl || item.posterUrl || item.thumbUrl || item.squareUrl || item.imageUrl);
let parentItem = null;
if (item.Type === 'Timer') {
parentItem = item;
item = parentItem.ProgramInfo;
}
// Determine card type based on cardFormat override or item type
let cardClass, padderClass, imageParams;
// Use custom footer text from item property if not explicitly provided
if (!customFooterText && item.CustomFooterText) {
customFooterText = item.CustomFooterText;
}
if (cardFormat) {
// Use specified cardFormat
if (
cardFormat === 'backdrop' ||
cardFormat === 'thumb' ||
cardFormat === 'series thumb' ||
cardFormat === 'logo' ||
cardFormat === 'clear art'
) {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (cardFormat === 'square' || cardFormat === 'disc') {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else if (cardFormat === 'banner') {
cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard';
padderClass = 'cardPadder-banner';
imageParams = 'fillHeight=100&fillWidth=540';
} else {
// portrait / poster / series poster (default)
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
} else {
// Use item type to determine card type
if (itemType === 'Episode' || itemType === 'TvChannel') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
} else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(itemType)) {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
} else {
// Default poster style for Movies, Series, etc.
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
}
if (cardImageParams) {
imageParams = cardImageParams;
}
const cardLinkId = useParentCard && (item.SeriesId || item.ParentId) ? item.SeriesId || item.ParentId : itemId;
// Create the main card container
const card = document.createElement('div');
card.className = `card ${cardClass}${!isMobileLayout() ? " card-hoverable" : ""} card-withuserdata`;
card.setAttribute('data-index', '0');
card.setAttribute('data-isfolder', itemType === 'MusicAlbum' || itemType === 'Artist' || item.IsFolder ? 'true' : 'false');
card.setAttribute('data-serverid', serverId);
if (!isCustomCard) card.setAttribute('data-id', cardLinkId);
if (isCustomCard) card.setAttribute('data-custom-card', 'true');
card.setAttribute('data-type', itemType);
if (normalizeCardBorderStyle(borderStyle) === 'retro-poster') {
card.setAttribute('data-retro-marquee', getRetroPosterMarqueeLabel(item));
}
const mediaType = item.MediaType === 'Unknown' && item.ChannelId ? 'Video' : item.MediaType;
card.setAttribute('data-mediatype', mediaType || 'Video');
if (item.CollectionType) {
card.setAttribute('data-collectiontype', item.CollectionType);
}
if (item.IsFolder && item.Path) {
card.setAttribute('data-path', item.Path);
}
if (item.ChannelId) {
card.setAttribute('data-channelid', item.ChannelId);
}
if (item.StartDate) {
card.setAttribute('data-startdate', item.StartDate);
}
if (item.EndDate) {
card.setAttribute('data-enddate', item.EndDate);
}
card.setAttribute('data-prefix', item.Name?.startsWith('The ') ? 'THE' : '');
if (item.UserData?.PlaybackPositionTicks && item.UserData?.PlaybackPositionTicks > 0) {
card.setAttribute('data-positionticks', item.UserData?.PlaybackPositionTicks);
}
// Card box container
const cardBox = document.createElement('div');
cardBox.className = `cardBox cardBox-bottompadded ${cardFormat === "button" ? "emby-button emby-button-foreground raised" : ""}`;
// Card scalable container
const cardScalable = document.createElement('div');
cardScalable.className = 'cardScalable';
// Card padder with icon
const cardPadder = document.createElement('div');
cardPadder.className = `cardPadder ${padderClass} lazy-hidden-children`;
const cardIcon = document.createElement('span');
cardIcon.className = 'cardImageIcon material-icons';
cardIcon.setAttribute('aria-hidden', 'true');
// Set icon based on item type
cardIcon.textContent = getItemMaterialIcon(item);
cardPadder.appendChild(cardIcon);
// Blurhash canvas (placeholder) – will be filled and shown when we have a blurhash
const blurhashCanvas = document.createElement('canvas');
blurhashCanvas.setAttribute('aria-hidden', 'true');
blurhashCanvas.width = 20;
blurhashCanvas.height = 20;
// lazy-hidden only when we have no blurhash (grey card until image loads)
blurhashCanvas.className = 'blurhash-canvas';
let libraryUrl = '';
if (item.Type === 'CollectionFolder') {
libraryUrl = buildCollectionFolderHref(item, serverId, { useHtmlExtension: true }) || '';
}
let cardUrl = item.cardUrl || libraryUrl || `#/details?id=${cardLinkId}&serverId=${serverId}`;
if (!item.cardUrl && itemType === 'Genre') {
const parentParam = item.ParentId ? `&parentId=${item.ParentId}` : '';
cardUrl = `#/list.html?genreId=${item.Id}&serverId=${serverId}${parentParam}`;
}
let imageUrl = '';
const hasCustomImages =
item.posterUrl || item.thumbUrl || item.squareUrl || item.imageUrl || item.backdropUrl || item.bannerUrl || item.logoUrl;
const imageItem = item.Type === 'Timer' ? item.ProgramInfo : item;
if (hasCustomImages) {
if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') {
imageUrl = `${item.backdropUrl || item.thumbUrl || item.imageUrl || item.posterUrl || item.squareUrl}`;
} else if (cardFormat === 'banner') {
imageUrl = `${item.bannerUrl || item.backdropUrl || item.thumbUrl || item.imageUrl || item.posterUrl || item.squareUrl}`;
} else if (cardFormat === 'logo' || cardFormat === 'clear art') {
imageUrl = `${item.logoUrl || item.imageUrl || item.posterUrl || item.thumbUrl || item.squareUrl}`;
} else if (cardFormat === 'square') {
imageUrl = `${item.squareUrl || item.imageUrl || item.posterUrl || item.thumbUrl}`;
} else {
// portrait/poster/series poster or default
imageUrl = `${item.posterUrl || item.imageUrl || item.thumbUrl || item.squareUrl}`;
}
} else if (cardFormat === 'backdrop') {
if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (useParentCard && item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (useParentCard && item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else if (item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
} else if (cardFormat === 'square') {
if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (useParentCard && item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (useParentCard && item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
imageUrl =
item.BackdropImageTags && item.BackdropImageTags[0]
? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`
: item.ImageTags?.Primary
? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`
: '';
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
} else if (item.PrimaryImageTag && item.Type === 'Episode') {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
} else if (item.BackdropImageTags && item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else if (item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else if (item.PrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
}
} else if (cardFormat === 'portrait' || cardFormat === 'poster') {
if ((useParentCard || item.Type === 'Episode') && item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} 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 === 'series poster') {
if (item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
} else if (cardFormat === 'series thumb') {
if (item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId || item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
} else if (item.BackdropImageTags && item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
} else if (cardFormat === 'thumb') {
if (item.Type === 'Episode' && !useParentCard) {
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 (item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId || item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
}
} else if (useParentCard && item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (item.ImageTags?.Thumb) {
// Prefer explicit thumb on the item
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else if (item.ParentThumbImageTag) {
// Fall back to a parent thumb (e.g. series thumb for episodes)
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else {
// No item or parent thumb – decide between primary vs backdrop based on primary orientation
const primaryAspect = typeof item.PrimaryImageAspectRatio === 'number' ? item.PrimaryImageAspectRatio : null;
const hasPrimary = !!item.ImageTags?.Primary;
const primaryLooksLikeThumb = hasPrimary && primaryAspect !== null && primaryAspect >= 1.5;
if (primaryLooksLikeThumb) {
// Primary is a wide (e.g. 16:9) thumbnail – use it directly
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
// Primary looks like a poster – prefer the item's backdrop
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
// Fall back to a parent backdrop if the item has none
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
} else if (hasPrimary) {
// No usable backdrop – final fallback to the item's primary (likely poster)
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
} else if (item.SeriesPrimaryImageTag) {
// As an absolute last resort, try the series primary
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
}
}
} else if (cardFormat === 'logo' && (item.ImageTags?.Logo || item.ParentLogoImageTag)) {
const logoTag = item.ImageTags?.Logo || item.ParentLogoImageTag;
const itemId = item.ImageTags?.Logo ? item.Id : item.ParentLogoItemId;
imageUrl = `${serverAddress}/Items/${itemId}/Images/Logo?${imageParams}&quality=96&tag=${logoTag}`;
} else if (cardFormat === 'clear art' && item.ImageTags?.Art) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Art?${imageParams}&quality=96&tag=${item.ImageTags.Art}`;
} else if (cardFormat === 'banner' && item.ImageTags?.Banner) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Banner?${imageParams}&quality=96&tag=${item.ImageTags.Banner}`;
} else if (cardFormat === 'disc' && item.ImageTags?.Disc) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Disc?${imageParams}&quality=96&tag=${item.ImageTags.Disc}`;
} else if (item.ImageTags?.Primary) {
if (item.IsJellyseerr) {
// Jellyseerr items use external image URLs
imageUrl = item.ImageTags.Primary.startsWith('http')
? item.ImageTags.Primary
: `https://image.tmdb.org/t/p/w500${item.ImageTags.Primary}`;
} else {
// Regular Jellyfin items
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
} else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
} else if (item.Type === 'Season' && item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else if (item.Type === 'Episode' && item.ParentThumbImageTag) {
imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
}
// Card image container
const cardImageContainer = isCustomCard ? document.createElement('div') : document.createElement('a');
if (!isCustomCard) {
cardImageContainer.href = cardUrl;
cardImageContainer.setAttribute('data-action', 'link');
}
const defaultBgClass = !imageUrl ? `defaultCardBackground${state.defaultCardBackgroundNumber++} ` : '';
cardImageContainer.className =
`cardImageContainer ${!imageUrl || imageUrl.length === 0 ? "defaultCardBackground " : ""}${defaultBgClass}coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast`
.replace(/\s+/g, ' ')
.trim();
cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown');
if (state.defaultCardBackgroundNumber > 5) {
state.defaultCardBackgroundNumber = 1;
}
// No image - add icon as inner element
if (!imageUrl) {
const iconSpan = document.createElement('span');
iconSpan.className = 'cardImageIcon material-icons';
iconSpan.setAttribute('aria-hidden', 'true');
// Set icon based on item type
iconSpan.textContent = getItemMaterialIcon(item);
cardImageContainer.appendChild(iconSpan);
}
const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null;
let includeKefinBlurhashCanvas = true;
if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') {
blurhashCanvas.classList.add('lazy-hidden');
} else if (shouldSkipBlurhashDecode()) {
// Skip CPU decode; keep default chrome / lazy image path only
blurhashCanvas.classList.add('lazy-hidden');
} else {
// Defer decode off the construction path — near-viewport filler paints later
blurhashCanvas.classList.add('lazy-hidden');
blurhashCanvas.setAttribute('data-blurhash-pending', blurhashStr);
}
if (imageUrl) {
// Use data-src for lazy loading instead of immediate backgroundImage
cardImageContainer.setAttribute('data-src', imageUrl);
if (blurhashStr && state.useBlurhash && cardFormat !== 'logo' && cardFormat !== 'clear art' && cardFormat !== 'disc') {
cardImageContainer.setAttribute('data-blurhash', blurhashStr);
}
// Add lazy class for styling/selection
cardImageContainer.classList.add('lazy');
}
//countIndicator indicator
if (!isCustomCard) {
const cardIndicators = document.createElement('div');
cardIndicators.className = 'cardIndicators';
if (item.UserData?.UnplayedItemCount && item.UserData?.UnplayedItemCount > 0) {
const unplayedIndicator = document.createElement('div');
unplayedIndicator.className = 'countIndicator indicator';
unplayedIndicator.textContent = item.UserData?.UnplayedItemCount;
cardIndicators.appendChild(unplayedIndicator);
}
if (item.LocationType === 'Virtual') {
if (item.PremiereDate && new Date(item.PremiereDate) > new Date()) {
const premiereIndicator = document.createElement('div');
premiereIndicator.className = 'unairedIndicator';
premiereIndicator.textContent = 'Unaired';
cardIndicators.appendChild(premiereIndicator);
} else {
const virtualIndicator = document.createElement('div');
virtualIndicator.className = 'missingIndicator';
virtualIndicator.textContent = 'Missing';
cardIndicators.appendChild(virtualIndicator);
}
}
if (item.UserData?.Played) {
const playedIndicator = document.createElement('div');
playedIndicator.className = 'playedIndicator indicator';
const playedIndicatorIcon = document.createElement('span');
playedIndicatorIcon.className = 'material-icons indicatorIcon check';
playedIndicatorIcon.setAttribute('aria-hidden', 'true');
playedIndicator.appendChild(playedIndicatorIcon);
cardIndicators.appendChild(playedIndicator);
}
if (parentItem && parentItem.Type === 'Timer') {
const timerIndicator = document.createElement('span');
timerIndicator.className = 'material-icons timerIndicator indicatorIcon fiber_manual_record';
timerIndicator.setAttribute('aria-hidden', 'true');
cardIndicators.appendChild(timerIndicator);
}
if (cardIndicators.childElementCount > 0) {
cardImageContainer.appendChild(cardIndicators);
}
if (item.MediaSourceCount > 1) {
const mediaSourceIndicator = document.createElement('div');
mediaSourceIndicator.className = 'mediaSourceIndicator';
mediaSourceIndicator.textContent = item.MediaSourceCount;
cardImageContainer.appendChild(mediaSourceIndicator);
}
if (item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0) {
const innerCardFooter = document.createElement('div');
innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear';
cardBox.appendChild(innerCardFooter);
const itemProgressBar = document.createElement('div');
itemProgressBar.className = 'itemProgressBar';
const itemProgressBarForeground = document.createElement('div');
itemProgressBarForeground.className = 'itemProgressBarForeground';
itemProgressBarForeground.style.width = `${item.UserData?.PlayedPercentage}%`;
itemProgressBar.appendChild(itemProgressBarForeground);
innerCardFooter.appendChild(itemProgressBar);
cardImageContainer.appendChild(innerCardFooter);
} else if (item.Type === 'Program') {
const isOnNow = Date.now() >= new Date(item.StartDate).getTime() && Date.now() <= new Date(item.EndDate).getTime()
if (isOnNow) {
const progress = (Date.now() - new Date(item.StartDate).getTime()) / (new Date(item.EndDate).getTime() - new Date(item.StartDate).getTime());
const progressPercentage = Math.round(progress * 100);
const innerCardFooter = document.createElement('div');
innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear';
cardBox.appendChild(innerCardFooter);
const itemProgressBar = document.createElement('div');
itemProgressBar.className = 'itemProgressBar';
const itemProgressBarForeground = document.createElement('div');
itemProgressBarForeground.className = 'itemProgressBarForeground';
itemProgressBarForeground.style.width = `${progressPercentage}%`;
itemProgressBar.appendChild(itemProgressBarForeground);
innerCardFooter.appendChild(itemProgressBar);
cardImageContainer.appendChild(innerCardFooter);
}
}
}
// Overlays: desktop = hover FAB + br actions; mobile = native bottom-right play only
const NON_PLAYABLE_TYPES = ['CollectionFolder', 'Folder', 'UserView', 'Virtual', 'Studio', 'Genre', 'Person'];
const NO_RATE_TYPES = ['UserView', 'CollectionFolder', 'Folder', 'Program', 'Timer', 'SeriesTimer', 'Channel'];
const canShowPlayOverlay =
!isCustomCard
&& !NON_PLAYABLE_TYPES.includes(item.Type)
&& item.LocationType !== 'Virtual'
&& item.Type !== 'Person';
// Match jellyfin-web itemHelper.canMarkPlayed (cards: no Folder/CollectionFolder/UserView)
const canMarkPlayed =
item.Type !== 'Program'
&& (
(item.MediaType === 'Video' && item.Type !== 'TvChannel')
|| (item.MediaType === 'Audio' && item.Type === 'AudioBook')
|| item.Type === 'Series'
|| item.Type === 'Season'
|| item.Type === 'BoxSet'
|| item.MediaType === 'Book'
)
&& !NON_PLAYABLE_TYPES.includes(item.Type);
// Match jellyfin-web itemHelper.canRate type denylist (do not gate on missing UserData)
const canRate = !NO_RATE_TYPES.includes(item.Type);
let cardOverlayContainer = null;
let mobileOverlayPlayButton = null;
if (isCustomCard) {
cardOverlayContainer = document.createElement('div');
cardOverlayContainer.className = 'cardOverlayContainer';
const overlayLink = document.createElement('a');
overlayLink.href = cardUrl;
overlayLink.className = 'button-link';
overlayLink.setAttribute('is', 'emby-linkbutton');
if (!cardUrl.startsWith('/') && !cardUrl.startsWith('#')) {
overlayLink.setAttribute('target', '_blank');
}
cardOverlayContainer.appendChild(overlayLink);
} else if (isMobileLayout()) {
// Match jellyfin-web layoutManager.mobile: play button with cardOverlayButton-br on the button
if (canShowPlayOverlay) {
mobileOverlayPlayButton = document.createElement('button');
mobileOverlayPlayButton.type = 'button';
mobileOverlayPlayButton.className =
'cardOverlayButton cardOverlayButton-br itemAction paper-icon-button-light';
mobileOverlayPlayButton.setAttribute('data-action', 'play');
mobileOverlayPlayButton.title = 'Play';
const playIcon = document.createElement('span');
playIcon.className = 'material-icons cardOverlayButtonIcon play_arrow';
playIcon.setAttribute('aria-hidden', 'true');
mobileOverlayPlayButton.appendChild(playIcon);
}
} else {
cardOverlayContainer = document.createElement('div');
cardOverlayContainer.className = 'cardOverlayContainer itemAction';
cardOverlayContainer.setAttribute('data-action', 'link');
const playButton = document.createElement('button');
playButton.className =
'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary';
playButton.setAttribute('data-action', 'resume');
const playIcon = document.createElement('span');
playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow';
playIcon.setAttribute('aria-hidden', 'true');
playButton.appendChild(playIcon);
const buttonContainer = document.createElement('div');
buttonContainer.className = 'cardOverlayButton-br flex';
const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
if (state.watchlist && watchlistSupportedTypes.includes(itemType)) {
const isLiked = item.UserData?.Likes === true;
const watchlistButton = document.createElement('button');
watchlistButton.type = 'button';
watchlistButton.className =
'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
watchlistButton.setAttribute('data-action', 'none');
watchlistButton.setAttribute('data-id', itemId);
watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
const watchlistIcon = document.createElement('span');
watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
watchlistIcon.setAttribute('aria-hidden', 'true');
watchlistButton.appendChild(watchlistIcon);
buttonContainer.appendChild(watchlistButton);
}
if (canMarkPlayed) {
const watchedButton = document.createElement('button');
watchedButton.setAttribute('is', 'emby-playstatebutton');
watchedButton.type = 'button';
watchedButton.setAttribute('data-action', 'none');
watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
watchedButton.setAttribute('data-id', itemId);
watchedButton.setAttribute('data-serverid', serverId);
watchedButton.setAttribute('data-itemtype', itemType);
watchedButton.setAttribute('data-played', item.UserData?.Played || 'false');
watchedButton.title = 'Mark played';
const watchedIcon = document.createElement('span');
watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed';
watchedIcon.setAttribute('aria-hidden', 'true');
watchedButton.appendChild(watchedIcon);
buttonContainer.appendChild(watchedButton);
}
if (canRate) {
const favoriteButton = document.createElement('button');
favoriteButton.setAttribute('is', 'emby-ratingbutton');
favoriteButton.type = 'button';
favoriteButton.setAttribute('data-action', 'none');
favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
favoriteButton.setAttribute('data-id', itemId);
favoriteButton.setAttribute('data-serverid', serverId);
favoriteButton.setAttribute('data-itemtype', itemType);
favoriteButton.setAttribute('data-likes', '');
favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false');
favoriteButton.title = 'Add to favorites';
const favoriteIcon = document.createElement('span');
favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite';
favoriteIcon.setAttribute('aria-hidden', 'true');
favoriteButton.appendChild(favoriteIcon);
buttonContainer.appendChild(favoriteButton);
}
const moreButton = document.createElement('button');
moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
moreButton.setAttribute('data-action', 'menu');
moreButton.title = 'More';
const moreIcon = document.createElement('span');
moreIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert';
moreIcon.setAttribute('aria-hidden', 'true');
moreButton.appendChild(moreIcon);
buttonContainer.appendChild(moreButton);
if (canShowPlayOverlay) {
cardOverlayContainer.appendChild(playButton);
}
cardOverlayContainer.appendChild(buttonContainer);
}
// Card text container - different structure for episodes
const cardTextFragment = document.createDocumentFragment();
const cardTextContainer = document.createElement('div');
cardTextContainer.className = 'cardText cardTextCentered cardText-first';
if (parentItem && parentItem.Type === 'Timer') {
const cardFooter = document.createElement('div');
cardFooter.className = 'cardFooter cardFooter-withlogo';
const cardFooterLogo = document.createElement('div');
cardFooterLogo.className = 'lazy cardFooterLogo lazy-image-fadein-fast';
const imageUrl = `${serverAddress}/Items/${parentItem.ChannelId}/Images/Primary?height=40&tag=${parentItem.ChannelPrimaryImageTag}&quality=90`;
cardFooterLogo.style.backgroundImage = `url(${imageUrl})`;
cardFooter.appendChild(cardFooterLogo);
const cardFooterText = document.createElement('div');
cardFooterText.className = 'cardText cardText-first';
cardFooterText.textContent = item.Name || 'Unknown';
cardFooter.appendChild(cardFooterText);
const cardFooterSecondaryText = document.createElement('div');
cardFooterSecondaryText.className = 'cardText cardText-secondary';
// format the start and end time to 12:21 AM - 2:39 AM
const startTime = new Date(parentItem.StartDate).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
const endTime = new Date(parentItem.EndDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
cardFooterSecondaryText.textContent = startTime + ' - ' + endTime;
cardFooter.appendChild(cardFooterSecondaryText);
cardTextFragment.appendChild(cardFooter);
} else if (itemType === 'Episode') {
// Episodes: Series name as primary, episode title as secondary
const seriesLink = document.createElement('a');
seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || itemId}&serverId=${serverId}`;
seriesLink.className = 'itemAction textActionButton';
if (!isCustomCard) seriesLink.setAttribute('data-id', item.SeriesId || itemId);
seriesLink.setAttribute('data-serverid', serverId);
seriesLink.setAttribute('data-type', 'Series');
seriesLink.setAttribute('data-mediatype', 'undefined');
seriesLink.setAttribute('data-channelid', 'undefined');
seriesLink.setAttribute('data-isfolder', 'true');
seriesLink.setAttribute('data-action', 'link');
seriesLink.title = item.SeriesName || 'Unknown Series';
seriesLink.textContent = item.SeriesName || 'Unknown Series';
const seriesBdi = document.createElement('bdi');
seriesBdi.appendChild(seriesLink);
cardTextContainer.appendChild(seriesBdi);
cardTextFragment.appendChild(cardTextContainer);
// Episode title as secondary
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
const episodeName =
item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name;
episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
if (!isCustomCard) episodeLink.setAttribute('data-id', itemId);
episodeLink.setAttribute('data-serverid', serverId);
episodeLink.setAttribute('data-type', 'Episode');
episodeLink.setAttribute('data-mediatype', 'undefined');
episodeLink.setAttribute('data-channelid', 'undefined');
episodeLink.setAttribute('data-isfolder', 'false');
episodeLink.setAttribute('data-action', 'link');
episodeLink.title = episodeName;
episodeLink.textContent = episodeName;
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
secondaryText.appendChild(episodeBdi);
cardTextFragment.appendChild(secondaryText);
// Add custom footer text if provided (e.g., air date)
if (customFooterText) {
const footerText = document.createElement('div');
footerText.className = 'cardText cardTextCentered cardText-secondary';
const footerBdi = document.createElement('bdi');
footerBdi.textContent = customFooterText;
footerText.appendChild(footerBdi);
cardTextFragment.appendChild(footerText);
}
} else if (itemType === 'Season') {
// Seasons: Series name as primary, season title as secondary
const seriesLink = document.createElement('a');
seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || itemId}&serverId=${serverId}`;
seriesLink.className = 'itemAction textActionButton';
if (!isCustomCard) seriesLink.setAttribute('data-id', item.SeriesId || itemId);
seriesLink.setAttribute('data-serverid', serverId);
seriesLink.setAttribute('data-type', 'Series');
seriesLink.setAttribute('data-mediatype', 'undefined');
seriesLink.setAttribute('data-channelid', 'undefined');
seriesLink.setAttribute('data-isfolder', 'true');
seriesLink.setAttribute('data-action', 'link');
seriesLink.title = item.SeriesName || 'Unknown Series';
seriesLink.textContent = item.SeriesName || 'Unknown Series';
const seriesBdi = document.createElement('bdi');
seriesBdi.appendChild(seriesLink);
cardTextContainer.appendChild(seriesBdi);
cardTextFragment.appendChild(cardTextContainer);
// Season title as secondary
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const seasonLink = document.createElement('a');
seasonLink.href = `${ApiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`;
seasonLink.className = 'itemAction textActionButton';
if (!isCustomCard) seasonLink.setAttribute('data-id', itemId);
seasonLink.setAttribute('data-serverid', serverId);
seasonLink.setAttribute('data-type', 'Season');
seasonLink.setAttribute('data-mediatype', 'undefined');
seasonLink.setAttribute('data-channelid', 'undefined');
seasonLink.setAttribute('data-isfolder', 'false');
seasonLink.setAttribute('data-action', 'link');
seasonLink.title = item.Name || 'Unknown Season';
seasonLink.textContent = item.Name || 'Unknown Season';
const seasonBdi = document.createElement('bdi');
seasonBdi.appendChild(seasonLink);
secondaryText.appendChild(seasonBdi);
cardTextFragment.appendChild(secondaryText);
// Add custom footer text if provided (e.g., air date)
if (customFooterText) {
const footerText = document.createElement('div');
footerText.className = 'cardText cardTextCentered cardText-secondary';
const footerBdi = document.createElement('bdi');
footerBdi.textContent = customFooterText;
footerText.appendChild(footerBdi);
cardTextFragment.appendChild(footerText);
}
} else {
// Default: Item name as primary, year as secondary
const titleLink = document.createElement('a');
titleLink.href = cardUrl;
titleLink.className = 'itemAction textActionButton';
if (!isCustomCard) titleLink.setAttribute('data-id', itemId);
titleLink.setAttribute('data-serverid', serverId);
titleLink.setAttribute('data-type', itemType);
titleLink.setAttribute('data-mediatype', 'undefined');
titleLink.setAttribute('data-channelid', 'undefined');
titleLink.setAttribute(
'data-isfolder',
itemType === 'MusicAlbum' || itemType === 'Artist' || itemType === 'MusicArtist' || item.IsFolder ? 'true' : 'false',
);
if (item.CollectionType) {
titleLink.setAttribute('data-collectiontype', item.CollectionType);
}
titleLink.setAttribute('data-action', 'link');
titleLink.title = item.Name || 'Unknown';
titleLink.textContent = item.Name || 'Unknown';
const titleBdi = document.createElement('bdi');
titleBdi.appendChild(titleLink);
cardTextContainer.appendChild(titleBdi);
cardTextFragment.appendChild(cardTextContainer);
// Secondary text (year)
// For Series items, also list their end date or Present if it's continuing aka Start Year - End Year
let secondaryTextContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
if (itemType === 'Series' && item.Status === 'Continuing') {
secondaryTextContent = item.ProductionYear + ' - Present';
} else if (itemType === 'Series' && item.EndDate && item.ProductionYear && item.EndDate.substring(0, 4) !== String(item.ProductionYear)) {
secondaryTextContent = item.ProductionYear + ' - ' + item.EndDate.substring(0, 4);
} else if (item.Type === 'Program') {
secondaryTextContent = new Date(item.StartDate).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: true });
}
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const yearBdi = document.createElement('bdi');
yearBdi.textContent = secondaryTextContent || item.CustomFooterText;
secondaryText.appendChild(yearBdi);
cardTextFragment.appendChild(secondaryText);
if (secondaryTextContent && item.CustomFooterText) {
const footerText = document.createElement('div');
footerText.className = 'cardText cardTextCentered cardText-secondary';
const footerBdi = document.createElement('bdi');
footerBdi.textContent = item.CustomFooterText;
footerText.appendChild(footerBdi);
cardTextFragment.appendChild(footerText);
} else if (item.Type === 'Program') {
// Format the HH:mm - HH:mm based on local time
const timeOptions = { hour: '2-digit', minute: '2-digit', hour12: true };
let startTime = new Date(item.StartDate).toLocaleTimeString('en-US', timeOptions).replace('AM', 'a.m.').replace('PM', 'p.m.').toLowerCase();
let endTime = new Date(item.EndDate).toLocaleTimeString('en-US', timeOptions).replace('AM', 'a.m.').replace('PM', 'p.m.').toLowerCase();
// Strip leading zero from hour (e.g., 01:00 a.m. -> 1:00 a.m.)
startTime = startTime.replace(/^0(\d:)/, '$1');
endTime = endTime.replace(/^0(\d:)/, '$1');
secondaryTextContent = startTime + ' - ' + endTime;
const footerText = document.createElement('div');
footerText.className = 'cardText cardTextCentered cardText-secondary';
const footerBdi = document.createElement('bdi');
footerBdi.textContent = secondaryTextContent;
footerText.appendChild(footerBdi);
cardTextFragment.appendChild(footerText);
}
}
// Assemble card (blurhash already drawn above when blurhashStr was set)
cardScalable.appendChild(cardPadder);
if (includeKefinBlurhashCanvas) {
cardScalable.appendChild(blurhashCanvas);
}
cardScalable.appendChild(cardImageContainer);
if (mobileOverlayPlayButton) {
cardScalable.appendChild(mobileOverlayPlayButton);
} else if (cardOverlayContainer) {
cardScalable.appendChild(cardOverlayContainer);
}
cardScalable.appendChild(createCardBorderHost());
cardBox.appendChild(cardScalable);
if (cardTextFragment.childNodes.length) {
const cardTextStack = document.createElement('div');
cardTextStack.className = 'cardTextStack';
cardTextStack.appendChild(cardTextFragment);
cardBox.appendChild(cardTextStack);
} else {
cardBox.appendChild(cardTextFragment);
}
card.appendChild(cardBox);
/* ensureCardRadiusCssVar();
if (cachedCardRadius == null) {
requestAnimationFrame(() => ensureCardRadiusCssVar());
} */
return card;
}
/**
* TV layout detection (Jellyfin sets layout-tv on <html>).
* Evaluated at call time so Display Mode changes are respected after re-render.
*/
function isTvLayout() {
return document.documentElement.classList.contains('layout-tv');
}
/** Feature-detect flex gap once; JMP/Qt WebEngine often lacks it. */
function ensureNoFlexGapClass() {
const root = document.documentElement;
if (root.dataset.kefinFlexGapChecked === 'true') return;
root.dataset.kefinFlexGapChecked = 'true';
try {
const probe = document.createElement('div');
probe.style.cssText =
'display:flex;gap:1px;position:absolute;left:-9999px;top:0;width:auto;height:auto;visibility:hidden';
const a = document.createElement('div');
const b = document.createElement('div');
a.style.cssText = 'width:1px;height:1px';
b.style.cssText = 'width:1px;height:1px';
probe.appendChild(a);
probe.appendChild(b);
document.body.appendChild(probe);
const supported = probe.scrollWidth >= 3;
probe.remove();
root.classList.toggle('no-flex-gap', !supported);
} catch (_) {
root.classList.add('no-flex-gap');
}
}
function isFocusableTvEl(el) {
if (!el || el.nodeType !== 1) return false;
if (el.getAttribute('tabindex') === '-1') return false;
if (el.disabled || el.getAttribute('aria-hidden') === 'true') return false;
const tag = el.tagName;
if (tag === 'A' && el.hasAttribute('href')) return true;
if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return true;
const ti = el.getAttribute('tabindex');
return ti != null && ti !== '' && Number(ti) >= 0;
}
function getTvFocusables(root) {
if (!root?.querySelectorAll) return [];
return Array.from(root.querySelectorAll('a[href], button, [tabindex]')).filter(isFocusableTvEl);
}
/** Leftmost TV focus stop in spotlight header (skips plain section labels). */
function getFirstSpotlightHeaderFocusable(headerCluster) {
if (!headerCluster) return null;
return getTvFocusables(headerCluster)[0] || null;
}
function focusTvEl(el) {
if (!el || typeof el.focus !== 'function') return false;
try {
el.focus({ preventScroll: true });
} catch (_) {
el.focus();
}
return document.activeElement === el;
}
function getMetadataLinkFocusables(metaContainer) {
if (!metaContainer) return [];
return Array.from(
metaContainer.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
).filter((el) => el.getAttribute('tabindex') !== '-1' || metaContainer.dataset.metadataDrilled === 'true');
}
function getMetadataRows(metaContainer) {
if (!metaContainer) return [];
return Array.from(metaContainer.querySelectorAll(':scope > .spotlight-metadata-row'));
}
function setMetadataDrilled(metaContainer, drilled) {
if (!metaContainer) return;
metaContainer.dataset.metadataDrilled = drilled ? 'true' : 'false';
const links = metaContainer.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
);
links.forEach((link) => {
if (drilled) {
link.classList.add('show-focus');
if (link.getAttribute('tabindex') === '-1') link.removeAttribute('tabindex');
} else {
link.setAttribute('tabindex', '-1');
}
});
if (drilled) {
metaContainer.setAttribute('tabindex', '-1');
} else {
metaContainer.setAttribute('tabindex', '0');
metaContainer.classList.add('show-focus');
}
}
function collapseAllMetadataDrillIn(root) {
root?.querySelectorAll?.('.metadata-container').forEach((meta) => {
setMetadataDrilled(meta, false);
});
}
/**
* Prefer TV button cards when layout-tv is active; otherwise desktop cards.
*/
function createCardElement(
item,
overflowCard = false,
cardFormat = null,
customFooterText = null,
useParentCard = false,
borderStyle = null,
cardImageParams = null,
) {
if (isTvLayout()) {
return buildTvCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
}
return createJellyfinCardElement(
item,
overflowCard,
cardFormat,
customFooterText,
useParentCard,
borderStyle,
cardImageParams,
);
}
/**
* Convert a desktop card DOM tree into a Jellyfin TV focusable button card.
* Reuses desktop image/footer construction, then strips hover overlays and linked titles.
*/
function convertDesktopCardToTvButton(sourceCard, item) {
const button = document.createElement('button');
button.type = 'button';
Array.from(sourceCard.attributes).forEach((attr) => {
if (attr.name === 'class') return;
button.setAttribute(attr.name, attr.value);
});
const classes = String(sourceCard.className || '')
.split(/\s+/)
.filter((c) => c && c !== 'card-hoverable');
if (!classes.includes('show-focus')) classes.push('show-focus');
if (!classes.includes('show-animation')) classes.push('show-animation');
if (!classes.includes('itemAction')) classes.push('itemAction');
button.className = classes.join(' ');
button.setAttribute('data-action', 'link');
button.setAttribute('aria-label', item?.Name || sourceCard.getAttribute('aria-label') || 'Unknown');
while (sourceCard.firstChild) {
button.appendChild(sourceCard.firstChild);
}
const imageLink = button.querySelector('a.cardImageContainer');
if (imageLink) {
const imageDiv = document.createElement('div');
Array.from(imageLink.attributes).forEach((attr) => {
if (attr.name === 'href') return;
imageDiv.setAttribute(attr.name, attr.value);
});
while (imageLink.firstChild) {
imageDiv.appendChild(imageLink.firstChild);
}
imageLink.replaceWith(imageDiv);
}
button.querySelectorAll('.cardOverlayContainer').forEach((el) => el.remove());
button.querySelectorAll('a.textActionButton').forEach((link) => {
const text = link.textContent || '';
const bdi = link.closest('bdi');
if (bdi) {
bdi.textContent = text;
} else {
link.replaceWith(document.createTextNode(text));
}
});
// Match Jellyfin TV backdrop image request size when easy
const imageEl = button.querySelector('.cardImageContainer[data-src]');
if (imageEl) {
const src = imageEl.getAttribute('data-src') || '';
if (src.includes('fillHeight=267') && src.includes('fillWidth=474')) {
imageEl.setAttribute(
'data-src',
src
.replace('fillHeight=267', 'fillHeight=335')
.replace('fillWidth=474', 'fillWidth=596'),
);
}
}
// Custom / external cards: overlay link was removed — navigate on activate
const customUrl = item?.cardUrl;
if (customUrl) {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (customUrl.startsWith('#') || customUrl.startsWith('/')) {
window.location.href = customUrl;
} else {
window.open(customUrl, '_blank', 'noopener,noreferrer');
}
});
}
return button;
}
/**
* Dedicated TV card builder (button root, no overlays, plain titles).
* Same argument shape as createJellyfinCardElement.
*/
function buildTvCardElement(
item,
overflowCard = false,
cardFormat = null,
customFooterText = null,
useParentCard = false,
borderStyle = null,
) {
const normalizedFormat = (cardFormat || '').toLowerCase() || null;
if (normalizedFormat === 'button') {
const libraryButton = createLibraryButtonElement(item);
libraryButton.classList.add('show-focus', 'show-animation', 'itemAction');
if (!libraryButton.getAttribute('data-action')) {
libraryButton.setAttribute('data-action', 'link');
}
return libraryButton;
}
const desktopCard = createJellyfinCardElement(
item,
overflowCard,
cardFormat,
customFooterText,
useParentCard,
borderStyle,
);
return convertDesktopCardToTvButton(desktopCard, item);
}
/**
* 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) {
return document.createElement('div');
}
const {
autoPlay = true,
interval = 10000,
showSlideState = true,
showDots = true,
showNavButtons = true,
showClearArt = false,
panAnimation = true,
tileCount: tileCountOpt,
cycleBackdrops = false,
cycleBackdropsTime,
backdropsCount: backdropsCountOpt,
entranceAnimationFirst = 'fadeIn',
entranceAnimationSecond = 'fadeIn',
entranceAnimationThird = 'fadeIn',
slideAnimationFirst = 'kenBurnsZoomIn',
slideAnimationSecond = 'kenBurnsZoomIn',
slideAnimationThird = 'kenBurnsZoomIn',
viewMoreUrl = null,
initialIndex = 0,
/** Build DOM only — no timers/observers (for harvesting slides during in-place refresh). */
inert = false,
} = options;
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));
}
backdropsCount = Math.max(1, parseInt(backdropsCount, 10) || 1);
const perBackdropMs = Math.max(500, Math.round(interval / backdropsCount));
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
const sectionKey = 'spotlight_' + Date.now() + '_' + Math.random().toString(36).slice(2, 9);
const cycleBackdropMap = new Map();
let currentIndex = Math.max(0, Math.min(items.length - 1, parseInt(initialIndex, 10) || 0));
let autoPlayTimer = null;
let cycleBackdropTimer = null;
let advanceDue = false;
let isPaused = false;
let isVisible = true; // updated by Intersection Observer; start true so initial startAutoPlay runs
let currentSlideAnimationComplete = false;
function shuffleArray(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Create main container
const container = document.createElement('div');
container.className = 'spotlight-section padded-left';
// Create banner container
const bannerContainer = document.createElement('div');
bannerContainer.className = 'spotlight-banner-container';
// Header wraps title + nav so TV Left/Right stays in-family (one focuscontainer-x).
// Title container hosts section name + refresh/configure controls.
// Always create it so controls can attach even when the section has no name.
{
const headerContainer = document.createElement('div');
headerContainer.className = 'spotlight-header-container';
if (isTvLayout()) headerContainer.classList.add('focuscontainer-x');
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'spotlight-section-title-container';
if (title) {
let sectionTitleEl;
if (viewMoreUrl) {
const titleLink = document.createElement('a');
titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground emby-button';
titleLink.textContent = title;
titleLink.title = 'See All';
titleLink.style.textDecoration = 'none';
if (isTvLayout()) titleLink.classList.add('show-focus');
if (typeof viewMoreUrl === 'function') {
if (isTvLayout()) titleLink.setAttribute('tabindex', '0');
titleLink.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
viewMoreUrl();
});
} else {
titleLink.href = viewMoreUrl;
titleLink.addEventListener('click', (e) => {
e.stopPropagation();
});
}
sectionTitleEl = titleLink;
} else {
// Label only — not a focus stop; section-controls / nav are first when entering header
sectionTitleEl = document.createElement('div');
sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground emby-button';
sectionTitleEl.textContent = title;
}
const sectionTitleWrapper = document.createElement('div');
sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? "" : "spotlight-title-link "}headerTabs sectionTabs`;
sectionTitleWrapper.appendChild(sectionTitleEl);
sectionTitleContainer.appendChild(sectionTitleWrapper);
}
headerContainer.appendChild(sectionTitleContainer);
bannerContainer.appendChild(headerContainer);
}
// Create items container (for fade transitions)
const itemsContainer = document.createElement('div');
itemsContainer.className = 'spotlight-items-container';
function applyAnimationToImg(img, entranceName, slideName) {
img.style.setProperty('--spotlight-entrance-animation', entranceName);
img.style.setProperty('--spotlight-slide-animation', slideName);
img.setAttribute('data-entrance-animation', entranceName);
img.setAttribute('data-slide-animation', slideName);
}
// Sliding window: render current + 4 prev + 4 next (9 slides). Update DOM as user navigates.
let currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length);
function createAndAppendSlide(item, index, eagerLoadImages) {
const itemType = item.Type || 'Movie';
const itemDiv = document.createElement('div');
itemDiv.className = 'spotlight-item';
itemDiv.setAttribute('data-id', item.Id);
itemDiv.setAttribute('data-index', String(index));
itemDiv.setAttribute('data-item-type', itemType);
let imageUrl = '';
// 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,
}));
} else {
// Handle Custom items with static images
const customImageOrder = [
"backdropUrl",
"thumbUrl",
"bannerUrl",
"squareUrl",
"posterUrl",
"imageUrl",
"logoUrl"
];
for (const key of customImageOrder) {
const url = item[key];
if (typeof url === "string" && url.trim()) {
// Use custom URL as only 'backdrop' for static slides
backdropSources = [{
itemId: null,
tag: null,
url: url.trim(),
key
}];
break;
}
}
}
// Set backdrop width based on layout-mobile presence
let backdropWidth = 2560;
if (document.querySelector('.layout-mobile')) {
backdropWidth = 1920;
}
const buildBackdropUrl = (src, idx) => src.url ? src.url :
`${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?maxWidth=${backdropWidth}&quality=96&tag=${src.tag}`;
/* Cap layers by available backdrops so we never build dual/triple with undefined sources. */
const effectiveTileCount = Math.min(tileCount, Math.max(1, backdropSources.length));
if (effectiveTileCount >= 2 && backdropSources.length >= 2) {
const indices = shuffleArray(backdropSources.map((_, i) => i));
const pick = (i) => backdropSources[indices[i]];
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');
const leftUrl = buildBackdropUrl(pick(0), indices[0]);
leftImg.setAttribute('data-src', leftUrl);
leftImg.alt = '';
leftImg.draggable = false;
applyAnimationToImg(leftImg, entranceAnimationFirst, slideAnimationFirst);
if (eagerLoadImages) {
leftImg.src = leftUrl;
leftImg.loading = 'eager';
}
leftLayer.appendChild(leftImg);
dualBackgroundContainer.appendChild(leftLayer);
if (effectiveTileCount >= 3) {
const centerLayer = document.createElement('div');
centerLayer.className = 'spotlight-background-layer spotlight-background-layer-center';
const centerImg = document.createElement('img');
const centerUrl = buildBackdropUrl(pick(1), indices[1]);
centerImg.setAttribute('data-src', centerUrl);
centerImg.alt = '';
centerImg.draggable = false;
applyAnimationToImg(centerImg, entranceAnimationSecond, slideAnimationSecond);
if (eagerLoadImages) {
centerImg.src = centerUrl;
centerImg.loading = 'eager';
}
centerLayer.appendChild(centerImg);
dualBackgroundContainer.appendChild(centerLayer);
}
const rightLayer = document.createElement('div');
rightLayer.className = 'spotlight-background-layer spotlight-background-layer-right';
const rightImg = document.createElement('img');
const rightIdx = effectiveTileCount >= 3 ? 2 : 1;
const rightUrl = buildBackdropUrl(pick(rightIdx), indices[rightIdx]);
rightImg.setAttribute('data-src', rightUrl);
rightImg.alt = '';
rightImg.draggable = false;
applyAnimationToImg(
rightImg,
effectiveTileCount >= 3 ? entranceAnimationThird : entranceAnimationSecond,
effectiveTileCount >= 3 ? slideAnimationThird : slideAnimationSecond,
);
if (eagerLoadImages) {
rightImg.src = rightUrl;
rightImg.loading = 'eager';
}
rightLayer.appendChild(rightImg);
dualBackgroundContainer.appendChild(rightLayer);
itemDiv.appendChild(dualBackgroundContainer);
} else {
let singleUrl = '';
const shuffled = shuffleArray(backdropSources.map((s, i) => ({ src: s, idx: i })));
if (shuffled.length > 0 && shuffled.some(s => s.src.url)) {
singleUrl = shuffled.find(s => s.src.url).src.url;
} else if (cycleBackdrops && tileCount === 1 && backdropSources.length > 0) {
const urls = shuffled.map(
({ src, idx }) =>
`${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?maxWidth=${backdropWidth}&quality=96&tag=${src.tag}`,
);
const mapKey = sectionKey + '_' + item.Id;
cycleBackdropMap.set(mapKey, { shuffledUrls: urls, currentIndex: 0 });
singleUrl = urls[0];
} else if (backdropSources.length) {
const first = backdropSources[0];
singleUrl = `${serverAddress}/Items/${first.itemId}/Images/Backdrop?maxWidth=${backdropWidth}&quality=96&tag=${first.tag}`;
} else if (item.ImageTags?.Primary) {
singleUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?maxWidth=${backdropWidth}&quality=96&tag=${item.ImageTags.Primary}`;
}
if (singleUrl) {
const singleBackdropContainer = document.createElement('div');
singleBackdropContainer.className =
'spotlight-background-single' +
(cycleBackdrops && tileCount === 1 && backdropSources.length > 0 ? ' cycle-backdrops' : '');
const singleImg = document.createElement('img');
singleImg.setAttribute('data-src', singleUrl);
singleImg.alt = '';
singleImg.draggable = false;
applyAnimationToImg(singleImg, entranceAnimationFirst, slideAnimationFirst);
if (eagerLoadImages) {
singleImg.src = singleUrl;
singleImg.loading = 'eager';
}
singleBackdropContainer.appendChild(singleImg);
if (cycleBackdrops && tileCount === 1 && backdropSources.length > 0) {
const singleImgNext = document.createElement('img');
singleImgNext.setAttribute('data-src', singleUrl);
singleImgNext.alt = '';
singleImgNext.draggable = false;
singleImgNext.style.opacity = '0';
applyAnimationToImg(singleImgNext, entranceAnimationFirst, slideAnimationFirst);
singleBackdropContainer.appendChild(singleImgNext);
}
itemDiv.appendChild(singleBackdropContainer);
}
}
// Create left overlay (40-50% width, dark semi-transparent)
// Add top padding to prevent content from overlapping with section title
const overlay = document.createElement('div');
overlay.className = 'spotlight-overlay' + (title ? ' has-title' : '');
// Item title or logo
let titleEl = null;
const hasLogo = item.ImageTags?.Logo;
if (hasLogo) {
// Use logo image instead of text title
const logoHeight = size === 'full' ? '300' : size === 'large' ? '250' : '200';
const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?maxHeight=${logoHeight}&quality=96&tag=${item.ImageTags.Logo}`;
titleEl = document.createElement('div');
titleEl.className = 'spotlight-item-logo';
titleEl.setAttribute('data-background-url', logoUrl);
if (eagerLoadImages) titleEl.style.backgroundImage = `url("${logoUrl}")`;
titleEl.alt = item.Name || 'Unknown';
} else {
// Use text title as fallback
titleEl = document.createElement('div');
titleEl.className = 'spotlight-item-title';
titleEl.textContent = item.Name || 'Unknown';
}
// Helper: wrap content in native Jellyfin itemDetailsGroup > detailsGroupItem structure
// icon: optional Material Icons name (e.g. 'schedule') - same pattern as genres/director/writer
function createMetadataItem(content, tag = 'span', extraClasses = '', icon = null) {
const group = document.createElement('div');
group.className = 'headerTabs sectionTabs emby-tabs-slider';
const item = document.createElement(tag);
item.className = 'emby-tab-button emby-button-foreground' + (extraClasses ? ' ' + extraClasses : '');
if (icon) item.setAttribute('data-icon', icon);
if (typeof content === 'string') {
item.textContent = content;
} else {
item.appendChild(content);
}
group.appendChild(item);
return group;
}
// Combined: Year + Time + Ends At + Genres (all on one line)
const metadataRow = document.createElement('div');
metadataRow.className = 'spotlight-metadata-row';
// Year
const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null);
if (year) {
metadataRow.appendChild(createMetadataItem(String(year)));
}
// Runtime and estimated end time (Movies only - not for Series/Season/Episode)
const isSeriesType = itemType === 'Series' || itemType === 'Season' || itemType === 'Episode';
if (!isSeriesType && item.RunTimeTicks) {
const runtimeMinutes = Math.round(item.RunTimeTicks / 10000000 / 60);
const hours = Math.floor(runtimeMinutes / 60);
const minutes = runtimeMinutes % 60;
let runtimeText = '';
if (hours > 0) {
runtimeText = `${hours}h ${minutes > 0 ? minutes + "m" : ""}`.trim();
} else {
runtimeText = `${minutes}m`;
}
metadataRow.appendChild(createMetadataItem(runtimeText));
// End time (when it would end if started now)
const now = new Date();
const runtimeMs = item.RunTimeTicks / 10000;
const endTime = new Date(now.getTime() + runtimeMs);
const endTimeItem = createMetadataItem(
endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
'span',
'spotlight-end-time',
'schedule',
);
metadataRow.appendChild(endTimeItem);
}
// Genres - merged into single container, max 3 visible, tooltip shows full list on hover
if (item.Genres && item.Genres.length > 0 && item.GenreItems && item.GenreItems.length > 0) {
const genreContainer = createTruncatedList('', item.GenreItems, 3, false, 'theater_comedy', 'genre');
if (genreContainer) {
metadataRow.appendChild(genreContainer);
}
}
// Helper function to create truncated list with tooltip for overflow items
// items can be array of strings (names) or array of objects with Name and Id
// icon: optional Material Icons name for full-screen display (e.g. 'movie', 'edit')
// linkType: 'person' | 'genre' | 'studio' | null - when set, create links
function createTruncatedList(label, items, maxItems = 3, isPeople = false, icon = null, linkType = null) {
const container = document.createElement('div');
container.className = 'spotlight-truncated-list headerTabs sectionTabs emby-tabs-slider';
container.setAttribute('data-label', label);
if (icon) container.setAttribute('data-icon', icon);
if (!items || items.length === 0) {
return container;
}
const displayContainer = document.createElement('span');
displayContainer.className = 'emby-tab-button emby-button-foreground';
if (icon) displayContainer.setAttribute('data-icon', icon);
if (label) {
const labelSpan = document.createElement('span');
labelSpan.className = 'spotlight-truncated-list-label';
labelSpan.textContent = `${label} `;
displayContainer.appendChild(labelSpan);
}
// Extract names and IDs from items (handle both strings and objects)
const itemsData = items
.map((item) => {
if (typeof item === 'string') {
return { name: item, id: null };
} else if (item && typeof item === 'object') {
return {
name: item.Name || item.name || null,
id: item.Id || item.id || null,
};
}
return container;
})
.filter((item) => item && item.name);
if (itemsData.length === 0) return null;
const hasMore = itemsData.length > maxItems;
// Resolve link type: explicit linkType, or fall back to isPeople for person links
const usePersonLinks = linkType === 'person' || (linkType !== 'genre' && linkType !== 'studio' && isPeople);
const useGenreLinks = linkType === 'genre';
const useStudioLinks = linkType === 'studio';
// Create spans for visible names only (no layout-shifting expand)
itemsData.forEach((itemData, index) => {
if (index >= maxItems) return;
let nameElement;
if (usePersonLinks && itemData.id) {
nameElement = document.createElement('a');
nameElement.className = 'spotlight-person-link';
nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
if (isTvLayout()) nameElement.classList.add('show-focus');
nameElement.addEventListener('click', (e) => {
e.stopPropagation();
});
} else if (useGenreLinks && itemData.id) {
nameElement = document.createElement('a');
nameElement.className = 'spotlight-genre-link';
nameElement.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
if (isTvLayout()) nameElement.classList.add('show-focus');
nameElement.addEventListener('click', (e) => e.stopPropagation());
} else if (useStudioLinks && itemData.id) {
nameElement = document.createElement('a');
nameElement.className = 'spotlight-studio-link';
nameElement.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
if (isTvLayout()) nameElement.classList.add('show-focus');
nameElement.addEventListener('click', (e) => e.stopPropagation());
} else {
nameElement = document.createElement('span');
nameElement.textContent = itemData.name;
}
nameElement.setAttribute('data-name-index', index);
displayContainer.appendChild(nameElement);
// Bullet separator (except for last visible item - use actual count when fewer than maxItems)
const lastVisibleIndex = Math.min(maxItems, itemsData.length) - 1;
if (index < lastVisibleIndex) {
const separator = document.createElement('span');
separator.className = 'spotlight-list-separator';
separator.textContent = '•';
displayContainer.appendChild(separator);
}
});
// Tooltip when more items exist (full list on hover) — skip on TV (no hover; links steal focus)
if (hasMore && !isTvLayout()) {
// Tooltip: full list as vertical stack with clickable links (no layout shift)
const tooltip = document.createElement('div');
tooltip.className = 'spotlight-truncated-list-tooltip';
if (label) {
const labelSpan = document.createElement('span');
labelSpan.className = 'spotlight-truncated-list-tooltip-label';
labelSpan.textContent = label + ' ';
tooltip.appendChild(labelSpan);
}
const listWrapper = document.createElement('div');
listWrapper.className = 'spotlight-truncated-list-tooltip-items';
itemsData.forEach((itemData) => {
const itemRow = document.createElement('div');
itemRow.className = 'spotlight-truncated-list-tooltip-item';
let linkEl;
if (usePersonLinks && itemData.id) {
linkEl = document.createElement('a');
linkEl.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
linkEl.className = 'spotlight-person-link';
linkEl.textContent = itemData.name;
linkEl.addEventListener('click', (e) => e.stopPropagation());
} else if (useGenreLinks && itemData.id) {
linkEl = document.createElement('a');
linkEl.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`;
linkEl.className = 'spotlight-genre-link';
linkEl.textContent = itemData.name;
linkEl.addEventListener('click', (e) => e.stopPropagation());
} else if (useStudioLinks && itemData.id) {
linkEl = document.createElement('a');
linkEl.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`;
linkEl.className = 'spotlight-studio-link';
linkEl.textContent = itemData.name;
linkEl.addEventListener('click', (e) => e.stopPropagation());
} else {
linkEl = document.createElement('span');
linkEl.textContent = itemData.name;
}
itemRow.appendChild(linkEl);
listWrapper.appendChild(itemRow);
});
tooltip.appendChild(listWrapper);
container.appendChild(tooltip);
container.style.position = 'relative';
const TOOLTIP_DELAY_MS = 600;
const HIDE_DELAY_MS = 150;
let tooltipTimer = null;
let hideTimer = null;
const showTooltip = () => {
tooltip.classList.add('visible');
tooltipTimer = null;
};
const hideTooltip = () => {
tooltip.classList.remove('visible');
if (tooltipTimer) {
clearTimeout(tooltipTimer);
tooltipTimer = null;
}
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
const scheduleHide = () => {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(hideTooltip, HIDE_DELAY_MS);
};
container.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
tooltipTimer = setTimeout(showTooltip, TOOLTIP_DELAY_MS);
});
container.addEventListener('mouseleave', () => {
scheduleHide();
});
tooltip.addEventListener('mouseenter', () => {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
});
tooltip.addEventListener('mouseleave', () => {
hideTooltip();
});
}
container.appendChild(displayContainer);
return container;
}
// Studios - show 1 visible, rest in tooltip with links (like directors/writers)
const studiosData =
Array.isArray(item.Studios) && item.Studios.length > 0
? item.Studios
: typeof item.Studio === 'string' && item.Studio
? item.Studio.split('|')
.map((s) => ({ Name: s.trim(), Id: null }))
.filter((x) => x.Name)
: [];
const studioContainer =
studiosData.length > 0 ? createTruncatedList('Produced by', studiosData, 1, false, 'apartment', 'studio') : null;
// For Series: show seasons/episodes. For Movies: show director/writer
let directorContainer = null;
let writerContainer = null;
let combinedContainer = null;
// Create series info container (seasons/episodes count)
const seriesInfoContainer = document.createElement('div');
seriesInfoContainer.className = 'spotlight-metadata-row spotlight-series-info';
if (itemType === 'Series') {
// Studio first (before seasons/episodes)
if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Series, show seasons and episodes count
const seasonsCount = item.ChildCount || 0;
if (seasonsCount > 1) {
const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? "season" : "seasons"}`;
seriesInfoContainer.appendChild(createMetadataItem(seasonsText));
}
const episodesCount = item.RecursiveItemCount || 0;
if (episodesCount > 0) {
const episodesText = `${episodesCount} ${episodesCount === 1 ? "episode" : "episodes"}`;
seriesInfoContainer.appendChild(createMetadataItem(episodesText));
}
} else if (itemType === 'Movie') {
// For Movies, show director/writer (existing logic)
if (item.People && Array.isArray(item.People)) {
const directors = item.People.filter((p) => p.Type === 'Director');
const writers = item.People.filter((p) => p.Type === 'Writer');
// Extract names for comparison
const directorNames = directors.map((p) => p.Name);
const writerNames = writers.map((p) => p.Name);
// Check if directors and writers are identical (same length and same names)
const areIdentical =
directorNames.length === writerNames.length &&
directorNames.length > 0 &&
directorNames.every((name, index) => name === writerNames[index]);
if (areIdentical) {
// Combine into single "Directed and Written by" row (with person objects for links)
combinedContainer = createTruncatedList('Directed and Written by', directors, 1, true, 'movie');
// Add empty writer container to maintain spacing
writerContainer = document.createElement('div');
writerContainer.className = 'spotlight-empty-writer-container';
} else {
// Show separately (with person objects for links)
if (directors.length > 0) {
directorContainer = createTruncatedList('Directed by', directors, 1, true, 'movie');
}
if (writers.length > 0) {
writerContainer = createTruncatedList('Written by', writers, 1, true, 'edit');
}
}
}
} else if (itemType === 'Season') {
// Studio first (before episodes)
if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Seasons, show episodes count
const episodesCount = item.RecursiveItemCount || 0;
const episodesText = `${episodesCount} ${episodesCount === 1 ? "episode" : "episodes"}`;
seriesInfoContainer.appendChild(createMetadataItem(episodesText));
} else if (itemType === 'Episode') {
// Studio first (before season/episode)
if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Episodes, show episode number
const seasonNumber = item.ParentIndexNumber || 0;
const seasonText = `Season ${seasonNumber}`;
const episodeNumber = item.IndexNumber || 0;
const episodeText = `Episode ${episodeNumber}`;
seriesInfoContainer.appendChild(createMetadataItem(`${seasonText} - ${episodeText}`));
}
// Tagline (tagline only) and Overview (separate element) - show/hide via CSS based on spotlight type
const taglineEl = document.createElement('p');
taglineEl.className = 'emby-tab-button emby-tab-button-active';
const tagline = item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0 ? item.Taglines[0] : '';
if (tagline) taglineEl.textContent = tagline;
const overviewEl = document.createElement('p');
overviewEl.className = 'spotlight-overview';
if (item.Overview) overviewEl.textContent = item.Overview;
// Buttons container
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'spotlight-buttons-container mainDetailButtons';
if (isTvLayout()) buttonsContainer.classList.add('focuscontainer-x');
// Play button (material icon span button)
const playButton = document.createElement('button');
playButton.className = 'emby-button raised button-flat btnPlay detailButton';
playButton.title = 'Play';
if (isTvLayout()) playButton.classList.add('show-focus');
const playIcon = document.createElement('span');
playIcon.className = 'material-icons';
playIcon.textContent = 'play_arrow';
playButton.appendChild(playIcon);
playButton.addEventListener('click', async (e) => {
e.stopPropagation();
try {
// TODO - this needs to be updated to use the data-action resume to play the item natively, if possible
if (window.apiHelper && window.apiHelper.playItem) {
await window.apiHelper.playItem(item.Id);
} else {
// Fallback to ApiClient.play if apiHelper is not available
if (window.ApiClient && typeof window.ApiClient.play === 'function') {
window.ApiClient.play({ ids: [item.Id] });
} else {
console.error('[KefinTweaks CardBuilder] No play method available');
}
}
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error playing item:', error);
}
});
// Watchlist button (same Likes / watchlist-button pattern as cards; clicks via watchlist.js)
const spotlightWatchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
let watchlistButton = null;
if (state.watchlist && spotlightWatchlistSupportedTypes.includes(itemType)) {
const isLiked = item.UserData?.Likes === true;
watchlistButton = document.createElement('button');
watchlistButton.type = 'button';
watchlistButton.className = 'watchlist-button spotlight-watchlist-button emby-button button-flat paper-icon-button-light';
if (isTvLayout()) watchlistButton.classList.add('show-focus');
watchlistButton.setAttribute('data-action', 'none');
watchlistButton.setAttribute('data-id', item.Id);
watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
const watchlistIcon = document.createElement('span');
watchlistIcon.className = 'material-icons watchlist';
watchlistIcon.setAttribute('aria-hidden', 'true');
watchlistButton.appendChild(watchlistIcon);
if (typeof window.KefinWatchlistLikedIds?.bindWatchlistButton === 'function') {
window.KefinWatchlistLikedIds.bindWatchlistButton(watchlistButton, item.Id, itemType);
}
}
// Info button (shows overview on hover)
const infoButton = document.createElement('button');
infoButton.className = 'emby-button button-flat spotlight-info-button paper-icon-button-light';
if (isTvLayout()) infoButton.classList.add('show-focus');
const infoIcon = document.createElement('span');
infoIcon.className = 'material-icons';
infoIcon.textContent = 'info';
infoIcon.title = 'Go To Item';
infoButton.appendChild(infoIcon);
// Overview tooltip (hover only) — skip on TV
if (item.Overview && !isTvLayout()) {
const overviewTooltip = document.createElement('div');
overviewTooltip.className = 'spotlight-overview-tooltip';
overviewTooltip.textContent = item.Overview;
infoButton.appendChild(overviewTooltip);
}
buttonsContainer.appendChild(playButton);
if (watchlistButton) {
buttonsContainer.appendChild(watchlistButton);
}
buttonsContainer.appendChild(infoButton);
// Rating with star icon
const rating = item.CommunityRating || item.CriticRating;
if (rating && typeof rating === 'number') {
const ratingContainer = document.createElement('div');
ratingContainer.className = 'emby-button button-flat starRatingContainer mediaInfoItem spotlight-rating-container';
const starIcon = document.createElement('span');
starIcon.className = 'material-icons starIcon star spotlight-rating-star';
if (rating && typeof rating === 'number') {
ratingContainer.innerText = rating.toFixed(1);
ratingContainer.prepend(starIcon);
buttonsContainer.appendChild(ratingContainer);
}
}
// Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons
overlay.appendChild(titleEl);
// TV: real emby-button so Jellyfin spatial nav lands on metadata (plain div is Tab-only)
const metadataContainer = isTvLayout()
? document.createElement('button')
: document.createElement('div');
metadataContainer.className = isTvLayout()
? 'metadata-container emby-button show-focus'
: 'metadata-container';
if (isTvLayout()) {
metadataContainer.type = 'button';
metadataContainer.setAttribute('tabindex', '0');
metadataContainer.setAttribute('aria-label', 'Item metadata');
metadataContainer.dataset.metadataDrilled = 'false';
// Collapsed click must not navigate; drill-in is Right/Enter only
metadataContainer.addEventListener('click', (e) => {
if (metadataContainer.dataset.metadataDrilled !== 'true') {
e.preventDefault();
e.stopPropagation();
}
});
}
if (isTvLayout()) metadataRow.classList.add('focuscontainer-x');
if (metadataRow.children.length > 0) {
metadataContainer.appendChild(metadataRow);
}
// For Series, show seasons/episodes. For Movies, show director/writer
if (itemType === 'Series' || itemType === 'Season' || itemType === 'Episode') {
if (seriesInfoContainer) {
if (isTvLayout()) seriesInfoContainer.classList.add('focuscontainer-x');
metadataContainer.appendChild(seriesInfoContainer);
}
} else {
let emptyDirectorContainer = document.createElement('div');
emptyDirectorContainer.className = 'spotlight-empty-director-container';
let emptyWriterContainer = document.createElement('div');
emptyWriterContainer.className = 'spotlight-empty-writer-container';
if (combinedContainer) {
const creditsRow = document.createElement('div');
creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (isTvLayout()) creditsRow.classList.add('focuscontainer-x');
if (studioContainer) creditsRow.appendChild(studioContainer);
creditsRow.appendChild(combinedContainer);
metadataContainer.appendChild(creditsRow);
} else if (directorContainer || writerContainer) {
const creditsRow = document.createElement('div');
creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (isTvLayout()) creditsRow.classList.add('focuscontainer-x');
if (studioContainer) creditsRow.appendChild(studioContainer);
if (directorContainer) creditsRow.appendChild(directorContainer);
if (writerContainer) creditsRow.appendChild(writerContainer);
metadataContainer.appendChild(creditsRow);
} else if (studioContainer) {
const studioRow = document.createElement('div');
studioRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (isTvLayout()) studioRow.classList.add('focuscontainer-x');
studioRow.appendChild(studioContainer);
metadataContainer.appendChild(studioRow);
}
}
if (isTvLayout()) {
// Collapsed by default: links not in vertical focus path until Right/OK drill-in
setMetadataDrilled(metadataContainer, false);
}
// Fixed 4-section grid: Logo, Metadata, Overview, Buttons (always render all to prevent layout shift)
const logoSection = document.createElement('div');
logoSection.className = 'spotlight-logo-section';
logoSection.appendChild(titleEl);
const metadataSection = document.createElement('div');
metadataSection.className = 'spotlight-metadata-section';
metadataSection.appendChild(metadataContainer);
const overviewSection = document.createElement('div');
overviewSection.className = 'spotlight-overview-section';
if (overviewEl.textContent) overviewSection.appendChild(overviewEl);
const buttonsSection = document.createElement('div');
buttonsSection.className = 'spotlight-buttons-section';
buttonsSection.appendChild(buttonsContainer);
overlay.appendChild(logoSection);
overlay.appendChild(metadataSection);
overlay.appendChild(overviewSection);
overlay.appendChild(buttonsSection);
itemDiv.appendChild(overlay);
if (taglineEl.textContent) {
const taglineContainer = document.createElement('div');
taglineContainer.className = 'spotlight-tagline headerTabs sectionTabs';
taglineContainer.appendChild(taglineEl);
itemDiv.appendChild(taglineContainer);
}
// ClearArt image (bottom right corner, if enabled and available)
if (showClearArt && item.ImageTags?.Art) {
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.setAttribute('data-src', clearArtUrl);
if (eagerLoadImages) clearArtEl.src = clearArtUrl;
clearArtEl.alt = item.Name || 'Unknown';
itemDiv.appendChild(clearArtEl);
}
itemsContainer.appendChild(itemDiv);
return itemDiv;
}
// Initial render: create slides for window around currentIndex
currentWindowIndices.forEach((idx) => {
createAndAppendSlide(items[idx], idx, idx === currentIndex);
});
bannerContainer.appendChild(itemsContainer);
// Mark the initially visible slide as active so only it is shown
const firstItemActive = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
if (firstItemActive) {
firstItemActive.setAttribute('data-active', 'true');
}
function syncSpotlightTvFocus() {
if (!isTvLayout()) return;
const isTooltipFocusable = (el) =>
!!el.closest?.('.spotlight-truncated-list-tooltip, .spotlight-overview-tooltip');
const isMetaLink = (el) =>
el.classList?.contains('spotlight-genre-link') ||
el.classList?.contains('spotlight-person-link') ||
el.classList?.contains('spotlight-studio-link');
bannerContainer.querySelectorAll('.spotlight-item').forEach((slide) => {
const active = slide.getAttribute('data-active') === 'true';
const meta = slide.querySelector('.metadata-container');
const drilled = meta?.dataset?.metadataDrilled === 'true';
slide.querySelectorAll('a[href], button').forEach((el) => {
if (el.classList.contains('spotlight-dot') || isTooltipFocusable(el)) {
el.setAttribute('tabindex', '-1');
return;
}
if (!active) {
el.setAttribute('tabindex', '-1');
return;
}
// Active slide: metadata links gated by drill-in
if (isMetaLink(el)) {
if (drilled) {
if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
} else {
el.setAttribute('tabindex', '-1');
}
return;
}
if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
});
if (meta) {
if (!active) {
meta.setAttribute('tabindex', '-1');
} else if (drilled) {
meta.setAttribute('tabindex', '-1');
} else {
meta.setAttribute('tabindex', '0');
meta.classList.add('show-focus');
}
}
});
bannerContainer.querySelectorAll('.spotlight-dot').forEach((dot) => {
dot.setAttribute('tabindex', '-1');
});
}
function captureSpotlightFocusSlot() {
if (!isTvLayout()) return null;
const ae = document.activeElement;
if (!ae || !container.contains(ae)) return null;
if (ae.closest('.spotlight-section-title-container')) return { slot: 'title' };
if (ae.closest('.spotlight-nav-container')) return { slot: 'nav' };
const meta = ae.closest('.metadata-container');
if (meta && container.contains(meta)) {
if (meta.dataset.metadataDrilled === 'true') {
const links = Array.from(
meta.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
return { slot: 'metadata-drilled', linkIndex: Math.max(0, links.indexOf(ae)) };
}
return { slot: 'metadata' };
}
const btn = ae.closest('.spotlight-buttons-container');
if (btn && container.contains(btn)) {
let role = 'play';
if (ae.classList.contains('watchlist-button') || ae.closest('.watchlist-button')) role = 'watchlist';
else if (ae.classList.contains('spotlight-info-button') || ae.closest('.spotlight-info-button'))
role = 'info';
else if (ae.classList.contains('btnPlay') || ae.closest('.btnPlay')) role = 'play';
return { slot: 'buttons', role };
}
return null;
}
function restoreSpotlightFocusSlot(slot) {
if (!slot || !isTvLayout()) return;
const activeSlide = bannerContainer.querySelector('.spotlight-item[data-active="true"]');
if (slot.slot === 'title') {
const headerCluster = bannerContainer.querySelector('.spotlight-header-container');
const first = getFirstSpotlightHeaderFocusable(headerCluster);
if (first) focusTvEl(first);
return;
}
if (slot.slot === 'nav') {
const nav = bannerContainer.querySelector('.spotlight-nav-buttons-container');
const first = getTvFocusables(nav)[0];
if (first) focusTvEl(first);
return;
}
if (!activeSlide) return;
if (slot.slot === 'metadata') {
collapseAllMetadataDrillIn(activeSlide);
const meta = activeSlide.querySelector('.metadata-container');
if (meta) {
setMetadataDrilled(meta, false);
focusTvEl(meta);
}
return;
}
if (slot.slot === 'metadata-drilled') {
const meta = activeSlide.querySelector('.metadata-container');
if (!meta) return;
setMetadataDrilled(meta, true);
const links = Array.from(
meta.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
const idx = Math.min(slot.linkIndex || 0, Math.max(0, links.length - 1));
if (links[idx]) focusTvEl(links[idx]);
else if (links[0]) focusTvEl(links[0]);
else {
setMetadataDrilled(meta, false);
focusTvEl(meta);
}
return;
}
if (slot.slot === 'buttons') {
const buttons = activeSlide.querySelector('.spotlight-buttons-container');
if (!buttons) return;
let target = null;
if (slot.role === 'watchlist') target = buttons.querySelector('.watchlist-button');
else if (slot.role === 'info') target = buttons.querySelector('.spotlight-info-button');
else target = buttons.querySelector('.btnPlay');
focusTvEl(target || getTvFocusables(buttons)[0]);
}
}
if (!inert) {
// Delegated handler: info button click -> navigate to item details for the current slide
bannerContainer.addEventListener('click', (e) => {
const infoBtn = e.target.closest('.spotlight-info-button');
if (!infoBtn || !bannerContainer.contains(infoBtn)) return;
e.preventDefault();
e.stopPropagation();
const slide = infoBtn.closest('.spotlight-item');
if (!slide) return;
const itemId = slide.getAttribute('data-id');
if (!itemId) return;
const detailsUrl = `#/details?id=${itemId}&serverId=${serverId}`;
if (typeof Dashboard !== 'undefined' && Dashboard && typeof Dashboard.navigate === 'function') {
Dashboard.navigate(detailsUrl);
} else {
const fullDetailsUrl = `${serverAddress}/web/#${detailsUrl}`;
window.location.href = fullDetailsUrl;
}
});
// Initial Ken Burns (.animate) is deferred until first visibilityObserver intersect + one rAF
// (see visibilityObserver below). Navigation/timer still applies sync via goToItem.
// Navigation buttons (top right)
if (showNavButtons && items.length > 1) {
const navButtonsContainer = document.createElement('div');
navButtonsContainer.className = 'spotlight-nav-buttons-container emby-tab-button emby-tab-button-active emby-button-foreground';
const prevButton = document.createElement('button');
prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
if (isTvLayout()) prevButton.classList.add('show-focus');
const prevIcon = document.createElement('span');
prevIcon.className = 'material-icons';
prevIcon.textContent = 'chevron_left';
prevButton.appendChild(prevIcon);
prevButton.addEventListener('click', (e) => {
e.stopPropagation();
// Reset timer when manually navigating
goToItem((currentIndex - 1 + items.length) % items.length, true);
});
const nextButton = document.createElement('button');
nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button';
if (isTvLayout()) nextButton.classList.add('show-focus');
const nextIcon = document.createElement('span');
nextIcon.className = 'material-icons';
nextIcon.textContent = 'chevron_right';
nextButton.appendChild(nextIcon);
nextButton.addEventListener('click', (e) => {
e.stopPropagation();
// Reset timer when manually navigating
goToItem((currentIndex + 1) % items.length, true);
});
navButtonsContainer.appendChild(prevButton);
navButtonsContainer.appendChild(nextButton);
const navContainer = document.createElement('div');
navContainer.className = 'spotlight-nav-container headerTabs sectionTabs';
navContainer.appendChild(navButtonsContainer);
const headerContainer =
bannerContainer.querySelector('.spotlight-header-container') || bannerContainer;
headerContainer.appendChild(navContainer);
}
// Pause button (bottom right) — only when nav chrome exists (showNavButtons)
if (autoPlay && items.length > 1) {
const navButtonsContainer = bannerContainer.querySelector('.spotlight-nav-container .spotlight-nav-buttons-container');
if (navButtonsContainer) {
const pauseButton = document.createElement('button');
pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button';
if (isTvLayout()) pauseButton.classList.add('show-focus');
const pauseIcon = document.createElement('span');
pauseIcon.className = 'material-icons';
pauseIcon.textContent = 'pause';
pauseButton.appendChild(pauseIcon);
pauseButton.addEventListener('click', (e) => {
e.stopPropagation();
isPaused = !isPaused;
if (isPaused) {
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
pauseIcon.textContent = 'play_arrow';
} else {
startAutoPlay();
pauseIcon.textContent = 'pause';
}
});
navButtonsContainer.insertBefore(pauseButton, navButtonsContainer.firstChild);
}
}
// Slide state container (dots or numeric "N / X")
const MAX_DOTS = 5;
if (showSlideState && items.length > 1) {
const dotsContainer = document.createElement('div');
dotsContainer.className = 'spotlight-dots' + (showDots ? '' : ' spotlight-dots-numeric');
dotsContainer.setAttribute('data-index', String(currentIndex + 1)); /* 1-based for "N / X" display */
dotsContainer.setAttribute('data-total-items', String(items.length));
if (showDots) {
const numDots = Math.min(items.length, MAX_DOTS);
const activeDotIndex = currentIndex % numDots;
for (let i = 0; i < numDots; i++) {
const dot = document.createElement('button');
dot.className = 'spotlight-dot' + (i === activeDotIndex ? ' active' : '');
dot.setAttribute('data-dot-index', i);
dot.addEventListener('click', (e) => {
e.stopPropagation();
const windowStart = Math.floor(currentIndex / numDots) * numDots;
const targetIndex = Math.min(windowStart + i, items.length - 1);
goToItem(targetIndex, true);
});
dotsContainer.appendChild(dot);
}
}
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');
// Use content attribute — img.src IDL resolves '' to the document URL and would skip reload
if (url && img.getAttribute('src') !== url) img.setAttribute('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)
const SLIDE_ANIMATION_NAMES = new Set([
'kenBurnsZoomOut',
'kenBurnsZoomIn',
'kenBurnsZoomOutFullscreen',
'kenBurnsZoomInFullscreen',
'kenBurnsPanRight',
'kenBurnsPanLeft',
'kenBurnsPanUp',
'kenBurnsDiagonal',
'fadeInScale',
'parallaxFloat',
'depthPulse',
'slowRotate',
'breathe',
'heatHaze',
'colorWash',
'vignetteIn',
]);
const SLIDE_ANIMATION_FINAL_TRANSFORM = {
kenBurnsZoomIn: 'scale3d(1.12, 1.12, 1)',
kenBurnsZoomOut: 'scale3d(1.02, 1.02, 1)',
kenBurnsZoomInFullscreen: 'scale3d(1.12, 1.12, 1)',
kenBurnsZoomOutFullscreen: 'scale3d(1.08, 1.08, 1)',
kenBurnsPanRight: 'scale3d(1.1, 1.1, 1) translateX(5%)',
kenBurnsPanLeft: 'scale3d(1.1, 1.1, 1) translateX(-5%)',
kenBurnsPanUp: 'scale3d(1.1, 1.1, 1) translateY(-5%)',
kenBurnsDiagonal: 'scale3d(1.13, 1.13, 1) translate(5%, -5%)',
fadeInScale: 'scale3d(1, 1, 1)',
parallaxFloat: 'scale3d(1, 1, 1) translateY(0)',
depthPulse: 'scale3d(1, 1, 1)',
slowRotate: 'scale3d(1.15, 1.15, 1) rotate(3deg)',
breathe: 'scale3d(1, 1, 1)',
heatHaze: 'scale3d(1.05, 1.05, 1) skewX(0deg)',
};
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.style.transform = '';
img.style.animationPlayState = '';
img.classList.remove('animate');
img.classList.add('animate');
const slideName = img.getAttribute('data-slide-animation') || '';
const handler = (e) => {
if (e.animationName === slideName || SLIDE_ANIMATION_NAMES.has(e.animationName)) {
img.removeEventListener('animationend', handler);
checkAllDone();
var finalTransform = SLIDE_ANIMATION_FINAL_TRANSFORM[e.animationName];
if (finalTransform) {
requestAnimationFrame(() => {
img.style.transform = finalTransform;
});
}
}
};
img.addEventListener('animationend', handler);
});
}
function setSlideAnimationPlayState(slideElement, playState) {
if (!slideElement || !panAnimation) return;
slideElement
.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img')
.forEach((img) => {
img.style.animationPlayState = playState;
});
}
function getCurrentSpotlightSlide() {
return bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
}
function startCycleBackdropTimer(slideElement, itemId) {
if (cycleBackdropTimer) {
clearInterval(cycleBackdropTimer);
cycleBackdropTimer = null;
}
if (!cycleBackdrops || tileCount !== 1 || !isVisible) return;
const container = slideElement.querySelector('.spotlight-background-single.cycle-backdrops');
const imgs = container ? container.querySelectorAll('img') : [];
const mapKey = sectionKey + '_' + itemId;
const entry = cycleBackdropMap.get(mapKey);
if (!entry || !entry.shuffledUrls.length) return;
var cycleCount = 0;
var maxCycles = Math.max(0, backdropsCount - 1);
if (imgs.length >= 2) {
const bottomImg = imgs[0];
const topImg = imgs[1];
const crossfadeDuration = '0.8s';
cycleBackdropTimer = setInterval(() => {
if (!isVisible) return;
if (cycleCount >= maxCycles) {
if (cycleBackdropTimer) {
clearInterval(cycleBackdropTimer);
cycleBackdropTimer = null;
}
return;
}
cycleCount++;
entry.currentIndex = (entry.currentIndex + 1) % entry.shuffledUrls.length;
const nextUrl = entry.shuffledUrls[entry.currentIndex];
const preload = new Image();
preload.onload = () => {
topImg.src = nextUrl;
topImg.setAttribute('data-src', nextUrl);
topImg.style.transition = 'opacity ' + crossfadeDuration + ' ease-in-out';
topImg.style.opacity = '0';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
topImg.style.opacity = '1';
});
});
const handler = (e) => {
if (e.propertyName !== 'opacity') return;
topImg.removeEventListener('transitionend', handler);
bottomImg.src = nextUrl;
bottomImg.setAttribute('data-src', nextUrl);
topImg.style.opacity = '0';
};
topImg.addEventListener('transitionend', handler);
};
preload.src = nextUrl;
}, perBackdropMs);
} else {
const img = slideElement.querySelector('.spotlight-background-single img');
if (!img) return;
cycleBackdropTimer = setInterval(() => {
if (!isVisible) return;
if (cycleCount >= maxCycles) {
if (cycleBackdropTimer) {
clearInterval(cycleBackdropTimer);
cycleBackdropTimer = null;
}
return;
}
cycleCount++;
entry.currentIndex = (entry.currentIndex + 1) % entry.shuffledUrls.length;
const nextUrl = entry.shuffledUrls[entry.currentIndex];
const preload = new Image();
preload.onload = () => {
img.style.opacity = '0';
img.style.transition = 'opacity 0.8s ease-in-out';
requestAnimationFrame(() => {
img.src = nextUrl;
img.setAttribute('data-src', nextUrl);
requestAnimationFrame(() => {
img.style.opacity = '1';
});
});
};
preload.src = nextUrl;
}, perBackdropMs);
}
}
// Go to item function (crossfade transition, then only active slide is visible)
function goToItem(index, resetTimer = true) {
if (index === currentIndex) return;
const focusSlot = captureSpotlightFocusSlot();
if (cycleBackdropTimer) {
clearInterval(cycleBackdropTimer);
cycleBackdropTimer = null;
}
const newWindowIndices = getSpotlightWindowIndices(index, items.length);
const indexToRemove = currentWindowIndices.find((i) => !newWindowIndices.includes(i));
const indexToAdd = newWindowIndices.find((i) => !currentWindowIndices.includes(i));
var slideToRemove = undefined;
if (indexToRemove !== undefined) {
slideToRemove = bannerContainer.querySelector(`.spotlight-item[data-index="${indexToRemove}"]`);
}
if (indexToAdd !== undefined) {
createAndAppendSlide(items[indexToAdd], indexToAdd, false);
}
currentWindowIndices = newWindowIndices;
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}"]`);
// Prune the slide that left the window so the container never exceeds 9 items.
// If it's not the one we're fading out, remove it now; otherwise remove it in onFadeOutEnd.
if (slideToRemove && slideToRemove.parentNode && slideToRemove !== currentItem) {
slideToRemove.remove();
}
if (currentItem && nextItem) {
// Start crossfade: outgoing stays visible but fades out; incoming becomes visible and fades in
currentItem.removeAttribute('data-active');
currentItem.setAttribute('data-fade-out', 'true');
nextItem.setAttribute('data-active', 'true');
nextItem.setAttribute('data-entering', 'true');
applyPanAnimationToSlide(nextItem);
startCycleBackdropTimer(nextItem, items[index].Id);
// After next frame, remove data-entering so incoming slide transitions from 0 to 1
requestAnimationFrame(() => {
requestAnimationFrame(() => {
nextItem.removeAttribute('data-entering');
});
});
// When outgoing slide opacity transition ends, clear its fade-out state and prune if it left the window
const onFadeOutEnd = (e) => {
if (e.propertyName !== 'opacity') return;
currentItem.removeEventListener('transitionend', onFadeOutEnd);
currentItem.removeAttribute('data-fade-out');
if (slideToRemove && slideToRemove.parentNode) {
slideToRemove.remove();
}
};
currentItem.addEventListener('transitionend', onFadeOutEnd);
} else if (slideToRemove && slideToRemove.parentNode) {
slideToRemove.remove();
}
currentIndex = index;
// Update slide state (dots or data attrs for numeric)
if (showSlideState) {
const dotsContainer = bannerContainer.querySelector('.spotlight-dots');
if (dotsContainer) {
dotsContainer.setAttribute('data-index', String(index + 1)); // 1-based for display
}
if (showDots) {
const dots = bannerContainer.querySelectorAll('.spotlight-dot');
const numDots = dots.length;
const activeDotIndex = numDots > 0 ? index % numDots : 0;
dots.forEach((dot, i) => {
if (i === activeDotIndex) {
dot.classList.add('active');
} else {
dot.classList.remove('active');
}
});
}
}
// Reset auto-play timer when manually navigating (always reset delay)
if (resetTimer && autoPlay && !isPaused) {
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
advanceDue = false;
startAutoPlay();
}
syncSpotlightTvFocus();
if (focusSlot) {
requestAnimationFrame(() => restoreSpotlightFocusSlot(focusSlot));
}
}
// Auto-play: single-timeout per slide, controlled only by pause/visibility (hover is ignored)
function startAutoPlay() {
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
if (!autoPlay || items.length <= 1 || isPaused || !isVisible) return;
autoPlayTimer = setTimeout(onAutoPlayTimerFired, interval);
}
function onAutoPlayTimerFired() {
autoPlayTimer = null;
if (isPaused) {
advanceDue = true;
return;
}
if (!autoPlay || !isVisible || items.length <= 1) return;
goToItem((currentIndex + 1) % items.length, false);
advanceDue = false;
// Schedule next slide timer for the new current slide
if (autoPlay && !isPaused && isVisible && items.length > 1) {
autoPlayTimer = setTimeout(onAutoPlayTimerFired, interval);
}
}
// Only cycle when spotlight is in view: pause CSS pan + stop timers when off-screen.
// First intersect also gates initial .animate (post one rAF so IO + animate don't stack).
let initialPanScheduled = false;
const visibilityObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) return;
isVisible = entry.isIntersecting;
const currentSlide = getCurrentSpotlightSlide();
if (!isVisible) {
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
if (cycleBackdropTimer) {
clearInterval(cycleBackdropTimer);
cycleBackdropTimer = null;
}
setSlideAnimationPlayState(currentSlide, 'paused');
} else {
ensureSlideImagesLoaded(currentIndex);
if (items.length > 1) {
ensureSlideImagesLoaded((currentIndex + 1) % items.length);
}
const needsInitialPan =
panAnimation &&
currentSlide &&
!initialPanScheduled &&
!currentSlide.querySelector(
'.spotlight-background-layer img.animate, .spotlight-background-single img.animate',
);
if (needsInitialPan) {
initialPanScheduled = true;
requestAnimationFrame(() => {
if (!container.isConnected || !panAnimation || !isVisible) return;
const slide = getCurrentSpotlightSlide();
if (!slide) return;
if (
!slide.querySelector(
'.spotlight-background-layer img.animate, .spotlight-background-single img.animate',
)
) {
applyPanAnimationToSlide(slide);
}
if (items[currentIndex]) {
startCycleBackdropTimer(slide, items[currentIndex].Id);
}
});
} else {
// Re-entering viewport: resume pan, restart timers
setSlideAnimationPlayState(currentSlide, 'running');
if (currentSlide && items[currentIndex]) {
startCycleBackdropTimer(currentSlide, items[currentIndex].Id);
}
}
if (autoPlay && !isPaused) {
startAutoPlay();
}
}
},
{ root: null, rootMargin: '0px', threshold: 0 },
);
visibilityObserver.observe(container);
document.addEventListener('visibilitychange', () => {
const currentSlide = getCurrentSpotlightSlide();
if (document.hidden) {
if (autoPlayTimer) {
clearTimeout(autoPlayTimer);
autoPlayTimer = null;
}
setSlideAnimationPlayState(currentSlide, 'paused');
} else {
if (isVisible) {
setSlideAnimationPlayState(currentSlide, 'running');
}
if (autoPlay && !isPaused && isVisible) {
startAutoPlay();
}
}
});
// 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));
}
// TV D-pad: metadata drill-in, row-scoped seams (header L/R is native focuscontainer-x)
if (isTvLayout()) {
const ARROW = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
function getHeaderCluster() {
return bannerContainer.querySelector('.spotlight-header-container');
}
function getActiveMeta() {
return bannerContainer.querySelector(
'.spotlight-item[data-active="true"] .metadata-container',
);
}
function getActiveButtons() {
return bannerContainer.querySelector(
'.spotlight-item[data-active="true"] .spotlight-buttons-container',
);
}
function exitMetadataTo(direction) {
const meta = getActiveMeta();
if (meta) setMetadataDrilled(meta, false);
if (direction === 'up') {
focusTvEl(getFirstSpotlightHeaderFocusable(getHeaderCluster()));
} else {
const buttons = getActiveButtons();
focusTvEl(getTvFocusables(buttons)[0]);
}
}
function enterMetadataDrillIn() {
const meta = getActiveMeta();
if (!meta) return false;
setMetadataDrilled(meta, true);
const rows = getMetadataRows(meta);
for (const row of rows) {
const links = Array.from(
row.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (links[0]) {
focusTvEl(links[0]);
return true;
}
}
setMetadataDrilled(meta, false);
return false;
}
bannerContainer.addEventListener(
'keydown',
(e) => {
if (!ARROW.has(e.key) && e.key !== 'Enter' && e.key !== ' ') return;
const ae = document.activeElement;
if (!ae || !bannerContainer.contains(ae)) return;
const meta = getActiveMeta();
const inMeta = !!(meta && meta.contains(ae));
const drilled = meta?.dataset?.metadataDrilled === 'true';
// Metadata container: Right/Enter drills in; Up/Down leave to neighbors
if (inMeta && ae === meta && !drilled) {
if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
enterMetadataDrillIn();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('up');
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('down');
return;
}
}
// Metadata drilled: row-scoped L/R + Up/Down exit
if (inMeta && drilled && meta) {
const rows = getMetadataRows(meta).filter((row) =>
row.querySelector(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
const row = ae.closest('.spotlight-metadata-row');
const rowIndex = rows.indexOf(row);
const rowLinks = row
? Array.from(
row.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
)
: [];
const linkIndex = rowLinks.indexOf(ae);
if (e.key === 'ArrowUp') {
if (rowIndex <= 0) {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('up');
return;
}
// Move to previous row rightmost for predictability when using Up between rows
const prevLinks = Array.from(
rows[rowIndex - 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (prevLinks.length) {
e.preventDefault();
e.stopPropagation();
focusTvEl(prevLinks[prevLinks.length - 1]);
}
return;
}
if (e.key === 'ArrowDown') {
if (rowIndex < 0 || rowIndex >= rows.length - 1) {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('down');
return;
}
const nextLinks = Array.from(
rows[rowIndex + 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (nextLinks[0]) {
e.preventDefault();
e.stopPropagation();
focusTvEl(nextLinks[0]);
}
return;
}
if (e.key === 'ArrowLeft' && linkIndex === 0) {
e.preventDefault();
e.stopPropagation();
if (rowIndex > 0) {
// Bottom/mid row first link → previous row (not category rail)
const prevLinks = Array.from(
rows[rowIndex - 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
focusTvEl(prevLinks[prevLinks.length - 1] || meta);
} else {
// Top row first link → title / header
exitMetadataTo('up');
}
return;
}
if (e.key === 'ArrowRight' && linkIndex === rowLinks.length - 1) {
if (rowIndex >= 0 && rowIndex < rows.length - 1) {
const nextLinks = Array.from(
rows[rowIndex + 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (nextLinks[0]) {
e.preventDefault();
e.stopPropagation();
focusTvEl(nextLinks[0]);
}
} else {
// Right on last bottom link → buttons row
e.preventDefault();
e.stopPropagation();
exitMetadataTo('down');
}
return;
}
// Left/Right mid-row: let focuscontainer-x / browser handle
}
},
true,
);
// Vertical entry into header / buttons → snap leftmost
bannerContainer.addEventListener(
'focusin',
(e) => {
const target = e.target;
if (!target || !bannerContainer.contains(target)) return;
const related = e.relatedTarget;
const headerCluster = getHeaderCluster();
const buttons = getActiveButtons();
const enteredHeader =
headerCluster?.contains(target) &&
(!related || !headerCluster.contains(related));
const enteredButtons =
buttons?.contains(target) && (!related || !buttons.contains(related));
if (enteredHeader) {
const first = getFirstSpotlightHeaderFocusable(headerCluster);
if (first && target !== first) focusTvEl(first);
} else if (enteredButtons) {
const fs = getTvFocusables(buttons);
if (fs[0] && target !== fs[0]) focusTvEl(fs[0]);
}
},
true,
);
}
// Touch swipe handling
let touchStartX = 0;
let touchStartY = 0;
bannerContainer.addEventListener(
'touchstart',
(e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
},
{ passive: true },
);
bannerContainer.addEventListener(
'touchmove',
(e) => {
if (!e.touches[0]) return;
const touchX = e.touches[0].clientX;
const touchY = e.touches[0].clientY;
const deltaX = touchX - touchStartX;
const deltaY = touchY - touchStartY;
// If horizontal movement is dominant, prevent vertical scrolling and propagation
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (e.cancelable) {
e.preventDefault();
e.stopPropagation();
}
}
},
{ passive: false },
);
bannerContainer.addEventListener(
'touchend',
(e) => {
if (!e.changedTouches[0]) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
// Threshold for swipe (50px)
if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY)) {
// Stop propagation to prevent parent handlers (like tab switching)
e.stopPropagation();
if (deltaX > 0) {
// Swipe right (previous)
goToItem((currentIndex - 1 + items.length) % items.length, true);
} else {
// Swipe left (next)
goToItem((currentIndex + 1) % items.length, true);
}
}
},
{ passive: true },
);
} // end !inert wiring
container.appendChild(bannerContainer);
container.dataset.layout = layout;
container.dataset.size = size;
container.dataset.tileCount = String(tileCount);
// Allow in-place SWR to update the closed-over items list used by goToItem / windowing
container._kefinSpotlightApplyItems = (nextItems, opts = {}) => {
items = Array.isArray(nextItems) ? nextItems.slice() : [];
if (typeof opts.currentIndex === 'number' && items.length) {
currentIndex = Math.max(0, Math.min(items.length - 1, opts.currentIndex));
} else if (currentIndex >= items.length) {
currentIndex = Math.max(0, items.length - 1);
}
currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length);
};
container._kefinSpotlightPruneWindow = () => {
if (!items.length) return;
currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length);
const keep = new Set(currentWindowIndices.map(String));
itemsContainer.querySelectorAll(':scope > .spotlight-item').forEach((slide) => {
const idx = slide.getAttribute('data-index');
if (keep.has(idx)) return;
if (slide.getAttribute('data-active') === 'true') return;
slide.remove();
});
currentWindowIndices.forEach((idx) => {
if (itemsContainer.querySelector(`.spotlight-item[data-index="${idx}"]`)) return;
if (!items[idx]) return;
createAndAppendSlide(items[idx], idx, idx === currentIndex);
});
syncSpotlightTvFocus();
};
syncSpotlightTvFocus();
return container;
}
const ITEMS_LAYOUT_CYCLE = ['row', 'grid'];
const ITEMS_LAYOUT_ICONS = {
row: 'view_array',
grid: 'grid_view',
};
const ITEMS_LAYOUT_NEXT_TITLE = {
row: 'Grid layout',
grid: 'Row layout',
};
function resolveItemsLayout(sectionConfig) {
if (!sectionConfig) return 'row';
if (sectionConfig.itemsLayout === 'grid') return 'grid';
if (sectionConfig.itemsLayout === 'row') return 'row';
if (sectionConfig.gridExpanded === true) return 'grid';
return 'row';
}
function resolveUseGaplessCards(sectionConfig) {
return sectionConfig?.useGaplessCards === true;
}
function nextItemsLayout(current) {
const idx = ITEMS_LAYOUT_CYCLE.indexOf(current);
return ITEMS_LAYOUT_CYCLE[(idx < 0 ? 0 : idx + 1) % ITEMS_LAYOUT_CYCLE.length];
}
function setShowAllIcon(button, layout) {
if (!button) return;
const iconName = ITEMS_LAYOUT_ICONS[layout] || 'view_array';
const icon = button.querySelector('.material-icons') || button;
icon.classList.remove('view_array', 'grid_view', 'grid_on');
icon.classList.add(iconName);
// Keep glyph classes off the paper-icon button itself
button.classList.remove('view_array', 'grid_view', 'grid_on');
}
function isButtonCardFormat(cardFormat) {
return (cardFormat || '').toLowerCase() === 'button';
}
function getPaintedSectionItems(itemsContainer, cardFormat) {
if (!itemsContainer) return [];
if (isButtonCardFormat(cardFormat)) {
return Array.from(itemsContainer.querySelectorAll(':scope > .homeLibraryButton'));
}
return Array.from(itemsContainer.querySelectorAll(':scope > .card'));
}
function getRealLayoutCards(itemsContainer) {
if (!itemsContainer) return [];
return Array.from(itemsContainer.querySelectorAll(':scope > .card'));
}
/**
* Apply items layout (row|grid) and optional gapless cards on a scrollable section.
* @param {HTMLElement} verticalSection
* @param {string} layout
* @param {boolean} [gapless]
*/
function applyItemsLayoutState(verticalSection, layout, gapless) {
if (!verticalSection) return;
const itemsContainer = verticalSection.querySelector('.itemsContainer');
if (!itemsContainer) return;
const resolved = layout === 'grid' ? 'grid' : 'row';
const showAllButton = verticalSection.querySelector('.show-all-button');
const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons');
itemsContainer.setAttribute('data-layout', resolved);
verticalSection.setAttribute('data-layout', resolved);
itemsContainer.removeAttribute('data-expanded');
if (gapless === true) {
itemsContainer.setAttribute('data-gapless', 'true');
} else if (gapless === false) {
itemsContainer.removeAttribute('data-gapless');
}
if (resolved === 'row') {
if (scrollButtons) scrollButtons.style.display = '';
} else if (scrollButtons) {
scrollButtons.style.display = 'none';
}
if (showAllButton) {
const nextLayout = nextItemsLayout(resolved);
showAllButton.title = ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row;
showAllButton.setAttribute('aria-label', showAllButton.title);
setShowAllIcon(showAllButton, nextLayout);
}
}
/** @deprecated Use applyItemsLayoutState */
function applyGridExpandedState(verticalSection, expanded) {
applyItemsLayoutState(verticalSection, expanded ? 'grid' : 'row');
}
const pendingItemsLayoutSaves = new Map();
let itemsLayoutSaveTimeout = null;
function scheduleItemsLayoutSave(sectionId, layout, sectionType) {
if (!sectionId) return;
const value = layout === 'grid' ? layout : 'row';
pendingItemsLayoutSaves.set(sectionId, { layout: value, type: sectionType });
window.KefinHomeScreenSectionConfigure?.updateRuntimeItemsLayout?.(sectionId, value);
clearTimeout(itemsLayoutSaveTimeout);
itemsLayoutSaveTimeout = setTimeout(async () => {
const batch = new Map(pendingItemsLayoutSaves);
pendingItemsLayoutSaves.clear();
const api = window.KefinUserHomeScreenConfig;
const save = api?.saveSectionItemsLayout || api?.saveSectionGridExpanded;
if (!save) return;
for (const [id, entry] of batch) {
try {
const itemsLayout = typeof entry === 'object' ? entry.layout : entry;
const type = typeof entry === 'object' ? entry.type : undefined;
await save.call(api, id, itemsLayout, type ? { type } : undefined);
} catch (e) {
console.warn('[KefinTweaks CardBuilder] Failed to save itemsLayout:', e);
}
}
}, 300);
}
function isCardScrollerSection(sectionElement) {
return !!sectionElement?.querySelector('.itemsContainer');
}
function getShowAllControlLabel(sectionElement) {
const itemsContainer = sectionElement?.querySelector('.itemsContainer');
const currentLayout = itemsContainer?.getAttribute('data-layout') || 'row';
const resolved = currentLayout === 'grid' ? currentLayout : 'row';
return ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row;
}
function getShowAllControlIcon(sectionElement) {
const itemsContainer = sectionElement?.querySelector('.itemsContainer');
const currentLayout = itemsContainer?.getAttribute('data-layout') || 'row';
const resolved = currentLayout === 'grid' ? currentLayout : 'row';
return ITEMS_LAYOUT_ICONS[nextItemsLayout(resolved)] || 'grid_view';
}
function handleShowAllLayoutToggle(sectionElement) {
const verticalSection = sectionElement.closest('.emby-scroller-container') || sectionElement;
const itemsContainer = verticalSection.querySelector('.itemsContainer');
if (!itemsContainer) return;
const scroller = verticalSection.querySelector('.emby-scroller');
const currentLayout = itemsContainer.getAttribute('data-layout') || 'row';
const gapless = itemsContainer.getAttribute('data-gapless') === 'true';
const layout = nextItemsLayout(currentLayout === 'grid' ? currentLayout : 'row');
const sectionId = verticalSection.getAttribute('data-section-id') || verticalSection.dataset?.sectionId;
const sectionType = verticalSection.getAttribute('data-section-type') || verticalSection.dataset?.sectionType;
if (layout === 'row') {
applyItemsLayoutState(verticalSection, 'row', gapless);
updateScrollButtonStateForSection(verticalSection);
const saved = parseFloat(itemsContainer.getAttribute('data-row-scroll-x'));
const position = Number.isFinite(saved) ? saved : 0;
setScrollerPosition(scroller, position, true);
requestAnimationFrame(() => applyScrollButtonState(verticalSection));
scheduleItemsLayoutSave(sectionId, 'row', sectionType);
} else {
if (currentLayout === 'row') {
itemsContainer.setAttribute('data-row-scroll-x', String(getScrollerPosition(scroller)));
setScrollerPosition(scroller, 0, false);
}
applyItemsLayoutState(verticalSection, layout, gapless);
scheduleItemsLayoutSave(sectionId, layout, sectionType);
}
}
function getSectionControlDefinitions(sectionConfig, sectionElement, cachedItems) {
const definitions = [];
definitions.push({
id: 'refresh',
icon: 'refresh',
label: 'Refresh',
className: 'section-refresh-button',
isVisible: () => true,
onClick: async (e) => {
e.preventDefault();
e.stopPropagation();
await executeSectionRefresh(sectionConfig, sectionElement);
},
});
if (isCardScrollerSection(sectionElement)) {
definitions.push({
id: 'showAll',
icon: 'grid_view',
getIcon: () => getShowAllControlIcon(sectionElement),
getLabel: () => getShowAllControlLabel(sectionElement),
label: 'Grid layout',
className: 'show-all-button',
isVisible: () => true,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
handleShowAllLayoutToggle(sectionElement);
},
});
}
const configureDef = window.KefinHomeScreenSectionConfigure?.getConfigureSectionControl?.(
sectionConfig,
sectionElement,
cachedItems,
);
if (configureDef) {
definitions.push(configureDef);
}
return definitions;
}
function renderSectionControls(sectionConfig, sectionElement, titleContainer, definitions) {
const { inline, moreButton } = ensureSectionControlsMount(titleContainer);
inline.innerHTML = '';
const visibleDefinitions = definitions.filter((def) => !def.isVisible || def.isVisible());
let refreshButton = null;
visibleDefinitions.forEach((def) => {
const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label;
const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon;
const button = createPaperIconButton({
className: def.className || '',
icon: icon || '',
title: label,
showFocus: isTvLayout(),
});
button.dataset.sectionControlId = def.id;
button.addEventListener('click', (e) => {
def.onClick(e, button);
});
inline.appendChild(button);
if (def.id === 'refresh') {
refreshButton = button;
}
});
const newMoreButton = moreButton.cloneNode(true);
newMoreButton.classList.add('paper-icon-button-light');
if (isTvLayout()) newMoreButton.classList.add('show-focus');
if (!newMoreButton.querySelector('.material-icons')) {
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons more_vert';
iconSpan.setAttribute('aria-hidden', 'true');
newMoreButton.appendChild(iconSpan);
}
// Ensure legacy more buttons no longer carry icon font classes on the button itself
newMoreButton.classList.remove('material-icons', 'more_vert');
moreButton.replaceWith(newMoreButton);
const MORE_CONTROLS_MODAL_ID = 'kefin-section-controls-more';
let activeMorePopover = null;
const closeMorePopover = () => {
if (window.ModalSystem?.isOpen?.(MORE_CONTROLS_MODAL_ID)) {
window.ModalSystem.close(MORE_CONTROLS_MODAL_ID);
}
if (activeMorePopover) {
activeMorePopover.remove();
activeMorePopover = null;
}
};
const buildMorePopoverContent = (onActivate, { forModal = false } = {}) => {
const popover = document.createElement('div');
popover.className = forModal
? 'kefinTweaks-popover section-controls-more-menu'
: 'kefinTweaks-popover section-controls-more-popover';
visibleDefinitions.forEach((def) => {
const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label;
const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon;
const item = document.createElement('div');
item.className = 'kefinTweaks-popover-item detailsGroupItem section-controls-more-item';
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
const iconSpan = document.createElement('span');
iconSpan.className = `material-icons section-controls-more-item-icon ${icon || ''}`.trim();
iconSpan.setAttribute('aria-hidden', 'true');
const labelSpan = document.createElement('span');
labelSpan.className = 'section-controls-more-item-label';
labelSpan.textContent = label;
item.appendChild(iconSpan);
item.appendChild(labelSpan);
const activate = (ev) => {
ev.preventDefault();
ev.stopPropagation();
onActivate();
def.onClick(ev, newMoreButton);
};
item.addEventListener('click', activate);
item.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
activate(ev);
}
});
popover.appendChild(item);
});
return popover;
};
const positionMoreModal = (modal, anchorBtn) => {
if (!modal?.dialog || !anchorBtn) return;
modal.dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
modal.dialog.style.position = 'fixed';
modal.dialog.style.margin = '0';
modal.dialog.style.maxHeight = 'none';
modal.dialog.style.height = 'auto';
modal.dialog.style.minHeight = 'auto';
modal.dialog.style.minWidth = '12rem';
modal.dialog.style.width = 'auto';
modal.dialog.style.padding = '0.25em 0';
if (typeof window.ModalSystem?.applyOpenAnimation === 'function') {
window.ModalSystem.applyOpenAnimation(modal.dialog);
} else {
modal.dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
}
if (modal.dialogContent) {
modal.dialogContent.style.padding = '0';
modal.dialogContent.style.overflow = 'visible';
modal.dialogContent.style.minHeight = 'auto';
modal.dialogContent.style.height = 'auto';
modal.dialogContent.style.flex = '0 0 auto';
}
if (modal.backdrop) {
modal.backdrop.style.background = 'transparent';
}
if (modal.dialogContainer) {
modal.dialogContainer.style.pointerEvents = 'none';
}
modal.dialog.style.pointerEvents = 'auto';
const btnRect = anchorBtn.getBoundingClientRect();
const margin = 8;
const gap = 4;
requestAnimationFrame(() => {
const menuHeight = modal.dialog.offsetHeight || 0;
const menuWidth = Math.max(modal.dialog.offsetWidth || 0, 180);
let top = btnRect.bottom + gap;
if (top + menuHeight > window.innerHeight - margin) {
top = Math.max(margin, btnRect.top - menuHeight - gap);
}
let left = btnRect.right - menuWidth;
left = Math.min(Math.max(left, margin), window.innerWidth - menuWidth - margin);
modal.dialog.style.top = `${top}px`;
modal.dialog.style.left = `${left}px`;
});
};
newMoreButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (activeMorePopover || window.ModalSystem?.isOpen?.(MORE_CONTROLS_MODAL_ID)) {
closeMorePopover();
return;
}
if (isMobileLayout() && window.ModalSystem?.create) {
const content = buildMorePopoverContent(() => closeMorePopover(), { forModal: true });
window.ModalSystem.create({
id: MORE_CONTROLS_MODAL_ID,
title: '',
content,
showCloseButton: false,
closeOnBackdrop: true,
closeOnEscape: true,
fixedSize: false,
onOpen: (modal) => {
if (modal?.dialogHeader) modal.dialogHeader.style.display = 'none';
if (modal?.dialogFooter) modal.dialogFooter.style.display = 'none';
positionMoreModal(modal, newMoreButton);
},
});
return;
}
const popover = buildMorePopoverContent(() => closeMorePopover());
titleContainer.style.position = 'relative';
newMoreButton.parentNode.insertBefore(popover, newMoreButton.nextSibling);
const buttonRect = newMoreButton.getBoundingClientRect();
const containerRect = titleContainer.getBoundingClientRect();
popover.style.right = '40px';
popover.style.left = 'auto';
popover.style.top = '0px';
popover.style.minWidth = `${Math.max(buttonRect.width, 180)}px`;
popover.style.maxWidth = `${Math.max(0, containerRect.width)}px`;
activeMorePopover = popover;
const closeHandler = (ev) => {
if (!popover.contains(ev.target) && !newMoreButton.contains(ev.target)) {
closeMorePopover();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
}, 100);
});
return refreshButton;
}
function isMobileLayout() {
return document.documentElement.classList.contains('layout-mobile') || window.innerWidth < 900;
}
function getSectionSkeletonCount(sectionConfig, cardFormat = null, itemsLayout = null) {
const raw = sectionConfig?.itemLimit || sectionConfig?.queries?.[0]?.queryOptions?.Limit || 0;
const fullLimit = raw > 0 ? raw : 16;
const layout = itemsLayout || resolveItemsLayout(sectionConfig);
if (layout === 'grid') return fullLimit;
const format = String(cardFormat || '').toLowerCase();
// Default/empty format resolves to portrait in createSkeletonCard
let rowCap = 6;
if (format === 'banner') {
rowCap = 3;
} else if (
!format ||
format === 'portrait' ||
format === 'poster' ||
format === 'series poster' ||
format === 'square' ||
format === 'disc'
) {
rowCap = 8;
}
return Math.min(fullLimit, rowCap);
}
const SKELETON_ONE_LINE_ITEM_TYPES = new Set(['Book', 'Genre', 'CollectionFolder', 'BoxSet', 'Playlist', 'Folder', 'Studio']);
const SKELETON_CUSTOM_FOOTER_SECTION_IDS = new Set(['upcoming', 'recentlyReleased.movies', 'recentlyReleased.episodes']);
function sectionUsesCustomFooterText(sectionConfig) {
if (!sectionConfig) return false;
if (SKELETON_CUSTOM_FOOTER_SECTION_IDS.has(sectionConfig.id)) return true;
if (Array.isArray(sectionConfig.items) && sectionConfig.items.some((item) => item?.CustomFooterText || item?.cardFooter)) {
return true;
}
return false;
}
function inferSkeletonItemType(sectionConfig) {
if (!sectionConfig) return null;
const queries = sectionConfig.queries || [];
const activeIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false });
const query = queries[activeIndex] || queries[0];
const path = typeof query?.path === 'string' ? query.path : '';
if (path.includes('/Studios')) return 'Studio';
if (path.includes('/Genres')) return 'Genre';
let includeTypes = query?.queryOptions?.IncludeItemTypes;
if (typeof includeTypes === 'string') {
includeTypes = includeTypes
.split(',')
.map((t) => t.trim())
.filter(Boolean);
}
if (!Array.isArray(includeTypes) || includeTypes.length === 0) return null;
if (includeTypes.length === 1) return includeTypes[0];
if (includeTypes.every((t) => SKELETON_ONE_LINE_ITEM_TYPES.has(t))) {
return includeTypes[0];
}
return null;
}
function getSkeletonFooterLineCount(sectionConfig) {
if (sectionConfig?.hideCardFooter === true) return 0;
if (sectionUsesCustomFooterText(sectionConfig)) return 3;
const itemType = inferSkeletonItemType(sectionConfig);
if (itemType && SKELETON_ONE_LINE_ITEM_TYPES.has(itemType)) return 1;
return 2;
}
function readScrollerPadding(scroller) {
if (!scroller) return { left: 0, right: 0 };
const metrics = getScrollerMetrics(scroller);
if (metrics.pad) return metrics.pad;
// Reuse first measured pad for sibling scrollers in the same sections container
const host = scroller.closest?.('.homeSectionsContainer, .sections');
if (host && host._kefinSharedScrollerPad) {
metrics.pad = host._kefinSharedScrollerPad;
return metrics.pad;
}
const style = window.getComputedStyle(scroller);
const pad = {
left: parseFloat(style.paddingLeft) || 0,
right: parseFloat(style.paddingRight) || 0,
};
metrics.pad = pad;
if (host) host._kefinSharedScrollerPad = pad;
return pad;
}
/** Frame content-box width (clientWidth minus horizontal padding) — viewport for slidee math. */
function getScrollerViewportWidth(scroller, pad) {
const p = pad || readScrollerPadding(scroller);
return Math.max(0, (scroller?.clientWidth || 0) - (p.left || 0) - (p.right || 0));
}
function readCardPadding(card) {
if (!card) return { left: 0, right: 0 };
const cached = cardPaddingByEl.get(card);
if (cached) return cached;
const style = window.getComputedStyle(card);
const pad = {
left: parseFloat(style.paddingLeft) || 0,
right: parseFloat(style.paddingRight) || 0,
};
cardPaddingByEl.set(card, pad);
return pad;
}
function readSampleCardPadding(scroller) {
if (!scroller) return { left: 0, right: 0 };
const metrics = getScrollerMetrics(scroller);
if (metrics.cardPad) return metrics.cardPad;
const sampleCard = scroller.querySelector?.('.itemsContainer > .card:not(.skeleton-card)');
const pad = readCardPadding(sampleCard);
metrics.cardPad = pad;
return pad;
}
/** Inner slidee that receives translateX (Jellyfin .scrollSlider), not the outer frame. */
function getScrollSlidee(scroller) {
if (!scroller) return null;
return scroller.querySelector('.scrollSlider') || scroller.querySelector('.itemsContainer');
}
function getCurrentPosition(scroller) {
if (!scroller) return 0;
const metrics = getScrollerMetrics(scroller);
if (metrics && Number.isFinite(metrics.scrollX)) return metrics.scrollX;
return 0;
}
function setStoredScrollPosition(scroller, scrollX) {
const metrics = getScrollerMetrics(scroller);
if (metrics) metrics.scrollX = scrollX;
}
function setMaxScrollPosition(scroller, maxScroll) {
const metrics = getScrollerMetrics(scroller);
if (metrics) metrics.maxScroll = maxScroll;
}
/**
* Apply row scroll as translateX on the slidee (itemsContainer), matching Jellyfin.
* Clears any leftover transform on the outer scroller frame.
*/
function setSlideePosition(scroller, position, options = {}) {
if (!scroller) return 0;
const opts = options || {};
const slidee = getScrollSlidee(scroller);
if (opts.freshMax) {
const metrics = getScrollerMetrics(scroller);
if (metrics) {
metrics.maxScroll = null;
metrics.pad = null;
metrics.cardPad = null;
}
}
const max = Math.max(
0,
getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)),
);
const scrollPosition = Math.min(Math.max(position, 0), max);
const clampedPosition = scrollPosition > 20 ? scrollPosition : 0;
if (scroller.style.transform) scroller.style.transform = '';
if (scroller.style.transition) scroller.style.transition = '';
if (slidee) {
if (opts.animate) {
slidee.style.transition = 'transform 270ms ease-out';
slidee.style.willChange = 'transform';
} else {
slidee.style.transition = 'none';
}
slidee.style.transform = `translateX(-${clampedPosition}px)`;
}
setStoredScrollPosition(scroller, clampedPosition);
return clampedPosition;
}
/**
* Content-space X of a card relative to the scroller frame (undoes slidee translate).
*/
function getCardContentOffsetX(scroller, card) {
if (!scroller || !card) return 0;
const current = getCurrentPosition(scroller);
const scrollerRect = scroller.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
return cardRect.left - scrollerRect.left + current;
}
/**
* JF-style toCenter: put card center on frame center, clamped to [0, max].
* Early cards stay left-aligned (desired < 0 → 0); mid/late track center.
*/
function toCenterCard(scroller, card, options = {}) {
if (!scroller || !card || !scroller.contains(card)) return;
if (card.classList.contains('skeleton-card')) return;
const animate = options.animate !== false;
const metrics = getScrollerMetrics(scroller);
if (metrics) metrics.maxScroll = null;
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const contentOffset = getCardContentOffsetX(scroller, card);
const cardWidth = card.offsetWidth || card.getBoundingClientRect().width || 0;
// Center within the frame content box (same viewport as max-scroll)
const frameInnerWidth = getScrollerViewportWidth(scroller, pad);
const cardCenterInContent = contentOffset + cardWidth / 2 - pad.left;
const desired = cardCenterInContent - frameInnerWidth / 2;
const max = getMaxPositionFromPadding(scroller, pad, cardPad);
setSlideePosition(scroller, Math.min(Math.max(desired, 0), max), { animate: animate });
}
function attachTvFocusScroll(scroller) {
if (!scroller || scroller.dataset.tvFocusScroll === '1') return;
scroller.dataset.tvFocusScroll = '1';
let rafId = 0;
let pendingCard = null;
scroller.addEventListener(
'focusin',
(e) => {
const card = e.target?.closest?.('.card');
if (!card || !scroller.contains(card)) return;
if (card.classList.contains('skeleton-card')) return;
pendingCard = card;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
const target = pendingCard;
pendingCard = null;
if (target && scroller.contains(target)) {
toCenterCard(scroller, target, { animate: true });
}
});
},
true,
);
}
function getFullyVisibleCardCount(scroller, cards) {
const epsilon = 1;
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const scrollerRect = scroller.getBoundingClientRect();
// Slidee is translated; frame is fixed — visible window is the content box
const contentWidth = getScrollerViewportWidth(scroller, pad);
const visibleLeft = scrollerRect.left + pad.left;
const visibleRight = visibleLeft + contentWidth;
let count = 0;
cards.forEach((card) => {
const cardRect = card.getBoundingClientRect();
if (cardRect.width <= 0) return;
const cardContentLeft = cardRect.left + cardPad.left;
const cardContentRight = cardRect.right - cardPad.right;
if (cardContentLeft >= visibleLeft - epsilon && cardContentRight <= visibleRight + epsilon) {
count++;
}
});
return count;
}
/**
* Max translateX so slidee content flushes to the frame content-box right edge.
* Pairs slidee.scrollWidth with viewport = clientWidth - padL - padR (not clientWidth + endInset hacks).
* @param {HTMLElement} scroller
* @param {{left:number,right:number}} [pad]
* @param {{left:number,right:number}} [_cardPad] unused; kept for call-site compatibility
*/
function getMaxPositionFromPadding(scroller, pad, _cardPad) {
if (!scroller) return 0;
const metrics = getScrollerMetrics(scroller);
if (metrics && metrics.maxScroll != null && Number.isFinite(metrics.maxScroll)) {
return metrics.maxScroll;
}
const cards = scroller.querySelectorAll('.itemsContainer > .card:not(.skeleton-card)');
const fullyVisible = getFullyVisibleCardCount(scroller, cards);
if (fullyVisible === cards.length) {
setMaxScrollPosition(scroller, 0);
return 0;
}
const resolvedPad = pad || readScrollerPadding(scroller);
const slidee = getScrollSlidee(scroller);
const contentWidth = slidee ? slidee.scrollWidth : scroller.scrollWidth;
const viewport = getScrollerViewportWidth(scroller, resolvedPad);
const maxScroll = Math.max(0, contentWidth - viewport);
setMaxScrollPosition(scroller, maxScroll);
return maxScroll;
}
function createScrollerElement() {
const scroller = document.createElement('div');
const isMobile = isMobileLayout();
scroller.setAttribute('data-horizontal', 'true');
scroller.setAttribute('data-centerfocus', 'true');
scroller.className = `padded-top-focusscale padded-bottom-focusscale emby-scroller padded-left custom-scroller${isMobile ? " scrollX hiddenScrollX" : ""}`;
scroller.setAttribute('data-scroll-mode-x', isMobile ? 'native' : 'custom');
scroller.style.scrollSnapType = 'none';
scroller.style.touchAction = 'auto';
scroller.style.overscrollBehaviorX = 'contain';
scroller.style.overscrollBehaviorY = 'auto';
scroller.style.webkitOverflowScrolling = 'touch';
// Desktop custom path: clip frame + translateX slidee. Mobile scrollX: native overflow swipe.
if (!isMobile) {
scroller.style.overflow = 'hidden';
}
setStoredScrollPosition(scroller, 0);
return scroller;
}
function createItemsSliderElement(isEmbyItemscontainer = true) {
const isMobile = isMobileLayout();
const itemsContainer = document.createElement('div');
if (isEmbyItemscontainer) {
itemsContainer.setAttribute('is', 'emby-itemscontainer');
}
const tvClass = isTvLayout() ? ' itemsContainer-tv' : '';
itemsContainer.className = `focuscontainer-x itemsContainer scrollSlider${!isMobile ? " animatedScrollX" : ""}${tvClass}`;
itemsContainer.style.whiteSpace = 'nowrap';
return itemsContainer;
}
/**
* Attach scroll buttons and desktop drag handlers to a scrollable section scroller.
* @param {HTMLElement} verticalSection
* @param {HTMLElement} scroller
* @returns {HTMLElement} scrollButtons container
*/
function attachScrollableSectionChrome(verticalSection, scroller) {
const isMobile = isMobileLayout();
const scrollButtons = document.createElement('div');
scrollButtons.className = 'emby-scrollbuttons padded-right';
const leftButton = document.createElement('button');
leftButton.type = 'button';
leftButton.setAttribute('data-ripple', 'false');
leftButton.setAttribute('data-direction', 'left');
leftButton.setAttribute('title', 'Previous');
leftButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
leftButton.disabled = true;
const leftSpan = document.createElement('span');
leftSpan.className = 'material-icons chevron_left';
leftSpan.setAttribute('aria-hidden', 'true');
leftButton.appendChild(leftSpan);
function getCurrentPosition() {
return getScrollerPosition(scroller);
}
// Gesture-local padding: captured once per drag/wheel burst, cleared on end/idle
let gesturePad = null;
let gestureCardPad = null;
let wheelIdleTimer = null;
function ensureGestureInsets() {
if (!gesturePad) gesturePad = readScrollerPadding(scroller);
if (!gestureCardPad) gestureCardPad = readSampleCardPadding(scroller);
}
function clearGestureInsets() {
gesturePad = null;
gestureCardPad = null;
}
function getMaxPosition() {
ensureGestureInsets();
return getMaxPositionFromPadding(scroller, gesturePad, gestureCardPad);
}
function getMaxPositionFresh() {
const metrics = getScrollerMetrics(scroller);
if (metrics) {
metrics.maxScroll = null;
metrics.pad = null;
metrics.cardPad = null;
}
return getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
}
function setPosition(newPosition, options) {
const opts = options || {};
const slidee = getScrollSlidee(scroller);
const clampedPosition = setSlideePosition(scroller, newPosition, {
animate: !!opts.animate,
freshMax: !!opts.freshMax,
});
const section = scroller.closest('.emby-scroller-container');
if (section && typeof applyScrollButtonState === 'function') {
if (opts.animate && slidee) {
const onTransitionEnd = (e) => {
if (e && e.target !== slidee) return;
slidee.removeEventListener('transitionend', onTransitionEnd);
applyScrollButtonState(section);
};
slidee.addEventListener('transitionend', onTransitionEnd);
} else {
requestAnimationFrame(() => applyScrollButtonState(section));
}
}
return clampedPosition;
}
function getScrollCards() {
const itemsContainer = scroller.querySelector('.itemsContainer');
if (!itemsContainer) return [];
return Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.skeleton-card)'));
}
function getCardScrollLeft(card) {
// Align card content edge (border-box left + card paddingLeft) to the
// scroller's padded content gutter. Content-space (undoes slidee translate).
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
return getCardContentOffsetX(scroller, card) - pad.left + cardPad.left;
}
/**
* Cards fully inside the frame content-box clip.
* Frame is fixed; slidee translate moves cards — use live rects vs content-box edges.
*/
function getFullyVisibleCardIndices(cards) {
const fullyVisible = [];
const epsilon = 1;
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const scrollerRect = scroller.getBoundingClientRect();
const contentWidth = getScrollerViewportWidth(scroller, pad);
const visibleLeft = scrollerRect.left + pad.left;
const visibleRight = visibleLeft + contentWidth;
cards.forEach((card, index) => {
const cardRect = card.getBoundingClientRect();
if (cardRect.width <= 0) return;
// Use content box (inside card padding) for visibility
const cardContentLeft = cardRect.left + cardPad.left;
const cardContentRight = cardRect.right - cardPad.right;
if (cardContentLeft >= visibleLeft - epsilon && cardContentRight <= visibleRight + epsilon) {
fullyVisible.push(index);
}
});
return fullyVisible;
}
function snapScrollToCardIndex(cards, targetIndex) {
if (!cards.length) return;
const clampedIndex = Math.max(0, Math.min(targetIndex, cards.length - 1));
// Last card: flush to frame content-box end (getMaxPositionFresh)
if (clampedIndex === cards.length - 1) {
setPosition(getMaxPositionFresh(), { animate: true, freshMax: true });
return;
}
const targetLeft = getCardScrollLeft(cards[clampedIndex]);
setPosition(targetLeft, { animate: true, freshMax: true });
}
leftButton.addEventListener('click', () => {
const cards = getScrollCards();
if (!cards.length) return;
const fullyVisible = getFullyVisibleCardIndices(cards);
const firstVisible = fullyVisible.length ? fullyVisible[0] : 0;
const step = Math.max(1, fullyVisible.length);
snapScrollToCardIndex(cards, firstVisible - step);
});
scrollButtons.appendChild(leftButton);
const rightButton = document.createElement('button');
rightButton.type = 'button';
rightButton.setAttribute('data-ripple', 'false');
rightButton.setAttribute('data-direction', 'right');
rightButton.setAttribute('title', 'Next');
rightButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
const rightSpan = document.createElement('span');
rightSpan.className = 'material-icons chevron_right';
rightSpan.setAttribute('aria-hidden', 'true');
rightButton.appendChild(rightSpan);
rightButton.addEventListener('click', () => {
const cards = getScrollCards();
if (!cards.length) return;
const fullyVisible = getFullyVisibleCardIndices(cards);
const lastVisible = fullyVisible.length ? fullyVisible[fullyVisible.length - 1] : -1;
const nextIndex = lastVisible + 1;
const lastWillBeVisible = nextIndex + fullyVisible.length >= cards.length;
if (nextIndex >= cards.length || lastWillBeVisible) {
setPosition(getMaxPositionFresh(), { animate: true, freshMax: true });
return;
}
snapScrollToCardIndex(cards, nextIndex);
});
scrollButtons.appendChild(rightButton);
let dragState = {
isDragging: false,
startX: 0,
startY: 0,
startPosition: 0,
currentPosition: 0,
maxPosition: 0,
hasMoved: false,
lastX: 0,
lastTime: 0,
velocity: 0,
momentumId: null,
};
if (!isMobile) {
const DRAG_THRESHOLD_PX = 8;
function onPointerDown(e) {
if (e.type === 'mousedown' && e.button !== 0) {
return;
}
const point = e.touches ? e.touches[0] : e;
if (dragState.momentumId !== null) {
cancelAnimationFrame(dragState.momentumId);
dragState.momentumId = null;
}
dragState.startX = point.clientX;
dragState.startY = point.clientY;
dragState.isDragging = false;
function checkThreshold(moveEvent) {
const p = moveEvent.touches ? moveEvent.touches[0] : moveEvent;
const dx = p.clientX - dragState.startX;
const dy = p.clientY - dragState.startY;
if (Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return;
ensureGestureInsets();
dragState.isDragging = true;
dragState.startPosition = getCurrentPosition();
dragState.currentPosition = dragState.startPosition;
dragState.maxPosition = getMaxPosition();
dragState.hasMoved = false;
dragState.lastX = p.clientX;
dragState.lastTime = Date.now();
dragState.velocity = 0;
cleanupPending();
if (e.touches) {
document.addEventListener('touchmove', onDragMove, { passive: false });
document.addEventListener('touchend', onDragEnd, { passive: true });
document.addEventListener('touchcancel', onDragEnd, { passive: true });
} else {
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
}
if (!moveEvent.touches) moveEvent.preventDefault();
}
function cleanupPending() {
if (e.touches) {
document.removeEventListener('touchmove', checkThreshold);
document.removeEventListener('touchend', onPointerUp);
} else {
document.removeEventListener('mousemove', checkThreshold);
document.removeEventListener('mouseup', onPointerUp);
}
}
function onPointerUp() {
if (!dragState.isDragging) cleanupPending();
}
if (e.touches) {
document.addEventListener('touchmove', checkThreshold, { passive: false });
document.addEventListener('touchend', onPointerUp, { passive: true });
} else {
document.addEventListener('mousemove', checkThreshold);
document.addEventListener('mouseup', onPointerUp);
e.preventDefault();
}
}
function onDragMove(e) {
if (!dragState.isDragging) return;
const point = e.touches ? e.touches[0] : e;
const deltaX = dragState.startX - point.clientX;
const deltaY = dragState.startY - point.clientY;
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) {
dragState.hasMoved = true;
const rawNewPosition = dragState.startPosition + deltaX;
const clampedPosition = Math.min(Math.max(rawNewPosition, 0), Math.max(dragState.maxPosition, 0));
dragState.currentPosition = clampedPosition;
setPosition(clampedPosition, { animate: false });
const currentTime = Date.now();
const timeDelta = currentTime - dragState.lastTime;
if (timeDelta > 0) {
const positionDelta = point.clientX - dragState.lastX;
dragState.velocity = positionDelta / timeDelta;
}
dragState.lastX = point.clientX;
dragState.lastTime = currentTime;
if (e.touches) {
e.preventDefault();
}
}
}
function onDragEnd(e) {
if (!dragState.isDragging) return;
if (e.type === 'touchend' || e.type === 'touchcancel') {
document.removeEventListener('touchmove', onDragMove);
document.removeEventListener('touchend', onDragEnd);
document.removeEventListener('touchcancel', onDragEnd);
} else {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
}
if (dragState.hasMoved) {
scroller.style.pointerEvents = 'none';
requestAnimationFrame(() => {
scroller.style.pointerEvents = '';
});
}
dragState.isDragging = false;
const friction = 0.965;
const velocityThreshold = 0.01;
const velocityBoost = 2.0;
dragState.velocity *= velocityBoost;
if (Math.abs(dragState.velocity) > velocityThreshold) {
let currentPosition = dragState.currentPosition;
const maxPosition = dragState.maxPosition;
let lastTimestamp = 0;
const step = (timestamp) => {
const frameTime = lastTimestamp ? timestamp - lastTimestamp : 16;
lastTimestamp = timestamp;
if (Math.abs(dragState.velocity) > velocityThreshold) {
currentPosition -= dragState.velocity * frameTime;
currentPosition = Math.min(Math.max(currentPosition, 0), Math.max(maxPosition, 0));
setPosition(currentPosition, { animate: false });
if (currentPosition === 0 || currentPosition === maxPosition) {
dragState.velocity = 0;
dragState.momentumId = null;
clearGestureInsets();
return;
}
dragState.velocity *= friction;
dragState.momentumId = requestAnimationFrame(step);
} else {
dragState.velocity = 0;
dragState.momentumId = null;
clearGestureInsets();
}
};
dragState.momentumId = requestAnimationFrame(step);
} else {
dragState.velocity = 0;
dragState.momentumId = null;
clearGestureInsets();
}
}
scroller.addEventListener('mousedown', onPointerDown);
scroller.addEventListener('touchstart', onPointerDown, { passive: true });
// Trackpad / mouse wheel → custom transform scroll (native overflow scrollers handle this themselves).
// Keep passive:false so we can preventDefault on horizontal gestures, but coalesce apply work
// into one rAF so the event callback stays cheap under high-frequency trackpad input.
const WHEEL_DELTA_MIN = 1;
const WHEEL_IDLE_MS = 150;
let pendingWheelDx = 0;
let wheelRafId = null;
const flushPendingWheel = () => {
wheelRafId = null;
const dx = pendingWheelDx;
pendingWheelDx = 0;
if (!dx) return;
ensureGestureInsets();
setPosition(getCurrentPosition() + dx, { animate: false });
};
scroller.addEventListener(
'wheel',
(e) => {
if (scroller.classList.contains('scrollX')) return;
let dx = e.deltaX;
let dy = e.deltaY;
// Line mode: normalize without layout. Page mode: defer clientWidth until apply frame.
if (e.deltaMode === 1) {
dx *= 16;
dy *= 16;
}
const shiftHorizontal = e.shiftKey && Math.abs(dy) >= WHEEL_DELTA_MIN && Math.abs(dx) < WHEEL_DELTA_MIN;
if (shiftHorizontal) {
dx = dy;
dy = 0;
}
if (Math.abs(dx) < WHEEL_DELTA_MIN) return;
if (Math.abs(dx) < Math.abs(dy)) return;
e.preventDefault();
if (e.deltaMode === 2) {
dx *= scroller.clientWidth || 1;
}
pendingWheelDx += dx;
if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
wheelIdleTimer = setTimeout(() => {
wheelIdleTimer = null;
clearGestureInsets();
}, WHEEL_IDLE_MS);
if (wheelRafId == null) {
wheelRafId = requestAnimationFrame(flushPendingWheel);
}
},
{ passive: false },
);
}
return scrollButtons;
}
/**
* Creates a scrollable container with horizontal scrolling functionality
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the scrollable container
* @param {string} viewMoreUrl - Optional URL to make title clickable
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed scrollable container
*/
function getFacetRowType(items) {
if (!Array.isArray(items) || items.length === 0) return null;
let genreCount = 0;
let studioCount = 0;
items.forEach((item) => {
if (item?.Type === 'Genre') genreCount++;
else if (item?.Type === 'Studio') studioCount++;
});
const threshold = Math.ceil(items.length / 2);
if (genreCount >= threshold) return 'Genre';
if (studioCount >= threshold) return 'Studio';
return null;
}
function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl, discoveryPending }) {
const captionText = caption && String(caption).trim();
const hasCaption = !!captionText;
const useSkeletonTitle = discoveryPending === true;
let parent = sectionTitleContainer;
if (hasCaption) {
const wrapper = document.createElement('div');
wrapper.className = 'sectionTitle-wrapper';
sectionTitleContainer.appendChild(wrapper);
parent = wrapper;
}
if (useSkeletonTitle) {
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
const skeletonTitle = document.createElement('span');
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
skeletonTitle.setAttribute('aria-hidden', 'true');
titleText.appendChild(skeletonTitle);
parent.appendChild(titleText);
} else if (viewMoreUrl) {
const titleLink = document.createElement('a');
titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button';
if (isTvLayout()) titleLink.classList.add('show-focus');
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
if (typeof viewMoreUrl === 'function') {
titleLink.addEventListener('click', (e) => {
e.preventDefault();
viewMoreUrl();
});
} else {
titleLink.href = viewMoreUrl;
}
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
const chevronIcon = document.createElement('span');
chevronIcon.className = 'material-icons chevron_right';
chevronIcon.setAttribute('aria-hidden', 'true');
titleLink.appendChild(titleText);
titleLink.appendChild(chevronIcon);
parent.appendChild(titleLink);
} else {
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
parent.appendChild(titleText);
}
if (hasCaption) {
let captionEl;
if (useSkeletonTitle) {
captionEl = document.createElement('span');
captionEl.className = 'sectionTitle sectionCaption';
const skeletonCaption = document.createElement('span');
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
skeletonCaption.setAttribute('aria-hidden', 'true');
captionEl.appendChild(skeletonCaption);
} else if (captionUrl) {
captionEl = document.createElement('a');
captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button';
if (isTvLayout()) captionEl.classList.add('show-focus');
if (typeof captionUrl === 'function') {
captionEl.addEventListener('click', (e) => {
e.preventDefault();
captionUrl();
});
} else {
captionEl.href = captionUrl;
}
captionEl.textContent = captionText;
} else {
captionEl = document.createElement('span');
captionEl.className = 'sectionTitle sectionCaption';
captionEl.textContent = captionText;
}
parent.appendChild(captionEl);
}
}
function createScrollableContainer(
items,
title,
viewMoreUrl = null,
overflowCard = false,
cardFormat = null,
sectionConfig = null,
registerScrollButtons = true,
) {
const normalizedFormat = (cardFormat || '').toLowerCase();
// Create the main vertical section container
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
const facetRowType = getFacetRowType(items);
if (facetRowType) {
verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`);
verticalSection.dataset.facetRowType = facetRowType;
}
// Persist the card format if provided (ensures consistency for random/updates)
if (cardFormat) {
verticalSection.setAttribute('data-card-format', cardFormat);
}
// Create section title
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
appendSectionTitleContent(sectionTitleContainer, {
title,
caption: getSectionCaption(sectionConfig),
captionUrl: getSectionCaptionUrl(sectionConfig),
viewMoreUrl,
});
ensureSectionControlsMount(sectionTitleContainer);
// Create scroller container
const scroller = createScrollerElement();
const itemsContainer = createItemsSliderElement();
// Add items to container (TV → button cards via createCardElement)
const useParentCard = !!sectionConfig?.useParentCard;
const borderStyle = sectionConfig?.borderStyle || null;
items.forEach((item, index) => {
let imageParams = null;
if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) {
imageParams = getDoubledAspectRatioImageParams(cardFormat);
}
const card = createCardElement(
item,
overflowCard,
cardFormat,
item.cardFooter,
useParentCard,
borderStyle,
imageParams,
);
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
scroller.appendChild(itemsContainer);
let scrollButtons = null;
if (isTvLayout()) {
attachTvFocusScroll(scroller);
} else {
scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
}
// Assemble the section
verticalSection.appendChild(sectionTitleContainer);
if (scrollButtons) {
verticalSection.appendChild(scrollButtons);
}
verticalSection.appendChild(scroller);
if (registerScrollButtons && scrollButtons) {
registerScrollSectionScrollButtons(verticalSection);
}
return verticalSection;
}
function createSectionScrollableContainer(
items,
title,
viewMoreUrl = null,
overflowCard = false,
cardFormat = null,
sectionConfig = null,
) {
return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat, sectionConfig);
}
/**
* Creates a skeleton card element for loading states
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', 'square', 'logo', 'clear art', 'disc', 'banner', etc.
* @param {boolean} overflowCard - Use overflow card classes
* @param {Object|null} sectionConfig - Section config used to infer footer line count
* @returns {HTMLElement} - Skeleton card element
*/
function createSkeletonCard(cardFormat = null, overflowCard = false, sectionConfig = null) {
const card = document.createElement('div');
// Determine card classes based on format (must match createJellyfinCardElement)
let cardClass, padderClass;
cardFormat = cardFormat?.toLowerCase() || 'portrait';
if (
cardFormat === 'backdrop' ||
cardFormat === 'thumb' ||
cardFormat === 'series thumb' ||
cardFormat === 'logo' ||
cardFormat === 'clear art'
) {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
} else if (cardFormat === 'square' || cardFormat === 'disc') {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
} else if (cardFormat === 'banner') {
cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard';
padderClass = 'cardPadder-banner';
} else {
// portrait / poster / series poster (default)
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
}
card.className = `card ${cardClass} skeleton-card`;
card.setAttribute('data-skeleton', 'true');
const cardBox = document.createElement('div');
cardBox.className = `cardBox cardBox-bottompadded`;
const cardScalable = document.createElement('div');
cardScalable.className = 'cardScalable';
const cardPadder = document.createElement('div');
cardPadder.className = `cardPadder ${padderClass} skeleton-padder`;
cardPadder.style.cssText = 'background: rgba(255,255,255,0.12);';
cardScalable.appendChild(cardPadder);
cardBox.appendChild(cardScalable);
const footerLineCount = getSkeletonFooterLineCount(sectionConfig);
if (footerLineCount > 0) {
const cardTextStack = document.createElement('div');
cardTextStack.className = 'cardTextStack';
const lineWidths = ['70%', '55%', '40%'];
for (let lineIndex = 0; lineIndex < footerLineCount; lineIndex++) {
const isFirst = lineIndex === 0;
const cardText = document.createElement('div');
cardText.className = isFirst ? 'cardText cardTextCentered cardText-first' : 'cardText cardTextCentered cardText-secondary';
const bdi = document.createElement('bdi');
const skeletonLine = document.createElement('span');
skeletonLine.className = 'skeleton-text-line';
skeletonLine.style.width = lineWidths[lineIndex] || '40%';
bdi.appendChild(skeletonLine);
cardText.appendChild(bdi);
cardTextStack.appendChild(cardText);
}
cardBox.appendChild(cardTextStack);
}
card.appendChild(cardBox);
return card;
}
/**
* Creates a skeleton spotlight section for progressive enhancement.
* Layout/size are derived from options so the skeleton matches the final spotlight (Border/Borderless, Normal/Large/Full).
* @param {string} title - Title for the spotlight section
* @param {Object} options - Options for the spotlight carousel (viewMoreUrl, spotlightLayout, spotlightSize, fullScreen)
* @returns {HTMLElement} - Skeleton spotlight container
*/
function createSkeletonSpotlightSection(title, options = {}) {
const { viewMoreUrl = null, discoveryPending = false, caption = null } = options;
const { layout, size } = resolveSpotlightPresentation(options);
const captionText = caption && String(caption).trim();
const useSkeletonTitle = discoveryPending === true;
// Create main container (same structure as real spotlight)
const container = document.createElement('div');
container.className = 'spotlight-section padded-left';
container.dataset.layout = layout;
container.dataset.size = size;
// Create banner container
const bannerContainer = document.createElement('div');
bannerContainer.className = 'spotlight-banner-container';
// Header wraps title (+ nav when present) to match live spotlight DOM.
// Title container hosts section name + refresh/configure controls.
// Always create it so controls can attach even when the section has no name.
{
const headerContainer = document.createElement('div');
headerContainer.className = 'spotlight-header-container';
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'spotlight-section-title-container';
if (useSkeletonTitle || title) {
let sectionTitleEl;
if (useSkeletonTitle) {
sectionTitleEl = document.createElement('div');
sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
const skeletonTitle = document.createElement('span');
skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
skeletonTitle.setAttribute('aria-hidden', 'true');
sectionTitleEl.appendChild(skeletonTitle);
} else if (viewMoreUrl) {
const titleLink = document.createElement('a');
titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
titleLink.textContent = title;
titleLink.title = 'See All';
titleLink.style.textDecoration = 'none';
if (typeof viewMoreUrl === 'function') {
titleLink.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
viewMoreUrl();
});
} else {
titleLink.href = viewMoreUrl;
titleLink.addEventListener('click', (e) => {
e.stopPropagation();
});
}
sectionTitleEl = titleLink;
} else {
sectionTitleEl = document.createElement('div');
sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
sectionTitleEl.textContent = title;
}
const sectionTitleWrapper = document.createElement('div');
sectionTitleWrapper.className = `spotlight-section-title ${!useSkeletonTitle && viewMoreUrl ? "" : "spotlight-title-link "}headerTabs sectionTabs`;
sectionTitleWrapper.appendChild(sectionTitleEl);
sectionTitleContainer.appendChild(sectionTitleWrapper);
if (useSkeletonTitle && captionText) {
const captionEl = document.createElement('span');
captionEl.className = 'sectionTitle sectionCaption';
const skeletonCaption = document.createElement('span');
skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
skeletonCaption.setAttribute('aria-hidden', 'true');
captionEl.appendChild(skeletonCaption);
sectionTitleContainer.appendChild(captionEl);
}
}
headerContainer.appendChild(sectionTitleContainer);
bannerContainer.appendChild(headerContainer);
}
// Create skeleton item (single full-container placeholder)
const itemsContainer = document.createElement('div');
itemsContainer.className = 'spotlight-items-container';
const skeletonItem = document.createElement('div');
skeletonItem.className = 'spotlight-item skeleton-spotlight-item';
skeletonItem.setAttribute('data-index', '0');
skeletonItem.setAttribute('data-active', 'true');
skeletonItem.style.cssText = 'background: rgba(255,255,255,0.12);';
itemsContainer.appendChild(skeletonItem);
bannerContainer.appendChild(itemsContainer);
ensureSkeletonShimmerSibling(itemsContainer);
container.appendChild(bannerContainer);
container.classList.add('skeleton-section');
observeSkeletonShimmer(container);
return container;
}
/**
* Creates a scrollable container with skeleton cards for progressive enhancement
* @param {string} title - Title for the scrollable container
* @param {string|Function} viewMoreUrl - Optional URL to make title clickable
* @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square'
* @param {boolean} overflowCard - Use overflow card classes
* @returns {HTMLElement} - Container with skeleton cards
*/
function createProgressivelyEnhancedScrollableContainer(
title,
viewMoreUrl = null,
cardFormat = null,
overflowCard = false,
sectionConfig = null,
) {
const tvLayout = isTvLayout();
// Create the main vertical section container (same structure as createScrollableContainer)
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
// Persist the card format if provided (ensures consistency for random/updates)
if (cardFormat) {
verticalSection.setAttribute('data-card-format', cardFormat);
}
// If the query is using the /Genres or /Studios paths, set the row hue to a random color
if (
sectionConfig?.queries?.some(
(query) => typeof query.path === 'string' && (query.path.includes('/Genres') || query.path.includes('/Studios')),
)
) {
verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`);
}
// Create section title
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
const discoveryPending = sectionConfig?.discoveryPending === true;
appendSectionTitleContent(sectionTitleContainer, {
title,
caption: getSectionCaption(sectionConfig),
captionUrl: getSectionCaptionUrl(sectionConfig),
viewMoreUrl: discoveryPending ? null : viewMoreUrl,
discoveryPending,
});
ensureSectionControlsMount(sectionTitleContainer);
const scroller = createScrollerElement();
const itemsContainer = createItemsSliderElement(true);
const itemsLayout = resolveItemsLayout(sectionConfig);
const skeletonCount = getSectionSkeletonCount(sectionConfig, cardFormat, itemsLayout);
for (let i = 0; i < skeletonCount; i++) {
const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig);
skeletonCard.setAttribute('data-index', i);
itemsContainer.appendChild(skeletonCard);
}
scroller.appendChild(itemsContainer);
ensureSkeletonShimmerSibling(itemsContainer);
verticalSection.appendChild(sectionTitleContainer);
if (tvLayout) {
attachTvFocusScroll(scroller);
} else {
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
if (scrollButtons) {
verticalSection.appendChild(scrollButtons);
}
}
verticalSection.appendChild(scroller);
verticalSection.classList.add('skeleton-section');
observeSkeletonShimmer(verticalSection);
return verticalSection;
}
/**
* Measures the width of an element
* @param {HTMLElement} el - Element to measure
* @returns {number} - Width of the element
*/
function measure(el) {
if (!el) {
return 0;
}
let measureRoot = document.querySelector('#measure-root');
if (!measureRoot) {
measureRoot = document.createElement('div');
measureRoot.id = 'measure-root';
measureRoot.style.cssText =
'position: absolute; visibility: hidden; contain: layout style paint; white-space: nowrap; pointer-events: none;';
document.body.appendChild(measureRoot);
}
const clonedEl = el.cloneNode(true);
measureRoot.appendChild(clonedEl);
const width = clonedEl.getBoundingClientRect().width;
measureRoot.removeChild(clonedEl);
return width;
}
/**
* Checks the state of a promise
* @param {Promise} p - Promise to check
* @returns {string} - State of the promise: 'pending', 'fulfilled', or 'rejected'
*/
function promiseState(p) {
const t = {};
return Promise.race([p, t]).then(
(v) => (v === t ? 'pending' : 'fulfilled'),
() => 'rejected',
);
}
// Section-level skeleton shimmer (keyframes live in cardBuilder.css)
let skeletonShimmerObserver = null;
/** @type {WeakMap<Element, number>} */
const skeletonShimmerRafBySection = new WeakMap();
function prefersReducedSkeletonMotion() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
function ensureSkeletonShimmerObserver() {
if (skeletonShimmerObserver || typeof IntersectionObserver === 'undefined') return skeletonShimmerObserver;
skeletonShimmerObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const el = entry.target;
if (!el?.classList?.contains('skeleton-section')) {
unobserveSkeletonShimmer(el);
return;
}
const pendingRaf = skeletonShimmerRafBySection.get(el);
if (pendingRaf) {
cancelAnimationFrame(pendingRaf);
skeletonShimmerRafBySection.delete(el);
}
if (entry.isIntersecting && !prefersReducedSkeletonMotion()) {
const raf = requestAnimationFrame(() => {
skeletonShimmerRafBySection.delete(el);
if (el.isConnected && el.classList.contains('skeleton-section')) {
el.classList.add('skeleton-shimmer-active');
}
});
skeletonShimmerRafBySection.set(el, raf);
} else {
el.classList.remove('skeleton-shimmer-active');
}
});
},
{ root: null, rootMargin: '100px 0px', threshold: 0.01 },
);
return skeletonShimmerObserver;
}
/**
* Insert a .skeleton-shimmer sibling after the items host (inside scroller / banner).
* @param {HTMLElement|null|undefined} itemsHost .itemsContainer or .spotlight-items-container
* @returns {HTMLElement|null}
*/
function ensureSkeletonShimmerSibling(itemsHost) {
if (!itemsHost?.parentElement) return null;
const parent = itemsHost.parentElement;
let shimmer = null;
if (itemsHost.nextElementSibling?.classList?.contains('skeleton-shimmer')) {
shimmer = itemsHost.nextElementSibling;
} else {
shimmer = parent.querySelector(':scope > .skeleton-shimmer');
}
if (!shimmer) {
shimmer = document.createElement('div');
shimmer.className = 'skeleton-shimmer';
shimmer.setAttribute('aria-hidden', 'true');
itemsHost.insertAdjacentElement('afterend', shimmer);
} else if (shimmer.previousElementSibling !== itemsHost) {
itemsHost.insertAdjacentElement('afterend', shimmer);
}
return shimmer;
}
function removeSkeletonShimmerOverlays(root) {
if (!root?.querySelectorAll) return;
root.querySelectorAll('.skeleton-shimmer').forEach((el) => el.remove());
}
function observeSkeletonShimmer(sectionElement) {
if (!sectionElement?.classList) return;
sectionElement.classList.add('skeleton-section');
sectionElement.classList.remove('skeleton-shimmer-active');
const itemsHost =
sectionElement.querySelector('.spotlight-items-container') ||
sectionElement.querySelector('.itemsContainer');
ensureSkeletonShimmerSibling(itemsHost);
const observer = ensureSkeletonShimmerObserver();
if (observer) observer.observe(sectionElement);
}
function unobserveSkeletonShimmer(sectionElement) {
if (!sectionElement) return;
const pendingRaf = skeletonShimmerRafBySection.get(sectionElement);
if (pendingRaf) {
cancelAnimationFrame(pendingRaf);
skeletonShimmerRafBySection.delete(sectionElement);
}
sectionElement.classList?.remove('skeleton-shimmer-active', 'skeleton-section');
removeSkeletonShimmerOverlays(sectionElement);
if (skeletonShimmerObserver) {
try {
skeletonShimmerObserver.unobserve(sectionElement);
} catch (_) {
/* ignore */
}
}
}
function stripCopiedSkeletonSectionClasses(el) {
el?.classList?.remove('skeleton-section', 'skeleton-shimmer-active');
removeSkeletonShimmerOverlays(el);
}
// Add skeleton loading animation CSS if not already present
if (!document.getElementById('skeleton-animation-style')) {
const style = document.createElement('style');
style.id = 'skeleton-animation-style';
style.textContent = `
@keyframes skeleton-shimmer-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
`;
document.head.appendChild(style);
}
// Smart Lazy Image Loading with Global Observers
let lazyImageObserver = null;
/** @type {WeakMap<Element, IntersectionObserver>} */
const lazyObserversByScroller = new WeakMap();
// Run updateScrollButtonStateForSection once when a section becomes visible (so --max-scroll is computed after layout)
let scrollSectionVisibilityObserver = null;
function getLazyScrollerRoot(el) {
if (!el || typeof el.closest !== 'function') return null;
return el.closest('.emby-scroller, .custom-scroller, [data-horizontal="true"]');
}
function clearLazyImagePaint(cardImageContainer) {
if (!cardImageContainer) return;
cardImageContainer.style.backgroundImage = '';
cardImageContainer.style.webkitMaskImage = '';
cardImageContainer.style.maskImage = '';
cardImageContainer.style.webkitMaskRepeat = '';
cardImageContainer.style.maskRepeat = '';
cardImageContainer.style.webkitMaskPosition = '';
cardImageContainer.style.maskPosition = '';
cardImageContainer.style.webkitMaskSize = '';
cardImageContainer.style.maskSize = '';
cardImageContainer.classList.remove('lazy-masked-svg');
}
function ensureLazyUnloadPlaceholder(cardImageContainer) {
const canvas = cardImageContainer.previousElementSibling;
const hasBlurhashCanvas = canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas');
const blurhash = cardImageContainer.getAttribute('data-blurhash');
if (state.useBlurhash && hasBlurhashCanvas && blurhash) {
// Unload restore: always prefer blurhash when available (ignore reduced-motion / saveData)
if (!canvas.hasAttribute('data-blurhash-pending') && canvas.classList.contains('lazy-hidden')) {
// Re-show existing painted blurhash, or re-queue decode
const hasPixels = canvas.hasAttribute('data-blurhash-painted');
if (!hasPixels) {
canvas.removeAttribute('data-blurhash-painted');
canvas.setAttribute('data-blurhash-pending', blurhash);
observeBlurhashCanvas(canvas);
}
}
canvas.classList.remove('lazy-hidden');
cardImageContainer.classList.add('lazy-hidden');
return;
}
if (hasBlurhashCanvas) {
canvas.classList.add('lazy-hidden');
}
cardImageContainer.classList.remove('lazy-hidden');
let icon = cardImageContainer.querySelector('.cardImageIcon');
if (!icon) {
icon = document.createElement('span');
icon.className = 'cardImageIcon material-icons';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = 'movie';
cardImageContainer.appendChild(icon);
}
if (!/\bdefaultCardBackground\d\b/.test(cardImageContainer.className)) {
cardImageContainer.classList.add('defaultCardBackground1');
}
}
function applyLazyImageToContainer(cardImageContainer, imageUrl, isSvg) {
if (!cardImageContainer?.isConnected) return;
if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
// Cancelled / unloaded before apply drained
if (!cardImageContainer.hasAttribute('data-loading')) return;
// Style writes first, then one class/attr batch so paint absorbs a single invalidation.
if (isSvg) {
const maskUrl = `url("${imageUrl}")`;
cardImageContainer.style.webkitMaskImage = maskUrl;
cardImageContainer.style.maskImage = maskUrl;
cardImageContainer.style.webkitMaskRepeat = 'no-repeat';
cardImageContainer.style.maskRepeat = 'no-repeat';
cardImageContainer.style.webkitMaskPosition = 'center';
cardImageContainer.style.maskPosition = 'center';
cardImageContainer.style.webkitMaskSize = 'contain';
cardImageContainer.style.maskSize = 'contain';
cardImageContainer.style.backgroundColor = 'white';
} else {
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
}
cardImageContainer.classList.remove('lazy', 'lazy-hidden');
if (isSvg) {
cardImageContainer.classList.add('lazy-loaded', 'lazy-masked-svg');
} else {
cardImageContainer.classList.add('lazy-loaded');
}
cardImageContainer.removeAttribute('data-loading');
const icon = cardImageContainer.querySelector('.cardImageIcon');
if (icon) icon.remove();
const canvas = cardImageContainer.previousElementSibling;
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
canvas.classList.add('lazy-hidden');
canvas.removeAttribute('data-blurhash-pending');
}
}
function unloadLazyImage(cardImageContainer) {
if (!cardImageContainer?.classList?.contains('lazy-loaded')) return;
clearLazyImagePaint(cardImageContainer);
cardImageContainer.classList.remove('lazy-loaded');
cardImageContainer.classList.add('lazy');
cardImageContainer.removeAttribute('data-loading');
ensureLazyUnloadPlaceholder(cardImageContainer);
}
function startLazyImageLoad(cardImageContainer) {
const imageUrl = cardImageContainer.getAttribute('data-src');
if (!imageUrl) return;
if (cardImageContainer.hasAttribute('data-loading')) return;
if (cardImageContainer.classList.contains('lazy-loaded')) return;
cardImageContainer.setAttribute('data-loading', 'true');
const img = new Image();
const finishError = () => {
if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
cardImageContainer.removeAttribute('data-loading');
cardImageContainer.classList.remove('lazy');
cardImageContainer.classList.add('lazy-loaded');
cardImageContainer.classList.remove('lazy-hidden');
const canvas = cardImageContainer.previousElementSibling;
if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
canvas.classList.add('lazy-hidden');
canvas.removeAttribute('data-blurhash-pending');
}
};
img.onload = () => {
if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
if (!cardImageContainer.isConnected) return;
if (!cardImageContainer.hasAttribute('data-loading')) return;
const isSvg = /\.svg(?:[?#]|$)/i.test(imageUrl);
requestAnimationFrame(() => {
if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
if (!cardImageContainer.isConnected) return;
if (!cardImageContainer.hasAttribute('data-loading')) return;
applyLazyImageToContainer(cardImageContainer, imageUrl, isSvg);
});
};
img.onerror = finishError;
img.src = imageUrl;
}
function handleLazyImageIntersection(entries, observer) {
const mobile = isMobileLayout();
entries.forEach((entry) => {
const cardImageContainer = entry.target;
if (entry.isIntersecting) {
startLazyImageLoad(cardImageContainer);
if (!mobile) {
observer.unobserve(cardImageContainer);
}
return;
}
if (mobile && cardImageContainer.classList.contains('lazy-loaded')) {
unloadLazyImage(cardImageContainer);
}
});
}
function createLazyImageObserver(root) {
return new IntersectionObserver(handleLazyImageIntersection, {
root: root || null,
threshold: 0,
rootMargin: '50%',
});
}
/**
* Initialize the global IntersectionObserver for lazy loading images
* Watches when elements enter the viewport and loads their images
*/
function initLazyImageObserver() {
if (lazyImageObserver) return; // Already initialized
lazyImageObserver = createLazyImageObserver(null);
}
function observeLazyImage(cardImageContainer) {
if (!cardImageContainer || !cardImageContainer.hasAttribute('data-src')) return;
initLazyImageObserver();
if (!lazyImageObserver) return;
if (isMobileLayout()) {
const scroller = getLazyScrollerRoot(cardImageContainer);
if (scroller) {
let observer = lazyObserversByScroller.get(scroller);
if (!observer) {
observer = createLazyImageObserver(scroller);
lazyObserversByScroller.set(scroller, observer);
}
observer.observe(cardImageContainer);
return;
}
}
lazyImageObserver.observe(cardImageContainer);
}
// --- Push lazy path (parallel native sections) — independent of progressive MO/kill-switch ---
let pushLazyImageObserver = null;
/** @type {WeakMap<Element, IntersectionObserver>} */
const pushLazyObserversByScroller = new WeakMap();
let pushBlurhashFillObserver = null;
function initPushLazyImageObserver() {
if (pushLazyImageObserver) return;
if (typeof IntersectionObserver === 'undefined') return;
pushLazyImageObserver = createLazyImageObserver(null);
}
function observeLazyImagePush(cardImageContainer) {
if (!cardImageContainer || !cardImageContainer.hasAttribute('data-src')) return;
initPushLazyImageObserver();
if (!pushLazyImageObserver) {
// No IO: load immediately
startLazyImageLoad(cardImageContainer);
return;
}
if (isMobileLayout()) {
const scroller = getLazyScrollerRoot(cardImageContainer);
if (scroller) {
let observer = pushLazyObserversByScroller.get(scroller);
if (!observer) {
observer = createLazyImageObserver(scroller);
pushLazyObserversByScroller.set(scroller, observer);
}
observer.observe(cardImageContainer);
return;
}
}
pushLazyImageObserver.observe(cardImageContainer);
}
function initPushBlurhashFillObserver() {
if (pushBlurhashFillObserver) return;
if (typeof IntersectionObserver === 'undefined') return;
pushBlurhashFillObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const canvas = entry.target;
pushBlurhashFillObserver.unobserve(canvas);
fillBlurhashCanvas(canvas);
});
},
{
threshold: 0,
rootMargin: getBlurhashFillRootMargin(),
},
);
}
function observeBlurhashCanvasPush(canvas) {
if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return;
if (shouldSkipBlurhashDecode()) {
canvas.removeAttribute('data-blurhash-pending');
canvas.classList.add('lazy-hidden');
return;
}
initPushBlurhashFillObserver();
if (pushBlurhashFillObserver) {
pushBlurhashFillObserver.observe(canvas);
} else {
fillBlurhashCanvas(canvas);
}
}
function observePendingBlurhashInNodePush(node) {
if (!node || node.nodeType !== 1) return;
if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) {
observeBlurhashCanvasPush(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvasPush);
}
}
/**
* Push-register lazy images + blurhash under an attached root.
* Call sites should pass each section's .itemsContainer (never .homeSectionsContainer).
* @param {ParentNode|Element|null|undefined} root
*/
function registerLazyImagesIn(root) {
if (!root) return;
if (root.nodeType === 1 && root.matches?.('.cardImageContainer[data-src]')) {
observeLazyImagePush(root);
}
if (root.querySelectorAll) {
root.querySelectorAll('.cardImageContainer[data-src]').forEach(observeLazyImagePush);
}
observePendingBlurhashInNodePush(root);
}
/** Register lazy images for a section via its .itemsContainer only. */
function registerLazyImagesInSection(sectionElement) {
if (!sectionElement?.querySelector) return;
const itemsContainer = sectionElement.querySelector('.itemsContainer');
if (itemsContainer) registerLazyImagesIn(itemsContainer);
}
/**
* Apply scroll button state for a section from in-memory scrollX / maxScroll.
*/
function applyScrollButtonState(verticalSection) {
const scroller = verticalSection.querySelector('.emby-scroller');
const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons');
const leftButton = scrollButtons && scrollButtons.querySelector('button[data-direction="left"]');
const rightButton = scrollButtons && scrollButtons.querySelector('button[data-direction="right"]');
if (!scroller || !scrollButtons || !leftButton || !rightButton) return;
const scrollX = getScrollerPosition(scroller);
let maxScroll = 0;
const metrics = getScrollerMetrics(scroller);
if (metrics && metrics.maxScroll != null && Number.isFinite(metrics.maxScroll)) {
maxScroll = metrics.maxScroll;
} else {
maxScroll = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
}
leftButton.disabled = scrollX <= 20;
rightButton.disabled = scrollX >= maxScroll - 1;
scrollButtons.classList.toggle('hide', leftButton.disabled && rightButton.disabled);
}
/**
* Compute max scroll from dimensions and apply button state. Called once when section is visible so layout is ready.
*/
function updateScrollButtonStateForSection(verticalSection) {
const scroller = verticalSection.querySelector('.emby-scroller');
if (!scroller) return;
const metrics = getScrollerMetrics(scroller);
if (metrics) metrics.maxScroll = null;
getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
applyScrollButtonState(verticalSection);
}
/**
* Ensure scroll section visibility observer exists. When a section becomes visible, runs updateScrollButtonStateForSection once then unobserves.
*/
function getScrollSectionVisibilityObserver() {
if (scrollSectionVisibilityObserver) return scrollSectionVisibilityObserver;
scrollSectionVisibilityObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const verticalSection = entry.target;
scrollSectionVisibilityObserver.unobserve(verticalSection);
const run = () => updateScrollButtonStateForSection(verticalSection);
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(run, { timeout: 200 });
} else {
requestAnimationFrame(() => requestAnimationFrame(run));
}
});
},
{ root: null, threshold: 0.01 },
);
return scrollSectionVisibilityObserver;
}
/**
* Register a scrollable section: when it becomes visible, set max scroll once and apply initial button state.
*/
function registerScrollSectionScrollButtons(verticalSection) {
const itemsContainer = verticalSection.querySelector('.itemsContainer');
const scroller = verticalSection.querySelector('.emby-scroller');
const first = itemsContainer && itemsContainer.firstElementChild;
const last = itemsContainer && itemsContainer.lastElementChild;
if (!first || !last) {
if (scroller) {
setMaxScrollPosition(scroller, 0);
applyScrollButtonState(verticalSection);
}
return;
}
getScrollSectionVisibilityObserver().observe(verticalSection);
}
/**
* Initialize push lazy loading (IntersectionObserver only — no body MutationObserver).
*/
function initSmartLazyLoading() {
if (typeof IntersectionObserver === 'undefined') {
return;
}
initLazyImageObserver();
initBlurhashFillObserver();
initPushLazyImageObserver();
initPushBlurhashFillObserver();
// One-time scan for nodes already present before first push register
document.querySelectorAll('.cardImageContainer[data-src]').forEach(observeLazyImagePush);
document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvasPush);
}
// Initialize smart lazy loading on module load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSmartLazyLoading);
} else {
initSmartLazyLoading();
}
// Progressive section enhancement (deferred via IntersectionObserver when enhanceOnVisible)
let sectionEnhanceObserver = null;
let sectionEnhanceObserverRootMargin = null;
const sectionEnhancePending = new WeakMap();
/** FIFO: at most one section enhance apply per animation frame. */
const enhanceFifoQueue = [];
let enhanceFifoRafScheduled = false;
function enqueueSectionEnhance(task) {
if (typeof task !== 'function') return;
enhanceFifoQueue.push(task);
if (enhanceFifoRafScheduled) return;
enhanceFifoRafScheduled = true;
const drain = () => {
const next = enhanceFifoQueue.shift();
if (next) {
try {
next();
} catch (err) {
console.warn('[KefinTweaks CardBuilder] enhance FIFO task failed:', err);
}
}
if (enhanceFifoQueue.length) {
requestAnimationFrame(drain);
} else {
enhanceFifoRafScheduled = false;
}
};
requestAnimationFrame(drain);
}
function applyProgressiveEnhancementQueued(sectionElement, section, result, options) {
enqueueSectionEnhance(() => {
if (!sectionElement?.isConnected) return;
applyProgressiveEnhancement(sectionElement, section, result, options);
});
}
/**
* Read the currently active spotlight slide from a live section.
* @param {HTMLElement} sectionEl
* @returns {{ slide: HTMLElement, index: number, id: string } | null}
*/
function getActiveSpotlightSlide(sectionEl) {
if (!sectionEl) return null;
const slide = sectionEl.querySelector('.spotlight-item[data-active="true"], .spotlight-item[data-active]');
if (!slide || slide.classList.contains('skeleton-spotlight-item')) return null;
const index = parseInt(slide.getAttribute('data-index'), 10);
const id = slide.getAttribute('data-id');
if (!id || Number.isNaN(index) || index < 0) return null;
return { slide, index, id };
}
/**
* Build the item list for a keep-current-slide spotlight refresh.
* Dedupes activeId from results when present; inserts at i or appends as N+1 when beyond.
* @param {Array} newItems
* @param {{ index: number, id: string }} activeMeta
* @param {Array} dataItems - prior/cached items used to resolve the active item object
* @returns {{ items: Array, preserveIndex: number }}
*/
function buildSpotlightRefreshItems(newItems, activeMeta, dataItems) {
const source = Array.isArray(newItems) ? newItems.slice() : [];
const i = activeMeta.index;
const activeId = String(activeMeta.id);
const N = source.length;
const findById = (list) => (list || []).find((d) => d && String(d.Id) === activeId);
let activeItem = findById(dataItems) || findById(source);
if (!activeItem) {
return {
items: source,
preserveIndex: Math.min(i, Math.max(0, N - 1)),
canPreserve: false,
};
}
// Prefer cached object so the kept slide matches item data
activeItem = findById(dataItems) || activeItem;
const wasInResults = source.some((d) => d && String(d.Id) === activeId);
let working = wasInResults ? source.filter((d) => !(d && String(d.Id) === activeId)) : source;
if (wasInResults) {
if (i <= working.length) {
working.splice(i, 0, activeItem);
return { items: working, preserveIndex: i, canPreserve: true };
}
working.push(activeItem);
return { items: working, preserveIndex: working.length - 1, canPreserve: true };
}
if (i < N) {
working.splice(i, 0, activeItem);
return { items: working, preserveIndex: i, canPreserve: true };
}
working.push(activeItem);
return { items: working, preserveIndex: working.length - 1, canPreserve: true };
}
/**
* Replace the rebuilt slide at preserveIndex with the detached active slide DOM.
* @param {HTMLElement} newSection
* @param {HTMLElement} activeSlide
* @param {number} preserveIndex
*/
function transplantActiveSpotlightSlide(newSection, activeSlide, preserveIndex) {
if (!newSection || !activeSlide) return;
const container = newSection.querySelector('.spotlight-items-container');
if (!container) return;
activeSlide.setAttribute('data-index', String(preserveIndex));
activeSlide.setAttribute('data-active', 'true');
activeSlide.removeAttribute('data-fade-out');
activeSlide.removeAttribute('data-entering');
const built = container.querySelector(`.spotlight-item[data-index="${preserveIndex}"]`);
if (built) {
built.replaceWith(activeSlide);
} else {
container.appendChild(activeSlide);
}
}
/**
* Rebuild a spotlight section from fresh items, optionally preserving the active slide.
* @param {HTMLElement} sectionElement
* @param {Array} items
* @param {Object} options
* @param {string} options.name
* @param {string} [options.viewMoreUrl]
* @param {Object} [options.spotlightConfig]
* @param {Array} [options.dataItems]
* @param {boolean} [options.keepCurrentSlide]
* @param {Object} [options.sectionConfig]
* @returns {{ content: HTMLElement, items: Array }}
*/
/**
* Rebuild a spotlight section from fresh items in-place (keeps section shell).
* Enhance/SWR (keepCurrentSlide): leave active slide DOM alone; swap inactive only.
* Manual refresh (!keepCurrentSlide): overwrite active + inactive slides.
* Skeleton sections still get a full createSpotlightSection + replaceWith for wiring.
* @returns {{ content: HTMLElement, items: Array }}
*/
function refreshSpotlight(sectionElement, items, options = {}) {
const { name, viewMoreUrl = null, spotlightConfig = {}, dataItems = [], keepCurrentSlide = false, sectionConfig = null } = options;
let itemsForBuild = Array.isArray(items) ? items.slice() : [];
let preserveIndex = 0;
let activeSlide = null;
let doPreserve = false;
const isSkeleton = !!sectionElement?.querySelector?.('.skeleton-spotlight-item');
const liveItemsContainer = sectionElement?.querySelector?.('.spotlight-items-container');
if (keepCurrentSlide && !isSkeleton) {
const activeMeta = getActiveSpotlightSlide(sectionElement);
if (activeMeta) {
const built = buildSpotlightRefreshItems(itemsForBuild, activeMeta, dataItems);
if (built.canPreserve) {
itemsForBuild = built.items;
preserveIndex = built.preserveIndex;
activeSlide = activeMeta.slide;
doPreserve = true;
}
}
}
// Skeleton or missing live container: full rebuild + replaceWith (needs interactive wiring)
if (isSkeleton || !liveItemsContainer) {
const content = createSpotlightSection(itemsForBuild, name, {
viewMoreUrl,
...spotlightConfig,
...(doPreserve ? { initialIndex: preserveIndex } : {}),
});
content.style.cssText = sectionElement.style.cssText;
content.className = sectionElement.className;
Array.from(sectionElement.attributes).forEach((attr) => {
content.setAttribute(attr.name, attr.value);
});
if (sectionConfig) {
attachSectionControlButtons(sectionConfig, content, itemsForBuild);
}
stripCopiedSkeletonSectionClasses(content);
unobserveSkeletonShimmer(sectionElement);
sectionElement.replaceWith(content);
ensureSectionRevealObservation(content);
return { content, items: itemsForBuild };
}
// In-place: harvest slides from an inert build; never transplant the active slide
const temp = createSpotlightSection(itemsForBuild, name, {
viewMoreUrl,
...spotlightConfig,
initialIndex: doPreserve
? preserveIndex
: parseInt(liveItemsContainer.querySelector('.spotlight-item[data-active]')?.getAttribute('data-index'), 10) || 0,
inert: true,
});
const tempItems = temp.querySelector('.spotlight-items-container');
if (doPreserve && activeSlide && tempItems) {
const keepId = activeSlide.getAttribute('data-id');
Array.from(liveItemsContainer.children).forEach((slide) => {
if (slide !== activeSlide) slide.remove();
});
Array.from(tempItems.children).forEach((slide) => {
if (keepId && slide.getAttribute('data-id') === keepId) {
slide.remove();
return;
}
liveItemsContainer.appendChild(slide);
});
activeSlide.setAttribute('data-index', String(preserveIndex));
activeSlide.setAttribute('data-active', 'true');
activeSlide.removeAttribute('data-fade-out');
activeSlide.removeAttribute('data-entering');
} else if (tempItems) {
liveItemsContainer.replaceChildren(...Array.from(tempItems.children));
}
// Update title text/link on the same root (spotlight title lives under banner)
const titleEl = sectionElement.querySelector('.spotlight-section-title .emby-tab-button');
if (titleEl && name != null) titleEl.textContent = name;
updateSectionViewMoreLink(sectionElement, viewMoreUrl);
if (typeof sectionElement._kefinSpotlightApplyItems === 'function') {
sectionElement._kefinSpotlightApplyItems(itemsForBuild, {
currentIndex: doPreserve ? preserveIndex : undefined,
});
}
if (typeof sectionElement._kefinSpotlightPruneWindow === 'function') {
sectionElement._kefinSpotlightPruneWindow();
}
if (sectionConfig) {
attachSectionControlButtons(sectionConfig, sectionElement, itemsForBuild);
}
ensureSectionRevealObservation(sectionElement);
return { content: sectionElement, items: itemsForBuild };
}
function getPaintedCardIds(sectionElement) {
const cards = sectionElement.querySelectorAll('.itemsContainer > .card[data-id]:not(.skeleton-card)');
if (cards.length) {
return Array.from(cards)
.map((card) => card.getAttribute('data-id'))
.filter(Boolean);
}
const buttons = sectionElement.querySelectorAll('.itemsContainer > .homeLibraryButton[data-id]');
return Array.from(buttons)
.map((button) => button.getAttribute('data-id'))
.filter(Boolean);
}
function classifyIdSequence(staleIds, freshIds) {
if (!staleIds.length || !freshIds.length) return { type: 'replace' };
if (staleIds.length === freshIds.length && staleIds.every((id, i) => id === freshIds[i])) {
return { type: 'perfect' };
}
const staleSet = new Set(staleIds);
const freshSet = new Set(freshIds);
const sharedStale = staleIds.filter((id) => freshSet.has(id));
const sharedFresh = freshIds.filter((id) => staleSet.has(id));
if (!sharedStale.length || sharedStale.length !== sharedFresh.length) return { type: 'replace' };
if (sharedStale.some((id, i) => id !== sharedFresh[i])) return { type: 'replace' };
return { type: 'reconcile' };
}
function runAfterTransition(element, durationMs, callback) {
let done = false;
const finish = () => {
if (done) return;
done = true;
element.removeEventListener('transitionend', onEnd);
callback();
};
const onEnd = (event) => {
if (event.target !== element) return;
finish();
};
element.addEventListener('transitionend', onEnd);
setTimeout(finish, durationMs);
}
function getItemProgress(item) {
if (item.Type === 'Program') {
const isOnNow = Date.now() >= new Date(item.StartDate).getTime() && Date.now() <= new Date(item.EndDate).getTime()
if (isOnNow) {
const progress = (Date.now() - new Date(item.StartDate).getTime()) / (new Date(item.EndDate).getTime() - new Date(item.StartDate).getTime());
const progressPercentage = Math.round(progress * 100);
return progressPercentage;
}
}
if (item.UserData?.PlayedPercentage) {
return item.UserData?.PlayedPercentage;
}
return 0;
}
function patchCardProgressBar(card, item) {
const imageContainer = card.querySelector('.cardImageContainer');
if (!imageContainer) return;
const percent = getItemProgress(item);
const existingBar = imageContainer.querySelector('.itemProgressBarForeground');
const existingFooter = existingBar?.closest('.innerCardFooter');
if (percent && percent > 0) {
if (existingBar) {
if (existingBar.style.width !== `${percent}%`) {
existingBar.style.width = `${percent}%`;
}
return;
}
const innerCardFooter = document.createElement('div');
innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear';
const itemProgressBar = document.createElement('div');
itemProgressBar.className = 'itemProgressBar';
const itemProgressBarForeground = document.createElement('div');
itemProgressBarForeground.className = 'itemProgressBarForeground';
itemProgressBarForeground.style.width = `${percent}%`;
itemProgressBar.appendChild(itemProgressBarForeground);
innerCardFooter.appendChild(itemProgressBar);
imageContainer.appendChild(innerCardFooter);
return;
}
if (existingFooter && existingFooter.querySelector('.itemProgressBar')) {
existingFooter.remove();
}
}
function patchCardsUserData(sectionElement, items) {
items.forEach((item) => {
if (!item?.Id) return;
updateCardResumeAttributes(item.Id, item.UserData || {});
const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(item.Id) : String(item.Id).replace(/["\\]/g, '\\$&');
const card = sectionElement.querySelector(`.itemsContainer > .card[data-id="${escapedId}"]`);
if (card) patchCardProgressBar(card, item);
});
}
function getCardOuterWidth(card) {
if (!card) return 0;
const style = window.getComputedStyle(card);
return card.getBoundingClientRect().width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0);
}
function getScrollerPosition(scroller) {
if (!scroller) return 0;
if (scroller.classList.contains('scrollX')) return scroller.scrollLeft || 0;
return getCurrentPosition(scroller);
}
function setScrollerPosition(scroller, position, animate) {
if (!scroller) return;
const metrics = getScrollerMetrics(scroller);
if (metrics) metrics.maxScroll = null;
if (scroller.classList.contains('scrollX')) {
const maxPosition = getMaxPositionFromPadding(
scroller,
readScrollerPadding(scroller),
readSampleCardPadding(scroller),
);
const scrollPosition = Math.min(Math.max(position, 0), maxPosition);
const clamped = scrollPosition > 20 ? scrollPosition : 0;
// Clear any desktop slidee transform so it cannot fight native scrollLeft
const slidee = getScrollSlidee(scroller);
if (slidee) {
if (slidee.style.transform) slidee.style.transform = '';
if (slidee.style.transition) slidee.style.transition = '';
}
if (scroller.style.transform) scroller.style.transform = '';
scroller.scrollTo({ left: clamped, behavior: animate ? 'smooth' : 'auto' });
setStoredScrollPosition(scroller, clamped);
} else {
const slidee = getScrollSlidee(scroller);
setSlideePosition(scroller, position, { animate: !!animate, freshMax: true });
const section = scroller.closest('.emby-scroller-container');
if (section) {
if (animate && slidee) {
const onTransitionEnd = (e) => {
if (e && e.target !== slidee) return;
slidee.removeEventListener('transitionend', onTransitionEnd);
applyScrollButtonState(section);
};
slidee.addEventListener('transitionend', onTransitionEnd);
requestAnimationFrame(() => applyScrollButtonState(section));
} else {
applyScrollButtonState(section);
}
}
return;
}
const section = scroller.closest('.emby-scroller-container');
if (section) {
if (animate) {
requestAnimationFrame(() => applyScrollButtonState(section));
} else {
applyScrollButtonState(section);
}
}
}
/**
* Scroll a Kefin custom scroller so card at index is left-aligned (last card → max).
* @param {HTMLElement} scroller
* @param {number} index
* @param {{ animate?: boolean }} [options]
*/
function scrollToCardIndex(scroller, index, options = {}) {
if (!scroller) return;
const animate = options.animate === true;
const itemsContainer = scroller.querySelector('.itemsContainer');
if (!itemsContainer) return;
const cards = Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.skeleton-card)'));
if (!cards.length) return;
const clampedIndex = Math.max(0, Math.min(index, cards.length - 1));
const metrics = getScrollerMetrics(scroller);
if (metrics) {
metrics.maxScroll = null;
metrics.pad = null;
metrics.cardPad = null;
}
if (clampedIndex === cards.length - 1) {
setScrollerPosition(
scroller,
getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)),
animate,
);
return;
}
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const targetLeft = getCardContentOffsetX(scroller, cards[clampedIndex]) - pad.left + cardPad.left;
setScrollerPosition(scroller, targetLeft, animate);
const section = scroller.closest('.emby-scroller-container');
if (section) applyScrollButtonState(section);
}
function reindexSectionCards(itemsContainer) {
getRealLayoutCards(itemsContainer).forEach((card, index) => {
card.setAttribute('data-index', String(index));
});
}
function clearCardReconcileStyles(card) {
card.classList.remove('cardbuilder-card-insert', 'cardbuilder-card-collapse', 'is-in');
card.style.width = '';
card.style.minWidth = '';
card.style.maxWidth = '';
card.style.marginLeft = '';
card.style.marginRight = '';
card.style.overflow = '';
card.style.opacity = '';
card.style.paddingLeft = '';
card.style.paddingRight = '';
}
function captureCardLayout(card) {
const style = window.getComputedStyle(card);
return {
card,
width: card.getBoundingClientRect().width,
marginLeft: style.marginLeft,
marginRight: style.marginRight,
};
}
function applyCardCollapsedSize(card) {
card.style.width = '0px';
card.style.minWidth = '0px';
card.style.maxWidth = '0px';
card.style.marginLeft = '0px';
card.style.marginRight = '0px';
card.style.overflow = 'hidden';
card.style.opacity = '0';
}
function applyCardExpandedSize(layout) {
const { card, width, marginLeft, marginRight } = layout;
card.style.width = `${width}px`;
card.style.minWidth = `${width}px`;
card.style.maxWidth = `${width}px`;
card.style.marginLeft = marginLeft;
card.style.marginRight = marginRight;
card.style.overflow = 'hidden';
card.style.opacity = '1';
}
function reconcileRowItems(sectionElement, items, sectionConfig, onComplete) {
console.log(`[KefinTweaks CardBuilder] Reconciling row items for section: ${sectionConfig.name} with ${items.length} items`);
const itemsContainer = sectionElement.querySelector('.itemsContainer');
const scroller = sectionElement.querySelector('.emby-scroller');
if (!itemsContainer) return false;
const overflowCard = sectionConfig.overflowCard;
const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
const paintedCards = getRealLayoutCards(itemsContainer).filter((card) => {
return card.getAttribute('data-id') && !card.classList.contains('skeleton-card');
});
const cardById = new Map();
paintedCards.forEach((card) => {
const id = card.getAttribute('data-id');
if (id && !cardById.has(id)) cardById.set(id, card);
});
const freshIds = sectionConfig?.useParentCard
? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean)
: items.map((item) => item?.Id).filter(Boolean);
const freshSet = new Set(freshIds);
const outgoing = paintedCards.filter((card) => !freshSet.has(card.getAttribute('data-id')));
const inserted = [];
const prefixInserted = [];
let nextNode = paintedCards[0] || null;
let seenKept = false;
items.forEach((item, index) => {
if (!item?.Id) return;
const existing = cardById.get(item.Id);
if (existing) {
seenKept = true;
nextNode = existing.nextElementSibling;
while (nextNode && nextNode.classList.contains('skeleton-card')) {
nextNode = nextNode.nextElementSibling;
}
return;
}
let imageParams = null;
if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) {
imageParams = getDoubledAspectRatioImageParams(cardFormat);
}
const card = createCardElement(
item,
overflowCard,
cardFormat,
item.cardFooter,
!!sectionConfig?.useParentCard,
sectionConfig?.borderStyle || null,
imageParams,
);
if (nextNode) itemsContainer.insertBefore(card, nextNode);
else itemsContainer.appendChild(card);
inserted.push(card);
if (!seenKept) prefixInserted.push(card);
});
ensureCardBorders(sectionElement);
registerLazyImagesIn(itemsContainer);
const previousPosition = getScrollerPosition(scroller);
let prefixWidth = 0;
prefixInserted.forEach((card) => {
prefixWidth += getCardOuterWidth(card);
});
const midInserted = inserted.filter((card) => !prefixInserted.includes(card));
const midLayouts = midInserted.map(captureCardLayout);
const outgoingLayouts = outgoing.map(captureCardLayout);
const canSlidePrefix = !!(scroller && prefixWidth > 0);
const prefixLayouts = canSlidePrefix ? [] : prefixInserted.map(captureCardLayout);
midLayouts.forEach(({ card }) => {
card.classList.add('cardbuilder-card-insert');
applyCardCollapsedSize(card);
});
prefixLayouts.forEach(({ card }) => {
card.classList.add('cardbuilder-card-insert');
applyCardCollapsedSize(card);
});
outgoingLayouts.forEach((layout) => {
layout.card.classList.add('cardbuilder-card-collapse');
applyCardExpandedSize(layout);
});
if (canSlidePrefix) {
prefixInserted.forEach((card) => {
card.classList.add('cardbuilder-card-insert');
card.style.opacity = '0';
});
setScrollerPosition(scroller, previousPosition + prefixWidth, false);
}
itemsContainer.offsetHeight;
itemsContainer.classList.add('cardbuilder-row-animating');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
outgoingLayouts.forEach(({ card }) => applyCardCollapsedSize(card));
midLayouts.forEach((layout) => {
layout.card.classList.add('is-in');
applyCardExpandedSize(layout);
});
prefixLayouts.forEach((layout) => {
layout.card.classList.add('is-in');
applyCardExpandedSize(layout);
});
if (canSlidePrefix) {
prefixInserted.forEach((card) => {
card.classList.add('is-in');
card.style.opacity = '1';
});
setScrollerPosition(scroller, previousPosition, true);
}
const animTarget = outgoing[0] || midInserted[0] || prefixInserted[0] || scroller || itemsContainer;
runAfterTransition(animTarget, 450, () => {
outgoing.forEach((card) => card.remove());
inserted.forEach(clearCardReconcileStyles);
itemsContainer.classList.remove('cardbuilder-row-animating');
if (scroller) {
scroller.style.transition = '';
const slidee = getScrollSlidee(scroller);
if (slidee) slidee.style.transition = '';
}
reindexSectionCards(itemsContainer);
patchCardsUserData(sectionElement, items);
ensureCardBorders(sectionElement);
if (typeof onComplete === 'function') {
onComplete();
}
});
});
});
return true;
}
function replaceItemsContainerContents(sectionElement, items, sectionConfig) {
const itemsContainer = sectionElement.querySelector('.itemsContainer');
if (!itemsContainer || !sectionConfig) return sectionElement;
const overflowCard = sectionConfig.overflowCard;
const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
const normalizedFormat = (cardFormat || '').toLowerCase();
const layoutFromDom = itemsContainer.getAttribute('data-layout');
const layout =
layoutFromDom === 'grid' || layoutFromDom === 'row'
? layoutFromDom
: resolveItemsLayout(sectionConfig);
const gapless = itemsContainer.getAttribute('data-gapless') === 'true' || resolveUseGaplessCards(sectionConfig);
const fragment = document.createDocumentFragment();
if (normalizedFormat === 'button' || isButtonCardFormat(cardFormat)) {
items.forEach((item, index) => {
const button = createLibraryButtonElement(item);
button.setAttribute('data-index', String(index));
fragment.appendChild(button);
});
} else {
const useParentCard = !!sectionConfig?.useParentCard;
const borderStyle = sectionConfig?.borderStyle || null;
items.forEach((item, index) => {
let imageParams = null;
if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) {
imageParams = getDoubledAspectRatioImageParams(cardFormat);
}
const card = createCardElement(
item,
overflowCard,
cardFormat,
item.cardFooter,
useParentCard,
borderStyle,
imageParams,
);
card.setAttribute('data-index', String(index));
fragment.appendChild(card);
});
}
replaceChildren(itemsContainer, fragment);
applyItemsLayoutState(sectionElement, layout, gapless);
ensureCardBorders(sectionElement);
attachSectionControlButtons(sectionConfig, sectionElement, items);
registerLazyImagesIn(itemsContainer);
const scroller = sectionElement.querySelector('.emby-scroller');
invalidateScrollerMetrics(scroller);
if (scroller && isTvLayout()) {
attachTvFocusScroll(scroller);
const focusedCard = document.activeElement?.closest?.('.card');
if (focusedCard && scroller.contains(focusedCard)) {
requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false }));
}
}
return sectionElement;
}
function fadeReplaceRowItems(sectionElement, items, sectionConfig, onComplete) {
const itemsContainer = sectionElement.querySelector('.itemsContainer');
if (!itemsContainer) return false;
const finish = () => {
replaceItemsContainerContents(sectionElement, items, sectionConfig);
requestAnimationFrame(() => itemsContainer.classList.remove('is-hidden'));
if (typeof onComplete === 'function') {
onComplete();
}
};
if (isMobileLayout()) {
finish();
return true;
}
itemsContainer.classList.add('cardbuilder-items-fade');
itemsContainer.classList.add('is-hidden');
runAfterTransition(itemsContainer, 320, finish);
return true;
}
function replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially) {
let finalCardFormat = sectionConfig.cardFormat;
const skeletonCardFormat = sectionElement.getAttribute('data-card-format');
if (skeletonCardFormat) {
finalCardFormat = skeletonCardFormat;
}
if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
const refreshed = refreshSpotlight(sectionElement, items, {
name: getSectionTitleForMultiQuery(sectionConfig) || sectionConfig.name,
viewMoreUrl: getActiveViewMoreUrl(sectionConfig) || sectionConfig.viewMoreUrl,
spotlightConfig: sectionConfig.spotlightConfig,
dataItems: Array.isArray(dataItems) ? dataItems : [],
keepCurrentSlide: true,
sectionConfig,
});
ensureCardBorders(refreshed.content);
ensureSectionRevealObservation(refreshed.content);
return refreshed.content;
}
// Prefer in-place itemsContainer swap (sticky is=emby-itemscontainer)
if (sectionElement.querySelector('.itemsContainer')) {
rebuildSectionTitleInPlace(sectionElement, sectionConfig);
replaceItemsContainerContents(sectionElement, items, {
...sectionConfig,
cardFormat: finalCardFormat,
});
ensureSectionRevealObservation(sectionElement);
return sectionElement;
}
const content = createScrollableContainer(
items,
sectionConfig.name,
sectionConfig.viewMoreUrl,
sectionConfig.overflowCard,
finalCardFormat,
sectionConfig,
false,
);
content.style.cssText = sectionElement.style.cssText;
content.className = sectionElement.className;
Array.from(sectionElement.attributes).forEach((attr) => {
content.setAttribute(attr.name, attr.value);
});
const oldItemsContainer = sectionElement.querySelector('.itemsContainer');
const layoutFromDom = oldItemsContainer?.getAttribute('data-layout');
const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
const gapless = oldItemsContainer
? oldItemsContainer.getAttribute('data-gapless') === 'true'
: resolveUseGaplessCards(sectionConfig);
attachSectionControlButtons(sectionConfig, content, items);
invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller'));
stripCopiedSkeletonSectionClasses(content);
unobserveSkeletonShimmer(sectionElement);
sectionElement.replaceWith(content);
applyItemsLayoutState(content, layout, gapless);
ensureCardBorders(content);
ensureSectionRevealObservation(content);
return content;
}
function fadeReplaceSpotlight(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially) {
sectionElement.classList.add('cardbuilder-items-fade');
sectionElement.classList.add('is-hidden');
runAfterTransition(sectionElement, 320, () => {
const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
markSectionEnhanced(content, sectionConfig);
content.classList.add('cardbuilder-items-fade', 'is-hidden');
requestAnimationFrame(() => content.classList.remove('is-hidden'));
});
}
function markSectionEnhanced(sectionElement, sectionConfig, { updateScrollButtons = false } = {}) {
if (!sectionElement) return;
unobserveSkeletonShimmer(sectionElement);
sectionElement.dataset.enhanced = 'true';
if (typeof sectionConfig?._onSectionEnhanced === 'function') {
sectionConfig._onSectionEnhanced(sectionElement, sectionConfig);
}
if (updateScrollButtons) {
const scroller = sectionElement.querySelector?.('.emby-scroller');
if (scroller) invalidateScrollerMetrics(scroller);
if (isTvLayout()) {
if (scroller) {
attachTvFocusScroll(scroller);
const focusedCard = document.activeElement?.closest?.('.card');
if (focusedCard && scroller.contains(focusedCard)) {
requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false }));
}
}
} else {
registerScrollSectionScrollButtons(sectionElement);
}
}
}
function getDismissEmptySectionTimerMs() {
const raw =
window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS?.dismissEmptySectionTimer ??
window.KefinHomeConfig2?.HOME_SETTINGS?.dismissEmptySectionTimer ??
0;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
}
/**
* Show "No items found" at locked section height, then fade and remove.
* Used when progressive enhance resolves to an empty item list and the timer is > 0.
*/
function dismissEmptyProgressiveSection(sectionElement, { holdMs = 1000 } = {}) {
if (!sectionElement || !sectionElement.isConnected) return;
if (sectionElement.dataset.emptyDismissing === 'true') return;
sectionElement.dataset.emptyDismissing = 'true';
const lockedHeight = Math.max(sectionElement.offsetHeight, 0);
if (lockedHeight > 0) {
sectionElement.style.minHeight = `${lockedHeight}px`;
}
const isSpotlight =
sectionElement.classList.contains('spotlight-section') ||
!!sectionElement.querySelector('.spotlight-items-container, .skeleton-spotlight-item');
const message = document.createElement('div');
message.className = 'cardbuilder-empty-message';
message.textContent = 'No items found';
if (isSpotlight) {
const itemsContainer = sectionElement.querySelector('.spotlight-items-container');
if (itemsContainer) {
itemsContainer.innerHTML = '';
itemsContainer.appendChild(message);
} else {
const body = sectionElement.querySelector('.spotlight-banner-container') || sectionElement;
Array.from(body.children).forEach((child) => {
if (
!child.classList.contains('spotlight-header-container') &&
!child.classList.contains('spotlight-section-title-container') &&
!child.classList.contains('sectionTitleContainer')
) {
child.remove();
}
});
body.appendChild(message);
}
} else {
const itemsContainer = sectionElement.querySelector('.itemsContainer');
if (itemsContainer) {
itemsContainer.innerHTML = '';
itemsContainer.appendChild(message);
} else {
sectionElement.appendChild(message);
}
}
const hold = Math.max(0, Number(holdMs) || 0);
setTimeout(() => {
if (!sectionElement.isConnected) return;
sectionElement.classList.add('cardbuilder-items-fade');
sectionElement.classList.add('is-hidden');
runAfterTransition(sectionElement, 320, () => {
sectionElement.remove();
notifyDiscoveryLeadFillAfterSectionRemove();
});
}, hold);
}
function hasSectionDeferredData(result) {
if (!result) return false;
if (typeof result.ensureData === 'function') return true;
if (result.isStalePromise) return true;
return !!Object.getOwnPropertyDescriptor(result, 'dataPromise');
}
function getSectionDataPromise(result) {
if (!result) return null;
if (typeof result.ensureData === 'function') return result.ensureData();
const desc = Object.getOwnPropertyDescriptor(result, 'dataPromise');
if (desc?.get) return desc.get.call(result);
return result.dataPromise || null;
}
function applyProgressiveEnhancement(
sectionElement,
section,
result,
{ revealSectionsSequentially = false, updateScrollButtons = false } = {},
) {
if (!sectionElement?.isConnected || !section?.config) return;
setSectionRefreshing(sectionElement, false);
sectionElement.dataset.enhanceApplied = 'true';
const sectionConfig = section.config;
let items = result?.Items ?? result ?? [];
if (!Array.isArray(items)) items = [];
const dataItems = section.result?.data?.Items ?? section.result?.data ?? [];
const isSpotlight = sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight';
const isSkeleton = !!sectionElement.querySelector('.skeleton-card, .skeleton-spotlight-item');
const hasSkeletonTitle = !!sectionElement.querySelector('.skeleton-section-title');
const enhanceMarkOpts = { updateScrollButtons: !!updateScrollButtons };
if (items.length === 0) {
const holdMs = getDismissEmptySectionTimerMs();
if (holdMs > 0) {
dismissEmptyProgressiveSection(sectionElement, { holdMs });
} else {
sectionElement.remove();
notifyDiscoveryLeadFillAfterSectionRemove();
}
return;
}
// Skeleton spotlight needs full createSpotlightSection wiring via replaceWith
if (isSkeleton && isSpotlight) {
const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
markSectionEnhanced(content, sectionConfig, enhanceMarkOpts);
return;
}
// Pending→resolved title (skeleton title text) + cards: rebuild title in place, swap cards
if (isSkeleton && hasSkeletonTitle && !isSpotlight) {
rebuildSectionTitleInPlace(sectionElement, sectionConfig);
replaceItemsContainerContents(sectionElement, items, sectionConfig);
markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
return;
}
if (!isSkeleton && isSpotlight) {
const staleIds = (Array.isArray(dataItems) ? dataItems : []).map((item) => item?.Id).filter(Boolean);
const paintedIds = staleIds.length
? staleIds
: Array.from(sectionElement.querySelectorAll('.spotlight-item[data-id]:not(.skeleton-spotlight-item)'))
.map((el) => el.getAttribute('data-id'))
.filter(Boolean);
const freshIds = sectionConfig?.useParentCard
? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean)
: items.map((item) => item?.Id).filter(Boolean);
const match = classifyIdSequence(paintedIds, freshIds);
if (match.type === 'perfect') {
markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
return;
}
// In-place inactive slides only (keepCurrentSlide); no full-section replaceWith
const spotlightContent = replaceSectionWithFreshCards(
sectionElement,
items,
sectionConfig,
dataItems,
revealSectionsSequentially,
);
markSectionEnhanced(spotlightContent, sectionConfig, enhanceMarkOpts);
return;
}
if (!isSkeleton && !isSpotlight) {
const paintedIds = getPaintedCardIds(sectionElement);
const freshIds = sectionConfig?.useParentCard
? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean)
: items.map((item) => item?.Id).filter(Boolean);
const match = classifyIdSequence(paintedIds, freshIds);
if (match.type === 'perfect') {
patchCardsUserData(sectionElement, items);
markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
return;
}
const itemsContainer = sectionElement.querySelector('.itemsContainer');
const layoutFromDom = itemsContainer?.getAttribute('data-layout');
const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
if (match.type === 'reconcile' && layout === 'row') {
reconcileRowItems(sectionElement, items, sectionConfig, () => {
markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
});
return;
}
const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
if (isButtonCardFormat(cardFormat) && match.type === 'replace') {
replaceItemsContainerContents(sectionElement, items, sectionConfig);
markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
return;
}
fadeReplaceRowItems(sectionElement, items, sectionConfig, () => {
markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
});
return;
}
// Skeleton cards without skeleton title: swap itemsContainer contents in place
replaceItemsContainerContents(sectionElement, items, sectionConfig);
markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
}
function parseEnhanceRootMargin(rootMargin) {
const raw = String(rootMargin || '30% 0px 30% 0px')
.trim()
.split(/\s+/);
const top = raw[0] || '0px';
const right = raw[1] || top;
const bottom = raw[2] || top;
const left = raw[3] || right;
const toPx = (token, axisSize) => {
const t = String(token || '0').trim();
if (t.endsWith('%')) {
const pct = parseFloat(t) || 0;
return (pct / 100) * axisSize;
}
return parseFloat(t) || 0;
};
return {
top: toPx(top, window.innerHeight || 0),
right: toPx(right, window.innerWidth || 0),
bottom: toPx(bottom, window.innerHeight || 0),
left: toPx(left, window.innerWidth || 0),
};
}
function isSectionWithinEnhanceMargin(el, rootMargin) {
if (!el?.isConnected || typeof el.getBoundingClientRect !== 'function') return false;
const rect = el.getBoundingClientRect();
const m = parseEnhanceRootMargin(rootMargin || sectionEnhanceObserverRootMargin || '30% 0px 30% 0px');
const vw = window.innerWidth || 0;
const vh = window.innerHeight || 0;
return rect.bottom >= -m.top && rect.top <= vh + m.bottom && rect.right >= -m.left && rect.left <= vw + m.right;
}
function setupSectionProgressiveEnhancement(sectionElement, section, options = {}) {
const { revealSectionsSequentially = false, deferUntilVisible = false } = options;
if (!sectionElement || !section?.result) return;
if (!hasSectionDeferredData(section.result)) return;
if (sectionElement.dataset.enhanceApplied === 'true') return;
if (sectionElement.dataset.enhanceScheduled === 'true') return;
sectionElement.dataset.enhanceScheduled = 'true';
const dataPromise = getSectionDataPromise(section.result);
if (!dataPromise) return;
const enhanceOptions = { revealSectionsSequentially, deferUntilVisible };
dataPromise
.then((result) => {
if (!sectionElement.isConnected) return;
const refreshPromise = section.result?.refreshPromise;
const hasPendingRefresh = !!refreshPromise;
const sectionConfig = section.config;
const isSpotlight = !!(sectionConfig?.spotlight || sectionConfig?.renderMode === 'Spotlight');
// Non-spotlight Random: keep skeleton until fresh refresh — avoid stale paint → fadeReplace
const skipStalePaint = hasPendingRefresh && sectionHasRandomQuerySort(sectionConfig) && !isSpotlight;
let items = result?.Items ?? result ?? [];
if (!Array.isArray(items)) items = [];
const applyOrDefer = (payload, { updateScrollButtons = false } = {}) => {
const applyOpts = {
...enhanceOptions,
updateScrollButtons: !!updateScrollButtons,
};
if (!deferUntilVisible) {
applyProgressiveEnhancementQueued(sectionElement, section, payload, applyOpts);
return;
}
const margin = sectionEnhanceObserverRootMargin || '30% 0px 30% 0px';
const inMargin = isSectionWithinEnhanceMargin(sectionElement, margin);
if (inMargin) {
applyProgressiveEnhancementQueued(sectionElement, section, payload, applyOpts);
} else {
// Data ready but section left the enhance margin — wait until it re-enters
delete sectionElement.dataset.enhanceScheduled;
sectionEnhancePending.set(sectionElement, {
section,
options: applyOpts,
pendingResult: payload,
});
observeSectionForEnhance(sectionElement);
}
};
if (!skipStalePaint) {
if (items.length === 0) {
// Empty sections: dismiss immediately even if scrolled away
applyProgressiveEnhancementQueued(sectionElement, section, result, {
...enhanceOptions,
updateScrollButtons: !hasPendingRefresh,
});
return;
}
applyOrDefer(result, { updateScrollButtons: !hasPendingRefresh });
}
// skipStalePaint: do not paint stale Random cache — wait for refreshPromise below
if (!hasPendingRefresh) return;
refreshPromise
.then((fresh) => {
if (!sectionElement.isConnected) return;
// Prefer live DOM apply; if still pending observe, update pending snapshot
const pending = sectionEnhancePending.get(sectionElement);
if (pending && pending.pendingResult !== undefined) {
pending.pendingResult = fresh;
pending.options = {
...(pending.options || enhanceOptions),
updateScrollButtons: true,
};
return;
}
if (skipStalePaint) {
applyOrDefer(fresh, { updateScrollButtons: true });
return;
}
// Refresh through same FIFO + margin gate as initial enhance
applyOrDefer(fresh, { updateScrollButtons: true });
})
.catch((err) => {
console.warn('[KefinTweaks CardBuilder] Background refresh failed:', section?.config?.id, err);
if (!sectionElement.isConnected) return;
// Random non-spotlight skipped stale paint — fall back so we are not stuck on skeletons
if (skipStalePaint) {
applyOrDefer(result, { updateScrollButtons: true });
}
});
})
.catch((err) => {
console.error('[KefinTweaks CardBuilder] Progressive enhance failed:', section?.config?.id, err);
if (!sectionElement.isConnected) return;
setSectionRefreshing(sectionElement, false);
delete sectionElement.dataset.enhanceScheduled;
applyProgressiveEnhancementQueued(sectionElement, section, { Items: [] }, enhanceOptions);
});
}
function initializeSectionEnhanceObserver(rootMargin = '30% 0px 30% 0px') {
if (typeof IntersectionObserver === 'undefined') {
return;
}
if (sectionEnhanceObserver && sectionEnhanceObserverRootMargin === rootMargin) {
return;
}
if (sectionEnhanceObserver) {
sectionEnhanceObserver.disconnect();
}
sectionEnhanceObserverRootMargin = rootMargin;
sectionEnhanceObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const target = entry.target;
sectionEnhanceObserver.unobserve(target);
const pending = sectionEnhancePending.get(target);
if (!pending) return;
sectionEnhancePending.delete(target);
if (pending.pendingResult !== undefined) {
applyProgressiveEnhancementQueued(target, pending.section, pending.pendingResult, pending.options || {});
return;
}
setupSectionProgressiveEnhancement(target, pending.section, pending.options);
});
},
{
threshold: 0,
rootMargin,
},
);
}
function observeSectionForEnhance(sectionElement) {
if (!sectionElement || !sectionEnhancePending.has(sectionElement)) return;
const sectionId = sectionElement.getAttribute('data-section-id');
console.log('[KefinTweaks CardBuilder] observeSectionForEnhance', {
sectionId,
hasObserver: !!sectionEnhanceObserver,
pendingCount: sectionEnhancePending.has(sectionElement) ? 1 : 0,
});
initializeSectionEnhanceObserver(sectionEnhanceObserverRootMargin || '30% 0px 30% 0px');
if (sectionEnhanceObserver) {
sectionEnhanceObserver.observe(sectionElement);
return;
}
// No IO (unsupported): apply pending immediately — do NOT call
// setupSectionProgressiveEnhancement again (that re-enters applyOrDefer → infinite loop).
const pending = sectionEnhancePending.get(sectionElement);
sectionEnhancePending.delete(sectionElement);
if (!pending) return;
console.warn('[KefinTweaks CardBuilder] enhance observer unavailable; applying pending immediately', sectionId);
if (pending.pendingResult !== undefined) {
applyProgressiveEnhancementQueued(sectionElement, pending.section, pending.pendingResult, pending.options || {});
} else {
applyProgressiveEnhancementQueued(sectionElement, pending.section, { Items: [] }, pending.options || {});
}
}
function scheduleSectionProgressiveEnhancement(sectionElement, section, options) {
const { enhanceOnVisible = false, enhanceRootMargin = '30% 0px 30% 0px', revealSectionsSequentially = false } = options;
if (!hasSectionDeferredData(section?.result)) {
return;
}
const enhanceOptions = {
revealSectionsSequentially,
deferUntilVisible: !!enhanceOnVisible,
};
if (!enhanceOnVisible) {
setupSectionProgressiveEnhancement(sectionElement, section, enhanceOptions);
return;
}
if (typeof IntersectionObserver === 'undefined') {
setupSectionProgressiveEnhancement(sectionElement, section, enhanceOptions);
return;
}
sectionEnhanceObserverRootMargin = enhanceRootMargin;
sectionEnhancePending.set(sectionElement, { section, options: enhanceOptions });
observeSectionForEnhance(sectionElement);
}
// Progressive Section Reveal with IntersectionObserver
let sectionRevealObserver = null;
/**
* Initialize the global IntersectionObserver for section reveal animations
* Watches when sections enter the viewport and triggers fade-in animations
*/
function initializeSectionRevealObserver() {
if (sectionRevealObserver) return; // Already initialized
if (typeof IntersectionObserver === 'undefined') {
return; // Browser doesn't support IntersectionObserver
}
const observerOptions = {
threshold: 0.1, // Trigger when 10% of section is visible
rootMargin: '0px 0px -50px 0px', // Trigger slightly before fully in view
};
sectionRevealObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Add in-viewport class to trigger animation
entry.target.classList.add('in-viewport');
// Unobserve after animation is triggered (one-time animation)
sectionRevealObserver.unobserve(entry.target);
}
});
}, observerOptions);
}
/**
* After replaceWith, the new node keeps cardbuilder-section-reveal but is no longer
* observed — re-attach so in-viewport can still be applied (or stays if already set).
*/
function ensureSectionRevealObservation(sectionElement) {
if (!sectionElement?.classList?.contains('cardbuilder-section-reveal')) return;
if (sectionElement.classList.contains('in-viewport')) return;
observeSectionForReveal(sectionElement);
}
/**
* Observe a section element for viewport entry to trigger reveal animation
* @param {HTMLElement} sectionElement - The section element to observe
*/
function observeSectionForReveal(sectionElement) {
if (!sectionElement) return;
// Initialize observer if not already done
if (!sectionRevealObserver) {
initializeSectionRevealObserver();
}
// Only observe if observer was successfully created
if (sectionRevealObserver) {
sectionRevealObserver.observe(sectionElement);
}
}
async function initialize() {
// Match loader default: watchlist enabled unless scripts.watchlist === false
const scripts = window.KefinTweaksConfig?.scripts || {};
state.watchlist = scripts.watchlist !== false;
if (window.userHelper?.waitForLogin) {
await window.userHelper.waitForLogin();
}
startCardUserDataListener();
startCardLibraryChangedListener();
startCardEditorCloseLiveSync();
try {
const userId = typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId ? ApiClient.getCurrentUserId() : null;
if (userId) {
const raw = localStorage.getItem(`${userId}-blurhash`);
// Jellyfin enableBlurhash: default on when unset; only explicit 'false' disables
state.useBlurhash = raw !== 'false';
} else {
state.useBlurhash = true;
}
} catch (e) {
state.useBlurhash = true;
}
}
initialize();
// Expose the cardBuilder to the global window object
window.cardBuilder = cardBuilder;
console.log('[KefinTweaks CardBuilder] Module loaded and available at window.cardBuilder');
})();