Make Discover More indicator a focusable button for TV display mode

This commit is contained in:
HighImKevin
2026-10-10 07:26:42 -04:00
parent 4f50af7206
commit 57bd16c197
4 changed files with 185 additions and 70 deletions
+14 -1
View File
@@ -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),
+23 -4
View File
@@ -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');
+50 -31
View File
@@ -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;
+98 -34
View File
@@ -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 || '<div class="spinner"></div>';
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 = `
<div class="spinner"></div>
`;
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(