mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Improve section rendering for TV display mode
This commit is contained in:
1 parent
62781fb6ad
commit
f1249ed271
2 files changed
+648
-51
No files matched your search
+161
-40
@@ -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
@@ -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')
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user