Add support for rendering sections for TV display mode and improve TV display mode handling in general

This commit is contained in:
HighImKevin
2026-10-06 22:24:41 -04:00
parent c3d098eb0d
commit 3c28d21b9e
4 changed files with 930 additions and 250 deletions
+65
View File
@@ -868,6 +868,14 @@
display: none !important;
}
/* TV: no hover tooltips; keep them out of the focus tree */
.layout-tv .spotlight-truncated-list-tooltip,
.layout-tv .spotlight-overview-tooltip {
display: none !important;
pointer-events: none !important;
visibility: hidden !important;
}
.spotlight-section:not(.hide)[data-section-id] > .spotlight-banner-container > .spotlight-nav-container {
position: absolute !important;
display: flex;
@@ -2114,6 +2122,7 @@
flex-direction: column;
justify-content: end;
align-items: center;
grid-gap: 3em;
}
.layout-mobile .spotlight-overview-section,
@@ -4199,4 +4208,60 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"
}
[data-section-id="liveTv-on-now"] .card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(3) {
display: block;
}
/* TV layout: native emby-scroller + button cards (match Jellyfin layout-tv rows) */
.layout-tv .emby-scroller-container > .emby-scroller.no-padding {
overflow: hidden;
}
.layout-tv .itemsContainer-tv > button.card {
-webkit-appearance: none;
appearance: none;
display: inline-block;
border: 0;
background: transparent;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
text-align: inherit;
vertical-align: top;
cursor: pointer;
}
/* Section controls are hover-only on desktop; TV has no hover */
.layout-tv .emby-scroller-container .section-refresh-button,
.layout-tv .emby-scroller-container .section-configure-button,
.layout-tv .emby-scroller-container .section-controls-more,
.layout-tv .spotlight-section .section-refresh-button,
.layout-tv .spotlight-section .section-configure-button {
opacity: 0.65;
visibility: visible;
pointer-events: auto;
}
.verticalSection[data-card-title-position^="overlay-"] .cardScalable::after,
.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable::after {
pointer-events: none !important;
}
.verticalSection[data-card-title-position^="overlay-"] .cardTextStack a,
.verticalSection[data-card-title-position^="overlay-"] .cardTextStack button,
.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack a,
.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack button {
pointer-events: auto !important;
position: relative;
z-index: 1;
}
.spotlight-section-title-container {
position: absolute;
z-index: 5;
isolation: isolate;
transform: translateZ(0);
}
.emby-scroller-container[data-section-id] {
z-index: 1;
}
+403 -160
View File
@@ -1648,6 +1648,7 @@
const selectButton = document.createElement('button');
selectButton.type = 'button';
selectButton.className = 'emby-button raised multi-query-picker-button';
if (isTvLayout()) selectButton.classList.add('show-focus');
selectButton.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
selectButton.style.cssText = 'margin-left: 1em; padding: 0.5em 1em; font-size: 0.9em;';
selectButton.setAttribute('aria-label', 'Select query');
@@ -1666,27 +1667,22 @@
titleContainer.appendChild(selectButton);
}
let activePopover = null;
let activePopoverCloseHandler = null;
const closePopover = () => {
if (activePopoverCloseHandler) {
document.removeEventListener('click', activePopoverCloseHandler);
activePopoverCloseHandler = null;
}
if (activePopover) {
activePopover.remove();
activePopover = null;
}
};
const MULTI_QUERY_MODAL_ID = 'kefin-multi-query-picker';
const multiQueryPopoverCss = `
.multiQueryPopover {
background-color: rgba(0, 0, 0, 0.95);
position: absolute !important;
.kefin-multi-query-modal {
background-color: rgba(0, 0, 0, 0.95) !important;
z-index: 1001;
display: block;
overflow-y: auto;
box-sizing: border-box;
min-width: 12rem;
max-width: min(90vw, 320px);
max-height: min(60vh, 420px);
overflow: hidden;
padding: 0 !important;
}
.kefin-multi-query-modal [data-name="kefin-modal-content"] {
padding: 0.25em 0 !important;
overflow-y: auto !important;
max-height: min(60vh, 420px);
}
`;
let multiQueryStyleEl = document.getElementById('kefinTweaks-multiQueryPopover-style');
@@ -1701,130 +1697,115 @@
e.stopPropagation();
e.preventDefault();
if (activePopover) {
closePopover();
if (!window.ModalSystem?.create) return;
if (window.ModalSystem.isOpen?.(MULTI_QUERY_MODAL_ID)) {
window.ModalSystem.close(MULTI_QUERY_MODAL_ID);
return;
}
const popover = document.createElement('div');
popover.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup';
const list = document.createElement('div');
list.className = 'kefinTweaks-popover multiQueryPopover itemDetailsGroup';
const selectedIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false });
let selectedItemElement = null;
const selectQuery = async (index) => {
window.ModalSystem.close(MULTI_QUERY_MODAL_ID);
if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) {
return;
}
sectionConfig._selectedQueryIndex = index;
const sectionIdAttr =
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(String(sectionConfig.id))
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
let activeSectionEl =
document.querySelector(
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
) || sectionElement;
const livePicker = activeSectionEl.querySelector('.multi-query-picker-button');
if (livePicker) {
livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
}
updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig));
activeSectionEl.dataset.refreshing = 'true';
try {
const loaded = await loadActiveSectionQueryItems(sectionConfig);
let newContent = null;
if (loaded.canPaintCached) {
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items);
} else {
activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl);
const freshItems = await loaded.itemsPromise;
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems);
}
if (typeof sectionConfig._onMultiQueryChange === 'function') {
sectionConfig._onMultiQueryChange(index, sectionConfig, newContent);
}
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error switching query:', error);
const live =
document.querySelector(
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
) || activeSectionEl;
if (live) live.dataset.refreshing = 'false';
}
};
queries.forEach((query, index) => {
const itemElement = document.createElement('div');
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem';
const itemElement = document.createElement('button');
itemElement.type = 'button';
itemElement.className = 'kefinTweaks-popover-item detailsGroupItem emby-button button-flat';
if (index === selectedIndex) {
itemElement.classList.add('selected');
itemElement.classList.add('selected', 'is-active');
selectedItemElement = itemElement;
}
itemElement.textContent = getQueryDisplayName(query, index);
itemElement.addEventListener('click', async () => {
closePopover();
if (index === resolveActiveQueryIndex(sectionConfig, { initialize: false })) {
return;
}
sectionConfig._selectedQueryIndex = index;
const sectionIdAttr =
typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(String(sectionConfig.id))
: String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
// Prefer the live section node (may have been replaced by a prior switch).
let activeSectionEl =
document.querySelector(
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
) || sectionElement;
const livePicker = activeSectionEl.querySelector('.multi-query-picker-button');
if (livePicker) {
livePicker.textContent = getMultiQueryPickerButtonLabel(sectionConfig);
}
updateSectionTitleText(activeSectionEl, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
updateSectionViewMoreLink(activeSectionEl, getActiveViewMoreUrl(sectionConfig));
activeSectionEl.dataset.refreshing = 'true';
try {
const loaded = await loadActiveSectionQueryItems(sectionConfig);
let newContent = null;
if (loaded.canPaintCached) {
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, loaded.items);
} else {
// Skeletons before awaiting network — cache check already finished above.
activeSectionEl = showSectionQuerySkeletons(sectionConfig, activeSectionEl);
const freshItems = await loaded.itemsPromise;
newContent = await replaceSectionContent(sectionConfig, activeSectionEl, freshItems);
}
if (typeof sectionConfig._onMultiQueryChange === 'function') {
sectionConfig._onMultiQueryChange(index, sectionConfig, newContent);
}
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error switching query:', error);
const live =
document.querySelector(
`.verticalSection[data-section-id="${sectionIdAttr}"], .spotlight-section[data-section-id="${sectionIdAttr}"]`,
) || activeSectionEl;
if (live) live.dataset.refreshing = 'false';
}
itemElement.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
selectQuery(index);
});
popover.appendChild(itemElement);
list.appendChild(itemElement);
});
// Mount on body with absolute + document coords so the menu scrolls with the button
document.body.appendChild(popover);
const margin = 8;
const edgeMargin = 16;
const gap = 8;
const scrollX = window.scrollX || window.pageXOffset || 0;
const scrollY = window.scrollY || window.pageYOffset || 0;
const buttonRect = selectButton.getBoundingClientRect();
const popoverWidth = popover.offsetWidth || popover.getBoundingClientRect().width || 200;
const spaceBelow = window.innerHeight - buttonRect.bottom - margin;
const spaceAbove = buttonRect.top - margin;
const placeBelow = spaceBelow >= Math.min(spaceAbove, 160) || spaceBelow >= spaceAbove;
const available = placeBelow ? spaceBelow : spaceAbove;
const maxHeight = Math.max(120, Math.min(window.innerHeight * 0.6, available - gap));
let leftViewport = buttonRect.left;
leftViewport = Math.min(
Math.max(edgeMargin, leftViewport),
Math.max(edgeMargin, window.innerWidth - popoverWidth - edgeMargin),
);
popover.style.position = 'absolute';
popover.style.left = `${leftViewport + scrollX}px`;
popover.style.maxHeight = `${maxHeight}px`;
popover.style.bottom = 'auto';
if (placeBelow) {
popover.style.top = `${buttonRect.bottom + gap + scrollY}px`;
} else {
const height = Math.min(popover.offsetHeight || maxHeight, maxHeight);
popover.style.top = `${Math.max(scrollY + margin, buttonRect.top - gap - height + scrollY)}px`;
}
activePopover = popover;
if (selectedItemElement) {
setTimeout(() => {
selectedItemElement.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
});
}, 10);
}
activePopoverCloseHandler = (ev) => {
if (!popover.contains(ev.target) && !selectButton.contains(ev.target)) {
closePopover();
}
};
setTimeout(() => {
document.addEventListener('click', activePopoverCloseHandler);
}, 100);
window.ModalSystem.create({
id: MULTI_QUERY_MODAL_ID,
title: null,
content: list,
footer: null,
showCloseButton: false,
closeOnBackdrop: true,
closeOnEscape: true,
fixedSize: false,
returnFocus: selectButton,
initialFocus: selectedItemElement,
anchor: selectButton,
dialogClassName: 'kefin-multi-query-modal',
onOpen: (modal) => {
if (modal.backdrop) modal.backdrop.style.background = 'transparent';
if (modal.dialogContainer) modal.dialogContainer.style.pointerEvents = 'none';
if (modal.dialog) modal.dialog.style.pointerEvents = 'auto';
if (modal.dialogContent) {
modal.dialogContent.style.padding = '0.25em 0';
modal.dialogContent.style.overflowY = 'auto';
modal.dialogContent.style.minHeight = '0';
modal.dialogContent.style.flex = '0 1 auto';
}
if (selectedItemElement) {
requestAnimationFrame(() => {
selectedItemElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
}
},
});
});
}
@@ -1846,7 +1827,8 @@
const moreButton = document.createElement('button');
moreButton.type = 'button';
moreButton.className = 'section-controls-more material-icons more_vert';
moreButton.className = 'section-controls-more material-icons more_vert paper-icon-button-light';
if (isTvLayout()) moreButton.classList.add('show-focus');
moreButton.title = 'More';
moreButton.setAttribute('aria-label', 'More');
@@ -2189,7 +2171,7 @@
useParentCard = false,
borderStyle = null,
) {
return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
return createCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
},
/** Push-register lazy images under an attached root (parallel native path). */
@@ -4216,18 +4198,146 @@
}
/**
* Renders a spotlight section (Netflix-style slim banner carousel)
* @param {Array} items - Array of Jellyfin item objects
* @param {string} title - Title for the spotlight section
* @param {Object} options - Options for the spotlight carousel
* @param {boolean} options.autoPlay - Auto-cycle through items (default: true)
* @param {number} options.interval - Auto-play interval in ms (default: 10000)
* @param {boolean} options.showSlideState - Show slide state container (dots or numeric) (default: true)
* @param {boolean} options.showDots - When showSlideState is true: true = dots (max 5), false = numeric "N / X" (default: true)
* @param {boolean} options.showNavButtons - Show prev/next buttons (default: true)
* @param {boolean} options.pauseOnHover - Pause auto-cycle when cursor is over spotlight (default: true, false when fullScreen)
* @returns {HTMLElement} - The constructed spotlight container
* TV layout detection (Jellyfin sets layout-tv on <html>).
* Evaluated at call time so Display Mode changes are respected after re-render.
*/
function isTvLayout() {
return document.documentElement.classList.contains('layout-tv');
}
/**
* Prefer TV button cards when layout-tv is active; otherwise desktop cards.
*/
function createCardElement(
item,
overflowCard = false,
cardFormat = null,
customFooterText = null,
useParentCard = false,
borderStyle = null,
) {
if (isTvLayout()) {
return buildTvCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
}
return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
}
/**
* Convert a desktop card DOM tree into a Jellyfin TV focusable button card.
* Reuses desktop image/footer construction, then strips hover overlays and linked titles.
*/
function convertDesktopCardToTvButton(sourceCard, item) {
const button = document.createElement('button');
button.type = 'button';
Array.from(sourceCard.attributes).forEach((attr) => {
if (attr.name === 'class') return;
button.setAttribute(attr.name, attr.value);
});
const classes = String(sourceCard.className || '')
.split(/\s+/)
.filter((c) => c && c !== 'card-hoverable');
if (!classes.includes('show-focus')) classes.push('show-focus');
if (!classes.includes('show-animation')) classes.push('show-animation');
if (!classes.includes('itemAction')) classes.push('itemAction');
button.className = classes.join(' ');
button.setAttribute('data-action', 'link');
button.setAttribute('aria-label', item?.Name || sourceCard.getAttribute('aria-label') || 'Unknown');
while (sourceCard.firstChild) {
button.appendChild(sourceCard.firstChild);
}
const imageLink = button.querySelector('a.cardImageContainer');
if (imageLink) {
const imageDiv = document.createElement('div');
Array.from(imageLink.attributes).forEach((attr) => {
if (attr.name === 'href') return;
imageDiv.setAttribute(attr.name, attr.value);
});
while (imageLink.firstChild) {
imageDiv.appendChild(imageLink.firstChild);
}
imageLink.replaceWith(imageDiv);
}
button.querySelectorAll('.cardOverlayContainer').forEach((el) => el.remove());
button.querySelectorAll('a.textActionButton').forEach((link) => {
const text = link.textContent || '';
const bdi = link.closest('bdi');
if (bdi) {
bdi.textContent = text;
} else {
link.replaceWith(document.createTextNode(text));
}
});
// Match Jellyfin TV backdrop image request size when easy
const imageEl = button.querySelector('.cardImageContainer[data-src]');
if (imageEl) {
const src = imageEl.getAttribute('data-src') || '';
if (src.includes('fillHeight=267') && src.includes('fillWidth=474')) {
imageEl.setAttribute(
'data-src',
src
.replace('fillHeight=267', 'fillHeight=335')
.replace('fillWidth=474', 'fillWidth=596'),
);
}
}
// Custom / external cards: overlay link was removed — navigate on activate
const customUrl = item?.cardUrl;
if (customUrl) {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (customUrl.startsWith('#') || customUrl.startsWith('/')) {
window.location.href = customUrl;
} else {
window.open(customUrl, '_blank', 'noopener,noreferrer');
}
});
}
return button;
}
/**
* Dedicated TV card builder (button root, no overlays, plain titles).
* Same argument shape as createJellyfinCardElement.
*/
function buildTvCardElement(
item,
overflowCard = false,
cardFormat = null,
customFooterText = null,
useParentCard = false,
borderStyle = null,
) {
const normalizedFormat = (cardFormat || '').toLowerCase() || null;
if (normalizedFormat === 'button') {
const libraryButton = createLibraryButtonElement(item);
libraryButton.classList.add('show-focus', 'show-animation', 'itemAction');
if (!libraryButton.getAttribute('data-action')) {
libraryButton.setAttribute('data-action', 'link');
}
return libraryButton;
}
const desktopCard = createJellyfinCardElement(
item,
overflowCard,
cardFormat,
customFooterText,
useParentCard,
borderStyle,
);
return convertDesktopCardToTvButton(desktopCard, item);
}
/**
* Resolve spotlight layout/size/tileCount from options (incl. legacy fullScreen).
* Shared by live create, skeleton create, and configure/defaults callers.
@@ -4332,6 +4442,7 @@
{
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'spotlight-section-title-container';
if (isTvLayout()) sectionTitleContainer.classList.add('focuscontainer-x');
if (title) {
let sectionTitleEl;
@@ -4342,8 +4453,10 @@
titleLink.textContent = title;
titleLink.title = 'See All';
titleLink.style.textDecoration = 'none';
if (isTvLayout()) titleLink.classList.add('show-focus');
if (typeof viewMoreUrl === 'function') {
if (isTvLayout()) titleLink.setAttribute('tabindex', '0');
titleLink.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
@@ -4673,6 +4786,7 @@
nameElement.className = 'spotlight-person-link';
nameElement.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
if (isTvLayout()) nameElement.classList.add('show-focus');
nameElement.addEventListener('click', (e) => {
e.stopPropagation();
});
@@ -4681,12 +4795,14 @@
nameElement.className = 'spotlight-genre-link';
nameElement.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
if (isTvLayout()) nameElement.classList.add('show-focus');
nameElement.addEventListener('click', (e) => e.stopPropagation());
} else if (useStudioLinks && itemData.id) {
nameElement = document.createElement('a');
nameElement.className = 'spotlight-studio-link';
nameElement.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`;
nameElement.textContent = itemData.name;
if (isTvLayout()) nameElement.classList.add('show-focus');
nameElement.addEventListener('click', (e) => e.stopPropagation());
} else {
nameElement = document.createElement('span');
@@ -4707,8 +4823,8 @@
}
});
// Tooltip when more items exist (full list on hover)
if (hasMore) {
// Tooltip when more items exist (full list on hover) — skip on TV (no hover; links steal focus)
if (hasMore && !isTvLayout()) {
// Tooltip: full list as vertical stack with clickable links (no layout shift)
const tooltip = document.createElement('div');
tooltip.className = 'spotlight-truncated-list-tooltip';
@@ -4901,11 +5017,13 @@
// Buttons container
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'spotlight-buttons-container mainDetailButtons';
if (isTvLayout()) buttonsContainer.classList.add('focuscontainer-x');
// Play button (material icon span button)
const playButton = document.createElement('button');
playButton.className = 'emby-button raised button-flat btnPlay detailButton';
playButton.title = 'Play';
if (isTvLayout()) playButton.classList.add('show-focus');
const playIcon = document.createElement('span');
playIcon.className = 'material-icons';
playIcon.textContent = 'play_arrow';
@@ -4937,6 +5055,7 @@
watchlistButton = document.createElement('button');
watchlistButton.type = 'button';
watchlistButton.className = 'watchlist-button spotlight-watchlist-button emby-button button-flat paper-icon-button-light';
if (isTvLayout()) watchlistButton.classList.add('show-focus');
watchlistButton.setAttribute('data-action', 'none');
watchlistButton.setAttribute('data-id', item.Id);
watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
@@ -4955,14 +5074,15 @@
// Info button (shows overview on hover)
const infoButton = document.createElement('button');
infoButton.className = 'emby-button button-flat spotlight-info-button paper-icon-button-light';
if (isTvLayout()) infoButton.classList.add('show-focus');
const infoIcon = document.createElement('span');
infoIcon.className = 'material-icons';
infoIcon.textContent = 'info';
infoIcon.title = 'Go To Item';
infoButton.appendChild(infoIcon);
// Overview tooltip (hidden by default, shown on hover)
if (item.Overview) {
// Overview tooltip (hover only) — skip on TV
if (item.Overview && !isTvLayout()) {
const overviewTooltip = document.createElement('div');
overviewTooltip.className = 'spotlight-overview-tooltip';
overviewTooltip.textContent = item.Overview;
@@ -4997,6 +5117,7 @@
const metadataContainer = document.createElement('div');
metadataContainer.className = 'metadata-container';
if (isTvLayout()) metadataContainer.classList.add('focuscontainer-x');
if (metadataRow.children.length > 0) {
metadataContainer.appendChild(metadataRow);
@@ -5091,6 +5212,29 @@
firstItemActive.setAttribute('data-active', 'true');
}
function syncSpotlightTvFocus() {
if (!isTvLayout()) return;
const isTooltipFocusable = (el) =>
!!el.closest?.('.spotlight-truncated-list-tooltip, .spotlight-overview-tooltip');
bannerContainer.querySelectorAll('.spotlight-item').forEach((slide) => {
const active = slide.getAttribute('data-active') === 'true';
slide.querySelectorAll('a[href], button').forEach((el) => {
if (el.classList.contains('spotlight-dot') || isTooltipFocusable(el)) {
el.setAttribute('tabindex', '-1');
return;
}
if (active) {
if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
} else {
el.setAttribute('tabindex', '-1');
}
});
});
bannerContainer.querySelectorAll('.spotlight-dot').forEach((dot) => {
dot.setAttribute('tabindex', '-1');
});
}
if (!inert) {
// Delegated handler: info button click -> navigate to item details for the current slide
bannerContainer.addEventListener('click', (e) => {
@@ -5131,6 +5275,7 @@
const prevButton = document.createElement('button');
prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
if (isTvLayout()) prevButton.classList.add('show-focus');
const prevIcon = document.createElement('span');
prevIcon.className = 'material-icons';
prevIcon.textContent = 'chevron_left';
@@ -5143,6 +5288,7 @@
const nextButton = document.createElement('button');
nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button';
if (isTvLayout()) nextButton.classList.add('show-focus');
const nextIcon = document.createElement('span');
nextIcon.className = 'material-icons';
nextIcon.textContent = 'chevron_right';
@@ -5169,6 +5315,7 @@
if (navButtonsContainer) {
const pauseButton = document.createElement('button');
pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button';
if (isTvLayout()) pauseButton.classList.add('show-focus');
const pauseIcon = document.createElement('span');
pauseIcon.className = 'material-icons';
pauseIcon.textContent = 'pause';
@@ -5494,6 +5641,8 @@
advanceDue = false;
startAutoPlay();
}
syncSpotlightTvFocus();
}
// Auto-play: single-timeout per slide, controlled only by pause/visibility (hover is ignored)
@@ -5681,8 +5830,11 @@
if (!items[idx]) return;
createAndAppendSlide(items[idx], idx, idx === currentIndex);
});
syncSpotlightTvFocus();
};
syncSpotlightTvFocus();
return container;
}
@@ -5697,6 +5849,8 @@
};
function resolveItemsLayout(sectionConfig) {
// Jellyfin TV rows are horizontal-only; never expand to grid/tiles on layout-tv
if (isTvLayout()) return 'row';
if (!sectionConfig) return 'row';
if (sectionConfig.itemsLayout === 'grid') return 'grid';
if (sectionConfig.itemsLayout === 'row') return 'row';
@@ -6080,7 +6234,7 @@
getLabel: () => getShowAllControlLabel(sectionElement),
label: 'Grid layout',
className: 'show-all-button',
isVisible: () => true,
isVisible: () => !isTvLayout(),
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
@@ -6112,7 +6266,8 @@
const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label;
const button = document.createElement('button');
button.type = 'button';
button.className = `${def.className} material-icons ${def.icon}`;
button.className = `${def.className} material-icons ${def.icon} paper-icon-button-light`;
if (isTvLayout()) button.classList.add('show-focus');
button.title = label;
button.setAttribute('aria-label', label);
button.dataset.sectionControlId = def.id;
@@ -6126,6 +6281,9 @@
});
const newMoreButton = moreButton.cloneNode(true);
if (isTvLayout()) {
newMoreButton.classList.add('paper-icon-button-light', 'show-focus');
}
moreButton.replaceWith(newMoreButton);
const MORE_CONTROLS_MODAL_ID = 'kefin-section-controls-more';
@@ -6288,6 +6446,76 @@
return document.documentElement.classList.contains('layout-mobile') || window.innerWidth < 900;
}
function createTvScrollerElement() {
const scroller = document.createElement('div');
scroller.setAttribute('is', 'emby-scroller');
scroller.setAttribute('data-centerfocus', 'true');
scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller';
scroller.style.overflow = 'hidden';
return scroller;
}
function createTvItemsSliderElement() {
const itemsContainer = document.createElement('div');
itemsContainer.setAttribute('is', 'emby-itemscontainer');
itemsContainer.className = 'scrollSlider focuscontainer-x itemsContainer animatedScrollX itemsContainer-tv';
itemsContainer.style.whiteSpace = 'nowrap';
return itemsContainer;
}
/**
* TV-mode scrollable section: native emby-scroller + itemsContainer-tv.
* No Kefin custom-scroller drag/wheel, no scroll buttons — Jellyfin owns row motion on focus.
*/
function createTvScrollableContainer(
items,
title,
viewMoreUrl = null,
overflowCard = false,
cardFormat = null,
sectionConfig = null,
) {
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container';
const facetRowType = getFacetRowType(items);
if (facetRowType) {
verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`);
verticalSection.dataset.facetRowType = facetRowType;
}
if (cardFormat) {
verticalSection.setAttribute('data-card-format', cardFormat);
}
const sectionTitleContainer = document.createElement('div');
sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
appendSectionTitleContent(sectionTitleContainer, {
title,
caption: getSectionCaption(sectionConfig),
captionUrl: getSectionCaptionUrl(sectionConfig),
viewMoreUrl,
});
ensureSectionControlsMount(sectionTitleContainer);
const scroller = createTvScrollerElement();
const itemsContainer = createTvItemsSliderElement();
const useParentCard = !!sectionConfig?.useParentCard;
const borderStyle = sectionConfig?.borderStyle || null;
items.forEach((item, index) => {
const card = buildTvCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
scroller.appendChild(itemsContainer);
verticalSection.appendChild(sectionTitleContainer);
verticalSection.appendChild(scroller);
return verticalSection;
}
function getSectionSkeletonCount(sectionConfig, cardFormat = null, itemsLayout = null) {
const raw = sectionConfig?.itemLimit || sectionConfig?.queries?.[0]?.queryOptions?.Limit || 0;
const fullLimit = raw > 0 ? raw : 16;
@@ -6974,6 +7202,7 @@
} else if (viewMoreUrl) {
const titleLink = document.createElement('a');
titleLink.className = 'sectionTitle-link button-flat button-flat-mini sectionTitleTextButton emby-button';
if (isTvLayout()) titleLink.classList.add('show-focus');
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
if (typeof viewMoreUrl === 'function') {
@@ -7015,6 +7244,7 @@
} else if (captionUrl) {
captionEl = document.createElement('a');
captionEl.className = 'sectionTitle sectionCaption button-flat button-flat-mini sectionTitleTextButton emby-button';
if (isTvLayout()) captionEl.classList.add('show-focus');
if (typeof captionUrl === 'function') {
captionEl.addEventListener('click', (e) => {
e.preventDefault();
@@ -7042,6 +7272,10 @@
sectionConfig = null,
registerScrollButtons = true,
) {
if (isTvLayout()) {
return createTvScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat, sectionConfig);
}
const normalizedFormat = (cardFormat || '').toLowerCase();
const isButtonLayout = normalizedFormat === 'button';
@@ -7322,9 +7556,13 @@
overflowCard = false,
sectionConfig = null,
) {
// Create the main vertical section container (same structure as createScrollableContainer)
const tvLayout = isTvLayout();
// Create the main vertical section container (same structure as createScrollableContainer / TV path)
const verticalSection = document.createElement('div');
verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
verticalSection.className = tvLayout
? 'verticalSection emby-scroller-container'
: 'verticalSection emby-scroller-container custom-scroller-container';
// Persist the card format if provided (ensures consistency for random/updates)
if (cardFormat) {
@@ -7355,11 +7593,11 @@
ensureSectionControlsMount(sectionTitleContainer);
const scroller = createScrollerElement();
const scroller = tvLayout ? createTvScrollerElement() : createScrollerElement();
// Sticky is=emby-itemscontainer on skeleton shell — never recreate on enhance
const itemsContainer = createItemsSliderElement(true);
const itemsContainer = tvLayout ? createTvItemsSliderElement() : createItemsSliderElement(true);
const itemsLayout = resolveItemsLayout(sectionConfig);
const itemsLayout = tvLayout ? 'row' : resolveItemsLayout(sectionConfig);
const skeletonCount = getSectionSkeletonCount(sectionConfig, cardFormat, itemsLayout);
for (let i = 0; i < skeletonCount; i++) {
const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig);
@@ -7369,11 +7607,12 @@
scroller.appendChild(itemsContainer);
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
verticalSection.appendChild(sectionTitleContainer);
if (scrollButtons) {
verticalSection.appendChild(scrollButtons);
if (!tvLayout) {
const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
if (scrollButtons) {
verticalSection.appendChild(scrollButtons);
}
}
verticalSection.appendChild(scroller);
@@ -8362,7 +8601,7 @@
) {
const cards = [];
items.forEach((item, offset) => {
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
const card = createCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
card.setAttribute('data-index', String(startIndex + offset));
itemsContainer.appendChild(card);
cards.push(card);
@@ -8454,7 +8693,7 @@
return;
}
const card = createJellyfinCardElement(
const card = createCardElement(
item,
overflowCard,
cardFormat,
@@ -8553,7 +8792,11 @@
const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
const normalizedFormat = (cardFormat || '').toLowerCase();
const layoutFromDom = itemsContainer.getAttribute('data-layout');
const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
const layout = isTvLayout()
? 'row'
: layoutFromDom === 'grid' || layoutFromDom === 'row'
? layoutFromDom
: resolveItemsLayout(sectionConfig);
const gapless = itemsContainer.getAttribute('data-gapless') === 'true' || resolveUseGaplessCards(sectionConfig);
invalidateLastRowPadding(itemsContainer);
@@ -8569,7 +8812,7 @@
const useParentCard = !!sectionConfig?.useParentCard;
const borderStyle = sectionConfig?.borderStyle || null;
items.forEach((item, index) => {
const card = createJellyfinCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
const card = createCardElement(item, overflowCard, cardFormat, item.cardFooter, useParentCard, borderStyle);
card.setAttribute('data-index', String(index));
fragment.appendChild(card);
});
+118 -41
View File
@@ -16,6 +16,11 @@
const CARD_TITLE_MENU_MODAL_ID = 'kefin-section-card-title-menu';
const RESTORE_SECTION_MODAL_ID = 'kefin-section-restore-defaults';
const PUBLISH_SECTION_MODAL_ID = 'kefin-section-publish-defaults';
const CONFIGURE_MODAL_ID = 'kefin-section-configure';
function isTvLayout() {
return document.documentElement.classList.contains('layout-tv');
}
/** True when the current pointer gesture started inside a Pickr app (drag may end outside). */
let pickrGestureFromApp = false;
@@ -1003,11 +1008,8 @@
activePopover.entry = entry;
activePopover.sectionElement = entry.element;
activePopover.anchorButton = getConfigureAnchorButton(entry);
const mountElement = preparePopoverMount(activePopover.anchorButton, entry.element);
activePopover.mountElement = mountElement;
if (activePopover.popover.parentElement !== mountElement) {
mountElement.appendChild(activePopover.popover);
}
activePopover.mountElement = resolvePopoverMount(activePopover.anchorButton, entry.element);
preparePopoverMount(activePopover.anchorButton, entry.element);
requestAnimationFrame(() => repositionActivePopover({ force: true }));
}
}
@@ -1083,7 +1085,7 @@
}
if (modal.dialogContent) {
modal.dialogContent.style.padding = '0.25em 0';
modal.dialogContent.style.overflow = 'hidden';
modal.dialogContent.style.overflow = 'visible';
modal.dialogContent.style.minHeight = '0';
}
if (modal.backdrop) {
@@ -1093,6 +1095,7 @@
modal.dialogContainer.style.pointerEvents = 'none';
}
modal.dialog.style.pointerEvents = 'auto';
modal.dialog.style.overflow = 'visible';
requestAnimationFrame(() => positionFormatMenu(modal.dialog, anchorBtn));
}
@@ -1259,6 +1262,9 @@
closeOnBackdrop: true,
closeOnEscape: true,
fixedSize: false,
returnFocus: anchorBtn,
initialFocus: '.kefin-section-format-option.is-active',
anchor: anchorBtn,
onOpen: (modalInstance) => {
styleAnchoredPopoverDialog(modalInstance, anchorBtn);
const root = modalInstance.dialogContent;
@@ -1403,7 +1409,7 @@
const content = `
<div class="kefin-section-card-title-menu-body">
<div class="kefin-section-card-title-menu-icons">
<div class="kefin-section-card-title-menu-icons" data-kefin-focus-row>
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-card-title-visibility-btn${titlesVisible ? '' : ' is-hidden-visibility'}" title="${titlesVisible ? 'Hide card titles' : 'Show card titles'}" aria-label="${titlesVisible ? 'Hide card titles' : 'Show card titles'}" aria-pressed="${titlesVisible}">
<span class="material-icons" aria-hidden="true">${titlesVisible ? 'visibility' : 'visibility_off'}</span>
</button>
@@ -1445,6 +1451,9 @@
closeOnBackdrop: true,
closeOnEscape: true,
fixedSize: false,
returnFocus: anchorBtn,
initialFocus: '.kefin-section-card-title-visibility-btn',
anchor: anchorBtn,
onOpen: (modalInstance) => {
styleAnchoredPopoverDialog(modalInstance, anchorBtn);
const root = modalInstance.dialogContent;
@@ -2135,7 +2144,7 @@
}
function closePopover() {
if (!activePopover) return;
if (!activePopover && !window.ModalSystem?.isOpen?.(CONFIGURE_MODAL_ID)) return;
closeCardTitleMenu();
if (window.ModalSystem?.isOpen?.(RESTORE_SECTION_MODAL_ID)) {
window.ModalSystem.close(RESTORE_SECTION_MODAL_ID);
@@ -2143,11 +2152,28 @@
if (window.ModalSystem?.isOpen?.(PUBLISH_SECTION_MODAL_ID)) {
window.ModalSystem.close(PUBLISH_SECTION_MODAL_ID);
}
const { popover, entry, stopTracking, mountElement, sectionElement } = activePopover;
if (window.ModalSystem?.isOpen?.(OPTION_MENU_MODAL_ID)) {
window.ModalSystem.close(OPTION_MENU_MODAL_ID);
}
if (window.ModalSystem?.isOpen?.(CARD_TITLE_MENU_MODAL_ID)) {
window.ModalSystem.close(CARD_TITLE_MENU_MODAL_ID);
}
// Closing the ModalSystem dialog runs onClose → finalizeConfigurePopoverClose
if (window.ModalSystem?.isOpen?.(CONFIGURE_MODAL_ID)) {
window.ModalSystem.close(CONFIGURE_MODAL_ID);
return;
}
finalizeConfigurePopoverClose();
}
function finalizeConfigurePopoverClose() {
if (!activePopover) return;
const { entry, stopTracking, mountElement, sectionElement, outsideHandler } = activePopover;
stopTracking?.();
popover.remove();
if (outsideHandler) {
document.removeEventListener('click', outsideHandler, true);
}
cleanupPopoverMount(mountElement, sectionElement || entry?.element);
document.removeEventListener('click', activePopover.outsideHandler, true);
pickrGestureFromApp = false;
activePopover = null;
}
@@ -2306,18 +2332,17 @@
if (activePopover) activePopover.anchorMode = placeBelow ? 'below' : 'above';
}
const scrollX = window.scrollX || window.pageXOffset || 0;
const scrollY = window.scrollY || window.pageYOffset || 0;
popover.classList.toggle('is-below', placeBelow);
popover.style.position = 'absolute';
popover.style.left = `${leftViewport + scrollX}px`;
popover.style.position = 'fixed';
popover.style.left = `${leftViewport}px`;
popover.style.bottom = 'auto';
popover.style.right = 'auto';
popover.style.margin = '0';
if (placeBelow) {
popover.style.top = `${buttonRect.bottom + gap + scrollY}px`;
popover.style.top = `${buttonRect.bottom + gap}px`;
} else {
const height = getPopoverPlacementHeight(popover, popover.classList.contains('is-expanded'));
popover.style.top = `${Math.max(scrollY + margin, buttonRect.top - gap - height + scrollY)}px`;
popover.style.top = `${Math.max(margin, buttonRect.top - gap - height)}px`;
}
}
@@ -2390,11 +2415,16 @@
const spotlightTilesLabel = spotlightNextTileCount === 1
? '1 tile'
: `${spotlightNextTileCount} tiles`;
const popover = document.createElement('div');
popover.className = 'kefin-section-configure-popover dialog';
popover.innerHTML = `
if (!window.ModalSystem?.create) {
WARN('ModalSystem unavailable for configure popover');
return;
}
const contentRoot = document.createElement('div');
contentRoot.className = 'kefin-section-configure-popover-body';
contentRoot.innerHTML = `
<div class="kefin-section-configure-title-container">
<div class="kefin-section-configure-title-row">
<div class="kefin-section-configure-title-row" data-kefin-focus-row>
<h3>${escapeHtml(sectionConfig.name)}</h3>
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-restore-defaults-btn${sectionDirty ? '' : ' is-disabled'}" title="Restore defaults" aria-label="Restore defaults" ${sectionDirty ? '' : 'hidden disabled'} aria-disabled="${sectionDirty ? 'false' : 'true'}">
<span class="material-icons" aria-hidden="true">settings_backup_restore</span>
@@ -2412,14 +2442,14 @@
</div>
<hr class="kefin-section-configure-separator" aria-hidden="true">
</div>
<div class="kefin-section-configure-more-panel">
<div class="kefin-section-configure-more-panel-inner">
<div class="kefin-section-configure-more-panel" aria-hidden="true">
<div class="kefin-section-configure-more-panel-inner" data-kefin-focus-row>
${moreModeRows}
</div>
</div>
<hr class="kefin-section-configure-separator" aria-hidden="true">
<div class="kefin-section-configure-footer">
<div class="kefin-section-configure-primary-row">
<div class="kefin-section-configure-primary-row" data-kefin-focus-row>
<div class="kefin-section-configure-order">
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-order-up" title="Move section up. Long press to move to top." aria-label="Move section up"><span class="material-icons" aria-hidden="true">arrow_upward</span></button>
<button type="button" is="paper-icon-button-light" class="paper-icon-button-light emby-button kefin-section-order-down" title="Move section down. Long press to move to bottom." aria-label="Move section down"><span class="material-icons" aria-hidden="true">arrow_downward</span></button>
@@ -2458,9 +2488,46 @@
`;
const mountElement = preparePopoverMount(anchorButton, sectionElement);
mountElement.appendChild(popover);
applyPresentationToElement(entry);
const modal = window.ModalSystem.create({
id: CONFIGURE_MODAL_ID,
title: null,
content: contentRoot,
footer: null,
showCloseButton: false,
closeOnBackdrop: true,
closeOnEscape: true,
fixedSize: false,
returnFocus: anchorButton,
initialFocus: '.kefin-section-order-up',
dialogClassName: 'kefin-section-configure-popover',
onClose: () => {
finalizeConfigurePopoverClose();
},
onOpen: (modalInstance) => {
if (modalInstance.backdrop) modalInstance.backdrop.style.background = 'transparent';
if (modalInstance.dialogContainer) modalInstance.dialogContainer.style.pointerEvents = 'none';
if (modalInstance.dialog) {
modalInstance.dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
modalInstance.dialog.style.pointerEvents = 'auto';
modalInstance.dialog.style.maxHeight = 'none';
modalInstance.dialog.style.minHeight = '0';
modalInstance.dialog.style.height = 'auto';
modalInstance.dialog.style.width = 'auto';
}
if (modalInstance.dialogContent) {
modalInstance.dialogContent.style.padding = '0';
modalInstance.dialogContent.style.overflow = 'visible';
modalInstance.dialogContent.style.minHeight = '0';
modalInstance.dialogContent.style.flex = '0 0 auto';
}
},
});
const popover = modal.dialog;
// Queries / expand state use the dialog root (has kefin-section-configure-popover)
const openEditorBtn = popover.querySelector('.kefin-section-open-editor-btn');
const publishBtn = popover.querySelector('.kefin-section-publish-btn');
if (openEditorBtn) {
@@ -2748,21 +2815,19 @@
popover.classList.toggle('is-expanded', willExpand);
const moreBtn = popover.querySelector('.kefin-section-more-toggle');
if (moreBtn) moreBtn.setAttribute('aria-expanded', String(willExpand));
const morePanel = popover.querySelector('.kefin-section-configure-more-panel');
if (morePanel) {
if (willExpand) morePanel.removeAttribute('aria-hidden');
else morePanel.setAttribute('aria-hidden', 'true');
}
});
// Backdrop close is handled by ModalSystem; ignore Pickr / nested modal hosts via capture filter
const outsideHandler = (e) => {
// Drag started in Pickr but released outside — not a real dismiss click
if (pickrGestureFromApp) return;
if (popover.contains(e.target) || anchorButton.contains(e.target)) return;
// Pickr mounts its app on document.body (outside our modals)
if (e.target.closest?.('.pcr-app, .pickr, .kefin-pickr-app')) return;
const modalHost = e.target.closest?.('[data-modal-id]');
const modalId = modalHost?.getAttribute('data-modal-id');
if (modalId === OPTION_MENU_MODAL_ID || modalId === CARD_TITLE_MENU_MODAL_ID || modalId === RESTORE_SECTION_MODAL_ID || modalId === PUBLISH_SECTION_MODAL_ID) return;
if (e.target.closest(
'.kefin-section-format-btn, .kefin-section-border-style-btn, .kefin-section-card-title-btn, .kefin-section-spotlight-layout-btn, .kefin-section-spotlight-size-btn, .kefin-section-spotlight-tiles-btn'
)) return;
closePopover();
if (e.target.closest?.('.pcr-app, .pickr, .kefin-pickr-app')) {
e.stopPropagation();
}
};
setTimeout(() => document.addEventListener('click', outsideHandler, true), 0);
@@ -2770,6 +2835,7 @@
activePopover = {
popover,
modal,
entry,
anchorButton,
mountElement,
@@ -2797,7 +2863,8 @@
function createConfigureButton(sectionConfig, sectionElement) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'section-configure-button material-icons settings';
button.className = 'section-configure-button material-icons settings paper-icon-button-light';
if (isTvLayout()) button.classList.add('show-focus');
button.title = 'Configure';
button.setAttribute('aria-label', 'Configure');
button.addEventListener('click', (e) => {
@@ -2877,16 +2944,25 @@
overflow: visible;
}
.kefin-section-configure-popover {
position: absolute;
position: fixed;
z-index: 12000;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: auto;
min-width: 14rem;
max-width: min(100vw - 16px, 380px);
padding: 0.5em;
overflow: hidden;
overflow: visible;
}
.kefin-section-configure-popover.dialog.formDialog,
.kefin-section-configure-popover.dialog {
background: var(--dialog-bg, #101010);
}
.kefin-section-configure-popover [data-name="kefin-modal-content"] {
padding: 0 !important;
overflow: visible !important;
flex: 0 0 auto !important;
min-height: 0 !important;
}
.kefin-section-configure-popover.is-below {
flex-direction: column-reverse;
@@ -2939,6 +3015,7 @@
display: flex;
align-items: center;
gap: 0.2em;
padding: 0.15em;
}
.kefin-section-configure-order {
display: flex;
+344 -49
View File
@@ -14,6 +14,20 @@ window.ModalSystem = (function() {
let savedBodyOverflow = null;
let savedHtmlOverflow = null;
const FOCUSABLE_SELECTOR = [
'button:not([disabled]):not([tabindex="-1"])',
'[href]:not([tabindex="-1"])',
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"])',
'[role="button"]:not([tabindex="-1"])',
].join(',');
function isTvLayout() {
return document.documentElement.classList.contains('layout-tv');
}
function lockBodyScroll() {
if (bodyScrollLockCount === 0) {
savedBodyOverflow = document.body.style.overflow;
@@ -52,20 +66,243 @@ window.ModalSystem = (function() {
dialog.addEventListener('animationend', onEnd);
}
function getFocusableElements(root) {
if (!root) return [];
return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => {
if (el.closest('[hidden], [aria-hidden="true"]')) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (el.disabled) return false;
return true;
});
}
function stampShowFocus(root) {
if (!root || !isTvLayout()) return;
root.querySelectorAll('button, a.emby-button, .emby-button, .paper-icon-button-light, [role="button"]').forEach((el) => {
if (el.getAttribute('tabindex') === '-1') return;
el.classList.add('show-focus');
});
}
function resolveInitialFocus(root, initialFocus) {
if (!root) return null;
if (initialFocus instanceof HTMLElement && root.contains(initialFocus)) {
return initialFocus;
}
if (typeof initialFocus === 'string') {
const bySelector = root.querySelector(initialFocus);
if (bySelector) return bySelector;
}
return (
root.querySelector('[data-autofocus]') ||
root.querySelector('.is-active') ||
root.querySelector('.selected') ||
getFocusableElements(root)[0] ||
null
);
}
/**
* Position a dialog near an anchor (fixed coords, viewport-clamped).
* @param {HTMLElement} dialog
* @param {HTMLElement} anchor
*/
function positionNearAnchor(dialog, anchor) {
if (!dialog || !anchor) return;
const margin = 8;
const gap = 8;
const edgeMargin = 16;
const btnRect = anchor.getBoundingClientRect();
const dialogWidth = dialog.offsetWidth || dialog.getBoundingClientRect().width || 200;
const dialogHeight = dialog.offsetHeight || dialog.getBoundingClientRect().height || 120;
const spaceBelow = window.innerHeight - btnRect.bottom - margin;
const spaceAbove = btnRect.top - margin;
const placeBelow = spaceBelow >= Math.min(spaceAbove, 160) || spaceBelow >= spaceAbove;
let left = btnRect.left;
left = Math.min(
Math.max(edgeMargin, left),
Math.max(edgeMargin, window.innerWidth - dialogWidth - edgeMargin),
);
let top;
if (placeBelow) {
top = btnRect.bottom + gap;
} else {
top = Math.max(margin, btnRect.top - gap - dialogHeight);
}
top = Math.min(Math.max(top, margin), window.innerHeight - Math.min(dialogHeight, window.innerHeight - margin * 2) - margin);
dialog.style.position = 'fixed';
dialog.style.left = `${left}px`;
dialog.style.top = `${top}px`;
dialog.style.margin = '0';
dialog.style.right = 'auto';
dialog.style.bottom = 'auto';
}
function focusElement(el) {
if (!el || typeof el.focus !== 'function') return;
try {
el.focus({ preventScroll: true });
} catch (_) {
el.focus();
}
}
function owningDialog(el) {
return el && typeof el.closest === 'function' ? el.closest('.dialog') : null;
}
function getFocusRows(dialogEl) {
const rows = Array.from(dialogEl.querySelectorAll('[data-kefin-focus-row]'));
const withItems = rows.map((row) => ({
row,
items: getFocusableElements(row).filter((el) => el.closest('[data-kefin-focus-row]') === row),
})).filter((entry) => entry.items.length);
withItems.sort((a, b) => {
const at = a.row.getBoundingClientRect();
const bt = b.row.getBoundingClientRect();
if (Math.abs(at.top - bt.top) > 4) return at.top - bt.top;
return at.left - bt.left;
});
return withItems;
}
/**
* Attach focus trap to a dialog element. Returns a dispose function.
* @param {HTMLElement} dialogEl
* @param {{ returnFocusEl?: HTMLElement|null, initialFocusEl?: HTMLElement|string|null, onEscape?: Function|null }} options
*/
function attachFocusTrap(dialogEl, options = {}) {
if (!dialogEl) return () => {};
const returnFocusEl = options.returnFocusEl || null;
const onEscape = typeof options.onEscape === 'function' ? options.onEscape : null;
dialogEl.classList.add('focuscontainer');
stampShowFocus(dialogEl);
const focusInitial = () => {
const target = resolveInitialFocus(dialogEl, options.initialFocusEl);
if (target) {
focusElement(target);
} else if (typeof dialogEl.focus === 'function') {
if (!dialogEl.hasAttribute('tabindex')) dialogEl.setAttribute('tabindex', '-1');
focusElement(dialogEl);
}
};
requestAnimationFrame(() => {
requestAnimationFrame(focusInitial);
});
const keydownHandler = (e) => {
if (!dialogEl.isConnected) return;
const active = document.activeElement;
const activeDialog = owningDialog(active);
// Nested/child modal owns keys until it closes
if (activeDialog && activeDialog !== dialogEl) return;
const isNavKey = e.key === 'ArrowDown' || e.key === 'ArrowUp'
|| e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Tab';
if (!dialogEl.contains(active) && e.key !== 'Escape') {
if (isNavKey) {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
focusInitial();
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
if (onEscape) onEscape(e);
return;
}
const focusables = getFocusableElements(dialogEl);
if (!focusables.length) return;
const currentIndex = focusables.indexOf(active);
const at = currentIndex >= 0 ? currentIndex : 0;
const rows = getFocusRows(dialogEl);
const useRows = rows.length >= 2;
const consume = () => {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
};
if (e.key === 'Tab') {
consume();
const next = e.shiftKey
? focusables[(at - 1 + focusables.length) % focusables.length]
: focusables[(at + 1) % focusables.length];
focusElement(next);
return;
}
if (useRows && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
consume();
let rowIndex = rows.findIndex((entry) => entry.items.includes(active));
if (rowIndex < 0) {
rowIndex = rows.findIndex((entry) => entry.row.contains(active));
}
if (rowIndex < 0) rowIndex = 0;
const row = rows[rowIndex];
let col = row.items.indexOf(active);
if (col < 0) col = 0;
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const nextCol = e.key === 'ArrowRight'
? (col + 1) % row.items.length
: (col - 1 + row.items.length) % row.items.length;
focusElement(row.items[nextCol]);
return;
}
const nextRowIndex = e.key === 'ArrowDown'
? (rowIndex + 1) % rows.length
: (rowIndex - 1 + rows.length) % rows.length;
const nextRow = rows[nextRowIndex];
const nextCol = Math.min(col, nextRow.items.length - 1);
focusElement(nextRow.items[nextCol]);
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
consume();
focusElement(focusables[(at + 1) % focusables.length]);
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
consume();
focusElement(focusables[(at - 1 + focusables.length) % focusables.length]);
}
};
// Document capture so Left/Right are not taken by Jellyfin spatial navigation
document.addEventListener('keydown', keydownHandler, true);
return () => {
document.removeEventListener('keydown', keydownHandler, true);
if (returnFocusEl && typeof returnFocusEl.focus === 'function' && document.contains(returnFocusEl)) {
focusElement(returnFocusEl);
}
};
}
/**
* Create a Jellyfin-style modal dialog
* @param {Object} options - Modal configuration
* @param {string} options.id - Unique modal ID
* @param {string} options.title - Modal title
* @param {string|HTMLElement} options.content - HTML content for the modal body
* @param {string|HTMLElement} options.footer - Optional footer HTML content
* @param {Function} options.onClose - Callback when modal closes
* @param {Function} options.onOpen - Callback when modal opens
* @param {boolean} options.closeOnBackdrop - Whether to close when clicking backdrop (default: true)
* @param {boolean} options.closeOnEscape - Whether to close on Escape key (default: true)
* @param {boolean} options.showCloseButton - Whether to show close button in header (default: true if title exists)
* @param {boolean} [options.fixedSize] - Use dialog-fixedSize. If undefined, enable when window width < 900.
* @param {Object} [options.dialogStyle] - CSS styles applied to the dialog before open (e.g. width/height).
* @returns {Object} Modal instance
*/
function createModal(options = {}) {
@@ -80,7 +317,12 @@ window.ModalSystem = (function() {
closeOnEscape = true,
showCloseButton = true,
fixedSize,
dialogStyle
dialogStyle,
returnFocus,
initialFocus,
trapFocus,
anchor = null,
dialogClassName = '',
} = options;
if (!id) {
@@ -94,10 +336,20 @@ window.ModalSystem = (function() {
|| (fixedSize !== false && typeof window !== 'undefined' && window.innerWidth < 900);
let _showCloseButton = showCloseButton;
if (window.innerWidth < 900 && useFixedSize) {
if (title == null && showCloseButton === false) {
_showCloseButton = false;
} else if (window.innerWidth < 900 && useFixedSize && showCloseButton !== false && title) {
_showCloseButton = true;
}
const shouldTrap = trapFocus === true || (trapFocus !== false && isTvLayout());
const returnFocusEl =
returnFocus instanceof HTMLElement
? returnFocus
: document.activeElement instanceof HTMLElement
? document.activeElement
: null;
// Create modal elements
const backdrop = document.createElement('div');
backdrop.className = 'dialogBackdrop dialogBackdropOpened';
@@ -108,7 +360,8 @@ window.ModalSystem = (function() {
dialogContainer.setAttribute('data-modal-id', id);
const dialog = document.createElement('div');
dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened`;
const extraClasses = dialogClassName ? ` ${dialogClassName}` : '';
dialog.className = `focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog ${useFixedSize ? 'dialog-fixedSize' : 'centeredDialog'} opened${extraClasses}`;
dialog.setAttribute('data-history', 'true');
dialog.setAttribute('data-autofocus', 'true');
dialog.setAttribute('data-removeonclose', 'true');
@@ -126,42 +379,43 @@ window.ModalSystem = (function() {
// Create header if title is provided
let dialogHeader = null;
dialogHeader = document.createElement('div');
dialogHeader.className = 'formDialogHeader';
dialogHeader.style.display = 'flex';
dialogHeader.style.justifyContent = 'space-between';
dialogHeader.style.alignItems = 'center';
dialogHeader.style.padding = '1.25em 1.5em';
dialogHeader.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
dialogHeader.style.flexShrink = '0';
if (title) {
const titleElement = document.createElement('h2');
titleElement.style.margin = '0';
titleElement.style.textAlign = 'left';
titleElement.textContent = title;
dialogHeader.appendChild(titleElement);
}
if (title || _showCloseButton) {
dialogHeader = document.createElement('div');
dialogHeader.className = 'formDialogHeader';
dialogHeader.style.display = 'flex';
dialogHeader.style.justifyContent = 'space-between';
dialogHeader.style.alignItems = 'center';
dialogHeader.style.padding = '1.25em 1.5em';
dialogHeader.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
dialogHeader.style.flexShrink = '0';
// Add close button if enabled
if (_showCloseButton) {
const closeButton = document.createElement('button');
closeButton.setAttribute('is', 'paper-icon-button-light');
closeButton.className = 'btnCancel btnClose autoSize paper-icon-button-light';
closeButton.setAttribute('tabindex', '-1');
closeButton.title = 'Close';
closeButton.onclick = () => closeModal(id);
if (title) {
const titleElement = document.createElement('h2');
titleElement.style.margin = '0';
titleElement.style.textAlign = 'left';
titleElement.textContent = title;
dialogHeader.appendChild(titleElement);
}
const closeIcon = document.createElement('span');
closeIcon.className = 'material-icons close';
closeIcon.setAttribute('aria-hidden', 'true');
closeButton.appendChild(closeIcon);
if (_showCloseButton) {
const closeButton = document.createElement('button');
closeButton.setAttribute('is', 'paper-icon-button-light');
closeButton.className = 'btnCancel btnClose autoSize paper-icon-button-light';
closeButton.setAttribute('tabindex', '-1');
closeButton.title = 'Close';
closeButton.onclick = () => closeModal(id);
dialogHeader.appendChild(closeButton);
}
const closeIcon = document.createElement('span');
closeIcon.className = 'material-icons close';
closeIcon.setAttribute('aria-hidden', 'true');
closeButton.appendChild(closeIcon);
if (dialogHeader.childNodes.length > 0) {
dialog.appendChild(dialogHeader);
dialogHeader.appendChild(closeButton);
}
if (dialogHeader.childNodes.length > 0) {
dialog.appendChild(dialogHeader);
}
}
// Create scrollable content area
@@ -227,11 +481,15 @@ window.ModalSystem = (function() {
dialogHeader,
dialogFooter,
isOpen: true,
onClose: typeof onClose === 'function' ? onClose : null,
returnFocusEl,
close: () => closeModal(id),
updateContent: (newContent) => updateModalContent(id, newContent),
addEventListener: (event, handler) => {
dialog.addEventListener(event, handler);
}
},
_disposeFocusTrap: null,
_escapeHandler: null,
};
// Store modal instance
@@ -248,7 +506,7 @@ window.ModalSystem = (function() {
});
}
if (closeOnEscape) {
if (closeOnEscape && !shouldTrap) {
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal(id);
@@ -258,6 +516,27 @@ window.ModalSystem = (function() {
modalInstance._escapeHandler = escapeHandler;
}
if (anchor instanceof HTMLElement) {
dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
dialog.style.maxHeight = 'none';
if (dialogContent) {
dialogContent.style.padding = dialogContent.style.padding || '0.25em 0';
}
requestAnimationFrame(() => {
positionNearAnchor(dialog, anchor);
});
}
if (shouldTrap) {
modalInstance._disposeFocusTrap = attachFocusTrap(dialog, {
returnFocusEl,
initialFocusEl: initialFocus,
onEscape: closeOnEscape ? () => closeModal(id) : null,
});
} else if (isTvLayout()) {
stampShowFocus(dialog);
}
// Call onOpen callback
if (onOpen && typeof onOpen === 'function') {
onOpen(modalInstance);
@@ -277,6 +556,18 @@ window.ModalSystem = (function() {
// Remove escape handler if it exists
if (modal._escapeHandler) {
document.removeEventListener('keydown', modal._escapeHandler);
modal._escapeHandler = null;
}
if (typeof modal._disposeFocusTrap === 'function') {
modal._disposeFocusTrap();
modal._disposeFocusTrap = null;
} else if (modal.returnFocusEl && typeof modal.returnFocusEl.focus === 'function' && document.contains(modal.returnFocusEl)) {
try {
modal.returnFocusEl.focus({ preventScroll: true });
} catch (_) {
modal.returnFocusEl.focus();
}
}
// Remove from DOM
@@ -323,6 +614,7 @@ window.ModalSystem = (function() {
modal.dialogContent.innerHTML = '';
modal.dialogContent.appendChild(content);
}
stampShowFocus(modal.dialog);
}
/**
@@ -359,6 +651,9 @@ window.ModalSystem = (function() {
isOpen: isModalOpen,
getActiveIds: getActiveModalIds,
closeAll: closeAllModals,
applyOpenAnimation
applyOpenAnimation,
attachFocusTrap,
positionNearAnchor,
getFocusableElements,
};
})();