mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Improve progressive section rendering
This commit is contained in:
1 parent
e4ddc9e645
commit
632d594bea
2 files changed
+460
-175
No files matched your search
+31
-21
@@ -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;
|
||||
}
|
||||
+429
-154
@@ -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<Element, IntersectionObserver>} */
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user