diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 2a46afc..bb0d1f0 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -868,6 +868,14 @@ display: none !important; } +/* TV: no hover tooltips; keep them out of the focus tree */ +.layout-tv .spotlight-truncated-list-tooltip, +.layout-tv .spotlight-overview-tooltip { + display: none !important; + pointer-events: none !important; + visibility: hidden !important; +} + .spotlight-section:not(.hide)[data-section-id] > .spotlight-banner-container > .spotlight-nav-container { position: absolute !important; display: flex; @@ -2114,6 +2122,7 @@ flex-direction: column; justify-content: end; align-items: center; + grid-gap: 3em; } .layout-mobile .spotlight-overview-section, @@ -4199,4 +4208,60 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" } [data-section-id="liveTv-on-now"] .card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(3) { display: block; +} + +/* TV layout: native emby-scroller + button cards (match Jellyfin layout-tv rows) */ +.layout-tv .emby-scroller-container > .emby-scroller.no-padding { + overflow: hidden; +} + +.layout-tv .itemsContainer-tv > button.card { + -webkit-appearance: none; + appearance: none; + display: inline-block; + border: 0; + background: transparent; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: inherit; + vertical-align: top; + cursor: pointer; +} + +/* Section controls are hover-only on desktop; TV has no hover */ +.layout-tv .emby-scroller-container .section-refresh-button, +.layout-tv .emby-scroller-container .section-configure-button, +.layout-tv .emby-scroller-container .section-controls-more, +.layout-tv .spotlight-section .section-refresh-button, +.layout-tv .spotlight-section .section-configure-button { + opacity: 0.65; + visibility: visible; + pointer-events: auto; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardScalable::after, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable::after { + pointer-events: none !important; +} + +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack a, +.verticalSection[data-card-title-position^="overlay-"] .cardTextStack button, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack a, +.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack button { + pointer-events: auto !important; + position: relative; + z-index: 1; +} + +.spotlight-section-title-container { + position: absolute; + z-index: 5; + isolation: isolate; + transform: translateZ(0); +} + +.emby-scroller-container[data-section-id] { + z-index: 1; } \ No newline at end of file diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 82863a3..5da8c9e 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -1648,6 +1648,7 @@ const selectButton = document.createElement('button'); selectButton.type = 'button'; selectButton.className = 'emby-button raised multi-query-picker-button'; + if (isTvLayout()) selectButton.classList.add('show-focus'); selectButton.textContent = getMultiQueryPickerButtonLabel(sectionConfig); selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;'; selectButton.setAttribute('aria-label', 'Select query'); @@ -1666,27 +1667,22 @@ titleContainer.appendChild(selectButton); } - let activePopover = null; - let activePopoverCloseHandler = null; - const closePopover = () => { - if (activePopoverCloseHandler) { - document.removeEventListener('click', activePopoverCloseHandler); - activePopoverCloseHandler = null; - } - if (activePopover) { - activePopover.remove(); - activePopover = null; - } - }; + const MULTI_QUERY_MODAL_ID = 'kefin-multi-query-picker'; const multiQueryPopoverCss = ` - .multiQueryPopover { - background-color: rgba(0, 0, 0, 0.95); - position: absolute !important; + .kefin-multi-query-modal { + background-color: rgba(0, 0, 0, 0.95) !important; z-index: 1001; - display: block; - overflow-y: auto; - box-sizing: border-box; + min-width: 12rem; + max-width: min(90vw, 320px); + max-height: min(60vh, 420px); + overflow: hidden; + padding: 0 !important; + } + .kefin-multi-query-modal [data-name="kefin-modal-content"] { + padding: 0.25em 0 !important; + overflow-y: auto !important; + max-height: min(60vh, 420px); } `; let multiQueryStyleEl = document.getElementById('kefinTweaks-multiQueryPopover-style'); @@ -1701,130 +1697,115 @@ e.stopPropagation(); e.preventDefault(); - if (activePopover) { - closePopover(); + if (!window.ModalSystem?.create) return; + if (window.ModalSystem.isOpen?.(MULTI_QUERY_MODAL_ID)) { + window.ModalSystem.close(MULTI_QUERY_MODAL_ID); return; } - const popover = document.createElement('div'); - popover.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup'; + const list = document.createElement('div'); + list.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup'; const selectedIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false }); let selectedItemElement = null; + const selectQuery = async (index) => { + window.ModalSystem.close(MULTI_QUERY_MODAL_ID); + if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) { + return; + } + + sectionConfig._selectedQueryIndex = index; + + const sectionIdAttr = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(String(sectionConfig.id)) + : String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + let activeSectionEl = + document.querySelector( + `.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`, + ) || sectionElement; + + const livePicker = activeSectionEl.querySelector('.multi-query-picker-button'); + if (livePicker) { + livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig); + } + updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig)); + updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig)); + activeSectionEl.dataset.refreshing = 'true'; + + try { + const loaded = await loadActiveSectionQueryItems(sectionConfig); + let newContent = null; + + if (loaded.canPaintCached) { + newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items); + } else { + activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl); + const freshItems = await loaded.itemsPromise; + newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems); + } + + if (typeof sectionConfig._onMultiQueryChange === 'function') { + sectionConfig._onMultiQueryChange(index, sectionConfig, newContent); + } + } catch (error) { + console.error('[KefinTweaks CardBuilder] Error switching query:', error); + const live = + document.querySelector( + `.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`, + ) || activeSectionEl; + if (live) live.dataset.refreshing = 'false'; + } + }; + queries.forEach((query, index) => { - const itemElement = document.createElement('div'); - itemElement.className = 'kefinTweaks-popover-item detailsGroupItem'; + const itemElement = document.createElement('button'); + itemElement.type = 'button'; + itemElement.className = 'kefinTweaks-popover-item detailsGroupItem emby-button button-flat'; if (index === selectedIndex) { - itemElement.classList.add('selected'); + itemElement.classList.add('selected', 'is-active'); selectedItemElement = itemElement; } itemElement.textContent = getQueryDisplayName(query, index); - itemElement.addEventListener('click', async () => { - closePopover(); - if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) { - return; - } - - sectionConfig._selectedQueryIndex = index; - - const sectionIdAttr = - typeof CSS !== 'undefined' && typeof CSS.escape === 'function' - ? CSS.escape(String(sectionConfig.id)) - : String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - - // Prefer the live section node (may have been replaced by a prior switch). - let activeSectionEl = - document.querySelector( - `.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`, - ) || sectionElement; - - const livePicker = activeSectionEl.querySelector('.multi-query-picker-button'); - if (livePicker) { - livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig); - } - updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig)); - updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig)); - activeSectionEl.dataset.refreshing = 'true'; - - try { - const loaded = await loadActiveSectionQueryItems(sectionConfig); - let newContent = null; - - if (loaded.canPaintCached) { - newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items); - } else { - // Skeletons before awaiting network — cache check already finished above. - activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl); - const freshItems = await loaded.itemsPromise; - newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems); - } - - if (typeof sectionConfig._onMultiQueryChange === 'function') { - sectionConfig._onMultiQueryChange(index, sectionConfig, newContent); - } - } catch (error) { - console.error('[KefinTweaks CardBuilder] Error switching query:', error); - const live = - document.querySelector( - `.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`, - ) || activeSectionEl; - if (live) live.dataset.refreshing = 'false'; - } + itemElement.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + selectQuery(index); }); - popover.appendChild(itemElement); + list.appendChild(itemElement); }); - // Mount on body with absolute + document coords so the menu scrolls with the button - document.body.appendChild(popover); - const margin = 8; - const edgeMargin = 16; - const gap = 8; - const scrollX = window.scrollX || window.pageXOffset || 0; - const scrollY = window.scrollY || window.pageYOffset || 0; - const buttonRect = selectButton.getBoundingClientRect(); - const popoverWidth = popover.offsetWidth || popover.getBoundingClientRect().width || 200; - const spaceBelow = window.innerHeight - buttonRect.bottom - margin; - const spaceAbove = buttonRect.top - margin; - const placeBelow = spaceBelow >= Math.min(spaceAbove, 160) || spaceBelow >= spaceAbove; - const available = placeBelow ? spaceBelow : spaceAbove; - const maxHeight = Math.max(120, Math.min(window.innerHeight * 0.6, available - gap)); - - let leftViewport = buttonRect.left; - leftViewport = Math.min( - Math.max(edgeMargin, leftViewport), - Math.max(edgeMargin, window.innerWidth - popoverWidth - edgeMargin), - ); - - popover.style.position = 'absolute'; - popover.style.left = `${leftViewport + scrollX}px`; - popover.style.maxHeight = `${maxHeight}px`; - popover.style.bottom = 'auto'; - if (placeBelow) { - popover.style.top = `${buttonRect.bottom + gap + scrollY}px`; - } else { - const height = Math.min(popover.offsetHeight || maxHeight, maxHeight); - popover.style.top = `${Math.max(scrollY + margin, buttonRect.top - gap - height + scrollY)}px`; - } - activePopover = popover; - - if (selectedItemElement) { - setTimeout(() => { - selectedItemElement.scrollIntoView({ - behavior: 'instant', - block: 'nearest', - inline: 'nearest', - }); - }, 10); - } - - activePopoverCloseHandler = (ev) => { - if (!popover.contains(ev.target) && !selectButton.contains(ev.target)) { - closePopover(); - } - }; - setTimeout(() => { - document.addEventListener('click', activePopoverCloseHandler); - }, 100); + window.ModalSystem.create({ + id: MULTI_QUERY_MODAL_ID, + title: null, + content: list, + footer: null, + showCloseButton: false, + closeOnBackdrop: true, + closeOnEscape: true, + fixedSize: false, + returnFocus: selectButton, + initialFocus: selectedItemElement, + anchor: selectButton, + dialogClassName: 'kefin-multi-query-modal', + onOpen: (modal) => { + if (modal.backdrop) modal.backdrop.style.background = 'transparent'; + if (modal.dialogContainer) modal.dialogContainer.style.pointerEvents = 'none'; + if (modal.dialog) modal.dialog.style.pointerEvents = 'auto'; + if (modal.dialogContent) { + modal.dialogContent.style.padding = '0.25em 0'; + modal.dialogContent.style.overflowY = 'auto'; + modal.dialogContent.style.minHeight = '0'; + modal.dialogContent.style.flex = '0 1 auto'; + } + if (selectedItemElement) { + requestAnimationFrame(() => { + selectedItemElement.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + } + }, + }); }); } @@ -1846,7 +1827,8 @@ const moreButton = document.createElement('button'); moreButton.type = 'button'; - moreButton.className = 'section-controls-more material-icons more_vert'; + moreButton.className = 'section-controls-more material-icons more_vert paper-icon-button-light'; + if (isTvLayout()) moreButton.classList.add('show-focus'); moreButton.title = 'More'; moreButton.setAttribute('aria-label', 'More'); @@ -2189,7 +2171,7 @@ useParentCard = false, borderStyle = null, ) { - return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle); + return createCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle); }, /** Push-register lazy images under an attached root (parallel native path). */ @@ -4216,18 +4198,146 @@ } /** - * Renders a spotlight section (Netflix-style slim banner carousel) - * @param {Array} items - Array of Jellyfin item objects - * @param {string} title - Title for the spotlight section - * @param {Object} options - Options for the spotlight carousel - * @param {boolean} options.autoPlay - Auto-cycle through items (default: true) - * @param {number} options.interval - Auto-play interval in ms (default: 10000) - * @param {boolean} options.showSlideState - Show slide state container (dots or numeric) (default: true) - * @param {boolean} options.showDots - When showSlideState is true: true = dots (max 5), false = numeric "N / X" (default: true) - * @param {boolean} options.showNavButtons - Show prev/next buttons (default: true) - * @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen) - * @returns {HTMLElement} - The constructed spotlight container + * TV layout detection (Jellyfin sets layout-tv on ). + * Evaluated at call time so Display Mode changes are respected after re-render. */ + function isTvLayout() { + return document.documentElement.classList.contains('layout-tv'); + } + + /** + * Prefer TV button cards when layout-tv is active; otherwise desktop cards. + */ + function createCardElement( + item, + overflowCard = false, + cardFormat = null, + customFooterText = null, + useParentCard = false, + borderStyle = null, + ) { + if (isTvLayout()) { + return buildTvCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle); + } + return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle); + } + + /** + * Convert a desktop card DOM tree into a Jellyfin TV focusable button card. + * Reuses desktop image/footer construction, then strips hover overlays and linked titles. + */ + function convertDesktopCardToTvButton(sourceCard, item) { + const button = document.createElement('button'); + button.type = 'button'; + + Array.from(sourceCard.attributes).forEach((attr) => { + if (attr.name === 'class') return; + button.setAttribute(attr.name, attr.value); + }); + + const classes = String(sourceCard.className || '') + .split(/\s+/) + .filter((c) => c && c !== 'card-hoverable'); + if (!classes.includes('show-focus')) classes.push('show-focus'); + if (!classes.includes('show-animation')) classes.push('show-animation'); + if (!classes.includes('itemAction')) classes.push('itemAction'); + button.className = classes.join(' '); + button.setAttribute('data-action', 'link'); + button.setAttribute('aria-label', item?.Name || sourceCard.getAttribute('aria-label') || 'Unknown'); + + while (sourceCard.firstChild) { + button.appendChild(sourceCard.firstChild); + } + + const imageLink = button.querySelector('a.cardImageContainer'); + if (imageLink) { + const imageDiv = document.createElement('div'); + Array.from(imageLink.attributes).forEach((attr) => { + if (attr.name === 'href') return; + imageDiv.setAttribute(attr.name, attr.value); + }); + while (imageLink.firstChild) { + imageDiv.appendChild(imageLink.firstChild); + } + imageLink.replaceWith(imageDiv); + } + + button.querySelectorAll('.cardOverlayContainer').forEach((el) => el.remove()); + + button.querySelectorAll('a.textActionButton').forEach((link) => { + const text = link.textContent || ''; + const bdi = link.closest('bdi'); + if (bdi) { + bdi.textContent = text; + } else { + link.replaceWith(document.createTextNode(text)); + } + }); + + // Match Jellyfin TV backdrop image request size when easy + const imageEl = button.querySelector('.cardImageContainer[data-src]'); + if (imageEl) { + const src = imageEl.getAttribute('data-src') || ''; + if (src.includes('fillHeight=267') && src.includes('fillWidth=474')) { + imageEl.setAttribute( + 'data-src', + src + .replace('fillHeight=267', 'fillHeight=335') + .replace('fillWidth=474', 'fillWidth=596'), + ); + } + } + + // Custom / external cards: overlay link was removed — navigate on activate + const customUrl = item?.cardUrl; + if (customUrl) { + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (customUrl.startsWith('#') || customUrl.startsWith('/')) { + window.location.href = customUrl; + } else { + window.open(customUrl, '_blank', 'noopener,noreferrer'); + } + }); + } + + return button; + } + + /** + * Dedicated TV card builder (button root, no overlays, plain titles). + * Same argument shape as createJellyfinCardElement. + */ + function buildTvCardElement( + item, + overflowCard = false, + cardFormat = null, + customFooterText = null, + useParentCard = false, + borderStyle = null, + ) { + const normalizedFormat = (cardFormat || '').toLowerCase() || null; + if (normalizedFormat === 'button') { + const libraryButton = createLibraryButtonElement(item); + libraryButton.classList.add('show-focus', 'show-animation', 'itemAction'); + if (!libraryButton.getAttribute('data-action')) { + libraryButton.setAttribute('data-action', 'link'); + } + return libraryButton; + } + + const desktopCard = createJellyfinCardElement( + item, + overflowCard, + cardFormat, + customFooterText, + useParentCard, + borderStyle, + ); + return convertDesktopCardToTvButton(desktopCard, item); + } + /** * Resolve spotlight layout/size/tileCount from options (incl. legacy fullScreen). * Shared by live create, skeleton create, and configure/defaults callers. @@ -4332,6 +4442,7 @@ { const sectionTitleContainer = document.createElement('div'); sectionTitleContainer.className = 'spotlight-section-title-container'; + if (isTvLayout()) sectionTitleContainer.classList.add('focuscontainer-x'); if (title) { let sectionTitleEl; @@ -4342,8 +4453,10 @@ titleLink.textContent = title; titleLink.title = 'See All'; titleLink.style.textDecoration = 'none'; + if (isTvLayout()) titleLink.classList.add('show-focus'); if (typeof viewMoreUrl === 'function') { + if (isTvLayout()) titleLink.setAttribute('tabindex', '0'); titleLink.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); @@ -4673,6 +4786,7 @@ nameElement.className = 'spotlight-person-link'; nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`; nameElement.textContent = itemData.name; + if (isTvLayout()) nameElement.classList.add('show-focus'); nameElement.addEventListener('click', (e) => { e.stopPropagation(); }); @@ -4681,12 +4795,14 @@ nameElement.className = 'spotlight-genre-link'; nameElement.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`; nameElement.textContent = itemData.name; + if (isTvLayout()) nameElement.classList.add('show-focus'); nameElement.addEventListener('click', (e) => e.stopPropagation()); } else if (useStudioLinks && itemData.id) { nameElement = document.createElement('a'); nameElement.className = 'spotlight-studio-link'; nameElement.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`; nameElement.textContent = itemData.name; + if (isTvLayout()) nameElement.classList.add('show-focus'); nameElement.addEventListener('click', (e) => e.stopPropagation()); } else { nameElement = document.createElement('span'); @@ -4707,8 +4823,8 @@ } }); - // Tooltip when more items exist (full list on hover) - if (hasMore) { + // Tooltip when more items exist (full list on hover) — skip on TV (no hover; links steal focus) + if (hasMore && !isTvLayout()) { // Tooltip: full list as vertical stack with clickable links (no layout shift) const tooltip = document.createElement('div'); tooltip.className = 'spotlight-truncated-list-tooltip'; @@ -4901,11 +5017,13 @@ // Buttons container const buttonsContainer = document.createElement('div'); buttonsContainer.className = 'spotlight-buttons-container mainDetailButtons'; + if (isTvLayout()) buttonsContainer.classList.add('focuscontainer-x'); // Play button (material icon span button) const playButton = document.createElement('button'); playButton.className = 'emby-button raised button-flat btnPlay detailButton'; playButton.title = 'Play'; + if (isTvLayout()) playButton.classList.add('show-focus'); const playIcon = document.createElement('span'); playIcon.className = 'material-icons'; playIcon.textContent = 'play_arrow'; @@ -4937,6 +5055,7 @@ watchlistButton = document.createElement('button'); watchlistButton.type = 'button'; watchlistButton.className = 'watchlist-button spotlight-watchlist-button emby-button button-flat paper-icon-button-light'; + if (isTvLayout()) watchlistButton.classList.add('show-focus'); watchlistButton.setAttribute('data-action', 'none'); watchlistButton.setAttribute('data-id', item.Id); watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false'); @@ -4955,14 +5074,15 @@ // Info button (shows overview on hover) const infoButton = document.createElement('button'); infoButton.className = 'emby-button button-flat spotlight-info-button paper-icon-button-light'; + if (isTvLayout()) infoButton.classList.add('show-focus'); const infoIcon = document.createElement('span'); infoIcon.className = 'material-icons'; infoIcon.textContent = 'info'; infoIcon.title = 'Go To Item'; infoButton.appendChild(infoIcon); - // Overview tooltip (hidden by default, shown on hover) - if (item.Overview) { + // Overview tooltip (hover only) — skip on TV + if (item.Overview && !isTvLayout()) { const overviewTooltip = document.createElement('div'); overviewTooltip.className = 'spotlight-overview-tooltip'; overviewTooltip.textContent = item.Overview; @@ -4997,6 +5117,7 @@ const metadataContainer = document.createElement('div'); metadataContainer.className = 'metadata-container'; + if (isTvLayout()) metadataContainer.classList.add('focuscontainer-x'); if (metadataRow.children.length > 0) { metadataContainer.appendChild(metadataRow); @@ -5091,6 +5212,29 @@ firstItemActive.setAttribute('data-active', 'true'); } + function syncSpotlightTvFocus() { + if (!isTvLayout()) return; + const isTooltipFocusable = (el) => + !!el.closest?.('.spotlight-truncated-list-tooltip, .spotlight-overview-tooltip'); + bannerContainer.querySelectorAll('.spotlight-item').forEach((slide) => { + const active = slide.getAttribute('data-active') === 'true'; + slide.querySelectorAll('a[href], button').forEach((el) => { + if (el.classList.contains('spotlight-dot') || isTooltipFocusable(el)) { + el.setAttribute('tabindex', '-1'); + return; + } + if (active) { + if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex'); + } else { + el.setAttribute('tabindex', '-1'); + } + }); + }); + bannerContainer.querySelectorAll('.spotlight-dot').forEach((dot) => { + dot.setAttribute('tabindex', '-1'); + }); + } + if (!inert) { // Delegated handler: info button click -> navigate to item details for the current slide bannerContainer.addEventListener('click', (e) => { @@ -5131,6 +5275,7 @@ const prevButton = document.createElement('button'); prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button'; + if (isTvLayout()) prevButton.classList.add('show-focus'); const prevIcon = document.createElement('span'); prevIcon.className = 'material-icons'; prevIcon.textContent = 'chevron_left'; @@ -5143,6 +5288,7 @@ const nextButton = document.createElement('button'); nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button'; + if (isTvLayout()) nextButton.classList.add('show-focus'); const nextIcon = document.createElement('span'); nextIcon.className = 'material-icons'; nextIcon.textContent = 'chevron_right'; @@ -5169,6 +5315,7 @@ if (navButtonsContainer) { const pauseButton = document.createElement('button'); pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button'; + if (isTvLayout()) pauseButton.classList.add('show-focus'); const pauseIcon = document.createElement('span'); pauseIcon.className = 'material-icons'; pauseIcon.textContent = 'pause'; @@ -5494,6 +5641,8 @@ advanceDue = false; startAutoPlay(); } + + syncSpotlightTvFocus(); } // Auto-play: single-timeout per slide, controlled only by pause/visibility (hover is ignored) @@ -5681,8 +5830,11 @@ if (!items[idx]) return; createAndAppendSlide(items[idx], idx, idx === currentIndex); }); + syncSpotlightTvFocus(); }; + syncSpotlightTvFocus(); + return container; } @@ -5697,6 +5849,8 @@ }; function resolveItemsLayout(sectionConfig) { + // Jellyfin TV rows are horizontal-only; never expand to grid/tiles on layout-tv + if (isTvLayout()) return 'row'; if (!sectionConfig) return 'row'; if (sectionConfig.itemsLayout === 'grid') return 'grid'; if (sectionConfig.itemsLayout === 'row') return 'row'; @@ -6080,7 +6234,7 @@ getLabel: () => getShowAllControlLabel(sectionElement), label: 'Grid layout', className: 'show-all-button', - isVisible: () => true, + isVisible: () => !isTvLayout(), onClick: (e) => { e.preventDefault(); e.stopPropagation(); @@ -6112,7 +6266,8 @@ const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label; const button = document.createElement('button'); button.type = 'button'; - button.className = `${def.className} material-icons ${def.icon}`; + button.className = `${def.className} material-icons ${def.icon} paper-icon-button-light`; + if (isTvLayout()) button.classList.add('show-focus'); button.title = label; button.setAttribute('aria-label', label); button.dataset.sectionControlId = def.id; @@ -6126,6 +6281,9 @@ }); const newMoreButton = moreButton.cloneNode(true); + if (isTvLayout()) { + newMoreButton.classList.add('paper-icon-button-light', 'show-focus'); + } moreButton.replaceWith(newMoreButton); const MORE_CONTROLS_MODAL_ID = 'kefin-section-controls-more'; @@ -6288,6 +6446,76 @@ return document.documentElement.classList.contains('layout-mobile') || window.innerWidth < 900; } + function createTvScrollerElement() { + const scroller = document.createElement('div'); + scroller.setAttribute('is', 'emby-scroller'); + scroller.setAttribute('data-centerfocus', 'true'); + scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller'; + scroller.style.overflow = 'hidden'; + return scroller; + } + + function createTvItemsSliderElement() { + const itemsContainer = document.createElement('div'); + itemsContainer.setAttribute('is', 'emby-itemscontainer'); + itemsContainer.className = 'scrollSlider focuscontainer-x itemsContainer animatedScrollX itemsContainer-tv'; + itemsContainer.style.whiteSpace = 'nowrap'; + return itemsContainer; + } + + /** + * TV-mode scrollable section: native emby-scroller + itemsContainer-tv. + * No Kefin custom-scroller drag/wheel, no scroll buttons — Jellyfin owns row motion on focus. + */ + function createTvScrollableContainer( + items, + title, + viewMoreUrl = null, + overflowCard = false, + cardFormat = null, + sectionConfig = null, + ) { + const verticalSection = document.createElement('div'); + verticalSection.className = 'verticalSection emby-scroller-container'; + const facetRowType = getFacetRowType(items); + if (facetRowType) { + verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`); + verticalSection.dataset.facetRowType = facetRowType; + } + + if (cardFormat) { + verticalSection.setAttribute('data-card-format', cardFormat); + } + + const sectionTitleContainer = document.createElement('div'); + sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; + + appendSectionTitleContent(sectionTitleContainer, { + title, + caption: getSectionCaption(sectionConfig), + captionUrl: getSectionCaptionUrl(sectionConfig), + viewMoreUrl, + }); + + ensureSectionControlsMount(sectionTitleContainer); + + const scroller = createTvScrollerElement(); + const itemsContainer = createTvItemsSliderElement(); + + const useParentCard = !!sectionConfig?.useParentCard; + const borderStyle = sectionConfig?.borderStyle || null; + items.forEach((item, index) => { + const card = buildTvCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle); + card.setAttribute('data-index', index); + itemsContainer.appendChild(card); + }); + + scroller.appendChild(itemsContainer); + verticalSection.appendChild(sectionTitleContainer); + verticalSection.appendChild(scroller); + return verticalSection; + } + function getSectionSkeletonCount(sectionConfig, cardFormat = null, itemsLayout = null) { const raw = sectionConfig?.itemLimit || sectionConfig?.queries?.[0]?.queryOptions?.Limit || 0; const fullLimit = raw > 0 ? raw : 16; @@ -6974,6 +7202,7 @@ } else if (viewMoreUrl) { const titleLink = document.createElement('a'); titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button'; + if (isTvLayout()) titleLink.classList.add('show-focus'); titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;'; if (typeof viewMoreUrl === 'function') { @@ -7015,6 +7244,7 @@ } else if (captionUrl) { captionEl = document.createElement('a'); captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button'; + if (isTvLayout()) captionEl.classList.add('show-focus'); if (typeof captionUrl === 'function') { captionEl.addEventListener('click', (e) => { e.preventDefault(); @@ -7042,6 +7272,10 @@ sectionConfig = null, registerScrollButtons = true, ) { + if (isTvLayout()) { + return createTvScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat, sectionConfig); + } + const normalizedFormat = (cardFormat || '').toLowerCase(); const isButtonLayout = normalizedFormat === 'button'; @@ -7322,9 +7556,13 @@ overflowCard = false, sectionConfig = null, ) { - // Create the main vertical section container (same structure as createScrollableContainer) + const tvLayout = isTvLayout(); + + // Create the main vertical section container (same structure as createScrollableContainer / TV path) const verticalSection = document.createElement('div'); - verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container'; + verticalSection.className = tvLayout + ? 'verticalSection emby-scroller-container' + : 'verticalSection emby-scroller-container custom-scroller-container'; // Persist the card format if provided (ensures consistency for random/updates) if (cardFormat) { @@ -7355,11 +7593,11 @@ ensureSectionControlsMount(sectionTitleContainer); - const scroller = createScrollerElement(); + const scroller = tvLayout ? createTvScrollerElement() : createScrollerElement(); // Sticky is=emby-itemscontainer on skeleton shell — never recreate on enhance - const itemsContainer = createItemsSliderElement(true); + const itemsContainer = tvLayout ? createTvItemsSliderElement() : createItemsSliderElement(true); - const itemsLayout = resolveItemsLayout(sectionConfig); + const itemsLayout = tvLayout ? 'row' : resolveItemsLayout(sectionConfig); const skeletonCount = getSectionSkeletonCount(sectionConfig, cardFormat, itemsLayout); for (let i = 0; i < skeletonCount; i++) { const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig); @@ -7369,11 +7607,12 @@ scroller.appendChild(itemsContainer); - const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller); - verticalSection.appendChild(sectionTitleContainer); - if (scrollButtons) { - verticalSection.appendChild(scrollButtons); + if (!tvLayout) { + const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller); + if (scrollButtons) { + verticalSection.appendChild(scrollButtons); + } } verticalSection.appendChild(scroller); @@ -8362,7 +8601,7 @@ ) { const cards = []; items.forEach((item, offset) => { - const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle); + const card = createCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle); card.setAttribute('data-index', String(startIndex + offset)); itemsContainer.appendChild(card); cards.push(card); @@ -8454,7 +8693,7 @@ return; } - const card = createJellyfinCardElement( + const card = createCardElement( item, overflowCard, cardFormat, @@ -8553,7 +8792,11 @@ const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat; const normalizedFormat = (cardFormat || '').toLowerCase(); const layoutFromDom = itemsContainer.getAttribute('data-layout'); - const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig); + const layout = isTvLayout() + ? 'row' + : layoutFromDom === 'grid' || layoutFromDom === 'row' + ? layoutFromDom + : resolveItemsLayout(sectionConfig); const gapless = itemsContainer.getAttribute('data-gapless') === 'true' || resolveUseGaplessCards(sectionConfig); invalidateLastRowPadding(itemsContainer); @@ -8569,7 +8812,7 @@ 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', String(index)); fragment.appendChild(card); }); diff --git a/scripts/homeScreenSectionConfigure.js b/scripts/homeScreenSectionConfigure.js index f135ac3..7d80886 100644 --- a/scripts/homeScreenSectionConfigure.js +++ b/scripts/homeScreenSectionConfigure.js @@ -16,6 +16,11 @@ const CARD_TITLE_MENU_MODAL_ID = 'kefin-section-card-title-menu'; const RESTORE_SECTION_MODAL_ID = 'kefin-section-restore-defaults'; const PUBLISH_SECTION_MODAL_ID = 'kefin-section-publish-defaults'; + const CONFIGURE_MODAL_ID = 'kefin-section-configure'; + + function isTvLayout() { + return document.documentElement.classList.contains('layout-tv'); + } /** True when the current pointer gesture started inside a Pickr app (drag may end outside). */ let pickrGestureFromApp = false; @@ -1003,11 +1008,8 @@ activePopover.entry = entry; activePopover.sectionElement = entry.element; activePopover.anchorButton = getConfigureAnchorButton(entry); - const mountElement = preparePopoverMount(activePopover.anchorButton, entry.element); - activePopover.mountElement = mountElement; - if (activePopover.popover.parentElement !== mountElement) { - mountElement.appendChild(activePopover.popover); - } + activePopover.mountElement = resolvePopoverMount(activePopover.anchorButton, entry.element); + preparePopoverMount(activePopover.anchorButton, entry.element); requestAnimationFrame(() => repositionActivePopover({ force: true })); } } @@ -1083,7 +1085,7 @@ } if (modal.dialogContent) { modal.dialogContent.style.padding = '0.25em 0'; - modal.dialogContent.style.overflow = 'hidden'; + modal.dialogContent.style.overflow = 'visible'; modal.dialogContent.style.minHeight = '0'; } if (modal.backdrop) { @@ -1093,6 +1095,7 @@ modal.dialogContainer.style.pointerEvents = 'none'; } modal.dialog.style.pointerEvents = 'auto'; + modal.dialog.style.overflow = 'visible'; requestAnimationFrame(() => positionFormatMenu(modal.dialog, anchorBtn)); } @@ -1259,6 +1262,9 @@ closeOnBackdrop: true, closeOnEscape: true, fixedSize: false, + returnFocus: anchorBtn, + initialFocus: '.kefin-section-format-option.is-active', + anchor: anchorBtn, onOpen: (modalInstance) => { styleAnchoredPopoverDialog(modalInstance, anchorBtn); const root = modalInstance.dialogContent; @@ -1403,7 +1409,7 @@ const content = `