Improve section rendering for TV display mode

This commit is contained in:
HighImKevin committed 2026-10-08 08:28:17 -04:00
1 parent 62781fb6ad
commit f1249ed271
2 files changed
+648 -51

No files matched your search

+161 -40
View File
@@ -481,7 +481,8 @@
.spotlight-overlay .spotlight-buttons-section,
.spotlight-overlay .spotlight-buttons-container,
.spotlight-overlay .spotlight-info-button,
.spotlight-overlay .emby-button {
.spotlight-overlay .emby-button,
.spotlight-overlay button.metadata-container {
pointer-events: auto;
}
@@ -875,11 +876,10 @@
visibility: hidden !important;
}
.spotlight-section:not(.hide)[data-section-id] > .spotlight-banner-container > .spotlight-nav-container {
position: absolute !important;
.spotlight-section:not(.hide)[data-section-id] > .spotlight-banner-container .spotlight-nav-container {
display: flex;
gap: 8px;
z-index: 2;
flex-shrink: 0;
}
.spotlight-nav-button {
@@ -1574,7 +1574,8 @@
overflow: hidden;
}
/* Dark gradient overlays at top and bottom (like left-side overlay): graceful fade and better visibility when content overlaps */
/* Dark gradient overlays at top and bottom (like left-side overlay): graceful fade and better visibility when content overlaps.
z-index above cycle-backdrops imgs (1/2) so vignettes do not flash during backdrop crossfade. */
.spotlight-section .spotlight-item .spotlight-background-single::before,
.spotlight-section .spotlight-item .spotlight-background-single::after,
.spotlight-section .spotlight-item .spotlight-background-dual::before,
@@ -1673,14 +1674,10 @@
/* Duplicate removed - clearart display:none already set for Borderless above */
.spotlight-section[data-layout="Borderless"] .spotlight-section-title-container {
.spotlight-section[data-layout="Borderless"] .spotlight-header-container {
left: var(--spotlight-fullscreen-horizontal);
top: calc(var(--spotlight-fullscreen-vertical) * 2.5);
}
.spotlight-section[data-layout="Borderless"] .spotlight-nav-container {
right: var(--spotlight-fullscreen-horizontal);
top: calc(var(--spotlight-fullscreen-vertical) * 2.5);
width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal));
}
.spotlight-section[data-layout="Borderless"] .spotlight-dots {
@@ -1825,13 +1822,14 @@
padding: 9rem 1rem 6rem 1rem !important;
}
.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-section-title-container {
.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-header-container {
top: 5rem;
}
.layout-mobile .spotlight-section .spotlight-section-title-container {
.layout-mobile .spotlight-section .spotlight-header-container {
top: 15px;
max-width: calc(100% - 2rem);
width: calc(100% - 2rem);
left: unset !important;
justify-content: center;
margin: 0 auto !important;
@@ -1962,10 +1960,6 @@
display: none !important;
}
.spotlight-section[data-layout="Border"] .spotlight-section-title-container {
top: 0;
}
.spotlight-section:not(.hide) .spotlight-nav-buttons-container {
display: flex !important;
padding: 0 0 !important;
@@ -2014,9 +2008,10 @@
.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-dots {
bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5);
}
.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-nav-container {
right: var(--spotlight-fullscreen-horizontal);
.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-header-container {
left: var(--spotlight-fullscreen-horizontal);
top: calc(var(--spotlight-fullscreen-vertical) * 2.5);
width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal));
}
.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay {
grid-template-rows: 1fr auto auto auto;
@@ -2069,23 +2064,16 @@
.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-overlay {
padding: 110px 40px 85px 40px;
}
.spotlight-section[data-layout="Border"] .spotlight-section-title-container {
.spotlight-section[data-layout="Border"] .spotlight-header-container {
top: 15px;
left: 40px;
width: calc(100% - 80px);
}
.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-section-title-container,
.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-section-title-container {
.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-header-container,
.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-header-container {
top: 2rem;
left: 2rem;
}
.spotlight-section[data-layout="Border"] .spotlight-nav-container {
top: 15px;
right: 40px;
}
.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-nav-container,
.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-nav-container {
top: 2rem !important;
right: 2rem !important;
width: calc(100% - 4rem);
}
.spotlight-section[data-layout="Border"] .spotlight-banner-container {
border: 1px solid rgb(255 255 255 / 10%);
@@ -2298,12 +2286,24 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"
display: none;
}
.spotlight-section-title-container {
.spotlight-header-container {
position: absolute;
width: 100%;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal, max(env(safe-area-inset-left), 3.3%)));
left: var(--spotlight-fullscreen-horizontal, max(env(safe-area-inset-left), 3.3%));
isolation: isolate;
transform: translateZ(0);
pointer-events: none;
}
.spotlight-section-title-container {
display: flex;
align-items: center;
gap: 0.35em;
min-width: 0;
}
.layout-mobile .spotlight-section-title-container .section-controls {
@@ -4185,6 +4185,7 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"
display: none !important;
}
.spotlight-section .spotlight-header-container > *,
.spotlight-section .spotlight-section-title-container > *,
.spotlight-section .spotlight-section-title-container .section-controls,
.spotlight-section .spotlight-section-title-container a,
@@ -4256,17 +4257,137 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"
z-index: 1;
}
.spotlight-section-title-container {
position: absolute;
z-index: 2;
isolation: isolate;
transform: translateZ(0);
}
.emby-scroller-container[data-section-id] {
z-index: 1;
}
/* TV metadata stop: look like the pill group, not a raised Jellyfin button */
.layout-tv button.metadata-container.emby-button {
display: flex;
flex-direction: column;
align-items: start;
gap: 8px;
overflow: visible;
min-width: 0;
width: 100%;
background: transparent;
border: 0;
padding: 0;
margin: 0;
box-shadow: none;
font: inherit;
color: inherit;
text-align: inherit;
cursor: default;
-webkit-appearance: none;
appearance: none;
min-height: 0;
}
.section-controls span.material-icons {
font-size: 18px !important;
}
/* --- TV spotlight focus: solid accent pills --- */
.layout-tv .spotlight-section-title .emby-button.show-focus:focus,
.layout-tv .spotlight-section-title .emby-tab-button.show-focus:focus,
.layout-tv .spotlight-section-title-container .section-refresh-button.show-focus:focus,
.layout-tv .spotlight-section-title-container .section-configure-button.show-focus:focus,
.layout-tv .spotlight-nav-container .spotlight-nav-button.show-focus:focus,
.layout-tv .metadata-container a.spotlight-genre-link.show-focus:focus,
.layout-tv .metadata-container a.spotlight-person-link.show-focus:focus,
.layout-tv .metadata-container a.spotlight-studio-link.show-focus:focus {
background-color: var(--theme-primary-color, #00a4dc) !important;
background: var(--theme-primary-color, #00a4dc) !important;
color: #fff !important;
outline: none;
box-shadow: none;
}
.layout-tv .metadata-container.show-focus:focus,
.layout-tv button.metadata-container.emby-button.show-focus:focus {
border-radius: 0.2em;
outline: 2px solid var(--theme-primary-color, #00a4dc) !important;
outline-offset: 2px;
background: transparent !important;
box-shadow: none;
color: inherit !important;
width: auto;
}
.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-genre-link.show-focus:focus,
.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-person-link.show-focus:focus,
.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-studio-link.show-focus:focus {
border-radius: 0.15em;
padding: 0.1em 0.25em;
}
/* Transparent card formats: visible TV focus ring (cardBox chrome is forced transparent) */
.layout-tv .emby-scroller-container[data-card-format="Logo"] .card.show-focus:focus .cardScalable,
.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .card.show-focus:focus .cardScalable,
.layout-tv .emby-scroller-container[data-card-format="Disc"] .card.show-focus:focus .cardScalable,
.layout-tv .emby-scroller-container[data-section-id="liveTv-channels"] .card.show-focus:focus .cardScalable,
.layout-tv .emby-scroller-container[data-card-format="Logo"] .cardButton-tv.show-focus:focus .cardScalable,
.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .cardButton-tv.show-focus:focus .cardScalable,
.layout-tv .emby-scroller-container[data-card-format="Disc"] .cardButton-tv.show-focus:focus .cardScalable {
outline: 3px solid #fff;
outline-offset: 2px;
border-radius: 2px;
}
/* --- JMP / no flex-gap: scoped margin fallbacks --- */
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-metadata-row {
gap: 0;
}
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-metadata-row > * + * {
margin-left: 12px !important;
}
html.no-flex-gap .spotlight-section:not(.hide) .metadata-container {
gap: 0;
}
html.no-flex-gap .spotlight-section:not(.hide) .metadata-container > * + * {
margin-top: 8px !important;
}
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-overlay {
gap: 0;
}
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-overlay > * + * {
margin-top: 1em !important;
}
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-buttons-container,
html.no-flex-gap .spotlight-section:not(.hide) .mainDetailButtons.spotlight-buttons-container {
gap: 0;
}
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-buttons-container > * + *,
html.no-flex-gap .spotlight-section:not(.hide) .mainDetailButtons.spotlight-buttons-container > * + * {
margin-left: 2em !important;
}
html.no-flex-gap .spotlight-section:not(.hide) .section-controls,
html.no-flex-gap .spotlight-section:not(.hide) .section-controls-inline {
gap: 0;
}
html.no-flex-gap .spotlight-section:not(.hide) .section-controls-inline > * + * {
margin-left: 0.15em !important;
}
html.no-flex-gap .spotlight-section:not(.hide) .custom-scroller-container .sectionTitleContainer,
html.no-flex-gap .spotlight-section:not(.hide) .emby-scroller-container .sectionTitleContainer {
gap: 0;
}
html.no-flex-gap .spotlight-section:not(.hide) .sectionTitleContainer > * + * {
margin-left: 0.35em !important;
}
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-nav-buttons-container {
gap: 0;
}
html.no-flex-gap .spotlight-section:not(.hide) .spotlight-nav-buttons-container > * + * {
margin-left: 8px !important;
}
html.no-flex-gap .itemsContainer:not([data-gapless="true"]) > * + * {
margin-left: 1em !important;
}
html.no-flex-gap .emby-scroller-container[data-card-format="Logo"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + *,
html.no-flex-gap .emby-scroller-container[data-card-format="Clear Art"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + *,
html.no-flex-gap .emby-scroller-container[data-card-format="Disc"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + * {
margin-left: 4em !important;
}
+487 -11
View File
@@ -9,6 +9,21 @@
// Developers can set this to false to disable the behavior.
const FADE_IN_SECTIONS = true;
// Detect flex gap after DOM is ready (JMP/Qt often lacks flex gap).
// ensureNoFlexGapClass is a function declaration (hoisted in this IIFE).
function scheduleFlexGapDetect() {
if (!document.body) {
document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true });
return;
}
ensureNoFlexGapClass();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true });
} else {
scheduleFlexGapDetect();
}
// Sliding window: render current slide + 4 prev + 4 next (9 total). As user navigates, remove/add slides.
const SPOTLIGHT_WINDOW_PREV = 4;
const SPOTLIGHT_WINDOW_NEXT = 4;
@@ -4240,6 +4255,104 @@
return document.documentElement.classList.contains('layout-tv');
}
/** Feature-detect flex gap once; JMP/Qt WebEngine often lacks it. */
function ensureNoFlexGapClass() {
const root = document.documentElement;
if (root.dataset.kefinFlexGapChecked === 'true') return;
root.dataset.kefinFlexGapChecked = 'true';
try {
const probe = document.createElement('div');
probe.style.cssText =
'display:flex;gap:1px;position:absolute;left:-9999px;top:0;width:auto;height:auto;visibility:hidden';
const a = document.createElement('div');
const b = document.createElement('div');
a.style.cssText = 'width:1px;height:1px';
b.style.cssText = 'width:1px;height:1px';
probe.appendChild(a);
probe.appendChild(b);
document.body.appendChild(probe);
const supported = probe.scrollWidth >= 3;
probe.remove();
root.classList.toggle('no-flex-gap', !supported);
} catch (_) {
root.classList.add('no-flex-gap');
}
}
function isFocusableTvEl(el) {
if (!el || el.nodeType !== 1) return false;
if (el.getAttribute('tabindex') === '-1') return false;
if (el.disabled || el.getAttribute('aria-hidden') === 'true') return false;
const tag = el.tagName;
if (tag === 'A' && el.hasAttribute('href')) return true;
if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return true;
const ti = el.getAttribute('tabindex');
return ti != null && ti !== '' && Number(ti) >= 0;
}
function getTvFocusables(root) {
if (!root?.querySelectorAll) return [];
return Array.from(root.querySelectorAll('a[href], button, [tabindex]')).filter(isFocusableTvEl);
}
/** Leftmost TV focus stop in spotlight header (skips plain section labels). */
function getFirstSpotlightHeaderFocusable(headerCluster) {
if (!headerCluster) return null;
return getTvFocusables(headerCluster)[0] || null;
}
function focusTvEl(el) {
if (!el || typeof el.focus !== 'function') return false;
try {
el.focus({ preventScroll: true });
} catch (_) {
el.focus();
}
return document.activeElement === el;
}
function getMetadataLinkFocusables(metaContainer) {
if (!metaContainer) return [];
return Array.from(
metaContainer.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
).filter((el) => el.getAttribute('tabindex') !== '-1' || metaContainer.dataset.metadataDrilled === 'true');
}
function getMetadataRows(metaContainer) {
if (!metaContainer) return [];
return Array.from(metaContainer.querySelectorAll(':scope > .spotlight-metadata-row'));
}
function setMetadataDrilled(metaContainer, drilled) {
if (!metaContainer) return;
metaContainer.dataset.metadataDrilled = drilled ? 'true' : 'false';
const links = metaContainer.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
);
links.forEach((link) => {
if (drilled) {
link.classList.add('show-focus');
if (link.getAttribute('tabindex') === '-1') link.removeAttribute('tabindex');
} else {
link.setAttribute('tabindex', '-1');
}
});
if (drilled) {
metaContainer.setAttribute('tabindex', '-1');
} else {
metaContainer.setAttribute('tabindex', '0');
metaContainer.classList.add('show-focus');
}
}
function collapseAllMetadataDrillIn(root) {
root?.querySelectorAll?.('.metadata-container').forEach((meta) => {
setMetadataDrilled(meta, false);
});
}
/**
* Prefer TV button cards when layout-tv is active; otherwise desktop cards.
*/
@@ -4472,12 +4585,16 @@
const bannerContainer = document.createElement('div');
bannerContainer.className = 'spotlight-banner-container';
// Header wraps title + nav so TV Left/Right stays in-family (one focuscontainer-x).
// Title container hosts section name + refresh/configure controls.
// Always create it so controls can attach even when the section has no name.
{
const headerContainer = document.createElement('div');
headerContainer.className = 'spotlight-header-container';
if (isTvLayout()) headerContainer.classList.add('focuscontainer-x');
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'spotlight-section-title-container';
if (isTvLayout()) sectionTitleContainer.classList.add('focuscontainer-x');
if (title) {
let sectionTitleEl;
@@ -4506,6 +4623,7 @@
sectionTitleEl = titleLink;
} else {
// Label only — not a focus stop; section-controls / nav are first when entering header
sectionTitleEl = document.createElement('div');
sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground emby-button';
sectionTitleEl.textContent = title;
@@ -4517,7 +4635,8 @@
sectionTitleContainer.appendChild(sectionTitleWrapper);
}
bannerContainer.appendChild(sectionTitleContainer);
headerContainer.appendChild(sectionTitleContainer);
bannerContainer.appendChild(headerContainer);
}
// Create items container (for fade transitions)
@@ -5176,16 +5295,35 @@
// Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons
overlay.appendChild(titleEl);
const metadataContainer = document.createElement('div');
metadataContainer.className = 'metadata-container';
if (isTvLayout()) metadataContainer.classList.add('focuscontainer-x');
// TV: real emby-button so Jellyfin spatial nav lands on metadata (plain div is Tab-only)
const metadataContainer = isTvLayout()
? document.createElement('button')
: document.createElement('div');
metadataContainer.className = isTvLayout()
? 'metadata-container emby-button show-focus'
: 'metadata-container';
if (isTvLayout()) {
metadataContainer.type = 'button';
metadataContainer.setAttribute('tabindex', '0');
metadataContainer.setAttribute('aria-label', 'Item metadata');
metadataContainer.dataset.metadataDrilled = 'false';
// Collapsed click must not navigate; drill-in is Right/Enter only
metadataContainer.addEventListener('click', (e) => {
if (metadataContainer.dataset.metadataDrilled !== 'true') {
e.preventDefault();
e.stopPropagation();
}
});
}
if (isTvLayout()) metadataRow.classList.add('focuscontainer-x');
if (metadataRow.children.length > 0) {
metadataContainer.appendChild(metadataRow);
}
// For Series, show seasons/episodes. For Movies, show director/writer
if (itemType === 'Series' || itemType === 'Season' || itemType === 'Episode') {
if (seriesInfoContainer) {
if (isTvLayout()) seriesInfoContainer.classList.add('focuscontainer-x');
metadataContainer.appendChild(seriesInfoContainer);
}
} else {
@@ -5198,12 +5336,14 @@
if (combinedContainer) {
const creditsRow = document.createElement('div');
creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (isTvLayout()) creditsRow.classList.add('focuscontainer-x');
if (studioContainer) creditsRow.appendChild(studioContainer);
creditsRow.appendChild(combinedContainer);
metadataContainer.appendChild(creditsRow);
} else if (directorContainer || writerContainer) {
const creditsRow = document.createElement('div');
creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (isTvLayout()) creditsRow.classList.add('focuscontainer-x');
if (studioContainer) creditsRow.appendChild(studioContainer);
if (directorContainer) creditsRow.appendChild(directorContainer);
if (writerContainer) creditsRow.appendChild(writerContainer);
@@ -5211,10 +5351,15 @@
} else if (studioContainer) {
const studioRow = document.createElement('div');
studioRow.className = 'spotlight-metadata-row spotlight-credits-row';
if (isTvLayout()) studioRow.classList.add('focuscontainer-x');
studioRow.appendChild(studioContainer);
metadataContainer.appendChild(studioRow);
}
}
if (isTvLayout()) {
// Collapsed by default: links not in vertical focus path until Right/OK drill-in
setMetadataDrilled(metadataContainer, false);
}
// Fixed 4-section grid: Logo, Metadata, Overview, Buttons (always render all to prevent layout shift)
const logoSection = document.createElement('div');
logoSection.className = 'spotlight-logo-section';
@@ -5277,25 +5422,136 @@
if (!isTvLayout()) return;
const isTooltipFocusable = (el) =>
!!el.closest?.('.spotlight-truncated-list-tooltip, .spotlight-overview-tooltip');
const isMetaLink = (el) =>
el.classList?.contains('spotlight-genre-link') ||
el.classList?.contains('spotlight-person-link') ||
el.classList?.contains('spotlight-studio-link');
bannerContainer.querySelectorAll('.spotlight-item').forEach((slide) => {
const active = slide.getAttribute('data-active') === 'true';
const meta = slide.querySelector('.metadata-container');
const drilled = meta?.dataset?.metadataDrilled === '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 {
if (!active) {
el.setAttribute('tabindex', '-1');
return;
}
// Active slide: metadata links gated by drill-in
if (isMetaLink(el)) {
if (drilled) {
if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
} else {
el.setAttribute('tabindex', '-1');
}
return;
}
if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
});
if (meta) {
if (!active) {
meta.setAttribute('tabindex', '-1');
} else if (drilled) {
meta.setAttribute('tabindex', '-1');
} else {
meta.setAttribute('tabindex', '0');
meta.classList.add('show-focus');
}
}
});
bannerContainer.querySelectorAll('.spotlight-dot').forEach((dot) => {
dot.setAttribute('tabindex', '-1');
});
}
function captureSpotlightFocusSlot() {
if (!isTvLayout()) return null;
const ae = document.activeElement;
if (!ae || !container.contains(ae)) return null;
if (ae.closest('.spotlight-section-title-container')) return { slot: 'title' };
if (ae.closest('.spotlight-nav-container')) return { slot: 'nav' };
const meta = ae.closest('.metadata-container');
if (meta && container.contains(meta)) {
if (meta.dataset.metadataDrilled === 'true') {
const links = Array.from(
meta.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
return { slot: 'metadata-drilled', linkIndex: Math.max(0, links.indexOf(ae)) };
}
return { slot: 'metadata' };
}
const btn = ae.closest('.spotlight-buttons-container');
if (btn && container.contains(btn)) {
let role = 'play';
if (ae.classList.contains('watchlist-button') || ae.closest('.watchlist-button')) role = 'watchlist';
else if (ae.classList.contains('spotlight-info-button') || ae.closest('.spotlight-info-button'))
role = 'info';
else if (ae.classList.contains('btnPlay') || ae.closest('.btnPlay')) role = 'play';
return { slot: 'buttons', role };
}
return null;
}
function restoreSpotlightFocusSlot(slot) {
if (!slot || !isTvLayout()) return;
const activeSlide = bannerContainer.querySelector('.spotlight-item[data-active="true"]');
if (slot.slot === 'title') {
const headerCluster = bannerContainer.querySelector('.spotlight-header-container');
const first = getFirstSpotlightHeaderFocusable(headerCluster);
if (first) focusTvEl(first);
return;
}
if (slot.slot === 'nav') {
const nav = bannerContainer.querySelector('.spotlight-nav-buttons-container');
const first = getTvFocusables(nav)[0];
if (first) focusTvEl(first);
return;
}
if (!activeSlide) return;
if (slot.slot === 'metadata') {
collapseAllMetadataDrillIn(activeSlide);
const meta = activeSlide.querySelector('.metadata-container');
if (meta) {
setMetadataDrilled(meta, false);
focusTvEl(meta);
}
return;
}
if (slot.slot === 'metadata-drilled') {
const meta = activeSlide.querySelector('.metadata-container');
if (!meta) return;
setMetadataDrilled(meta, true);
const links = Array.from(
meta.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
const idx = Math.min(slot.linkIndex || 0, Math.max(0, links.length - 1));
if (links[idx]) focusTvEl(links[idx]);
else if (links[0]) focusTvEl(links[0]);
else {
setMetadataDrilled(meta, false);
focusTvEl(meta);
}
return;
}
if (slot.slot === 'buttons') {
const buttons = activeSlide.querySelector('.spotlight-buttons-container');
if (!buttons) return;
let target = null;
if (slot.role === 'watchlist') target = buttons.querySelector('.watchlist-button');
else if (slot.role === 'info') target = buttons.querySelector('.spotlight-info-button');
else target = buttons.querySelector('.btnPlay');
focusTvEl(target || getTvFocusables(buttons)[0]);
}
}
if (!inert) {
// Delegated handler: info button click -> navigate to item details for the current slide
bannerContainer.addEventListener('click', (e) => {
@@ -5361,7 +5617,9 @@
navContainer.className = 'spotlight-nav-container headerTabs sectionTabs';
navContainer.appendChild(navButtonsContainer);
bannerContainer.appendChild(navContainer);
const headerContainer =
bannerContainer.querySelector('.spotlight-header-container') || bannerContainer;
headerContainer.appendChild(navContainer);
}
// Pause button (bottom right) — only when nav chrome exists (showNavButtons)
@@ -5607,6 +5865,7 @@
// Go to item function (crossfade transition, then only active slide is visible)
function goToItem(index, resetTimer = true) {
if (index === currentIndex) return;
const focusSlot = captureSpotlightFocusSlot();
if (cycleBackdropTimer) {
clearInterval(cycleBackdropTimer);
cycleBackdropTimer = null;
@@ -5698,6 +5957,9 @@
}
syncSpotlightTvFocus();
if (focusSlot) {
requestAnimationFrame(() => restoreSpotlightFocusSlot(focusSlot));
}
}
// Auto-play: single-timeout per slide, controlled only by pause/visibility (hover is ignored)
@@ -5820,6 +6082,215 @@
requestAnimationFrame(() => ensureSlideImagesLoaded(1));
}
// TV D-pad: metadata drill-in, row-scoped seams (header L/R is native focuscontainer-x)
if (isTvLayout()) {
const ARROW = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
function getHeaderCluster() {
return bannerContainer.querySelector('.spotlight-header-container');
}
function getActiveMeta() {
return bannerContainer.querySelector(
'.spotlight-item[data-active="true"] .metadata-container',
);
}
function getActiveButtons() {
return bannerContainer.querySelector(
'.spotlight-item[data-active="true"] .spotlight-buttons-container',
);
}
function exitMetadataTo(direction) {
const meta = getActiveMeta();
if (meta) setMetadataDrilled(meta, false);
if (direction === 'up') {
focusTvEl(getFirstSpotlightHeaderFocusable(getHeaderCluster()));
} else {
const buttons = getActiveButtons();
focusTvEl(getTvFocusables(buttons)[0]);
}
}
function enterMetadataDrillIn() {
const meta = getActiveMeta();
if (!meta) return false;
setMetadataDrilled(meta, true);
const rows = getMetadataRows(meta);
for (const row of rows) {
const links = Array.from(
row.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (links[0]) {
focusTvEl(links[0]);
return true;
}
}
setMetadataDrilled(meta, false);
return false;
}
bannerContainer.addEventListener(
'keydown',
(e) => {
if (!ARROW.has(e.key) && e.key !== 'Enter' && e.key !== ' ') return;
const ae = document.activeElement;
if (!ae || !bannerContainer.contains(ae)) return;
const meta = getActiveMeta();
const inMeta = !!(meta && meta.contains(ae));
const drilled = meta?.dataset?.metadataDrilled === 'true';
// Metadata container: Right/Enter drills in; Up/Down leave to neighbors
if (inMeta && ae === meta && !drilled) {
if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
enterMetadataDrillIn();
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('up');
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('down');
return;
}
}
// Metadata drilled: row-scoped L/R + Up/Down exit
if (inMeta && drilled && meta) {
const rows = getMetadataRows(meta).filter((row) =>
row.querySelector(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
const row = ae.closest('.spotlight-metadata-row');
const rowIndex = rows.indexOf(row);
const rowLinks = row
? Array.from(
row.querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
)
: [];
const linkIndex = rowLinks.indexOf(ae);
if (e.key === 'ArrowUp') {
if (rowIndex <= 0) {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('up');
return;
}
// Move to previous row rightmost for predictability when using Up between rows
const prevLinks = Array.from(
rows[rowIndex - 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (prevLinks.length) {
e.preventDefault();
e.stopPropagation();
focusTvEl(prevLinks[prevLinks.length - 1]);
}
return;
}
if (e.key === 'ArrowDown') {
if (rowIndex < 0 || rowIndex >= rows.length - 1) {
e.preventDefault();
e.stopPropagation();
exitMetadataTo('down');
return;
}
const nextLinks = Array.from(
rows[rowIndex + 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (nextLinks[0]) {
e.preventDefault();
e.stopPropagation();
focusTvEl(nextLinks[0]);
}
return;
}
if (e.key === 'ArrowLeft' && linkIndex === 0) {
e.preventDefault();
e.stopPropagation();
if (rowIndex > 0) {
const prevLinks = Array.from(
rows[rowIndex - 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
focusTvEl(prevLinks[prevLinks.length - 1] || meta);
} else {
setMetadataDrilled(meta, false);
focusTvEl(meta);
}
return;
}
if (e.key === 'ArrowRight' && linkIndex === rowLinks.length - 1) {
if (rowIndex >= 0 && rowIndex < rows.length - 1) {
const nextLinks = Array.from(
rows[rowIndex + 1].querySelectorAll(
'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
),
);
if (nextLinks[0]) {
e.preventDefault();
e.stopPropagation();
focusTvEl(nextLinks[0]);
}
} else {
// Right on last bottom link: stay (no wrap to top row / out)
e.preventDefault();
e.stopPropagation();
}
return;
}
// Left/Right mid-row: let focuscontainer-x / browser handle
}
},
true,
);
// Vertical entry into header / buttons → snap leftmost
bannerContainer.addEventListener(
'focusin',
(e) => {
const target = e.target;
if (!target || !bannerContainer.contains(target)) return;
const related = e.relatedTarget;
const headerCluster = getHeaderCluster();
const buttons = getActiveButtons();
const enteredHeader =
headerCluster?.contains(target) &&
(!related || !headerCluster.contains(related));
const enteredButtons =
buttons?.contains(target) && (!related || !buttons.contains(related));
if (enteredHeader) {
const first = getFirstSpotlightHeaderFocusable(headerCluster);
if (first && target !== first) focusTvEl(first);
} else if (enteredButtons) {
const fs = getTvFocusables(buttons);
if (fs[0] && target !== fs[0]) focusTvEl(fs[0]);
}
},
true,
);
}
// Touch swipe handling
let touchStartX = 0;
let touchStartY = 0;
@@ -6585,7 +7056,6 @@
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';
@@ -7602,9 +8072,13 @@
const bannerContainer = document.createElement('div');
bannerContainer.className = 'spotlight-banner-container';
// Header wraps title (+ nav when present) to match live spotlight DOM.
// Title container hosts section name + refresh/configure controls.
// Always create it so controls can attach even when the section has no name.
{
const headerContainer = document.createElement('div');
headerContainer.className = 'spotlight-header-container';
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'spotlight-section-title-container';
if (useSkeletonTitle || title) {
@@ -7655,7 +8129,8 @@
sectionTitleContainer.appendChild(captionEl);
}
}
bannerContainer.appendChild(sectionTitleContainer);
headerContainer.appendChild(sectionTitleContainer);
bannerContainer.appendChild(headerContainer);
}
// Create skeleton item (single full-container placeholder)
@@ -9161,6 +9636,7 @@
const body = sectionElement.querySelector('.spotlight-banner-container') || sectionElement;
Array.from(body.children).forEach((child) => {
if (
!child.classList.contains('spotlight-header-container') &&
!child.classList.contains('spotlight-section-title-container') &&
!child.classList.contains('sectionTitleContainer')
) {