diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 10eee18..a474698 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -3,7 +3,7 @@ .spotlight-section { position: relative; - width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%))); + width: calc(100% - (1 * max(env(safe-area-inset-left), 3.3%))); margin: 2em 0; } @@ -4327,10 +4327,23 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" background-color: var(--theme-primary-color, #00a4dc) !important; background: var(--theme-primary-color, #00a4dc) !important; color: #fff !important; + opacity: 1; outline: none; box-shadow: none; } +.layout-tv .spotlight-section-title-container .show-all-button.show-focus:focus .material-icons, +.layout-tv .spotlight-section-title-container .section-refresh-button.show-focus:focus .material-icons, +.layout-tv .spotlight-section-title-container .section-configure-button.show-focus:focus .material-icons, +.layout-tv .sectionTitleContainer .show-all-button.show-focus:focus .material-icons, +.layout-tv .sectionTitleContainer .section-refresh-button.show-focus:focus .material-icons, +.layout-tv .sectionTitleContainer .section-configure-button.show-focus:focus .material-icons, +.layout-tv .sectionTitle-wrapper .show-all-button.show-focus:focus .material-icons, +.layout-tv .sectionTitle-wrapper .section-refresh-button.show-focus:focus .material-icons, +.layout-tv .sectionTitle-wrapper .section-configure-button.show-focus:focus .material-icons { + color: #fff !important; +} + .layout-tv .metadata-container.show-focus:focus, .layout-tv button.metadata-container.emby-button.show-focus:focus, .layout-tv button.metadata-container.emby-button.show-focus:focus:not(.skip), diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 5df60bb..265ffff 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -2413,6 +2413,7 @@ enhanceOnVisible = false, enhanceRootMargin = '30% 0px 30% 0px', showStaleDataBeforeRefresh = true, + insertBefore = null, } = options; const renderProgressiveSectionsStartTime = performance.now(); @@ -2587,7 +2588,11 @@ const batch = sectionNodes.slice(i, i + BATCH_SIZE); const batchFragment = document.createDocumentFragment(); batch.forEach((node) => batchFragment.appendChild(node)); - container.appendChild(batchFragment); + if (insertBefore && insertBefore.parentNode === container) { + container.insertBefore(batchFragment, insertBefore); + } else { + container.appendChild(batchFragment); + } batch.forEach((sectionEl) => { if (sectionEl?.nodeType === 1) registerLazyImagesInSection(sectionEl); }); @@ -6435,8 +6440,12 @@ function setShowAllIcon(button, layout) { if (!button) return; + const iconName = ITEMS_LAYOUT_ICONS[layout] || 'view_array'; + const icon = button.querySelector('.material-icons') || button; + icon.classList.remove('view_array', 'grid_view', 'grid_on'); + icon.classList.add(iconName); + // Keep glyph classes off the paper-icon button itself button.classList.remove('view_array', 'grid_view', 'grid_on'); - button.classList.add(ITEMS_LAYOUT_ICONS[layout] || 'view_array'); } function isButtonCardFormat(cardFormat) { @@ -6791,6 +6800,13 @@ return ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row; } + function getShowAllControlIcon(sectionElement) { + const itemsContainer = sectionElement?.querySelector('.itemsContainer'); + const currentLayout = itemsContainer?.getAttribute('data-layout') || 'row'; + const resolved = currentLayout === 'grid' ? currentLayout : 'row'; + return ITEMS_LAYOUT_ICONS[nextItemsLayout(resolved)] || 'grid_view'; + } + function handleShowAllLayoutToggle(sectionElement) { const verticalSection = sectionElement.closest('.emby-scroller-container') || sectionElement; const itemsContainer = verticalSection.querySelector('.itemsContainer'); @@ -6841,6 +6857,7 @@ definitions.push({ id: 'showAll', icon: 'grid_view', + getIcon: () => getShowAllControlIcon(sectionElement), getLabel: () => getShowAllControlLabel(sectionElement), label: 'Grid layout', className: 'show-all-button', @@ -6874,9 +6891,10 @@ visibleDefinitions.forEach((def) => { const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label; + const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon; const button = createPaperIconButton({ className: def.className || '', - icon: def.icon || '', + icon: icon || '', title: label, showFocus: isTvLayout(), }); @@ -6922,13 +6940,14 @@ : 'kefinTweaks-popover section-controls-more-popover'; visibleDefinitions.forEach((def) => { const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label; + const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon; const item = document.createElement('div'); item.className = 'kefinTweaks-popover-item detailsGroupItem section-controls-more-item'; item.setAttribute('role', 'button'); item.setAttribute('tabindex', '0'); const iconSpan = document.createElement('span'); - iconSpan.className = `material-icons section-controls-more-item-icon ${def.icon}`; + iconSpan.className = `material-icons section-controls-more-item-icon ${icon || ''}`.trim(); iconSpan.setAttribute('aria-hidden', 'true'); const labelSpan = document.createElement('span'); diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index 9931772..25d8257 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -27,36 +27,36 @@ opacity: 1; } */ -#indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before { +#discovery-loading-indicator { + order: 999999999 !important; + width: fit-content; + margin: 0 auto; +} + +#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show)::before { content: "Discover More"; } -#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::before { +#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::before { content: "No More Content to Discover"; } -div#discovery-loading-indicator:not(.show)::after { +#discovery-loading-indicator:not(.show)::after { content: "\E313"; font-family: "Material Icons"; - position: absolute; - top: calc(50% + 0.4em); - left: 50%; - transform: translateX(-50%) translateY(-50%); font-size: 1.5em; color: #ffffff; visibility: visible; opacity: 1; } -#indexPage[data-discovery-exhausted] div#discovery-loading-indicator:not(.show)::after { +#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::after { content: ""; } -[data-loading-discovery="true"] div#discovery-loading-indicator, -.homeSectionsContainer[data-loading-discovery="true"] ~ #discovery-loading-indicator, -.homeSectionsContainer[data-loading-discovery="true"] + #discovery-loading-indicator, -.sections[data-loading-discovery="true"] ~ #discovery-loading-indicator, -.sections[data-loading-discovery="true"] + #discovery-loading-indicator { +[data-loading-discovery="true"] #discovery-loading-indicator, +.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator, +.sections[data-loading-discovery="true"] #discovery-loading-indicator { display: flex; } @@ -65,6 +65,7 @@ div#discovery-loading-indicator:not(.show)::after { display: flex !important; visibility: visible !important; opacity: 1 !important; + gap: 0; } /* Hide Discover More / loading affordance outside Home + Discover categories */ @@ -73,11 +74,6 @@ div#discovery-loading-indicator:not(.show)::after { .libraryPage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) #discovery-loading-indicator, -#indexPage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) ~ #discovery-loading-indicator, -.homePage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) ~ #discovery-loading-indicator, -.libraryPage - .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) - ~ #discovery-loading-indicator, #indexPage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) .discovery-loading-indicator, .homePage .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) .discovery-loading-indicator, .libraryPage @@ -96,13 +92,14 @@ div#discovery-loading-indicator:not(.show)::after { #indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner { visibility: hidden; opacity: 0; + display: none; } /* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator { display: none; } */ -#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after { +#indexPage:not([data-discovery-ready]) #discovery-loading-indicator:not(.show)::after { content: ""; } @@ -131,13 +128,19 @@ div#discovery-loading-indicator:not(.show)::after { opacity: 1 !important; } -.discovery-loading-indicator, -.load-more-discovery-btn { - height: 50px; -} - .discovery-loading-indicator { display: none; + appearance: none; + -webkit-appearance: none; + background: transparent; + border: none; + margin: 0; + padding: 0; + width: 100%; + font: inherit; + text-align: center; + color: inherit; + cursor: default; opacity: 1; flex-direction: column; gap: 10px; @@ -156,14 +159,26 @@ div#discovery-loading-indicator:not(.show)::after { cursor: pointer; } +/* TV: real button + emby-button for spatial nav; keep Discover More chrome unstyled */ +.layout-tv button#discovery-loading-indicator.emby-button, .layout-tv #discovery-loading-indicator.discovery-load-more-focusable { outline: none; min-height: 3em; padding: 0.5em 1em; box-sizing: border-box; + background: transparent; + border: 0; + box-shadow: none; + transform: none; + -webkit-appearance: none; + appearance: none; + font: inherit; + color: inherit; + cursor: pointer; } -.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus { +.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus, +.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus { background-color: var(--theme-primary-color, #00a4dc) !important; background: var(--theme-primary-color, #00a4dc) !important; color: #fff !important; @@ -173,7 +188,9 @@ div#discovery-loading-indicator:not(.show)::after { } .layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::before, -.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::after { +.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::after, +.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::before, +.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::after { color: #fff !important; } @@ -210,10 +227,8 @@ div#discovery-loading-indicator:not(.show)::after { } .sections[data-loading-discovery="true"] .discovery-loading-indicator, -.sections[data-loading-discovery="true"] ~ #discovery-loading-indicator, -.sections[data-loading-discovery="true"] + #discovery-loading-indicator, -.homeSectionsContainer[data-loading-discovery="true"] ~ #discovery-loading-indicator, -.homeSectionsContainer[data-loading-discovery="true"] + #discovery-loading-indicator { +.sections[data-loading-discovery="true"] #discovery-loading-indicator, +.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator { display: flex !important; opacity: 1 !important; } @@ -297,7 +312,7 @@ div#discovery-loading-indicator:not(.show)::after { display: none; } -.homePage[data-discovery-ready] div#discovery-loading-indicator:not(.show) { +.homePage[data-discovery-ready] #discovery-loading-indicator:not(.show) { min-height: 50px; } @@ -674,6 +689,10 @@ html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-se background-position: bottom center; } +html[data-active-page="home"] body[data-seasonal-background="true"] .backdropContainer .backdropImage { + display: none !important; +} + @media (hover: hover) and (pointer: fine) and (min-width: 900px) { #kefin-home-category-rail .kefin-home-category-rail.kefin-home-chrome { pointer-events: auto; diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index 85d1f68..8c08b1f 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -134,7 +134,7 @@ pendingJellyfinSectionObservers: [] }; - // Create loading indicator as next sibling of the sections container (outside flex column) + // Create loading indicator as last child of the sections container (keeps height during load; TV Down focus) function createDiscoveryLoadingIndicator(container) { // Prefer the provided container, but fall back to the standard sections container if (!container) { @@ -142,44 +142,47 @@ || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); } - let loadingDiv = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') + let loadingEl = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator') || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); - if (loadingDiv) { - if (container && loadingDiv.previousElementSibling !== container) { - // Place as next sibling of the sections container - if (container.nextSibling) { - container.parentNode.insertBefore(loadingDiv, container.nextSibling); - } else if (container.parentNode) { - container.parentNode.appendChild(loadingDiv); - } - } - bindDiscoveryLoadMoreControl(loadingDiv); - syncDiscoveryLoadMoreFocus(loadingDiv); - return loadingDiv; + // Migrate legacy div indicator to a real button (Jellyfin TV spatial nav targets buttons) + if (loadingEl && loadingEl.tagName !== 'BUTTON') { + const replacement = document.createElement('button'); + replacement.type = 'button'; + replacement.id = loadingEl.id; + replacement.className = loadingEl.className; + replacement.innerHTML = loadingEl.innerHTML || '
'; + loadingEl.replaceWith(replacement); + loadingEl = replacement; } - if (!container || !container.parentNode) { + if (loadingEl) { + if (container) { + container.appendChild(loadingEl); + } + bindDiscoveryLoadMoreControl(loadingEl); + syncDiscoveryLoadMoreFocus(loadingEl); + return loadingEl; + } + + if (!container) { WARN('Discovery loading indicator: sections container not found'); return null; } - loadingDiv = document.createElement('div'); - loadingDiv.className = 'discovery-loading-indicator'; - loadingDiv.id = 'discovery-loading-indicator'; - loadingDiv.innerHTML = ` + loadingEl = document.createElement('button'); + loadingEl.type = 'button'; + loadingEl.className = 'discovery-loading-indicator'; + loadingEl.id = 'discovery-loading-indicator'; + loadingEl.innerHTML = ` `; - if (container.nextSibling) { - container.parentNode.insertBefore(loadingDiv, container.nextSibling); - } else { - container.parentNode.appendChild(loadingDiv); - } - LOG('Created discovery loading indicator as sibling of sections container'); - bindDiscoveryLoadMoreControl(loadingDiv); - syncDiscoveryLoadMoreFocus(loadingDiv); - return loadingDiv; + container.appendChild(loadingEl); + LOG('Created discovery loading indicator as last child of sections container'); + bindDiscoveryLoadMoreControl(loadingEl); + syncDiscoveryLoadMoreFocus(loadingEl); + return loadingEl; } function removeDiscoveryLoadingIndicator() { @@ -208,14 +211,17 @@ const interactive = isDiscoveryLoadMoreInteractive(); if (tv && interactive) { - el.setAttribute('role', 'button'); + el.disabled = false; + el.removeAttribute('disabled'); el.setAttribute('tabindex', '0'); el.setAttribute('aria-label', 'Discover More'); + // emby-button: Jellyfin TV spatial nav treats plain controls as Tab-only otherwise + el.classList.add('emby-button'); el.classList.add('show-focus'); el.classList.add('discovery-load-more-focusable'); } else { el.setAttribute('tabindex', '-1'); - el.removeAttribute('role'); + el.classList.remove('emby-button'); el.classList.remove('show-focus'); el.classList.remove('discovery-load-more-focusable'); if (state.discoverySectionsRemain === false || getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') { @@ -1570,7 +1576,7 @@ // Chevron mode: nudge scroll so newly appended sections start entering the viewport if (state.discoveryMode === 'chevron') { - window.scrollBy({ top: 100, behavior: 'smooth' }); + window.scrollBy({ top: 200, behavior: 'smooth' }); } try { @@ -1599,14 +1605,15 @@ const config = await window.KefinHomeScreen.getConfig(); const revealSectionsSequentially = config.DISCOVERY_SETTINGS?.fadeInSections === true; - // Sibling spinner stays outside the flex column — no insertBefore parking - createDiscoveryLoadingIndicator(container); + // Spinner stays mounted as last child; sections insert before it (cardBuilder) + const indicator = createDiscoveryLoadingIndicator(container); await window.cardBuilder.renderProgressiveSections(container, bufferedSections, { revealSectionsSequentially, enhanceOnVisible: true, enhanceRootMargin: DISCOVERY_ENHANCE_ROOT_MARGIN, - showStaleDataBeforeRefresh: config.HOME_SETTINGS?.showStaleDataBeforeRefresh === true + showStaleDataBeforeRefresh: config.HOME_SETTINGS?.showStaleDataBeforeRefresh === true, + insertBefore: indicator || undefined, }); createDiscoveryLoadingIndicator(container); @@ -1624,6 +1631,22 @@ ensureDiscoveryBuffer(); } + // TV chevron: hand focus to first card of the first newly added section + if ( + document.documentElement.classList.contains('layout-tv') + && state.discoveryMode === 'chevron' + ) { + const firstId = bufferedSections[0]?.config?.id; + if (firstId != null) { + const sectionIdAttr = + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(String(firstId)) + : String(firstId).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const sectionEl = container.querySelector(`[data-section-id="${sectionIdAttr}"]`); + focusFirstCardInDiscoverySection(sectionEl); + } + } + } catch (e) { ERR('Error rendering discovery group:', e); state.discoveryBuffer = null; @@ -2540,6 +2563,47 @@ return rect.width > 0 && rect.height > 0; } + function getFirstFocusableInDiscoverySection(sectionEl) { + if (!sectionEl) return null; + const card = Array.from( + sectionEl.querySelectorAll( + '.card.itemAction:not([tabindex="-1"]), button.card:not([tabindex="-1"]), a.card:not([tabindex="-1"])', + ), + ).find((node) => isVisibleFocusTarget(node)); + if (card) return card; + + // Spotlight / non-card sections: first visible focusable + return Array.from( + sectionEl.querySelectorAll( + 'button:not([disabled]):not([tabindex="-1"]), a[href]:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])', + ), + ).find((node) => isVisibleFocusTarget(node)) || null; + } + + function focusFirstCardInDiscoverySection(sectionEl) { + const tryFocus = () => { + const target = getFirstFocusableInDiscoverySection(sectionEl); + if (!target || typeof target.focus !== 'function') return false; + try { + target.focus({ preventScroll: true }); + } catch (_) { + target.focus(); + } + try { + target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } catch (_) { + /* ignore */ + } + return true; + }; + + if (tryFocus()) return; + // Skeleton may not have cards yet — one frame retry + requestAnimationFrame(() => { + tryFocus(); + }); + } + /** Live spotlight target after slide change (inactive/drilled nodes go stale). */ function getActiveSpotlightReturnFocus() { const activeSlide = document.querySelector(