Files
KefinTweaks/scripts/cardBuilder.js
T

9163 lines
415 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;
// 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,
};
/** 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:not(.card-layout-dummy) .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:not(.card-layout-dummy) .cardScalable, .card:not(.card-layout-dummy) .cardContent, .card:not(.card-layout-dummy)',
);
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 === 0;
rightButton.disabled = pos >= maxPosition;
}
/**
* 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)];
}
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;
// Keep only the first item in the array for any given series
const seriesMap = new Map();
items.forEach((item) => {
if (item.Type !== 'Episode') {
return;
}
if (seriesMap.has(item.SeriesId)) {
return;
}
seriesMap.set(item.SeriesId, item);
});
return Array.from(seriesMap.values());
}
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);
const raw =
typeof merged.result?.ensureData === 'function'
? await merged.result.ensureData()
: await (merged.result?.dataPromise ?? merged.result?.data);
return extractItemsFromQueryPayload(raw);
}
if (results.length === 1) {
const qr = results[0];
const raw = typeof qr.ensureData === 'function' ? await qr.ensureData() : await (qr.dataPromise ?? qr.data);
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 [];
}
/**
* 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));
// Fade out current icon
/* button.classList.add('icon-fade-out');
// Wait for fade out to complete
await new Promise(resolve => setTimeout(resolve, 200));*/
// Change to checkmark icon
button.classList.remove('refresh', 'icon-fade-out');
button.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');
// After 1.5 seconds, fade out checkmark and fade in refresh icon
setTimeout(async () => {
// Fade out checkmark
button.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');
// Wait for fade in to complete
await new Promise((resolve) => setTimeout(resolve, 200));
button.classList.remove('icon-fade-in');
}, 1500);
}
async function replaceSectionContent(sectionConfig, sectionElement, freshItems) {
if (freshItems.length === 0) {
sectionElement.remove();
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);
content.dataset.refreshing = '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);
content.dataset.refreshing = 'false';
registerLazyImagesInSection(content);
ensureSectionRevealObservation(content);
return content;
}
replaceItemsContainerContents(sectionElement, freshItems, {
...sectionConfig,
cardFormat: finalCardFormat,
});
sectionElement.dataset.refreshing = '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);
skeleton.dataset.refreshing = 'true';
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';
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');
if (controlsMount) {
titleContainer.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);
}
let activePopover = null;
const closePopover = () => {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
};
if (!document.getElementById('kefinTweaks-multiQueryPopover-style')) {
const styleElement = document.createElement('style');
styleElement.id = 'kefinTweaks-multiQueryPopover-style';
styleElement.textContent = `
.multiQueryPopover {
background-color: rgba(0, 0, 0, 0.95);
position: absolute !important;
z-index: 1001;
display: block;
}
`;
document.head.appendChild(styleElement);
}
selectButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
if (activePopover) {
closePopover();
return;
}
const popover = document.createElement('div');
popover.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup';
const selectedIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false });
let selectedItemElement = null;
queries.forEach((query, index) => {
const itemElement = document.createElement('div');
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem';
if (index === selectedIndex) {
itemElement.classList.add('selected');
selectedItemElement = itemElement;
}
itemElement.textContent = getQueryDisplayName(query, index);
itemElement.addEventListener('click', async () => {
closePopover();
if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) {
return;
}
sectionConfig._selectedQueryIndex = index;
const sectionIdAttr =
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(String(sectionConfig.id))
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// Prefer the live section node (may have been replaced by a prior switch).
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));
updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig));
activeSectionEl.dataset.refreshing = 'true';
try {
const loaded = await loadActiveSectionQueryItems(sectionConfig);
let newContent = null;
if (loaded.canPaintCached) {
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items);
} else {
// Skeletons before awaiting network — cache check already finished above.
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;
if (live) live.dataset.refreshing = 'false';
}
});
popover.appendChild(itemElement);
});
titleContainer.style.position = 'relative';
selectButton.parentNode.insertBefore(popover, selectButton.nextSibling);
const buttonRect = selectButton.getBoundingClientRect();
const containerRect = titleContainer.getBoundingClientRect();
popover.style.left = `${Math.max(0, buttonRect.left - containerRect.left)}px`;
popover.style.top = '100%';
popover.style.bottom = 'auto';
popover.style.marginTop = '8px';
popover.style.marginBottom = '0';
activePopover = popover;
if (selectedItemElement) {
setTimeout(() => {
selectedItemElement.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
});
}, 10);
}
const closeHandler = (ev) => {
if (!popover.contains(ev.target) && !selectButton.contains(ev.target)) {
closePopover();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => {
document.addEventListener('click', closeHandler);
}, 100);
});
}
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 = document.createElement('button');
moreButton.type = 'button';
moreButton.className = 'section-controls-more material-icons more_vert';
moreButton.title = 'More';
moreButton.setAttribute('aria-label', 'More');
controls.appendChild(inline);
controls.appendChild(moreButton);
mountParent.appendChild(controls);
} else if (controls.parentNode !== mountParent) {
mountParent.appendChild(controls);
}
return {
controls,
inline: controls.querySelector('.section-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);
return sectionConfig.items.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) {
sectionElement.dataset.refreshing = 'true';
try {
const hasStaticItems = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
let freshItems;
if (hasStaticItems) {
if (!sectionHasRandomQuerySort(sectionConfig)) {
sectionElement.dataset.refreshing = 'false';
const refreshButton = sectionElement.querySelector('.section-refresh-button');
if (refreshButton) {
showRefreshComplete(refreshButton);
}
return refreshButton || null;
}
freshItems = postProcessItems(sectionConfig, normalizeStaticSectionItems(sectionConfig));
if (freshItems.length === 0) {
sectionElement.dataset.refreshing = 'false';
return null;
}
} else {
freshItems = await refreshSectionQueries(sectionConfig);
if (freshItems.length === 0) {
sectionElement.remove();
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);
content.dataset.refreshing = '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,
});
sectionElement.dataset.refreshing = '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);
});
invalidateLastRowPadding(content.querySelector('.itemsContainer'));
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);
content.dataset.refreshing = 'false';
registerLazyImagesInSection(content);
ensureSectionRevealObservation(content);
if (newButton) {
showRefreshComplete(newButton);
}
return newButton;
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error refreshing section:', error);
sectionElement.dataset.refreshing = '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;
function getPrimaryUserIdForCardSync() {
return typeof ApiClient !== 'undefined' && typeof ApiClient.getCurrentUserId === 'function' ? ApiClient.getCurrentUserId() : null;
}
function extractUserDataChangePayload(event) {
const data = event?.detail ?? event;
const userId = data?.Data?.UserId ?? data?.UserId ?? null;
const list = data?.Data?.UserDataList ?? data?.UserDataList;
if (!Array.isArray(list)) return { userId, entries: [] };
return { userId, entries: list };
}
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 escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(itemId) : itemId.replace(/["\\]/g, '\\$&');
const cards = document.querySelectorAll(`.card[data-id="${escapedId}"]`);
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 (!itemId || !userData || !('Likes' in userData)) return;
const isLiked = userData.Likes === true;
const likedHelper = window.KefinWatchlistLikedIds;
if (likedHelper?.set) {
likedHelper.set(itemId, isLiked);
}
const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(itemId) : itemId.replace(/["\\]/g, '\\$&');
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 applyUserDataEntriesToCards(entries, userId) {
if (!isPrimaryUserDataChange(userId)) return;
entries.forEach((entry) => {
if (entry?.ItemId) {
updateCardResumeAttributes(entry.ItemId, entry);
updateCardWatchlistButtons(entry.ItemId, entry);
}
});
}
function handleCardUserDataChangedEvent(event) {
const { userId, entries } = extractUserDataChangePayload(event);
if (!entries.length) return;
applyUserDataEntriesToCards(entries, userId);
}
function startCardUserDataListener() {
if (cardUserDataListenerStarted) return;
let registered = false;
if (window.ApiClient?.addEventListener) {
window.ApiClient.addEventListener('userdatachanged', handleCardUserDataChangedEvent);
registered = true;
}
if (window.websocketHelper?.listen) {
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);
});
registered = true;
}
if (registered) {
cardUserDataListenerStarted = true;
console.log('[KefinTweaks CardBuilder] Listening for UserDataChanged to sync card resume attributes');
return;
}
setTimeout(startCardUserDataListener, 1000);
}
// 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,
attachSectionControlButtons: attachSectionControlButtons,
ensureCardBorders: ensureCardBorders,
applyItemsLayoutState: applyItemsLayoutState,
resolveItemsLayout: resolveItemsLayout,
resolveUseGaplessCards: resolveUseGaplessCards,
invalidateLastRowPadding: invalidateLastRowPadding,
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 createJellyfinCardElement(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,
} = 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`);
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 {
content = createScrollableContainer(
dataItems,
getSectionTitleForMultiQuery(sectionConfig),
getActiveViewMoreUrl(sectionConfig),
sectionConfig.overflowCard,
finalCardFormat,
sectionConfig,
);
}
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));
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;
}
/**
* 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,
) {
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';
}
}
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') {
if (item.ImageTags?.Banner) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Banner?${imageParams}&quality=96&tag=${item.ImageTags.Banner}`;
} else {
imageUrl = `${serverAddress}/Items/${item.SeriesId || item.ParentId}/Images/Banner?${imageParams}&quality=96`;
}
} 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);
}
}
}
// Card overlay container
const cardOverlayContainer = document.createElement('div');
cardOverlayContainer.className = 'cardOverlayContainer';
if (isCustomCard) {
// Overlay link (first so it sits under buttons)
const overlayLink = document.createElement('a');
overlayLink.href = cardUrl;
overlayLink.className = 'button-link';
overlayLink.setAttribute('is', 'emby-linkbutton');
// Add target=_blank if its an external link, internal links will start with / or #
if (!cardUrl.startsWith('/') && !cardUrl.startsWith('#')) {
overlayLink.setAttribute('target', '_blank');
}
cardOverlayContainer.appendChild(overlayLink);
} else {
cardOverlayContainer.className = 'cardOverlayContainer itemAction';
cardOverlayContainer.setAttribute('data-action', 'link');
// Play button
const playButton = document.createElement('button');
playButton.setAttribute('is', 'paper-icon-button-light');
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);
// Button container for additional overlay buttons (watchlist, etc.)
const buttonContainer = document.createElement('div');
buttonContainer.className = 'cardOverlayButton-br flex';
// Watchlist button (active state from UserData.Likes; click bound by watchlist.js)
const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
if (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);
}
// Watched button
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);
// Favorite button
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.setAttribute('is', 'paper-icon-button-light');
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 (item.Type !== 'CollectionFolder' && item.Type !== 'Folder' && item.Type !== 'UserView' && item.LocationType !== 'Virtual') {
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) {
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);
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;
}
/**
* Renders a spotlight section (Netflix-style slim banner carousel)
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the spotlight section
* @param {Object} options - Options for the spotlight carousel
* @param {boolean} options.autoPlay - Auto-cycle through items (default: true)
* @param {number} options.interval - Auto-play interval in ms (default: 10000)
* @param {boolean} options.showSlideState - Show slide state container (dots or numeric) (default: true)
* @param {boolean} options.showDots - When showSlideState is true: true = dots (max 5), false = numeric "N / X" (default: true)
* @param {boolean} options.showNavButtons - Show prev/next buttons (default: true)
* @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen)
* @returns {HTMLElement} - The constructed spotlight container
*/
function createSpotlightSection(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,
spotlightLayout,
spotlightSize,
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;
// Backward compat: derive layout/size from fullScreen if new keys missing
const fullScreen = options.fullScreen === true;
const layout = spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border');
const size = spotlightSize ?? (fullScreen ? 'full' : 'normal');
let tileCount = tileCountOpt;
if (tileCount == null || tileCount < 1 || tileCount > 3) {
tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3;
}
tileCount = Math.max(1, Math.min(3, Math.floor(tileCount)));
// Cap multi-tile layouts on narrower viewports
const vw = window.innerWidth || 0;
if (vw < 900) tileCount = Math.min(tileCount, 1);
else if (vw < 1600) tileCount = Math.min(tileCount, 2);
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';
// Title container hosts section name + refresh/configure controls.
// Always create it so controls can attach even when the section has no name.
{
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';
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 ${viewMoreUrl ? "" : "spotlight-title-link "}headerTabs sectionTabs`;
sectionTitleWrapper.appendChild(sectionTitleEl);
sectionTitleContainer.appendChild(sectionTitleWrapper);
}
bannerContainer.appendChild(sectionTitleContainer);
}
// 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,
}));
}
// Set backdrop width based on layout-mobile presence
let backdropWidth = 0;
if (document.querySelector('.layout-mobile')) {
backdropWidth = 640;
}
const buildBackdropUrl = (src, idx) =>
`${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?fillWidth=${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 = '';
if (cycleBackdrops && tileCount === 1 && backdropSources.length > 0) {
const shuffled = shuffleArray(backdropSources.map((s, i) => ({ src: s, idx: i })));
const urls = shuffled.map(
({ src, idx }) =>
`${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?fillWidth=${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?fillWidth=${backdropWidth}&quality=96&tag=${first.tag}`;
} else if (item.ImageTags?.Primary) {
singleUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillWidth=${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?fillHeight=${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;
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;
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;
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)
if (hasMore) {
// 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';
// Play button (material icon span button)
const playButton = document.createElement('button');
playButton.className = 'emby-button raised button-flat btnPlay detailButton';
playButton.title = 'Play';
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 (material icon span button with bookmark icon)
const watchlistButton = document.createElement('button');
watchlistButton.className = 'emby-button button-flat btnWatchlist';
// Check watchlist status - try to get from cache or API
let isInWatchlist = false;
const sectionName =
itemType === 'Movie'
? 'movies'
: itemType === 'Series'
? 'series'
: itemType === 'Season'
? 'seasons'
: itemType === 'Episode'
? 'episodes'
: null;
// Check watchlist cache if available
if (sectionName && typeof window.watchlistCache !== 'undefined' && window.watchlistCache[sectionName]?.data) {
isInWatchlist = window.watchlistCache[sectionName].data.some((watchlistItem) => watchlistItem.Id === item.Id);
}
const watchlistIcon = document.createElement('span');
watchlistIcon.className = 'material-icons';
watchlistIcon.textContent = 'bookmark';
watchlistIcon.title = isInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist';
watchlistButton.appendChild(watchlistIcon);
if (isInWatchlist) {
watchlistButton.classList.add('watchlisted');
}
watchlistButton.addEventListener('click', async (e) => {
e.stopPropagation();
const userId = ApiClient.getCurrentUserId();
const newStatus = !isInWatchlist;
try {
// Update watchlist status using proper API
await ApiClient.updateUserItemRating(userId, item.Id, newStatus ? 'true' : 'false');
// Update local state
isInWatchlist = newStatus;
// Update button class
if (isInWatchlist) {
watchlistButton.classList.add('watchlisted');
} else {
watchlistButton.classList.remove('watchlisted');
}
// Update watchlist cache if available
if (sectionName && typeof window.updateWatchlistCacheOnToggle === 'function') {
await window.updateWatchlistCacheOnToggle(item.Id, itemType, isInWatchlist);
}
} catch (err) {
console.error('Failed to update watchlist status:', err);
}
});
// Info button (shows overview on hover)
const infoButton = document.createElement('button');
infoButton.className = 'emby-button button-flat spotlight-info-button';
const infoIcon = document.createElement('span');
infoIcon.className = 'material-icons';
infoIcon.textContent = 'info';
infoIcon.title = 'Go To Item';
infoButton.appendChild(infoIcon);
// Overview tooltip (hidden by default, shown on hover)
if (item.Overview) {
const overviewTooltip = document.createElement('div');
overviewTooltip.className = 'spotlight-overview-tooltip';
overviewTooltip.textContent = item.Overview;
infoButton.appendChild(overviewTooltip);
}
buttonsContainer.appendChild(playButton);
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);
const metadataContainer = document.createElement('div');
metadataContainer.className = 'metadata-container';
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) {
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 (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 (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';
studioRow.appendChild(studioContainer);
metadataContainer.appendChild(studioRow);
}
}
// 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');
}
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;
}
});
// Apply initial pan animation to the starting slide (helper also sets up completion tracking)
if (panAnimation && items.length > 0) {
const firstItemPan = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
if (firstItemPan) {
applyPanAnimationToSlide(firstItemPan);
startCycleBackdropTimer(firstItemPan, items[currentIndex].Id);
}
}
// 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';
const prevButton = document.createElement('button');
prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
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';
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);
bannerContainer.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';
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;
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();
}
}
// 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
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 {
// Re-entering viewport: resume pan, ensure current media painted, restart timers
setSlideAnimationPlayState(currentSlide, 'running');
ensureSlideImagesLoaded(currentIndex);
if (items.length > 1) {
ensureSlideImagesLoaded((currentIndex + 1) % items.length);
}
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));
}
// 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);
});
};
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;
button.classList.remove('view_array', 'grid_view', 'grid_on');
button.classList.add(ITEMS_LAYOUT_ICONS[layout] || 'view_array');
}
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:not(.card-layout-dummy)'));
}
function getRealLayoutCards(itemsContainer) {
if (!itemsContainer) return [];
return Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.card-layout-dummy)'));
}
function removeLayoutDummies(itemsContainer) {
if (!itemsContainer) return;
itemsContainer.querySelectorAll(':scope > .card-layout-dummy').forEach((el) => el.remove());
}
function invalidateLastRowPadding(itemsContainer) {
if (!itemsContainer) return;
removeLayoutDummies(itemsContainer);
itemsContainer.removeAttribute('data-last-row-padding');
itemsContainer.removeAttribute('data-last-row-padding-layout');
itemsContainer.removeAttribute('data-measuring');
}
function canMeasureLastRowPadding(itemsContainer) {
return !!(itemsContainer?.isConnected && itemsContainer.clientWidth >= 1);
}
function createLayoutDummyCard(templateCard, layout) {
const dummy = document.createElement('div');
dummy.className = templateCard?.className || 'card';
dummy.classList.add('card', 'card-layout-dummy');
dummy.classList.remove('card-hoverable');
dummy.setAttribute('aria-hidden', 'true');
dummy.setAttribute('tabindex', '-1');
// cardBox
const cardBox = document.createElement('div');
cardBox.className = 'cardBox';
dummy.appendChild(cardBox);
// cardScalable
const cardScalable = document.createElement('div');
cardScalable.className = 'cardScalable';
cardBox.appendChild(cardScalable);
if (layout === 'grid') {
const templatePadder = templateCard?.querySelector('.cardPadder');
const padder = document.createElement('div');
padder.className = templatePadder?.className || 'cardPadder cardPadder-portrait lazy-hidden-children';
const cardIcon = document.createElement('span');
cardIcon.className = 'cardImageIcon material-icons';
cardIcon.setAttribute('aria-hidden', 'true');
cardIcon.textContent = 'folder';
padder.appendChild(cardIcon);
cardScalable.appendChild(padder);
const border = createCardBorderHost();
const section = templateCard?.closest('[data-border],[data-border-style]');
const raw =
section?.dataset?.border ||
section?.dataset?.borderStyle ||
templateCard?.querySelector('.cardBorder')?.dataset?.style ||
'';
const style = normalizeCardBorderStyle(raw);
if (style) fillCardBorder(border, style);
cardScalable.appendChild(border);
}
return dummy;
}
/**
* Count how many real cards fit on the first row at natural (non-growing) width.
* @param {HTMLElement} itemsContainer
* @returns {number}
*/
function countItemsPerRow(itemsContainer) {
if (!canMeasureLastRowPadding(itemsContainer)) return 0;
const cards = getRealLayoutCards(itemsContainer);
if (!cards.length) return 0;
itemsContainer.setAttribute('data-measuring', 'true');
void itemsContainer.offsetWidth;
const firstTop = cards[0].offsetTop;
let count = 0;
for (const card of cards) {
if (card.offsetTop !== firstTop) break;
count++;
}
itemsContainer.removeAttribute('data-measuring');
return count;
}
/**
* Pad the last flex row with invisible dummy cards so grow distribution matches full rows.
* Reuses data-last-row-padding when cached for the same layout.
* @param {HTMLElement} itemsContainer
* @param {'grid'} layout
*/
function syncLastRowPadding(itemsContainer, layout) {
if (!itemsContainer || layout !== 'grid') return;
const cachedPad = itemsContainer.getAttribute('data-last-row-padding');
const cachedLayout = itemsContainer.getAttribute('data-last-row-padding-layout');
const hasCache = cachedPad != null && cachedPad !== '' && cachedLayout === layout && /^\d+$/.test(cachedPad);
// Disconnected / zero-width: do not measure or cache (retry when laid out).
if (!hasCache && !canMeasureLastRowPadding(itemsContainer)) return;
removeLayoutDummies(itemsContainer);
let padding;
if (hasCache) {
padding = parseInt(cachedPad, 10);
} else {
itemsContainer.removeAttribute('data-last-row-padding');
itemsContainer.removeAttribute('data-last-row-padding-layout');
const cards = getRealLayoutCards(itemsContainer);
if (!cards.length) return;
const perRow = countItemsPerRow(itemsContainer);
if (perRow <= 0) return;
const rem = cards.length % perRow;
padding = rem === 0 ? 0 : perRow - rem;
itemsContainer.setAttribute('data-last-row-padding', String(padding));
itemsContainer.setAttribute('data-last-row-padding-layout', layout);
}
if (padding <= 0) return;
if (!itemsContainer.isConnected) return;
const template = getRealLayoutCards(itemsContainer)[0];
if (!template) return;
const frag = document.createDocumentFragment();
for (let i = 0; i < padding; i++) {
frag.appendChild(createLayoutDummyCard(template, layout));
}
itemsContainer.appendChild(frag);
}
let lastRowPaddingResizeTimer = null;
function handleLastRowPaddingWindowResize() {
clearTimeout(lastRowPaddingResizeTimer);
lastRowPaddingResizeTimer = setTimeout(() => {
document.querySelectorAll('.itemsContainer[data-layout="grid"]').forEach((container) => {
const layout = container.getAttribute('data-layout');
invalidateLastRowPadding(container);
syncLastRowPadding(container, layout);
});
}, 150);
}
if (!window.__kefinLastRowPaddingResizeBound) {
window.__kefinLastRowPaddingResizeBound = true;
window.addEventListener('resize', handleLastRowPaddingWindowResize);
}
let lastRowPaddingRo = null;
let lastRowPaddingRoTimer = null;
const lastRowPaddingRoPending = new Set();
function ensureLastRowPaddingResizeObserver() {
if (lastRowPaddingRo || typeof ResizeObserver === 'undefined') return lastRowPaddingRo;
lastRowPaddingRo = new ResizeObserver((entries) => {
entries.forEach((entry) => {
if (entry?.target) lastRowPaddingRoPending.add(entry.target);
});
clearTimeout(lastRowPaddingRoTimer);
lastRowPaddingRoTimer = setTimeout(() => {
const targets = [...lastRowPaddingRoPending];
lastRowPaddingRoPending.clear();
targets.forEach((el) => {
const layout = el.getAttribute?.('data-layout');
if (layout !== 'grid') return;
invalidateLastRowPadding(el);
syncLastRowPadding(el, layout);
});
}, 80);
});
return lastRowPaddingRo;
}
function observeLastRowPadding(itemsContainer) {
const ro = ensureLastRowPaddingResizeObserver();
if (!ro || !itemsContainer) return;
try {
ro.observe(itemsContainer);
} catch (_) {
/* ignore */
}
}
function unobserveLastRowPadding(itemsContainer) {
if (!lastRowPaddingRo || !itemsContainer) return;
try {
lastRowPaddingRo.unobserve(itemsContainer);
} catch (_) {
/* ignore */
}
}
/**
* 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') {
unobserveLastRowPadding(itemsContainer);
removeLayoutDummies(itemsContainer);
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);
}
if (resolved === 'grid') {
observeLastRowPadding(itemsContainer);
// Two frames so wrap layout is settled before measuring / padding
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (itemsContainer.getAttribute('data-layout') !== resolved) return;
syncLastRowPadding(itemsContainer, resolved);
});
});
}
}
function syncAttachedGridTilesLayouts(root) {
if (!root?.querySelectorAll) return;
root.querySelectorAll('.itemsContainer[data-layout="grid"]').forEach((itemsContainer) => {
const layout = itemsContainer.getAttribute('data-layout');
const gapless = itemsContainer.getAttribute('data-gapless') === 'true';
const section = itemsContainer.closest('.verticalSection, .emby-scroller-container') || itemsContainer.parentElement;
if (section) applyItemsLayoutState(section, layout, gapless);
else {
observeLastRowPadding(itemsContainer);
invalidateLastRowPadding(itemsContainer);
syncLastRowPadding(itemsContainer, layout);
}
});
}
/** @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 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',
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 button = document.createElement('button');
button.type = 'button';
button.className = `${def.className} material-icons ${def.icon}`;
button.title = label;
button.setAttribute('aria-label', label);
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);
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 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 ${def.icon}`;
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;
}
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(.card-layout-dummy):not(.skeleton-card)');
const pad = readCardPadding(sampleCard);
metrics.cardPad = pad;
return pad;
}
function getFullyVisibleCardCount(scroller, cards) {
const epsilon = 1;
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const endInset = Math.max(pad.right, pad.left);
const scrollerRect = scroller.getBoundingClientRect();
const currentPosition = getCurrentPosition(scroller);
const contentWidth = Math.max(0, scroller.clientWidth - pad.left - endInset);
const visibleLeft = scrollerRect.left + currentPosition + 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;
}
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;
}
/**
* Max transform/scroll offset so the trailing content edge matches the leading
* gutter when the skin only pads the left (e.g. Fin --sidePadding), plus the
* sample card's horizontal padding so the last card's right padding clears the clip.
*/
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(.card-layout-dummy):not(.skeleton-card)');
const fullyVisible = getFullyVisibleCardCount(scroller, cards);
if (fullyVisible === cards.length) {
setMaxScrollPosition(scroller, 0);
return 0;
}
const { left, right } = pad || { left: 0, right: 0 };
const cp = cardPad || { left: 0, right: 0 };
const endInset = Math.max(right, left);
const base = Math.max(0, scroller.scrollWidth - scroller.clientWidth + endInset);
const maxScroll = base + (cp.left || 0) + (cp.right || 0);
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', 'custom');
scroller.style.scrollSnapType = 'none';
scroller.style.touchAction = 'auto';
scroller.style.overscrollBehaviorX = 'contain';
scroller.style.overscrollBehaviorY = 'auto';
scroller.style.webkitOverflowScrolling = 'touch';
setStoredScrollPosition(scroller, 0);
return scroller;
}
function createItemsSliderElement(isEmbyItemscontainer = true) {
const isMobile = isMobileLayout();
const itemsContainer = document.createElement('div');
if (isEmbyItemscontainer) {
itemsContainer.setAttribute('is', 'emby-itemscontainer');
}
itemsContainer.className = `focuscontainer-x itemsContainer scrollSlider${!isMobile ? " animatedScrollX" : ""}`;
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 maxPosition = Math.max(0, opts.freshMax ? getMaxPositionFresh() : getMaxPosition());
const position = Math.min(Math.max(newPosition, 0), maxPosition);
const clampedPosition = position > 20 ? position : 0;
if (opts.animate) {
scroller.style.transition = 'transform 270ms ease-out';
} else {
scroller.style.transition = 'none';
}
scroller.style.transform = `translateX(-${clampedPosition}px)`;
setStoredScrollPosition(scroller, clampedPosition);
const section = scroller.closest('.emby-scroller-container');
if (section && typeof applyScrollButtonState === 'function') {
if (opts.animate) {
const onTransitionEnd = () => {
scroller.removeEventListener('transitionend', onTransitionEnd);
applyScrollButtonState(section);
};
scroller.addEventListener('transitionend', onTransitionEnd);
} else {
requestAnimationFrame(() => applyScrollButtonState(section));
}
}
}
function getScrollCards() {
const itemsContainer = scroller.querySelector('.itemsContainer');
if (!itemsContainer) return [];
return Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.card-layout-dummy):not(.skeleton-card)'));
}
function getCardScrollLeft(card) {
// Align card content edge (border-box left + card paddingLeft) to the
// scroller's padded content gutter.
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const scrollerRect = scroller.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
return cardRect.left - scrollerRect.left - pad.left + cardPad.left;
}
/**
* Cards fully inside the padded content clip (not the browser viewport / border box).
* Undo translateX, then inset by scroller + card padding so visibility matches snap targets.
* When the skin only pads the left, treat trailing inset as max(left, right).
*/
function getFullyVisibleCardIndices(cards) {
const fullyVisible = [];
const epsilon = 1;
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const endInset = Math.max(pad.right, pad.left);
const scrollerRect = scroller.getBoundingClientRect();
const currentPosition = getCurrentPosition();
const contentWidth = Math.max(0, scroller.clientWidth - pad.left - endInset);
const visibleLeft = scrollerRect.left + currentPosition + 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: use max (scroller end inset + card L+R padding), not left-align snap
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);
// 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';
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 (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) {
const normalizedFormat = (cardFormat || '').toLowerCase();
const isButtonLayout = normalizedFormat === 'button';
// 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);
/* if (isButtonLayout) {
const itemsContainer = document.createElement('div');
//itemsContainer.setAttribute('is', 'emby-itemscontainer');
itemsContainer.className = 'itemsContainer padded-left padded-right focuscontainer-x';
items.forEach((item, index) => {
const button = createLibraryButtonElement(item);
button.setAttribute('data-index', index);
itemsContainer.appendChild(button);
});
verticalSection.appendChild(sectionTitleContainer);
verticalSection.appendChild(itemsContainer);
return verticalSection;
} */
// Create scroller container
const scroller = createScrollerElement();
const itemsContainer = createItemsSliderElement();
// Add items to container
const useParentCard = !!sectionConfig?.useParentCard;
const borderStyle = sectionConfig?.borderStyle || null;
items.forEach((item, index) => {
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
scroller.appendChild(itemsContainer);
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
// Assemble the section
verticalSection.appendChild(sectionTitleContainer);
if (scrollButtons) {
verticalSection.appendChild(scrollButtons);
}
verticalSection.appendChild(scroller);
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, spotlightLayout, spotlightSize, fullScreen, discoveryPending = false, caption = null } = options;
const layout = spotlightLayout ?? (fullScreen === true ? 'Borderless' : 'Border');
const size = spotlightSize ?? (fullScreen === true ? 'full' : 'normal');
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';
// Title container hosts section name + refresh/configure controls.
// Always create it so controls can attach even when the section has no name.
{
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);
}
}
bannerContainer.appendChild(sectionTitleContainer);
}
// 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);
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,
) {
// 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();
// Sticky is=emby-itemscontainer on skeleton shell — never recreate on enhance
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);
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
verticalSection.appendChild(sectionTitleContainer);
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;
}
function observeSkeletonShimmer(sectionElement) {
if (!sectionElement?.classList) return;
sectionElement.classList.add('skeleton-section');
sectionElement.classList.remove('skeleton-shimmer-active');
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');
if (skeletonShimmerObserver) {
try {
skeletonShimmerObserver.unobserve(sectionElement);
} catch (_) {
/* ignore */
}
}
}
function stripCopiedSkeletonSectionClasses(el) {
el?.classList?.remove('skeleton-section', 'skeleton-shimmer-active');
}
// 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;
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';
cardImageContainer.classList.add('lazy-masked-svg');
} else {
cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
}
cardImageContainer.classList.remove('lazy');
cardImageContainer.classList.add('lazy-loaded');
cardImageContainer.removeAttribute('data-loading');
cardImageContainer.classList.remove('lazy-hidden');
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 <= 0;
rightButton.disabled = scrollX >= maxScroll - 1;
}
/**
* 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(.card-layout-dummy):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;
const maxPosition = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
const scrollPosition = Math.min(Math.max(position, 0), maxPosition);
const clamped = scrollPosition > 20 ? scrollPosition : 0;
if (scroller.classList.contains('scrollX')) {
scroller.scrollTo({ left: clamped, behavior: animate ? 'smooth' : 'auto' });
setStoredScrollPosition(scroller, clamped);
return;
}
scroller.style.transition = animate ? 'transform 400ms ease-out' : 'none';
scroller.style.transform = `translateX(-${clamped}px)`;
setStoredScrollPosition(scroller, clamped);
}
/**
* 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(.card-layout-dummy):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 scrollerRect = scroller.getBoundingClientRect();
const cardRect = cards[clampedIndex].getBoundingClientRect();
const targetLeft = cardRect.left - scrollerRect.left - 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 appendCardsForItems(
itemsContainer,
items,
overflowCard,
cardFormat,
startIndex = 0,
useParentCard = false,
borderStyle = null,
) {
const cards = [];
items.forEach((item, offset) => {
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
card.setAttribute('data-index', String(startIndex + offset));
itemsContainer.appendChild(card);
cards.push(card);
});
return cards;
}
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) {
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) => {
if (!item?.Id) return;
const existing = cardById.get(item.Id);
if (existing) {
seenKept = true;
nextNode = existing.nextElementSibling;
while (nextNode && (nextNode.classList.contains('card-layout-dummy') || nextNode.classList.contains('skeleton-card'))) {
nextNode = nextNode.nextElementSibling;
}
return;
}
const card = createJellyfinCardElement(
item,
overflowCard,
cardFormat,
item.cardFooter,
!!sectionConfig?.useParentCard,
sectionConfig?.borderStyle || null,
);
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 = '';
reindexSectionCards(itemsContainer);
invalidateLastRowPadding(itemsContainer);
patchCardsUserData(sectionElement, items);
ensureCardBorders(sectionElement);
//updateScrollButtonStateForSection(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);
invalidateLastRowPadding(itemsContainer);
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) => {
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
card.setAttribute('data-index', String(index));
fragment.appendChild(card);
});
}
replaceChildren(itemsContainer, fragment);
applyItemsLayoutState(sectionElement, layout, gapless);
ensureCardBorders(sectionElement);
attachSectionControlButtons(sectionConfig, sectionElement, items);
registerLazyImagesIn(itemsContainer);
invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller'));
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,
);
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);
invalidateLastRowPadding(content.querySelector('.itemsContainer'));
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) {
if (!sectionElement) return;
unobserveSkeletonShimmer(sectionElement);
sectionElement.dataset.enhanced = 'true';
if (typeof sectionConfig?._onSectionEnhanced === 'function') {
sectionConfig._onSectionEnhanced(sectionElement, sectionConfig);
}
}
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-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();
});
}, 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 } = {}) {
if (!sectionElement?.isConnected || !section?.config) return;
sectionElement.dataset.refreshing = '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');
if (items.length === 0) {
const holdMs = getDismissEmptySectionTimerMs();
if (holdMs > 0) {
dismissEmptyProgressiveSection(sectionElement, { holdMs });
} else {
sectionElement.remove();
}
return;
}
// Skeleton spotlight needs full createSpotlightSection wiring via replaceWith
if (isSkeleton && isSpotlight) {
const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
markSectionEnhanced(content, sectionConfig);
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);
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);
return;
}
// In-place inactive slides only (keepCurrentSlide); no full-section replaceWith
const spotlightContent = replaceSectionWithFreshCards(
sectionElement,
items,
sectionConfig,
dataItems,
revealSectionsSequentially,
);
markSectionEnhanced(spotlightContent, sectionConfig);
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);
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);
});
return;
}
const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
if (isButtonCardFormat(cardFormat) && match.type === 'replace') {
replaceItemsContainerContents(sectionElement, items, sectionConfig);
markSectionEnhanced(sectionElement, sectionConfig);
return;
}
fadeReplaceRowItems(sectionElement, items, sectionConfig, () => {
markSectionEnhanced(sectionElement, sectionConfig);
});
return;
}
// Skeleton cards without skeleton title: swap itemsContainer contents in place
replaceItemsContainerContents(sectionElement, items, sectionConfig);
markSectionEnhanced(sectionElement, sectionConfig);
}
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 };
section.result.isStalePromise?.then((isStale) => {
if (isStale) {
sectionElement.dataset.refreshing = 'true';
}
});
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;
// Keep refreshing indicator while a background revalidate is in flight
sectionElement.dataset.refreshing = hasPendingRefresh ? 'true' : 'false';
let items = result?.Items ?? result ?? [];
if (!Array.isArray(items)) items = [];
const applyOrDefer = (payload) => {
if (!deferUntilVisible) {
applyProgressiveEnhancementQueued(sectionElement, section, payload, enhanceOptions);
return;
}
const margin = sectionEnhanceObserverRootMargin || '30% 0px 30% 0px';
const inMargin = isSectionWithinEnhanceMargin(sectionElement, margin);
if (inMargin) {
applyProgressiveEnhancementQueued(sectionElement, section, payload, enhanceOptions);
} else {
// Data ready but section left the enhance margin — wait until it re-enters
delete sectionElement.dataset.enhanceScheduled;
sectionEnhancePending.set(sectionElement, {
section,
options: enhanceOptions,
pendingResult: payload,
});
observeSectionForEnhance(sectionElement);
}
};
if (skipStalePaint) {
// Do not paint stale Random cache or stash it in pending — wait for refreshPromise
sectionElement.dataset.refreshing = 'true';
} else if (items.length === 0) {
// Empty sections: dismiss immediately even if scrolled away
applyProgressiveEnhancementQueued(sectionElement, section, result, enhanceOptions);
return;
} else {
applyOrDefer(result);
}
if (!hasPendingRefresh) return;
refreshPromise
.then((fresh) => {
if (!sectionElement.isConnected) return;
sectionElement.dataset.refreshing = 'false';
// Prefer live DOM apply; if still pending observe, update pending snapshot
const pending = sectionEnhancePending.get(sectionElement);
if (pending && pending.pendingResult !== undefined) {
pending.pendingResult = fresh;
return;
}
if (skipStalePaint) {
applyOrDefer(fresh);
return;
}
// Refresh through same FIFO + margin gate as initial enhance
applyOrDefer(fresh);
})
.catch((err) => {
console.warn('[KefinTweaks CardBuilder] Background refresh failed:', section?.config?.id, err);
if (!sectionElement.isConnected) return;
sectionElement.dataset.refreshing = 'false';
// Random non-spotlight skipped stale paint — fall back so we are not stuck on skeletons
if (skipStalePaint) {
applyOrDefer(result);
}
});
})
.catch((err) => {
console.error('[KefinTweaks CardBuilder] Progressive enhance failed:', section?.config?.id, err);
if (!sectionElement.isConnected) return;
sectionElement.dataset.refreshing = '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() {
if (window.userHelper?.waitForLogin) {
await window.userHelper.waitForLogin();
}
startCardUserDataListener();
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');
})();