diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index fc1f874..a5c3c81 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -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; } diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 02a99da..78a1bed 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -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); + } } } diff --git a/scripts/sectionHelper.js b/scripts/sectionHelper.js index 3afbd83..eae1684 100644 --- a/scripts/sectionHelper.js +++ b/scripts/sectionHelper.js @@ -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 , 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;