Fix scrolling on TV display mode, and improve interaction state of Spotlight sections

This commit is contained in:
HighImKevin
2026-10-08 13:16:46 -04:00
parent 9f4d08c86a
commit 4e93fcc284
3 changed files with 249 additions and 73 deletions
+12
View File
@@ -1459,6 +1459,7 @@
display: inline-block !important;
overflow: hidden;
max-width: calc(100% - max(env(safe-area-inset-left), 3.3%));
justify-self: start;
}
.sectionTitle-wrapper {
@@ -2325,6 +2326,17 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"
z-index: 1;
}
/* Row scroll lives on the slidee (JF .scrollSlider), not the outer frame */
.custom-scroller-container .custom-scroller,
.emby-scroller-container .custom-scroller {
overflow: hidden;
}
.custom-scroller-container .scrollSlider,
.emby-scroller-container .scrollSlider {
will-change: transform;
}
[dir="ltr"] .emby-scrollbuttons {
right: 0;
}
+219 -73
View File
@@ -7170,27 +7170,10 @@
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;
/** Inner slidee that receives translateX (Jellyfin .scrollSlider), not the outer frame. */
function getScrollSlidee(scroller) {
if (!scroller) return null;
return scroller.querySelector('.scrollSlider') || scroller.querySelector('.itemsContainer');
}
function getCurrentPosition(scroller) {
@@ -7210,6 +7193,134 @@
if (metrics) metrics.maxScroll = maxScroll;
}
/**
* Apply row scroll as translateX on the slidee (itemsContainer), matching Jellyfin.
* Clears any leftover transform on the outer scroller frame.
*/
function setSlideePosition(scroller, position, options = {}) {
if (!scroller) return 0;
const opts = options || {};
const slidee = getScrollSlidee(scroller);
if (opts.freshMax) {
const metrics = getScrollerMetrics(scroller);
if (metrics) {
metrics.maxScroll = null;
metrics.pad = null;
metrics.cardPad = null;
}
}
const max = Math.max(
0,
getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)),
);
const scrollPosition = Math.min(Math.max(position, 0), max);
const clampedPosition = scrollPosition > 20 ? scrollPosition : 0;
if (scroller.style.transform) scroller.style.transform = '';
if (scroller.style.transition) scroller.style.transition = '';
if (slidee) {
if (opts.animate) {
slidee.style.transition = 'transform 270ms ease-out';
slidee.style.willChange = 'transform';
} else {
slidee.style.transition = 'none';
}
slidee.style.transform = `translateX(-${clampedPosition}px)`;
}
setStoredScrollPosition(scroller, clampedPosition);
return clampedPosition;
}
/**
* Content-space X of a card relative to the scroller frame (undoes slidee translate).
*/
function getCardContentOffsetX(scroller, card) {
if (!scroller || !card) return 0;
const current = getCurrentPosition(scroller);
const scrollerRect = scroller.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
return cardRect.left - scrollerRect.left + current;
}
/**
* JF-style toCenter: put card center on frame center, clamped to [0, max].
* Early cards stay left-aligned (desired < 0 → 0); mid/late track center.
*/
function toCenterCard(scroller, card, options = {}) {
if (!scroller || !card || !scroller.contains(card)) return;
if (card.classList.contains('skeleton-card') || card.classList.contains('card-layout-dummy')) return;
const animate = options.animate !== false;
const metrics = getScrollerMetrics(scroller);
if (metrics) metrics.maxScroll = null;
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const contentOffset = getCardContentOffsetX(scroller, card);
const cardWidth = card.offsetWidth || card.getBoundingClientRect().width || 0;
// Center within the padded content box (same gutters as desktop snap)
const frameInnerWidth = Math.max(0, scroller.clientWidth - pad.left - Math.max(pad.right, pad.left));
const cardCenterInContent = contentOffset + cardWidth / 2 - pad.left;
const desired = cardCenterInContent - frameInnerWidth / 2;
const max = getMaxPositionFromPadding(scroller, pad, cardPad);
setSlideePosition(scroller, Math.min(Math.max(desired, 0), max), { animate: animate });
}
function attachTvFocusScroll(scroller) {
if (!scroller || scroller.dataset.tvFocusScroll === '1') return;
scroller.dataset.tvFocusScroll = '1';
let rafId = 0;
let pendingCard = null;
scroller.addEventListener(
'focusin',
(e) => {
const card = e.target?.closest?.('.card');
if (!card || !scroller.contains(card)) return;
if (card.classList.contains('skeleton-card') || card.classList.contains('card-layout-dummy')) return;
pendingCard = card;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
const target = pendingCard;
pendingCard = null;
if (target && scroller.contains(target)) {
toCenterCard(scroller, target, { animate: true });
}
});
},
true,
);
}
function getFullyVisibleCardCount(scroller, cards) {
const epsilon = 1;
const pad = readScrollerPadding(scroller);
const cardPad = readSampleCardPadding(scroller);
const endInset = Math.max(pad.right, pad.left);
const scrollerRect = scroller.getBoundingClientRect();
// Slidee is translated; frame is fixed — visible window is the scroller clip
const contentWidth = Math.max(0, scroller.clientWidth - pad.left - endInset);
const visibleLeft = scrollerRect.left + pad.left;
const visibleRight = visibleLeft + contentWidth;
let count = 0;
cards.forEach((card) => {
const cardRect = card.getBoundingClientRect();
if (cardRect.width <= 0) return;
const cardContentLeft = cardRect.left + cardPad.left;
const cardContentRight = cardRect.right - cardPad.right;
if (cardContentLeft >= visibleLeft - epsilon && cardContentRight <= visibleRight + epsilon) {
count++;
}
});
return count;
}
/**
* Max 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
@@ -7233,7 +7344,9 @@
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 slidee = getScrollSlidee(scroller);
const contentWidth = slidee ? slidee.scrollWidth : scroller.scrollWidth;
const base = Math.max(0, contentWidth - scroller.clientWidth + endInset);
const maxScroll = base + (cp.left || 0) + (cp.right || 0);
setMaxScrollPosition(scroller, maxScroll);
return maxScroll;
@@ -7252,6 +7365,7 @@
scroller.style.overscrollBehaviorX = 'contain';
scroller.style.overscrollBehaviorY = 'auto';
scroller.style.webkitOverflowScrolling = 'touch';
scroller.style.overflow = 'hidden';
setStoredScrollPosition(scroller, 0);
return scroller;
@@ -7263,7 +7377,8 @@
if (isEmbyItemscontainer) {
itemsContainer.setAttribute('is', 'emby-itemscontainer');
}
itemsContainer.className = `focuscontainer-x itemsContainer scrollSlider${!isMobile ? " animatedScrollX" : ""}`;
const tvClass = isTvLayout() ? ' itemsContainer-tv' : '';
itemsContainer.className = `focuscontainer-x itemsContainer scrollSlider${!isMobile ? " animatedScrollX" : ""}${tvClass}`;
itemsContainer.style.whiteSpace = 'nowrap';
return itemsContainer;
}
@@ -7327,31 +7442,26 @@
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 slidee = getScrollSlidee(scroller);
const clampedPosition = setSlideePosition(scroller, newPosition, {
animate: !!opts.animate,
freshMax: !!opts.freshMax,
});
const section = scroller.closest('.emby-scroller-container');
if (section && typeof applyScrollButtonState === 'function') {
if (opts.animate) {
const onTransitionEnd = () => {
scroller.removeEventListener('transitionend', onTransitionEnd);
if (opts.animate && slidee) {
const onTransitionEnd = (e) => {
if (e && e.target !== slidee) return;
slidee.removeEventListener('transitionend', onTransitionEnd);
applyScrollButtonState(section);
};
scroller.addEventListener('transitionend', onTransitionEnd);
slidee.addEventListener('transitionend', onTransitionEnd);
} else {
requestAnimationFrame(() => applyScrollButtonState(section));
}
}
return clampedPosition;
}
function getScrollCards() {
@@ -7362,17 +7472,15 @@
function getCardScrollLeft(card) {
// Align card content edge (border-box left + card paddingLeft) to the
// scroller's padded content gutter.
// scroller's padded content gutter. Content-space (undoes slidee translate).
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;
return getCardContentOffsetX(scroller, card) - 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.
* Frame is fixed; slidee translate moves cards — use live rects vs scroller clip.
* When the skin only pads the left, treat trailing inset as max(left, right).
*/
function getFullyVisibleCardIndices(cards) {
@@ -7382,9 +7490,8 @@
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 visibleLeft = scrollerRect.left + pad.left;
const visibleRight = visibleLeft + contentWidth;
cards.forEach((card, index) => {
const cardRect = card.getBoundingClientRect();
@@ -7843,18 +7950,23 @@
const scroller = createScrollerElement();
const itemsContainer = createItemsSliderElement();
// Add items to container
// Add items to container (TV → button cards via createCardElement)
const useParentCard = !!sectionConfig?.useParentCard;
const borderStyle = sectionConfig?.borderStyle || null;
items.forEach((item, index) => {
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
const card = createCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
scroller.appendChild(itemsContainer);
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
let scrollButtons = null;
if (isTvLayout()) {
attachTvFocusScroll(scroller);
} else {
scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
}
// Assemble the section
verticalSection.appendChild(sectionTitleContainer);
@@ -7863,7 +7975,7 @@
}
verticalSection.appendChild(scroller);
if (registerScrollButtons) {
if (registerScrollButtons && scrollButtons) {
registerScrollSectionScrollButtons(verticalSection);
}
return verticalSection;
@@ -8081,11 +8193,9 @@
) {
const tvLayout = isTvLayout();
// Create the main vertical section container (same structure as createScrollableContainer / TV path)
// Create the main vertical section container (same structure as createScrollableContainer)
const verticalSection = document.createElement('div');
verticalSection.className = tvLayout
? 'verticalSection emby-scroller-container'
: 'verticalSection emby-scroller-container custom-scroller-container';
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
// Persist the card format if provided (ensures consistency for random/updates)
if (cardFormat) {
@@ -8131,7 +8241,9 @@
ensureSkeletonShimmerSibling(itemsContainer);
verticalSection.appendChild(sectionTitleContainer);
if (!tvLayout) {
if (tvLayout) {
attachTvFocusScroll(scroller);
} else {
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
if (scrollButtons) {
verticalSection.appendChild(scrollButtons);
@@ -9075,28 +9187,41 @@
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')) {
const maxPosition = getMaxPositionFromPadding(
scroller,
readScrollerPadding(scroller),
readSampleCardPadding(scroller),
);
const scrollPosition = Math.min(Math.max(position, 0), maxPosition);
const clamped = scrollPosition > 20 ? scrollPosition : 0;
scroller.scrollTo({ left: clamped, behavior: animate ? 'smooth' : 'auto' });
setStoredScrollPosition(scroller, clamped);
} else {
scroller.style.transition = animate ? 'transform 400ms ease-out' : 'none';
scroller.style.transform = `translateX(-${clamped}px)`;
setStoredScrollPosition(scroller, clamped);
const slidee = getScrollSlidee(scroller);
setSlideePosition(scroller, position, { animate: !!animate, freshMax: true });
const section = scroller.closest('.emby-scroller-container');
if (section) {
if (animate && slidee) {
const onTransitionEnd = (e) => {
if (e && e.target !== slidee) return;
slidee.removeEventListener('transitionend', onTransitionEnd);
applyScrollButtonState(section);
};
slidee.addEventListener('transitionend', onTransitionEnd);
requestAnimationFrame(() => applyScrollButtonState(section));
} else {
applyScrollButtonState(section);
}
}
return;
}
const section = scroller.closest('.emby-scroller-container');
if (section) {
if (animate) {
const onTransitionEnd = () => {
scroller.removeEventListener('transitionend', onTransitionEnd);
applyScrollButtonState(section);
};
scroller.addEventListener('transitionend', onTransitionEnd);
// Native scrollX has no transitionend — refresh after a frame too
requestAnimationFrame(() => applyScrollButtonState(section));
} else {
applyScrollButtonState(section);
@@ -9137,9 +9262,7 @@
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;
const targetLeft = getCardContentOffsetX(scroller, cards[clampedIndex]) - pad.left + cardPad.left;
setScrollerPosition(scroller, targetLeft, animate);
const section = scroller.closest('.emby-scroller-container');
@@ -9332,7 +9455,11 @@
outgoing.forEach((card) => card.remove());
inserted.forEach(clearCardReconcileStyles);
itemsContainer.classList.remove('cardbuilder-row-animating');
if (scroller) scroller.style.transition = '';
if (scroller) {
scroller.style.transition = '';
const slidee = getScrollSlidee(scroller);
if (slidee) slidee.style.transition = '';
}
reindexSectionCards(itemsContainer);
invalidateLastRowPadding(itemsContainer);
patchCardsUserData(sectionElement, items);
@@ -9387,7 +9514,16 @@
ensureCardBorders(sectionElement);
attachSectionControlButtons(sectionConfig, sectionElement, items);
registerLazyImagesIn(itemsContainer);
invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller'));
const scroller = sectionElement.querySelector('.emby-scroller');
invalidateScrollerMetrics(scroller);
if (scroller && isTvLayout()) {
attachTvFocusScroll(scroller);
const focusedCard = document.activeElement?.closest?.('.card');
if (focusedCard && scroller.contains(focusedCard)) {
requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false }));
}
}
return sectionElement;
}
@@ -9501,7 +9637,17 @@
if (updateScrollButtons) {
const scroller = sectionElement.querySelector?.('.emby-scroller');
if (scroller) invalidateScrollerMetrics(scroller);
registerScrollSectionScrollButtons(sectionElement);
if (isTvLayout()) {
if (scroller) {
attachTvFocusScroll(scroller);
const focusedCard = document.activeElement?.closest?.('.card');
if (focusedCard && scroller.contains(focusedCard)) {
requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false }));
}
}
} else {
registerScrollSectionScrollButtons(sectionElement);
}
}
}
+18
View File
@@ -113,6 +113,16 @@
const escaped = typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(id)
: String(id).replace(/["\\]/g, '\\$&');
// Do not upgrade skeleton/empty titles into links — that eats .skeleton-section-title
// and leaves an empty <a>, so enhance skips rebuildSectionTitleInPlace.
// viewMoreUrl stays on sectionConfig for rebuild / appendSectionTitleContent.
const titleReadyForLinkUpgrade = (el) => {
if (!el) return false;
if (el.querySelector?.('.skeleton-section-title, .skeleton-text-line')) return false;
return !!(el.textContent && el.textContent.trim());
};
document.querySelectorAll(
`.verticalSection[data-section-id="${escaped}"], .spotlight-section[data-section-id="${escaped}"]`
).forEach((sectionElement) => {
@@ -121,12 +131,19 @@
sectionElement.querySelector('.spotlight-section-title a.emby-tab-button');
if (existingLink) {
existingLink.href = viewMoreUrl;
// Recover empty links left by an earlier race (href only, no title text)
if (!existingLink.textContent?.trim() && sectionConfig.name) {
const nestedTitle = existingLink.querySelector('.sectionTitle.sectionTitle-cards');
if (nestedTitle) nestedTitle.textContent = sectionConfig.name;
else existingLink.textContent = sectionConfig.name;
}
return;
}
const plainTitle = sectionElement.querySelector(
'.sectionTitleContainer .sectionTitle.sectionTitle-cards:not(a)'
);
if (plainTitle) {
if (!titleReadyForLinkUpgrade(plainTitle)) return;
const link = document.createElement('a');
link.className = 'sectionTitle sectionTitle-cards sectionTitle-link emby-button';
link.href = viewMoreUrl;
@@ -138,6 +155,7 @@
'.spotlight-section-title .emby-tab-button:not(a)'
);
if (spotTitle) {
if (!titleReadyForLinkUpgrade(spotTitle)) return;
const link = document.createElement('a');
link.className = 'emby-tab-button emby-tab-button-active emby-button';
link.href = viewMoreUrl;