From eda0a94420949ec33934222cb6eb405721aa1cae Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Mon, 21 Sep 2026 03:01:43 -0400 Subject: [PATCH] Add Home Screen Category filters and fix bugs related to Home Screen not rendering when navigating back to it --- configuration/homeScreen-configuration.css | 4 +- configuration/homeScreen-configuration.js | 228 +++++++++- .../homeScreen-user-configuration.js | 33 +- configuration/homeScreenAdvancedEditor.css | 14 +- configuration/homeScreenAdvancedEditor.js | 78 +++- scripts/HomeScreenSection.ts | 6 +- scripts/cardBuilder.css | 73 ++-- scripts/cardBuilder.js | 67 ++- scripts/homeScreen.css | 126 +++++- scripts/homeScreen3.js | 410 ++++++++++++++++-- scripts/homeScreenConfig2.js | 9 +- scripts/homeScreenSectionConfigure.js | 173 ++++++-- 12 files changed, 1069 insertions(+), 152 deletions(-) diff --git a/configuration/homeScreen-configuration.css b/configuration/homeScreen-configuration.css index ba0f8db..653ebc3 100644 --- a/configuration/homeScreen-configuration.css +++ b/configuration/homeScreen-configuration.css @@ -169,7 +169,7 @@ span.listItemBodyText.secondary.seasonal-tag { white-space: nowrap; } -@media (max-width: 900px) { +@media (max-width: 899px) { [data-modal-id="kefin-homescreen-config-main"] .hsc-split-pane { grid-template-columns: 1fr; gap: 1em; @@ -315,7 +315,7 @@ span.listItemBodyText.secondary.seasonal-tag { align-items: end; } -@media (max-width: 900px) { +@media (max-width: 899px) { [data-modal-id="kefin-homescreen-config-main"] #global-settings-content .hsae-field-grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js index f422547..430218b 100644 --- a/configuration/homeScreen-configuration.js +++ b/configuration/homeScreen-configuration.js @@ -70,6 +70,18 @@ 'home-loadPeopleEpisodeData': { on: 'Loads all Person appearances from all episodes in your library. This data takes much longer to cache than just the Movies/Series level data.', off: 'Person appearance data is limited to only Movies and Series.' + }, + 'home-showCreateSectionButtonOnHome': { + on: 'Adds a hidden button to the bottom left corner of the home screen so that you can quickly create a new section directly from the Home Screen. Only admins will see this button.', + off: 'You must use the KefinTweaks Configuration > Home Screen > New Section flow to create new Home Screen sections.' + }, + 'home-showCategoryFilters': { + on: 'Category Filters will appear on the Home Screen to quickly navigate between Discovery, Pinned or any custom Category sections.', + off: 'Category Filters will not appear on the Home Screen.' + }, + 'home-renderCategoriesSeparately': { + on: 'Sections will only appear when their defined category is selected on the Home Screen. Discovery sections will only load when the Discovery category is selected.', + off: 'All sections will be visible when the default Home category is selected. Discovery sections will render at the bottom of the Home and Discovery categories.' } }; @@ -1184,6 +1196,17 @@ mergedConfig.HOME_SETTINGS.minPeopleAppearancesEpisodes = legacyPeopleMin; } + mergedConfig.HOME_SETTINGS.categories = normalizeHomeCategories(mergedConfig.HOME_SETTINGS.categories); + if (mergedConfig.HOME_SETTINGS.renderCategoriesSeparately == null) { + mergedConfig.HOME_SETTINGS.renderCategoriesSeparately = false; + } + if (mergedConfig.HOME_SETTINGS.showCategoryFilters == null) { + mergedConfig.HOME_SETTINGS.showCategoryFilters = true; + } + if (mergedConfig.HOME_SETTINGS.showCreateSectionButtonOnHome == null) { + mergedConfig.HOME_SETTINGS.showCreateSectionButtonOnHome = false; + } + // Add ENABLED_NORMAL_SECTIONS and ENABLED_DISCOVERY_SECTIONS // ENABLED_NORMAL_SECTIONS is all enabled sections from HOME_SECTION_GROUPS, SEASONAL_SECTION_GROUPS, and CUSTOM_SECTION_GROUPS that aren't discovery sections // If they have a start/end date the current date must fall within that date range @@ -1948,6 +1971,7 @@ { id: 'general', label: 'General Settings' }, { id: 'spotlight', label: 'Spotlight Settings' }, { id: 'discovery', label: 'Discovery Settings' }, + { id: 'categories', label: 'Category Settings' }, { id: 'cache', label: 'Cache Settings' } ]; return ` @@ -1972,6 +1996,7 @@ case 'general': return buildGeneralSettingsHTML(); case 'spotlight': return se().buildGlobalSpotlightSettingsHTML?.(currentConfig) || ''; case 'discovery': return buildDiscoverySettingsHTML(); + case 'categories': return buildCategorySettingsHTML(); case 'cache': return buildCacheSettingsHTML(); default: return buildGeneralSettingsHTML(); } @@ -2237,6 +2262,9 @@
${hscToggleCard('home-ensureThumbsForPopularTVNetworks', homeSettings.ensureThumbsForPopularTVNetworks === true, 'Require Studio Thumbs', D)}
+
+ ${hscToggleCard('home-showCreateSectionButtonOnHome', homeSettings.showCreateSectionButtonOnHome === true, 'Create Section button on Home Screen', D)} +
${buildTextInput('home-dismissEmptySectionTimer', homeSettings.dismissEmptySectionTimer ?? 0, 'Empty Section Dismiss Timer (ms)', 'number')}
@@ -2298,6 +2326,193 @@ return ''; } + const CATEGORY_SENTINEL_IDS = new Set(['none', 'discovery', 'pinned']); + + function normalizeHomeCategories(list) { + const defaults = window.KefinHomeConfig2?.HOME_SETTINGS?.categories || []; + const incoming = Array.isArray(list) ? list : []; + const byId = new Map(); + defaults.forEach((cat) => { + if (cat?.id) byId.set(cat.id, { ...cat }); + }); + incoming.forEach((cat) => { + if (!cat?.id) return; + const prev = byId.get(cat.id) || {}; + byId.set(cat.id, { + id: cat.id, + name: cat.name || prev.name || cat.id, + icon: cat.icon || prev.icon || 'label' + }); + }); + const ordered = []; + const seen = new Set(); + incoming.forEach((cat) => { + if (!cat?.id || seen.has(cat.id)) return; + ordered.push(byId.get(cat.id)); + seen.add(cat.id); + }); + defaults.forEach((cat) => { + if (!cat?.id || seen.has(cat.id)) return; + ordered.push(byId.get(cat.id)); + seen.add(cat.id); + }); + return ordered.filter(Boolean); + } + + function buildCategoryRowHTML(cat, index, total) { + const isSentinel = CATEGORY_SENTINEL_IDS.has(cat.id); + return ` +
+ ${index + 1}. + + + + + ${isSentinel ? '' : ` + `} +
+ `; + } + + function buildCategorySettingsHTML() { + if (!currentConfig) return ''; + const homeSettings = currentConfig.HOME_SETTINGS || {}; + const categories = normalizeHomeCategories(homeSettings.categories); + const D = GENERAL_TOGGLE_DESCRIPTIONS; + return ` +
+
+ Categories control which Home Screen sections are visible from the fixed category rail. Custom sections can be assigned a category in the section editor. +
+
+ ${hscToggleCard('home-showCategoryFilters', homeSettings.showCategoryFilters !== false, 'Show Category Filters', D)} + ${hscToggleCard('home-renderCategoriesSeparately', homeSettings.renderCategoriesSeparately === true, 'Render Categories Separately', D)} +
+
Categories
+
+ Home, Discover, and Pinned are built-in. Their ids cannot change and they cannot be deleted. Custom categories get a stable id when created. +
+
+ ${categories.map((cat, i) => buildCategoryRowHTML(cat, i, categories.length)).join('')} +
+
+ + + +
+
+ `; + } + + function collectCategoriesFromForm(root) { + const rows = Array.from(root.querySelectorAll('.kefin-home-category-row')); + return rows.map((row) => ({ + id: row.dataset.categoryId, + name: (row.querySelector('.kefin-home-category-name')?.value || '').trim() || row.dataset.categoryId, + icon: (row.querySelector('.kefin-home-category-icon')?.value || '').trim() || 'label' + })).filter((cat) => cat.id); + } + + function refreshCategoryListUI(root) { + const list = root.querySelector('#kefin-home-category-list'); + if (!list) return; + const categories = normalizeHomeCategories(currentConfig.HOME_SETTINGS?.categories); + list.innerHTML = categories.map((cat, i) => buildCategoryRowHTML(cat, i, categories.length)).join(''); + } + + function wireCategorySettingsListeners(root) { + if (!root?.querySelector('#kefin-home-category-list')) return; + + const persistCategories = () => { + currentConfig.HOME_SETTINGS = { + ...(currentConfig.HOME_SETTINGS || {}), + showCategoryFilters: root.querySelector('#home-showCategoryFilters')?.checked === true, + renderCategoriesSeparately: root.querySelector('#home-renderCategoriesSeparately')?.checked === true, + categories: normalizeHomeCategories(collectCategoriesFromForm(root)) + }; + applyGlobalSettingsFromForm({ save: true }); + try { + window.homeScreen3?.refreshHomeCategoryChrome?.(); + } catch (_) { /* ignore */ } + }; + + root.querySelector('#home-showCategoryFilters')?.addEventListener('change', () => { + updateHscToggleCardHints(root, GENERAL_TOGGLE_DESCRIPTIONS); + persistCategories(); + }); + + root.querySelector('#home-renderCategoriesSeparately')?.addEventListener('change', () => { + updateHscToggleCardHints(root, GENERAL_TOGGLE_DESCRIPTIONS); + persistCategories(); + }); + + root.addEventListener('change', (e) => { + if (e.target.matches?.('.kefin-home-category-name, .kefin-home-category-icon')) { + persistCategories(); + } + }); + + root.addEventListener('click', (e) => { + const up = e.target.closest?.('.kefin-home-category-up'); + const down = e.target.closest?.('.kefin-home-category-down'); + const remove = e.target.closest?.('.kefin-home-category-remove'); + const add = e.target.closest?.('#kefin-home-category-add-btn'); + if (!up && !down && !remove && !add) return; + + let categories = collectCategoriesFromForm(root); + + if (add) { + const name = (root.querySelector('#kefin-home-category-add-name')?.value || '').trim(); + const icon = (root.querySelector('#kefin-home-category-add-icon')?.value || '').trim() || 'label'; + if (!name) return; + const id = `cat_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + categories.push({ id, name, icon }); + const nameInput = root.querySelector('#kefin-home-category-add-name'); + const iconInput = root.querySelector('#kefin-home-category-add-icon'); + if (nameInput) nameInput.value = ''; + if (iconInput) iconInput.value = ''; + } else { + const index = parseInt((up || down || remove).dataset.index, 10); + if (!Number.isFinite(index)) return; + if (up && index > 0) { + [categories[index - 1], categories[index]] = [categories[index], categories[index - 1]]; + } else if (down && index < categories.length - 1) { + [categories[index + 1], categories[index]] = [categories[index], categories[index + 1]]; + } else if (remove) { + const id = categories[index]?.id; + if (CATEGORY_SENTINEL_IDS.has(id)) return; + categories.splice(index, 1); + } + } + + currentConfig.HOME_SETTINGS = { + ...(currentConfig.HOME_SETTINGS || {}), + showCategoryFilters: root.querySelector('#home-showCategoryFilters')?.checked === true, + renderCategoriesSeparately: root.querySelector('#home-renderCategoriesSeparately')?.checked === true, + categories: normalizeHomeCategories(categories) + }; + refreshCategoryListUI(root); + applyGlobalSettingsFromForm({ save: true }); + try { + window.homeScreen3?.refreshHomeCategoryChrome?.(); + } catch (_) { /* ignore */ } + }); + } + + function escapeHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + /** * Convert milliseconds to minutes */ @@ -2427,6 +2642,7 @@ || root.querySelector('#libraryCache-mdblistApiKey') || root.querySelector('#home-minPeopleAppearancesTotal') || root.querySelector('#home-dismissEmptySectionTimer') + || root.querySelector('#home-showCreateSectionButtonOnHome') || root.querySelector('#home-loadPeopleEpisodeData')) { const prevLoadPeopleEpisodeData = currentConfig.HOME_SETTINGS?.loadPeopleEpisodeData === true; currentConfig.SEASONAL_THEME_SETTINGS = { @@ -2447,6 +2663,7 @@ ensureThumbsForPopularTVNetworks: root.querySelector('#home-ensureThumbsForPopularTVNetworks')?.checked === true, showStaleDataBeforeRefresh: root.querySelector('#home-showStaleDataBeforeRefresh')?.checked === true, dismissEmptySectionTimer: Number.isFinite(dismissEmptyRaw) && dismissEmptyRaw > 0 ? dismissEmptyRaw : 0, + showCreateSectionButtonOnHome: root.querySelector('#home-showCreateSectionButtonOnHome')?.checked === true, minPeopleAppearancesTotal: parseInt(root.querySelector('#home-minPeopleAppearancesTotal')?.value || '10', 10), minPeopleAppearancesMovies: parseInt(root.querySelector('#home-minPeopleAppearancesMovies')?.value || '10', 10), minPeopleAppearancesSeries: parseInt(root.querySelector('#home-minPeopleAppearancesSeries')?.value || '10', 10), @@ -2926,6 +3143,7 @@ window.KefinTweaksUI?.bindToggleCards?.(contentRoot); updateHscToggleCardHints(contentRoot, GENERAL_TOGGLE_DESCRIPTIONS); updateHscToggleCardHints(contentRoot, DISCOVERY_TOGGLE_DESCRIPTIONS); + wireCategorySettingsListeners(contentRoot); } function ensureGlobalSettingsLiveSync(dialog) { @@ -2967,13 +3185,15 @@ }); } - function openNewCustomSectionEditor() { + function openNewCustomSectionEditor(options = {}) { + getConfig(); const defaultTtl = Number(currentConfig?.CACHE?.DEFAULT_TTL); const newSection = { id: `custom-${Date.now()}`, name: 'New Custom Section', enabled: true, order: 100, + category: 'none', cardFormat: 'Poster', ...(Number.isFinite(defaultTtl) ? { ttl: defaultTtl } : {}), queries: [{ @@ -2983,7 +3203,7 @@ } }] }; - openSectionEditor(newSection, 'custom'); + openSectionEditor(newSection, 'custom', options); } function applyOrderListFromForm(dialogRoot) { @@ -5211,6 +5431,10 @@ window.KefinHomeScreen.buildEditorPreviewProgressiveSection = buildEditorPreviewProgressiveSection; window.KefinHomeScreen.canOpenSectionEditor = canOpenSectionEditor; window.KefinHomeScreen.openSectionEditorForId = openSectionEditorForId; + window.KefinHomeScreen.openNewCustomSection = (options = {}) => openNewCustomSectionEditor({ + refreshHomeOnSave: true, + ...options + }); window.KefinHomeScreen.publishSectionPresentationDefaults = publishSectionPresentationDefaults; window.KefinHomeScreen.findSectionInAllGroups = findSectionInAllGroups; window.KefinHomeScreen.ensureKefinTweaksDefaultSections = ensureKefinTweaksDefaultSections; diff --git a/configuration/homeScreen-user-configuration.js b/configuration/homeScreen-user-configuration.js index 74a1295..8606736 100644 --- a/configuration/homeScreen-user-configuration.js +++ b/configuration/homeScreen-user-configuration.js @@ -45,7 +45,7 @@ return flattened; } - const SECTION_PREF_FIELD_COUNT = 20; + const SECTION_PREF_FIELD_COUNT = 21; const DEFAULT_PINNED_LIST_NAME = 'Pinned'; const PINNED_LIST_ID_PREFIX = 'pinned-list-'; const PINNED_PARENT_ID_PREFIX = 'pinned-parent-'; @@ -174,9 +174,27 @@ // Field 19: useGaplessCards if (parts[19] === 'true') result.useGaplessCards = true; else if (parts[19] === 'false') result.useGaplessCards = false; + // Field 20: renderMode Normal|Spotlight|Random + if (parts[20] !== '') { + const renderMode = normalizeRenderMode(parts[20]); + if (renderMode) result.renderMode = renderMode; + } return result; } + const RENDER_MODES = ['Normal', 'Spotlight', 'Random']; + + function normalizeRenderMode(value) { + if (value == null || value === '') return null; + const lower = String(value).trim().toLowerCase(); + return RENDER_MODES.find(mode => mode.toLowerCase() === lower) || null; + } + + function resolveRenderMode(section) { + return normalizeRenderMode(section?.renderMode) + || (section?.spotlight === true ? 'Spotlight' : 'Normal'); + } + function normalizeItemsLayout(value) { if (value === true || value === 'true' || value === 'grid') return 'grid'; return 'row'; @@ -438,6 +456,11 @@ appendPrefField(fields, overrides.borderColor, serverDefaults.borderColor); appendPrefField(fields, overrides.cardTitleColor, serverDefaults.cardTitleColor); appendBoolPrefField(fields, overrides.useGaplessCards, serverDefaults.useGaplessCards); + appendPrefField( + fields, + normalizeRenderMode(overrides.renderMode), + normalizeRenderMode(serverDefaults.renderMode) || 'Normal' + ); return fields.join(';'); } @@ -921,9 +944,9 @@ function getServerSectionDefaults(section) { if (!section) return {}; - const isSpotlight = section.renderMode === 'Spotlight' || section.spotlight === true; const spotlight = section.spotlightConfig || {}; return { + renderMode: resolveRenderMode(section), order: section.order ?? 0, ttl: section.ttl, cardFormat: section.cardFormat, @@ -970,6 +993,10 @@ serverById.get(section.id) || serverById.get(storedId) || section ); if (pref.enabled !== undefined) next.enabled = pref.enabled; + if (pref.renderMode !== undefined) { + next.renderMode = pref.renderMode; + next.spotlight = pref.renderMode === 'Spotlight'; + } if (pref.order !== undefined) next.order = pref.order; if (pref.ttl !== undefined) next.ttl = pref.ttl; if (pref.cardFormat !== undefined) next.cardFormat = pref.cardFormat; @@ -2031,6 +2058,8 @@ saveKefinTweaksSectionState, createEmptySectionState, normalizeItemsLayout, + normalizeRenderMode, + resolveRenderMode, DEFAULT_PINNED_LIST_NAME, PINNED_DEFAULT_ORDER, homeScreenToLegacyArray, diff --git a/configuration/homeScreenAdvancedEditor.css b/configuration/homeScreenAdvancedEditor.css index b7e8f42..cfe7272 100644 --- a/configuration/homeScreenAdvancedEditor.css +++ b/configuration/homeScreenAdvancedEditor.css @@ -131,12 +131,18 @@ :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-group-row { display: grid; - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75em; margin-top: 0.85em; margin-bottom: 0.85em; } +:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) + .hsae-basic-group-row + .hsae-group-new { + grid-column: 1 / -1; +} + :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -149,7 +155,7 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } -@media (max-width: 900px) { +@media (max-width: 899px) { :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-4, :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-sort { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -792,7 +798,7 @@ gap: 0.75em; } -@media (max-width: 900px) { +@media (max-width: 899px) { :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-grid { grid-template-columns: 1fr; } @@ -1556,6 +1562,7 @@ } .hsae-root[data-editor-profile] .hsae-field-grid[data-hsae-profiles], +.hsae-root[data-editor-profile] .hsae-basic-group-row[data-hsae-profiles], .hsae-root[data-editor-profile] .hsae-advanced-section-sort[data-hsae-profiles], .hsae-root[data-editor-profile] .hsae-advanced-min-max-age[data-hsae-profiles], .hsae-root[data-editor-profile] .hsae-advanced-limits[data-hsae-profiles] { @@ -1567,6 +1574,7 @@ .hsae-root[data-editor-profile="postProcessing"] .hsae-field-grid[data-hsae-profiles~="postProcessing"], .hsae-root[data-editor-profile="normal"] .hsae-field-grid[data-hsae-profiles~="normal"], .hsae-root[data-editor-profile="dateCutoffs"] .hsae-field-grid[data-hsae-profiles~="dateCutoffs"], +.hsae-root[data-editor-profile="full"] .hsae-basic-group-row[data-hsae-profiles~="full"], .hsae-root[data-editor-profile="full"] .hsae-advanced-section-sort[data-hsae-profiles~="full"], .hsae-root[data-editor-profile="postProcessing"] .hsae-advanced-section-sort[data-hsae-profiles~="postProcessing"], .hsae-root[data-editor-profile="normal"] .hsae-advanced-section-sort[data-hsae-profiles~="normal"], diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js index 11be7bd..21fdff7 100644 --- a/configuration/homeScreenAdvancedEditor.js +++ b/configuration/homeScreenAdvancedEditor.js @@ -393,7 +393,14 @@ const ADVANCED_TOGGLE_DESCRIPTIONS = { 'section-flattenSeries': 'Multiple episodes from the same series will be grouped into one item in the section.', 'hsae-limit-before-sort': 'When enabled, the item limit is applied before section-level sort.', - 'hsae-hide-card-titles': 'Card titles for individual items in the section will not be shown.' + 'hsae-hide-card-titles': { + on: 'Card titles for individual items in the section will not be shown.', + off: 'Card titles for individual items in the section will be shown.' + }, + 'hsae-use-parent-card': { + on: 'Items in this section will link to their Parent item where available. This means that an Episode or Season will link to the Series.', + off: 'Items in this section will link to the normal item.' + } }; const RANDOM_QUERY_TOGGLE_DESCRIPTIONS = { @@ -1014,6 +1021,15 @@ if (groupNewEl) state.groupNew = groupNewEl.value; if (visibilityEl) state.sectionVisibility = visibilityEl.value; if (orderEl) state.order = orderEl.value; + + const categoryEl = root.querySelector('#section-category'); + if (categoryEl) { + const isDiscovery = state.sectionVisibility === 'discovery'; + state.category = isDiscovery ? 'discovery' : (categoryEl.value || 'none'); + state.section = state.section || {}; + state.section.category = state.category; + } + if (hideWatchedEl) state.hideWatched = hideWatchedEl.checked === true; if (hideNameEl) state.hideName = hideNameEl.checked === true; if (startDateEl) state.startDate = startDateEl.value; @@ -1156,6 +1172,13 @@ } } + const useParentCardEl = root.querySelector('#hsae-use-parent-card'); + if (useParentCardEl) { + state.section = state.section || {}; + if (useParentCardEl.checked === true) state.section.useParentCard = true; + else delete state.section.useParentCard; + } + const cardTitlePositionEl = root.querySelector('#hsae-card-title-position'); if (cardTitlePositionEl) { state.section = state.section || {}; @@ -1525,11 +1548,33 @@
${buildTextInput('section-name', section.name || state.sectionName || '', 'Section Name')} - ${buildTextInput('section-caption', section.caption || state.caption || '', 'Caption', 'text', 'Displayed below the Section Name.')} + ${buildTextInput('section-caption', section.caption || state.caption || '', 'Caption', 'text', 'Displayed below the Section Name')}
-
+
${buildSelect('section-group', groupOptions, selectedGroup, 'Section Group')} + ${(() => { + const categories = (context.config?.HOME_SETTINGS?.categories + || window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS?.categories + || window.KefinHomeConfig2?.HOME_SETTINGS?.categories + || []).map((c) => ({ value: c.id, label: c.name || c.id })); + const isDiscovery = state.sectionVisibility === 'discovery' + || section.discoveryEnabled === true + || section.type === 'discovery'; + const selectedCategory = isDiscovery ? 'discovery' : (section.category || state.category || 'none'); + const categorySelect = buildSelect( + 'section-category', + categories.length ? categories : [{ value: 'none', label: 'Home' }], + selectedCategory, + 'Category' + ); + return isDiscovery + ? categorySelect.replace( + 'id="section-category" class="fld emby-select emby-select-withcolor"', + 'id="section-category" class="fld emby-select emby-select-withcolor" disabled' + ) + : categorySelect; + })()}
${buildTextInput('section-group-new', state.groupNew || '', 'New Group Name')}
@@ -1853,6 +1898,7 @@ itemsLayout: draft?.itemsLayout, cardTitleVisibility: draft?.cardTitleVisibility, hideCardTitles: draft?.hideCardTitles, + useParentCard: draft?.useParentCard === true, cardTitlePosition: draft?.cardTitlePosition, cardTitleCapitalization: draft?.cardTitleCapitalization, cardTitleFontFamily: draft?.cardTitleFontFamily, @@ -2017,7 +2063,15 @@ 'hsae-hide-card-titles', hideCardTitles, 'Hide Card Titles', - ADVANCED_TOGGLE_DESCRIPTIONS['hsae-hide-card-titles'] + ADVANCED_TOGGLE_DESCRIPTIONS['hsae-hide-card-titles'][hideCardTitles ? 'on' : 'off'], + { hintKey: 'hsae-hide-card-titles' } + )} + ${buildToggleCard( + 'hsae-use-parent-card', + section.useParentCard === true, + 'Link Parent Item', + ADVANCED_TOGGLE_DESCRIPTIONS['hsae-use-parent-card'][section.useParentCard === true ? 'on' : 'off'], + { hintKey: 'hsae-use-parent-card' } )}
@@ -2524,6 +2578,8 @@ else section.cardTitleVisibility = ''; section.hideCardTitles = ''; + section.useParentCard = dialog.querySelector('#hsae-use-parent-card')?.checked === true; + const cardTitlePosition = dialog.querySelector('#hsae-card-title-position')?.value; if (cardTitlePosition && cardTitlePosition !== 'default') section.cardTitlePosition = cardTitlePosition; else section.cardTitlePosition = ''; @@ -2547,6 +2603,14 @@ : targetGroupName; } + const categoryEl = dialog.querySelector('#section-category'); + if (categoryEl) { + const isDiscovery = dialog.querySelector('#hsae-section-visibility')?.value === 'discovery' + || section.discoveryEnabled === true + || section.type === 'discovery'; + section.category = isDiscovery ? 'discovery' : (categoryEl.value || 'none'); + } + return section; } @@ -2885,13 +2949,15 @@ { key: 'hideName', checkboxId: 'hsae-hide-section-name', tooltips: HIDE_NAME_TOGGLE_DESCRIPTIONS }, { key: 'hideWatched', checkboxId: 'hsae-hide-watched', tooltips: HIDE_WATCHED_TOGGLE_DESCRIPTIONS }, { key: 'userEnabledByDefault', checkboxId: 'hsae-user-enabled-by-default', tooltips: USER_TOGGLE_TOOLTIPS.userEnabledByDefault }, - { key: 'userConfigurable', checkboxId: 'hsae-user-configurable', tooltips: USER_TOGGLE_TOOLTIPS.userConfigurable } + { key: 'userConfigurable', checkboxId: 'hsae-user-configurable', tooltips: USER_TOGGLE_TOOLTIPS.userConfigurable }, + { key: 'hsae-hide-card-titles', checkboxId: 'hsae-hide-card-titles', tooltips: ADVANCED_TOGGLE_DESCRIPTIONS['hsae-hide-card-titles'] }, + { key: 'hsae-use-parent-card', checkboxId: 'hsae-use-parent-card', tooltips: ADVANCED_TOGGLE_DESCRIPTIONS['hsae-use-parent-card'] } ]; toggles.forEach(({ key, checkboxId, tooltips }) => { const checkbox = root.querySelector(`#${checkboxId}`); const hint = root.querySelector(`[data-hsae-hint="${key}"]`); - if (!checkbox || !hint) return; + if (!checkbox || !hint || !tooltips) return; const enabled = checkbox.checked === true; hint.textContent = tooltips[enabled ? 'on' : 'off']; }); diff --git a/scripts/HomeScreenSection.ts b/scripts/HomeScreenSection.ts index 7d5159b..489e6dd 100644 --- a/scripts/HomeScreenSection.ts +++ b/scripts/HomeScreenSection.ts @@ -5,6 +5,7 @@ interface HomeScreenSection { caption?: string; enabled: boolean; order: number; + category?: string; // Stable category id (custom sections); runtime may force discovery/pinned // Display Options cardFormat?: "Poster" | "Thumb" | "Backdrop" | "Random"; @@ -31,6 +32,7 @@ interface HomeScreenSection { cardTitleFontSize?: "small" | "normal" | "large"; userConfigurable?: boolean; // Whether users can toggle/reorder this section useGaplessCards?: boolean; // When true, remove gaps between cards (row or grid) + useParentCard?: boolean; // When true, use the parent item will be rendered as the card image and link // Loads items from an external URL and loads matches from that list from your Jellyfin library // Currently supports: mdblist.com only @@ -145,7 +147,7 @@ interface SpotlightConfig { // Used for the customPrefs.kefinTweaks.homeScreen property // All values are arrays of semi-colon separated strings interface KefinHomeScreen { - sections: Array; // id;enabled;order;ttl;cardFormat;animationEnabled;hideName;hideCardTitles;cardTitlePosition;borderStyle;spotlightLayout;spotlightSize;spotlightTileCount;itemsLayout(row|grid; legacy true→grid);cardTitleCapitalization;cardTitleFontFamily;cardTitleFontSize;borderColor;cardTitleColor;useGaplessCards + sections: Array; // id;enabled;order;ttl;cardFormat;animationEnabled;hideName;hideCardTitles;cardTitlePosition;borderStyle;spotlightLayout;spotlightSize;spotlightTileCount;itemsLayout(row|grid; legacy true→grid);cardTitleCapitalization;cardTitleFontFamily;cardTitleFontSize;borderColor;cardTitleColor;useGaplessCards;renderMode(Normal|Spotlight|Random) pinnedLists: Array; // name;id1;id2;id3;... pinnedParents: Array; // name;id;type } @@ -191,4 +193,6 @@ interface KefinHomeScreenSectionUserConfig { cardTitleCapitalization?: string; cardTitleFontFamily?: string; cardTitleFontSize?: string; + /** User override of the section's render mode. */ + renderMode?: "Normal" | "Spotlight" | "Random"; } diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 05061f6..a031e94 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -2049,16 +2049,24 @@ .emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardScalable, .emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardBox, .emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardScalable, .emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardBox, .emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardScalable, .emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardBox, .emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardScalable, .emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardPadder, +.emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardBox, html.sleekfin-main-ui .emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardPadder, html.sleekfin-main-ui .emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardPadder, html.sleekfin-main-ui .emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardPadder, -html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardPadder { +html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardPadder, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Logo"]:not(.visualCardBox) .cardBox, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Clear Art"]:not(.visualCardBox) .cardBox, +html.sleekfin-main-ui .emby-scroller-container[data-card-format="Disc"]:not(.visualCardBox) .cardBox, +html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels"]:not(.visualCardBox) .cardBox { background: transparent !important; box-shadow: none !important; border: none !important; @@ -2192,88 +2200,89 @@ html.sleekfin-main-ui .emby-scroller-container[data-section-id="liveTv-channels" display: inline-block; } -/* .spotlight-section .spotlight-item:has(.spotlight-background-layer-center) .spotlight-background-layer { - width: 37.5%; +.spotlight-section .spotlight-item:has(.spotlight-background-layer-center) .spotlight-background-layer { + width: 37.5%; } .emby-scroller-container:has([data-direction="right"]:disabled):has([data-direction="left"]:disabled) button.show-all-button { - display: none; + display: none; } .emby-scroller-container .emby-scrollbuttons:has([data-direction="right"]:disabled):has([data-direction="left"]:disabled) { - display: none; + display: none; } .spotlight-truncated-list:has(.spotlight-truncated-list-tooltip) { - overflow: visible; + overflow: visible; } .emby-scroller-container:has(.itemsContainer[data-layout="grid"]) .emby-scrollbuttons { - display: none !important; + display: none !important; } .emby-scroller-container:has(.itemsContainer[data-layout="grid"]) .show-all-button { - display: initial !important; + display: initial !important; } :has(.spotlight-section[data-layout="Borderless"]) .homeSectionsContainer > *:not(.spotlight-section) { - z-index: 1; + z-index: 1; } -.spotlight-section[data-size="full"] .spotlight-truncated-list.itemDetailsGroup:has(>.detailsGroupItem), -.spotlight-section[data-size="full"] .spotlight-truncated-list.sectionTabs:has(>.emby-tab-button) { - font-size: large; - display: flex; - align-items: center; - align-self: start; +.spotlight-section[data-size="full"] .spotlight-truncated-list.itemDetailsGroup:has(> .detailsGroupItem), +.spotlight-section[data-size="full"] .spotlight-truncated-list.sectionTabs:has(> .emby-tab-button) { + font-size: large; + display: flex; + align-items: center; + align-self: start; } -.spotlight-section .itemDetailsGroup:has(.detailsGroupItem:not(.hide)), .spotlight-section .sectionTabs:not(.spotlight-section-title):has(.emby-tab-button:not(.hide)) { - margin: 0px !important; - padding: 0px !important; - width: initial !important; - min-width: initial !important; +.spotlight-section .itemDetailsGroup:has(.detailsGroupItem:not(.hide)), +.spotlight-section .sectionTabs:not(.spotlight-section-title):has(.emby-tab-button:not(.hide)) { + margin: 0px !important; + padding: 0px !important; + width: initial !important; + min-width: initial !important; } .spotlight-section:not(.hide) span.emby-tab-button.emby-tab-button-active:not(:has(a)) { - cursor: default; + cursor: default; } .emby-scroller-container:has(.itemsContainer[data-layout="grid"]) button.show-all-button::after { - content: 'Collapse'; + content: "Collapse"; } .layout-mobile .emby-scroller-container:has(.itemsContainer[data-layout="grid"]) button.show-all-button::after { - content: 'keyboard_arrow_up'; + content: "keyboard_arrow_up"; } .layout-mobile .spotlight-section .spotlight-section-title.sectionTabs:has(.emby-tab-button:not(.hide)) { - justify-self: center; - display: flex !important; - justify-content: center; + justify-self: center; + display: flex !important; + justify-content: center; } .layout-mobile .spotlight-section .spotlight-section-title.sectionTabs:has(.emby-tab-button:not(.hide)) > * { - display: block !important; + display: block !important; } .layout-mobile .spotlight-section[data-hide-section-name="true"] .spotlight-section-title, .layout-mobile .spotlight-section[data-hide-section-name="true"] .spotlight-section-title.sectionTabs, .spotlight-section[data-hide-section-name="true"] .spotlight-section-title.sectionTabs { - display: none !important; + display: none !important; } .emby-scroller-container:has(.emby-scrollbuttons-button:not(.hide)) button.show-all-button { - display: flex; + display: flex; } .layout-mobile .emby-scroller-container:has(.overflowPortraitCard:nth-child(4)) button.show-all-button, .layout-mobile .emby-scroller-container:has(.overflowBackdropCard:nth-child(3)) button.show-all-button { - display: flex; + display: flex; } .emby-scroller-container:has([data-custom-card="true"]) .section-refresh-button { - display: none; -} */ + display: none; +} /* Section-level card title presentation */ .verticalSection .cardText, diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index b601023..a5743b8 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -551,6 +551,13 @@ element.removeAttribute('data-section-type'); } + const resolvedCategory = resolveSectionCategory(sectionConfig); + if (resolvedCategory) { + element.setAttribute('data-category', resolvedCategory); + } else { + element.removeAttribute('data-category'); + } + if (isCustomSection) element.dataset.customSection = 'true'; else delete element.dataset.customSection; @@ -558,6 +565,20 @@ else delete element.dataset.discoverySection; } + function resolveSectionCategory(sectionConfig) { + if (!sectionConfig) return 'none'; + const id = String(sectionConfig.id || ''); + if (id.startsWith('pinned-list-') || id.startsWith('pinned-parent-')) return 'pinned'; + const sectionType = String(sectionConfig.type || '').toLowerCase(); + const isDiscovery = sectionConfig.discoveryEnabled === true + || sectionConfig.discoverySection === true + || sectionType === 'discovery' + || sectionType === 'custom-discovery' + || !!sectionConfig.discoveryType; + if (isDiscovery) return 'discovery'; + return sectionConfig.category || 'none'; + } + /** * Updates the scroll buttons for a scrollable container * @param {HTMLElement} scroller - The scroller element @@ -4691,31 +4712,33 @@ bannerContainer.appendChild(navContainer); } - // Pause button (bottom right) + // Pause button (bottom right) — only when nav chrome exists (showNavButtons) if (autoPlay && items.length > 1) { const navButtonsContainer = bannerContainer.querySelector('.spotlight-nav-container .spotlight-nav-buttons-container'); - const pauseButton = document.createElement('button'); - pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button'; - const pauseIcon = document.createElement('span'); - pauseIcon.className = 'material-icons'; - pauseIcon.textContent = 'pause'; - pauseButton.appendChild(pauseIcon); - pauseButton.addEventListener('click', (e) => { - e.stopPropagation(); - isPaused = !isPaused; - if (isPaused) { - if (autoPlayTimer) { - clearTimeout(autoPlayTimer); - autoPlayTimer = null; + if (navButtonsContainer) { + const pauseButton = document.createElement('button'); + pauseButton.className = 'spotlight-pause-button spotlight-nav-button emby-button'; + const pauseIcon = document.createElement('span'); + pauseIcon.className = 'material-icons'; + pauseIcon.textContent = 'pause'; + pauseButton.appendChild(pauseIcon); + pauseButton.addEventListener('click', (e) => { + e.stopPropagation(); + isPaused = !isPaused; + if (isPaused) { + if (autoPlayTimer) { + clearTimeout(autoPlayTimer); + autoPlayTimer = null; + } + pauseIcon.textContent = 'play_arrow'; + } else { + startAutoPlay(); + pauseIcon.textContent = 'pause'; } - pauseIcon.textContent = 'play_arrow'; - } else { - startAutoPlay(); - pauseIcon.textContent = 'pause'; - } - }); - - navButtonsContainer.insertBefore(pauseButton, navButtonsContainer.firstChild); + }); + + navButtonsContainer.insertBefore(pauseButton, navButtonsContainer.firstChild); + } } // Slide state container (dots or numeric "N / X") diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index d92e95a..ae3c474 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -67,6 +67,27 @@ div#discovery-loading-indicator:not(.show)::after { opacity: 1 !important; } +/* Hide Discover More / loading affordance outside Home + Discover categories */ +#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 + .homeSectionsContainer[data-category]:not([data-category="none"]):not([data-category="discovery"]) + .discovery-loading-indicator { + display: none !important; + visibility: hidden !important; + min-height: 0 !important; + height: 0 !important; + overflow: hidden !important; + padding: 0 !important; + margin: 0 !important; + opacity: 0 !important; +} + #indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) .spinner { visibility: hidden; opacity: 0; @@ -358,7 +379,7 @@ body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay { text-align: center; margin: 3em auto; display: block; - animation: fadeInDelayed 0.1s linear 1s forwards; + animation: fadeInDelayed 0.1s linear 3s forwards; opacity: 0; visibility: hidden; } @@ -384,3 +405,106 @@ body[data-seasonal-animations="true"]:has(.homePage:not(.hide)) .snowverlay { width: 30vw !important; } } + +/* Home screen chrome: create section + category rail */ +.kefin-home-chrome { + position: fixed; + z-index: 12; + pointer-events: none; + opacity: 0.18; + transition: opacity 0.2s ease; +} +.kefin-home-chrome:hover, +.kefin-home-chrome:focus-within { + opacity: 1; +} +.kefin-home-chrome[hidden], +#kefin-home-category-rail[hidden] { + display: none !important; +} +.kefin-home-chrome button, +#kefin-home-category-rail button { + pointer-events: auto; +} +.kefin-home-create-section { + left: max(0.75rem, env(safe-area-inset-left)); + bottom: max(0.75rem, env(safe-area-inset-bottom)); +} +.kefin-home-create-section-btn .material-icons { + font-size: 1.75rem; +} + +/* Outer sectionTabs wrapper — no native header layout */ +#kefin-home-category-rail.sectionTabs { + position: static; + display: contents; +} + +/* Inner visual bar — keep our layout over skinHeader / MuiAppBar positioning */ +#kefin-home-category-rail .kefin-home-category-rail { + position: fixed; + z-index: 12; + top: max(4.5rem, calc(env(safe-area-inset-top) + 4.5rem)); + left: max(0.5rem, env(safe-area-inset-left)); + right: auto; + bottom: auto; + width: auto; + height: auto; + max-width: none; + display: flex; + flex-direction: column; + gap: 0.25rem; + border-radius: 999px; + box-shadow: 1px 1px 2px 0 #fff !important; + transform: none; + pointer-events: none; +} +#kefin-home-category-rail .kefin-home-category-rail:hover, +#kefin-home-category-rail .kefin-home-category-rail:focus-within { + opacity: 1; +} +.kefin-home-category-btn { + opacity: 0.75; +} +.kefin-home-category-btn.is-active { + opacity: 1; + color: var(--theme-primary-color, #00a4dc); + background: rgba(0, 164, 220, 0.18); +} +.kefin-home-category-btn .material-icons { + font-size: 1.35rem; +} + +@media (max-width: 899px) { + #kefin-home-category-rail .kefin-home-category-rail { + top: auto; + left: 0; + right: 0; + bottom: 0; + flex-direction: row; + justify-content: space-evenly; + align-items: center; + gap: 0.15rem; + padding: 0.4rem 0.5rem calc(0.4rem + env(safe-area-inset-bottom, 0px)) !important; + border-radius: 0; + opacity: 1; + pointer-events: auto; + margin: 0 !important; + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + } + .kefin-home-create-section { + bottom: calc(3.25rem + env(safe-area-inset-bottom, 0px)); + } + .homePage:not(.hide) { + padding-bottom: calc(3.5rem + env(safe-area-inset-bottom, 0px)); + } +} + +.kefin-section-more-toggle { + margin-left: auto; +} + +body:not(:has(.homePage:not(.hide))) div#kefin-home-category-rail { + display: none; +} diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js index 1940a42..f9d497e 100644 --- a/scripts/homeScreen3.js +++ b/scripts/homeScreen3.js @@ -75,7 +75,8 @@ kefinContinueWatching: false, kefinLatestMedia: false, jellyfinOrders: {}, - homePaintedEventFired: false + homePaintedEventFired: false, + isRenderingHome: false }; async function fetchDisplayPreferences() { @@ -427,11 +428,14 @@ * Main Entry Point */ async function enhanceHomeScreen() { - LOG('Login detected, continuing with home screen initialization'); LOG('Initializing Home Screen v3...'); performanceTimer.loadTimeStart = performance.now(); performanceMetrics.initialRenderStart = performance.now(); + mountCreateSectionButton(); + mountCategoryRail(); + syncHomeScreenChromeVisibility(); + // Reset cache/metrics for a new initialization cycle sectionCache.renderedSections = []; sectionCache.fragmentCache = null; @@ -476,7 +480,7 @@ } // Check if another page container exists with an already rendered KefinTweaks home screen -/* const otherPageContainer = document.querySelector('.pageContainer:not(.hide) .homeSectionsContainer[data-sections-rendered="true"]'); + const otherPageContainer = document.querySelector('.pageContainer:not(.hide) .homeSectionsContainer[data-sections-rendered="true"]'); if (otherPageContainer) { // Copy the existing home sections with [data-section-id] from that container into our target container instead of re-rendering it const otherPageSections = otherPageContainer.querySelectorAll('[data-section-id]'); @@ -487,7 +491,7 @@ container.appendChild(homeScreenFragment); LOG('Cloned existing home sections from a previous container'); return; - } */ + } let performanceStartTime = performance.now(); @@ -530,6 +534,8 @@ filteredHomeSections = filteredHomeSections.filter(section => section.enabled !== false); } + updatePinnedCategoryAvailability(filteredHomeSections, homeScreen); + performanceEndTime = performance.now(); performanceDuration = performanceEndTime - performanceStartTime; LOG(`Home Screen v3 Load user home screen sections initialization time: ${performanceDuration.toFixed(2)}ms`); @@ -606,6 +612,12 @@ LOG('Initializing Discovery Sections...'); setupDiscoveryInteraction(container); + try { + refreshHomeCategoryChrome(); + } catch (err) { + ERR('Failed to refresh home category chrome after home render:', err); + } + /* // If Jellyfin hasn't rendered after 5 seconds, assume it won't and clean up the observer setTimeout(() => { if (!sectionCache.jellyfinHasRendered && observer) { @@ -1147,9 +1159,6 @@ return; } - // Skip if we've already rendered into this container for this cycle and we're not explicitly re-using the cache - if (container.dataset.sectionsRendered && !useCache) return; - // Check if children with [data-section-id] are present const children = container.children; const hasSections = Array.from(children).some(child => child.dataset && child.dataset.sectionId); @@ -1159,6 +1168,8 @@ } container.dataset.sectionsRendered = 'true'; + applyActiveHomeCategory(homeChromeActiveCategory || 'none'); + syncHomeScreenChromeVisibility(); const sectionsToRender = []; @@ -1233,34 +1244,47 @@ const homeConfig = await window.KefinHomeScreen.getConfig(); const showStaleDataBeforeRefresh = homeConfig?.HOME_SETTINGS?.showStaleDataBeforeRefresh === true; - const targetContainer = container; + const targetContainer = container; - const renderStartTime = performance.now(); - await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, { - waitForContainerClass: 'homeSectionsContainer', - enhanceOnVisible: true, - showStaleDataBeforeRefresh - }); - const renderEndTime = performance.now(); - const renderDuration = renderEndTime - renderStartTime; - LOG(`Home Screen v3 Render progressive sections initialization time: ${renderDuration.toFixed(2)}ms`); - - // If MediaBar plugin is in use, call LayoutSync.update() to update the layout - if (typeof LayoutSync !== 'undefined' && LayoutSync && typeof LayoutSync.update === 'function') { - LayoutSync.update(); + const kefinSections = Array.from(children).some(child => child.dataset && child.dataset.sectionId); + if (kefinSections) { + LOG('Sections already rendered, skipping'); + return; } - // Allow deferred library-cache crawls to start (movies/series/people scheduleBootstrap) - if (!state.homePaintedEventFired) { - state.homePaintedEventFired = true; - try { - document.dispatchEvent(new CustomEvent('kefinTweaksHomePainted')); - } catch (_) { /* ignore */ } - } + if (!state.isRenderingHome && !kefinSections) { + const renderStartTime = performance.now(); + state.isRenderingHome = true; - // Check if target container is homeSectionsContainer - if (!targetContainer.classList.contains('homeSectionsContainer')) { - targetContainer.dataset.sectionsPrerendered = 'true'; + await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender, { + waitForContainerClass: 'homeSectionsContainer', + enhanceOnVisible: true, + showStaleDataBeforeRefresh + }); + + state.isRenderingHome = false; + + const renderEndTime = performance.now(); + const renderDuration = renderEndTime - renderStartTime; + LOG(`Home Screen v3 Render progressive sections initialization time: ${renderDuration.toFixed(2)}ms`); + + // If MediaBar plugin is in use, call LayoutSync.update() to update the layout + if (typeof LayoutSync !== 'undefined' && LayoutSync && typeof LayoutSync.update === 'function') { + LayoutSync.update(); + } + + // Allow deferred library-cache crawls to start (movies/series/people scheduleBootstrap) + if (!state.homePaintedEventFired) { + state.homePaintedEventFired = true; + try { + document.dispatchEvent(new CustomEvent('kefinTweaksHomePainted')); + } catch (_) { /* ignore */ } + } + + // Check if target container is homeSectionsContainer + if (!targetContainer.classList.contains('homeSectionsContainer')) { + targetContainer.dataset.sectionsPrerendered = 'true'; + } } } @@ -1301,6 +1325,10 @@ * Ensures discovery buffer is populated (pre-fetches next group) */ async function ensureDiscoveryBuffer() { + if (!isDiscoveryAllowedForActiveCategory()) { + LOG('Discovery buffer skipped: active category does not allow discovery'); + return state.discoveryBuffer; + } if (state.ensuringDiscoveryBuffer) { LOG('Already ensuring discovery buffer...'); @@ -1411,6 +1439,11 @@ const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); if (!container) return; + if (!isDiscoveryAllowedForActiveCategory()) { + LOG('Discovery render skipped: active category does not allow discovery'); + return; + } + // Single-flight: set synchronously before any await if (state.isRenderingDiscovery) return; if (state.discoverySectionsRemain === false) return; @@ -1808,15 +1841,327 @@ await enhanceHomeScreen(); } + const HOME_CHROME_CREATE_ID = 'kefin-home-create-section-btn'; + const HOME_CHROME_CATEGORY_RAIL_ID = 'kefin-home-category-rail'; + const HOME_CHROME_CATEGORY_STYLE_ID = 'kefin-home-category-filter-styles'; + const HOME_CHROME_CLASSIC_RAIL_CLASSES = 'skinHeader skinHeader-withBackground emby-tab-button'; + let homeChromeActiveCategory = 'none'; + let homeChromeCreateMounted = false; + let homeChromeCategoryMounted = false; + let homeChromeHasPinnedContent = false; + let homeChromeVisibilityObserver = null; + let homeChromeMuiAppBarObserver = null; + + function getHomeSettings() { + return window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS + || window.KefinHomeConfig2?.HOME_SETTINGS + || {}; + } + + function getConfiguredCategories() { + const defaults = window.KefinHomeConfig2?.HOME_SETTINGS?.categories || []; + const configured = getHomeSettings().categories; + if (Array.isArray(configured) && configured.length) return configured; + return defaults; + } + + function sectionIsPinnedCategory(section) { + const id = String(section?.id || ''); + return id.startsWith('pinned-list-') + || id.startsWith('pinned-parent-') + || section?.category === 'pinned'; + } + + function updatePinnedCategoryAvailability(sections, homeScreen) { + const userHomeConfig = window.KefinUserHomeScreenConfig; + const fromUserPins = (userHomeConfig?.buildPinnedSectionConfigs?.(homeScreen) || []).length > 0; + const fromSections = (sections || []).some(sectionIsPinnedCategory); + homeChromeHasPinnedContent = fromUserPins || fromSections; + } + + function getRailCategories() { + return getConfiguredCategories().filter((cat) => { + if (cat?.id === 'pinned') return homeChromeHasPinnedContent; + return true; + }); + } + + function isModernUIChrome() { + return document.querySelector('.MuiBox-root') !== null || localStorage.getItem('layout')?.length === 0; + } + + function getCategoryRailNativeClasses() { + const base = 'kefin-home-chrome kefin-home-category-rail'; + if (isModernUIChrome()) { + let appBar = document.querySelector('.MuiAppBar-root'); + + if (appBar?.className) { + return `${base} ${appBar.className}`; + } + } + return `${base} ${HOME_CHROME_CLASSIC_RAIL_CLASSES}`; + } + + function ensureMuiAppBarClassObserver() { + if (!isModernUIChrome() || homeChromeMuiAppBarObserver) return; + if (document.querySelector('.MuiAppBar-root')) return; + homeChromeMuiAppBarObserver = new MutationObserver(() => { + if (!document.querySelector('.MuiAppBar-root')) return; + try { homeChromeMuiAppBarObserver?.disconnect(); } catch (_) { /* ignore */ } + homeChromeMuiAppBarObserver = null; + const inner = document.querySelector(`#${HOME_CHROME_CATEGORY_RAIL_ID} .kefin-home-category-rail`); + if (inner) inner.className = getCategoryRailNativeClasses(); + }); + homeChromeMuiAppBarObserver.observe(document.body, { childList: true, subtree: true }); + } + + function isHomePageVisible() { + return !!document.querySelector('.homePage:not(.hide)'); + } + + function syncHomeScreenChromeVisibility() { + const onHome = isHomePageVisible(); + const createBtn = document.getElementById(HOME_CHROME_CREATE_ID); + if (createBtn) { + createBtn.hidden = !onHome; + createBtn.setAttribute('aria-hidden', onHome ? 'false' : 'true'); + } + const showFilters = getHomeSettings().showCategoryFilters !== false; + const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); + if (rail) { + const visible = onHome && showFilters; + rail.hidden = !visible; + rail.setAttribute('aria-hidden', visible ? 'false' : 'true'); + } + if (onHome) { + applyActiveHomeCategory(homeChromeActiveCategory || 'none'); + } + } + + function ensureHomeChromeVisibilityObserver() { + if (homeChromeVisibilityObserver) return; + homeChromeVisibilityObserver = new MutationObserver(() => syncHomeScreenChromeVisibility()); + homeChromeVisibilityObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'] + }); + } + + async function mountCreateSectionButton() { + if (homeChromeCreateMounted || document.getElementById(HOME_CHROME_CREATE_ID)) { + homeChromeCreateMounted = true; + return; + } + if (getHomeSettings().showCreateSectionButtonOnHome !== true) return; + try { + const isAdmin = !!(await window.apiHelper?.isAdmin?.()); + if (!isAdmin) return; + } catch { + return; + } + + const wrap = document.createElement('div'); + wrap.id = HOME_CHROME_CREATE_ID; + wrap.className = 'kefin-home-chrome kefin-home-create-section'; + wrap.hidden = !isHomePageVisible(); + wrap.innerHTML = ` + + `; + wrap.querySelector('button')?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + try { + window.KefinHomeScreen?.openNewCustomSection?.({ refreshHomeOnSave: true }); + } catch (err) { + ERR('Failed to open create section from home:', err); + } + }); + document.body.appendChild(wrap); + homeChromeCreateMounted = true; + } + + function buildCategoryFilterStyles(categories) { + const rules = []; + rules.push(` +.homeSectionsContainer[data-render-categories-separately="true"][data-category="none"] > [data-section-id]:not([data-category="none"]) { + display: none !important; +} +`); + (categories || []).forEach((cat) => { + const id = String(cat?.id || '').trim(); + if (!id || id === 'none') return; + const safe = CSS.escape(id); + rules.push(` +.homeSectionsContainer[data-category="${safe}"] > [data-section-id]:not([data-category="${safe}"]) { + display: none !important; +} +`); + }); + return rules.join('\n'); + } + + function ensureCategoryFilterStyles() { + let styleEl = document.getElementById(HOME_CHROME_CATEGORY_STYLE_ID); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = HOME_CHROME_CATEGORY_STYLE_ID; + document.head.appendChild(styleEl); + } + styleEl.textContent = buildCategoryFilterStyles(getConfiguredCategories()); + } + + function applyActiveHomeCategory(categoryId) { + const categories = getRailCategories(); + const next = categories.some((c) => c.id === categoryId) ? categoryId : 'none'; + const prev = homeChromeActiveCategory || 'none'; + homeChromeActiveCategory = next; + const separate = getHomeSettings().renderCategoriesSeparately === true; + + document.querySelectorAll('.homeSectionsContainer').forEach((container) => { + container.dataset.category = next; + container.dataset.renderCategoriesSeparately = separate ? 'true' : 'false'; + }); + + const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); + if (rail) { + rail.querySelectorAll('.kefin-home-category-btn').forEach((btn) => { + const active = btn.dataset.categoryId === next; + btn.classList.toggle('is-active', active); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + } + + if (next === 'discovery' && prev !== 'discovery' && isDiscoveryAllowedForActiveCategory()) { + const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer, .homePage:not(.hide) .homeSectionsContainer'); + if (container) { + const hasDiscovery = !!( + container.querySelector('[data-discovery-section="true"], [data-category="discovery"]') + ); + if (!hasDiscovery) { + try { + renderNextDiscoveryGroup(); + } catch (err) { + ERR('Failed to bootstrap discovery group for Discover category:', err); + } + } + } + } + } + + function isDiscoveryAllowedForActiveCategory() { + const separate = getHomeSettings().renderCategoriesSeparately === true; + const active = homeChromeActiveCategory || 'none'; + if (separate) return active === 'discovery'; + return active === 'none' || active === 'discovery'; + } + + function mountCategoryRail() { + if (getHomeSettings().showCategoryFilters === false) { + const existing = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); + if (existing) existing.remove(); + homeChromeCategoryMounted = false; + ensureCategoryFilterStyles(); + homeChromeActiveCategory = 'none'; + applyActiveHomeCategory('none'); + return; + } + + if (homeChromeCategoryMounted && document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID)) { + ensureCategoryFilterStyles(); + applyActiveHomeCategory(homeChromeActiveCategory || 'none'); + return; + } + + const categories = getRailCategories(); + ensureCategoryFilterStyles(); + + const desiredCategory = categories.some((c) => c.id === homeChromeActiveCategory) + ? homeChromeActiveCategory + : 'none'; + + const outer = document.createElement('div'); + outer.id = HOME_CHROME_CATEGORY_RAIL_ID; + outer.className = 'sectionTabs'; + outer.hidden = !isHomePageVisible(); + outer.setAttribute('role', 'toolbar'); + outer.setAttribute('aria-label', 'Home categories'); + outer.setAttribute('aria-hidden', outer.hidden ? 'true' : 'false'); + + const inner = document.createElement('div'); + inner.className = getCategoryRailNativeClasses(); + inner.innerHTML = categories.map((cat) => { + const id = escapeHtmlChrome(cat.id); + const name = escapeHtmlChrome(cat.name || cat.id); + const icon = escapeHtmlChrome(cat.icon || 'label'); + const active = desiredCategory === cat.id; + return ` + + `; + }).join(''); + + outer.appendChild(inner); + outer.addEventListener('click', (e) => { + const btn = e.target.closest('.kefin-home-category-btn'); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + applyActiveHomeCategory(btn.dataset.categoryId || 'none'); + }); + + document.body.appendChild(outer); + homeChromeCategoryMounted = true; + ensureMuiAppBarClassObserver(); + applyActiveHomeCategory(desiredCategory); + } + + function refreshHomeCategoryChrome() { + ensureCategoryFilterStyles(); + const rail = document.getElementById(HOME_CHROME_CATEGORY_RAIL_ID); + if (rail) rail.remove(); + homeChromeCategoryMounted = false; + mountCategoryRail(); + syncHomeScreenChromeVisibility(); + } + + function escapeHtmlChrome(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + async function initHomeScreenChrome() { + ensureHomeChromeVisibilityObserver(); + await mountCreateSectionButton(); + mountCategoryRail(); + syncHomeScreenChromeVisibility(); + } + window.homeScreen3 = { init: enhanceHomeScreen, refreshHomeSections, resolveDiscoverySection: buildDiscoverySectionInstance, - sectionHelper: () => window.sectionHelper + sectionHelper: () => window.sectionHelper, + getActiveHomeCategory: () => homeChromeActiveCategory || 'none', + isDiscoveryAllowedForActiveCategory, + refreshHomeCategoryChrome }; //enhanceHomeScreen(); connectHomeSectionsReadyObserver(); + //initHomeScreenChrome(); if (window.KefinTweaksUtils && typeof window.KefinTweaksUtils.onViewPage === 'function') { window.KefinTweaksUtils.onViewPage((view, element, hash) => { @@ -1834,6 +2179,7 @@ try { manageBodyClasses(); enhanceHomeScreen(); + syncHomeScreenChromeVisibility(); } catch (err) { ERR('Home screen page change handler failed:', err); } }, { pages: ['home', 'home.html'] }); } diff --git a/scripts/homeScreenConfig2.js b/scripts/homeScreenConfig2.js index 941ea06..7b45e18 100644 --- a/scripts/homeScreenConfig2.js +++ b/scripts/homeScreenConfig2.js @@ -2227,6 +2227,9 @@ ensureThumbsForPopularTVNetworks: false, showStaleDataBeforeRefresh: true, dismissEmptySectionTimer: 0, + showCreateSectionButtonOnHome: false, + showCategoryFilters: true, + renderCategoriesSeparately: false, minPeopleAppearancesTotal: 10, minPeopleAppearancesMovies: 10, minPeopleAppearancesSeries: 10, @@ -2236,18 +2239,18 @@ categories: [ { id: 'none', - name: 'None', + name: 'Home', icon: 'home', }, { - id: 'discovery', + id: 'discovery', name: 'Discover', icon: 'explore', }, { id: 'pinned', name: 'Pinned', - icon: 'pin', + icon: 'push_pin', } ] } diff --git a/scripts/homeScreenSectionConfigure.js b/scripts/homeScreenSectionConfigure.js index c74ea85..3252d99 100644 --- a/scripts/homeScreenSectionConfigure.js +++ b/scripts/homeScreenSectionConfigure.js @@ -345,6 +345,7 @@ function buildPrefOverrides(cfg) { const spotlight = cfg.spotlightConfig || {}; return { + renderMode: resolveRenderMode(cfg), order: cfg.order, ttl: cfg.ttl, cardFormat: cfg.cardFormat, @@ -394,6 +395,9 @@ function applyServerDefaultsToConfig(cfg, defaults) { if (!cfg || !defaults) return; + const renderMode = defaults.renderMode || 'Normal'; + cfg.renderMode = renderMode; + cfg.spotlight = renderMode === 'Spotlight'; cfg.order = defaults.order; cfg.ttl = defaults.ttl; cfg.cardFormat = defaults.cardFormat; @@ -751,10 +755,38 @@ ]; } + function resolveRenderMode(cfg) { + const api = getConfigApi(); + if (api?.resolveRenderMode) return api.resolveRenderMode(cfg); + return cfg?.renderMode || (cfg?.spotlight === true ? 'Spotlight' : 'Normal'); + } + function isSpotlightSection(cfg) { return cfg?.renderMode === 'Spotlight' || cfg?.spotlight === true; } + // 'Random' picks a mode at render time, so a re-render must keep whatever is on screen. + function shouldRenderAsSpotlight(entry) { + if (resolveRenderMode(entry?.effectiveConfig) === 'Random') { + return entry?.element?.classList?.contains('spotlight-section') === true; + } + return isSpotlightSection(entry?.effectiveConfig); + } + + // A section that was never spotlight has no spotlightConfig, so the popover controls and + // the renderer would otherwise fall back to different defaults. + function buildSpotlightConfigSeed(existingConfig, serverDefaults = {}) { + const existing = existingConfig || {}; + const admin = window.KefinHomeScreen?.getConfig?.()?.SPOTLIGHT_SETTINGS || {}; + return { + ...admin, + ...existing, + spotlightLayout: existing.spotlightLayout ?? admin.spotlightLayout ?? serverDefaults.spotlightLayout ?? 'Border', + spotlightSize: existing.spotlightSize ?? admin.spotlightSize ?? serverDefaults.spotlightSize ?? 'normal', + tileCount: existing.tileCount ?? admin.tileCount ?? serverDefaults.spotlightTileCount ?? 1 + }; + } + function applyPresentationToElement(entry) { const cfg = entry.effectiveConfig; const el = entry.element; @@ -827,7 +859,7 @@ delete el.dataset.fontSize; } - if (isSpotlightSection(cfg)) { + if (shouldRenderAsSpotlight(entry)) { const spotlight = cfg.spotlightConfig || {}; if (spotlight.spotlightLayout) { el.dataset.layout = spotlight.spotlightLayout; @@ -846,9 +878,16 @@ } } + // Structural classes/attributes owned by each render mode's builder. When a section + // switches modes the freshly built root must keep its own set instead of inheriting + // the previous mode's. + const SPOTLIGHT_MODE_CLASSES = ['spotlight-section', 'padded-left']; + const NORMAL_MODE_CLASSES = ['verticalSection', 'emby-scroller-container', 'custom-scroller-container']; + const SPOTLIGHT_MODE_ATTRS = ['data-layout', 'data-size', 'data-tile-count']; + function replaceSectionElement(entry, newContent) { const cfg = entry.effectiveConfig; - const isSpotlight = isSpotlightSection(cfg); + const isSpotlight = newContent.classList.contains('spotlight-section'); let preservedLayout = null; let preservedGapless = null; if (!isSpotlight) { @@ -862,9 +901,21 @@ || window.cardBuilder?.resolveUseGaplessCards?.(cfg) === true; } + const wasSpotlight = entry.element.classList.contains('spotlight-section'); + const modeChanged = wasSpotlight !== isSpotlight; + newContent.style.cssText = entry.element.style.cssText; - newContent.className = entry.element.className; + if (modeChanged) { + const staleClasses = wasSpotlight ? SPOTLIGHT_MODE_CLASSES : NORMAL_MODE_CLASSES; + entry.element.classList.forEach(cls => { + if (!staleClasses.includes(cls)) newContent.classList.add(cls); + }); + } else { + newContent.className = entry.element.className; + } Array.from(entry.element.attributes).forEach(attr => { + if (attr.name === 'class') return; + if (modeChanged && !isSpotlight && SPOTLIGHT_MODE_ATTRS.includes(attr.name)) return; newContent.setAttribute(attr.name, attr.value); }); if (cfg.order != null) { @@ -910,7 +961,7 @@ } let newContent = null; - if (isSpotlightSection(cfg) && window.cardBuilder?.renderSpotlightSection) { + if (shouldRenderAsSpotlight(entry) && window.cardBuilder?.renderSpotlightSection) { const activeSlide = entry.element?.querySelector('.spotlight-item[data-active]'); const initialIndex = parseInt(activeSlide?.getAttribute('data-index'), 10); newContent = window.cardBuilder.renderSpotlightSection(items, cfg.name, { @@ -1129,6 +1180,7 @@ showCloseButton: false, closeOnBackdrop: true, closeOnEscape: true, + fixedSize: false, onOpen: (modalInstance) => { styleAnchoredPopoverDialog(modalInstance, anchorBtn); const root = modalInstance.dialogContent; @@ -1314,6 +1366,7 @@ showCloseButton: false, closeOnBackdrop: true, closeOnEscape: true, + fixedSize: false, onOpen: (modalInstance) => { styleAnchoredPopoverDialog(modalInstance, anchorBtn); const root = modalInstance.dialogContent; @@ -1959,20 +2012,16 @@ } function resolvePopoverMount(anchorButton, sectionElement) { - return anchorButton?.closest( - '.sectionTitleContainer, .spotlight-section-title-container' - ) || sectionElement; + return document.body; } function preparePopoverMount(anchorButton, sectionElement) { const mountElement = resolvePopoverMount(anchorButton, sectionElement); - mountElement.classList.add('kefin-section-has-configure-popover'); sectionElement?.classList.add('kefin-section-configure-popover-open'); return mountElement; } function cleanupPopoverMount(mountElement, sectionElement) { - mountElement?.classList.remove('kefin-section-has-configure-popover'); sectionElement?.classList.remove('kefin-section-configure-popover-open'); } @@ -2139,54 +2188,51 @@ } function positionConfigurePopover(popover, anchorButton, mountElement) { - const sectionElement = mountElement.closest('[data-section-id]') - || activePopover?.entry?.element + const sectionElement = activePopover?.entry?.element + || anchorButton?.closest?.('[data-section-id]') || mountElement; - const mountRect = mountElement.getBoundingClientRect(); - const sectionRect = sectionElement.getBoundingClientRect(); + const sectionRect = sectionElement?.getBoundingClientRect?.() || anchorButton.getBoundingClientRect(); const buttonRect = anchorButton.getBoundingClientRect(); - const popoverWidth = popover.offsetWidth || popover.getBoundingClientRect().width; + const popoverWidth = popover.offsetWidth || popover.getBoundingClientRect().width || 280; const margin = 8; const gap = 8; - // Center on the configure button in viewport space first. let leftViewport = buttonRect.left + (buttonRect.width / 2) - (popoverWidth / 2); - - // Keep inside the section when possible; always keep inside the viewport. const minLeft = Math.max(sectionRect.left + margin, margin); const maxLeft = Math.min(sectionRect.right - popoverWidth - margin, window.innerWidth - popoverWidth - margin); if (maxLeft >= minLeft) { leftViewport = Math.min(Math.max(leftViewport, minLeft), maxLeft); } else { - // Section narrower than popover (common when section name is hidden): - // pin to the section's left edge, then clamp to the viewport. leftViewport = Math.min( Math.max(sectionRect.left + margin, margin), Math.max(margin, window.innerWidth - popoverWidth - margin) ); } - popover.style.left = `${leftViewport - mountRect.left}px`; - - // Spotlights: always open below so the popover stays inside the banner - // (overflow/mask on .spotlight-banner-container would clip anything above). - // Normal sections: decide once from expanded height; never flip after open. let placeBelow; if (activePopover?.anchorMode === 'above' || activePopover?.anchorMode === 'below') { placeBelow = activePopover.anchorMode === 'below'; } else { - const isSpotlight = !!mountElement.closest('.spotlight-section') - || activePopover?.entry?.element?.classList.contains('spotlight-section'); + const isSpotlight = !!sectionElement?.classList?.contains('spotlight-section') + || !!anchorButton?.closest?.('.spotlight-section'); const expandedHeight = getPopoverPlacementHeight(popover, true); - const spaceAbove = mountRect.top - margin; + const spaceAbove = buttonRect.top - margin; placeBelow = isSpotlight || spaceAbove < expandedHeight + gap; if (activePopover) activePopover.anchorMode = placeBelow ? 'below' : 'above'; } popover.classList.toggle('is-below', placeBelow); - popover.style.top = ''; - popover.style.bottom = ''; + popover.style.position = 'fixed'; + popover.style.left = `${leftViewport}px`; + if (placeBelow) { + popover.style.top = `${buttonRect.bottom + gap}px`; + popover.style.bottom = 'auto'; + } else { + const height = getPopoverPlacementHeight(popover, popover.classList.contains('is-expanded')); + popover.style.top = `${Math.max(margin, buttonRect.top - gap - height)}px`; + popover.style.bottom = 'auto'; + } } function openConfigurePopover(sectionConfig, sectionElement, anchorButton) { @@ -2263,6 +2309,13 @@

${escapeHtml(sectionConfig.name)}

+ + ${isPinnedParent ? ` + ` : ''} @@ -2287,6 +2340,10 @@ + + ${!isSpotlight ? ` `} - ${isPinnedParent ? ` - ` : ''} - @@ -2589,6 +2639,25 @@ openRestoreSectionDefaultsConfirm(() => restoreSectionToServerDefaults(entry, serverDefaults)); }); + popover.querySelector('.kefin-section-view-carousel-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + const nextSpotlight = !isSpotlightSection(entry.effectiveConfig); + entry.effectiveConfig.renderMode = nextSpotlight ? 'Spotlight' : 'Normal'; + entry.effectiveConfig.spotlight = nextSpotlight; + if (nextSpotlight) { + entry.effectiveConfig.spotlightConfig = buildSpotlightConfigSeed( + entry.effectiveConfig.spotlightConfig, + serverDefaults + ); + } + persistAndApply(entry, serverDefaults, () => { + closePopover(); + rerenderSection(entry); + const anchor = getConfigureAnchorButton(entry); + if (anchor) openConfigurePopover(entry.effectiveConfig, entry.element, anchor); + }); + }); + popover.querySelector('.kefin-section-more-toggle')?.addEventListener('click', (e) => { e.stopPropagation(); const willExpand = !popover.classList.contains('is-expanded'); @@ -2724,9 +2793,7 @@ overflow: visible; } .kefin-section-configure-popover { - position: absolute; - bottom: calc(100% - 15px); - top: auto; + position: fixed; z-index: 12000; box-sizing: border-box; display: flex; @@ -2738,8 +2805,6 @@ overflow: hidden; } .kefin-section-configure-popover.is-below { - top: 55px; - bottom: auto; flex-direction: column-reverse; } .kefin-section-configure-popover.is-below .kefin-section-configure-title-container { @@ -2809,12 +2874,22 @@ .kefin-section-configure-primary-row .paper-icon-button-light .material-icons { font-size: 1.25rem; } - .kefin-section-configure-primary-row .kefin-section-restore-defaults-btn { - margin-left: auto; + .kefin-section-configure-divider { + width: 1px; + align-self: stretch; + margin: 0.35em 0.25em; + background: rgba(255, 255, 255, 0.2); + flex: 0 0 1px; } .kefin-section-visibility-btn.is-hidden-visibility { opacity: 0.45; } + .kefin-section-view-carousel-btn:not(.is-active) { + opacity: 0.4; + } + .kefin-section-view-carousel-btn.is-active { + opacity: 1; + } .kefin-section-restore-defaults-btn.is-disabled, .kefin-section-restore-defaults-btn:disabled { opacity: 0.4; @@ -3066,7 +3141,9 @@ margin: 0 0 0.5rem 0.5rem; } .kefin-section-configure-title-row .kefin-section-open-editor-btn, - .kefin-section-configure-title-row .kefin-section-publish-btn { + .kefin-section-configure-title-row .kefin-section-publish-btn, + .kefin-section-configure-title-row .kefin-section-restore-defaults-btn, + .kefin-section-configure-title-row .kefin-section-unpin-button { flex: 0 0 auto; opacity: 0.85; padding: 0; @@ -3075,7 +3152,9 @@ height: 2.25rem; } .kefin-section-configure-title-row .kefin-section-open-editor-btn .material-icons, - .kefin-section-configure-title-row .kefin-section-publish-btn .material-icons { + .kefin-section-configure-title-row .kefin-section-publish-btn .material-icons, + .kefin-section-configure-title-row .kefin-section-restore-defaults-btn .material-icons, + .kefin-section-configure-title-row .kefin-section-unpin-button .material-icons { font-size: 1.25rem; } .kefin-section-configure-title-row .kefin-section-open-editor-btn[hidden], @@ -3083,7 +3162,9 @@ display: none !important; } .kefin-section-configure-title-row .kefin-section-open-editor-btn:hover, - .kefin-section-configure-title-row .kefin-section-publish-btn:hover { + .kefin-section-configure-title-row .kefin-section-publish-btn:hover, + .kefin-section-configure-title-row .kefin-section-restore-defaults-btn:hover, + .kefin-section-configure-title-row .kefin-section-unpin-button:hover { opacity: 1; } `;