diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 00d8dd6..155476d 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -130,9 +130,10 @@ /* Single backdrop (when tileCount is 1 or only one image) */ .spotlight-background-single { position: absolute; - inset: 0; z-index: 1; overflow: hidden; + width: 100%; + height: 100%; } .spotlight-background-single img { @@ -157,16 +158,16 @@ @keyframes kenBurnsZoomIn { from { - transform: scale3d(1, 1, 0); + transform: scale3d(1, 1, 1); } to { - transform: scale3d(1.12, 1.12, 0.1); + transform: scale3d(1.12, 1.12, 1); } } @keyframes kenBurnsZoomOut { from { - transform: scale3d(1.15, 1.15, 0.1); + transform: scale3d(1.15, 1.15, 1); } to { transform: scale3d(1.02, 1.02, 1); @@ -176,16 +177,16 @@ /* Full-screen: start images pre-scaled so they extend past edges from the start (no hard edge before zoom) */ @keyframes kenBurnsZoomInFullscreen { from { - transform: scale3d(1.08, 1.08, 0); + transform: scale3d(1.08, 1.08, 1); } to { - transform: scale3d(1.12, 1.12, 0.1); + transform: scale3d(1.12, 1.12, 1); } } @keyframes kenBurnsZoomOutFullscreen { from { - transform: scale3d(1.15, 1.15, 0.1); + transform: scale3d(1.15, 1.15, 1); } to { transform: scale3d(1.08, 1.08, 1); @@ -225,7 +226,7 @@ @keyframes kenBurnsPanRight { from { - transform: scale3d(1.1, 1.1, 0) translateX(-5%); + transform: scale3d(1.1, 1.1, 1) translateX(-5%); } to { transform: scale3d(1.1, 1.1, 1) translateX(5%); @@ -234,7 +235,7 @@ @keyframes kenBurnsPanLeft { from { - transform: scale3d(1.1, 1.1, 0) translateX(5%); + transform: scale3d(1.1, 1.1, 1) translateX(5%); } to { transform: scale3d(1.1, 1.1, 1) translateX(-5%); @@ -243,7 +244,7 @@ @keyframes kenBurnsPanUp { from { - transform: scale3d(1.1, 1.1, 0) translateY(5%); + transform: scale3d(1.1, 1.1, 1) translateY(5%); } to { transform: scale3d(1.1, 1.1, 1) translateY(-5%); @@ -252,7 +253,7 @@ @keyframes kenBurnsDiagonal { from { - transform: scale3d(1.08, 1.08, 0) translate(-5%, 5%); + transform: scale3d(1.08, 1.08, 1) translate(-5%, 5%); } to { transform: scale3d(1.13, 1.13, 1) translate(5%, -5%); @@ -853,7 +854,7 @@ opacity: 1; } -.spotlight-section:is([data-section-id]):not(.hide) .spotlight-nav-container { +.spotlight-section:not(.hide)[data-section-id] .spotlight-nav-container { position: absolute !important; display: flex; gap: 8px; @@ -1740,7 +1741,7 @@ } .layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-section-title-container { - top: 4rem; + top: 5rem; } .layout-mobile .spotlight-section .spotlight-section-title-container { @@ -1791,13 +1792,18 @@ .spotlight-section .spotlight-buttons-container.mainDetailButtons .btnPlay { right: 0px !important; margin-top: 0 !important; + margin-left: 0 !important; + margin-bottom: 0 !important; + margin-right: 1em !important; min-width: unset; flex: unset !important; - margin-right: 0 !important; } - +.spotlight-buttons-container > * { + margin: 0 !important; +} .spotlight-section .spotlight-buttons-container.mainDetailButtons { margin: 0px !important; + padding: 0px !important; } .spotlight-section:not(.hide) .headerTabs, @@ -2338,9 +2344,6 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" .layout-mobile .emby-scroller-container:has(.overflowBackdropCard:nth-child(3)) button.show-all-button { display: flex; } -.emby-scroller-container:has([data-custom-card="true"]) .section-refresh-button { - display: none; -} /* Section-level card title presentation */ .verticalSection .cardText, @@ -2593,7 +2596,7 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" .emby-scroller-container[data-card-title-position="overlay-top-center"] .cardTextStack, .emby-scroller-container[data-card-title-position="overlay-top-right"] .cardTextStack { /* Solid at top → fade out toward bottom */ - padding: 0.6em 1em 1.75em; + padding: 0.9em 1.1em; border-top-left-radius: var(--kefin-card-radius, 0); border-top-right-radius: var(--kefin-card-radius, 0); overflow: hidden; @@ -2606,7 +2609,7 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" .emby-scroller-container[data-card-title-position="overlay-bottom-center"] .cardTextStack, .emby-scroller-container[data-card-title-position="overlay-bottom-right"] .cardTextStack { /* Solid at bottom → fade out toward top */ - padding: 1.75em 1em 0.85em; + padding: 0.9em 1.1em; border-bottom-left-radius: var(--kefin-card-radius, 0); border-bottom-right-radius: var(--kefin-card-radius, 0); overflow: hidden; @@ -2619,7 +2622,7 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" .emby-scroller-container[data-card-title-position="overlay-center-center"] .cardTextStack, .emby-scroller-container[data-card-title-position="overlay-center-right"] .cardTextStack { /* Solid at bottom → fade out toward top */ - padding: 0 1em; + padding: 0 1.1em; overflow: hidden; } @@ -4120,3 +4123,10 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" margin: 0 0.25em; padding: 0.25em 0.5em; } + +.custom-scroller-container .sectionCaption.sectionTitleTextButton:not(.padded-left) { + padding-top: 0 !important; + padding-bottom: 0 !important; + margin-top: 0 !important; + margin-bottom: 0 !important; +} diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index d75cb3d..18197e9 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -15,7 +15,7 @@ const state = { defaultCardBackgroundNumber: 1, - useBlurhash: false + useBlurhash: true } function getSpotlightWindowIndices(currentIndex, totalLength) { @@ -42,6 +42,20 @@ 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'], @@ -298,7 +312,7 @@ circle.setAttribute('r', String(r)); frag.appendChild(circle); }); - g.replaceChildren(frag); + replaceChildren(g, frag); }); } @@ -346,7 +360,7 @@ } disconnectRetroPosterBulbs(host); host.dataset.style = next; - host.replaceChildren(); + replaceChildren(host); if (!needsChildren) return; if (isVintage) { @@ -2685,6 +2699,7 @@ } function shouldSkipBlurhashDecode() { + if (!state.useBlurhash) return true; try { if (navigator.connection && navigator.connection.saveData === true) return true; } catch (e) { /* ignore */ } @@ -3434,6 +3449,7 @@ // 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') { @@ -6695,80 +6711,237 @@ // Smart Lazy Image Loading with Global Observers let lazyImageObserver = null; let lazyMutationObserver = null; + /** @type {WeakMap} */ + const lazyObserversByScroller = new WeakMap(); + const lazyImageApplyQueue = []; + let lazyImageApplyRaf = null; + const LAZY_IMAGE_APPLY_PER_FRAME = 3; // 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 = (() => { + try { + const ctx = canvas.getContext('2d'); + if (!ctx) return false; + const sample = ctx.getImageData(0, 0, 1, 1).data; + return sample[3] > 0; + } catch (e) { + return false; + } + })(); + if (!hasPixels) { + 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 drainLazyImageApplyQueue() { + lazyImageApplyRaf = null; + let n = 0; + while (n < LAZY_IMAGE_APPLY_PER_FRAME && lazyImageApplyQueue.length) { + const job = lazyImageApplyQueue.shift(); + if (!job) continue; + applyLazyImageToContainer(job.container, job.url, job.isSvg); + n++; + } + if (lazyImageApplyQueue.length) { + lazyImageApplyRaf = requestAnimationFrame(drainLazyImageApplyQueue); + } + } + + function enqueueLazyImageApply(container, url, isSvg) { + lazyImageApplyQueue.push({ container, url, isSvg }); + if (lazyImageApplyRaf == null) { + lazyImageApplyRaf = requestAnimationFrame(drainLazyImageApplyQueue); + } + } + + 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 = async () => { + if (cardImageContainer.getAttribute('data-src') !== imageUrl) return; + if (!cardImageContainer.isConnected) return; + try { + if (typeof img.decode === 'function') { + await img.decode(); + } + } catch (e) { /* apply anyway */ } + if (cardImageContainer.getAttribute('data-src') !== imageUrl) return; + if (!cardImageContainer.isConnected) return; + // Unloaded or cancelled while decoding + if (!cardImageContainer.hasAttribute('data-loading')) return; + const isSvg = /\.svg(?:[?#]|$)/i.test(imageUrl); + enqueueLazyImageApply(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) { + const mobile = isMobileLayout(); + return new IntersectionObserver(handleLazyImageIntersection, { + root: root || null, + threshold: mobile ? 0 : 0.1, + rootMargin: mobile ? '0px' : '800px' + }); + } + /** * 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); + } - // Mobile: tighter prefetch so image downloads don't stampede during discovery paint - const rootMargin = isMobileLayout() ? '300px' : '800px'; - - const observerOptions = { - threshold: 0.1, // Trigger when 10% of element is visible - rootMargin - }; - - lazyImageObserver = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (!entry.isIntersecting) return; - const cardImageContainer = entry.target; - const imageUrl = cardImageContainer.getAttribute('data-src'); - if (!imageUrl) return; - if (cardImageContainer.hasAttribute('data-loading')) return; + function observeLazyImage(cardImageContainer) { + if (!cardImageContainer || !cardImageContainer.hasAttribute('data-src')) return; + initLazyImageObserver(); - cardImageContainer.setAttribute('data-loading', 'true'); - const img = new Image(); - img.onload = () => { - const isSvg = /\.svg(?:[?#]|$)/i.test(imageUrl); - 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-src'); - cardImageContainer.removeAttribute('data-loading'); - 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'); - } - lazyImageObserver.unobserve(cardImageContainer); - }; - img.onerror = () => { - cardImageContainer.removeAttribute('data-loading'); - cardImageContainer.removeAttribute('data-src'); - 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'); - } - lazyImageObserver.unobserve(cardImageContainer); - }; - img.src = imageUrl; - }); - }, observerOptions); + 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); } /** @@ -6790,14 +6963,14 @@ node.classList && node.classList.contains('cardImageContainer') && node.hasAttribute('data-src')) { - lazyImageObserver.observe(node); + observeLazyImage(node); } // Check for cardImageContainer descendants if (node.nodeType === 1 && node.querySelectorAll) { const lazyImages = node.querySelectorAll('.cardImageContainer[data-src]'); lazyImages.forEach(img => { - lazyImageObserver.observe(img); + observeLazyImage(img); }); } @@ -6882,7 +7055,7 @@ const existingLazyImages = document.querySelectorAll('.cardImageContainer[data-src]'); existingLazyImages.forEach(img => { - lazyImageObserver.observe(img); + observeLazyImage(img); }); document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas); @@ -7398,7 +7571,7 @@ }); } - itemsContainer.replaceChildren(fragment); + replaceChildren(itemsContainer, fragment); applyItemsLayoutState(sectionElement, layout, gapless); ensureCardBorders(sectionElement); @@ -7582,9 +7755,148 @@ return result.dataPromise || null; } - function setupSectionProgressiveEnhancement(sectionElement, section, { revealSectionsSequentially = false } = {}) { + 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; + } + + // Discovery pending (and any skeleton with placeholder title) needs a full rebuild for title/viewMore/caption + if (isSkeleton && (isSpotlight || hasSkeletonTitle)) { + const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); + markSectionEnhanced(content, 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; + } + // Soft-replace without fading the section to black (avoids visible→black→image blink on load) + 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; + } + + if (isSkeleton && isSpotlight) { + const spotlightContent = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); + markSectionEnhanced(spotlightContent, sectionConfig); + return; + } + + 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'; @@ -7592,6 +7904,8 @@ const dataPromise = getSectionDataPromise(section.result); if (!dataPromise) return; + const enhanceOptions = { revealSectionsSequentially, deferUntilVisible }; + section.result.isStalePromise?.then(isStale => { if (isStale) { sectionElement.dataset.refreshing = 'true'; @@ -7599,100 +7913,39 @@ }); dataPromise.then(result => { + if (!sectionElement.isConnected) return; + sectionElement.dataset.refreshing = 'false'; - 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'); - + // Empty sections: dismiss immediately even if scrolled away if (items.length === 0) { - const holdMs = getDismissEmptySectionTimerMs(); - if (holdMs > 0) { - dismissEmptyProgressiveSection(sectionElement, { holdMs }); - } else { - sectionElement.remove(); - } + applyProgressiveEnhancement(sectionElement, section, result, enhanceOptions); return; } - // Discovery pending (and any skeleton with placeholder title) needs a full rebuild for title/viewMore/caption - if (isSkeleton && (isSpotlight || hasSkeletonTitle)) { - const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); - markSectionEnhanced(content, sectionConfig); + const margin = sectionEnhanceObserverRootMargin || '30% 0px 30% 0px'; + if (!deferUntilVisible || isSectionWithinEnhanceMargin(sectionElement, margin)) { + applyProgressiveEnhancement(sectionElement, section, result, enhanceOptions); 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; - } - // Soft-replace without fading the section to black (avoids visible→black→image blink on load) - 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; - } - - if (isSkeleton && isSpotlight) { - const spotlightContent = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially); - markSectionEnhanced(spotlightContent, sectionConfig); - return; - } - - replaceItemsContainerContents(sectionElement, items, sectionConfig); - markSectionEnhanced(sectionElement, sectionConfig); + // Data ready but section left the enhance margin — wait until it re-enters + delete sectionElement.dataset.enhanceScheduled; + sectionEnhancePending.set(sectionElement, { + section, + options: enhanceOptions, + pendingResult: result + }); + observeSectionForEnhance(sectionElement); + }).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; + applyProgressiveEnhancement(sectionElement, section, { Items: [] }, enhanceOptions); }); } @@ -7721,6 +7974,12 @@ if (!pending) return; sectionEnhancePending.delete(target); + + if (pending.pendingResult !== undefined) { + applyProgressiveEnhancement(target, pending.section, pending.pendingResult, pending.options || {}); + return; + } + setupSectionProgressiveEnhancement(target, pending.section, pending.options); }); }, { @@ -7752,7 +8011,10 @@ return; } - const enhanceOptions = { revealSectionsSequentially }; + const enhanceOptions = { + revealSectionsSequentially, + deferUntilVisible: !!enhanceOnVisible + }; if (!enhanceOnVisible) { setupSectionProgressiveEnhancement(sectionElement, section, enhanceOptions); @@ -7823,7 +8085,20 @@ await window.userHelper.waitForLogin(); } startCardUserDataListener(); - state.useBlurhash = localStorage.getItem(`${ApiClient.getCurrentUserId()}-blurhash`) === 'true' || false; + 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();