Error loading editor. Please refresh the page.
';
+ return;
+ }
+
+ if (!window.KefinTweaksUI || !window.KefinTweaksUI.renderHomeSectionsOrderEditor) {
+ ERR('KefinTweaksUI.renderHomeSectionsOrderEditor not available');
+ container.innerHTML = 'Error loading editor. Please refresh the page.
';
+ return;
+ }
+
+ // Resolved Kefin sections only (no Jellyfin-native duplicate rows)
+ const userConfig = await getConfig();
+ let mergedHomeConfig = null;
+ try {
+ mergedHomeConfig = await window.KefinHomeScreen?.getConfig?.();
+ } catch (e) {
+ WARN('Could not load merged home config for editor labels:', e);
+ }
+ const discoveryFeatureEnabled = mergedHomeConfig?.DISCOVERY_SETTINGS?.enabled !== false;
+ const serverSectionsById = buildServerSectionsByIdFromConfig(mergedHomeConfig);
+ const discoveryGroupMap = buildDiscoverySectionGroupMap(mergedHomeConfig);
+
+ const filterEditorSection = (s) => {
+ if (s.userConfigurable === false) return false;
+ // Keep hidden sections only when currently enabled
+ if (s.hidden === true && s.enabled === false) return false;
+ return true;
+ };
+
+ const normalizeEditorSectionType = (section) => {
+ if (isDiscoverySectionForPref(section)) return 'discovery';
+ const t = String(section.type || '').toLowerCase();
+ if (t === 'seasonal') return 'seasonal';
+ return 'home';
+ };
+
+ let homeSections = (userConfig.sections || []).filter(filterEditorSection);
+ let discoverySections = [];
+ if (discoveryFeatureEnabled && Array.isArray(userConfig.enabledDiscoverySections)) {
+ discoverySections = applyUserSectionOverrides(
+ userConfig.enabledDiscoverySections.filter(filterEditorSection),
+ userConfig.homeScreen,
+ { serverSectionsById }
+ ).map((section) => {
+ const groupMeta = discoveryGroupMap.get(section.id);
+ if (!groupMeta) return section;
+ return {
+ ...section,
+ discoveryGroupId: groupMeta.groupId,
+ discoveryGroupName: groupMeta.groupName
+ };
+ });
+ }
+
+ let allSections = [...homeSections, ...discoverySections].map((section) => {
+ const serverSection = serverSectionsById.get(section.id);
+ const caption = section.caption || serverSection?.caption;
+ const groupMeta = discoveryGroupMap.get(section.id);
+ const next = {
+ ...section,
+ type: normalizeEditorSectionType(section),
+ ...(caption != null && caption !== '' ? { caption } : {})
+ };
+ if (groupMeta) {
+ next.discoveryGroupId = groupMeta.groupId;
+ next.discoveryGroupName = groupMeta.groupName;
+ }
+ return next;
+ });
+
+ // Sort by order
+ allSections.sort((a, b) => (a.order || 0) - (b.order || 0));
+
+ // Create editor HTML
+ const editorHTML = window.KefinTweaksUI.renderHomeSectionsOrderEditor(allSections, {
+ showUpDownButtons: true
+ });
+
+ const serverPairDefault = getServerPairNativeHomeSectionsDefault();
+ const effectivePair = resolvePairNativeHomeSections(userConfig.homeScreen);
+ const pairDescOn = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.';
+ const pairDescOff = 'Do NOT enable this if you use the official Jellyfin for Roku or Android TV clients. When this is enabled, your Home Screen on those clients will be empty and you will need to go to your User Settings to re-enable sections.';
+ const pairToggleHTML = typeof window.KefinTweaksUI?.buildToggleCard === 'function'
+ ? window.KefinTweaksUI.buildToggleCard(
+ 'kefin-user-pair-native-home-sections',
+ !effectivePair,
+ 'Improve Home Screen Performance',
+ !effectivePair ? pairDescOn : pairDescOff,
+ { hintKey: 'pairNativeHomeSections' }
+ )
+ : '';
+
+ container.innerHTML = `
+
+ `;
+
+ // Pair-native toggle: dirty-only persist (INVERTED UI: on in UI means flag == false)
+ const pairCheckbox = container.querySelector('#kefin-user-pair-native-home-sections');
+ const pairCard = pairCheckbox?.closest('.kefin-toggle-card');
+ if (pairCheckbox && pairCard) {
+ const pairSwitch = pairCard.querySelector('.kefin-toggle-switch, .toggle-slider, button');
+ // When the toggle appears "on" in the UI, the flag is actually false (inverse)
+ const updatePairDesc = (checked) => {
+ // checked (UI "on") means flag === false
+ const desc = pairCard.querySelector('.kefin-toggle-card-desc');
+ if (desc) desc.textContent = checked ? pairDescOn : pairDescOff;
+ };
+ const onPairToggle = async () => {
+ // checked (UI 'on') means flag should be false
+ const flagValue = !pairCheckbox.checked;
+ updatePairDesc(pairCheckbox.checked);
+ if (typeof window.KefinTweaksUI?.updateToggleSwitchUI === 'function' && pairSwitch) {
+ window.KefinTweaksUI.updateToggleSwitchUI(pairSwitch, pairCheckbox.checked);
+ }
+ // For persistence: (flagValue === serverPairDefault) ? null : flagValue
+ await saveUserPreferences(allSections, {
+ pairNativeHomeSections: flagValue === serverPairDefault ? null : flagValue
+ });
+ };
+ pairSwitch?.addEventListener('click', (e) => {
+ e.preventDefault();
+ pairCheckbox.checked = !pairCheckbox.checked;
+ onPairToggle();
+ });
+ pairCheckbox.addEventListener('change', onPairToggle);
+
+ // On initial render, enforce the correct description and checked value
+ // (in case render logic needs to set checked/unchecked based on flag)
+ // (Optional: If not already being handled by upstream render logic)
+ //pairCheckbox.checked = !effectivePair;
+ updatePairDesc(pairCheckbox.checked);
+ }
+
+ // Restore Defaults: confirm then clear kefinHomeScreen and re-render
+ const restoreBtn = container.querySelector('.kefin-restore-defaults-btn');
+ if (restoreBtn && window.ModalSystem) {
+ restoreBtn.addEventListener('click', () => {
+ const modalId = 'kefin-user-homescreen-restore-defaults';
+ const content = 'Restoring the default Home Screen Settings will override your existing settings. Are you sure you want to proceed?
';
+ const footer = `
+ Error loading editor. Please refresh the page.
';
+ }
+ }
+
+ // Register onViewPage handler for mypreferenceshome page
+ function registerPreferencesPageHandler() {
+ if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
+ setTimeout(registerPreferencesPageHandler, 1000);
+ return;
+ }
+
+ window.KefinTweaksUtils.onViewPage(async (view, element, hash) => {
+ LOG('Preferences home page detected');
+
+ let form = null;
+ for (let attempt = 0; attempt < 20; attempt++) {
+ form = document.querySelector('.libraryPage:not(.hide) .homeScreenSettingsContainer > form');
+ if (form && form.firstChild) break;
+ await delay(150);
+ }
+
+ if (!form || !form.firstChild) {
+ WARN('Home settings form not ready');
+ return;
+ }
+
+ if (form.dataset.kefinNativeSaveHook !== 'true') {
+ form.dataset.kefinNativeSaveHook = 'true';
+ form.addEventListener('submit', () => {
+ armNativeHomeSaveKefinMerge({
+ getSections: () => activeUserHomeEditorSections
+ });
+ });
+ }
+
+ if (form.querySelector('#kefin-user-home-sections-editor')) {
+ return;
+ }
+
+ const editorContainer = document.createElement('div');
+ editorContainer.id = 'kefin-user-home-sections-editor';
+ form.appendChild(editorContainer);
+ await renderUserHomeSectionsEditor(editorContainer);
+ }, {
+ pages: ['mypreferenceshome', 'userpreferences']
+ });
+
+ LOG('Registered preferences page handler');
+ }
+
+ // Initialize
+ registerPreferencesPageHandler();
+
+ // Expose render function globally
+ window.KefinUserHomeScreenConfig = {
+ renderUserHomeSectionsEditor,
+ parseKefinTweaksHomeScreen,
+ parseSectionPrefString,
+ serializeSectionPref,
+ getStoredSectionPrefId,
+ upsertSectionPref,
+ removeSectionPref,
+ applyUserSectionOverrides,
+ buildPinnedSectionConfigs,
+ saveKefinTweaksHomeScreen,
+ saveKefinTweaksHomeScreenAndSyncNative,
+ loadKefinTweaksHomeScreenFromDisplayPrefs,
+ createEmptyHomeScreen,
+ parsePinnedListString,
+ serializePinnedList,
+ parsePinnedParentString,
+ serializePinnedParent,
+ buildPinnedParentQueryOptions,
+ buildPinnedParentViewMoreUrl,
+ getPinnedListSectionId,
+ getPinnedParentSectionId,
+ getServerSectionDefaults,
+ saveSectionItemsLayout,
+ saveSectionUseGaplessCards,
+ loadSectionItemsLayout,
+ saveSectionGridExpanded,
+ isHomeScreenSection,
+ parseKefinTweaksSectionState,
+ saveKefinTweaksSectionState,
+ createEmptySectionState,
+ normalizeItemsLayout,
+ normalizeRenderMode,
+ resolveRenderMode,
+ DEFAULT_PINNED_LIST_NAME,
+ PINNED_DEFAULT_ORDER,
+ homeScreenToLegacyArray,
+ getConfig,
+ resolvePreferencesTargetUserId,
+ enableJellyfinSectionsFromKefin,
+ syncNativeHomeSectionsFromKefin,
+ syncKefinEnabledFromNativeHomeSections,
+ resolvePairNativeHomeSections,
+ getServerPairNativeHomeSectionsDefault,
+ buildHomesectionSlotsFromKefin,
+ getSectionJellyfinIds,
+ sectionMatchesNativeType,
+ resolveSectionJellyfinId,
+ resolveHomesectionValue,
+ DEFAULT_NATIVE_HOME_SECTIONS,
+ updateUserHomeScreenConfiguration,
+ updateUserHomeScreenSectionConfiguration
+ };
+
+ LOG('User Home Screen Configuration loaded');
+
+})();
diff --git a/configuration/homeScreenAdvancedEditor.css b/configuration/homeScreenAdvancedEditor.css
new file mode 100644
index 0000000..a8e69b3
--- /dev/null
+++ b/configuration/homeScreenAdvancedEditor.css
@@ -0,0 +1,1836 @@
+/* Advanced section editor (custom sections) — kefin-homescreen-section-editor modal */
+.dialogContainer:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ [data-name="kefin-modal-content"] {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-root {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ margin: 0 auto;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-editor-body {
+ flex: 1;
+ padding: 0.25em 0;
+ min-height: 0;
+ margin: 0 auto;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-editor-body > * {
+ max-width: 800px;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group:last-child {
+ padding-bottom: 1.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-main {
+ display: flex;
+ align-items: center;
+ gap: 0.35em;
+ flex: 1;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ min-width: 0;
+ flex: 1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-title-wrap
+ h2,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-title-wrap
+ .dialogTitle {
+ margin: 0;
+ text-align: left;
+ flex-shrink: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-section-name-badge {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ padding: 0.2em 0.65em;
+ border-radius: 999px;
+ border: 1px solid rgba(0, 164, 220, 0.45);
+ background: rgba(0, 164, 220, 0.12);
+ color: var(--theme-primary-color, #00a4dc);
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.35;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.1em;
+ flex-shrink: 0;
+ margin-left: 0.15em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-action-btn {
+ flex-shrink: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-action-btn[hidden] {
+ display: none !important;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .formDialogHeader
+ .hsae-header-action-btn
+ .material-icons {
+ font-size: 1.35rem;
+}
+
+.hsae-restore-diff-table {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ font-size: 0.85rem;
+}
+
+.hsae-restore-diff-row {
+ display: grid;
+ grid-template-columns: minmax(5.5rem, 0.9fr) 1.1fr 1.1fr;
+ gap: 0.5em;
+ padding: 0.45em 0.65em;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ align-items: start;
+}
+
+.hsae-restore-diff-row:first-child {
+ border-top: none;
+}
+
+.hsae-restore-diff-head {
+ font-weight: 600;
+ opacity: 0.85;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.hsae-restore-diff-row > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hsae-troubleshoot-summary-wrap {
+ margin-bottom: 0.85em;
+}
+
+.hsae-troubleshoot-summary {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.hsae-troubleshoot-summary th,
+.hsae-troubleshoot-summary td {
+ padding: 0.45em 0.65em;
+ text-align: left;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.hsae-troubleshoot-summary thead th {
+ font-weight: 600;
+ opacity: 0.9;
+ background: rgba(255, 255, 255, 0.04);
+ border-top: none;
+}
+
+.hsae-troubleshoot-panels {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85em;
+}
+
+.hsae-troubleshoot-panel {
+ min-width: 0;
+}
+
+.hsae-troubleshoot-textarea {
+ width: 100%;
+ min-height: 180px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.9em;
+ line-height: 1.5;
+ box-sizing: border-box;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group {
+ margin-bottom: 1.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75em;
+ margin-bottom: 0.65em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group-title {
+ font-weight: 600;
+ font-size: 1rem;
+ line-height: 1.35;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group-desc {
+ font-size: 0.875rem;
+ margin-top: 0.2em;
+ line-height: 1.45;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-queries-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.45em;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ padding: 1em 1.1em;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 0.85em 1.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+: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: 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));
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-sort {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-seasonal-dates {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+@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));
+ }
+}
+
+@media (max-width: 640px) {
+ :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-grid-2,
+ :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: 1fr;
+ }
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-label {
+ font-size: 0.875rem;
+ margin-bottom: 0.35em;
+ line-height: 1.35;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-hint {
+ font-size: 0.8125rem;
+ margin-top: 0.35em;
+ line-height: 1.45;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-toggle-with-tooltip {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-enabled-row {
+ margin-top: 0.85em;
+ padding-top: 0.85em;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75em;
+}
+
+@media (max-width: 640px) {
+ :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-card {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.35em;
+ width: 100%;
+ text-align: left;
+ padding: 1em 1.1em;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+ cursor: pointer;
+ color: inherit;
+ font-family: inherit;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-card:hover {
+ border-color: rgba(0, 164, 220, 0.5);
+ background: rgba(0, 164, 220, 0.06);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-source-card.hsae-active {
+ border-color: rgba(0, 164, 220, 0.6);
+ background: rgba(0, 164, 220, 0.1);
+ box-shadow: inset 3px 0 0 var(--theme-primary-color, #00a4dc);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-card-text {
+ flex: 1;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-title {
+ display: flex;
+ align-items: center;
+ gap: 0.4em;
+ font-weight: 600;
+ font-size: 0.9375rem;
+ line-height: 1.35;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-icon {
+ font-size: 1.125rem;
+ opacity: 0.85;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-source-desc {
+ margin-top: 0.3em;
+ font-size: 0.8125rem;
+ line-height: 1.45;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-chip-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-field-row
+ .hsae-field-hint {
+ margin-top: 0.15em;
+ margin-bottom: 0.4em;
+}
+
+/* .hsae-chip base + active come from shared .kefin-chip */
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-cache-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-cache-section
+ .hsae-ttl-custom {
+ margin-top: 0.85em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-cache-section
+ .hsae-ttl-custom
+ .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-cache-section
+ .hsae-ttl-custom
+ select,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-cache-section
+ .hsae-ttl-custom
+ input {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-cache-preset {
+ padding: 0.4em 0.75em;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.04);
+ font-size: 0.8125rem;
+ cursor: pointer;
+ font-family: inherit;
+ color: inherit;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-cache-preset.hsae-active {
+ border-color: rgba(0, 164, 220, 0.6);
+ background: rgba(0, 164, 220, 0.15);
+ color: var(--theme-primary-color, #00a4dc);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-queries-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-card {
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75em;
+ padding: 0.75em 1em;
+ cursor: pointer;
+ list-style: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-summary::-webkit-details-marker {
+ display: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ min-width: 0;
+ flex: 1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-name-input {
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid transparent;
+ border-radius: 0;
+ padding: 0.1em 0;
+ color: inherit;
+ font: inherit;
+ cursor: text;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-name-input:hover,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-name-input:focus {
+ border-bottom-color: rgba(255, 255, 255, 0.25);
+ outline: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-multi-query-sort {
+ margin-bottom: 0.85em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-multi-query-toggle-row {
+ margin-bottom: 0.85em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-multi-query-picker-label-row {
+ margin-bottom: 0.85em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-badge {
+ font-size: 0.75rem;
+ padding: 0.15em 0.5em;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ white-space: nowrap;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-body {
+ padding: 0 1em 1em;
+ display: flex;
+ flex-direction: column;
+ gap: 0.85em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-divider {
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ margin: 0.15em 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-divider-before-include-types {
+ margin-bottom: 0.85em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-divider-before-parent {
+ margin-top: 1.1em;
+ margin-bottom: 1.1em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-divider-parent-filters {
+ margin-top: 1.1em;
+ margin-bottom: 1.1em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-divider-before-additional {
+ margin-top: 1.25em;
+ margin-bottom: 1.1em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-header-divider {
+ margin-top: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-delete {
+ flex-shrink: 0;
+ opacity: 0.7;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-add-query-btn {
+ border: 1px dashed rgba(255, 255, 255, 0.2);
+ background: transparent;
+ margin: 0.75em auto;
+ display: flex;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-add-query-btn:hover {
+ border-color: rgba(0, 164, 220, 0.5);
+ background: rgba(0, 164, 220, 0.06);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-details-summary {
+ display: flex;
+ align-items: center;
+ gap: 0.35em;
+ cursor: pointer;
+ margin-bottom: 0.5em;
+ list-style: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-details-summary::-webkit-details-marker {
+ display: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-additional-details[open]
+ .hsae-advanced-chevron {
+ transform: rotate(180deg);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-details {
+ margin-top: 0.35em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-additional-options-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-row-full {
+ grid-column: 1 / -1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-additional-row
+ > .kefin-toggle-card,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-additional-row
+ > .hsae-additional-field,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-additional-row
+ > .hsae-custom-option-grid {
+ flex: 1;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-additional-row
+ > .hsae-additional-field
+ .fld {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-remove-additional-option {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.7);
+ cursor: pointer;
+ padding: 0.25em;
+ display: inline-flex;
+ align-items: center;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-custom-option-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5em;
+ flex: 1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-info-link {
+ color: inherit;
+ opacity: 0.75;
+ display: inline-flex;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-info-link:hover {
+ opacity: 1;
+}
+
+/* Reuse wizard custom-items visuals inside the advanced editor modal */
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-items-stage,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-items-grid,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-tile,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-editor-overlay,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-editor-panel {
+ /* inherit from create-flow rules via duplicated selectors below */
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-items-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1em;
+ justify-content: start;
+ padding: 0.5em 0 1em;
+ align-items: center;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-tile,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile {
+ width: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.35em;
+}
+
+.hsse-custom-items-grid > * {
+ max-width: 120px;
+}
+.listItemBodyText.hsse-custom-item-tile-label {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+}
+.hsse-custom-item-edit-badge {
+ position: absolute;
+ bottom: 0.2em;
+ right: 0.2em;
+}
+.hsse-custom-item-actions > * {
+ height: 25px;
+ width: 25px;
+ display: flex;
+ justify-content: center;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-tile.is-hidden
+ .hsse-custom-item-tile-image,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-tile.is-hidden
+ .hsse-custom-item-tile-label {
+ opacity: 0.4;
+ filter: grayscale(0.35);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-actions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.2em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-action,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-trash {
+ font-size: 0;
+ padding: 0.3em;
+ min-height: 0;
+ min-width: 0;
+ line-height: 1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-action:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-action-icon,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-trash-icon {
+ font-size: 1.1rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-tile-image,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile {
+ width: auto;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.04);
+ padding: 0;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsse-custom-item-add-tile {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-style: dashed;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-editor-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-editor-backdrop {
+ position: absolute;
+ inset: 0;
+ border: none;
+ background: rgba(0, 0, 0, 0.55);
+ cursor: pointer;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsse-custom-item-editor-panel {
+ position: relative;
+ z-index: 1;
+ width: min(720px, 100%);
+ max-height: 90vh;
+ overflow: auto;
+ padding: 2em;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
+}
+
+.hsse-custom-item-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+ margin-bottom: 1em;
+}
+
+/* Layout tokens and advanced editor layout refinements */
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-root {
+ --hsae-gap-field: 0.85em;
+ --hsae-gap-section: 1.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-field-wrap {
+ margin: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--hsae-gap-field);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--hsae-gap-section);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-summary {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75em;
+ cursor: pointer;
+ margin-bottom: 0.65em;
+ list-style: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-summary-left {
+ display: flex;
+ align-items: center;
+ gap: 0.35em;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-advanced-summary
+ .hsae-group-desc {
+ text-align: right;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-advanced-summary::-webkit-details-marker {
+ display: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-advanced-chevron {
+ transition: transform 0.2s ease;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-advanced-options[open]
+ .hsae-advanced-chevron,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-appearance-advanced[open]
+ .hsae-advanced-chevron {
+ transform: rotate(180deg);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-advanced {
+ margin-top: 0.35em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-appearance-advanced
+ > summary {
+ list-style: none;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-appearance-advanced-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85em;
+ padding-top: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-color-input-row,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-border-color-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-color-pickr-host,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-border-color-pickr-host {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ background: var(--hsae-swatch-color, #e8dab2);
+ cursor: pointer;
+ box-sizing: border-box;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-color-pickr-host
+ .pcr-button,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-border-color-pickr-host
+ .pcr-button {
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+}
+
+.pcr-app {
+ z-index: 100050 !important;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-inline-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: var(--hsae-gap-field) 1.25em;
+ align-items: end;
+}
+
+/* Query row A: query type + endpoint/cache in two equal columns */
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-row-a
+ > .hsae-custom-path {
+ grid-column: 1 / -1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-f {
+ margin-top: 1.1em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-endpoint-inline {
+ display: flex;
+ gap: var(--hsae-gap-field);
+ align-items: flex-end;
+ flex-wrap: nowrap;
+ flex: 2 1 320px;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-endpoint-inline
+ .hsae-field-wrap {
+ flex: 1;
+ min-width: 140px;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75em;
+}
+
+@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;
+ }
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-card {
+ text-align: left;
+ padding: 1.15em 1.25em;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+ cursor: pointer;
+ font-family: inherit;
+ color: inherit;
+ display: grid;
+ align-content: start;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-card:hover {
+ border-color: rgba(0, 164, 220, 0.5);
+ background: rgba(0, 164, 220, 0.06);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-appearance-card.hsae-active {
+ border-color: rgba(0, 164, 220, 0.6);
+ background: rgba(0, 164, 220, 0.1);
+ box-shadow: inset 3px 0 0 var(--theme-primary-color, #00a4dc);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-title {
+ font-weight: 600;
+ font-size: 1rem;
+ line-height: 1.3;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-desc {
+ font-size: 0.875rem;
+ margin-top: 0.35em;
+ line-height: 1.5;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.5em;
+}
+
+@media (max-width: 640px) {
+ :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-btn {
+ text-align: left;
+ padding: 0.75em;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.03);
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-family: inherit;
+ color: inherit;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-btn:hover {
+ border-color: rgba(0, 164, 220, 0.5);
+ background: rgba(0, 164, 220, 0.06);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-card-format-btn.hsae-active {
+ border-color: rgba(0, 164, 220, 0.6);
+ background: rgba(0, 164, 220, 0.1);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-appearance-normal-row {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25em;
+ align-items: stretch;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-appearance-live-preview-row {
+ margin-top: 0.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-card-format-grid-2col {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-appearance-preview-container {
+ margin-top: 0;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 0.75em;
+ background: rgba(0, 0, 0, 0.15);
+ overflow: auto;
+ min-height: 200px;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-preview-placeholder {
+ text-align: center;
+ padding: 1.5em 0;
+ margin: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-control {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65em;
+ padding: 0.85em 1em;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-text {
+ flex: 1;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-label {
+ font-weight: 600;
+ font-size: 0.9375rem;
+ line-height: 1.35;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-desc {
+ margin-top: 0.25em;
+ font-size: 0.8125rem;
+ line-height: 1.45;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-actions {
+ display: flex;
+ gap: 0.35em;
+ flex-shrink: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.75);
+ cursor: pointer;
+ font-family: inherit;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .kt-autocomplete-icon-btn:hover {
+ border-color: rgba(0, 164, 220, 0.45);
+ color: rgba(255, 255, 255, 0.95);
+ background: rgba(0, 164, 220, 0.08);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .kt-autocomplete-icon-btn
+ .material-icons {
+ font-size: 1.125rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-field {
+ position: relative;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-input-row {
+ display: flex;
+ align-items: stretch;
+ gap: 0.5em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .kt-autocomplete-input-row
+ .autocomplete-input {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35em;
+ align-items: flex-start;
+ min-height: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .kt-autocomplete-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25em;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 6px;
+ padding: 0.2em 0.45em;
+ font-size: 0.875rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .kt-autocomplete-badge-remove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.65);
+ cursor: pointer;
+ padding: 0;
+ margin: 0;
+ line-height: 1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .kt-autocomplete-badge-remove
+ .material-icons {
+ font-size: 1rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .autocomplete-suggestions {
+ display: none;
+ position: absolute;
+ z-index: 10000;
+ top: calc(100% + 2px);
+ left: 0;
+ right: 0;
+ background: #1a1a1a;
+ color: rgba(255, 255, 255, 0.87);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ max-height: 200px;
+ overflow-y: auto;
+ box-sizing: border-box;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .autocomplete-suggestion {
+ padding: 0.5em 0.75em;
+ cursor: pointer;
+ border-left: 3px solid transparent;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .autocomplete-suggestion:hover,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .autocomplete-suggestion.autocomplete-suggestion-active {
+ background: rgba(255, 255, 255, 0.08);
+ border-left-color: var(--theme-primary-color, #00a4dc);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-basic-dropdowns
+ .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-dropdowns select,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-basic-dropdowns input {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ #hsae-card-title-options-row
+ .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ #hsae-card-title-options-row
+ select {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-field-grid
+ .hsae-field-with-hint
+ select {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-toggle-card-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-appearance-advanced-body
+ .hsae-toggle-card-grid
+ .kefin-toggle-card {
+ grid-column: 1 / -1;
+}
+
+@media (max-width: 640px) {
+ :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-toggle-card-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-additional-toolbar {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5em;
+ flex-wrap: wrap;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-additional-toolbar
+ .additional-option-select {
+ flex: 1;
+ min-width: 180px;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-subsection-label {
+ font-weight: 600;
+ font-size: 0.9375rem;
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+ line-height: 1.35;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-spotlight-inner {
+ display: flex;
+ flex-direction: column;
+ gap: var(--hsae-gap-section);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-row-1
+ .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-row-1
+ select {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-backdrops-row
+ .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-backdrops-row
+ .fld {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-spotlight-animations {
+ display: grid;
+ gap: var(--hsae-gap-field) 1.25em;
+ grid-template-columns: minmax(0, 1fr);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-animations--1 {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-animations--2 {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-animations--3 {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-spotlight-anim-group {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-anim-pair
+ .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-anim-pair
+ select {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-row-autoplay
+ > .kefin-toggle-card,
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-row-autoplay
+ > .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-spotlight-row-autoplay
+ .fld {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-multi-query-settings {
+ margin-top: 0.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-multi-query-settings
+ .hsae-query-mode-grid {
+ margin-top: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-multi-query-sort
+ .hsae-field-wrap {
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-multi-query-sort
+ select {
+ width: 100%;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-mode-grid {
+ margin-top: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-mode-card.hsae-active {
+ border-color: rgba(0, 164, 220, 0.6);
+ background: rgba(0, 164, 220, 0.1);
+ box-shadow: inset 3px 0 0 var(--theme-primary-color, #00a4dc);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75em;
+}
+
+@media (max-width: 700px) {
+ :is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65em;
+ padding: 0.85em 1em;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+ min-width: 0;
+ cursor: pointer;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card:hover {
+ border-color: rgba(0, 164, 220, 0.4);
+ background: rgba(0, 164, 220, 0.05);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-select-card.hsae-active {
+ border-color: rgba(0, 164, 220, 0.45);
+ background: rgba(0, 164, 220, 0.06);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-text {
+ flex: 1;
+ min-width: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-label {
+ display: flex;
+ align-items: center;
+ gap: 0.4em;
+ font-weight: 600;
+ font-size: 0.9375rem;
+ line-height: 1.35;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-icon {
+ font-size: 1.125rem;
+ opacity: 0.85;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-desc {
+ margin-top: 0.3em;
+ font-size: 0.8125rem;
+ line-height: 1.45;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-add-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.85);
+ cursor: pointer;
+ flex-shrink: 0;
+ font-family: inherit;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-add-btn:hover {
+ border-color: rgba(0, 164, 220, 0.5);
+ background: rgba(0, 164, 220, 0.1);
+ color: #fff;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-select-add-btn
+ .material-icons {
+ font-size: 1.25rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-card-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35em;
+ min-height: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25em;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 6px;
+ padding: 0.2em 0.45em;
+ font-size: 0.875rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-select-chip-remove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.65);
+ cursor: pointer;
+ padding: 0;
+ margin: 0;
+ line-height: 1;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-select-chip-remove
+ .material-icons {
+ font-size: 1rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-types {
+ margin-bottom: 0.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-types-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-types-toggle {
+ flex-shrink: 0;
+ min-width: auto;
+ padding: 0.4em 0.75em;
+ font-size: 0.8125rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-include-type-chips {
+ margin-top: 0.5em;
+ overflow-y: auto;
+}
+
+[data-modal-id="kefin-homescreen-section-create-flow"] .hsse-selection-popover,
+[data-modal-id="kefin-selection-popover"] .hsse-selection-popover,
+[data-modal-id="kefin-library-item-picker"] .hsse-selection-popover {
+ min-width: min(520px, 90vw);
+}
+
+[data-modal-id="kefin-selection-popover"] .hsse-parent-list,
+[data-modal-id="kefin-library-item-picker"] .hsse-parent-list {
+ max-height: min(50vh, 420px);
+ overflow-y: auto;
+}
+
+[data-modal-id="kefin-library-item-picker"] .hsse-section-block {
+ margin-bottom: 0.75em;
+}
+
+[data-modal-id="kefin-library-item-picker"] .hsse-section-block .hsae-field-hint {
+ margin-bottom: 0.5em;
+}
+
+[data-modal-id="kefin-library-item-picker"] .hsse-chip-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+}
+
+/* .hsse-chip base + active come from shared .kefin-chip */
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-query-row-c {
+ margin-top: 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-row-c
+ .hsae-select-card-grid {
+ margin-top: 0.75em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-query-row-c
+ .hsae-field-hint {
+ margin-top: 0.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-items-section {
+ margin-top: 0.25em;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-items-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
+ margin: 0.75em 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-items-empty {
+ margin: 0;
+ padding: 0.75em 0;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75em;
+ padding: 0.65em 0.85em;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-remove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.7);
+ cursor: pointer;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-static-item-remove:hover {
+ background: rgba(255, 80, 80, 0.15);
+ color: #ff8a8a;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-static-item-remove
+ .material-icons {
+ font-size: 1.125rem;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-static-item-add-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4em;
+ padding: 0.5em 0.85em;
+ border: 1px dashed rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.03);
+ color: inherit;
+ cursor: pointer;
+ font-family: inherit;
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-static-item-add-btn:hover {
+ border-color: rgba(0, 164, 220, 0.45);
+ background: rgba(0, 164, 220, 0.08);
+ color: var(--theme-primary-color, #00a4dc);
+}
+
+:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"])
+ .hsae-endpoint-wrap
+ .fld[disabled] {
+ opacity: 0.75;
+ cursor: not-allowed;
+}
+
+.hsse-selection-search {
+ margin-bottom: 1em;
+}
+
+.hsse-custom-item-tile-image.hsse-image-preview.hsse-item-tile-preview img,
+.hsse-image-preview.hsse-item-main-preview img {
+ width: 100%;
+}
+
+/* Profile-driven field visibility — data-hsae-profiles must match EDITOR_PROFILES in homeScreenEditorProfiles.js */
+.hsae-root[data-editor-profile] [data-hsae-profiles] {
+ display: none !important;
+}
+
+.hsae-root[data-editor-profile="full"] [data-hsae-profiles~="full"],
+.hsae-root[data-editor-profile="minimal"] [data-hsae-profiles~="minimal"],
+.hsae-root[data-editor-profile="postProcessing"] [data-hsae-profiles~="postProcessing"],
+.hsae-root[data-editor-profile="normal"] [data-hsae-profiles~="normal"],
+.hsae-root[data-editor-profile="dateCutoffs"] [data-hsae-profiles~="dateCutoffs"] {
+ display: revert !important;
+}
+
+.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] {
+ display: none !important;
+}
+
+.hsae-root[data-editor-profile="full"] .hsae-field-grid[data-hsae-profiles~="full"],
+.hsae-root[data-editor-profile="minimal"] .hsae-field-grid[data-hsae-profiles~="minimal"],
+.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"],
+.hsae-root[data-editor-profile="dateCutoffs"] .hsae-advanced-section-sort[data-hsae-profiles~="dateCutoffs"],
+.hsae-root[data-editor-profile="full"] .hsae-advanced-min-max-age[data-hsae-profiles~="full"],
+.hsae-root[data-editor-profile="dateCutoffs"] .hsae-advanced-min-max-age[data-hsae-profiles~="dateCutoffs"],
+.hsae-root[data-editor-profile="full"] .hsae-advanced-limits[data-hsae-profiles~="full"],
+.hsae-root[data-editor-profile="postProcessing"] .hsae-advanced-limits[data-hsae-profiles~="postProcessing"],
+.hsae-root[data-editor-profile="normal"] .hsae-advanced-limits[data-hsae-profiles~="normal"],
+.hsae-root[data-editor-profile="dateCutoffs"] .hsae-advanced-limits[data-hsae-profiles~="dateCutoffs"] {
+ display: grid !important;
+}
+
+.hsae-root[data-editor-profile] .hsae-toggle-card-grid > [data-hsae-profiles] {
+ display: none !important;
+}
+
+.hsae-root[data-editor-profile="full"] .hsae-toggle-card-grid > [data-hsae-profiles~="full"],
+.hsae-root[data-editor-profile="normal"] .hsae-toggle-card-grid > [data-hsae-profiles~="normal"],
+.hsae-root[data-editor-profile="dateCutoffs"] .hsae-toggle-card-grid > [data-hsae-profiles~="dateCutoffs"],
+.hsae-root[data-editor-profile="postProcessing"] .hsae-toggle-card-grid > [data-hsae-profiles~="postProcessing"] {
+ display: grid !important;
+}
+
+/* Item source — show only the panel matching the active source */
+.hsae-root[data-item-source="jellyfin"] .hsae-custom-items-group,
+.hsae-root[data-item-source="jellyfin"] .hsae-external-list-group {
+ display: none !important;
+}
+
+.hsae-root[data-item-source="custom"] .hsae-queries-group,
+.hsae-root[data-item-source="custom"] .hsae-external-list-group {
+ display: none !important;
+}
+
+.hsae-root[data-item-source="external"] .hsae-queries-group,
+.hsae-root[data-item-source="external"] .hsae-custom-items-group {
+ display: none !important;
+}
+
+.hsae-root[data-editor-profile="full"] .hsae-add-query-btn[data-hsae-profiles~="full"] {
+ display: flex !important;
+ margin-left: auto !important;
+ margin-right: auto !important;
+}
+
+.hsae-root[data-editor-profile] .hsae-query-row-sort .hsae-query-sort-by,
+.hsae-root[data-editor-profile] .hsae-query-row-sort .hsae-query-sort-order,
+.hsae-root[data-editor-profile] .hsae-query-row-sort .hsae-query-limit {
+ display: grid !important;
+}
+
+.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort .hsae-query-sort-by,
+.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort .hsae-query-sort-order,
+.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort .hsae-query-sort-by,
+.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort .hsae-query-sort-order {
+ pointer-events: none;
+ opacity: 0.5;
+}
+
+.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort,
+.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort,
+.hsae-root[data-editor-profile="dateCutoffs"] .hsae-query-row-sort,
+.hsae-root[data-editor-profile="normal"] .hsae-query-row-sort {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.hsae-root[data-editor-profile="minimal"] .hsae-query-row-sort > *,
+.hsae-root[data-editor-profile="postProcessing"] .hsae-query-row-sort > *,
+.hsae-root[data-editor-profile="dateCutoffs"] .hsae-query-row-sort > *,
+.hsae-root[data-editor-profile="normal"] .hsae-query-row-sort > * {
+ flex: 1;
+}
+
+/* Full-width only when Flatten Series is hidden for the profile (solo toggle in the row). */
+.hsae-root[data-editor-profile="minimal"] .hsae-advanced-limit-before-sort,
+.hsae-root[data-editor-profile="postProcessing"] .hsae-advanced-limit-before-sort {
+ grid-column: -1 / 1;
+}
+
+.layout-mobile .hsae-appearance-preview-col {
+ display: none !important;
+}
+
+.hsse-image-preview img {
+ height: 120px;
+ max-width: 120px;
+ width: auto !important;
+}
\ No newline at end of file
diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js
new file mode 100644
index 0000000..b167aa3
--- /dev/null
+++ b/configuration/homeScreenAdvancedEditor.js
@@ -0,0 +1,4926 @@
+// KefinTweaks Home Screen Advanced Section Editor (custom sections)
+
+(function() {
+ 'use strict';
+
+ const SECTION_VISIBILITY_OPTIONS = [
+ { value: 'normal', label: 'Normal' },
+ { value: 'seasonal', label: 'Seasonal' },
+ { value: 'discovery', label: 'Discovery' }
+ ];
+
+
+ const ENDPOINT_OPTIONS = [
+ { value: 'Items', label: 'Items', path: '' },
+ { value: 'Genres', label: 'Genres', path: '/Genres' },
+ { value: 'Persons', label: 'Persons', path: '/Persons' },
+ { value: 'Studios', label: 'Studios', path: '/Studios' },
+ { value: 'Next Up', label: 'Next Up', path: '/Shows/NextUp' },
+ { value: 'Latest', label: 'Latest', path: '/Items/Latest' },
+ { value: 'Custom', label: 'Custom', path: null }
+ ];
+
+ const PEOPLE_CACHE_ITEM_TYPE_VALUES = [
+ { value: '', label: 'All' },
+ { value: 'Movie', label: 'Movie' },
+ { value: 'Series', label: 'Series' },
+ { value: 'Episode', label: 'Episode' }
+ ];
+
+ const PEOPLE_CACHE_ARGS = [
+ {
+ key: 'ItemType',
+ type: 'select',
+ label: 'Item Type',
+ possibleValues: PEOPLE_CACHE_ITEM_TYPE_VALUES
+ },
+ { key: 'MinCount', type: 'number', label: 'Min Count' }
+ ];
+
+ const CACHE_SOURCE_OPTIONS = [
+ { value: 'MoviesCache.getImdbTop250Movies', label: 'IMDb Top 250 Movies' },
+ {
+ value: 'StudiosCache.getPopularTVNetworks',
+ label: 'Popular TV Networks',
+ args: [{ key: 'MinCount', type: 'number', label: 'Min Count' }]
+ },
+ { value: 'PeopleCache.getTopActors', label: 'Top Actors', args: PEOPLE_CACHE_ARGS },
+ { value: 'PeopleCache.getTopDirectors', label: 'Top Directors', args: PEOPLE_CACHE_ARGS },
+ { value: 'PeopleCache.getTopWriters', label: 'Top Writers', args: PEOPLE_CACHE_ARGS }
+ ];
+
+ const DEFAULT_SECTION_QUERY_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16;
+ const LIMIT_ZERO_MODAL_ID = 'kefin-hsae-limit-zero';
+ const LIMIT_ZERO_MESSAGE = 'Setting the limit to 0 can result in queries requesting a lot of items from the server and may result in slow response times. This is fine to use if you know there are only a few hundred results at maximum. If you expect thousands of results, this is probably not a good idea unless you use a fairly long cache time.';
+
+ /** Live editor state for collectData (which only receives a dialog element). */
+ let activeEditorState = null;
+
+ function getLiveEditorState(dialog) {
+ return dialog?._hsaeState
+ || dialog?.closest?.('.dialogContainer')?._hsaeState
+ || activeEditorState
+ || null;
+ }
+
+ function createDefaultQuery() {
+ return { queryOptions: { Limit: DEFAULT_SECTION_QUERY_LIMIT } };
+ }
+
+ /**
+ * Confirm Limit = 0. Cancel / dismiss restores previous; OK keeps 0.
+ * @param {{ onConfirm?: () => void, onCancel?: () => void }} opts
+ */
+ function openLimitZeroConfirm({ onConfirm, onCancel } = {}) {
+ let settled = false;
+ const finish = (ok) => {
+ if (settled) return;
+ settled = true;
+ if (ok) onConfirm?.();
+ else onCancel?.();
+ };
+
+ if (!window.ModalSystem?.create) {
+ if (window.confirm(LIMIT_ZERO_MESSAGE)) finish(true);
+ else finish(false);
+ return;
+ }
+
+ if (window.ModalSystem.isOpen?.(LIMIT_ZERO_MODAL_ID)) {
+ window.ModalSystem.close(LIMIT_ZERO_MODAL_ID);
+ }
+
+ const modalId = LIMIT_ZERO_MODAL_ID;
+ const content = `No items selected yet.
';
+ }
+ return items.map(item => `
+ MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.
`
+ : '';
+ const rows = urls.map((url, index) => `
+
+ ${buildTextInput(`hsae-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')}
+
+ `).join('');
+ return `
+ Custom items editor not available.
';
+ const groupDesc = allowRemove
+ ? 'Create cards with custom images and links'
+ : 'Reorder or hide cards. Default section items cannot be deleted.';
+
+ return `
+
+ ${buildSelect('section-spotlight-entranceAnimation' + ord, SPOTLIGHT_ENTRANCE_OPTIONS, spotlightOpts['entranceAnimation' + ord] ?? 'fadeIn', 'Entrance animation ' + n)}
+ ${buildSelect('section-spotlight-slideAnimation' + ord, SPOTLIGHT_SLIDE_OPTIONS, spotlightOpts['slideAnimation' + ord] ?? 'kenBurnsZoomIn', 'Slide animation ' + n)}
+
+ `;
+ }
+
+ function spotlightToggle(id, checked, label) {
+ return buildToggleCard(id, checked, label, SPOTLIGHT_TOGGLE_DESCRIPTIONS[id] || '');
+ }
+
+ return `
+
+
+ ${buildSelect('section-spotlight-spotlightLayout', SPOTLIGHT_LAYOUT_OPTIONS, spotlightLayout, 'Layout')}
+ ${buildSelect('section-spotlight-spotlightSize', SPOTLIGHT_SIZE_OPTIONS, spotlightSize, 'Size')}
+ ${buildSelect('section-spotlight-tileCount', SPOTLIGHT_TILE_COUNT_OPTIONS, tileCountStr, 'Tiled Backdrops')}
+ ${buildSelect('section-spotlight-slideState', SLIDE_STATE_OPTIONS, slideStateVal, 'Slide State')}
+
+
+ ${spotlightToggle('section-spotlight-panAnimation', animateSlides, 'Animate Slides')}
+ ${spotlightToggle('section-spotlight-showNavButtons', spotlightOpts.showNavButtons !== false, 'Show Controls')}
+ ${spotlightToggle('section-spotlight-showClearArt', spotlightOpts.showClearArt === true, 'Show Clear Art')}
+ ${spotlightToggle('section-spotlight-cycleBackdrops', cycleBackdropsOn, 'Cycle Backdrops')}
+
+
+ ${buildTextInput('section-spotlight-backdropsCount', backdropsCount, 'Backdrops Count', 'number')}
+
+
+
+
${animRow(1)}
+
${animRow(2)}
+
${animRow(3)}
+
+
+ ${spotlightToggle('section-spotlight-autoPlay', spotlightOpts.autoPlay !== false, 'Cycle Slides Automatically')}
+ ${buildTextInput('section-spotlight-interval', intervalSeconds, 'Slide Timer (seconds)', 'number')}
+
+
+ `;
+ }
+
+ /**
+ * Appearance-panel draft: same form→section path as save/Preview, forced to Normal cards.
+ * @param {HTMLElement} dialog
+ * @param {Object} originalSection
+ * @returns {Object|null}
+ */
+ function buildAppearancePreviewDraft(dialog, originalSection) {
+ const draft = collectData(dialog, originalSection);
+ if (!draft) return null;
+ return {
+ ...draft,
+ renderMode: 'Normal'
+ };
+ }
+
+ function getAppearancePreviewFetchKeyFromDraft(draft) {
+ return JSON.stringify({
+ itemSource: inferItemSource(draft),
+ queries: draft?.queries,
+ items: draft?.items,
+ externalListUrls: draft?.externalListUrls,
+ hideWatched: Array.isArray(draft?.queries)
+ && draft.queries.length > 0
+ && draft.queries.every(q => q?.queryOptions?.IsUnplayed === true)
+ });
+ }
+
+ function getAppearancePreviewRenderKeyFromDraft(draft) {
+ return JSON.stringify({
+ fetch: getAppearancePreviewFetchKeyFromDraft(draft),
+ cardFormat: draft?.cardFormat,
+ sortBy: draft?.sortBy,
+ sortOrder: draft?.sortOrder,
+ itemsLayout: draft?.itemsLayout,
+ cardTitleVisibility: draft?.cardTitleVisibility,
+ hideCardTitles: draft?.hideCardTitles,
+ useParentCard: draft?.useParentCard === true,
+ cardTitlePosition: draft?.cardTitlePosition,
+ cardTitleCapitalization: draft?.cardTitleCapitalization,
+ cardTitleFontFamily: draft?.cardTitleFontFamily,
+ cardTitleFontSize: draft?.cardTitleFontSize,
+ borderStyle: draft?.borderStyle,
+ borderColor: draft?.borderColor,
+ cardTitleColor: draft?.cardTitleColor,
+ sectionCssClass: draft?.sectionCssClass,
+ hideName: draft?.hideName
+ });
+ }
+
+ const LIVE_PREVIEW_DEBOUNCE_MS = 1000;
+
+ let appearancePreviewDebounceTimer = null;
+ let appearancePreviewRequestId = 0;
+ let appearancePreviewCache = { fetchKey: null, renderKey: null, items: null, resolvedSection: null };
+
+ function clearAppearancePreviewScheduling() {
+ if (appearancePreviewDebounceTimer) {
+ clearTimeout(appearancePreviewDebounceTimer);
+ appearancePreviewDebounceTimer = null;
+ }
+ appearancePreviewRequestId += 1;
+ }
+
+ async function runAppearancePreview(modalInstance, options = {}) {
+ const state = modalInstance?._hsaeState;
+ if (!state || state.renderMode !== 'Normal') return;
+
+ const root = modalInstance.dialogContent;
+ const container = root?.querySelector('#hsae-appearance-preview-container');
+ if (!container) return;
+
+ const fetchPreview = window.KefinHomeScreen?.fetchSectionPreviewItems;
+ const renderPreview = window.KefinHomeScreen?.renderSectionPreviewInto;
+ const mergeResolved = window.KefinHomeScreen?.mergeDraftWithResolvedPreview;
+ if (typeof fetchPreview !== 'function' || typeof renderPreview !== 'function') {
+ container.innerHTML = 'Preview unavailable.
';
+ return;
+ }
+
+ // Keep live editor UI state in sync; draft for fetch/render comes from collectData only.
+ collectStateFromForm(root, state);
+
+ const dialog = modalInstance.dialogContainer || modalInstance.dialogContent;
+ const originalSection = state.section || {};
+ const getDraftSection = () => buildAppearancePreviewDraft(dialog, originalSection);
+
+ const draft = getDraftSection();
+ if (!draft) {
+ container.innerHTML = 'Preview unavailable.
';
+ return;
+ }
+
+ const fetchKey = getAppearancePreviewFetchKeyFromDraft(draft);
+ const renderKey = getAppearancePreviewRenderKeyFromDraft(draft);
+ const requestId = ++appearancePreviewRequestId;
+ const forceFetch = options.forceFetch === true;
+ const cacheHit = !forceFetch
+ && appearancePreviewCache.fetchKey === fetchKey
+ && appearancePreviewCache.items !== null;
+
+ if (!cacheHit) {
+ container.innerHTML = 'No items found for preview.
';
+ return;
+ }
+
+ const liveDraft = getDraftSection();
+ const previewSection = typeof mergeResolved === 'function'
+ ? mergeResolved(liveDraft, appearancePreviewCache.resolvedSection)
+ : liveDraft;
+ await renderPreview(previewSection, previewItems, container, { getDraftSection });
+ } catch (err) {
+ if (requestId !== appearancePreviewRequestId) return;
+ appearancePreviewCache = { fetchKey: null, renderKey: null, items: null, resolvedSection: null };
+ container.innerHTML = `Preview outdated — refresh the section or enable Live Preview Updates.
';
+ }
+ }
+ }
+
+ const HSAE_KEFIN_DEFAULT_GROUP_KEYS = [
+ 'HOME_SECTION_GROUPS',
+ 'SEASONAL_SECTION_GROUPS',
+ 'DISCOVERY_SECTION_GROUPS'
+ ];
+ const HSAE_SECTION_EXPORT_MODAL_ID = 'hsae-section-export';
+ const HSAE_SECTION_IMPORT_MODAL_ID = 'hsae-section-import-paste';
+ const HSAE_RESTORE_DEFAULTS_MODAL_ID = 'hsae-section-restore-defaults';
+ const HSAE_TROUBLESHOOT_MODAL_ID = 'hsae-section-troubleshoot';
+ const HSAE_SECTION_DIFF_KEYS = [
+ 'name', 'caption', 'description', 'enabled', 'order', 'cardFormat', 'renderMode', 'ttl',
+ 'queries', 'items', 'externalListUrls', 'spotlightConfig', 'hideName', 'userConfigurable',
+ 'useRandomQuery', 'useMultiQueryPicker', 'multiQueryPickerLabel', 'useQueryNamesForSection',
+ 'sortBy', 'sortOrder', 'sortOrderDirection', 'itemLimit', 'minimumItems', 'flattenSeries',
+ 'limitBeforeSort', 'sectionCssClass', 'borderStyle', 'borderColor', 'cardTitleColor',
+ 'itemsLayout', 'useGaplessCards', 'cardTitleVisibility', 'useParentCard', 'cardTitlePosition',
+ 'cardTitleCapitalization', 'cardTitleFontFamily', 'cardTitleFontSize', 'startDate', 'endDate',
+ 'hidden', 'discoveryEnabled', 'type', 'category', 'livePreviewUpdates'
+ ];
+
+ function showHsaeToast(message, duration = '3') {
+ if (window.KefinTweaksToaster?.toast) {
+ window.KefinTweaksToaster.toast(message, duration);
+ }
+ }
+
+ function deepCloneJson(value) {
+ return value == null ? value : JSON.parse(JSON.stringify(value));
+ }
+
+ /** collectData writes these even when canonical omits them (runtime defaults). */
+ const HSAE_IMPLICIT_SECTION_DEFAULTS = {
+ renderMode: 'Normal',
+ useGaplessCards: false,
+ useParentCard: false,
+ discoveryEnabled: false,
+ livePreviewUpdates: false,
+ hideName: false,
+ useRandomQuery: false,
+ useMultiQueryPicker: false,
+ useQueryNamesForSection: false,
+ flattenSeries: false,
+ limitBeforeSort: false,
+ hideCardTitles: false,
+ category: 'none'
+ };
+
+ const HSAE_GROUP_KEY_TO_TYPE = {
+ HOME_SECTION_GROUPS: 'home',
+ SEASONAL_SECTION_GROUPS: 'seasonal',
+ DISCOVERY_SECTION_GROUPS: 'discovery'
+ };
+
+ function getDefaultTtlMs() {
+ const preset = getCachePresets().find((p) => p.key === 'DEFAULT');
+ return preset?.ms ?? 3600000;
+ }
+
+ /** Empty / missing editor values are equivalent for restore diffs ("" vs omitted). */
+ function normalizeForDiff(value) {
+ if (value === undefined || value === null) return null;
+ if (typeof value === 'string' && value.trim() === '') return null;
+ if (Array.isArray(value) && value.length === 0) return null;
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) {
+ return null;
+ }
+ return value;
+ }
+
+ function omitEmptyDeep(value) {
+ if (value === undefined || value === null || value === '') return undefined;
+ if (Array.isArray(value)) {
+ return value.map(omitEmptyDeep).filter((v) => v !== undefined);
+ }
+ if (typeof value === 'object') {
+ const out = {};
+ Object.keys(value).sort().forEach((key) => {
+ const nested = omitEmptyDeep(value[key]);
+ if (nested === undefined) return;
+ if (typeof nested === 'object' && !Array.isArray(nested) && Object.keys(nested).length === 0) {
+ return;
+ }
+ out[key] = nested;
+ });
+ return out;
+ }
+ return value;
+ }
+
+ function stableStringify(value) {
+ if (value === undefined) return 'null';
+ if (value === null || typeof value !== 'object') {
+ try {
+ return JSON.stringify(value);
+ } catch (_) {
+ return String(value);
+ }
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(stableStringify).join(',')}]`;
+ }
+ const keys = Object.keys(value).sort();
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
+ }
+
+ function coerceQueryOptionValue(val) {
+ if (typeof val === 'string' && val.trim() !== '' && /^-?\d+(\.\d+)?$/.test(val.trim())) {
+ const n = Number(val);
+ if (Number.isFinite(n)) return n;
+ }
+ return val;
+ }
+
+ function normalizeQueryForCompare(query) {
+ if (!query || typeof query !== 'object') return null;
+ const clone = deepCloneJson(query) || {};
+ Object.keys(clone).forEach((key) => {
+ if (key.startsWith('_')) delete clone[key];
+ });
+ if (clone.queryOptions && typeof clone.queryOptions === 'object') {
+ Object.keys(clone.queryOptions).forEach((key) => {
+ if (key.startsWith('_')) {
+ delete clone.queryOptions[key];
+ return;
+ }
+ clone.queryOptions[key] = coerceQueryOptionValue(clone.queryOptions[key]);
+ });
+ }
+ return omitEmptyDeep(clone) || {};
+ }
+
+ function normalizeQueriesForCompare(queries) {
+ if (!Array.isArray(queries)) return null;
+ return queries.map(normalizeQueryForCompare);
+ }
+
+ function normalizeItemsForCompare(items) {
+ if (!Array.isArray(items)) return null;
+ return items.map((item) => {
+ if (!item || typeof item !== 'object') return item;
+ const clone = deepCloneJson(item) || {};
+ const name = clone.Name || clone.name;
+ if (name != null) {
+ clone.Name = name;
+ delete clone.name;
+ }
+ ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl', 'imageUrl', 'cardFooter', 'cardUrl']
+ .forEach((key) => {
+ if (typeof clone[key] === 'string' && !clone[key].trim()) delete clone[key];
+ });
+ return omitEmptyDeep(clone) || {};
+ });
+ }
+
+ function normalizeDiffFieldValue(key, value, section) {
+ if (key === 'queries') {
+ const normalized = normalizeQueriesForCompare(Array.isArray(value) ? value : null);
+ if (!normalized || normalized.length === 0) return null;
+ return normalized;
+ }
+ if (key === 'items') {
+ const normalized = normalizeItemsForCompare(Array.isArray(value) ? value : null);
+ if (!normalized || normalized.length === 0) return null;
+ return normalized;
+ }
+
+ let normalized = normalizeForDiff(value);
+ if (Object.prototype.hasOwnProperty.call(HSAE_IMPLICIT_SECTION_DEFAULTS, key)) {
+ const implicit = HSAE_IMPLICIT_SECTION_DEFAULTS[key];
+ if (normalized === null || valuesRawEqual(normalized, implicit)) {
+ return null;
+ }
+ }
+ if (key === 'ttl') {
+ const defaultTtl = getDefaultTtlMs();
+ if (normalized === null || valuesRawEqual(Number(normalized), defaultTtl)) {
+ return null;
+ }
+ }
+ if (key === 'type') {
+ const derived = deriveSectionSurfaceType(section);
+ if (normalized === null || valuesRawEqual(normalized, derived)) {
+ return derived;
+ }
+ }
+ if (key === 'hideName' && (normalized === false || normalized === null)) return null;
+ return normalized;
+ }
+
+ function valuesRawEqual(a, b) {
+ try {
+ return stableStringify(a) === stableStringify(b);
+ } catch (_) {
+ return a === b;
+ }
+ }
+
+ function deriveSectionSurfaceType(section) {
+ if (!section) return 'home';
+ const existing = String(section.type || '').toLowerCase();
+ if (existing === 'home' || existing === 'seasonal' || existing === 'discovery') return existing;
+ if (section.discoveryEnabled === true || section.discoveryType) return 'discovery';
+ if (section.startDate?.trim?.() && section.endDate?.trim?.()) return 'seasonal';
+ if (section.startDate && section.endDate) return 'seasonal';
+ return 'home';
+ }
+
+ function valuesDeepEqual(a, b, key, sectionA, sectionB) {
+ try {
+ const left = key != null
+ ? normalizeDiffFieldValue(key, a, sectionA)
+ : normalizeForDiff(a);
+ const right = key != null
+ ? normalizeDiffFieldValue(key, b, sectionB)
+ : normalizeForDiff(b);
+ return stableStringify(left) === stableStringify(right);
+ } catch (_) {
+ return normalizeForDiff(a) === normalizeForDiff(b);
+ }
+ }
+
+ function getCanonicalDefaultSection(sectionId) {
+ if (!sectionId) return null;
+ const defaults = window.KefinHomeConfig2;
+ if (!defaults) return null;
+ for (const groupKey of HSAE_KEFIN_DEFAULT_GROUP_KEYS) {
+ const groups = defaults[groupKey];
+ if (!Array.isArray(groups)) continue;
+ for (const group of groups) {
+ const sections = group?.sections;
+ if (!Array.isArray(sections)) continue;
+ const found = sections.find((s) => s?.id === sectionId);
+ if (found) {
+ const clone = deepCloneJson(found);
+ const surfaceType = HSAE_GROUP_KEY_TO_TYPE[groupKey];
+ if (clone && surfaceType && !clone.type) clone.type = surfaceType;
+ return clone;
+ }
+ }
+ }
+ return null;
+ }
+
+ function isKefinDefaultSection(section, context) {
+ if (context?.isCustomSection === true) return false;
+ const id = section?.id;
+ if (!id) return false;
+ return getCanonicalDefaultSection(id) != null;
+ }
+
+ function parseSectionImportPayload(text) {
+ if (text == null || String(text).trim() === '') return null;
+ let parsed;
+ try {
+ parsed = JSON.parse(String(text));
+ } catch (_) {
+ return null;
+ }
+ if (Array.isArray(parsed)) {
+ if (parsed.length !== 1) return null;
+ parsed = parsed[0];
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
+ const hasShape = ['name', 'queries', 'items', 'renderMode', 'id', 'cardFormat']
+ .some((key) => Object.prototype.hasOwnProperty.call(parsed, key));
+ if (!hasShape) return null;
+ return parsed;
+ }
+
+ function exportCurrentSectionJson(modalInstance) {
+ const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent;
+ const state = modalInstance?._hsaeState;
+ if (!dialog || !state) return null;
+ const original = state.context?.originalSection || state.section || {};
+ const section = collectData(dialog, original);
+ if (!section) return null;
+ if (state.section?.id) section.id = state.section.id;
+ return JSON.stringify(section, null, 2);
+ }
+
+ function applyImportedSection(modalInstance, importedSection) {
+ const state = modalInstance?._hsaeState;
+ if (!state || !importedSection) return;
+ const current = state.section || {};
+ const context = { ...(state.context || {}) };
+ const merged = deepCloneJson(importedSection) || {};
+ merged.id = current.id || merged.id;
+ if (current.deleted === true) merged.deleted = true;
+ // Keep collect/export/troubleshoot bases in sync with the applied section
+ context.originalSection = deepCloneJson(merged);
+ context.section = merged;
+ const newState = buildEditorStateFromSection(merged, context);
+ modalInstance._hsaeState = newState;
+ const stateHost = modalInstance.dialogContainer || modalInstance.dialogContent;
+ if (stateHost) stateHost._hsaeState = newState;
+ activeEditorState = newState;
+ refreshEditorBody(modalInstance, { skipCollect: true });
+ updateSectionNameBadge(modalInstance);
+ }
+
+ function abbreviateDiffValue(value, { markChanged = false } = {}) {
+ if (value === undefined) return '(none)';
+ if (value === null) return '(none)';
+ if (Array.isArray(value)) {
+ if (value.length === 0) return '[]';
+ const base = `array (${value.length})`;
+ return markChanged ? `${base} · changed` : base;
+ }
+ if (typeof value === 'object') {
+ const keys = Object.keys(value);
+ return keys.length ? `object (${keys.length} keys)` : '{}';
+ }
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
+ const s = String(value);
+ if (!s) return '""';
+ return s.length > 48 ? `${s.slice(0, 45)}…` : s;
+ }
+
+ function diffSectionAgainstDefault(current, canonical) {
+ const diffs = [];
+ if (!current || !canonical) return diffs;
+ for (const key of HSAE_SECTION_DIFF_KEYS) {
+ const hasCurrent = Object.prototype.hasOwnProperty.call(current, key);
+ const hasCanonical = Object.prototype.hasOwnProperty.call(canonical, key);
+ if (!hasCurrent && !hasCanonical) continue;
+ const curVal = hasCurrent ? current[key] : undefined;
+ const defVal = hasCanonical ? canonical[key] : undefined;
+ if (valuesDeepEqual(curVal, defVal, key, current, canonical)) continue;
+
+ const normCur = normalizeDiffFieldValue(key, curVal, current);
+ const normDef = normalizeDiffFieldValue(key, defVal, canonical);
+ const bothArraysSameLen = Array.isArray(normCur) && Array.isArray(normDef)
+ && normCur.length === normDef.length
+ && normCur.length > 0;
+ diffs.push({
+ key,
+ current: abbreviateDiffValue(normCur ?? curVal, { markChanged: bothArraysSameLen }),
+ default: abbreviateDiffValue(normDef ?? defVal, { markChanged: bothArraysSameLen })
+ });
+ }
+ return diffs;
+ }
+
+ function isHttpsContextForClipboard() {
+ try {
+ if (typeof ApiClient !== 'undefined' && ApiClient._serverAddress?.startsWith?.('https://')) return true;
+ } catch (_) { /* ignore */ }
+ return typeof location !== 'undefined' && location.protocol === 'https:';
+ }
+
+ function canUseClipboardWrite() {
+ return !!(isHttpsContextForClipboard()
+ && navigator.clipboard
+ && typeof navigator.clipboard.writeText === 'function');
+ }
+
+ function canUseClipboardRead() {
+ return !!(isHttpsContextForClipboard()
+ && navigator.clipboard
+ && typeof navigator.clipboard.readText === 'function');
+ }
+
+ function showSectionExportModal(json) {
+ if (!window.ModalSystem) {
+ showHsaeToast('Unable to open export modal');
+ return;
+ }
+ if (window.ModalSystem.isOpen?.(HSAE_SECTION_EXPORT_MODAL_ID)) {
+ window.ModalSystem.close(HSAE_SECTION_EXPORT_MODAL_ID);
+ }
+ const canCopy = canUseClipboardWrite();
+ window.ModalSystem.create({
+ id: HSAE_SECTION_EXPORT_MODAL_ID,
+ title: 'Export Section',
+ content: `
+
+ Section JSON is shown below. Select and copy this text to back up or share the section.
+ ${canCopy ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'}
+
+
+ Paste a single section JSON below, then Import.
+
+
+ Restoring defaults will save the KefinTweaks defaults for this section and reload the editor.
+ Enabled status and order are kept.
+
+ ${rowsHtml}
+ `;
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.5em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ No query results.
';
+ }
+ const rows = entries.map((entry) => {
+ const stats = entry.stats || {};
+ const itemCount = stats.itemCount != null ? String(stats.itemCount) : '—';
+ const duration = formatTroubleshootDuration(stats.durationMs);
+ const size = formatTroubleshootSize(stats.responseBytes);
+ return `
+ You can place images in your "Jellyfin\\Server\\jellyfin-web" folder and use the "/web/image_name.png" path to access them.
`
+ : '';
+ return `
+
+ ${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '', opts)).join('')}
+
+ `;
+ }
+
+ function renderCustomItemTileHTML(index, item, canRemove, options, itemCount) {
+ const opts = getOptions(options);
+ const label = item.name?.trim() || `Item ${index + 1}`;
+ const isHidden = item.hidden === true;
+ const canMoveLeft = index > 0;
+ const canMoveRight = index < itemCount - 1;
+ const hideIcon = isHidden ? 'visibility_off' : 'visibility';
+ const hideLabel = isHidden ? `Show ${label}` : `Hide ${label}`;
+ const removeTitle = canRemove
+ ? `Remove ${label}`
+ : (opts.allowRemove === false
+ ? 'Default section items cannot be deleted — hide instead'
+ : `Remove ${label}`);
+
+ return `
+ >}
+ */
+ async function resolveSelectionItemLabels(type, items, options = {}) {
+ if (!Array.isArray(items) || items.length === 0) return items;
+
+ const unresolved = items.filter(selectionItemNeedsLabel);
+ if (unresolved.length === 0) return items;
+
+ const idToName = new Map();
+ const ids = unresolved.map(item => item.id);
+ const includeItemTypes = options.includeItemTypes || [];
+
+ try {
+ switch (type) {
+ case 'Collection':
+ await resolveItemsByGetItems(ids, 'BoxSet,CollectionFolder', idToName);
+ break;
+ case 'Playlist':
+ await resolveItemsByGetItems(ids, 'Playlist', idToName);
+ break;
+ case 'Library':
+ (await fetchLibraries()).forEach(item => idToName.set(item.id, item.name));
+ await resolveItemsByGetItems(ids.filter(id => !idToName.has(id)), null, idToName);
+ break;
+ case 'Custom':
+ await resolveItemsByGetItems(ids, null, idToName);
+ break;
+ case 'Genre': {
+ (await fetchGenres(includeItemTypes.length ? includeItemTypes : null))
+ .forEach(item => idToName.set(item.id, item.name));
+ const remaining = ids.filter(id => !idToName.has(id));
+ if (remaining.length) {
+ await resolveItemsByGetItems(remaining, 'Genre', idToName);
+ }
+ break;
+ }
+ case 'Tag':
+ (await fetchTags(includeItemTypes.length ? includeItemTypes : null))
+ .forEach(item => idToName.set(item.id, item.name));
+ break;
+ case 'Person':
+ await resolveItemsByGetItems(ids, 'Person', idToName);
+ break;
+ case 'Studio': {
+ (await fetchStudios('', includeItemTypes.length ? includeItemTypes : null))
+ .forEach(item => idToName.set(item.id, item.name));
+ const remaining = ids.filter(id => !idToName.has(id));
+ if (remaining.length) {
+ await resolveItemsByGetItems(remaining, 'Studio', idToName);
+ }
+ break;
+ }
+ case 'LibraryItem':
+ await resolveItemsByGetItems(ids, null, idToName);
+ break;
+ default:
+ await resolveItemsByGetItems(ids, null, idToName);
+ break;
+ }
+ } catch (err) {
+ WARN('Error resolving selection labels:', err);
+ }
+
+ return items.map(item => {
+ const resolvedName = idToName.get(item.id);
+ if (resolvedName) {
+ return { ...item, name: resolvedName };
+ }
+ return item;
+ });
+ }
+
+ /**
+ * Render a searchable selection list (shared by wizard Selection step and advanced popovers).
+ */
+ function renderSelectionListHTML({
+ type,
+ items = [],
+ selected = [],
+ search = '',
+ status = 'idle',
+ error = '',
+ actionAttr = 'data-hsse-action',
+ selectAction = 'wizard-select-parent',
+ retryAction = 'wizard-retry-parent-load'
+ } = {}) {
+ const meta = getSelectionTypeMeta(type);
+ const selectedIds = new Set((selected || []).map(s => (typeof s === 'object' ? s.id : s)));
+
+ if (status === 'loading') {
+ return `Loading ${escapeHtml(meta?.plural || 'items')}…
`;
+ }
+ if (status === 'error') {
+ return `
+ ${escapeHtml(error || 'Failed to load items.')}
+ Retry
+ `;
+ }
+ if (items.length === 0) {
+ const emptyMsg = search
+ ? `No ${meta?.plural || 'items'} match “${escapeHtml(search)}”`
+ : `No ${meta?.plural || 'items'} found`;
+ return `${emptyMsg}
`;
+ }
+
+ return items.map(item => {
+ const isSelected = selectedIds.has(item.id);
+ const icon = item.type
+ ? (window.cardBuilder?.getItemMaterialIcon?.({ Type: item.type }) || meta?.icon || 'folder')
+ : (meta?.icon || 'folder');
+ return `
+
+ ${renderMaterialIcon(icon, 'hsse-parent-icon')}
+
+
${escapeHtml(item.name)}
+ ${item.meta ? `
${escapeHtml(item.meta)}
` : ''}
+
+
+ `;
+ }).join('');
+ }
+
+ function getWizardParentListItems(wizardState) {
+ const type = wizardState.parentType;
+ const search = getParentSearch(wizardState, type);
+ if (type === 'Person') {
+ return wizardState.personSearchResults || [];
+ }
+ if (SERVER_SEARCH_PARENT_TYPES.has(type)) {
+ return wizardState.parentItemsByType[type] || [];
+ }
+ return getDisplayParentItems(
+ wizardState.parentItemsByType[type] || [],
+ search
+ );
+ }
+
+ function renderParentListHTML(wizardState) {
+ const items = getWizardParentListItems(wizardState);
+ const search = getParentSearch(wizardState);
+
+ return renderSelectionListHTML({
+ type: wizardState.parentType,
+ items,
+ selected: wizardState.parents || [],
+ search,
+ status: wizardState.parentListStatus,
+ error: wizardState.parentListError,
+ actionAttr: 'data-hsse-action',
+ selectAction: 'wizard-select-parent',
+ retryAction: 'wizard-retry-parent-load'
+ });
+ }
+
+ /**
+ * Open a Selection popover (same UX as wizard Selection step).
+ * @param {Object} options
+ * @param {string} options.type - Collection|Playlist|Library|Custom|Genre|Tag|Person|Studio
+ * @param {boolean} options.multi - multi-select mode
+ * @param {Array<{id:string,name:string}>} options.selected - initially selected items
+ * @param {string[]} [options.includeItemTypes]
+ * @param {Function} options.onConfirm - called with selected items array
+ */
+ function openSelectionPopover(options = {}) {
+ const {
+ type,
+ multi = false,
+ selected = [],
+ includeItemTypes = [],
+ onConfirm
+ } = options;
+
+ const meta = getSelectionTypeMeta(type);
+ const modalId = 'kefin-selection-popover';
+ if (window.ModalSystem?.isOpen?.(modalId)) {
+ window.ModalSystem.close(modalId);
+ }
+
+ let draftSelected = (selected || []).map(s => (typeof s === 'object' ? { ...s } : { id: s, name: s }));
+ let search = '';
+ let itemsCache = [];
+ let personResults = [];
+ let activeItemTypes = [...(includeItemTypes || [])];
+ let listStatus = type === 'Custom' ? 'idle' : 'loading';
+ let listError = '';
+ let searchTimer = null;
+ let loadRequestId = 0;
+
+ const content = document.createElement('div');
+ content.className = 'hsse-selection-popover';
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.5em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Cancel
+ Confirm
+ `;
+
+ function getVisibleItems() {
+ if (type === 'Person') return personResults;
+ if (SERVER_SEARCH_PARENT_TYPES.has(type)) return itemsCache;
+ return getDisplayParentItems(itemsCache, search);
+ }
+
+ function renderBody(options = {}) {
+ const preserveScroll = options.preserveScroll === true;
+ const prevList = preserveScroll ? content.querySelector('#hsse-selection-list') : null;
+ const prevScrollTop = prevList ? prevList.scrollTop : 0;
+ if (type === 'Custom') {
+ const currentGuid = draftSelected[0]?.id || '';
+ content.innerHTML = `
+
+
Parent Item ID
+
Enter the GUID of a library folder or other parent item.
+
+
+ `;
+ return;
+ }
+
+ const filterChips = PARENT_FILTER_CHIP_TYPES.has(type)
+ ? renderParentItemTypeChipsHTML(activeItemTypes, 'data-sel-action', 'toggle-filter-type')
+ : '';
+
+ content.innerHTML = `
+ ${filterChips}
+
+
+
+
+ ${renderSelectionListHTML({
+ type,
+ items: getVisibleItems(),
+ selected: draftSelected,
+ search,
+ status: listStatus,
+ error: listError,
+ actionAttr: 'data-sel-action',
+ selectAction: 'select-item',
+ retryAction: 'retry-load'
+ })}
+
+ `;
+
+ if (preserveScroll) {
+ const nextList = content.querySelector('#hsse-selection-list');
+ if (nextList) nextList.scrollTop = prevScrollTop;
+ }
+ }
+
+ async function loadItems(forceSearch) {
+ if (type === 'Custom') return;
+ const requestId = ++loadRequestId;
+ listStatus = 'loading';
+ listError = '';
+ renderBody();
+
+ try {
+ const fetched = await fetchSelectionItems(type, {
+ search: forceSearch != null ? forceSearch : search,
+ includeItemTypes: PARENT_FILTER_CHIP_TYPES.has(type) ? activeItemTypes : includeItemTypes
+ });
+ if (requestId !== loadRequestId) return;
+ if (type === 'Person') {
+ personResults = fetched;
+ } else {
+ itemsCache = fetched;
+ }
+ listStatus = 'idle';
+ } catch (err) {
+ if (requestId !== loadRequestId) return;
+ listStatus = 'error';
+ listError = err?.message || 'Failed to load items.';
+ }
+ renderBody();
+ }
+
+ function toggleItem(id, name) {
+ const existing = draftSelected.findIndex(p => p.id === id);
+ if (multi) {
+ if (existing >= 0) draftSelected.splice(existing, 1);
+ else draftSelected.push({ id, name });
+ } else {
+ draftSelected = existing >= 0 ? [] : [{ id, name }];
+ }
+ renderBody({ preserveScroll: true });
+ const searchEl = content.querySelector('#hsse-selection-search');
+ if (searchEl) {
+ searchEl.focus();
+ const len = searchEl.value.length;
+ searchEl.setSelectionRange(len, len);
+ }
+ }
+
+ window.ModalSystem.create({
+ id: modalId,
+ title: meta ? `Select ${meta.label}` : 'Select',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ onOpen: (modalInstance) => {
+ const root = modalInstance.dialogContent;
+ const foot = modalInstance.dialogFooter;
+
+ renderBody();
+ if (type !== 'Custom') loadItems();
+
+ root.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-sel-action]');
+ if (!actionEl || !root.contains(actionEl)) return;
+ const action = actionEl.dataset.selAction;
+ if (action === 'select-item') {
+ toggleItem(actionEl.dataset.parentId, actionEl.dataset.parentName);
+ } else if (action === 'retry-load') {
+ loadItems();
+ } else if (action === 'toggle-filter-type') {
+ const chipType = actionEl.dataset.itemType;
+ const idx = activeItemTypes.indexOf(chipType);
+ if (idx >= 0) activeItemTypes.splice(idx, 1);
+ else activeItemTypes.push(chipType);
+ loadItems();
+ }
+ });
+
+ root.addEventListener('input', (e) => {
+ if (e.target.id === 'hsse-selection-search') {
+ search = e.target.value;
+ if (searchTimer) clearTimeout(searchTimer);
+ if (SERVER_SEARCH_PARENT_TYPES.has(type)) {
+ searchTimer = setTimeout(() => loadItems(search), WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS);
+ } else {
+ renderBody();
+ const searchEl = content.querySelector('#hsse-selection-search');
+ if (searchEl) {
+ searchEl.focus();
+ const len = searchEl.value.length;
+ searchEl.setSelectionRange(len, len);
+ }
+ }
+ } else if (e.target.id === 'hsse-selection-guid') {
+ const guid = e.target.value.trim();
+ draftSelected = guid ? [{ id: guid, name: guid }] : [];
+ }
+ });
+
+ foot.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-sel-action]');
+ if (!actionEl) return;
+ if (actionEl.dataset.selAction === 'cancel') {
+ window.ModalSystem.close(modalId);
+ return;
+ }
+ if (actionEl.dataset.selAction === 'confirm') {
+ if (type === 'Custom') {
+ const guidInput = root.querySelector('#hsse-selection-guid');
+ const guid = guidInput?.value?.trim() || '';
+ draftSelected = guid ? [{ id: guid, name: guid }] : [];
+ }
+ if (typeof onConfirm === 'function') {
+ onConfirm(draftSelected.slice());
+ }
+ window.ModalSystem.close(modalId);
+ }
+ });
+ }
+ });
+ }
+
+ /**
+ * Open a library item picker for static query items.
+ * @param {Object} options
+ * @param {Array<{id:string,name:string}>} [options.selected]
+ * @param {Function} options.onConfirm
+ */
+ function openLibraryItemPickerPopover(options = {}) {
+ const { selected = [], onConfirm } = options;
+ const modalId = 'kefin-library-item-picker';
+ const meta = LIBRARY_ITEM_META;
+
+ if (window.ModalSystem?.isOpen?.(modalId)) {
+ window.ModalSystem.close(modalId);
+ }
+
+ let draftSelected = (selected || []).map(s => (typeof s === 'object' ? { ...s } : { id: s, name: s }));
+ let search = '';
+ let activeItemTypes = [];
+ let itemsCache = [];
+ let listStatus = 'loading';
+ let listError = '';
+ let searchTimer = null;
+ let loadRequestId = 0;
+
+ const content = document.createElement('div');
+ content.className = 'hsse-selection-popover hsse-library-item-picker';
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.5em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Cancel
+ Confirm
+ `;
+
+ function renderTypeChipsHTML() {
+ return LIBRARY_ITEM_TYPE_OPTIONS.map(type => `
+ ${escapeHtml(humanizeLabel(type))}
+ `).join('');
+ }
+
+ function renderBody(options = {}) {
+ const preserveScroll = options.preserveScroll === true;
+ const prevList = preserveScroll ? content.querySelector('#hsse-library-item-list') : null;
+ const prevScrollTop = prevList ? prevList.scrollTop : 0;
+
+ content.innerHTML = `
+
+
+
+
+
Filter by item type (optional)
+
+ ${renderTypeChipsHTML()}
+
+
+
+ ${renderSelectionListHTML({
+ type: 'LibraryItem',
+ items: itemsCache,
+ selected: draftSelected,
+ search,
+ status: listStatus,
+ error: listError,
+ actionAttr: 'data-libpick-action',
+ selectAction: 'select-item',
+ retryAction: 'retry-load'
+ })}
+
+ `;
+
+ if (preserveScroll) {
+ const nextList = content.querySelector('#hsse-library-item-list');
+ if (nextList) nextList.scrollTop = prevScrollTop;
+ }
+ }
+
+ async function loadItems() {
+ const requestId = ++loadRequestId;
+ listStatus = 'loading';
+ listError = '';
+ renderBody();
+
+ try {
+ const fetched = await fetchLibrarySearchItems({
+ search,
+ includeItemTypes: activeItemTypes
+ });
+ if (requestId !== loadRequestId) return;
+ itemsCache = fetched;
+ listStatus = 'idle';
+ } catch (err) {
+ if (requestId !== loadRequestId) return;
+ listStatus = 'error';
+ listError = err?.message || 'Failed to load items.';
+ }
+ renderBody();
+ }
+
+ function toggleItem(id, name) {
+ const existing = draftSelected.findIndex(p => p.id === id);
+ if (existing >= 0) draftSelected.splice(existing, 1);
+ else draftSelected.push({ id, name });
+ renderBody({ preserveScroll: true });
+ const searchEl = content.querySelector('#hsse-library-item-search');
+ if (searchEl) {
+ searchEl.focus();
+ const len = searchEl.value.length;
+ searchEl.setSelectionRange(len, len);
+ }
+ }
+
+ function toggleItemType(type) {
+ const idx = activeItemTypes.indexOf(type);
+ if (idx >= 0) activeItemTypes.splice(idx, 1);
+ else activeItemTypes.push(type);
+ loadItems();
+ }
+
+ window.ModalSystem.create({
+ id: modalId,
+ title: 'Select Items',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ onOpen: (modalInstance) => {
+ const root = modalInstance.dialogContent;
+ const foot = modalInstance.dialogFooter;
+
+ renderBody();
+ loadItems();
+
+ root.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-libpick-action]');
+ if (!actionEl || !root.contains(actionEl)) return;
+ const action = actionEl.dataset.libpickAction;
+ if (action === 'select-item') {
+ toggleItem(actionEl.dataset.parentId, actionEl.dataset.parentName);
+ } else if (action === 'toggle-type') {
+ toggleItemType(actionEl.dataset.itemType);
+ } else if (action === 'retry-load') {
+ loadItems();
+ }
+ });
+
+ root.addEventListener('input', (e) => {
+ if (e.target.id !== 'hsse-library-item-search') return;
+ search = e.target.value;
+ if (searchTimer) clearTimeout(searchTimer);
+ searchTimer = setTimeout(() => loadItems(), WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS);
+ });
+
+ foot.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-libpick-action]');
+ if (!actionEl) return;
+ if (actionEl.dataset.libpickAction === 'cancel') {
+ window.ModalSystem.close(modalId);
+ return;
+ }
+ if (actionEl.dataset.libpickAction === 'confirm') {
+ if (typeof onConfirm === 'function') {
+ onConfirm(draftSelected.slice());
+ }
+ window.ModalSystem.close(modalId);
+ }
+ });
+ }
+ });
+ }
+
+ function renderAppearancePreviewHTML() {
+ return `
+
+ `;
+ }
+
+ function getAppearancePreviewQueryKey(wizardState) {
+ if (isCustomType(wizardState)) {
+ return JSON.stringify({
+ parentType: 'Custom',
+ customItems: (wizardState.customItems || []).map(item => ({
+ name: item.name,
+ cardFooter: item.cardFooter,
+ cardUrl: item.cardUrl,
+ posterUrl: item.posterUrl,
+ thumbUrl: item.thumbUrl,
+ backdropUrl: item.backdropUrl,
+ squareUrl: item.squareUrl,
+ bannerUrl: item.bannerUrl,
+ logoUrl: item.logoUrl
+ }))
+ });
+ }
+ if (isExternalType(wizardState)) {
+ return JSON.stringify({
+ parentType: 'External',
+ externalListUrls: wizardState.externalListUrls || []
+ });
+ }
+ const parents = wizardState.parents || [];
+ return JSON.stringify({
+ parentType: wizardState.parentType,
+ parentIds: parents.map(p => p.id).sort(),
+ parentNames: parents.map(p => p.name).sort(),
+ includeItemTypes: [...(wizardState.includeItemTypes || [])].sort(),
+ sortBy: wizardState.sortBy,
+ sortOrder: wizardState.sortOrder,
+ unwatchedOnly: wizardState.unwatchedOnly === true
+ });
+ }
+
+ const ITEM_TYPE_OPTIONS = ['Movie', 'Series', 'Episode', 'Season', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book'];
+
+ function getIncludedItemTypesHintText(includeItemTypes) {
+ if (!includeItemTypes || includeItemTypes.length === 0) {
+ return 'None selected - all item types will be included.';
+ }
+ const selected = ITEM_TYPE_OPTIONS.filter(type => includeItemTypes.includes(type));
+ return `This section will display items with the following types: ${formatItemTypesLabel(selected)}`;
+ }
+
+ function updateIncludedItemTypesHint(root, includeItemTypes) {
+ const hint = root?.querySelector('#hsse-item-types-hint');
+ if (hint) hint.textContent = getIncludedItemTypesHintText(includeItemTypes);
+ }
+
+ const FALLBACK_SORT_ORDERS = ['Default', 'Random', 'SortName', 'DateCreated', 'CommunityRating'];
+ const FALLBACK_SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending'];
+ const FALLBACK_CARD_FORMATS = ['Poster', 'Thumb', 'Series Thumb', 'Series Poster', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc'];
+ const FALLBACK_SPOTLIGHT_LAYOUT_OPTIONS = [{ value: 'Border', label: 'Border' }, { value: 'Borderless', label: 'Borderless' }];
+ const FALLBACK_SPOTLIGHT_SIZE_OPTIONS = [{ value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }, { value: 'full', label: 'Full' }];
+ const FALLBACK_SPOTLIGHT_TILE_COUNT_OPTIONS = [{ value: '1', label: '1' }, { value: '2', label: '2' }, { value: '3', label: '3' }];
+
+ function getEditorConstants() {
+ return window.KefinHomeScreenEditorConstants || {};
+ }
+
+ function getSortOrders() {
+ return getEditorConstants().SORT_ORDERS || FALLBACK_SORT_ORDERS;
+ }
+
+ function getSortOrderDirections() {
+ return getEditorConstants().SORT_ORDER_DIRECTIONS || FALLBACK_SORT_ORDER_DIRECTIONS;
+ }
+
+ function getCardFormats() {
+ return getEditorConstants().CARD_FORMATS || FALLBACK_CARD_FORMATS;
+ }
+
+ function getSpotlightLayoutOptions() {
+ return getEditorConstants().SPOTLIGHT_LAYOUT_OPTIONS || FALLBACK_SPOTLIGHT_LAYOUT_OPTIONS;
+ }
+
+ function getSpotlightSizeOptions() {
+ return getEditorConstants().SPOTLIGHT_SIZE_OPTIONS || FALLBACK_SPOTLIGHT_SIZE_OPTIONS;
+ }
+
+ function getSpotlightTileCountOptions() {
+ return getEditorConstants().SPOTLIGHT_TILE_COUNT_OPTIONS || FALLBACK_SPOTLIGHT_TILE_COUNT_OPTIONS;
+ }
+
+ function applyRandomAppearanceOptions(state) {
+ const formats = getCardFormats().filter(f => f !== 'Random');
+ state.cardFormat = pickRandom(formats.length ? formats : ['Poster']);
+ const layouts = getSpotlightLayoutOptions();
+ const sizes = getSpotlightSizeOptions();
+ state.spotlightLayout = pickRandom(layouts).value;
+ state.spotlightSize = pickRandom(sizes).value;
+ const maxTiles = state.spotlightSize === 'full' ? 1
+ : state.spotlightSize === 'large' ? 2 : 3;
+ state.tileCount = String(1 + Math.floor(Math.random() * maxTiles));
+ state.panAnimation = Math.random() < 0.5;
+ }
+
+ function escapeHtml(str) {
+ return String(str ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function humanizeLabel(value) {
+ if (!value || typeof value !== 'string') return value;
+ return value
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
+ }
+
+ function formatItemTypesLabel(types) {
+ return (types || []).map(humanizeLabel).join(', ');
+ }
+
+ function renderMaterialIcon(name, className = '') {
+ return `${escapeHtml(name)} `;
+ }
+
+ function scrollSelectedParentIntoView(root) {
+ root?.querySelector('.hsse-parent-item.hsse-active')
+ ?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
+ }
+
+ function buildFieldLabel(label, labelTooltip) {
+ if (labelTooltip) {
+ return `
+ ${escapeHtml(label)}
+ info
+
`;
+ }
+ return `${escapeHtml(label)}
`;
+ }
+
+ function hsseBuildSelect(id, options, selectedValue, label, labelTooltip) {
+ const optionsHTML = options.map(opt => {
+ const value = typeof opt === 'string' ? opt : opt.value;
+ const optLabel = typeof opt === 'string' ? humanizeLabel(opt) : opt.label;
+ const selected = value === selectedValue ? 'selected' : '';
+ return `${escapeHtml(optLabel)} `;
+ }).join('');
+
+ return `
+
+ ${buildFieldLabel(label, labelTooltip)}
+
+ ${optionsHTML}
+
+
+ `;
+ }
+
+ function hsseBuildTextInput(id, value, label, type = 'text', placeholder = '') {
+ return `
+
+ ${buildFieldLabel(label)}
+
+
+ `;
+ }
+
+ function getExistingGroupNames(config) {
+ const groups = config?.CUSTOM_SECTION_GROUPS || [];
+ return [...new Set(groups.map(g => g.name).filter(Boolean))];
+ }
+
+ function getDefaultGroupSelect(config) {
+ const existing = getExistingGroupNames(config);
+ if (existing.includes('Custom Sections')) return 'Custom Sections';
+ return existing.length > 0 ? existing[0] : 'New...';
+ }
+
+ function resolveGroupName(wizardState, config) {
+ const select = wizardState.sectionGroupSelect || getDefaultGroupSelect(config);
+ if (select === 'New...') {
+ return (wizardState.sectionGroupNew || '').trim() || 'Custom Sections';
+ }
+ return select;
+ }
+
+ function buildSectionGroupHTML(config, wizardState) {
+ const existing = getExistingGroupNames(config);
+ const options = ['New...', ...existing];
+ const selected = wizardState.sectionGroupSelect || getDefaultGroupSelect(config);
+ const showNew = selected === 'New...';
+
+ return `
+
+ ${hsseBuildSelect('hsse-wizard-sectionGroup', options, selected, 'Section Group')}
+
+ ${buildFieldLabel('New Group Name')}
+
+
+
+ `;
+ }
+
+ function getSectionVisibilityLabel(value) {
+ return SECTION_VISIBILITY_OPTIONS.find(o => o.value === value)?.title || value || 'Normal';
+ }
+
+ function hasValidSeasonalDates(wizardState) {
+ return !!(wizardState.startDate?.trim() && wizardState.endDate?.trim());
+ }
+
+ function applySectionVisibilityToSection(section, wizardState, baseSection) {
+ const visibility = wizardState.sectionVisibility || 'normal';
+ const { order, startDate, endDate } = wizardState;
+
+ if (visibility === 'discovery') {
+ section.discoveryEnabled = true;
+ section.type = 'discovery';
+ delete section.startDate;
+ delete section.endDate;
+ section.order = baseSection.order || 100;
+ } else if (visibility === 'seasonal') {
+ section.discoveryEnabled = false;
+ section.type = 'seasonal';
+ section.startDate = (startDate || '').trim();
+ section.endDate = (endDate || '').trim();
+ section.order = order ? parseInt(order, 10) : (baseSection.order || 100);
+ } else {
+ section.discoveryEnabled = false;
+ section.type = 'home';
+ delete section.startDate;
+ delete section.endDate;
+ section.order = order ? parseInt(order, 10) : (baseSection.order || 100);
+ }
+ }
+
+ function renderSectionVisibilityHTML(wizardState) {
+ const visibility = wizardState.sectionVisibility || 'normal';
+ const showSeasonalDates = visibility === 'seasonal';
+ const showOrder = visibility !== 'discovery';
+
+ return `
+
+
Section Type
+
+ ${SECTION_VISIBILITY_OPTIONS.map(opt => `
+
+ ${escapeHtml(opt.title)}
+ ${escapeHtml(opt.desc)}
+
+ `).join('')}
+
+
+ ${showSeasonalDates ? `
+
+
+ ${hsseBuildTextInput('hsse-wizard-startDate', wizardState.startDate, 'Start Date (MM-DD)')}
+ ${hsseBuildTextInput('hsse-wizard-endDate', wizardState.endDate, 'End Date (MM-DD)')}
+
+
+ ` : ''}
+ ${showOrder ? `
+
+ ${hsseBuildTextInput('hsse-wizard-order', wizardState.order, 'Order', 'number', 'Auto')}
+
+ ` : ''}
+ `;
+ }
+
+ function collectExternalListFromForm(root, wizardState) {
+ const inputs = root.querySelectorAll('[id^="hsse-external-list-url-"]');
+ wizardState.externalListUrls = Array.from(inputs).map((el) => (el.value || '').trim()).filter(Boolean);
+ if (!wizardState.externalListUrls.length) wizardState.externalListUrls = [''];
+ }
+
+ function collectSettingsFromForm(root, wizardState) {
+ const nameEl = root.querySelector('#hsse-wizard-sectionName');
+ const groupSelect = root.querySelector('#hsse-wizard-sectionGroup');
+ const groupNew = root.querySelector('#hsse-wizard-sectionGroup-new');
+ const orderEl = root.querySelector('#hsse-wizard-order');
+ const startDateEl = root.querySelector('#hsse-wizard-startDate');
+ const endDateEl = root.querySelector('#hsse-wizard-endDate');
+ if (nameEl) wizardState.sectionName = nameEl.value;
+ if (groupSelect) wizardState.sectionGroupSelect = groupSelect.value;
+ if (groupNew) wizardState.sectionGroupNew = groupNew.value;
+ if (startDateEl) wizardState.startDate = startDateEl.value;
+ if (endDateEl) wizardState.endDate = endDateEl.value;
+ if (orderEl && wizardState.sectionVisibility !== 'discovery') wizardState.order = orderEl.value;
+ }
+
+ function collectQueryStepFromForm(root, wizardState) {
+ const sortByEl = root.querySelector('#hsse-wizard-sortBy');
+ const sortOrderEl = root.querySelector('#hsse-wizard-sortOrder');
+ const unwatchedEl = root.querySelector('#hsse-wizard-unwatched');
+ if (sortByEl) wizardState.sortBy = sortByEl.value;
+ if (sortOrderEl) wizardState.sortOrder = sortOrderEl.value;
+ if (unwatchedEl) wizardState.unwatchedOnly = unwatchedEl.checked;
+ }
+
+ function collectAppearanceStepFromForm(root, wizardState) {
+ if (wizardState.renderMode !== 'Spotlight') return;
+ const layoutEl = root.querySelector('#hsse-wizard-spotlightLayout');
+ const sizeEl = root.querySelector('#hsse-wizard-spotlightSize');
+ const tileEl = root.querySelector('#hsse-wizard-tileCount');
+ const animatedEl = root.querySelector('#hsse-wizard-animated');
+ if (layoutEl) wizardState.spotlightLayout = layoutEl.value;
+ if (sizeEl) wizardState.spotlightSize = sizeEl.value;
+ if (tileEl) wizardState.tileCount = tileEl.value;
+ if (animatedEl) wizardState.panAnimation = animatedEl.checked;
+ }
+
+ function renderConfirmOverview(wizardState, config) {
+ const isCustom = isCustomType(wizardState);
+ const isExternal = isExternalType(wizardState);
+ const meta = isCustom ? CUSTOM_TYPE
+ : (isExternal ? EXTERNAL_TYPE : PARENT_TYPES.find(t => t.type === wizardState.parentType));
+ const parents = wizardState.parents || [];
+ const selectionLabel = isCustom
+ ? ((wizardState.customItems || []).map(i => i.name?.trim()).filter(Boolean).join(', ') || '—')
+ : (isExternal
+ ? ((wizardState.externalListUrls || []).filter(Boolean).join(', ') || '—')
+ : (parents.length ? parents.map(p => p.name).join(', ') : '—'));
+ const rows = [
+ ['Section Name', wizardState.sectionName || '—'],
+ ['Section Group', resolveGroupName(wizardState, config)],
+ ['Based On', meta?.label || wizardState.parentType || '—'],
+ [isCustom ? 'Items' : (isExternal ? 'List URLs' : 'Selection'), selectionLabel]
+ ];
+
+ if (!isCustom && !isExternal) {
+ rows.push(
+ ['Item Types', wizardState.includeItemTypes.length ? formatItemTypesLabel(wizardState.includeItemTypes) : 'All types'],
+ ['Sort By', wizardState.sortBy === 'Default' ? 'Default' : `${humanizeLabel(wizardState.sortBy)} (${wizardState.sortOrder})`],
+ ['Hide Watched Items', wizardState.unwatchedOnly ? 'Yes' : 'No']
+ );
+ }
+
+ rows.push(['Render Mode', wizardState.renderMode || 'Normal']);
+
+ if (wizardState.renderMode === 'Normal') {
+ rows.push(['Card Format', wizardState.cardFormat || 'Poster']);
+ }
+
+ if (wizardState.renderMode === 'Spotlight') {
+ const layoutLabel = getSpotlightLayoutOptions().find(o => o.value === wizardState.spotlightLayout)?.label || wizardState.spotlightLayout;
+ const sizeLabel = getSpotlightSizeOptions().find(o => o.value === wizardState.spotlightSize)?.label || wizardState.spotlightSize;
+ rows.push(['Spotlight Layout', layoutLabel || '—']);
+ rows.push(['Spotlight Size', sizeLabel || '—']);
+ rows.push(['Tiled Backdrops', wizardState.tileCount || '1']);
+ rows.push(['Animated Transitions', wizardState.panAnimation ? 'Yes' : 'No']);
+ }
+
+ if (wizardState.renderMode === 'Random') {
+ const layoutLabel = getSpotlightLayoutOptions().find(o => o.value === wizardState.spotlightLayout)?.label || wizardState.spotlightLayout;
+ const sizeLabel = getSpotlightSizeOptions().find(o => o.value === wizardState.spotlightSize)?.label || wizardState.spotlightSize;
+ rows.push(['Card Format (random)', wizardState.cardFormat || 'Poster']);
+ rows.push(['Spotlight (random)', `${layoutLabel || '—'}, ${sizeLabel || '—'}, ${wizardState.tileCount || '1'} tiles, ${wizardState.panAnimation ? 'animated' : 'static'}`]);
+ }
+
+ const visibility = wizardState.sectionVisibility || 'normal';
+ rows.push(['Section Type', getSectionVisibilityLabel(visibility)]);
+ if (visibility === 'seasonal') {
+ rows.push(['Start Date', wizardState.startDate?.trim() || '—']);
+ rows.push(['End Date', wizardState.endDate?.trim() || '—']);
+ }
+ if (visibility !== 'discovery') {
+ rows.push(['Order', wizardState.order ? wizardState.order : 'Auto']);
+ }
+
+ return `
+
+
+ ${rows.map(([label, value]) => `
+
+
${escapeHtml(label)}
+ ${escapeHtml(String(value))}
+
+ `).join('')}
+
+ `;
+ }
+
+ function buildSectionName(parentType, items) {
+ const list = Array.isArray(items) ? items : (items ? [items] : []);
+ if (list.length === 0) return '';
+ const names = list.map(i => i.name).join(' & ');
+ switch (parentType) {
+ case 'Genre': return `${names} Picks`;
+ case 'Collection':
+ case 'Playlist': return list[0].name;
+ case 'Studio': return `More from ${names}`;
+ case 'Person': return `Starring ${names}`;
+ case 'Tag': return `${names} Collection`;
+ default: return names;
+ }
+ }
+
+ /**
+ * Map wizard state to a HomeScreenSection-shaped draft object.
+ */
+ function wizardStateToSection(wizardState, baseSection) {
+ const {
+ parentType,
+ parents = [],
+ customItems = [],
+ includeItemTypes,
+ sortBy,
+ sortOrder,
+ unwatchedOnly,
+ renderMode,
+ cardFormat,
+ spotlightLayout,
+ spotlightSize,
+ tileCount,
+ panAnimation,
+ sectionName,
+ order
+ } = wizardState;
+
+ if (parentType === 'Custom') {
+ const items = customItems.map(buildStaticItemFromDraft).filter(Boolean);
+ const section = {
+ ...baseSection,
+ id: baseSection.id,
+ name: sectionName || 'Custom Section',
+ enabled: true,
+ isCustom: true,
+ renderMode: renderMode || 'Normal',
+ cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'),
+ items,
+ queries: []
+ };
+
+ applySectionVisibilityToSection(section, wizardState, baseSection);
+
+ const targetGroup = resolveGroupName(wizardState, wizardState._config);
+ if (targetGroup) {
+ section._targetGroupName = targetGroup;
+ }
+
+ if (renderMode === 'Spotlight' || renderMode === 'Random') {
+ section.spotlightConfig = {
+ spotlightLayout: spotlightLayout || 'Border',
+ spotlightSize: spotlightSize || 'normal',
+ tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)),
+ panAnimation: panAnimation !== false
+ };
+ }
+
+ return section;
+ }
+
+ if (parentType === 'External') {
+ const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean);
+ const section = {
+ ...baseSection,
+ id: baseSection.id,
+ name: sectionName || 'External List',
+ enabled: true,
+ isCustom: true,
+ renderMode: renderMode || 'Normal',
+ cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'),
+ externalListUrls: urls,
+ queries: []
+ };
+
+ applySectionVisibilityToSection(section, wizardState, baseSection);
+
+ const targetGroup = resolveGroupName(wizardState, wizardState._config);
+ if (targetGroup) {
+ section._targetGroupName = targetGroup;
+ }
+
+ if (renderMode === 'Spotlight' || renderMode === 'Random') {
+ section.spotlightConfig = {
+ spotlightLayout: spotlightLayout || 'Border',
+ spotlightSize: spotlightSize || 'normal',
+ tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)),
+ panAnimation: panAnimation !== false
+ };
+ }
+
+ return section;
+ }
+
+ const queryOptions = {
+ Limit: DEFAULT_SECTION_QUERY_LIMIT
+ };
+
+ if (includeItemTypes && includeItemTypes.length > 0) {
+ queryOptions.IncludeItemTypes = includeItemTypes;
+ }
+
+ if (sortBy && sortBy !== 'Default') {
+ queryOptions.SortBy = sortBy;
+ queryOptions.SortOrder = sortOrder || 'Descending';
+ }
+
+ if (unwatchedOnly) {
+ queryOptions.IsUnplayed = true;
+ }
+
+ if (parentType && parents.length > 0) {
+ const meta = PARENT_TYPES.find(t => t.type === parentType);
+ const field = meta?.queryField;
+ switch (field) {
+ case 'GenreIds':
+ queryOptions.GenreIds = parents.map(p => p.id);
+ break;
+ case 'StudioIds':
+ queryOptions.StudioIds = parents.map(p => p.id);
+ break;
+ case 'PersonIds':
+ queryOptions.PersonIds = parents.map(p => p.id);
+ break;
+ case 'Tags':
+ queryOptions.Tags = parents.map(p => p.name);
+ break;
+ case 'ParentId':
+ queryOptions.ParentId = parents[0].id;
+ break;
+ default:
+ break;
+ }
+ }
+
+ const section = {
+ ...baseSection,
+ id: baseSection.id,
+ name: sectionName || (parents.length ? buildSectionName(parentType, parents) : baseSection.name),
+ enabled: true,
+ isCustom: true,
+ renderMode: renderMode || 'Normal',
+ cardFormat: renderMode === 'Spotlight' ? undefined : (cardFormat || 'Poster'),
+ queries: [{ queryOptions }]
+ };
+
+ applySectionVisibilityToSection(section, wizardState, baseSection);
+
+ if (section.type === 'discovery' && parentType && parentType !== 'Custom') {
+ section.discoveryType = parentType;
+ }
+
+ const targetGroup = resolveGroupName(wizardState, wizardState._config);
+ if (targetGroup) {
+ section._targetGroupName = targetGroup;
+ }
+
+ if (renderMode === 'Spotlight' || renderMode === 'Random') {
+ section.spotlightConfig = {
+ spotlightLayout: spotlightLayout || 'Border',
+ spotlightSize: spotlightSize || 'normal',
+ tileCount: Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)),
+ panAnimation: panAnimation !== false
+ };
+ }
+
+ return section;
+ }
+
+ function renderModeSelectHTML() {
+ return `
+
+
Choose how you'd like to get started
+
+
+
+
+ Wizard
+ Build a section from a pre-existing template with the ability to customize the general appearance and settings.
+
+ Streamlined
+ Limited Options
+
+ Start Wizard →
+
+
+
+ Advanced
+ Use the advanced editor to build a section from scratch with complete control over queries, filters, display and cache options.
+
+ Multi-Query
+ Advanced Options
+
+ Open Editor →
+
+
+
+
+ `;
+ }
+
+ function renderSingleWizardStepHTML(step, i, wizardState) {
+ const currentStep = wizardState.step;
+ const maxReachedStepIndex = wizardState.maxReachedStepIndex;
+ const currentIndex = getStepIndex(currentStep, wizardState);
+ const isActive = step === currentStep;
+ const isDone = i < currentIndex;
+ const isClickable = i <= maxReachedStepIndex && !isActive;
+ const classes = [
+ 'hsse-step',
+ isActive ? 'hsse-active' : '',
+ isDone ? 'hsse-done' : '',
+ isClickable ? 'hsse-clickable' : ''
+ ].filter(Boolean).join(' ');
+
+ const inner = `
+ ${isDone ? '✓' : i + 1}
+ ${WIZARD_STEP_LABELS[step]}
+ `;
+
+ if (isClickable) {
+ return `${inner} `;
+ }
+ return `${inner}
`;
+ }
+
+ function renderWizardStepsHTML(wizardState) {
+ const steps = getWizardSteps(wizardState);
+ const parts = [];
+ steps.forEach((step, i) => {
+ if (i > 0) {
+ parts.push('> ');
+ }
+ parts.push(renderSingleWizardStepHTML(step, i, wizardState));
+ });
+ return parts.join('');
+ }
+
+ function buildCustomImageFieldHTML(index, field, value, options = {}) {
+ const { isPoster = false } = options;
+ const requiredMark = field.required ? ' * ' : '';
+ const posterHint = isPoster
+ ? `You can place images in your "Jellyfin\\Server\\jellyfin-web" folder and use the "/web/image_name.png" path to access them.
`
+ : '';
+ return `
+
+ ${escapeHtml(field.label)}${requiredMark}
+
+ ${posterHint}
+
+ `;
+ }
+
+ function renderCustomItemEditorHTML(index, item, wizardState) {
+ const additionalExpanded = isCustomItemAdditionalExpanded(wizardState, index, item);
+ const posterInputId = `hsse-custom-${index}-posterUrl`;
+
+ return `
+
+
+
+ Name *
+
+
+
+ Card Footer
+
+
+
+ Link URL
+
+
+ ${buildCustomImageFieldHTML(index, CUSTOM_POSTER_FIELD, item.posterUrl || '', { isPoster: true })}
+
+
+
+
+
+
+
+
+
+ ${additionalExpanded ? 'Hide Additional Image Types' : 'Configure Additional Image Types'}
+
+
+
+ ${CUSTOM_ADDITIONAL_IMAGE_FIELDS.map(field => buildCustomImageFieldHTML(index, field, item[field.key] || '')).join('')}
+
+ `;
+ }
+
+ function renderCustomItemTileHTML(index, item, canRemove) {
+ const label = item.name?.trim() || `Item ${index + 1}`;
+ return `
+
+
+
+ ${renderMaterialIcon('edit')}
+
+
${escapeHtml(label)}
+
+ ${renderMaterialIcon('delete', 'hsse-custom-item-trash-icon')}
+
+
+ `;
+ }
+
+ function renderCustomItemEditorPanelHTML(wizardState) {
+ const editorIndex = wizardState.customItemEditorIndex;
+ if (editorIndex == null || Number.isNaN(editorIndex)) return '';
+ const item = wizardState.customItems?.[editorIndex];
+ if (!item) return '';
+
+ const title = item.name?.trim()
+ ? `Edit: ${item.name.trim()}`
+ : `Edit Item ${editorIndex + 1}`;
+
+ return `
+
+
+
+
+
+ ${renderCustomItemEditorHTML(editorIndex, item, wizardState)}
+
+
+
+
+ `;
+ }
+
+ function renderCustomItemsHTML(wizardState) {
+ const shared = window.KefinHomeScreenCustomItemsEditor;
+ if (shared) {
+ return shared.renderCustomItemsHTML(wizardState, getWizardCustomItemsOptions());
+ }
+ ensureCustomItemsInitialized(wizardState);
+ const items = wizardState.customItems;
+ const canRemove = items.length > 1;
+
+ return `
+
+
+
Custom Items
+
Name and poster required. Link and other images optional.
+
+
+ ${items.map((item, index) => renderCustomItemTileHTML(index, item, canRemove)).join('')}
+
+ ${renderMaterialIcon('add', 'hsse-custom-item-add-icon')}
+
+
+ ${renderCustomItemEditorPanelHTML(wizardState)}
+
+ `;
+ }
+
+ function renderExternalListHTML(wizardState) {
+ const urls = Array.isArray(wizardState.externalListUrls) && wizardState.externalListUrls.length
+ ? wizardState.externalListUrls
+ : [''];
+ const apiKey = window.LibraryCacheUtils?.getLibraryCacheSettings?.()?.mdblistApiKey
+ || window.KefinHomeConfig2?.LIBRARY_CACHE?.mdblistApiKey
+ || '';
+ const warn = !String(apiKey || '').trim()
+ ? `MDBList API key is not configured. Add it under Home Screen → General settings before using External Lists.
`
+ : '';
+ return `
+
+
External List URLs
+
Paste one or more MDBList URLs. Matched against your library by IMDb/TMDB ids.
+ ${warn}
+ ${urls.map((url, index) => hsseBuildTextInput(`hsse-external-list-url-${index}`, url || '', `List URL ${index + 1}`, 'url', 'https://mdblist.com/...')).join('')}
+
Add URL
+
+ `;
+ }
+
+ function renderWizardBodyHTML(wizardState, config) {
+ const step = wizardState.step;
+
+ if (step === 'type') {
+ return `
+
+ ${PARENT_TYPES.map(t => `
+
+ ${renderMaterialIcon(t.icon, 'hsse-type-icon')}
+
+
${escapeHtml(t.label)}
+
${escapeHtml(t.description)}
+
+
+ `).join('')}
+
+ ${renderMaterialIcon(CUSTOM_TYPE.icon, 'hsse-type-icon')}
+
+
${escapeHtml(CUSTOM_TYPE.label)}
+
${escapeHtml(CUSTOM_TYPE.description)}
+
+
+
+ `;
+ }
+
+ if (step === 'customItems') {
+ return renderCustomItemsHTML(wizardState);
+ }
+
+ if (step === 'externalList') {
+ return renderExternalListHTML(wizardState);
+ }
+
+ if (step === 'parent') {
+ const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType);
+ const parentSearch = getParentSearch(wizardState);
+ const filterChips = PARENT_FILTER_CHIP_TYPES.has(wizardState.parentType)
+ ? renderParentItemTypeChipsHTML(
+ getParentFilterItemTypes(wizardState),
+ 'data-hsse-action',
+ 'wizard-toggle-parent-filter-type'
+ )
+ : '';
+
+ return `
+ ${filterChips}
+
+
+
+
+ ${renderParentListHTML(wizardState)}
+
+ `;
+ }
+
+ if (step === 'query') {
+ return `
+
+
Included Item Types
+
${escapeHtml(getIncludedItemTypesHintText(wizardState.includeItemTypes))}
+
+ ${ITEM_TYPE_OPTIONS.map(type => {
+ const active = wizardState.includeItemTypes.includes(type);
+ return `${escapeHtml(humanizeLabel(type))} `;
+ }).join('')}
+
+
+
+
+ ${hsseBuildSelect('hsse-wizard-sortBy', getSortOrders(), wizardState.sortBy, 'Sort by')}
+ ${hsseBuildSelect('hsse-wizard-sortOrder', getSortOrderDirections(), wizardState.sortOrder, 'Sort order')}
+
+
+ ${buildToggleCard('hsse-wizard-unwatched', wizardState.unwatchedOnly === true, 'Hide Watched Items', '')}
+ `;
+ }
+
+ if (step === 'appearance') {
+ const modes = [
+ { value: 'Normal', title: 'Normal', desc: 'Matches the default Jellyfin horizontal row of cards.' },
+ { value: 'Spotlight', title: 'Spotlight', desc: 'A customizable, full-width, featured slideshow carousel.' },
+ { value: 'Random', title: 'Random', desc: 'A random layout will be used when the section is loaded.' }
+ ];
+ return `
+
+
Appearance
+
+ ${modes.map(m => `
+
+ ${escapeHtml(m.title)}
+ ${escapeHtml(m.desc)}
+
+ `).join('')}
+
+
+ ${wizardState.renderMode === 'Normal' ? `
+
+
Card format
+
+ ${getCardFormats().map(fmt => `
+ ${escapeHtml(fmt)}
+ `).join('')}
+
+
+ ` : ''}
+ ${wizardState.renderMode === 'Spotlight' ? `
+
+
+ ${hsseBuildSelect('hsse-wizard-spotlightLayout', getSpotlightLayoutOptions(), wizardState.spotlightLayout, 'Spotlight Layout')}
+ ${hsseBuildSelect('hsse-wizard-spotlightSize', getSpotlightSizeOptions(), wizardState.spotlightSize, 'Spotlight Size')}
+ ${hsseBuildSelect('hsse-wizard-tileCount', getSpotlightTileCountOptions(), wizardState.tileCount, 'Tiled Backdrop Count', 'Set the number of backdrops that will be tiled next to each other in a single slide of the Spotlight.')}
+
+
+ ${buildToggleCard('hsse-wizard-animated', wizardState.panAnimation !== false, 'Animated Slides', '')}
+ ` : ''}
+ ${renderAppearancePreviewHTML()}
+ `;
+ }
+
+ if (step === 'settings') {
+ return `
+ ${hsseBuildTextInput('hsse-wizard-sectionName', wizardState.sectionName, 'Section Name')}
+ ${buildSectionGroupHTML(config, wizardState)}
+ ${renderSectionVisibilityHTML(wizardState)}
+ `;
+ }
+
+ if (step === 'confirm') {
+ return renderConfirmOverview(wizardState, config);
+ }
+
+ return '';
+ }
+
+ function renderWizardHTML(wizardState, config) {
+ return `
+
+
${renderWizardStepsHTML(wizardState)}
+
${renderWizardBodyHTML(wizardState, config)}
+
+ `;
+ }
+
+ function getModalTitle(stage, wizardState) {
+ if (stage === 'mode' || stage === 'advanced') return 'Create a Section';
+ if (stage === 'wizard') {
+ if (wizardState.step === 'confirm') return 'Review Section';
+ if (wizardState.step === 'parent' && wizardState.parentType) {
+ const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType);
+ return `Choose a ${meta?.label?.toLowerCase() || 'item'}`;
+ }
+ if (wizardState.step === 'customItems') return 'Create Items';
+ if (wizardState.step === 'externalList') return 'External List';
+ return 'Create a Home Screen Section';
+ }
+ return 'Create a Section';
+ }
+
+ function getFooterHintText(wizardState) {
+ if (wizardState.step === 'type') {
+ return 'Select a type to continue';
+ }
+ if (wizardState.step === 'parent' && !isMultiSelectType(wizardState.parentType)) {
+ const meta = PARENT_TYPES.find(t => t.type === wizardState.parentType);
+ const label = (meta?.label || 'item').toLowerCase();
+ return `Select a ${label} to continue`;
+ }
+ return '';
+ }
+
+ function buildWizardFooterHTML(wizardState) {
+ const step = wizardState.step;
+ const parentUsesContinue = step === 'parent' && isMultiSelectType(wizardState.parentType);
+
+ let endContent = '';
+ if (step === 'type' || (step === 'parent' && !parentUsesContinue)) {
+ endContent = `${escapeHtml(getFooterHintText(wizardState))} `;
+ } else if (step === 'confirm') {
+ endContent = `Create Section `;
+ } else {
+ endContent = `Continue `;
+ }
+
+ const backLabel = step === 'type' ? 'Back to start' : 'Back';
+
+ return `
+
+ `;
+ }
+
+ function buildAdvancedCreateFooterHTML(sectionType = 'custom') {
+ return `
+
+ `;
+ }
+
+ function getFooterHTML(stage, wizardState, options = {}) {
+ if (stage === 'mode') {
+ return `Cancel `;
+ }
+
+ if (stage === 'wizard') {
+ return buildWizardFooterHTML(wizardState);
+ }
+
+ if (stage === 'advanced') {
+ return buildAdvancedCreateFooterHTML(options.sectionType || 'custom');
+ }
+
+ return '';
+ }
+
+ function attachToggleHandlers(root) {
+ if (root.dataset.hsseToggleHandlersAttached === 'true') {
+ if (typeof window.KefinTweaksUI?.bindToggleCards === 'function') {
+ window.KefinTweaksUI.bindToggleCards(root);
+ }
+ return;
+ }
+ root.dataset.hsseToggleHandlersAttached = 'true';
+
+ if (typeof window.KefinTweaksUI?.bindToggleCards === 'function') {
+ window.KefinTweaksUI.bindToggleCards(root);
+ }
+
+ root.addEventListener('click', (e) => {
+ const switchBtn = e.target.closest('.kefin-toggle-switch');
+ if (switchBtn && root.contains(switchBtn)) return;
+
+ const btn = e.target.closest('.toggle-slider');
+ if (!btn || !root.contains(btn)) return;
+ const checkboxId = btn.dataset.checkboxId;
+ if (!checkboxId) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const checkbox = document.getElementById(checkboxId);
+ if (checkbox && root.contains(checkbox)) {
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event('change', { bubbles: true }));
+ updateToggleSliderUI(btn, checkbox.checked);
+ }
+ });
+
+ root.addEventListener('change', (e) => {
+ const checkbox = e.target;
+ if (!checkbox.matches?.('input[type="checkbox"]') || !root.contains(checkbox)) return;
+ const switchBtn = root.querySelector(`.kefin-toggle-switch[data-checkbox-id="${checkbox.id}"]`);
+ if (switchBtn && typeof window.KefinTweaksUI?.updateToggleSwitchUI === 'function') {
+ window.KefinTweaksUI.updateToggleSwitchUI(switchBtn, checkbox.checked);
+ return;
+ }
+ const btn = root.querySelector(`.toggle-slider[data-checkbox-id="${checkbox.id}"]`);
+ if (btn) updateToggleSliderUI(btn, checkbox.checked);
+ });
+ }
+
+ /**
+ * Open the create flow (mode select or wizard) for new custom sections.
+ * @param {Object} section - Base section (new section stub)
+ * @param {Object} options
+ * @param {Function} options.onOpenAdvanced - Opens legacy advanced editor with section draft
+ * @param {Function} options.onSaveWizardSection - Saves wizard-created section via JS Injector
+ */
+ // --- Migrated section edit modal (from homeScreen-configuration.js) ---
+
+ const WARN = (...args) => console.warn('[KefinTweaks Section Editor]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks Section Editor]', ...args);
+
+ const SECTION_EDITOR_MODAL_ID = 'kefin-homescreen-section-editor';
+ const DISCOVERY_EDITOR_MODAL_ID = 'kefin-homescreen-discovery-editor';
+
+ // Supported additional query options (matching configuration.js)
+ const SUPPORTED_QUERY_OPTIONS = {
+ Ids: { label: 'Item IDs', type: 'array', hint: 'Comma-separated GUIDs' },
+ ParentId: { label: 'Parent ID', type: 'string', hint: 'GUID of parent item' },
+ ExcludeItemIds: { label: 'Exclude Item IDs', type: 'array', hint: 'Comma-separated GUIDs' },
+ GenreIds: { label: 'Genre IDs', type: 'array', hint: 'Comma-separated GUIDs' },
+ Person: { label: 'Person', type: 'string', hint: 'Person name' },
+ PersonIds: { label: 'Person IDs', type: 'array', hint: 'Comma-separated GUIDs' },
+ PersonTypes: { label: 'Person Types', type: 'array', hint: 'Actor, Director, Writer, etc.' },
+ ExcludePersonTypes: { label: 'Exclude Person Types', type: 'array', hint: 'Actor, Director, Writer, etc.' },
+ StudioIds: { label: 'Studio IDs', type: 'array', hint: 'Comma-separated GUIDs' },
+ Fields: { label: 'Fields', type: 'array', hint: 'Comma-separated field names' },
+ MinPremiereDate: { label: 'Min Premiere Date', type: 'date', hint: 'YYYY-MM-DD' },
+ MaxPremiereDate: { label: 'Max Premiere Date', type: 'date', hint: 'YYYY-MM-DD' },
+ MinDateLastSaved: { label: 'Min Date Last Saved', type: 'date', hint: 'YYYY-MM-DD' },
+ MinDateLastSavedForUser: { label: 'Min Date Last Saved For User', type: 'date', hint: 'YYYY-MM-DD' },
+ IndexNumber: { label: 'Index Number', type: 'number', hint: 'Episode/Season index' },
+ ParentIndexNumber: { label: 'Parent Index Number', type: 'number', hint: 'Season index' },
+ MinCommunityRating: { label: 'Min Community Rating', type: 'number', hint: '0-10' },
+ MinCriticRating: { label: 'Min Critic Rating', type: 'number', hint: '0-10' },
+ IsFavorite: { label: 'Is Favorite', type: 'boolean' },
+ IsPlayed: { label: 'Is Played', type: 'boolean' },
+ // IsUnplayed is owned by Hide watched — not offered in Additional Options
+ IsMissing: { label: 'Is Missing', type: 'boolean' },
+ IsUnaired: { label: 'Is Unaired', type: 'boolean' },
+ HasThemeSong: { label: 'Has Theme Song', type: 'boolean' },
+ HasThemeVideo: { label: 'Has Theme Video', type: 'boolean' },
+ HasSubtitles: { label: 'Has Subtitles', type: 'boolean' },
+ HasSpecialFeature: { label: 'Has Special Feature', type: 'boolean' },
+ HasTrailer: { label: 'Has Trailer', type: 'boolean' },
+ HasParentalRating: { label: 'Has Parental Rating', type: 'boolean' },
+ IsHd: { label: 'Is HD', type: 'boolean' },
+ Is4K: { label: 'Is 4K', type: 'boolean' },
+ HasOverview: { label: 'Has Overview', type: 'boolean' },
+ HasOfficialRating: { label: 'Has Official Rating', type: 'boolean' },
+ Recursive: { label: 'Recursive', type: 'boolean', default: true },
+ MaxOfficialRating: { label: 'Max Official Rating', type: 'string', hint: 'e.g., PG-13, TV-MA' },
+ MinOfficialRating: { label: 'Min Official Rating', type: 'string', hint: 'e.g., PG, TV-14' },
+ NameStartsWith: { label: 'Name Starts With', type: 'string' },
+ NameStartsWithOrGreater: { label: 'Name Starts With Or Greater', type: 'string' },
+ NameLessThan: { label: 'Name Less Than', type: 'string' },
+ LocationTypes: { label: 'Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' },
+ ExcludeLocationTypes: { label: 'Exclude Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' },
+ ExcludeItemTypes: { label: 'Exclude Item Types', type: 'array', hint: 'Movie, Series, Episode, etc.' },
+ ImageTypes: { label: 'Image Types', type: 'array', hint: 'Primary, Backdrop, Thumb, Logo, etc.' },
+ OfficialRatings: { label: 'Official Ratings', type: 'array', hint: 'PG, PG-13, R, etc.' },
+ Studios: { label: 'Studios', type: 'array', hint: 'Pipe-delimited names' },
+ Artists: { label: 'Artists', type: 'array', hint: 'Pipe-delimited names' },
+ Albums: { label: 'Albums', type: 'array', hint: 'Pipe-delimited names' },
+ SeriesStatus: { label: 'Series Status', type: 'array', hint: 'Continuing, Ended, Unreleased' },
+ Years: { label: 'Years', type: 'array', hint: 'Comma-separated years' },
+ EnableResumable: { label: 'Enable Resumable', type: 'boolean' },
+ EnableRewatching: { label: 'Enable Rewatching', type: 'boolean' },
+ EnableUserData: { label: 'Enable User Data', type: 'boolean' }
+ };
+
+ const CARD_FORMATS = ['Poster', 'Thumb', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc', 'Series Poster', 'Series Thumb'];
+ const BADGE_FIELD_DESCRIPTIONS = {
+ IncludeItemTypes: 'Limit results to these item types.',
+ Genres: 'Filter results to items matching these genres.',
+ Tags: 'Filter results to items with these tags.',
+ Collections: 'Limit results to items in these collections.',
+ Playlists: 'Limit results to items in these playlists.'
+ };
+ const SORT_ORDERS = ['Default', 'Random', 'Name', 'SortName', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber', 'IsFolder', 'SimilarityScore', 'SearchScore', 'DateLastContentAdded', 'SeriesDatePlayed', 'ChildCount'];
+ const SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending'];
+ const RENDER_MODE_OPTIONS = [{ value: 'Normal', label: 'Normal' }, { value: 'Spotlight', label: 'Spotlight' }, { value: 'Random', label: 'Random' }];
+ const SLIDE_STATE_OPTIONS = [
+ { value: 'none', label: 'None' },
+ { value: 'dots', label: 'Dots' },
+ { value: 'numeric', label: 'Numeric' }
+ ];
+
+ const SPOTLIGHT_LAYOUT_OPTIONS = [
+ { value: 'Border', label: 'Border' },
+ { value: 'Borderless', label: 'Borderless' }
+ ];
+ const SPOTLIGHT_SIZE_OPTIONS = [
+ { value: 'normal', label: 'Normal' },
+ { value: 'large', label: 'Large' },
+ { value: 'full', label: 'Full' }
+ ];
+ const SPOTLIGHT_TILE_COUNT_OPTIONS = [
+ { value: '1', label: '1' },
+ { value: '2', label: '2' },
+ { value: '3', label: '3' }
+ ];
+ const SPOTLIGHT_ENTRANCE_OPTIONS = [
+ { value: 'fadeIn', label: 'Fade in' },
+ { value: 'fadeInUp', label: 'Fade in up' },
+ { value: 'fadeInDown', label: 'Fade in down' }
+ ];
+ const SPOTLIGHT_SLIDE_OPTIONS = [
+ { value: 'kenBurnsZoomIn', label: 'Ken Burns zoom in' },
+ { value: 'kenBurnsZoomOut', label: 'Ken Burns zoom out' },
+ { value: 'kenBurnsZoomInFullscreen', label: 'Ken Burns zoom in (fullscreen)' },
+ { value: 'kenBurnsZoomOutFullscreen', label: 'Ken Burns zoom out (fullscreen)' },
+ { value: 'kenBurnsPanRight', label: 'Ken Burns pan right' },
+ { value: 'kenBurnsPanLeft', label: 'Ken Burns pan left' },
+ { value: 'kenBurnsPanUp', label: 'Ken Burns pan up' },
+ { value: 'kenBurnsDiagonal', label: 'Ken Burns diagonal' },
+ { value: 'fadeInScale', label: 'Fade in scale' },
+ { value: 'parallaxFloat', label: 'Parallax float' },
+ { value: 'depthPulse', label: 'Depth pulse' },
+ { value: 'slowRotate', label: 'Slow rotate' },
+ { value: 'breathe', label: 'Breathe' },
+ { value: 'heatHaze', label: 'Heat haze' },
+ { value: 'colorWash', label: 'Color wash' },
+ { value: 'vignetteIn', label: 'Vignette in' }
+ ];
+
+ let activeEditCtx = null;
+ function getActiveConfig() { return activeEditCtx?.config || null; }
+
+ function sectionEditorShowToast(message, duration = '3') {
+ if (window.KefinTweaksToaster?.toast) {
+ window.KefinTweaksToaster.toast(message, duration);
+ } else {
+ alert(message);
+ }
+ }
+
+ function findSectionInGroupsForEditor(groups, sectionId) {
+ if (!Array.isArray(groups)) return null;
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
+ const group = groups[groupIndex];
+ if (group?.sections) {
+ const sectionIndex = group.sections.findIndex(s => s.id === sectionId);
+ if (sectionIndex !== -1) {
+ return { section: group.sections[sectionIndex], group, groupIndex, sectionIndex };
+ }
+ }
+ }
+ return null;
+ }
+
+ function buildCheckbox(id, checked, label) {
+ return window.KefinTweaksUI.buildCheckbox(id, checked, label);
+ }
+
+ function buildSelect(id, options, selectedValue, label) {
+ return window.KefinTweaksUI.buildSelect(id, options, selectedValue, label);
+ }
+
+ function buildTextInput(id, value, label, type = 'text', placeholder = '') {
+ return window.KefinTweaksUI.buildTextInput(id, value, label, type, placeholder);
+ }
+
+ function buildToggleCard(id, checked, label, description, options = {}) {
+ return window.KefinTweaksUI.buildToggleCard(id, checked, label, description, options);
+ }
+
+ function updateToggleSwitchUI(btn, checked) {
+ return window.KefinTweaksUI.updateToggleSwitchUI(btn, checked);
+ }
+
+ function buildEnabledToggleButtons(id, enabled) {
+ return window.KefinTweaksUI.buildEnabledToggleButtons(id, enabled);
+ }
+
+ function buildToggleSlider(id, checked, label, options = {}) {
+ return window.KefinTweaksUI.buildToggleSlider(id, checked, label, options);
+ }
+
+ function updateToggleSliderUI(toggleButton, isEnabled) {
+ return window.KefinTweaksUI.updateToggleSliderUI(toggleButton, isEnabled);
+ }
+
+ /**
+ * Build filter by played status toggle button
+ */
+ function buildFilterByPlayedStatusToggle(id, checked) {
+ const isChecked = checked === true;
+ return `
+
+ Hide Watched Items
+
+ ON
+ OFF
+
+
+
+ `;
+ }
+
+ /**
+ * Resolve backdrops count from spotlight config (migrates legacy cycleBackdropsTime).
+ */
+ function resolveSpotlightBackdropsCount(spotlightOpts = {}) {
+ if (spotlightOpts.backdropsCount != null) {
+ return Math.max(1, parseInt(spotlightOpts.backdropsCount, 10) || 1);
+ }
+ if (spotlightOpts.cycleBackdropsTime > 0 && spotlightOpts.interval > 0) {
+ return Math.max(1, Math.round(spotlightOpts.interval / spotlightOpts.cycleBackdropsTime));
+ }
+ return 1;
+ }
+
+ const SPOTLIGHT_TOGGLE_DESCRIPTIONS = {
+ panAnimation: 'Apply pan and zoom animations to each slide.',
+ showNavButtons: 'Show previous and next navigation buttons on the carousel.',
+ showClearArt: 'Display clear art logo overlay when available.',
+ cycleBackdrops: 'Cycle through multiple backdrop images per slide.',
+ autoPlay: 'Automatically advance slides after the timer interval.'
+ };
+
+ /**
+ * Collect spotlightConfig from dialog fields.
+ */
+ function collectSpotlightConfigFromDialog(dialog, idPrefix, options = {}) {
+ const useIntervalSeconds = options.useIntervalSeconds === true;
+ const slideStateVal = dialog.querySelector('#' + idPrefix + 'slideState')?.value || 'dots';
+ const tileCountVal = Math.max(1, Math.min(3, parseInt(dialog.querySelector('#' + idPrefix + 'tileCount')?.value || '1', 10)));
+ const intervalRaw = dialog.querySelector('#' + idPrefix + 'interval')?.value;
+ const intervalMs = useIntervalSeconds
+ ? Math.round((parseFloat(intervalRaw || '10') || 10) * 1000)
+ : parseInt(intervalRaw || '10000', 10);
+
+ return {
+ spotlightLayout: dialog.querySelector('#' + idPrefix + 'spotlightLayout')?.value || 'Border',
+ spotlightSize: dialog.querySelector('#' + idPrefix + 'spotlightSize')?.value || 'normal',
+ tileCount: tileCountVal,
+ autoPlay: dialog.querySelector('#' + idPrefix + 'autoPlay')?.checked !== false,
+ interval: intervalMs,
+ showSlideState: slideStateVal !== 'none',
+ showDots: slideStateVal === 'dots',
+ showNavButtons: dialog.querySelector('#' + idPrefix + 'showNavButtons')?.checked !== false,
+ showClearArt: dialog.querySelector('#' + idPrefix + 'showClearArt')?.checked === true,
+ panAnimation: dialog.querySelector('#' + idPrefix + 'panAnimation')?.checked !== false,
+ entranceAnimationFirst: dialog.querySelector('#' + idPrefix + 'entranceAnimationFirst')?.value || 'fadeIn',
+ entranceAnimationSecond: dialog.querySelector('#' + idPrefix + 'entranceAnimationSecond')?.value || 'fadeIn',
+ entranceAnimationThird: dialog.querySelector('#' + idPrefix + 'entranceAnimationThird')?.value || 'fadeIn',
+ slideAnimationFirst: dialog.querySelector('#' + idPrefix + 'slideAnimationFirst')?.value || 'kenBurnsZoomIn',
+ slideAnimationSecond: dialog.querySelector('#' + idPrefix + 'slideAnimationSecond')?.value || 'kenBurnsZoomIn',
+ slideAnimationThird: dialog.querySelector('#' + idPrefix + 'slideAnimationThird')?.value || 'kenBurnsZoomIn',
+ cycleBackdrops: dialog.querySelector('#' + idPrefix + 'cycleBackdrops')?.checked === true,
+ backdropsCount: Math.max(1, parseInt(dialog.querySelector('#' + idPrefix + 'backdropsCount')?.value || '1', 10))
+ };
+ }
+
+ /**
+ * Shared spotlight settings fields HTML (advanced-editor layout).
+ */
+ function buildSharedSpotlightFieldsHTML(spotlightOpts, idPrefix, options = {}) {
+ const useIntervalSeconds = options.useIntervalSeconds === true;
+ const spotlightLayout = spotlightOpts.spotlightLayout ?? (spotlightOpts.fullScreen === true ? 'Borderless' : 'Border');
+ const spotlightSize = spotlightOpts.spotlightSize ?? (spotlightOpts.fullScreen === true ? 'full' : 'normal');
+ const tileCount = Math.max(1, Math.min(3, parseInt(
+ spotlightOpts.tileCount ?? (spotlightSize === 'full' ? 1 : spotlightSize === 'large' ? 2 : 3),
+ 10
+ ) || 1));
+ const tileCountStr = String(tileCount);
+ const slideStateVal = spotlightOpts.showSlideState === false ? 'none' : (spotlightOpts.showDots === false ? 'numeric' : 'dots');
+ const intervalValue = useIntervalSeconds
+ ? Math.round((spotlightOpts.interval ?? 10000) / 1000)
+ : (spotlightOpts.interval ?? 10000);
+ const intervalLabel = useIntervalSeconds ? 'Slide Timer (seconds)' : 'Auto-play interval (ms)';
+ const cycleBackdropsOn = spotlightOpts.cycleBackdrops === true;
+ const rawBackdropsCount = spotlightOpts.backdropsCount ?? resolveSpotlightBackdropsCount(spotlightOpts);
+ const backdropsCount = cycleBackdropsOn
+ ? Math.max(2, parseInt(rawBackdropsCount, 10) || 2)
+ : Math.max(1, parseInt(rawBackdropsCount, 10) || 1);
+ const animateSlides = spotlightOpts.panAnimation !== false;
+
+ function animRow(n) {
+ const ord = n === 1 ? 'First' : n === 2 ? 'Second' : 'Third';
+ return `
+
+ ${buildSelect(idPrefix + 'entranceAnimation' + ord, SPOTLIGHT_ENTRANCE_OPTIONS, spotlightOpts['entranceAnimation' + ord] ?? 'fadeIn', 'Entrance animation ' + n)}
+ ${buildSelect(idPrefix + 'slideAnimation' + ord, SPOTLIGHT_SLIDE_OPTIONS, spotlightOpts['slideAnimation' + ord] ?? 'kenBurnsZoomIn', 'Slide animation ' + n)}
+
`;
+ }
+
+ function spotlightToggle(key, checked, label) {
+ return buildToggleCard(idPrefix + key, checked, label, SPOTLIGHT_TOGGLE_DESCRIPTIONS[key] || '');
+ }
+
+ return `
+
+
+ ${buildSelect(idPrefix + 'spotlightLayout', SPOTLIGHT_LAYOUT_OPTIONS, spotlightLayout, 'Layout')}
+ ${buildSelect(idPrefix + 'spotlightSize', SPOTLIGHT_SIZE_OPTIONS, spotlightSize, 'Size')}
+ ${buildSelect(idPrefix + 'tileCount', SPOTLIGHT_TILE_COUNT_OPTIONS, tileCountStr, 'Tiled Backdrops')}
+ ${buildSelect(idPrefix + 'slideState', SLIDE_STATE_OPTIONS, slideStateVal, 'Slide State')}
+
+
+ ${spotlightToggle('panAnimation', animateSlides, 'Animate Slides')}
+ ${spotlightToggle('showNavButtons', spotlightOpts.showNavButtons !== false, 'Show Controls')}
+ ${spotlightToggle('showClearArt', spotlightOpts.showClearArt === true, 'Show Clear Art')}
+ ${spotlightToggle('cycleBackdrops', cycleBackdropsOn, 'Cycle Backdrops')}
+
+
+ ${buildTextInput(idPrefix + 'backdropsCount', backdropsCount, 'Backdrops Count', 'number')}
+
+
+
+
${animRow(1)}
+
${animRow(2)}
+
${animRow(3)}
+
+
+ ${spotlightToggle('autoPlay', spotlightOpts.autoPlay !== false, 'Cycle Slides Automatically')}
+ ${buildTextInput(idPrefix + 'interval', intervalValue, intervalLabel, 'number')}
+
+
+ `;
+ }
+
+ /**
+ * Build spotlight settings HTML for a section editor (custom or discovery).
+ * Shown only when Render Mode is Spotlight; call from buildSectionEditorHTML when the editor includes render mode.
+ * @param {Object} section - Section or discovery config (for spotlightConfig / defaults)
+ * @param {string} idPrefix - Input id prefix, e.g. 'section-' or 'discovery-'
+ * @param {boolean} isSpotlight - Initial visibility (true = show)
+ * @returns {string} HTML for the spotlight options container
+ */
+ function buildSectionEditorSpotlightHTML(section, idPrefix, isSpotlight) {
+ const globalSpotlight = getActiveConfig()?.SPOTLIGHT_SETTINGS || {};
+ const spotlightDefaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {};
+ const sectionSpotlight = section.spotlightConfig || {};
+ const spotlightOpts = { ...spotlightDefaults, ...globalSpotlight, ...sectionSpotlight };
+ const containerId = idPrefix + 'spotlight-options-container';
+ const fieldPrefix = idPrefix + 'spotlight-';
+ return `
+
+
Spotlight Options
+ ${buildSharedSpotlightFieldsHTML(spotlightOpts, fieldPrefix, { useIntervalSeconds: true })}
+
+ `;
+ }
+
+ /**
+ * Attach event listeners for spotlight settings (Size -> tileCount, tileCount -> cycle row + animation dropdown visibility).
+ * @param {HTMLElement} container - Container that has the spotlight form (e.g. #global-settings-content or spotlight options div)
+ * @param {string} idPrefix - Prefix for input ids, e.g. 'spotlight-' or 'section-spotlight-' or 'discovery-spotlight-'
+ */
+ function attachSpotlightSettingsListeners(container, idPrefix) {
+ if (!container) return;
+ window.KefinTweaksUI?.bindToggleCards?.(container);
+
+ const sizeSelect = container.querySelector('#' + idPrefix + 'spotlightSize');
+ const tileCountSelect = container.querySelector('#' + idPrefix + 'tileCount');
+ const panAnimation = container.querySelector('#' + idPrefix + 'panAnimation');
+ const animDropdowns = container.querySelector('#' + idPrefix + 'animation-dropdowns');
+ const animDivider = container.querySelector('#' + idPrefix + 'animation-divider');
+ const animSecond = container.querySelector('#' + idPrefix + 'anim-second');
+ const animThird = container.querySelector('#' + idPrefix + 'anim-third');
+ const cycleBackdrops = container.querySelector('#' + idPrefix + 'cycleBackdrops');
+ const backdropsRow = container.querySelector('#' + idPrefix + 'backdrops-row');
+ const backdropsCountInput = container.querySelector('#' + idPrefix + 'backdropsCount');
+
+ function updateFromTileCount(tc) {
+ const t = parseInt(tc, 10) || 1;
+ if (animSecond) animSecond.style.display = t >= 2 ? 'block' : 'none';
+ if (animThird) animThird.style.display = t >= 3 ? 'block' : 'none';
+ if (animDropdowns) {
+ animDropdowns.classList.remove('hsae-spotlight-animations--1', 'hsae-spotlight-animations--2', 'hsae-spotlight-animations--3');
+ animDropdowns.classList.add(`hsae-spotlight-animations--${t}`);
+ }
+ }
+ function updateAnimateVisibility() {
+ const enabled = panAnimation?.checked !== false;
+ if (animDropdowns) animDropdowns.style.display = enabled ? 'grid' : 'none';
+ if (animDivider) animDivider.style.display = enabled ? 'block' : 'none';
+ }
+ function updateCycleBackdropsRow() {
+ const on = cycleBackdrops?.checked === true;
+ if (backdropsRow) backdropsRow.style.display = on ? 'block' : 'none';
+ if (on && backdropsCountInput) {
+ const val = parseInt(backdropsCountInput.value, 10);
+ if (!val || val < 2) backdropsCountInput.value = '2';
+ backdropsCountInput.min = '2';
+ }
+ }
+ if (tileCountSelect) {
+ tileCountSelect.addEventListener('change', () => updateFromTileCount(tileCountSelect.value));
+ updateFromTileCount(tileCountSelect.value);
+ }
+ if (panAnimation) {
+ panAnimation.addEventListener('change', updateAnimateVisibility);
+ updateAnimateVisibility();
+ }
+ if (cycleBackdrops) {
+ cycleBackdrops.addEventListener('change', updateCycleBackdropsRow);
+ updateCycleBackdropsRow();
+ }
+ backdropsCountInput?.addEventListener('change', () => {
+ if (cycleBackdrops?.checked === true) {
+ const val = parseInt(backdropsCountInput.value, 10);
+ if (!val || val < 2) backdropsCountInput.value = '2';
+ }
+ });
+
+ // Toggle cards flip checkbox without always firing change; sync conditional UI after click
+ container.querySelectorAll('.kefin-toggle-switch').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ setTimeout(() => {
+ updateAnimateVisibility();
+ updateCycleBackdropsRow();
+ }, 0);
+ });
+ });
+ }
+
+ function resolveAdvancedEditorBuildContext(section) {
+ let currentGroupName = activeEditCtx?.groupContext?.currentGroupName || '';
+ let groupType = activeEditCtx?.groupType || null;
+
+ if (section.id && !currentGroupName) {
+ const config = getActiveConfig();
+ if (config) {
+ const groupTypes = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS'];
+ for (const gt of groupTypes) {
+ const found = findSectionInGroupsForEditor(config[gt] || [], section.id);
+ if (found) {
+ currentGroupName = found.group.name || '';
+ if (!groupType) groupType = gt;
+ break;
+ }
+ }
+ }
+ }
+
+ if (!currentGroupName && section._targetGroupName && section._targetGroupName !== 'New...') {
+ currentGroupName = section._targetGroupName;
+ }
+
+ const allGroups = getActiveConfig()?.CUSTOM_SECTION_GROUPS || [];
+ const existingGroupNames = [...new Set(allGroups.map(g => g.name).filter(Boolean))];
+ const editorProfile = activeEditCtx?.editorProfile
+ || window.KefinHomeScreenEditorProfiles?.resolveEditorProfile(section, {
+ groupType,
+ isCustomSection: groupType === 'CUSTOM_SECTION_GROUPS' || activeEditCtx?.sectionType === 'custom'
+ })
+ || 'full';
+ const lockVisibility = activeEditCtx?.lockVisibility
+ ?? window.KefinHomeScreenEditorProfiles?.getEditorProfileDefinition(editorProfile)?.lockVisibility === true;
+
+ return {
+ currentGroupName,
+ existingGroupNames,
+ config: getActiveConfig(),
+ editorProfile,
+ groupType,
+ lockVisibility
+ };
+ }
+
+ function buildSectionEditorHTML(section) {
+ if (!window.KefinHomeScreenAdvancedEditor?.buildHTML) {
+ return 'Advanced section editor is not available.
';
+ }
+ return window.KefinHomeScreenAdvancedEditor.buildHTML(section, resolveAdvancedEditorBuildContext(section));
+ }
+
+ function buildQueryEditor(query, index, canDelete) {
+ const queryType = query.path ? 'custom' : (query.dataSource ? 'datasource' : 'standard');
+ const pathDisplay = queryType === 'custom' ? 'block' : 'none';
+ const dataSourceDisplay = queryType === 'datasource' ? 'block' : 'none';
+
+ return `
+
+
+
Query ${index + 1}
+ ${canDelete ? `
+
+ Delete
+
+ ` : ''}
+
+ ${buildSelect(`query-${index}-type`, [
+ { value: 'standard', label: 'Standard Items Query' },
+ { value: 'custom', label: 'Custom Endpoint' },
+ { value: 'datasource', label: 'Data Source' }
+ ], queryType, 'Query Type')}
+
+ ${buildTextInput(`query-${index}-path`, query.path || '', 'Endpoint Path', 'text', '/Genres, /Persons, /Shows/NextUp, /Items/Latest, etc')}
+
+
+ ${buildTextInput(`query-${index}-dataSource`, query.dataSource || '', 'Data Source (e.g., MoviesCache.getImdbTop250Movies)')}
+
+
+
Parent Options
+ ${buildParentOptionsEditor(query, index)}
+
+
+
Query Options
+ ${buildQueryOptionsEditor(query.queryOptions || {}, index)}
+
+
+ `;
+ }
+
+ /**
+ * Build parent options editor
+ */
+ function buildParentOptionsEditor(query, queryIndex) {
+ const queryOptions = query.queryOptions || {};
+ // Determine parent item type from query level first, then fall back to queryOptions for backwards compatibility
+ let parentItemType = query._parentItemType || '';
+
+ // Backwards compatibility: check queryOptions._parentItemType
+ if (!parentItemType && (queryOptions._parentItemType === 'Collection' || queryOptions._parentItemType === 'Playlist')) {
+ parentItemType = queryOptions._parentItemType;
+ } else if (!parentItemType && queryOptions.CollectionIds && (Array.isArray(queryOptions.CollectionIds) ? queryOptions.CollectionIds.length > 0 : queryOptions.CollectionIds)) {
+ // Backwards compatibility: check old CollectionIds field
+ parentItemType = 'Collection';
+ } else if (!parentItemType && queryOptions.PlaylistIds && (Array.isArray(queryOptions.PlaylistIds) ? queryOptions.PlaylistIds.length > 0 : queryOptions.PlaylistIds)) {
+ // Backwards compatibility: check old PlaylistIds field
+ parentItemType = 'Playlist';
+ } else if (!parentItemType && queryOptions.ParentId) {
+ parentItemType = 'Generic Parent';
+ }
+
+ return `
+
+
+ ${buildSelect(`query-${queryIndex}-parentItemType`, [
+ { value: '', label: 'Select a Parent... (optional)' },
+ { value: 'Collection', label: 'Collection' },
+ { value: 'Playlist', label: 'Playlist' },
+ { value: 'Generic Parent', label: 'Generic Parent' }
+ ], parentItemType, 'Parent Item')}
+
+
+
+
+
+ ${buildTextInput(`query-${queryIndex}-ParentId`, queryOptions.ParentId || '', 'Parent ID (GUID)', 'text')}
+
+
+
+
+
+ `;
+ }
+
+ /**
+ * Build query options editor
+ */
+ function buildQueryOptionsEditor(queryOptions, queryIndex) {
+
+ // Regular text/number fields
+ const commonFields = [
+ { key: 'SortBy', label: 'Sort By', value: queryOptions.SortBy || '', options: SORT_ORDERS },
+ { key: 'SortOrder', label: 'Sort Order', value: queryOptions.SortOrder || '', options: SORT_ORDER_DIRECTIONS },
+ { key: 'Limit', label: 'Limit', value: queryOptions.Limit || '', type: 'number' },
+ { key: 'Filters', label: 'Filters', value: queryOptions.Filters || '', placeholder: 'IsUnplayed,IsResumable,IsNotFolder' },
+ { key: 'SearchTerm', label: 'Search Term', value: queryOptions.SearchTerm || '' }
+ ];
+
+ // Extract additional options (not in common fields or badge fields)
+ const additionalOptions = [];
+ const badgeFieldKeys = ['IncludeItemTypes', 'Genres', 'Tags', 'CollectionIds', 'PlaylistIds', 'ParentId'];
+ Object.entries(queryOptions || {}).forEach(([key, value]) => {
+ if (!commonFields.find(f => f.key === key) && !badgeFieldKeys.includes(key)) {
+ // If the key doesn't start with an underscore, add it to additionalOptions
+ if (!key.startsWith('_')) {
+ additionalOptions.push({ key, value });
+ }
+ }
+ });
+
+ return `
+
+
+
+
+
+
+ ${commonFields.map(field => `
+
+ ${field.options ? buildSelect(`query-${queryIndex}-${field.key}`, field.options, field.value, field.label) : buildTextInput(`query-${queryIndex}-${field.key}`, field.value, field.label, field.type || 'text', field.placeholder || '')}
+
+ `).join('')}
+
+
+ Additional Options
+
+ ${buildAdditionalOptionsControls(queryIndex)}
+ ${buildAdditionalOptionsList(queryIndex, additionalOptions)}
+
+
+
+ `;
+ }
+
+ /**
+ * Setup badge autocomplete for a query field
+ */
+ async function setupQueryBadgeAutocomplete(modalInstance, queryIndex, type, queryOptions = {}, includeItemTypes = null, parentItemType = null) {
+ if (!window.KefinTweaksUI || !window.KefinTweaksUI.createAutoCompleteInput) {
+ WARN('KefinTweaksUI.createAutoCompleteInput not available');
+ return;
+ }
+
+ const container = modalInstance.dialogContent.querySelector(`#query-${queryIndex}-${type}-container`);
+ if (!container) return;
+
+ // Calculate itemTypesForFilters early (used for Genres/Tags)
+ const rawItemTypes = includeItemTypes || queryOptions.IncludeItemTypes;
+ let itemTypesForFilters = null;
+ if (type === 'Genres' || type === 'Tags') {
+ if (Array.isArray(rawItemTypes)) {
+ itemTypesForFilters = rawItemTypes.filter(Boolean);
+ } else if (rawItemTypes) {
+ itemTypesForFilters = [rawItemTypes];
+ } else {
+ itemTypesForFilters = [];
+ }
+ }
+
+ // Extract current values from query options
+ let currentValues = [];
+ if (type === 'IncludeItemTypes') {
+ currentValues = Array.isArray(queryOptions.IncludeItemTypes) ? queryOptions.IncludeItemTypes : (queryOptions.IncludeItemTypes ? [queryOptions.IncludeItemTypes] : []);
+ } else if (type === 'Genres') {
+ // Get selected genre names from queryOptions
+ const selectedGenres = queryOptions.Genres || '';
+ const selectedGenreNames = selectedGenres ? (Array.isArray(selectedGenres) ? selectedGenres : selectedGenres.split(',').map(s => s.trim()).filter(s => s)) : [];
+
+ if (window.dataHelper?.getFilterGenreItems) {
+ try {
+ const filterTypes = itemTypesForFilters?.length ? itemTypesForFilters : null;
+ const availableGenres = await window.dataHelper.getFilterGenreItems(filterTypes, { useCache: true });
+ const genreNames = availableGenres.map(g => g.name);
+ const genreMap = new Map(genreNames.map(g => [g.toLowerCase(), g]));
+ currentValues = selectedGenreNames
+ .map(name => {
+ const exactMatch = genreNames.find(g => g === name);
+ if (exactMatch) return exactMatch;
+ return genreMap.get(name.toLowerCase()) || name;
+ })
+ .filter(Boolean);
+ } catch (err) {
+ WARN('Error fetching genres from dataHelper:', err);
+ currentValues = selectedGenreNames;
+ }
+ } else {
+ currentValues = selectedGenreNames;
+ }
+ } else if (type === 'Tags') {
+ // Get selected tag names from queryOptions
+ const selectedTags = queryOptions.Tags || '';
+ const selectedTagNames = selectedTags ? (Array.isArray(selectedTags) ? selectedTags : selectedTags.split(',').map(s => s.trim()).filter(s => s)) : [];
+
+ if (window.dataHelper?.getFilterTagItems) {
+ try {
+ const filterTypes = itemTypesForFilters?.length ? itemTypesForFilters : null;
+ const availableTags = await window.dataHelper.getFilterTagItems(filterTypes, { useCache: true });
+ const tagNames = availableTags.map(t => t.name);
+ const tagMap = new Map(tagNames.map(t => [t.toLowerCase(), t]));
+ currentValues = selectedTagNames
+ .map(name => {
+ const exactMatch = tagNames.find(t => t === name);
+ if (exactMatch) return exactMatch;
+ return tagMap.get(name.toLowerCase()) || name;
+ })
+ .filter(Boolean);
+ } catch (err) {
+ WARN('Error fetching tags from dataHelper:', err);
+ currentValues = selectedTagNames;
+ }
+ } else {
+ currentValues = selectedTagNames;
+ }
+ } else if (type === 'Collections') {
+ // Check if ParentId contains a collection ID (when _parentItemType is 'Collection')
+ let collectionIds = [];
+ // Check query level first, then queryOptions for backwards compatibility
+ const effectiveParentItemType = parentItemType || queryOptions._parentItemType;
+ if (effectiveParentItemType === 'Collection' && queryOptions.ParentId) {
+ collectionIds = [queryOptions.ParentId];
+ } else {
+ // Backwards compatibility: check old CollectionIds field
+ collectionIds = Array.isArray(queryOptions.CollectionIds) ? queryOptions.CollectionIds : (queryOptions.CollectionIds ? [queryOptions.CollectionIds] : []);
+ }
+
+ if (collectionIds.length ===0) {
+ currentValues = [];
+ } else {
+ // For Collections, we need to fetch names from IDs
+ if (window.apiHelper && window.ApiClient) {
+ try {
+ const userId = window.ApiClient.getCurrentUserId();
+ const serverAddress = window.ApiClient.serverAddress();
+ if (userId && serverAddress) {
+ const data = await window.apiHelper.getItems(
+ {
+ IncludeItemTypes: 'BoxSet,CollectionFolder',
+ Recursive: true,
+ Fields: 'ItemCounts',
+ Ids: collectionIds.join(',')
+ },
+ true,
+ 300000
+ );
+ // Convert to objects with id and name
+ currentValues = (data?.Items || []).map(item => ({
+ id: item.Id,
+ name: item.Name
+ }));
+ // Add any IDs that weren't found (shouldn't happen, but just in case)
+ const foundIds = new Set(currentValues.map(v => v.id));
+ collectionIds.forEach(id => {
+ if (!foundIds.has(id)) {
+ currentValues.push({ id: id, name: id }); // Fallback to ID if not found
+ }
+ });
+ } else {
+ // Fallback: convert IDs to objects
+ currentValues = collectionIds.map(id => ({ id: id, name: id }));
+ }
+ } catch (err) {
+ WARN('Error fetching collection names:', err);
+ // Fallback: convert IDs to objects
+ currentValues = collectionIds.map(id => ({ id: id, name: id }));
+ }
+ } else {
+ // Convert IDs to objects for consistency
+ currentValues = collectionIds.map(id => ({ id: id, name: id }));
+ }
+ }
+ } else if (type === 'Playlists') {
+ // Check if ParentId contains a playlist ID (when _parentItemType is 'Playlist')
+ let playlistIds = [];
+ // Check query level first, then queryOptions for backwards compatibility
+ const effectiveParentItemType = parentItemType || queryOptions._parentItemType;
+ if (effectiveParentItemType === 'Playlist' && queryOptions.ParentId) {
+ playlistIds = [queryOptions.ParentId];
+ } else {
+ // Backwards compatibility: check old PlaylistIds field
+ playlistIds = Array.isArray(queryOptions.PlaylistIds) ? queryOptions.PlaylistIds : (queryOptions.PlaylistIds ? [queryOptions.PlaylistIds] : []);
+ }
+
+ if (playlistIds.length ===0) {
+ currentValues = [];
+ } else {
+ // For Playlists, we need to fetch names from IDs
+ if (window.apiHelper && window.ApiClient) {
+ try {
+ const userId = window.ApiClient.getCurrentUserId();
+ const serverAddress = window.ApiClient.serverAddress();
+ if (userId && serverAddress) {
+ const data = await window.apiHelper.getItems(
+ {
+ IncludeItemTypes: 'Playlist',
+ Recursive: true,
+ Fields: 'ItemCounts',
+ Ids: playlistIds.join(',')
+ },
+ true,
+ 300000
+ );
+ // Convert to objects with id and name
+ currentValues = (data?.Items || []).map(item => ({
+ id: item.Id,
+ name: item.Name
+ }));
+ // Add any IDs that weren't found
+ const foundIds = new Set(currentValues.map(v => v.id));
+ playlistIds.forEach(id => {
+ if (!foundIds.has(id)) {
+ currentValues.push({ id: id, name: id }); // Fallback to ID if not found
+ }
+ });
+ } else {
+ // Fallback: convert IDs to objects
+ currentValues = playlistIds.map(id => ({ id: id, name: id }));
+ }
+ } catch (err) {
+ WARN('Error fetching playlist names:', err);
+ // Fallback: convert IDs to objects
+ currentValues = playlistIds.map(id => ({ id: id, name: id }));
+ }
+ } else {
+ // Convert IDs to objects for consistency
+ currentValues = playlistIds.map(id => ({ id: id, name: id }));
+ }
+ }
+ }
+
+ // Get available item types for IncludeItemTypes
+ const allAvailableItemTypes = [
+ 'AggregateFolder', 'Audio', 'AudioBook', 'BasePluginFolder', 'Book', 'BoxSet', 'Channel',
+ 'ChannelFolderItem', 'CollectionFolder', 'Episode', 'Folder', 'Genre', 'ManualPlaylistsFolder',
+ 'Movie', 'LiveTvChannel', 'LiveTvProgram', 'MusicAlbum', 'MusicArtist', 'MusicGenre', 'MusicVideo',
+ 'Person', 'Photo', 'PhotoAlbum', 'Playlist', 'PlaylistsFolder', 'Program', 'Recording', 'Season',
+ 'Series', 'Studio', 'Trailer', 'TvChannel', 'TvProgram', 'UserRootFolder', 'UserView', 'Video', 'Year'
+ ];
+
+ // Normalize type: Collections->Collection, Playlists->Playlist, Genres->Genre, Tags->Tag
+ const normalizedType = type === 'IncludeItemTypes' ? 'IncludeItemTypes'
+ : (type === 'Collections' ? 'Collection'
+ : (type === 'Playlists' ? 'Playlist'
+ : (type === 'Genres' ? 'Genre'
+ : (type === 'Tags' ? 'Tag' : type))));
+
+ const autocomplete = window.KefinTweaksUI.createAutoCompleteInput({
+ type: normalizedType,
+ prefix: 'query',
+ sectionIndex: queryIndex,
+ container: container,
+ label: type === 'IncludeItemTypes' ? 'Include Item Types' : (type === 'Collections' ? 'Collections' : (type === 'Playlists' ? 'Playlists' : type)),
+ description: BADGE_FIELD_DESCRIPTIONS[type] || '',
+ currentValuesArray: currentValues,
+ availableTypes: type === 'IncludeItemTypes' ? allAvailableItemTypes : null,
+ includeItemTypes: itemTypesForFilters,
+ onChange: (values) => {
+ // Values are updated automatically via hidden inputs
+ }
+ });
+
+ if (autocomplete && autocomplete.element) {
+ container.innerHTML = '';
+ container.appendChild(autocomplete.element);
+ await autocomplete.setup();
+ }
+ }
+
+ /**
+ * Build additional options controls (dropdown + add button)
+ */
+ function buildAdditionalOptionsControls(queryIndex) {
+ const dropdownOptions = Object.entries(SUPPORTED_QUERY_OPTIONS)
+ .map(([key, meta]) => `${meta.label} `)
+ .join('');
+
+ return `
+
+
Additional Options
+
+
+ Select an option to add...
+ ${dropdownOptions}
+
+
+ Add
+
+
+
+ `;
+ }
+
+ /**
+ * Build additional options list
+ */
+ function buildAdditionalOptionsList(queryIndex, currentOptions) {
+ const optionsHtml = (currentOptions || []).map((option, index) => {
+ const meta = SUPPORTED_QUERY_OPTIONS[option.key] || { label: option.key, type: 'string' };
+ const inputId = `query-${queryIndex}-additionalOption-${index}`;
+
+ let inputField = '';
+ if (meta.type === 'boolean') {
+ const isChecked = option.value === true || option.value === 'true';
+ inputField = `
+
+
+
+ ${meta.label}
+
+
+ `;
+ } else {
+ const displayValue = Array.isArray(option.value) ? option.value.join(',') : (option.value || '');
+ inputField = `
+
+ `;
+ }
+
+ return `
+
+ ${inputField}
+
+ close
+
+
+ `;
+ }).join('');
+
+ return `
+
+ `;
+ }
+
+ /**
+ * Build discovery section editor HTML (simplified)
+ */
+ function buildDiscoveryEditorHTML(section) {
+ const isDiscoverySpotlight = section.renderMode === 'Spotlight';
+ const discoverySpotlightHTML = buildSectionEditorSpotlightHTML(section, 'discovery-', isDiscoverySpotlight);
+ return `
+
+
+ Note: Discovery sections use dynamic sources that are determined at runtime. You can only modify basic settings here.
+
+ ${buildTextInput('discovery-name', section.name || '', 'Name Template (use placeholders like {Genre}, {Person}, {Title})')}
+ ${buildCheckbox('discovery-enabled', section.enabled !== false, 'Enabled')}
+ ${buildTextInput('discovery-itemLimit', section.itemLimit || 20, 'Item Limit', 'number')}
+ ${buildSelect('discovery-sortOrder', SORT_ORDERS, section.sortOrder || 'Random', 'Sort Order')}
+ ${section.sortOrderDirection ? buildSelect('discovery-sortOrderDirection', SORT_ORDER_DIRECTIONS, section.sortOrderDirection || 'Ascending', 'Sort Order Direction') : ''}
+ ${buildSelect('discovery-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format')}
+ ${buildTextInput('discovery-ttl', section.ttl !== undefined ? section.ttl : '', 'TTL (ms)', 'number')}
+ ${buildSelect('discovery-renderMode', RENDER_MODE_OPTIONS, section.renderMode || 'Normal', 'Render Mode')}
+
+ ${discoverySpotlightHTML}
+
+ `;
+ }
+
+ /**
+ * Collect form data from section editor
+ * @param {Object} originalSection - Original section data (for preserving hidden fields in default sections)
+ */
+ function collectSectionEditorData(originalSection = null) {
+ const dialog = document.querySelector(`.dialogContainer[data-modal-id="${SECTION_EDITOR_MODAL_ID}"]`) || document.querySelector(`.dialogContainer[data-modal-id="${DISCOVERY_EDITOR_MODAL_ID}"]`);
+ if (!dialog) return null;
+
+ if (dialog.querySelector('.hsae-root') && window.KefinHomeScreenAdvancedEditor?.collectData) {
+ const sectionData = window.KefinHomeScreenAdvancedEditor.collectData(dialog, originalSection);
+ if (!sectionData) return null;
+
+ const groupSelect = dialog.querySelector('#section-group');
+ const groupNew = dialog.querySelector('#section-group-new');
+ if (groupSelect) {
+ const selectedGroup = groupSelect.value;
+ if (selectedGroup === 'New...' && groupNew?.value?.trim()) {
+ sectionData._targetGroupName = groupNew.value.trim();
+ } else if (selectedGroup && selectedGroup !== 'New...') {
+ sectionData._targetGroupName = selectedGroup;
+ }
+ }
+
+ if (sectionData.renderMode !== 'Spotlight') {
+ delete sectionData.spotlightConfig;
+ }
+
+ if (sectionData.ttl === '') {
+ sectionData.ttl = undefined;
+ }
+
+ return sectionData;
+ }
+
+ console.warn('[KefinTweaks Section Editor] Advanced editor markup not found');
+ return null;
+ }
+
+ function buildAdvancedAttachContext(section, originalSection) {
+ const buildCtx = resolveAdvancedEditorBuildContext(section);
+ return {
+ section,
+ originalSection: originalSection || JSON.parse(JSON.stringify(section)),
+ ...buildCtx,
+ ...(activeEditCtx?.groupContext || {})
+ };
+ }
+
+ function buildAdvancedPersistCallbacks(modalInstance) {
+ return {
+ persistSection: async (sectionData, options = {}) => {
+ if (typeof activeEditCtx?.onSave !== 'function') {
+ throw new Error('No save handler available');
+ }
+ await activeEditCtx.onSave(sectionData, options);
+ },
+ remountAfterPersist: (sectionData) => {
+ remountOpenSectionEditor(modalInstance, sectionData);
+ }
+ };
+ }
+
+ function remountOpenSectionEditor(modalInstance, sectionData) {
+ if (!modalInstance?.dialogContent || !window.KefinHomeScreenAdvancedEditor) return;
+ const section = JSON.parse(JSON.stringify(sectionData || {}));
+ const originalSection = JSON.parse(JSON.stringify(section));
+ if (activeEditCtx) {
+ activeEditCtx.originalSection = originalSection;
+ }
+
+ window.KefinHomeScreenAdvancedEditor.destroyAppearancePickrs?.(modalInstance);
+ modalInstance.dialogContent.innerHTML = buildSectionEditorHTML(section);
+
+ const titleEl = modalInstance.dialogHeader?.querySelector('.dialogTitle, h2');
+ if (titleEl) {
+ // Keep the "Edit Section:" chrome; badge shows the live name
+ titleEl.textContent = `Edit Section: ${section.name || 'Unnamed'}`;
+ }
+
+ // Root listeners stay on dialogContent across innerHTML rebuilds — do not reset
+ // _hsaeRootListenersAttached or they will double-bind.
+ window.KefinHomeScreenAdvancedEditor.attachListeners(
+ modalInstance,
+ buildAdvancedAttachContext(section, originalSection),
+ buildAdvancedPersistCallbacks(modalInstance)
+ );
+ }
+
+ function openSectionEditorModal(section, ctx) {
+ const modalId = SECTION_EDITOR_MODAL_ID;
+
+ // Close if already open
+ if (window.ModalSystem.isOpen(modalId)) {
+ window.ModalSystem.close(modalId);
+ }
+
+ const originalSection = ctx.originalSection || JSON.parse(JSON.stringify(section));
+ if (ctx) ctx.originalSection = originalSection;
+ if (activeEditCtx) activeEditCtx.originalSection = originalSection;
+
+ const content = document.createElement('div');
+ content.innerHTML = buildSectionEditorHTML(section);
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.style.gap = '0.5em';
+ footer.style.display = 'flex';
+ footer.innerHTML = `
+ Preview
+ Cancel
+ Update
+ OK
+ `;
+
+ const modalInstance = window.ModalSystem.create({
+ id: modalId,
+ title: `Edit Section: ${section.name || 'Unnamed'}`,
+ content: content,
+ footer: footer,
+ closeOnBackdrop: false,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onClose: () => {
+ window.KefinHomeScreenAdvancedEditor?.destroyAppearancePickrs?.(modalInstance);
+ activeEditCtx = null;
+ },
+ onOpen: (modalInstance) => {
+ if (modalInstance.dialogContent?.querySelector('.hsae-root') && window.KefinHomeScreenAdvancedEditor) {
+ window.KefinHomeScreenAdvancedEditor.setupModalHeader(modalInstance);
+ window.KefinHomeScreenAdvancedEditor.attachListeners(
+ modalInstance,
+ buildAdvancedAttachContext(section, originalSection),
+ buildAdvancedPersistCallbacks(modalInstance)
+ );
+ }
+
+ // Attach event listeners
+ const saveSectionFromEditor = async () => {
+ const liveOriginal = activeEditCtx?.originalSection || originalSection;
+ const sectionData = collectSectionEditorData(liveOriginal);
+ if (!sectionData) {
+ sectionEditorShowToast('Error collecting section data');
+ return null;
+ }
+ sectionData.id = sectionData.id || liveOriginal?.id || section.id;
+ if (typeof ctx.onSave === 'function') {
+ await ctx.onSave(sectionData);
+ }
+ return sectionData;
+ };
+
+ const saveBtn = modalInstance.dialogFooter.querySelector('.section-save-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ saveBtn.disabled = true;
+ try {
+ const sectionData = await saveSectionFromEditor();
+ if (!sectionData) return;
+ window.ModalSystem.close(modalId);
+ } finally {
+ saveBtn.disabled = false;
+ }
+ });
+ }
+
+ const updateBtn = modalInstance.dialogFooter.querySelector('.section-update-btn');
+ if (updateBtn) {
+ updateBtn.addEventListener('click', async () => {
+ updateBtn.disabled = true;
+ if (saveBtn) saveBtn.disabled = true;
+ try {
+ const sectionData = await saveSectionFromEditor();
+ if (!sectionData) return;
+ const storedId = window.KefinUserHomeScreenConfig?.getStoredSectionPrefId?.(sectionData)
+ || sectionData.id;
+ sectionEditorShowToast('Section saved. Updating users…');
+ await window.KefinUserHomeScreenConfig?.updateUserHomeScreenSectionConfiguration?.(storedId);
+ window.ModalSystem.close(modalId);
+ } catch (e) {
+ console.warn('[KefinTweaks] Failed to update section for all users:', e);
+ sectionEditorShowToast('Error updating users');
+ } finally {
+ updateBtn.disabled = false;
+ if (saveBtn) saveBtn.disabled = false;
+ }
+ });
+ }
+
+ // Delegated toggle-slider handler for section editor (Spotlight Options, Discovery Enabled, etc.)
+ // Single listener on content root so it works when controls are re-rendered and is not re-added
+ const contentRoot = modalInstance.dialogContent;
+ if (contentRoot) {
+ contentRoot.addEventListener('click', (e) => {
+ const btn = e.target.closest('.toggle-slider');
+ if (!btn || !contentRoot.contains(btn)) return;
+ // Advanced editor attaches its own toggle handlers on .hsae-root
+ if (btn.closest('.hsae-root')) return;
+ const checkboxId = btn.dataset.checkboxId;
+ if (!checkboxId) return;
+ e.stopPropagation();
+ const checkbox = document.getElementById(checkboxId);
+ if (checkbox && contentRoot.contains(checkbox)) {
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event('change', { bubbles: true }));
+ updateToggleSliderUI(btn, checkbox.checked);
+ }
+ });
+ }
+
+ // Preview button handler
+ const previewBtn = modalInstance.dialogFooter.querySelector('.section-preview-btn');
+ if (previewBtn && typeof ctx.onPreview === 'function') {
+ previewBtn.addEventListener('click', async () => {
+ const liveOriginal = activeEditCtx?.originalSection || originalSection;
+ const sectionData = collectSectionEditorData(liveOriginal);
+ if (!sectionData) {
+ sectionEditorShowToast('Error collecting section data for preview');
+ return;
+ }
+ sectionData.id = sectionData.id || liveOriginal?.id || section.id;
+ await ctx.onPreview(sectionData, ctx.sectionType, previewBtn);
+ });
+ }
+
+ // Add query button handler (legacy editor only; advanced editor uses .hsae-add-query-btn)
+ const addQueryBtn = modalInstance.dialogContent.querySelector('.add-query-btn');
+ if (addQueryBtn && !modalInstance.dialogContent.querySelector('.hsae-root')) {
+ addQueryBtn.addEventListener('click', () => {
+ const queriesList = modalInstance.dialogContent.querySelector('#queries-list');
+ const newIndex = queriesList.querySelectorAll('.query-editor').length;
+ const newQueryHTML = buildQueryEditor({}, newIndex, true);
+ queriesList.insertAdjacentHTML('beforeend', newQueryHTML);
+
+ // Setup additional options listeners for new query
+ setupAdditionalOptionsListeners(modalInstance, newIndex);
+
+ // Setup badge autocomplete for new query
+ (async () => {
+ const emptyQueryOptions = {};
+ await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'IncludeItemTypes', emptyQueryOptions);
+ await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'Genres', emptyQueryOptions, ['Movie']);
+ await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'Tags', emptyQueryOptions, ['Movie']);
+ // Collections/Playlists will be set up when parent item type is selected
+ })();
+
+ // Setup query type change handler for new query
+ const newQueryEditor = queriesList.querySelector(`.query-editor[data-query-index="${newIndex}"]`);
+ if (newQueryEditor) {
+ const typeSelect = newQueryEditor.querySelector(`#query-${newIndex}-type`);
+ if (typeSelect) {
+ const pathContainer = newQueryEditor.querySelector(`#query-${newIndex}-path-container`);
+ const dataSourceContainer = newQueryEditor.querySelector(`#query-${newIndex}-dataSource-container`);
+
+ typeSelect.addEventListener('change', () => {
+ const newQueryType = typeSelect.value;
+ if (pathContainer) pathContainer.style.display = newQueryType === 'custom' ? 'block' : 'none';
+ if (dataSourceContainer) dataSourceContainer.style.display = newQueryType === 'datasource' ? 'block' : 'none';
+ });
+ }
+ }
+
+ // Re-attach delete handlers for all queries
+ modalInstance.dialogContent.querySelectorAll('.delete-query-btn').forEach(btn => {
+ if (!btn.dataset.listenerAttached) {
+ btn.dataset.listenerAttached = 'true';
+ btn.addEventListener('click', () => {
+ const index = parseInt(btn.dataset.queryIndex, 10);
+ const queryEditor = modalInstance.dialogContent.querySelector(`.query-editor[data-query-index="${index}"]`);
+ if (queryEditor) {
+ queryEditor.remove();
+ // Re-index remaining queries
+ modalInstance.dialogContent.querySelectorAll('.query-editor').forEach((editor, newIndex) => {
+ editor.dataset.queryIndex = newIndex;
+ const titleEl = editor.querySelector('.listItemBodyText');
+ if (titleEl) titleEl.textContent = `Query ${newIndex + 1}`;
+ });
+ // Show/hide merge sorting based on query count
+ const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length;
+ const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]');
+ if (mergeSorting) {
+ mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none';
+ }
+ }
+ });
+ }
+ });
+
+ // Show merge sorting if multiple queries
+ const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length;
+ const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]');
+ if (mergeSorting) {
+ mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none';
+ }
+ });
+ }
+
+ // Delete query button handlers
+ modalInstance.dialogContent.querySelectorAll('.delete-query-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const index = parseInt(btn.dataset.queryIndex, 10);
+ const queryEditor = modalInstance.dialogContent.querySelector(`.query-editor[data-query-index="${index}"]`);
+ if (queryEditor) {
+ queryEditor.remove();
+ // Re-index remaining queries
+ modalInstance.dialogContent.querySelectorAll('.query-editor').forEach((editor, newIndex) => {
+ editor.dataset.queryIndex = newIndex;
+ const titleEl = editor.querySelector('.listItemBodyText');
+ if (titleEl) titleEl.textContent = `Query ${newIndex + 1}`;
+ // Update all input IDs
+ editor.querySelectorAll('input, select, textarea').forEach(input => {
+ const oldId = input.id;
+ if (oldId && oldId.includes(`query-${index}-`)) {
+ input.id = oldId.replace(`query-${index}-`, `query-${newIndex}-`);
+ const label = editor.querySelector(`label[for="${oldId}"]`);
+ if (label) label.setAttribute('for', input.id);
+ }
+ });
+ });
+ // Show/hide merge sorting based on query count
+ const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length;
+ const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]');
+ if (mergeSorting) {
+ mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none';
+ }
+ }
+ });
+ });
+
+ // Handle query type changes to show/hide relevant fields
+ modalInstance.dialogContent.querySelectorAll('[id^="query-"][id$="-type"]').forEach(select => {
+ const queryEditor = select.closest('.query-editor');
+ const queryIndex = queryEditor.dataset.queryIndex;
+
+ // Set initial state
+ const queryType = select.value;
+ const pathContainer = queryEditor.querySelector(`#query-${queryIndex}-path-container`);
+ const dataSourceContainer = queryEditor.querySelector(`#query-${queryIndex}-dataSource-container`);
+
+ if (pathContainer) pathContainer.style.display = queryType === 'custom' ? 'block' : 'none';
+ if (dataSourceContainer) dataSourceContainer.style.display = queryType === 'datasource' ? 'block' : 'none';
+
+ // Handle changes
+ select.addEventListener('change', () => {
+ const newQueryType = select.value;
+ if (pathContainer) pathContainer.style.display = newQueryType === 'custom' ? 'block' : 'none';
+ if (dataSourceContainer) dataSourceContainer.style.display = newQueryType === 'datasource' ? 'block' : 'none';
+ });
+ });
+
+ // Handle parent item type dropdown changes
+ modalInstance.dialogContent.querySelectorAll('[id^="query-"][id$="-parentItemType"]').forEach(select => {
+ const queryEditor = select.closest('.query-editor');
+ const queryIndex = parseInt(queryEditor.dataset.queryIndex, 10);
+
+ const collectionsContainer = queryEditor.querySelector(`#query-${queryIndex}-Collections-container`);
+ const playlistsContainer = queryEditor.querySelector(`#query-${queryIndex}-Playlists-container`);
+ const genericParentContainer = queryEditor.querySelector(`#query-${queryIndex}-GenericParent-container`);
+ const hiddenParentItemType = queryEditor.querySelector(`#query-${queryIndex}-ParentItemType`);
+
+ // Helper function to setup autocomplete for a container
+ const setupAutocompleteForType = async (containerType, container) => {
+ if (!container) {
+ WARN(`Container not found for ${containerType} at query index ${queryIndex}`);
+ return;
+ }
+ // Check if autocomplete is already set up by looking for the autocomplete input
+ const existingInput = container.querySelector('.autocomplete-input');
+ if (existingInput) {
+ LOG(`Autocomplete already set up for ${containerType} at query index ${queryIndex}`);
+ return; // Already set up
+ }
+ LOG(`Setting up autocomplete for ${containerType} at query index ${queryIndex}`);
+
+ // Get query and queryOptions from the section's queries array (passed to openSectionEditor)
+ // Fallback to empty object if not available
+ const query = (section.queries && section.queries[queryIndex]) ? section.queries[queryIndex] : {};
+ const queryOptions = query.queryOptions || {};
+ const queryParentItemType = query._parentItemType || null;
+ await setupQueryBadgeAutocomplete(modalInstance, queryIndex, containerType, queryOptions, null, queryParentItemType);
+ };
+
+ // Set initial state and setup autocomplete if needed
+ const parentItemType = select.value;
+
+ // Set parent item type data attribute
+ const parentElement = select.parentElement?.parentElement;
+ if (parentElement) {
+ parentElement.dataset.parentItemType = parentItemType;
+ }
+
+ // Set initial display states
+ if (collectionsContainer) {
+ collectionsContainer.style.display = parentItemType === 'Collection' ? 'block' : 'none';
+ }
+ if (playlistsContainer) {
+ playlistsContainer.style.display = parentItemType === 'Playlist' ? 'block' : 'none';
+ }
+ if (genericParentContainer) {
+ genericParentContainer.style.display = parentItemType === 'Generic Parent' ? 'block' : 'none';
+ }
+ if (hiddenParentItemType) hiddenParentItemType.value = parentItemType;
+
+ // Setup autocomplete for initially visible containers
+ if (collectionsContainer && parentItemType === 'Collection') {
+ setupAutocompleteForType('Collections', collectionsContainer).catch(err => {
+ ERR('Error setting up Collections autocomplete:', err);
+ });
+ }
+ if (playlistsContainer && parentItemType === 'Playlist') {
+ setupAutocompleteForType('Playlists', playlistsContainer).catch(err => {
+ ERR('Error setting up Playlists autocomplete:', err);
+ });
+ }
+
+ // Handle changes
+ select.addEventListener('change', () => {
+ const newParentItemType = select.value;
+
+ // Select list immediate parent element
+ const parentElement = select.parentElement?.parentElement;
+ if (parentElement) {
+ parentElement.dataset.parentItemType = newParentItemType;
+ }
+
+ // Update display styles synchronously
+ if (collectionsContainer) {
+ collectionsContainer.style.display = newParentItemType === 'Collection' ? 'block' : 'none';
+ }
+ if (playlistsContainer) {
+ playlistsContainer.style.display = newParentItemType === 'Playlist' ? 'block' : 'none';
+ }
+ if (genericParentContainer) {
+ genericParentContainer.style.display = newParentItemType === 'Generic Parent' ? 'block' : 'none';
+ }
+ if (hiddenParentItemType) hiddenParentItemType.value = newParentItemType;
+
+ // Force synchronous repaint by accessing layout property
+ if (collectionsContainer) void collectionsContainer.offsetHeight;
+ if (playlistsContainer) void playlistsContainer.offsetHeight;
+ if (genericParentContainer) void genericParentContainer.offsetHeight;
+
+ // Defer async autocomplete setup to allow browser to paint first
+ requestAnimationFrame(async () => {
+ if (collectionsContainer && newParentItemType === 'Collection') {
+ await setupAutocompleteForType('Collections', collectionsContainer);
+ }
+ if (playlistsContainer && newParentItemType === 'Playlist') {
+ await setupAutocompleteForType('Playlists', playlistsContainer);
+ }
+ });
+ });
+ });
+
+ // Setup additional options listeners for all queries
+ modalInstance.dialogContent.querySelectorAll('.query-editor').forEach(queryEditor => {
+ const queryIndex = parseInt(queryEditor.dataset.queryIndex, 10);
+ setupAdditionalOptionsListeners(modalInstance, queryIndex);
+ });
+
+ // Setup badge autocomplete for all queries (only for custom sections, not default sections)
+ const queriesList = modalInstance.dialogContent.querySelector('#queries-list');
+ if (queriesList) {
+ // This is a custom section with queries editor
+ const queries = section.queries || [{}];
+ queries.forEach(async (query, queryIndex) => {
+ const queryOptions = query.queryOptions || {};
+ const includeItemTypes = queryOptions.IncludeItemTypes;
+ // Always setup: IncludeItemTypes, Genres, Tags
+ await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'IncludeItemTypes', queryOptions, null, query._parentItemType);
+ await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Genres', queryOptions, includeItemTypes, query._parentItemType);
+ await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Tags', queryOptions, includeItemTypes, query._parentItemType);
+
+ // Conditionally setup Collections/Playlists based on parent item type
+ const queryEditor = queriesList.querySelector(`.query-editor[data-query-index="${queryIndex}"]`);
+
+ // Check query level first, then queryOptions for backwards compatibility
+ let parentItemType = query._parentItemType || queryOptions._parentItemType || '';
+
+ if (parentItemType === 'Collection') {
+ await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Collections', queryOptions, null, query._parentItemType);
+ } else if (parentItemType === 'Playlist') {
+ await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Playlists', queryOptions, null, query._parentItemType);
+ }
+ });
+ }
+
+ // Render Mode dropdown - show/hide Card Format and Spotlight Options (section editor)
+ const renderModeSelect = modalInstance.dialogContent.querySelector('#section-renderMode');
+ const cardFormatContainer = modalInstance.dialogContent.querySelector('#section-cardFormat-container');
+ const spotlightOptionsContainer = modalInstance.dialogContent.querySelector('#section-spotlight-options-container');
+ if (renderModeSelect) {
+ const updateRenderModeDependentVisibility = () => {
+ const isSpotlight = renderModeSelect.value === 'Spotlight';
+ if (cardFormatContainer) cardFormatContainer.style.display = isSpotlight ? 'none' : 'block';
+ if (spotlightOptionsContainer) spotlightOptionsContainer.style.display = isSpotlight ? 'block' : 'none';
+ if (isSpotlight && spotlightOptionsContainer) attachSpotlightSettingsListeners(spotlightOptionsContainer, 'section-spotlight-');
+ };
+ renderModeSelect.addEventListener('change', updateRenderModeDependentVisibility);
+ updateRenderModeDependentVisibility(); // Set initial state
+ }
+
+ // Render Mode dropdown - show/hide Spotlight Options (discovery editor)
+ const discoveryRenderModeSelect = modalInstance.dialogContent.querySelector('#discovery-renderMode');
+ const discoverySpotlightOptionsContainer = modalInstance.dialogContent.querySelector('#discovery-spotlight-options-container');
+ if (discoveryRenderModeSelect && discoverySpotlightOptionsContainer) {
+ const updateDiscoverySpotlightVisibility = () => {
+ const isSpotlight = discoveryRenderModeSelect.value === 'Spotlight';
+ discoverySpotlightOptionsContainer.style.display = isSpotlight ? 'block' : 'none';
+ if (isSpotlight) attachSpotlightSettingsListeners(discoverySpotlightOptionsContainer, 'discovery-spotlight-');
+ };
+ discoveryRenderModeSelect.addEventListener('change', updateDiscoverySpotlightVisibility);
+ updateDiscoverySpotlightVisibility(); // Set initial state
+ }
+
+ // Section Group dropdown - show/hide new group input
+ const groupSelect = modalInstance.dialogContent.querySelector('#section-group');
+ const newGroupContainer = modalInstance.dialogContent.querySelector('#section-group-new-container');
+ if (groupSelect && newGroupContainer) {
+ // Set initial state
+ if (groupSelect.value === 'New...') {
+ newGroupContainer.style.display = 'block';
+ }
+ groupSelect.addEventListener('change', () => {
+ newGroupContainer.style.display = groupSelect.value === 'New...' ? 'block' : 'none';
+ });
+ }
+
+ // Enabled toggle buttons handler
+ modalInstance.dialogContent.querySelectorAll('.enabled-toggle-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const enabled = btn.dataset.enabled === 'true';
+ // Update all buttons
+ modalInstance.dialogContent.querySelectorAll('.enabled-toggle-btn').forEach(b => {
+ const isActive = b.dataset.enabled === String(enabled);
+ b.classList.toggle('active', isActive);
+ if (isActive) {
+ b.style.borderColor = enabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.8)';
+ b.style.background = enabled ? 'rgba(0, 164, 220, 0.2)' : 'rgba(158, 158, 158, 0.2)';
+ b.style.color = enabled ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.9)';
+ b.style.fontWeight = '600';
+ } else {
+ b.style.borderColor = 'rgba(255,255,255,0.2)';
+ b.style.background = 'transparent';
+ b.style.color = 'rgba(255,255,255,0.7)';
+ b.style.fontWeight = '400';
+ }
+ });
+ });
+ });
+
+ // Filter by played status toggle handler
+ const filterToggle = modalInstance.dialogContent.querySelector('.filter-played-toggle');
+ if (filterToggle) {
+ filterToggle.addEventListener('click', () => {
+ const currentChecked = filterToggle.dataset.checked === 'true';
+ const newChecked = !currentChecked;
+ filterToggle.dataset.checked = newChecked;
+
+ // Update toggle visual state
+ filterToggle.style.background = newChecked ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)';
+
+ const onText = filterToggle.querySelector('span:first-of-type');
+ if (onText) {
+ onText.style.opacity = newChecked ? '1' : '0';
+ onText.style.left = newChecked ? '8px' : '6px';
+ }
+
+ const offText = filterToggle.querySelector('span:nth-of-type(2)');
+ if (offText) {
+ offText.style.opacity = newChecked ? '0' : '1';
+ offText.style.right = newChecked ? '6px' : '8px';
+ }
+
+ const knob = filterToggle.querySelector('span:last-of-type');
+ if (knob) {
+ knob.style.left = newChecked ? 'calc(100% - 26px)' : '3px';
+ }
+ });
+ }
+
+ const discoveryEnabledToggle = modalInstance.dialogContent.querySelector('.section-discoveryEnabled-toggle');
+ if (discoveryEnabledToggle) {
+ discoveryEnabledToggle.addEventListener('click', () => {
+ const currentChecked = discoveryEnabledToggle.dataset.enabled === 'true';
+ const newChecked = !currentChecked;
+ discoveryEnabledToggle.dataset.enabled = newChecked;
+
+ updateToggleSliderUI(discoveryEnabledToggle, newChecked);
+ });
+ }
+ }
+ });
+ }
+
+ function setupAdditionalOptionsListeners(modalInstance, queryIndex) {
+ const dialog = modalInstance.dialogContainer;
+ const controls = dialog.querySelector(`.query-${queryIndex}-additional-options-controls[data-query-index="${queryIndex}"]`);
+ const listContainer = dialog.querySelector(`.query-${queryIndex}-additional-options-list-container[data-query-index="${queryIndex}"]`);
+
+ if (controls) {
+ const addBtn = controls.querySelector('.add-additional-option');
+ const select = controls.querySelector('.additional-option-select');
+ const list = listContainer ? listContainer.querySelector('.additional-options-list') : null;
+
+ if (addBtn && select && list) {
+ addBtn.addEventListener('click', () => {
+ const key = select.value;
+ if (!key) return;
+
+ const meta = SUPPORTED_QUERY_OPTIONS[key];
+ if (!meta) return;
+
+ const index = list.children.length;
+ const inputId = `query-${queryIndex}-additionalOption-${Date.now()}_${index}`;
+
+ let inputField = '';
+ if (meta.type === 'boolean') {
+ inputField = `
+
+
+
+ ${meta.label}
+
+
+ `;
+ } else {
+ inputField = `
+
+ `;
+ }
+
+ const row = document.createElement('div');
+ row.className = 'additional-option-row';
+ row.style.cssText = 'display: flex; align-items: flex-end; gap: 0.5em; margin-top: 0.5em; padding: 0.5em; background: rgba(255,255,255,0.05); border-radius: 4px;';
+ row.innerHTML = `
+ ${inputField}
+
+ close
+
+ `;
+
+ // Add delete listener for new row
+ row.querySelector('.remove-additional-option').addEventListener('click', () => {
+ row.remove();
+ });
+
+ list.appendChild(row);
+
+ // Reset select
+ select.value = '';
+ });
+ }
+ }
+
+ // Add listeners for existing delete buttons
+ if (listContainer) {
+ listContainer.querySelectorAll('.remove-additional-option').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const row = e.target.closest('.additional-option-row');
+ if (row) row.remove();
+ });
+ });
+ }
+ }
+
+ function buildGlobalSpotlightSettingsHTML(config) {
+ if (!config) return '';
+ const spotlight = config.SPOTLIGHT_SETTINGS || {};
+ const defaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {};
+ const spotlightOpts = { ...defaults, ...spotlight };
+ return `
+
+
+
Default behavior for spotlight sections on the home screen. Per-section overrides can be set when editing a section.
+
+ ${buildSharedSpotlightFieldsHTML(spotlightOpts, 'spotlight-', { useIntervalSeconds: true })}
+
+ `;
+ }
+
+ function attachGlobalSpotlightSettings(container, config) {
+ if (!container) return;
+ container.innerHTML = buildGlobalSpotlightSettingsHTML(config);
+ attachSpotlightSettingsListeners(container, 'spotlight-');
+ }
+
+ function openEditSection(section, ctx) {
+ activeEditCtx = ctx;
+ return openSectionEditorModal(section, ctx);
+ }
+
+ function openCreateSection(section, ctx = {}) {
+ if (!window.ModalSystem) {
+ console.error('[KefinTweaks Section Editor] ModalSystem not available');
+ return null;
+ }
+
+ const { sectionType = 'custom', config = {}, onSave, onPreview } = ctx;
+
+ if (typeof onSave !== 'function') {
+ console.error('[KefinTweaks Section Editor] onSave callback is required');
+ return null;
+ }
+
+ if (window.ModalSystem.isOpen(CREATE_FLOW_MODAL_ID)) {
+ window.ModalSystem.close(CREATE_FLOW_MODAL_ID);
+ }
+
+ let stage = 'mode';
+ let modalInstance = null;
+
+ const wizardState = {
+ step: 'type',
+ maxReachedStepIndex: 0,
+ parentType: null,
+ parents: [],
+ search: '',
+ parentSearchByType: {},
+ parentFilterItemTypesByType: {},
+ parentFetchKeyByType: {},
+ parentItemsByType: {},
+ parentListStatus: 'idle',
+ parentListError: null,
+ personSearchResults: [],
+ customItems: [],
+ externalListUrls: [''],
+ customItemAdditionalExpanded: {},
+ customItemEditorIndex: null,
+ includeItemTypes: [],
+ sortBy: 'Default',
+ sortOrder: 'Descending',
+ unwatchedOnly: false,
+ renderMode: 'Normal',
+ cardFormat: 'Poster',
+ spotlightLayout: 'Border',
+ spotlightSize: 'normal',
+ tileCount: '1',
+ panAnimation: true,
+ sectionName: '',
+ sectionGroupSelect: '',
+ sectionGroupNew: '',
+ order: '',
+ sectionVisibility: 'normal',
+ startDate: '',
+ endDate: '',
+ _config: config
+ };
+
+ let parentSearchDebounceTimer = null;
+ let customImagePreviewTimer = null;
+ let appearancePreviewDebounceTimer = null;
+ let appearancePreviewRequestId = 0;
+ let parentLoadRequestId = 0;
+ let appearancePreviewCache = { queryKey: null, items: null, resolvedSection: null };
+
+ async function loadParentItemsForType(wizardState, root) {
+ if (!wizardState.parentType) return;
+
+ const requestId = ++parentLoadRequestId;
+ wizardState.parentListStatus = 'loading';
+ wizardState.parentListError = null;
+
+ const list = root?.querySelector('#hsse-parent-list');
+ if (list) list.innerHTML = renderParentListHTML(wizardState);
+
+ try {
+ const parentSearch = getParentSearch(wizardState);
+ const parentFilterTypes = getParentFilterItemTypes(wizardState);
+ if (wizardState.parentType === 'Person') {
+ const results = await fetchPersons(parentSearch);
+ if (requestId !== parentLoadRequestId) return;
+ wizardState.personSearchResults = results;
+ } else if (SERVER_SEARCH_PARENT_TYPES.has(wizardState.parentType)) {
+ const items = await fetchWizardParentItems(wizardState.parentType, {
+ search: parentSearch,
+ includeItemTypes: parentFilterTypes
+ });
+ if (requestId !== parentLoadRequestId) return;
+ wizardState.parentItemsByType[wizardState.parentType] = items;
+ } else {
+ const items = await fetchWizardParentItems(wizardState.parentType, {
+ includeItemTypes: parentFilterTypes
+ });
+ if (requestId !== parentLoadRequestId) return;
+ wizardState.parentItemsByType[wizardState.parentType] = items;
+ }
+ markParentFetchKey(wizardState);
+ wizardState.parentListStatus = 'idle';
+ } catch (err) {
+ if (requestId !== parentLoadRequestId) return;
+ wizardState.parentListStatus = 'error';
+ wizardState.parentListError = err.message || 'Failed to load items';
+ console.error('[KefinTweaks Section Editor] Parent load error:', err);
+ }
+
+ const listEl = root?.querySelector('#hsse-parent-list');
+ if (listEl) listEl.innerHTML = renderParentListHTML(wizardState);
+ if (wizardState.parents?.length) scrollSelectedParentIntoView(root);
+ }
+
+ function refreshParentListDisplay(root) {
+ const list = root?.querySelector('#hsse-parent-list');
+ if (list) list.innerHTML = renderParentListHTML(wizardState);
+ }
+
+ function scheduleParentSearch(root) {
+ if (parentSearchDebounceTimer) clearTimeout(parentSearchDebounceTimer);
+
+ if (SERVER_SEARCH_PARENT_TYPES.has(wizardState.parentType)) {
+ parentSearchDebounceTimer = setTimeout(() => {
+ loadParentItemsForType(wizardState, root);
+ }, WIZARD_PARENT_DATA.SEARCH_DEBOUNCE_MS);
+ } else {
+ refreshParentListDisplay(root);
+ }
+ }
+
+ function scheduleAppearancePreview(root) {
+ if (wizardState.step !== 'appearance') return;
+ if (appearancePreviewDebounceTimer) clearTimeout(appearancePreviewDebounceTimer);
+
+ appearancePreviewDebounceTimer = setTimeout(async () => {
+ const container = root?.querySelector('#hsse-appearance-preview-container');
+ if (!container) return;
+
+ const fetchPreview = window.KefinHomeScreen?.fetchSectionPreviewItems;
+ const renderPreview = window.KefinHomeScreen?.renderSectionPreviewInto;
+ const mergeResolved = window.KefinHomeScreen?.mergeDraftWithResolvedPreview;
+ if (typeof fetchPreview !== 'function' || typeof renderPreview !== 'function') {
+ container.innerHTML = 'Preview unavailable.
';
+ return;
+ }
+
+ syncCurrentStepFromForm(root, wizardState);
+ const draft = wizardStateToSection(wizardState, section);
+ const queryKey = getAppearancePreviewQueryKey(wizardState);
+ const requestId = ++appearancePreviewRequestId;
+ const cacheHit = appearancePreviewCache.queryKey === queryKey
+ && appearancePreviewCache.items !== null;
+
+ if (!cacheHit) {
+ container.innerHTML = 'Loading preview…
';
+ }
+
+ try {
+ if (!cacheHit) {
+ const { items, section: resolvedSection } = await fetchPreview(draft, { useSpotlightFields: true });
+ if (requestId !== appearancePreviewRequestId) return;
+ appearancePreviewCache = { queryKey, items, resolvedSection: resolvedSection || null };
+ }
+
+ if (requestId !== appearancePreviewRequestId) return;
+ const previewSection = typeof mergeResolved === 'function'
+ ? mergeResolved(draft, appearancePreviewCache.resolvedSection)
+ : draft;
+ renderPreview(previewSection, appearancePreviewCache.items, container);
+ } catch (err) {
+ if (requestId !== appearancePreviewRequestId) return;
+ appearancePreviewCache = { queryKey: null, items: null, resolvedSection: null };
+ container.innerHTML = `${escapeHtml(err.message || 'Preview failed')}
`;
+ }
+ }, WIZARD_PARENT_DATA.APPEARANCE_PREVIEW_DEBOUNCE_MS);
+ }
+
+ function navigateToStep(root, targetStep) {
+ const targetIndex = getStepIndex(targetStep, wizardState);
+ if (targetIndex < 0 || targetIndex > wizardState.maxReachedStepIndex) return;
+ if (targetStep === wizardState.step) return;
+
+ syncCurrentStepFromForm(root, wizardState);
+ if (wizardState.step === 'customItems' && targetStep !== 'customItems') {
+ wizardState.customItemEditorIndex = null;
+ }
+ wizardState.step = targetStep;
+ refreshModal(root);
+ }
+
+ function advanceToStep(root, nextStep) {
+ if (wizardState.step === 'customItems' && nextStep !== 'customItems') {
+ wizardState.customItemEditorIndex = null;
+ }
+ const nextIndex = getStepIndex(nextStep, wizardState);
+ markStepReached(wizardState, nextIndex);
+ wizardState.step = nextStep;
+ refreshModal(root);
+ }
+
+ function resetModalHeader() {
+ if (!modalInstance?.dialogHeader) return;
+
+ const header = modalInstance.dialogHeader;
+ const closeBtn = header.querySelector('.btnClose');
+ const titleText = header.querySelector('.dialogTitle, h2')?.textContent || 'Create a Section';
+
+ header.querySelector('.hsse-header-main')?.remove();
+ header.querySelector('.hsae-header-main')?.remove();
+ delete header.dataset.hsseHeaderSetup;
+ delete header.dataset.hsaeHeaderSetup;
+
+ if (!header.querySelector('h2')) {
+ const titleEl = document.createElement('h2');
+ titleEl.className = 'dialogTitle';
+ titleEl.textContent = titleText;
+ if (closeBtn) {
+ header.insertBefore(titleEl, closeBtn);
+ } else {
+ header.prepend(titleEl);
+ }
+ }
+ }
+
+ function buildAdvancedGroupContext(sectionDraft) {
+ return {
+ currentGroupName: sectionDraft._targetGroupName || '',
+ existingGroupNames: getExistingGroupNames(config),
+ config,
+ editorProfile: 'full',
+ lockVisibility: false
+ };
+ }
+
+ function persistAdvancedDraft() {
+ const adv = window.KefinHomeScreenAdvancedEditor;
+ if (!adv?.collectData || stage !== 'advanced' || !modalInstance) return;
+
+ const dialog = modalInstance.dialogContainer || modalInstance.dialogContent;
+ const collected = adv.collectData(dialog, section);
+ if (collected) {
+ Object.assign(section, collected);
+ }
+ }
+
+ function mountAdvancedStage(root) {
+ const adv = window.KefinHomeScreenAdvancedEditor;
+ if (!adv?.buildHTML || !modalInstance) return;
+
+ stage = 'advanced';
+ resetModalHeader();
+
+ const groupContext = buildAdvancedGroupContext(section);
+ const originalSection = JSON.parse(JSON.stringify(section));
+
+ modalInstance.dialogContent.innerHTML = adv.buildHTML(section, groupContext);
+
+ if (modalInstance.dialogFooter) {
+ modalInstance.dialogFooter.innerHTML = buildAdvancedCreateFooterHTML(sectionType);
+ modalInstance.dialogFooter.style.display = 'flex';
+ }
+
+ adv.attachListeners(modalInstance, {
+ section,
+ ...groupContext,
+ originalSection,
+ headerTitle: 'Create a Section'
+ }, {
+ onSave: async (sectionData) => {
+ Object.assign(section, sectionData);
+ await onSave(sectionData);
+ window.ModalSystem.close(CREATE_FLOW_MODAL_ID);
+ }
+ });
+
+ const previewBtn = modalInstance.dialogFooter?.querySelector('.section-preview-btn');
+ if (previewBtn && typeof onPreview === 'function' && previewBtn.dataset.listenerAttached !== 'true') {
+ previewBtn.dataset.listenerAttached = 'true';
+ previewBtn.addEventListener('click', async () => {
+ const dialog = modalInstance.dialogContainer || modalInstance.dialogContent;
+ const sectionData = adv.collectData(dialog, section);
+ if (!sectionData) return;
+ await onPreview(sectionData, sectionType, previewBtn);
+ });
+ }
+ }
+
+ function setupModalHeader() {
+ if (!modalInstance?.dialogHeader) return;
+ if (modalInstance.dialogHeader.dataset.hsseHeaderSetup === 'true') return;
+ modalInstance.dialogHeader.dataset.hsseHeaderSetup = 'true';
+
+ const titleEl = modalInstance.dialogHeader.querySelector('h2');
+ const closeBtn = modalInstance.dialogHeader.querySelector('.btnClose');
+
+ const headerMain = document.createElement('div');
+ headerMain.className = 'hsse-header-main';
+
+ const titleWrap = document.createElement('div');
+ titleWrap.className = 'hsse-header-title-wrap';
+ if (titleEl) {
+ titleEl.classList.add('dialogTitle');
+ titleWrap.appendChild(titleEl);
+ }
+
+ headerMain.appendChild(titleWrap);
+
+ if (closeBtn) {
+ modalInstance.dialogHeader.insertBefore(headerMain, closeBtn);
+ } else {
+ modalInstance.dialogHeader.prepend(headerMain);
+ }
+ }
+
+ function updateModalHeader() {
+ if (!modalInstance?.dialogHeader) return;
+ setupModalHeader();
+
+ const titleEl = modalInstance.dialogHeader.querySelector('.dialogTitle, h2');
+ if (titleEl) titleEl.textContent = getModalTitle(stage, wizardState);
+ }
+
+ function refreshModal(root) {
+ if (!modalInstance) return;
+
+ const content = document.createElement('div');
+ content.className = 'hsse-root';
+ content.innerHTML = stage === 'mode' ? renderModeSelectHTML() : renderWizardHTML(wizardState, config);
+
+ modalInstance.dialogContent.innerHTML = '';
+ modalInstance.dialogContent.appendChild(content);
+
+ updateModalHeader();
+
+ if (modalInstance.dialogFooter) {
+ const footerHTML = getFooterHTML(stage, wizardState);
+ modalInstance.dialogFooter.innerHTML = footerHTML;
+ modalInstance.dialogFooter.style.display = footerHTML.trim() ? 'flex' : 'none';
+ }
+
+ const activeRoot = root || modalInstance.dialogContent;
+ attachToggleHandlers(activeRoot);
+
+ if (stage === 'wizard' && wizardState.step === 'parent' && wizardState.parentType) {
+ const cached = wizardState.parentItemsByType[wizardState.parentType];
+ const keyMatches = parentFetchKeyMatches(wizardState);
+ const needsLoad = wizardState.parentType === 'Person'
+ ? !keyMatches || (wizardState.parentListStatus === 'idle' && wizardState.personSearchResults.length === 0)
+ : !keyMatches || (!cached && wizardState.parentListStatus !== 'loading');
+ if (needsLoad || wizardState.parentListStatus === 'error') {
+ loadParentItemsForType(wizardState, activeRoot);
+ } else if (wizardState.parents?.length) {
+ scrollSelectedParentIntoView(activeRoot);
+ }
+ }
+
+ if (stage === 'wizard' && wizardState.step === 'appearance') {
+ scheduleAppearancePreview(activeRoot);
+ }
+
+ if (stage === 'wizard' && wizardState.step === 'customItems') {
+ window.KefinHomeScreenCustomItemsEditor?.refreshPreviews(activeRoot, wizardState, getWizardCustomItemsOptions());
+ }
+ }
+
+ function handleAction(action, el, root) {
+ const wizardCustomActions = new Set([
+ 'wizard-open-custom-item-editor',
+ 'wizard-close-custom-item-editor',
+ 'wizard-add-custom-item',
+ 'wizard-remove-custom-item',
+ 'wizard-toggle-custom-images',
+ 'wizard-toggle-custom-item-hide',
+ 'wizard-move-custom-item-left',
+ 'wizard-move-custom-item-right'
+ ]);
+ if (wizardCustomActions.has(action) && window.KefinHomeScreenCustomItemsEditor) {
+ window.KefinHomeScreenCustomItemsEditor.handleAction(
+ action,
+ el,
+ root,
+ wizardState,
+ getWizardCustomItemsOptions(),
+ () => refreshModal(root)
+ );
+ return;
+ }
+
+ switch (action) {
+ case 'cancel':
+ window.ModalSystem.close(CREATE_FLOW_MODAL_ID);
+ break;
+
+ case 'select-wizard':
+ stage = 'wizard';
+ wizardState.step = 'type';
+ wizardState.maxReachedStepIndex = 0;
+ refreshModal(root);
+ break;
+
+ case 'select-advanced':
+ mountAdvancedStage(root);
+ break;
+
+ case 'advanced-back':
+ persistAdvancedDraft();
+ resetModalHeader();
+ delete modalInstance._hsaeState;
+ stage = 'mode';
+ refreshModal(root);
+ break;
+
+ case 'wizard-select-type': {
+ const selected = el.dataset.type;
+ if (selected !== wizardState.parentType) {
+ wizardState.parentType = selected;
+ wizardState.parents = [];
+ // Restore per-type search; do not share chips/search across Genre/Tag/Studio
+ wizardState.search = getParentSearch(wizardState, selected);
+ wizardState.parentListStatus = 'idle';
+ wizardState.parentListError = null;
+ if (selected !== 'Person') {
+ wizardState.personSearchResults = [];
+ }
+ wizardState.maxReachedStepIndex = 0;
+ if (selected === 'Custom') {
+ wizardState.customItems = [createBlankCustomItem()];
+ wizardState.customItemEditorIndex = null;
+ wizardState.externalListUrls = [''];
+ advanceToStep(root, 'customItems');
+ } else if (selected === 'External') {
+ wizardState.customItems = [];
+ wizardState.externalListUrls = wizardState.externalListUrls?.length
+ ? wizardState.externalListUrls
+ : [''];
+ advanceToStep(root, 'externalList');
+ } else {
+ wizardState.customItems = [];
+ wizardState.externalListUrls = [''];
+ advanceToStep(root, 'parent');
+ }
+ } else if (selected === 'Custom') {
+ wizardState.customItemEditorIndex = null;
+ advanceToStep(root, 'customItems');
+ } else if (selected === 'External') {
+ advanceToStep(root, 'externalList');
+ } else {
+ advanceToStep(root, 'parent');
+ }
+ break;
+ }
+
+ case 'wizard-add-external-list-url': {
+ collectExternalListFromForm(root, wizardState);
+ if (!Array.isArray(wizardState.externalListUrls)) wizardState.externalListUrls = [''];
+ wizardState.externalListUrls.push('');
+ refreshModal(root);
+ break;
+ }
+
+ case 'wizard-open-custom-item-editor': {
+ const openIndex = parseInt(el.dataset.itemIndex, 10);
+ if (Number.isNaN(openIndex)) break;
+ if (wizardState.customItemEditorIndex != null && wizardState.customItemEditorIndex !== openIndex) {
+ collectCustomItemFromForm(root, wizardState, wizardState.customItemEditorIndex);
+ }
+ wizardState.customItemEditorIndex = openIndex;
+ refreshModal(root);
+ break;
+ }
+
+ case 'wizard-close-custom-item-editor': {
+ const discard = el.dataset.discard === 'true';
+ if (!discard && wizardState.customItemEditorIndex != null) {
+ collectCustomItemFromForm(root, wizardState, wizardState.customItemEditorIndex);
+ }
+ wizardState.customItemEditorIndex = null;
+ refreshModal(root);
+ break;
+ }
+
+ case 'wizard-add-custom-item': {
+ collectCustomItemsFromForm(root, wizardState);
+ wizardState.customItems.push(createBlankCustomItem());
+ wizardState.customItemEditorIndex = wizardState.customItems.length - 1;
+ refreshModal(root);
+ break;
+ }
+
+ case 'wizard-remove-custom-item': {
+ const removeIndex = parseInt(el.dataset.itemIndex, 10);
+ if (wizardState.customItems.length <= 1 || Number.isNaN(removeIndex)) break;
+ if (wizardState.customItemEditorIndex === removeIndex) {
+ collectCustomItemFromForm(root, wizardState, removeIndex);
+ } else {
+ collectCustomItemsFromForm(root, wizardState);
+ }
+ const itemToRemove = wizardState.customItems[removeIndex];
+ if (hasCustomItemContent(itemToRemove) && !confirm('Are you sure you want to remove this item?')) {
+ break;
+ }
+ wizardState.customItems.splice(removeIndex, 1);
+ reindexCustomItemAdditionalExpanded(wizardState, removeIndex);
+ if (wizardState.customItemEditorIndex === removeIndex) {
+ wizardState.customItemEditorIndex = null;
+ } else if (wizardState.customItemEditorIndex > removeIndex) {
+ wizardState.customItemEditorIndex -= 1;
+ }
+ refreshModal(root);
+ break;
+ }
+
+ case 'wizard-toggle-custom-images': {
+ const toggleIndex = parseInt(el.dataset.itemIndex, 10);
+ if (Number.isNaN(toggleIndex)) break;
+ collectCustomItemFromForm(root, wizardState, toggleIndex);
+ if (!wizardState.customItemAdditionalExpanded) {
+ wizardState.customItemAdditionalExpanded = {};
+ }
+ const item = wizardState.customItems[toggleIndex];
+ const currentlyExpanded = isCustomItemAdditionalExpanded(wizardState, toggleIndex, item);
+ wizardState.customItemAdditionalExpanded[toggleIndex] = !currentlyExpanded;
+ refreshModal(root);
+ break;
+ }
+
+ case 'wizard-select-parent': {
+ const parentId = el.dataset.parentId;
+ const parentName = el.dataset.parentName;
+ const item = { id: parentId, name: parentName };
+
+ if (isMultiSelectType(wizardState.parentType)) {
+ const idx = wizardState.parents.findIndex(p => p.id === parentId);
+ if (idx >= 0) {
+ wizardState.parents.splice(idx, 1);
+ } else {
+ wizardState.parents.push(item);
+ }
+ el.classList.toggle('hsse-active');
+ break;
+ }
+
+ if (wizardState.parents[0]?.id === parentId) {
+ advanceToStep(root, 'query');
+ break;
+ }
+ wizardState.parents = [item];
+ wizardState.sectionName = buildSectionName(wizardState.parentType, wizardState.parents);
+ advanceToStep(root, 'query');
+ break;
+ }
+
+ case 'wizard-retry-parent-load':
+ loadParentItemsForType(wizardState, root);
+ break;
+
+ case 'wizard-navigate-step':
+ navigateToStep(root, el.dataset.step);
+ break;
+
+ case 'wizard-toggle-item-type': {
+ const type = el.dataset.itemType;
+ const idx = wizardState.includeItemTypes.indexOf(type);
+ if (idx >= 0) {
+ wizardState.includeItemTypes.splice(idx, 1);
+ } else {
+ wizardState.includeItemTypes.push(type);
+ }
+ el.classList.toggle('hsse-active');
+ updateIncludedItemTypesHint(root, wizardState.includeItemTypes);
+ break;
+ }
+
+ case 'wizard-toggle-parent-filter-type': {
+ const type = el.dataset.itemType;
+ const filterTypes = ensureParentFilterItemTypes(wizardState);
+ const idx = filterTypes.indexOf(type);
+ if (idx >= 0) {
+ filterTypes.splice(idx, 1);
+ } else {
+ filterTypes.push(type);
+ }
+ el.classList.toggle('hsse-active');
+ updateIncludedItemTypesHint(root, filterTypes);
+ // Invalidate cached list for this parent type so chips stay in sync with results
+ delete wizardState.parentItemsByType[wizardState.parentType];
+ if (wizardState.parentFetchKeyByType) {
+ delete wizardState.parentFetchKeyByType[wizardState.parentType];
+ }
+ if (wizardState.parentType === 'Person') {
+ wizardState.personSearchResults = [];
+ }
+ loadParentItemsForType(wizardState, root);
+ break;
+ }
+
+ case 'wizard-set-render-mode':
+ wizardState.renderMode = el.dataset.renderMode;
+ if (wizardState.renderMode === 'Random') {
+ applyRandomAppearanceOptions(wizardState);
+ }
+ refreshModal(root);
+ if (wizardState.step === 'appearance') {
+ scheduleAppearancePreview(root);
+ }
+ break;
+
+ case 'wizard-set-section-visibility':
+ syncCurrentStepFromForm(root, wizardState);
+ wizardState.sectionVisibility = el.dataset.visibility || 'normal';
+ refreshModal(root);
+ break;
+
+ case 'wizard-set-card-format':
+ wizardState.cardFormat = el.dataset.cardFormat;
+ root.querySelectorAll('[data-hsse-action="wizard-set-card-format"]').forEach(btn => {
+ btn.classList.toggle('hsse-active', btn === el);
+ });
+ scheduleAppearancePreview(root);
+ break;
+
+ case 'wizard-back':
+ syncCurrentStepFromForm(root, wizardState);
+ if (wizardState.step === 'type') {
+ stage = 'mode';
+ refreshModal(root);
+ } else {
+ const steps = getWizardSteps(wizardState);
+ const idx = getStepIndex(wizardState.step, wizardState);
+ const prevStep = steps[idx - 1];
+ if (wizardState.step === 'customItems') {
+ wizardState.customItemEditorIndex = null;
+ }
+ wizardState.step = prevStep;
+ refreshModal(root);
+ }
+ break;
+
+ case 'wizard-continue': {
+ syncCurrentStepFromForm(root, wizardState);
+ if (wizardState.step === 'parent' && isMultiSelectType(wizardState.parentType)) {
+ if (!wizardState.parents.length) break;
+ wizardState.sectionName = buildSectionName(wizardState.parentType, wizardState.parents);
+ }
+ if (wizardState.step === 'customItems') {
+ if (!hasValidCustomItems(wizardState.customItems)) break;
+ if (!wizardState.sectionName?.trim()) {
+ wizardState.sectionName = 'Custom Section';
+ }
+ }
+ if (wizardState.step === 'externalList') {
+ const urls = (wizardState.externalListUrls || []).map((u) => String(u || '').trim()).filter(Boolean);
+ if (!urls.length) break;
+ wizardState.externalListUrls = urls;
+ if (!wizardState.sectionName?.trim()) {
+ wizardState.sectionName = 'External List';
+ }
+ }
+ if (wizardState.step === 'settings' && wizardState.sectionVisibility === 'seasonal') {
+ if (!hasValidSeasonalDates(wizardState)) break;
+ }
+ const steps = getWizardSteps(wizardState);
+ const idx = getStepIndex(wizardState.step, wizardState);
+ if (idx < steps.length - 1) {
+ advanceToStep(root, steps[idx + 1]);
+ }
+ break;
+ }
+
+ case 'wizard-finish': {
+ syncCurrentStepFromForm(root, wizardState);
+ const draft = wizardStateToSection(wizardState, section);
+ Promise.resolve(onSave(draft)).then(() => {
+ if (modalInstance) {
+ window.ModalSystem.close(CREATE_FLOW_MODAL_ID);
+ }
+ }).catch(err => {
+ console.error('[KefinTweaks Section Editor] Save failed:', err);
+ });
+ break;
+ }
+
+ default:
+ break;
+ }
+ }
+
+ const content = document.createElement('div');
+ content.className = 'hsse-root';
+ content.innerHTML = renderModeSelectHTML();
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.5em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = getFooterHTML('mode', wizardState);
+
+ modalInstance = window.ModalSystem.create({
+ id: CREATE_FLOW_MODAL_ID,
+ title: getModalTitle('mode', wizardState),
+ content: content,
+ footer: footer,
+ closeOnBackdrop: false,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onClose: () => {
+ window.KefinHomeScreenAdvancedEditor?.destroyAppearancePickrs?.(modalInstance);
+ },
+ onOpen: (instance) => {
+ const root = instance.dialogContent;
+
+ attachToggleHandlers(root);
+ setupModalHeader();
+ updateModalHeader();
+
+ root.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-hsse-action]');
+ if (!actionEl || !root.contains(actionEl)) return;
+ handleAction(actionEl.dataset.hsseAction, actionEl, root);
+ });
+
+ root.addEventListener('change', (e) => {
+ if (e.target.id === 'hsse-wizard-sectionGroup') {
+ const container = root.querySelector('#hsse-wizard-sectionGroup-new-container');
+ if (container) {
+ container.style.display = e.target.value === 'New...' ? 'block' : 'none';
+ }
+ }
+ if (wizardState.step === 'appearance' && (
+ e.target.id === 'hsse-wizard-spotlightLayout' ||
+ e.target.id === 'hsse-wizard-spotlightSize' ||
+ e.target.id === 'hsse-wizard-tileCount' ||
+ e.target.id === 'hsse-wizard-animated'
+ )) {
+ collectAppearanceStepFromForm(root, wizardState);
+ scheduleAppearancePreview(root);
+ }
+ });
+
+ root.addEventListener('input', (e) => {
+ if (e.target.id === 'hsse-parent-search') {
+ setParentSearch(wizardState, e.target.value);
+ scheduleParentSearch(root);
+ }
+ if (e.target.matches('[data-hsse-custom-preview]')) {
+ clearTimeout(customImagePreviewTimer);
+ const input = e.target;
+ customImagePreviewTimer = setTimeout(() => {
+ const img = root.querySelector(`img[data-preview-for="${input.id}"]`);
+ updateCustomImagePreview(img, input.value);
+ }, 300);
+ }
+ });
+
+ instance.dialogFooter.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-hsse-action]');
+ if (!actionEl) return;
+ handleAction(actionEl.dataset.hsseAction, actionEl, root);
+ });
+
+ instance.dialogHeader.addEventListener('click', (e) => {
+ const actionEl = e.target.closest('[data-hsse-action]');
+ if (!actionEl) return;
+ handleAction(actionEl.dataset.hsseAction, actionEl, root);
+ });
+ }
+ });
+
+ LOG('Create flow opened for section', section.id);
+ return modalInstance;
+ }
+
+ window.KefinHomeScreenSectionEditor = {
+ openCreateSection,
+ openEditSection,
+ renderHomeScreenSectionEditor: openCreateSection,
+ attachGlobalSpotlightSettings,
+ buildGlobalSpotlightSettingsHTML,
+ collectSpotlightConfigFromDialog,
+ attachSpotlightSettingsListeners,
+ setupAdditionalOptionsListeners,
+ openSelectionPopover,
+ openLibraryItemPickerPopover,
+ fetchLibrarySearchItems,
+ fetchSelectionItems,
+ resolveSelectionItemLabels,
+ getSelectionTypeMeta,
+ ALL_ITEM_TYPES,
+ PARENT_SELECTION_TYPES,
+ FACET_SELECTION_TYPES,
+ buildSelect,
+ buildTextInput,
+ buildToggleSlider,
+ buildToggleCard,
+ buildCheckbox,
+ updateToggleSliderUI,
+ updateToggleSwitchUI
+ };
+
+ window.KefinHomeScreenEditorConstants = {
+ DEFAULT_SECTION_QUERY_LIMIT,
+ SORT_ORDERS,
+ SORT_ORDER_DIRECTIONS,
+ CARD_FORMATS,
+ RENDER_MODE_OPTIONS,
+ SPOTLIGHT_LAYOUT_OPTIONS,
+ SPOTLIGHT_SIZE_OPTIONS,
+ SPOTLIGHT_TILE_COUNT_OPTIONS,
+ SPOTLIGHT_ENTRANCE_OPTIONS,
+ SPOTLIGHT_SLIDE_OPTIONS,
+ SLIDE_STATE_OPTIONS,
+ SUPPORTED_QUERY_OPTIONS
+ };
+
+ LOG('Home Screen Section Editor loaded');
+
+})();
diff --git a/configuration/homescreenBenchmark.js b/configuration/homescreenBenchmark.js
new file mode 100644
index 0000000..c1d3bda
--- /dev/null
+++ b/configuration/homescreenBenchmark.js
@@ -0,0 +1,981 @@
+// Home Screen Section Benchmarks — timed query overview for admin config
+(function () {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinHomeScreenBenchmark]', ...args);
+ const STORAGE_KEY = 'kefin.homeScreen.benchmark.selectedIds';
+ const DEFAULT_QUERY_LIMIT = window.KefinHomeScreenEditorConstants?.DEFAULT_SECTION_QUERY_LIMIT ?? 16;
+
+ const CATEGORIES = [
+ { id: 'default', label: 'Default', groupsKey: 'HOME_SECTION_GROUPS' },
+ { id: 'custom', label: 'Custom', groupsKey: 'CUSTOM_SECTION_GROUPS' },
+ { id: 'discovery', label: 'Discovery', groupsKey: 'DISCOVERY_SECTION_GROUPS' },
+ { id: 'seasonal', label: 'Seasonal', groupsKey: 'SEASONAL_SECTION_GROUPS' }
+ ];
+
+ const MS_BANDS = [
+ { max: 500, className: 'kefin-bench-ms-fast' },
+ { max: 1000, className: 'kefin-bench-ms-mid' },
+ { max: 2000, className: 'kefin-bench-ms-slow' },
+ { max: Infinity, className: 'kefin-bench-ms-worst' }
+ ];
+
+ /** @type {{ aborted: boolean, controller: AbortController|null, onSkipRemaining?: Function } | null} */
+ let activeRun = null;
+ let navCancelBound = false;
+
+ /**
+ * Survives re-renders of the tab so results are not lost when the user
+ * switches tabs or reopens the config modal.
+ * @type {{ entries: Array, results: Map } | null}
+ */
+ let lastRun = null;
+ let lastViewMode = 'picker';
+
+ function hasStoredResults() {
+ return !!lastRun?.entries?.length;
+ }
+
+ function flattenGroups(groups) {
+ if (!Array.isArray(groups)) return [];
+ const out = [];
+ groups.forEach((group) => {
+ if (group && Array.isArray(group.sections)) {
+ out.push(...group.sections);
+ }
+ });
+ return out;
+ }
+
+ function getSectionsForCategories(config, enabledCategoryIds) {
+ const enabled = new Set(enabledCategoryIds);
+ const sections = [];
+ CATEGORIES.forEach((cat) => {
+ if (!enabled.has(cat.id)) return;
+ const groups = config?.[cat.groupsKey] || [];
+ flattenGroups(groups).forEach((section) => {
+ if (!section?.id) return;
+ sections.push({
+ section,
+ category: cat.label,
+ categoryId: cat.id
+ });
+ });
+ });
+ return sections;
+ }
+
+ function loadStoredSelectedIds() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function saveStoredSelectedIds(ids) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
+ } catch (e) {
+ LOG('Could not persist selection', e);
+ }
+ }
+
+ function msBandClass(ms) {
+ if (ms == null || Number.isNaN(ms)) return '';
+ for (const band of MS_BANDS) {
+ if (ms < band.max) return band.className;
+ }
+ return MS_BANDS[MS_BANDS.length - 1].className;
+ }
+
+ function withBenchmarkQueryOptions(query) {
+ const queryOptions = { ...(query?.queryOptions || {}) };
+ if (queryOptions.Limit == null || queryOptions.Limit === '') {
+ queryOptions.Limit = DEFAULT_QUERY_LIMIT;
+ }
+ return { ...query, queryOptions };
+ }
+
+ function uniqueItemsById(items) {
+ const seen = new Set();
+ const out = [];
+ (items || []).forEach((item) => {
+ const id = item?.Id ?? item?.id;
+ if (id != null) {
+ if (seen.has(id)) return;
+ seen.add(id);
+ }
+ out.push(item);
+ });
+ return out;
+ }
+
+ function analyzeItemFields(items) {
+ let hasMediaStreams = false;
+ let hasPeople = false;
+ let hasStudios = false;
+ (items || []).forEach((item) => {
+ if (item?.MediaStreams?.length) hasMediaStreams = true;
+ if (item?.People?.length) hasPeople = true;
+ if (item?.Studios?.length) hasStudios = true;
+ });
+ return { hasMediaStreams, hasPeople, hasStudios };
+ }
+
+ function jsonByteSize(value) {
+ try {
+ const json = typeof value === 'string' ? value : JSON.stringify(value);
+ return new TextEncoder().encode(json || '').byteLength;
+ } catch (e) {
+ return 0;
+ }
+ }
+
+ function formatBytes(bytes) {
+ if (!Number.isFinite(bytes)) return '—';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+ }
+
+ function getAuthHeaders() {
+ if (window.apiHelper?.getAuthHeader) {
+ return {
+ Authorization: window.apiHelper.getAuthHeader(),
+ Accept: 'application/json'
+ };
+ }
+ return { Accept: 'application/json' };
+ }
+
+ async function timedFetchJson(url, signal) {
+ const start = performance.now();
+ const response = await fetch(url, { headers: getAuthHeaders(), signal });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ const text = await response.text();
+ const data = JSON.parse(text);
+ return { data, ms: performance.now() - start, responseBytes: jsonByteSize(text) };
+ }
+
+ async function timedDataSource(dataSource, options) {
+ const start = performance.now();
+ const result = await window.apiHelper.fetchFromDataSource(dataSource, options || {}, false);
+ const ms = performance.now() - start;
+ const items = result?.Items || result || [];
+ return {
+ items: Array.isArray(items) ? items : [],
+ ms,
+ responseBytes: jsonByteSize(result)
+ };
+ }
+
+ async function fetchQueryItems(query, section, signal) {
+ const userId = window.ApiClient?.getCurrentUserId?.();
+ const serverUrl = window.ApiClient?.serverAddress?.();
+ if (!userId || !serverUrl) {
+ throw new Error('ApiClient not ready');
+ }
+
+ const previewQuery = withBenchmarkQueryOptions(query);
+ const useSpotlight =
+ section.renderMode === 'Spotlight' || section.spotlight === true;
+
+ if (previewQuery.dataSource) {
+ return await timedDataSource(previewQuery.dataSource, previewQuery.queryOptions);
+ }
+
+ if (!window.apiHelper?.buildQueryFromSection) {
+ throw new Error('apiHelper.buildQueryFromSection not available');
+ }
+
+ const built = window.apiHelper.buildQueryFromSection(
+ previewQuery,
+ userId,
+ serverUrl,
+ useSpotlight
+ );
+
+ if (built && typeof built === 'object' && built.dataSource) {
+ return await timedDataSource(built.dataSource, built.options);
+ }
+
+ if (!built || typeof built !== 'string') {
+ throw new Error('Could not build query URL');
+ }
+
+ const { data, ms, responseBytes } = await timedFetchJson(built, signal);
+ const items = data?.Items || (Array.isArray(data) ? data : []);
+ return { items, ms, responseBytes };
+ }
+
+ /**
+ * Discovery sections ship as templates without queries: their source (genre, person,
+ * studio, collection, ...) is picked at render time. Reuse the home screen resolver so
+ * the benchmark measures the same queries the home screen would actually issue.
+ */
+ async function resolveSectionQueries(entry) {
+ const section = entry.section;
+ const isTemplate = !section.queries?.length && !section.items?.length;
+ if (!isTemplate || !section.type) return { section, error: null };
+
+ const resolve = window.homeScreen3?.resolveDiscoverySection;
+ if (!resolve) {
+ return { section, error: 'Discovery resolver unavailable' };
+ }
+
+ try {
+ const resolved = await resolve(section, {
+ pairSpotlight: false,
+ resolveViewMore: false
+ });
+ if (!resolved?.queries?.length) {
+ return { section, error: `Could not resolve ${section.type} source` };
+ }
+ return { section: resolved, error: null };
+ } catch (e) {
+ return { section, error: `Source resolve failed: ${e?.message || e}` };
+ }
+ }
+
+ async function benchmarkSection(section, signal) {
+ if (section.items?.length) {
+ return {
+ totalMs: 0,
+ items: section.items,
+ responseBytes: 0,
+ queryMetrics: [],
+ error: null,
+ skippedQueries: true
+ };
+ }
+
+ const queries = section.queries || [];
+ if (!queries.length) {
+ return {
+ totalMs: 0,
+ items: [],
+ responseBytes: 0,
+ queryMetrics: [],
+ error: 'No queries',
+ skippedQueries: true
+ };
+ }
+
+ let totalMs = 0;
+ let responseBytes = 0;
+ const allItems = [];
+ const queryMetrics = [];
+
+ for (const query of queries) {
+ if (signal?.aborted) {
+ return {
+ totalMs,
+ items: allItems,
+ responseBytes,
+ queryMetrics,
+ error: 'Cancelled',
+ cancelled: true
+ };
+ }
+ const result = await fetchQueryItems(query, section, signal);
+ const { items, ms } = result;
+ const queryBytes = result.responseBytes || 0;
+ totalMs += ms;
+ responseBytes += queryBytes;
+ allItems.push(...items);
+ queryMetrics.push({ ms, itemCount: items.length, responseBytes: queryBytes });
+ }
+
+ return {
+ totalMs,
+ items: uniqueItemsById(allItems),
+ responseBytes,
+ queryMetrics,
+ error: null
+ };
+ }
+
+ function chipHtml(on, label) {
+ const cls = on ? 'kefin-bench-chip-on' : 'kefin-bench-chip-off';
+ const text = on ? 'On' : 'Off';
+ return `${label}: ${text} `;
+ }
+
+ function escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function ensureBenchmarkStyles() {
+ if (document.getElementById('kefin-bench-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'kefin-bench-styles';
+ style.textContent = `
+ .kefin-bench-wrap { font-size: 0.95em; }
+ .kefin-bench-cats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1em;
+ margin: 1.25em 0;
+ }
+ .kefin-bench-cat-btn {
+ padding: 0.75em 1.75em;
+ font-size: 1em;
+ line-height: 1.2;
+ border-radius: 999px;
+ border: 2px solid rgba(255,255,255,0.18);
+ background: rgba(255,255,255,0.04);
+ color: rgba(255,255,255,0.5);
+ cursor: pointer;
+ font-weight: 600;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
+ }
+ .kefin-bench-cat-btn:hover:not(:disabled) {
+ color: rgba(255,255,255,0.85);
+ border-color: rgba(255,255,255,0.4);
+ }
+ .kefin-bench-cat-btn.is-active {
+ border-color: var(--theme-primary-color, #00a4dc);
+ color: #fff;
+ background: var(--theme-primary-color, #00a4dc);
+ }
+ .kefin-bench-cat-btn.is-active:hover:not(:disabled) {
+ border-color: var(--theme-primary-color, #00a4dc);
+ color: #fff;
+ filter: brightness(1.1);
+ }
+ .kefin-bench-cat-btn:disabled { opacity: 0.5; cursor: default; }
+ .kefin-bench-actions {
+ display: flex; gap: 0.75em; align-items: center; flex-wrap: wrap; margin-top: 1em;
+ }
+ .kefin-bench-wrap [hidden] { display: none !important; }
+ .kefin-bench-panel { min-height: 120px; }
+ .kefin-bench-picker-toolbar {
+ display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.65em; flex-wrap: wrap;
+ }
+ .kefin-bench-picker-count {
+ font-size: 0.85em; color: rgba(255,255,255,0.55); margin-left: 0.25em;
+ }
+ #kefin-bench-picker-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr 1fr;
+ gap: 0.4em;
+ overflow-y: auto;
+ padding: 0.5em;
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 6px;
+ background: rgba(0,0,0,0.15);
+ }
+ @media (max-width: 1100px) {
+ #kefin-bench-picker-grid { grid-template-columns: 1fr 1fr 1fr; }
+ }
+ @media (max-width: 800px) {
+ #kefin-bench-picker-grid { grid-template-columns: 1fr 1fr; }
+ }
+ @media (max-width: 520px) {
+ #kefin-bench-picker-grid { grid-template-columns: 1fr; }
+ }
+ .kefin-bench-picker-cell {
+ height: 24px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ padding: 0 0.4em;
+ border-radius: 4px;
+ min-width: 0;
+ cursor: pointer;
+ }
+ .kefin-bench-picker-cell:hover { background: rgba(255,255,255,0.07); }
+ .kefin-bench-picker-cell input { flex-shrink: 0; margin: 0; }
+ .kefin-bench-picker-label {
+ flex: 1; min-width: 0;
+ display: flex; align-items: center; gap: 0.4em;
+ overflow: hidden;
+ }
+ .kefin-bench-picker-name {
+ font-size: 1em;
+ color: rgba(255,255,255,0.92);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .kefin-bench-picker-badge {
+ flex-shrink: 0;
+ font-size: 0.65em;
+ padding: 0.1em 0.4em;
+ border-radius: 3px;
+ background: rgba(0,164,220,0.2);
+ color: rgba(255,255,255,0.7);
+ }
+ .kefin-bench-table-wrap { overflow-x: auto; border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; }
+ .kefin-bench-summary-wrap {
+ overflow-x: auto;
+ margin-bottom: 1em;
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 6px;
+ }
+ .kefin-bench-summary-table td {
+ font-size: 1.1em;
+ font-weight: 600;
+ text-align: center;
+ }
+ .kefin-bench-summary-table th { text-align: center; white-space: nowrap; }
+ .kefin-bench-summary-table td[title] { cursor: help; }
+ .kefin-bench-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9em;
+ /* Fixed layout keeps column widths stable as rows fill in */
+ table-layout: fixed;
+ }
+ .kefin-bench-table:not(.kefin-bench-summary-table) { min-width: 780px; }
+ .kefin-bench-table th, .kefin-bench-table td {
+ padding: 0.55em 0.65em; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.08);
+ overflow: hidden; text-overflow: ellipsis;
+ }
+ .kefin-bench-table tbody tr { height: 2.9em; }
+ .kefin-bench-col-name, .kefin-bench-col-plain { white-space: nowrap; }
+ .kefin-bench-table th { font-weight: 600; color: rgba(255,255,255,0.85); background: rgba(0,0,0,0.2); }
+ .kefin-bench-table tr.kefin-bench-row-running td { color: rgba(255,255,255,0.6); }
+ .kefin-bench-ms-fast { background: rgba(47,158,68,0.35); }
+ .kefin-bench-ms-mid { background: rgba(255,212,59,0.25); }
+ .kefin-bench-ms-slow { background: rgba(253,126,20,0.3); }
+ .kefin-bench-ms-worst { background: rgba(224,49,49,0.35); }
+ .kefin-bench-chips { display: flex; flex-wrap: nowrap; gap: 0.35em; }
+ .kefin-bench-chip {
+ font-size: 0.75em; padding: 0.15em 0.45em; border-radius: 4px;
+ border: 1px solid rgba(255,255,255,0.2);
+ white-space: nowrap;
+ }
+ .kefin-bench-chip-on { background: rgba(47,158,68,0.25); }
+ .kefin-bench-chip-off { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); }
+ `;
+ document.head.appendChild(style);
+ }
+
+ function buildPickerGridHtml(sections, checkedSet) {
+ if (!sections.length) {
+ return 'No sections in the selected categories.
';
+ }
+ return sections
+ .map((entry) => {
+ const id = entry.section.id;
+ const checked = checkedSet ? checkedSet.has(id) : true;
+ const name = escapeHtml(entry.section.name || id);
+ const cat = escapeHtml(entry.category);
+ return `
+
+
+
+ ${name}
+ ${cat}
+
+
+ `;
+ })
+ .join('');
+ }
+
+ function buildTableShell() {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ <500ms
+ 500–1000ms
+ 1000–2000ms
+ 2000ms+
+ >100 items
+ >5 MB
+
+
+
+
+ 0
+ 0
+ 0
+ 0
+ 0
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Section
+ Category
+ Time (ms)
+ Response size
+ Items
+ Fields
+ Status
+
+
+
+
+
+ `;
+ }
+
+ function rowHtml(entry, result) {
+ const name = result?.resolvedName || entry.section.name || entry.section.id;
+ const status = result?.status || 'pending';
+ const ms = result?.totalMs;
+ const msClass = status === 'done' ? msBandClass(ms) : '';
+ const msText =
+ status === 'running' ? '…' :
+ status === 'skipped' ? '—' :
+ status === 'error' ? '—' :
+ status === 'done' ? Math.round(ms) : '—';
+
+ const items = result?.itemCount ?? '—';
+ const responseSize = status === 'done' ? formatBytes(result.responseBytes) : '—';
+ const fields =
+ status === 'done'
+ ? `
+ ${chipHtml(result.hasMediaStreams, 'MediaStreams')}
+ ${chipHtml(result.hasPeople, 'People')}
+ ${chipHtml(result.hasStudios, 'Studios')}
+
`
+ : '—';
+
+ const statusText =
+ status === 'running' ? 'Running…' :
+ status === 'skipped' ? 'Skipped' :
+ status === 'error' ? (result.error || 'Error') :
+ status === 'done' ? (result.skippedQueries ? 'Static / no query' : 'OK') : 'Pending';
+
+ return `
+
+ ${escapeHtml(name)}
+ ${escapeHtml(entry.category)}
+ ${msText}
+ ${responseSize}
+ ${items}
+ ${fields}
+ ${escapeHtml(statusText)}
+
+ `;
+ }
+
+ function cancelActiveRun(markSkipped) {
+ if (!activeRun) return;
+ activeRun.aborted = true;
+ if (activeRun.controller) {
+ try {
+ activeRun.controller.abort();
+ } catch (e) { /* ignore */ }
+ }
+ if (markSkipped && activeRun.onSkipRemaining) {
+ activeRun.onSkipRemaining();
+ }
+ activeRun = null;
+ }
+
+ function bindNavCancel() {
+ if (navCancelBound) return;
+ navCancelBound = true;
+
+ const onNavigate = () => {
+ if (!activeRun) return;
+ if (window.location.hash === activeRun.startHash) return;
+ cancelActiveRun(true);
+ };
+
+ window.addEventListener('hashchange', onNavigate);
+ if (window.KefinTweaksUtils?.onViewPage) {
+ window.KefinTweaksUtils.onViewPage(() => onNavigate());
+ }
+ }
+
+ function updateSummaryCells(scopeEl, entries, results) {
+ const buckets = {
+ fast: new Map(),
+ mid: new Map(),
+ slow: new Map(),
+ worst: new Map(),
+ 'large-items': new Map(),
+ 'large-response': new Map()
+ };
+
+ entries.forEach((entry) => {
+ const result = results.get(entry.section.id);
+ if (!result?.queryMetrics?.length) return;
+ const name = result.resolvedName || entry.section.name || entry.section.id;
+
+ const tally = (key) => {
+ buckets[key].set(name, (buckets[key].get(name) || 0) + 1);
+ };
+
+ result.queryMetrics.forEach((query) => {
+ if (query.ms < 500) tally('fast');
+ else if (query.ms < 1000) tally('mid');
+ else if (query.ms < 2000) tally('slow');
+ else tally('worst');
+ if (query.itemCount > 100) tally('large-items');
+ if (query.responseBytes > 5 * 1024 * 1024) tally('large-response');
+ });
+ });
+
+ const summary = scopeEl?.querySelector('.kefin-bench-summary-row');
+ if (!summary) return;
+
+ Object.keys(buckets).forEach((key) => {
+ const cell = summary.querySelector(`[data-summary="${key}"]`);
+ if (!cell) return;
+ const tallied = [...buckets[key].entries()];
+ const total = tallied.reduce((sum, [, count]) => sum + count, 0);
+ cell.textContent = total;
+ if (total) {
+ // One line per section, with a query count when a section contributes more than once
+ cell.title = tallied
+ .map(([name, count]) => (count > 1 ? `${name} (${count} queries)` : name))
+ .join('\n');
+ } else {
+ cell.removeAttribute('title');
+ }
+ });
+ }
+
+ function renderResultsTable(resultsEl, entries, results) {
+ resultsEl.innerHTML = buildTableShell();
+ const tbody = resultsEl.querySelector('.kefin-bench-tbody');
+ tbody.innerHTML = entries
+ .map((entry) => rowHtml(entry, results.get(entry.section.id) || { status: 'pending' }))
+ .join('');
+ updateSummaryCells(resultsEl, entries, results);
+ return tbody;
+ }
+
+ async function runBenchmarks(selectedEntries, tbody, ui, results) {
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ const updateSummary = () => {
+ updateSummaryCells(tbody.closest('#kefin-bench-results'), selectedEntries, results);
+ };
+
+ const updateRow = (entry, result) => {
+ results.set(entry.section.id, result);
+ const tr = tbody.querySelector(`tr[data-section-id="${entry.section.id}"]`);
+ if (tr) {
+ tr.outerHTML = rowHtml(entry, result);
+ }
+ updateSummary();
+ };
+
+ const skipRemaining = () => {
+ selectedEntries.forEach((entry) => {
+ if (results.get(entry.section.id)?.status === 'running' ||
+ !results.has(entry.section.id)) {
+ updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
+ }
+ });
+ ui.setRunning(false);
+ };
+
+ activeRun = {
+ aborted: false,
+ controller,
+ startHash: window.location.hash,
+ onSkipRemaining: skipRemaining
+ };
+
+ ui.setRunning(true);
+
+ for (const entry of selectedEntries) {
+ if (activeRun?.aborted || signal.aborted) {
+ updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
+ continue;
+ }
+
+ updateRow(entry, { status: 'running' });
+
+ try {
+ const resolved = await resolveSectionQueries(entry);
+ if (resolved.error) {
+ updateRow(entry, {
+ status: 'error',
+ error: resolved.error,
+ totalMs: null,
+ itemCount: '—'
+ });
+ continue;
+ }
+
+ const bench = await benchmarkSection(resolved.section, signal);
+ if (activeRun?.aborted || signal.aborted || bench.cancelled) {
+ updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
+ continue;
+ }
+ const fields = analyzeItemFields(bench.items);
+ updateRow(entry, {
+ status: bench.error && !bench.skippedQueries ? 'error' : 'done',
+ resolvedName: resolved.section !== entry.section ? resolved.section.name : null,
+ totalMs: bench.totalMs,
+ itemCount: bench.items.length,
+ hasMediaStreams: fields.hasMediaStreams,
+ hasPeople: fields.hasPeople,
+ hasStudios: fields.hasStudios,
+ responseBytes: bench.responseBytes,
+ queryMetrics: bench.queryMetrics,
+ skippedQueries: bench.skippedQueries,
+ error: bench.error
+ });
+ } catch (e) {
+ if (signal.aborted || activeRun?.aborted) {
+ updateRow(entry, { status: 'skipped', totalMs: null, itemCount: '—' });
+ } else {
+ updateRow(entry, {
+ status: 'error',
+ error: e?.message || String(e),
+ totalMs: null,
+ itemCount: '—'
+ });
+ }
+ }
+
+ await new Promise((r) => setTimeout(r, 0));
+ }
+
+ activeRun = null;
+ ui.setRunning(false);
+ }
+
+ /**
+ * Render benchmark UI into host element.
+ * @param {HTMLElement} hostEl
+ * @param {{ getConfig?: () => Object }} options
+ */
+ function renderHomeScreenBenchmarkUI(hostEl, options = {}) {
+ if (!hostEl) return;
+
+ cancelActiveRun(true);
+
+ const getConfig = options.getConfig || (() => window.KefinHomeScreen?.getConfig?.() || {});
+
+ ensureBenchmarkStyles();
+
+ hostEl.innerHTML = `
+
+
+ Home Screen Section Benchmarks
+
+
+ Test your home sections for slow queries or otherwise unintended results.
+ Timings reflect server round-trip per query (not full card render).
+
+
+
+
+ Run Benchmarks
+ Stop
+ View Results
+ Edit Selection
+
+
+
+ `;
+
+ const catsEl = hostEl.querySelector('#kefin-bench-cats');
+ const runBtn = hostEl.querySelector('#kefin-bench-run');
+ const stopBtn = hostEl.querySelector('#kefin-bench-stop');
+ const viewResultsBtn = hostEl.querySelector('#kefin-bench-view-results');
+ const editBtn = hostEl.querySelector('#kefin-bench-edit');
+ const panelEl = hostEl.querySelector('#kefin-bench-panel');
+ const resultsEl = hostEl.querySelector('#kefin-bench-results');
+
+ const enabledCats = new Set(CATEGORIES.map((c) => c.id));
+
+ function getCurrentSections() {
+ const config = typeof getConfig === 'function' ? getConfig() : {};
+ return getSectionsForCategories(config, [...enabledCats]);
+ }
+
+ function getCheckedIdsFromPanel() {
+ return Array.from(panelEl.querySelectorAll('.kefin-bench-pick:checked'))
+ .map((cb) => cb.value);
+ }
+
+ function updatePickerCount() {
+ const countEl = panelEl.querySelector('.kefin-bench-picker-count');
+ if (!countEl) return;
+ const total = panelEl.querySelectorAll('.kefin-bench-pick').length;
+ const checked = panelEl.querySelectorAll('.kefin-bench-pick:checked').length;
+ countEl.textContent = `${checked} of ${total} selected`;
+ }
+
+ function bindPickerToolbar() {
+ panelEl.querySelector('#kefin-bench-run-top')
+ ?.addEventListener('click', () => startRun());
+ const selectAllBtn = panelEl.querySelector('#kefin-bench-select-all');
+ const deselectAllBtn = panelEl.querySelector('#kefin-bench-deselect-all');
+ selectAllBtn?.addEventListener('click', () => {
+ panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = true; });
+ updatePickerCount();
+ });
+ deselectAllBtn?.addEventListener('click', () => {
+ panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => { cb.checked = false; });
+ updatePickerCount();
+ });
+ panelEl.querySelectorAll('.kefin-bench-pick').forEach((cb) => {
+ cb.addEventListener('change', () => updatePickerCount());
+ });
+ }
+
+ function showPickerPanel(preserveCheckedIds) {
+ const sections = getCurrentSections();
+ const stored = loadStoredSelectedIds();
+ const storedSet = stored ? new Set(stored) : null;
+ const checkedSet = preserveCheckedIds instanceof Set ? preserveCheckedIds : storedSet;
+
+ panelEl.innerHTML = `
+
+ Run Benchmarks
+ Select all
+ Deselect all
+
+
+
+ ${buildPickerGridHtml(sections, checkedSet)}
+
+ `;
+ bindPickerToolbar();
+ updatePickerCount();
+ }
+
+ function showPicker() {
+ cancelActiveRun(true);
+ lastViewMode = 'picker';
+ resultsEl.innerHTML = '';
+ catsEl.hidden = false;
+ panelEl.hidden = false;
+ editBtn.hidden = true;
+ viewResultsBtn.hidden = !hasStoredResults();
+ ui.setRunning(false);
+ }
+
+ function showResults(entries, results) {
+ lastViewMode = 'results';
+ catsEl.hidden = true;
+ panelEl.hidden = true;
+ editBtn.hidden = false;
+ viewResultsBtn.hidden = true;
+ return renderResultsTable(resultsEl, entries, results);
+ }
+
+ const ui = {
+ setRunning(running) {
+ stopBtn.hidden = !running;
+ hostEl.querySelectorAll('.kefin-bench-run-btn').forEach((btn) => {
+ btn.disabled = running;
+ });
+ }
+ };
+
+ function startRun() {
+ if (activeRun) return;
+
+ const sections = getCurrentSections();
+ if (!sections.length) {
+ window.KefinTweaksToaster?.toast?.('No sections in the selected categories.');
+ return;
+ }
+
+ const selectedIds = getCheckedIdsFromPanel();
+ if (!selectedIds.length) {
+ window.KefinTweaksToaster?.toast?.('Select at least one section to benchmark.');
+ return;
+ }
+
+ saveStoredSelectedIds(selectedIds);
+ const idSet = new Set(selectedIds);
+ const selected = sections.filter((e) => idSet.has(e.section.id));
+ if (!selected.length) return;
+
+ lastRun = { entries: selected, results: new Map() };
+ const tbody = showResults(lastRun.entries, lastRun.results);
+ runBenchmarks(lastRun.entries, tbody, ui, lastRun.results);
+ }
+
+ CATEGORIES.forEach((cat) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'kefin-bench-cat-btn is-active';
+ btn.dataset.cat = cat.id;
+ btn.setAttribute('aria-pressed', 'true');
+ btn.textContent = cat.label;
+ btn.addEventListener('click', () => {
+ if (activeRun) return;
+ const prevChecked = new Set(getCheckedIdsFromPanel());
+ if (enabledCats.has(cat.id)) {
+ enabledCats.delete(cat.id);
+ btn.classList.remove('is-active');
+ btn.setAttribute('aria-pressed', 'false');
+ } else {
+ enabledCats.add(cat.id);
+ btn.classList.add('is-active');
+ btn.setAttribute('aria-pressed', 'true');
+ }
+ showPickerPanel(prevChecked);
+ });
+ catsEl.appendChild(btn);
+ });
+
+ stopBtn.addEventListener('click', () => cancelActiveRun(true));
+ editBtn.addEventListener('click', () => showPicker());
+ runBtn.addEventListener('click', () => startRun());
+ viewResultsBtn.addEventListener('click', () => {
+ if (!hasStoredResults()) return;
+ showResults(lastRun.entries, lastRun.results);
+ });
+
+ showPickerPanel();
+
+ if (lastViewMode === 'results' && hasStoredResults()) {
+ showResults(lastRun.entries, lastRun.results);
+ } else {
+ showPicker();
+ }
+ }
+
+ window.KefinHomeScreenBenchmark = {
+ renderHomeScreenBenchmarkUI,
+ cancelActiveRun
+ };
+
+ bindNavCancel();
+
+ LOG('loaded');
+})();
diff --git a/configuration/search-configuration.js b/configuration/search-configuration.js
new file mode 100644
index 0000000..85d23fe
--- /dev/null
+++ b/configuration/search-configuration.js
@@ -0,0 +1,155 @@
+// KefinTweaks Search Configuration UI
+// Modular configuration modal for managing search settings
+
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks Search Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks Search Config]', ...args);
+
+ const MODAL_ID = 'kefin-search-config';
+
+ /**
+ * Load configuration from window.KefinTweaksConfig
+ */
+ async function loadConfig() {
+ const config = window.KefinTweaksConfig || {};
+ return config.search || {};
+ }
+
+ /**
+ * Save configuration to JS Injector
+ */
+ async function saveConfig(searchConfig) {
+ try {
+ // Ensure window.KefinTweaksConfig exists
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+
+ // Update search config
+ window.KefinTweaksConfig.search = searchConfig;
+
+ // Save to JS Injector using utils
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ } else {
+ // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
+ if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ }
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Build configuration HTML
+ */
+ function buildConfigHTML(config) {
+ return `
+
+
+
+
Enable Seerr results from JellyfinEnhanced
+
If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests
+
+
+ Enabled
+
+
+
+
+ `;
+ }
+
+ /**
+ * Collect configuration from form
+ */
+ function collectConfig() {
+ const enableJellyseerr = document.getElementById('search_enableJellyseerr')?.checked === true;
+ return {
+ enableJellyseerr: enableJellyseerr
+ };
+ }
+
+ /**
+ * Open configuration modal
+ */
+ async function openConfigModal() {
+ try {
+ // Load config
+ const config = await loadConfig();
+
+ // Build content
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(config);
+
+ // Build footer
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ // Create modal
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Search Configuration',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onOpen: (modal) => {
+ // Save button
+ const saveBtn = modal.dialogFooter.querySelector('#save-search-config-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const searchConfig = collectConfig();
+ const success = await saveConfig(searchConfig);
+ if (success) {
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Search configuration saved!');
+ }
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ }
+ });
+
+ LOG('Search configuration modal opened');
+ } catch (error) {
+ ERR('Error opening search config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ // Export
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.search = { openConfigModal };
+
+ console.log('[KefinTweaks Search Config] Script loaded');
+
+})();
+
diff --git a/configuration/seriesEpisodes-configuration.js b/configuration/seriesEpisodes-configuration.js
new file mode 100644
index 0000000..824fb1e
--- /dev/null
+++ b/configuration/seriesEpisodes-configuration.js
@@ -0,0 +1,156 @@
+// KefinTweaks Series Episodes Configuration UI
+// Modular configuration modal for managing series episodes settings
+
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks SeriesEpisodes Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes Config]', ...args);
+
+ const MODAL_ID = 'kefin-seriesepisodes-config';
+
+ /**
+ * Load configuration from window.KefinTweaksConfig
+ */
+ async function loadConfig() {
+ const config = window.KefinTweaksConfig || {};
+ return config.flattenSingleSeasonShows || {};
+ }
+
+ /**
+ * Save configuration to JS Injector
+ */
+ async function saveConfig(flattenShowsConfig) {
+ try {
+ // Ensure window.KefinTweaksConfig exists
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+
+ // Update flattenSingleSeasonShows config
+ window.KefinTweaksConfig.flattenSingleSeasonShows = flattenShowsConfig;
+
+ // Save to JS Injector using utils
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ } else {
+ // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
+ if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ }
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Build configuration HTML
+ */
+ function buildConfigHTML(config) {
+ const hideSingleSeasonContainer = config.hideSingleSeasonContainer === true;
+ return `
+
+
+
+
Flatten Single Season Shows
+
When enabled, hides the season container section when a series has only one season, since episodes are already displayed directly.
+
+
+ Enabled
+
+
+
+
+ `;
+ }
+
+ /**
+ * Collect configuration from form
+ */
+ function collectConfig() {
+ const hideSingleSeasonContainer = document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true;
+ return {
+ hideSingleSeasonContainer: hideSingleSeasonContainer
+ };
+ }
+
+ /**
+ * Open configuration modal
+ */
+ async function openConfigModal() {
+ try {
+ // Load config
+ const config = await loadConfig();
+
+ // Build content
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(config);
+
+ // Build footer
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ // Create modal
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Episodes On Series Page Configuration',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onOpen: (modal) => {
+ // Save button
+ const saveBtn = modal.dialogFooter.querySelector('#save-seriesepisodes-config-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const flattenShowsConfig = collectConfig();
+ const success = await saveConfig(flattenShowsConfig);
+ if (success) {
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Configuration saved!');
+ }
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ }
+ });
+
+ LOG('Series Episodes configuration modal opened');
+ } catch (error) {
+ ERR('Error opening series episodes config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ // Export
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.seriesEpisodes = { openConfigModal };
+
+ console.log('[KefinTweaks SeriesEpisodes Config] Script loaded');
+
+})();
+
diff --git a/configuration/seriesInfo-configuration.js b/configuration/seriesInfo-configuration.js
new file mode 100644
index 0000000..eaca85a
--- /dev/null
+++ b/configuration/seriesInfo-configuration.js
@@ -0,0 +1,156 @@
+// KefinTweaks Series Episodes Configuration UI
+// Modular configuration modal for managing series episodes settings
+
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks SeriesInfo Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks SeriesInfo Config]', ...args);
+
+ const MODAL_ID = 'kefin-seriesinfo-config';
+
+ /**
+ * Load configuration from window.KefinTweaksConfig
+ */
+ async function loadConfig() {
+ const config = window.KefinTweaksConfig || {};
+ return config.seriesInfo || {};
+ }
+
+ /**
+ * Save configuration to JS Injector
+ */
+ async function saveConfig(seriesInfoConfig) {
+ try {
+ // Ensure window.KefinTweaksConfig exists
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+
+ // Update flattenSingleSeasonShows config
+ window.KefinTweaksConfig.seriesInfo = seriesInfoConfig;
+
+ // Save to JS Injector using utils
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ } else {
+ // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
+ if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ }
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Build configuration HTML
+ */
+ function buildConfigHTML(config) {
+ const use24HourTimeFormat = config.use24HourTimeFormat === true;
+ return `
+
+
+
+
Use 24 Hour Time Format
+
When enabled, uses a 24 hour time format for the series info.
+
+
+ Enabled
+
+
+
+
+ `;
+ }
+
+ /**
+ * Collect configuration from form
+ */
+ function collectConfig() {
+ const use24HourTimeFormat = document.getElementById('seriesInfo_use24HourTimeFormat')?.checked === true;
+ return {
+ use24HourTimeFormat: use24HourTimeFormat
+ };
+ }
+
+ /**
+ * Open configuration modal
+ */
+ async function openConfigModal() {
+ try {
+ // Load config
+ const config = await loadConfig();
+
+ // Build content
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(config);
+
+ // Build footer
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ // Create modal
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Series Info Configuration',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onOpen: (modal) => {
+ // Save button
+ const saveBtn = modal.dialogFooter.querySelector('#save-seriesinfo-config-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const seriesInfoConfig = collectConfig();
+ const success = await saveConfig(seriesInfoConfig);
+ if (success) {
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Configuration saved!');
+ }
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ }
+ });
+
+ LOG('Series Info configuration modal opened');
+ } catch (error) {
+ ERR('Error opening series info config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ // Export
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.seriesInfo = { openConfigModal };
+
+ console.log('[KefinTweaks SeriesInfo Config] Script loaded');
+
+})();
+
diff --git a/configuration/skinManager-configuration.js b/configuration/skinManager-configuration.js
new file mode 100644
index 0000000..2e4d7e5
--- /dev/null
+++ b/configuration/skinManager-configuration.js
@@ -0,0 +1,699 @@
+// KefinTweaks Skin Manager Configuration UI
+// Modular configuration modal for managing skins, themes, and optional includes
+
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks SkinManager Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks SkinManager Config]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks SkinManager Config]', ...args);
+
+ const MODAL_ID = 'kefin-skinmanager-config';
+
+ // Store loaded config for use in listeners
+ let currentLoadedConfig = null;
+
+ function hasDefaultSkinsCatalog() {
+ const skins = window.KefinTweaksDefaultSkinsConfig?.skins;
+ return Array.isArray(skins) && skins.length > 0;
+ }
+
+ function notifySkinManagerConfig(message) {
+ if (window.KefinTweaksToaster?.toast) {
+ window.KefinTweaksToaster.toast(message);
+ } else {
+ alert(message);
+ }
+ }
+
+ // Helper function to build Jellyfin checkbox
+ function buildJellyfinCheckbox(id, checked, label, dataAttributes = {}) {
+ let className = 'emby-checkbox';
+ const otherAttrs = {};
+
+ for (const [key, value] of Object.entries(dataAttributes)) {
+ if (key === 'class') {
+ className += ' ' + value;
+ } else {
+ otherAttrs[key] = value;
+ }
+ }
+
+ const dataAttrs = Object.entries(otherAttrs).map(([key, value]) => `${key}="${value}"`).join(' ');
+ const checkedAttr = checked ? 'checked' : '';
+ return `
+
+
+
+ ${label}
+
+
+
+
+
+
+ `;
+ }
+
+ // Extract GitHub repository URL from skin configuration
+ function getSkinGitHubUrl(skin) {
+ if (skin.github) {
+ return skin.github;
+ }
+
+ if (skin.url && Array.isArray(skin.url)) {
+ for (const urlObj of skin.url) {
+ if (urlObj.urls && Array.isArray(urlObj.urls)) {
+ for (const url of urlObj.urls) {
+ const match = url.match(/https:\/\/cdn\.jsdelivr\.net\/gh\/([^\/]+)\/([^\/@]+)/);
+ if (match) {
+ const owner = match[1];
+ const repo = match[2];
+ return `https://github.com/${owner}/${repo}`;
+ }
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ // Build skin toggle switches
+ function buildSkinToggles(skins, skinSources = {}) {
+ return skins.map(skin => {
+ const isEnabled = skin.enabled !== false;
+ const githubUrl = getSkinGitHubUrl(skin);
+ const source = skinSources[skin.name] || 'default';
+
+ let borderStyle = '1px solid rgba(255,255,255,0.1)';
+ let backgroundStyle = 'rgba(255,255,255,0.02)';
+ let badgeText = '';
+ let badgeStyle = '';
+ let badgeIcon = '';
+
+ if (source === 'default') {
+ backgroundStyle = 'rgba(255,255,255,0.01)';
+ badgeText = 'D';
+ badgeStyle = 'background: rgba(0,122,255,0.2); color: rgba(0,122,255,0.9);';
+ } else if (source === 'overridden') {
+ borderStyle = '2px solid rgba(0, 122, 255, 0.5)';
+ backgroundStyle = 'rgba(0,122,255,0.05)';
+ badgeIcon = 'warning';
+ badgeStyle = 'color: rgba(255,165,0,0.9);';
+ }
+
+ let tooltipText = '';
+ if (source === 'default') {
+ tooltipText = 'This skin is included automatically by KefinTweaks';
+ } else if (source === 'overridden') {
+ tooltipText = 'This default skin has been overridden by a custom configuration';
+ } else if (githubUrl) {
+ tooltipText = 'View on GitHub';
+ }
+
+ return `
+
+
+
+ ${githubUrl ? `
+
+ ${skin.name}
+ info
+
+ ` : `
+
${skin.name}
+ `}
+ ${badgeIcon ? `
${badgeIcon} ` : ''}
+ ${badgeText ? `
${badgeText} ` : ''}
+
+ ${buildJellyfinCheckbox(`skin_${skin.name.replace(/[^a-zA-Z0-9]/g, '_')}`, isEnabled, '', { 'data-skin-name': skin.name })}
+
+
+ `;
+ }).join('');
+ }
+
+ // Build optional includes skin options
+ function buildOptionalIncludesSkinOptions(skins) {
+ const skinsWithOptionalIncludes = skins.filter(skin =>
+ skin.optionalIncludes && skin.optionalIncludes.length > 0
+ );
+
+ return skinsWithOptionalIncludes.map(skin =>
+ `${skin.name} `
+ ).join('');
+ }
+
+ // Extract author from URL
+ function extractAuthorFromUrl(url) {
+ if (!url) return '';
+ try {
+ const jsdelivrMatch = url.match(/cdn\.jsdelivr\.net\/gh\/([^\/]+)\//);
+ if (jsdelivrMatch) {
+ return jsdelivrMatch[1];
+ }
+ } catch (e) {
+ // Ignore errors
+ }
+ return '';
+ }
+
+ // Generate optional include key
+ function generateOptionalIncludeKey(skinName, author, url) {
+ const normalizedSkinName = skinName === 'global' ? 'global' : skinName.replace(/\s+/g, '_');
+ const filename = url ? url.split('?')[0].split('#')[0].split('/').pop() : '';
+ const normalizedAuthor = (author || '').replace(/\s+/g, '_');
+ return `${normalizedSkinName}-${normalizedAuthor}-${filename}`;
+ }
+
+ // Extract filename from URL
+ function extractFilenameFromUrl(url) {
+ if (!url) return '';
+ try {
+ const urlWithoutParams = url.split('?')[0].split('#')[0];
+ const pathParts = urlWithoutParams.split('/');
+ return pathParts[pathParts.length - 1] || '';
+ } catch (e) {
+ return '';
+ }
+ }
+
+ // Build optional includes editor (simplified version)
+ function buildOptionalIncludesEditor(category, config, skins) {
+ let optionalIncludes = [];
+
+ if (category === 'global') {
+ const defaultGlobalIncludes = window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || [];
+ optionalIncludes = defaultGlobalIncludes.map(include => ({ ...include }));
+
+ const adminOptionalIncludes = config.optionalIncludes || [];
+ adminOptionalIncludes.forEach(adminEntry => {
+ if (typeof adminEntry === 'object' && adminEntry.key) {
+ const keyParts = adminEntry.key.split('-');
+ if (keyParts[0] === 'global' && keyParts.length >= 3) {
+ const author = keyParts[1];
+ const filename = keyParts.slice(2).join('-');
+
+ const matchingInclude = optionalIncludes.find(inc => {
+ const incAuthor = extractAuthorFromUrl(inc.url);
+ const incFilename = extractFilenameFromUrl(inc.url);
+ return incAuthor === author && incFilename === filename;
+ });
+
+ if (matchingInclude) {
+ matchingInclude.enabled = adminEntry.enabled;
+ }
+ }
+ }
+ });
+ } else {
+ const skin = skins.find(s => s.name === category);
+ if (skin && skin.optionalIncludes) {
+ optionalIncludes = skin.optionalIncludes.map(include => {
+ const author = extractAuthorFromUrl(include.url);
+ const key = generateOptionalIncludeKey(category, author, include.url);
+
+ const adminOptionalIncludes = config.optionalIncludes || [];
+ const adminEntry = adminOptionalIncludes.find(entry =>
+ typeof entry === 'object' && entry.key === key
+ );
+
+ return {
+ ...include,
+ key: key,
+ enabled: adminEntry ? adminEntry.enabled : (include.enabled || false)
+ };
+ });
+ }
+ }
+
+ if (optionalIncludes.length === 0) {
+ return `
+
+ No optional CSS modules available for ${category === 'global' ? 'global options' : category}.
+
+ `;
+ }
+
+ return `
+
+ Configure which optional CSS modules are enabled by default. Users can override these settings individually.
+
+
+ ${optionalIncludes.map((include, index) => {
+ const author = extractAuthorFromUrl(include.url);
+ const key = include.key || generateOptionalIncludeKey(category, author, include.url);
+ const filename = include.url ? extractFilenameFromUrl(include.url) : '';
+
+ return `
+
+
+
+
+
${include.name}
+ ${author ? `
` : ''}
+
+
+
+ ${filename ? `
${filename}
` : ''}
+
+
+ `;
+ }).join('')}
+
+ `;
+ }
+
+ /**
+ * Load configuration from window.KefinTweaksConfig
+ */
+ async function loadConfig() {
+ const config = window.KefinTweaksConfig || {};
+
+ // Reconstruct full skin list (enabled + disabled) for configuration
+ const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || [];
+ const adminSkins = config.skins || [];
+
+ const mergedSkins = [];
+
+ // Add all default skins first
+ defaultSkins.forEach(skin => {
+ mergedSkins.push({ ...skin, enabled: true });
+ });
+
+ // Override/Add with admin skins
+ adminSkins.forEach(adminSkin => {
+ const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name);
+ if (existingIndex >= 0) {
+ mergedSkins[existingIndex] = { ...mergedSkins[existingIndex], ...adminSkin };
+ } else {
+ mergedSkins.push({ ...adminSkin });
+ }
+ });
+
+ return {
+ skins: mergedSkins,
+ themes: config.themes || [],
+ defaultSkin: config.defaultSkin || null,
+ optionalIncludes: config.optionalIncludes || []
+ };
+ }
+
+ /**
+ * Save configuration to JS Injector
+ */
+ async function saveConfig(skinManagerConfig) {
+ try {
+ if (!hasDefaultSkinsCatalog()) {
+ WARN('Refusing to save Skin Manager config: default skins catalog not loaded');
+ notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.');
+ return false;
+ }
+
+ const existingSkins = window.KefinTweaksConfig?.skins;
+ const existingNonEmpty = Array.isArray(existingSkins) && existingSkins.length > 0;
+ const nextSkins = skinManagerConfig?.skins;
+ const nextEmpty = !Array.isArray(nextSkins) || nextSkins.length === 0;
+ if (nextEmpty && existingNonEmpty) {
+ WARN('Refusing to save Skin Manager config: would wipe non-empty skins array');
+ notifySkinManagerConfig('Save blocked: collected skins list is empty but existing config has skins. Reload and try again.');
+ return false;
+ }
+
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+
+ // Update skin manager config
+ window.KefinTweaksConfig.skins = skinManagerConfig.skins;
+ window.KefinTweaksConfig.themes = skinManagerConfig.themes;
+ window.KefinTweaksConfig.defaultSkin = skinManagerConfig.defaultSkin;
+ window.KefinTweaksConfig.optionalIncludes = skinManagerConfig.optionalIncludes;
+
+ // Save to JS Injector using utils
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ } else {
+ // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
+ if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ }
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Build configuration HTML
+ */
+ function buildConfigHTML(config) {
+ const allSkinNames = config.skins.map(skin => skin.name).filter(Boolean);
+ const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {};
+ const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden');
+
+ return `
+
+
+
+
+
Default Skin
+
+ ${allSkinNames.map(name => `${name} `).join('')}
+ ${allSkinNames.length === 0 ? 'No skins available ' : ''}
+
+
Select a default skin for all users
+
+
+
+
+
+
+
Enable/Disable Skins
+
Disabled skins will not appear in the appearance dropdowns for users
+
+ All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations.
+ KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility.
+
+
+ ${buildSkinToggles(config.skins, skinSources)}
+
+
+
+
+ ${hasOverriddenSkins ? `
+
+
+
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
+
+ Remove Custom Skins Overriding Defaults
+
+
+
+ ` : ''}
+
+
+
+
+
Optional CSS Modules
+
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
+
Select Skin
+
+ Global (applies to all skins)
+ ${buildOptionalIncludesSkinOptions(config.skins)}
+
+
+ ${buildOptionalIncludesEditor('global', { optionalIncludes: config.optionalIncludes }, config.skins)}
+
+
+
+
+
+
+
+ chevron_right
+ Skins JSON
+
+
+
+
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
+
+
+
+
+
+
+
+
+ chevron_right
+ Themes JSON
+
+
+
+
Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.
+
+
+
+
+
+ `;
+ }
+
+ /**
+ * Collect configuration from form
+ * @returns {Object|null} Collected config, or null if unsafe to save
+ */
+ function collectConfig() {
+ if (!hasDefaultSkinsCatalog()) {
+ WARN('Cannot collect Skin Manager config: default skins catalog not loaded');
+ notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.');
+ return null;
+ }
+
+ // Get default skin
+ let defaultSkin = document.getElementById('defaultSkin')?.value || null;
+ if (defaultSkin === '') {
+ defaultSkin = null;
+ }
+
+ // Get skin enabled states
+ const skinEnabledStates = {};
+ document.querySelectorAll('[data-skin-name]').forEach(checkbox => {
+ const skinName = checkbox.getAttribute('data-skin-name');
+ skinEnabledStates[skinName] = checkbox.checked;
+ });
+
+ // Get default skin names to filter them out
+ const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name));
+
+ // Parse skins JSON
+ let skinsFromJson = [];
+ try {
+ const skinsJsonText = document.getElementById('skinsJson')?.value || '[]';
+ skinsFromJson = JSON.parse(skinsJsonText);
+ } catch (e) {
+ ERR('Error parsing skins JSON:', e);
+ }
+
+ // Only save admin skins (from JSON textarea) - filter out any that match default names
+ const customSkins = skinsFromJson
+ .filter(skin => !defaultSkinNames.has(skin.name))
+ .map(skin => ({
+ ...skin,
+ enabled: skinEnabledStates[skin.name] !== false,
+ hidden: false
+ }));
+
+ // Handle disabled default skins
+ const disabledDefaultSkins = [];
+ defaultSkinNames.forEach(name => {
+ if (skinEnabledStates[name] === false) {
+ disabledDefaultSkins.push({
+ name: name,
+ enabled: false
+ });
+ }
+ });
+
+ // Combine custom skins and disabled default skins
+ const skins = [...customSkins, ...disabledDefaultSkins];
+
+ // Parse themes JSON
+ let themes = [];
+ try {
+ const themesJsonText = document.getElementById('themesJson')?.value || '[]';
+ themes = JSON.parse(themesJsonText);
+ } catch (e) {
+ ERR('Error parsing themes JSON:', e);
+ }
+
+ // Collect optional includes for the currently selected category only
+ const categorySelect = document.getElementById('optionalIncludesCategory');
+ const currentCategory = categorySelect ? categorySelect.value : 'global';
+
+ // Get existing optional includes from config
+ const existingOptionalIncludes = (window.KefinTweaksConfig?.optionalIncludes || []).slice();
+
+ // Collect only the optional includes for the current category
+ const currentCategoryOptionalIncludes = [];
+ document.querySelectorAll('.optionalIncludeCheckbox').forEach(checkbox => {
+ const category = checkbox.getAttribute('data-category');
+ const key = checkbox.getAttribute('data-key');
+ const enabled = checkbox.checked;
+
+ // Only collect if it matches the current category
+ if (category === currentCategory) {
+ currentCategoryOptionalIncludes.push({
+ key: key,
+ enabled: enabled
+ });
+ }
+ });
+
+ // Merge: remove existing entries for this category, then add new ones
+ const optionalIncludes = existingOptionalIncludes.filter(entry => {
+ if (typeof entry === 'object' && entry.key) {
+ const keyParts = entry.key.split('-');
+ const entryCategory = keyParts[0];
+ return entryCategory !== currentCategory;
+ }
+ return true;
+ });
+
+ // Add the current category's optional includes
+ optionalIncludes.push(...currentCategoryOptionalIncludes);
+
+ const existingSkins = window.KefinTweaksConfig?.skins;
+ const existingNonEmpty = Array.isArray(existingSkins) && existingSkins.length > 0;
+ if (skins.length === 0 && existingNonEmpty) {
+ WARN('Cannot collect Skin Manager config: would wipe non-empty skins array');
+ notifySkinManagerConfig('Save blocked: collected skins list is empty but existing config has skins. Reload and try again.');
+ return null;
+ }
+
+ return {
+ skins: skins,
+ themes: themes,
+ defaultSkin: defaultSkin,
+ optionalIncludes: optionalIncludes
+ };
+ }
+
+ /**
+ * Attach event listeners
+ */
+ function attachListeners(modalInstance) {
+ // Optional includes category change
+ const categorySelect = modalInstance.dialogContent.querySelector('#optionalIncludesCategory');
+ if (categorySelect) {
+ categorySelect.addEventListener('change', () => {
+ const category = categorySelect.value;
+ const editor = modalInstance.dialogContent.querySelector('#optionalIncludesEditor');
+ if (editor) {
+ // Use saved config from window.KefinTweaksConfig to get the actual saved optionalIncludes
+ // This ensures the UI reflects the saved state, not just what's currently visible
+ const savedOptionalIncludes = window.KefinTweaksConfig?.optionalIncludes || [];
+ const config = { optionalIncludes: savedOptionalIncludes };
+ const mergedSkins = currentLoadedConfig ? currentLoadedConfig.skins : collectConfig().skins;
+ editor.innerHTML = buildOptionalIncludesEditor(category, config, mergedSkins);
+ attachListeners(modalInstance); // Re-attach listeners for new checkboxes
+ }
+ });
+ }
+
+ // Remove duplicate defaults button
+ const removeBtn = modalInstance.dialogContent.querySelector('#removeDuplicateDefaultsBtn');
+ if (removeBtn) {
+ removeBtn.addEventListener('click', () => {
+ // This would need to be implemented to remove overridden skins
+ alert('This feature will remove custom skins that override defaults. Implementation needed.');
+ });
+ }
+ }
+
+ /**
+ * Open configuration modal
+ */
+ async function openConfigModal() {
+ try {
+ if (!hasDefaultSkinsCatalog()) {
+ notifySkinManagerConfig('Default skins are not loaded yet. Try again in a moment.');
+ return;
+ }
+
+ // Load config
+ const config = await loadConfig();
+
+ // Store loaded config for use in listeners
+ currentLoadedConfig = config;
+
+ // Build content
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(config);
+
+ // Build footer
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ // Create modal
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Skin Manager Configuration',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onOpen: (modal) => {
+ attachListeners(modal);
+
+ // Save button
+ const saveBtn = modal.dialogFooter.querySelector('#save-skinmanager-config-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const skinManagerConfig = collectConfig();
+ if (!skinManagerConfig) return;
+ const success = await saveConfig(skinManagerConfig);
+ if (success) {
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Skin Manager configuration saved!');
+ }
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ }
+ });
+
+ LOG('Skin Manager configuration modal opened');
+ } catch (error) {
+ ERR('Error opening skin manager config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ // Export
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.skinManager = { openConfigModal };
+
+ console.log('[KefinTweaks SkinManager Config] Script loaded');
+
+})();
+
diff --git a/configuration/thumbnailScrubber-configuration.js b/configuration/thumbnailScrubber-configuration.js
new file mode 100644
index 0000000..e89540b
--- /dev/null
+++ b/configuration/thumbnailScrubber-configuration.js
@@ -0,0 +1,294 @@
+// KefinTweaks Thumbnail Scrubber Configuration UI
+// Modular configuration modal for managing thumbnail scrubber settings
+
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks ThumbnailScrubber Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks ThumbnailScrubber Config]', ...args);
+
+ const MODAL_ID = 'kefin-thumbnailscrubber-config';
+
+ /**
+ * Load configuration from window.KefinTweaksConfig
+ */
+ async function loadConfig() {
+ const config = window.KefinTweaksConfig || {};
+ return config.thumbnailScrubber || {};
+ }
+
+ /**
+ * Save configuration to JS Injector
+ */
+ async function saveConfig(thumbnailScrubberConfig) {
+ try {
+ // Ensure window.KefinTweaksConfig exists
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+
+ // Update thumbnailScrubber config
+ window.KefinTweaksConfig.thumbnailScrubber = thumbnailScrubberConfig;
+
+ // Save to JS Injector using utils
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ } else {
+ // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js
+ if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ }
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Build configuration HTML
+ */
+ function buildConfigHTML(config) {
+ const previewOnHover = config.PreviewOnHover === true;
+ const useVideoStream = config.UseVideoStream !== false;
+ let fallbackAfterMs = 500;
+ if (Number.isFinite(config.FallbackAfterMs * 1) && config.FallbackAfterMs >= 0) {
+ fallbackAfterMs = config.FallbackAfterMs * 1;
+ } else if (Number.isFinite(config.FallbackAfterSeconds * 1) && config.FallbackAfterSeconds >= 0) {
+ fallbackAfterMs = config.FallbackAfterSeconds * 1000;
+ }
+ const hoverDelay = Number.isFinite(config.HOVER_PREVIEW_TIMEOUT_MS * 1)
+ ? config.HOVER_PREVIEW_TIMEOUT_MS * 1
+ : (Number.isFinite(config.HOVER_TIMEOUT * 1) ? config.HOVER_TIMEOUT * 1 : 800);
+ const hoverSpeed = Number.isFinite(config.HOVER_PREVIEW_FRAME_MS * 1)
+ ? config.HOVER_PREVIEW_FRAME_MS * 1
+ : 250;
+ const scrubDelay = Number.isFinite(config.SCRUB_ACTIVATION_DELAY_MS * 1)
+ ? config.SCRUB_ACTIVATION_DELAY_MS * 1
+ : 300;
+
+ return `
+
+
+
+
Preview On Hover
+
+ When enabled, hovering over an eligible card will automatically cycle trickplay thumbnails
+ as the card background after a short delay, without needing to scrub in the progress zone.
+
+
+
+ Enabled
+
+
+
+
+
+
+
Use Video Stream Preview
+
+ When enabled, hover preview plays a muted video stream (/Videos/{id}/stream)
+ instead of cycling trickplay images as the card background.
+
+
+
+ Enabled
+
+
+
+
+
+
+
Fallback After (ms)
+
+ When Use Video Stream is on, how long to wait for the direct stream to start playing
+ before falling back to trickplay images.
+ Set to 0 to disable the timeout (play errors still fall back to trickplay).
+
+
+
+
+
+
+
+
Delay Before Previewing On Hover (ms)
+
+ How long the cursor must stay over a card before the passive preview starts.
+ Set to 0 to start immediately when hovering a card.
+
+
+
+
+
+
+
+
Hover Preview Speed (ms between frames)
+
+ Interval in milliseconds between each trickplay frame during hover preview.
+ Leave at 0 to use the default trickplay interval from the server.
+
+
+
+
+
+
+
+
Delay Before Activating Scrubbing (ms)
+
+ How long the cursor must stay in the scrub zone at the bottom of the card before
+ the scrubber UI activates.
+
+
+
+
+
+ `;
+ }
+
+ /**
+ * Collect configuration from form
+ */
+ function collectConfig() {
+ const previewOnHover = document.getElementById('thumbnailScrubber_PreviewOnHover')?.checked === true;
+ const useVideoStream = document.getElementById('thumbnailScrubber_UseVideoStream')?.checked === true;
+ const hoverDelayInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_TIMEOUT_MS');
+ const scrubDelayInput = document.getElementById('thumbnailScrubber_SCRUB_ACTIVATION_DELAY_MS');
+ const hoverSpeedInput = document.getElementById('thumbnailScrubber_HOVER_PREVIEW_FRAME_MS');
+ const fallbackAfterInput = document.getElementById('thumbnailScrubber_FallbackAfterMs');
+
+ let hoverDelay = hoverDelayInput ? parseInt(hoverDelayInput.value, 10) : 800;
+ if (!Number.isFinite(hoverDelay) || hoverDelay < 0) {
+ hoverDelay = 800;
+ }
+
+ let scrubDelay = scrubDelayInput ? parseInt(scrubDelayInput.value, 10) : 300;
+ if (!Number.isFinite(scrubDelay) || scrubDelay < 0) {
+ scrubDelay = 300;
+ }
+
+ let hoverSpeed = hoverSpeedInput ? parseInt(hoverSpeedInput.value, 10) : 0;
+ if (!Number.isFinite(hoverSpeed) || hoverSpeed < 0) {
+ hoverSpeed = 0;
+ }
+
+ let fallbackAfterMs = fallbackAfterInput ? parseInt(fallbackAfterInput.value, 10) : 500;
+ if (!Number.isFinite(fallbackAfterMs) || fallbackAfterMs < 0) {
+ fallbackAfterMs = 500;
+ }
+
+ return {
+ PreviewOnHover: previewOnHover,
+ UseVideoStream: useVideoStream,
+ FallbackAfterMs: fallbackAfterMs,
+ // Delay before previewing on hover
+ HOVER_PREVIEW_TIMEOUT_MS: hoverDelay,
+ HOVER_TIMEOUT: hoverDelay, // legacy key
+ // Hover preview speed (ms between frames)
+ HOVER_PREVIEW_FRAME_MS: hoverSpeed,
+ // Scrub activation delay
+ SCRUB_ACTIVATION_DELAY_MS: scrubDelay
+ };
+ }
+
+ /**
+ * Open configuration modal
+ */
+ async function openConfigModal() {
+ try {
+ // Load config
+ const config = await loadConfig();
+
+ // Build content
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(config);
+
+ // Build footer
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ // Create modal
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Thumbnail Scrubber Configuration',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '800px', height: 'auto' }
+ : undefined,
+ onOpen: (modal) => {
+ // Save button
+ const saveBtn = modal.dialogFooter.querySelector('#save-thumbnailscrubber-config-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const thumbnailScrubberConfig = collectConfig();
+ const success = await saveConfig(thumbnailScrubberConfig);
+ if (success) {
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Thumbnail Scrubber configuration saved!');
+ }
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ }
+ });
+
+ LOG('Thumbnail Scrubber configuration modal opened');
+ } catch (error) {
+ ERR('Error opening thumbnail scrubber config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ // Export
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.thumbnailScrubber = { openConfigModal };
+
+ console.log('[KefinTweaks ThumbnailScrubber Config] Script loaded');
+
+})();
+
diff --git a/configuration/userManager-configuration.js b/configuration/userManager-configuration.js
new file mode 100644
index 0000000..782f7a3
--- /dev/null
+++ b/configuration/userManager-configuration.js
@@ -0,0 +1,868 @@
+// KefinTweaks User Manager Configuration UI — User Templates
+
+(function () {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks UserManager Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks UserManager Config]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks UserManager Config]', ...args);
+
+ const CONFIG_MODAL_ID = 'kefin-usermanager-config';
+ const EDITOR_MODAL_ID = 'kefin-usermanager-template-editor';
+ const TAG_MODAL_ID = 'kefin-usermanager-tag-modal';
+ const SCHEDULE_MODAL_ID = 'kefin-usermanager-schedule-modal';
+
+ const DEFAULT_AUTH = 'Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider';
+ const DEFAULT_PASSWORD_RESET = 'Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider';
+
+ const UNRATED_TYPES = [
+ { type: 'Book', label: 'Books' },
+ { type: 'ChannelContent', label: 'Channels' },
+ { type: 'LiveTvChannel', label: 'Live TV' },
+ { type: 'Movie', label: 'Movies' },
+ { type: 'Music', label: 'Music' },
+ { type: 'Trailer', label: 'Trailers' },
+ { type: 'Series', label: 'Shows' }
+ ];
+
+ const DAYS = [
+ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
+ 'Everyday', 'Weekday', 'Weekend'
+ ];
+
+ function escapeHtml(str) {
+ return String(str ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function clone(obj) {
+ return JSON.parse(JSON.stringify(obj ?? {}));
+ }
+
+ function getSeedPolicy() {
+ return {
+ IsAdministrator: false,
+ IsHidden: false,
+ EnableCollectionManagement: false,
+ EnableSubtitleManagement: false,
+ EnableLyricManagement: false,
+ IsDisabled: false,
+ BlockedTags: [],
+ AllowedTags: [],
+ EnableUserPreferenceAccess: true,
+ AccessSchedules: [],
+ BlockUnratedItems: [],
+ EnableRemoteControlOfOtherUsers: false,
+ EnableSharedDeviceControl: true,
+ EnableRemoteAccess: true,
+ EnableLiveTvManagement: true,
+ EnableLiveTvAccess: true,
+ EnableMediaPlayback: true,
+ EnableAudioPlaybackTranscoding: true,
+ EnableVideoPlaybackTranscoding: true,
+ EnablePlaybackRemuxing: true,
+ ForceRemoteSourceTranscoding: false,
+ EnableContentDeletion: false,
+ EnableContentDeletionFromFolders: [],
+ EnableContentDownloading: true,
+ EnableSyncTranscoding: true,
+ EnableMediaConversion: true,
+ EnabledDevices: [],
+ EnableAllDevices: true,
+ EnabledChannels: [],
+ EnableAllChannels: true,
+ EnabledFolders: [],
+ EnableAllFolders: true,
+ InvalidLoginAttemptCount: 0,
+ LoginAttemptsBeforeLockout: -1,
+ MaxActiveSessions: 0,
+ BlockedMediaFolders: [],
+ BlockedChannels: [],
+ RemoteClientBitrateLimit: 0,
+ AuthenticationProviderId: DEFAULT_AUTH,
+ PasswordResetProviderId: DEFAULT_PASSWORD_RESET,
+ SyncPlayAccess: 'CreateAndJoinGroups'
+ };
+ }
+
+ function normalizeConfig(raw) {
+ const cfg = raw && typeof raw === 'object' ? raw : {};
+ const templates = Array.isArray(cfg.templates)
+ ? cfg.templates.filter((t) => t && typeof t.name === 'string' && t.name.trim())
+ : [];
+ let defaultTemplateName = typeof cfg.defaultTemplateName === 'string' ? cfg.defaultTemplateName : '';
+ if (defaultTemplateName && !templates.some((t) => t.name === defaultTemplateName)) {
+ defaultTemplateName = '';
+ }
+ return { defaultTemplateName, templates };
+ }
+
+ function loadConfig() {
+ return normalizeConfig(window.KefinTweaksConfig?.userManager);
+ }
+
+ async function saveConfig(next) {
+ try {
+ if (!window.KefinTweaksConfig) window.KefinTweaksConfig = {};
+ window.KefinTweaksConfig.userManager = normalizeConfig(next);
+
+ if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved');
+ return true;
+ }
+ if (window.KefinTweaksConfiguration?.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ } catch (e) {
+ ERR('Error saving config:', e);
+ return false;
+ }
+ }
+
+ async function fetchMediaFolders() {
+ try {
+ const url = ApiClient.getUrl('Library/MediaFolders');
+ const data = await ApiClient.getJSON(url);
+ return Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
+ } catch (e) {
+ WARN('Failed to load media folders', e);
+ return [];
+ }
+ }
+
+ function buildCheckbox(id, checked, label, options) {
+ return window.KefinTweaksUI?.buildCheckbox?.(id, checked, label, options) || '';
+ }
+
+ function hourOptions(selected) {
+ const opts = [];
+ for (let h = 0; h <= 24; h += 0.5) {
+ const hours = Math.floor(h) % 12 || 12;
+ const mins = h % 1 ? '30' : '00';
+ const ampm = h < 12 || h === 24 ? 'AM' : 'PM';
+ const label = h === 24 ? '12:00 AM' : `${hours}:${mins} ${ampm}`;
+ opts.push(`${label} `);
+ }
+ return opts.join('');
+ }
+
+ function formatScheduleTime(h) {
+ const n = Number(h);
+ if (!Number.isFinite(n)) return '';
+ if (n === 24) return '12:00 AM';
+ const hours = Math.floor(n) % 12 || 12;
+ const mins = n % 1 ? '30' : '00';
+ const ampm = n < 12 ? 'AM' : 'PM';
+ return `${hours}:${mins} ${ampm}`;
+ }
+
+ function bitrateToMbps(bps) {
+ const n = Number(bps) || 0;
+ if (n <= 0) return '';
+ return String(Math.round((n / 1000000) * 100) / 100);
+ }
+
+ function mbpsToBitrate(mbps) {
+ const n = parseFloat(mbps);
+ if (!Number.isFinite(n) || n <= 0) return 0;
+ return Math.round(n * 1000000);
+ }
+
+ function buildTagListHTML(tags, listClass) {
+ if (!tags?.length) {
+ return `No tags
`;
+ }
+ return tags.map((tag, i) => `
+
+
${escapeHtml(tag)}
+
+ delete
+
+
`).join('');
+ }
+
+ function buildScheduleListHTML(schedules) {
+ if (!schedules?.length) {
+ return `No access schedules
`;
+ }
+ return schedules.map((s, i) => `
+
+
+
${escapeHtml(s.DayOfWeek)}
+
${formatScheduleTime(s.StartHour)} - ${formatScheduleTime(s.EndHour)}
+
+
+ delete
+
+
`).join('');
+ }
+
+ function buildEditorHTML(template, folders) {
+ const policy = { ...getSeedPolicy(), ...(template?.policy || {}) };
+ const name = template?.name || '';
+ const enabledFolders = new Set(policy.EnabledFolders || []);
+ const deleteFolders = new Set(policy.EnableContentDeletionFromFolders || []);
+ const blockUnrated = new Set(policy.BlockUnratedItems || []);
+ const allowedTags = policy.AllowedTags || [];
+ const blockedTags = policy.BlockedTags || [];
+ const schedules = policy.AccessSchedules || [];
+
+ const folderChecks = folders.map((f) => {
+ const id = f.Id || f.id;
+ const label = f.Name || f.name || id;
+ return buildCheckbox('', enabledFolders.has(id), label, {
+ className: 'um-folder',
+ wrapInSection: true,
+ dataAttributes: { id }
+ });
+ }).join('');
+
+ const deleteFolderChecks = folders.map((f) => {
+ const id = f.Id || f.id;
+ const label = f.Name || f.name || id;
+ return buildCheckbox('', deleteFolders.has(id), label, {
+ className: 'um-delete-folder',
+ wrapInSection: true,
+ dataAttributes: { id }
+ });
+ }).join('');
+
+ const unratedChecks = UNRATED_TYPES.map((u) =>
+ buildCheckbox('', blockUnrated.has(u.type), u.label, {
+ className: 'um-unrated',
+ wrapInSection: true,
+ dataAttributes: { itemtype: u.type }
+ })
+ ).join('');
+
+ return `
+
+
+ Template name
+
+
+
+
+
Permissions
+
+ ${buildCheckbox('', !!policy.EnableRemoteAccess, 'Allow remote connections to this server', { className: 'um-chk-remote', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.IsAdministrator, 'Allow this user to manage the server', { className: 'um-chk-admin', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableCollectionManagement, 'Allow this user to manage collections', { className: 'um-chk-collections', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableSubtitleManagement, 'Allow this user to edit subtitles', { className: 'um-chk-subtitles', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableLyricManagement, 'Allow this user to manage lyrics', { className: 'um-chk-lyrics', wrapInSection: true })}
+
+
+
+
+
Feature access
+
+ ${buildCheckbox('', !!policy.EnableLiveTvAccess, 'Allow Live TV access', { className: 'um-chk-livetv', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableLiveTvManagement, 'Allow Live TV recording management', { className: 'um-chk-livetv-mgmt', wrapInSection: true })}
+
+
+
+
+
Media playback
+
+ ${buildCheckbox('', !!policy.EnableMediaPlayback, 'Allow media playback', { className: 'um-chk-playback', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableAudioPlaybackTranscoding, 'Allow audio playback that requires transcoding', { className: 'um-chk-audio-tc', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableVideoPlaybackTranscoding, 'Allow video playback that requires transcoding', { className: 'um-chk-video-tc', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnablePlaybackRemuxing, 'Allow video playback that requires conversion without re-encoding', { className: 'um-chk-remux', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.ForceRemoteSourceTranscoding, 'Force transcoding of remote media sources such as Live TV', { className: 'um-chk-force-remote-tc', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableSyncTranscoding, 'Allow sync transcoding', { className: 'um-chk-sync-tc', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableMediaConversion, 'Allow media conversion', { className: 'um-chk-conversion', wrapInSection: true })}
+
+
+
+ SyncPlay access
+
+ Allow user to create and join groups
+ Allow user to join groups
+ Disabled for this user
+
+
+
+
+
+
Allow media deletion from
+
+ ${buildCheckbox('', !!policy.EnableContentDeletion, 'All libraries', { className: 'um-chk-delete-all', wrapInSection: true })}
+
+ ${deleteFolderChecks || '
No libraries found
'}
+
+
+
+
+
+
Remote Control
+
+ ${buildCheckbox('', !!policy.EnableRemoteControlOfOtherUsers, 'Allow remote control of other users', { className: 'um-chk-remote-others', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableSharedDeviceControl, 'Allow remote control of shared devices', { className: 'um-chk-remote-shared', wrapInSection: true })}
+
+
+
+
+
Other
+ ${buildCheckbox('', !!policy.EnableContentDownloading, 'Allow media downloads', { className: 'um-chk-download', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.EnableUserPreferenceAccess, 'Allow user preference access', { className: 'um-chk-prefs', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.IsDisabled, 'Disable this user', { className: 'um-chk-disabled', wrapInSection: true })}
+ ${buildCheckbox('', !!policy.IsHidden, 'Hide this user from login screens', { className: 'um-chk-hidden', wrapInSection: true })}
+
+
+
+
+
+
Library Access
+ ${buildCheckbox('', !!policy.EnableAllFolders, 'Enable access to all libraries', { className: 'um-chk-all-folders', wrapInSection: true })}
+
+
Libraries
+
+ ${folderChecks || '
No libraries found
'}
+
+
+
+
+
+
Block items with no or unrecognized rating information
+
${unratedChecks}
+
+
+
+
+
Allow items with tags
+
+ add
+
+
+
Only show media with at least one of the specified tags.
+
${buildTagListHTML(allowedTags, 'allowed')}
+
+
+
+
+
Block items with tags
+
+ add
+
+
+
Hide media with at least one of the specified tags.
+
${buildTagListHTML(blockedTags, 'blocked')}
+
+
+
+
+
Access Schedule
+
+ add
+
+
+
Limit access to certain hours.
+
${buildScheduleListHTML(schedules)}
+
+
+ `;
+ }
+
+ function collectCheckedIds(root, selector) {
+ return Array.from(root.querySelectorAll(`${selector}:checked`))
+ .map((el) => el.getAttribute('data-id'))
+ .filter(Boolean);
+ }
+
+ function collectPolicyFromEditor(root, state) {
+ const enableAllFolders = !!root.querySelector('.um-chk-all-folders')?.checked;
+ const enableContentDeletion = !!root.querySelector('.um-chk-delete-all')?.checked;
+
+ return {
+ ...getSeedPolicy(),
+ IsAdministrator: !!root.querySelector('.um-chk-admin')?.checked,
+ IsHidden: !!root.querySelector('.um-chk-hidden')?.checked,
+ EnableCollectionManagement: !!root.querySelector('.um-chk-collections')?.checked,
+ EnableSubtitleManagement: !!root.querySelector('.um-chk-subtitles')?.checked,
+ EnableLyricManagement: !!root.querySelector('.um-chk-lyrics')?.checked,
+ IsDisabled: !!root.querySelector('.um-chk-disabled')?.checked,
+ BlockedTags: state.blockedTags.slice(),
+ AllowedTags: state.allowedTags.slice(),
+ EnableUserPreferenceAccess: !!root.querySelector('.um-chk-prefs')?.checked,
+ AccessSchedules: state.schedules.map((s) => ({ ...s })),
+ BlockUnratedItems: Array.from(root.querySelectorAll('.um-unrated:checked'))
+ .map((el) => el.getAttribute('data-itemtype'))
+ .filter(Boolean),
+ EnableRemoteControlOfOtherUsers: !!root.querySelector('.um-chk-remote-others')?.checked,
+ EnableSharedDeviceControl: !!root.querySelector('.um-chk-remote-shared')?.checked,
+ EnableRemoteAccess: !!root.querySelector('.um-chk-remote')?.checked,
+ EnableLiveTvManagement: !!root.querySelector('.um-chk-livetv-mgmt')?.checked,
+ EnableLiveTvAccess: !!root.querySelector('.um-chk-livetv')?.checked,
+ EnableMediaPlayback: !!root.querySelector('.um-chk-playback')?.checked,
+ EnableAudioPlaybackTranscoding: !!root.querySelector('.um-chk-audio-tc')?.checked,
+ EnableVideoPlaybackTranscoding: !!root.querySelector('.um-chk-video-tc')?.checked,
+ EnablePlaybackRemuxing: !!root.querySelector('.um-chk-remux')?.checked,
+ ForceRemoteSourceTranscoding: !!root.querySelector('.um-chk-force-remote-tc')?.checked,
+ EnableContentDeletion: enableContentDeletion,
+ EnableContentDeletionFromFolders: enableContentDeletion
+ ? []
+ : collectCheckedIds(root, '.um-delete-folder'),
+ EnableContentDownloading: !!root.querySelector('.um-chk-download')?.checked,
+ EnableSyncTranscoding: !!root.querySelector('.um-chk-sync-tc')?.checked,
+ EnableMediaConversion: !!root.querySelector('.um-chk-conversion')?.checked,
+ EnabledDevices: [],
+ EnableAllDevices: true,
+ EnabledChannels: [],
+ EnableAllChannels: true,
+ EnabledFolders: enableAllFolders ? [] : collectCheckedIds(root, '.um-folder'),
+ EnableAllFolders: enableAllFolders,
+ LoginAttemptsBeforeLockout: parseInt(root.querySelector('#um-lockout')?.value ?? '-1', 10),
+ MaxActiveSessions: parseInt(root.querySelector('#um-max-sessions')?.value ?? '0', 10),
+ RemoteClientBitrateLimit: mbpsToBitrate(root.querySelector('#um-bitrate')?.value),
+ AuthenticationProviderId: DEFAULT_AUTH,
+ PasswordResetProviderId: DEFAULT_PASSWORD_RESET,
+ SyncPlayAccess: root.querySelector('#um-syncplay')?.value || 'CreateAndJoinGroups'
+ };
+ }
+
+ function promptText(title, placeholder) {
+ return new Promise((resolve) => {
+ const content = document.createElement('div');
+ content.innerHTML = `
+
+ ${escapeHtml(title)}
+
+
`;
+ const footer = document.createElement('div');
+ footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
+ footer.innerHTML = `
+ Cancel
+ Add `;
+
+ const modal = window.ModalSystem.create({
+ id: TAG_MODAL_ID,
+ title,
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: { maxWidth: '420px', width: '90vw' },
+ onOpen: (m) => {
+ const input = m.dialogContent.querySelector('#um-tag-input');
+ const finish = (val) => {
+ window.ModalSystem.close(TAG_MODAL_ID);
+ resolve(val);
+ };
+ m.dialogFooter.querySelector('#um-tag-cancel')?.addEventListener('click', () => finish(null));
+ m.dialogFooter.querySelector('#um-tag-ok')?.addEventListener('click', () => {
+ finish(input?.value?.trim() || null);
+ });
+ input?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ finish(input.value.trim() || null);
+ }
+ });
+ setTimeout(() => input?.focus(), 50);
+ },
+ onClose: () => resolve(null)
+ });
+ });
+ }
+
+ function promptSchedule() {
+ return new Promise((resolve) => {
+ const dayOpts = DAYS.map((d) => `${d === 'Everyday' ? 'Every day' : d === 'Weekday' ? 'Weekdays' : d === 'Weekend' ? 'Weekends' : d} `).join('');
+ const content = document.createElement('div');
+ content.innerHTML = `
+
+ Day of week
+ ${dayOpts}
+
+
+ Start time
+ ${hourOptions(0)}
+
+
+ End time
+ ${hourOptions(24)}
+
`;
+ const footer = document.createElement('div');
+ footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
+ footer.innerHTML = `
+ Cancel
+ Add `;
+
+ let settled = false;
+ const finish = (val) => {
+ if (settled) return;
+ settled = true;
+ window.ModalSystem.close(SCHEDULE_MODAL_ID);
+ resolve(val);
+ };
+
+ const modal = window.ModalSystem.create({
+ id: SCHEDULE_MODAL_ID,
+ title: 'Access Schedule',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: { maxWidth: '420px', width: '90vw' },
+ onOpen: (m) => {
+ m.dialogFooter.querySelector('#um-sched-cancel')?.addEventListener('click', () => finish(null));
+ m.dialogFooter.querySelector('#um-sched-ok')?.addEventListener('click', () => {
+ const DayOfWeek = m.dialogContent.querySelector('#um-sched-day')?.value;
+ const StartHour = parseFloat(m.dialogContent.querySelector('#um-sched-start')?.value);
+ const EndHour = parseFloat(m.dialogContent.querySelector('#um-sched-end')?.value);
+ if (!DayOfWeek || !Number.isFinite(StartHour) || !Number.isFinite(EndHour) || EndHour <= StartHour) {
+ window.KefinTweaksToaster?.toast?.('End time must be after start time');
+ return;
+ }
+ finish({ DayOfWeek, StartHour, EndHour });
+ });
+ },
+ onClose: () => finish(null)
+ });
+ });
+ }
+
+ function refreshTagLists(root, state) {
+ const allowedEl = root.querySelector('.um-allowed-tags');
+ const blockedEl = root.querySelector('.um-blocked-tags');
+ if (allowedEl) allowedEl.innerHTML = buildTagListHTML(state.allowedTags, 'allowed');
+ if (blockedEl) blockedEl.innerHTML = buildTagListHTML(state.blockedTags, 'blocked');
+ }
+
+ function refreshSchedules(root, state) {
+ const el = root.querySelector('.um-schedules');
+ if (el) el.innerHTML = buildScheduleListHTML(state.schedules);
+ }
+
+ function setCheckboxesChecked(root, selector, checked) {
+ root.querySelectorAll(selector).forEach((el) => {
+ el.checked = checked;
+ if (checked) el.setAttribute('checked', '');
+ else el.removeAttribute('checked');
+ });
+ }
+
+ function bindEditorInteractions(root, state) {
+ const bindAllLibrariesToggle = (chkSel, listSel, itemSel) => {
+ const chk = root.querySelector(chkSel);
+ const list = root.querySelector(listSel);
+ const sync = () => {
+ const allChecked = !!chk?.checked;
+ if (list) list.style.display = allChecked ? 'none' : '';
+ // When leaving "all", select every library by default
+ if (!allChecked) setCheckboxesChecked(root, itemSel, true);
+ };
+ chk?.addEventListener('change', sync);
+ // Initial visibility only — do not force-select on load (preserve saved template)
+ if (list) list.style.display = chk?.checked ? 'none' : '';
+ };
+
+ bindAllLibrariesToggle('.um-chk-all-folders', '.um-folder-list', '.um-folder');
+ bindAllLibrariesToggle('.um-chk-delete-all', '.um-delete-folders', '.um-delete-folder');
+
+ root.addEventListener('click', async (e) => {
+ if (e.target.closest('.um-add-allowed-tag')) {
+ const tag = await promptText('Allow tag', 'Enter a tag');
+ if (tag && !state.allowedTags.includes(tag)) {
+ state.allowedTags.push(tag);
+ refreshTagLists(root, state);
+ }
+ return;
+ }
+ if (e.target.closest('.um-add-blocked-tag')) {
+ const tag = await promptText('Block tag', 'Enter a tag');
+ if (tag && !state.blockedTags.includes(tag)) {
+ state.blockedTags.push(tag);
+ refreshTagLists(root, state);
+ }
+ return;
+ }
+ if (e.target.closest('.um-add-schedule')) {
+ const schedule = await promptSchedule();
+ if (schedule) {
+ state.schedules.push(schedule);
+ refreshSchedules(root, state);
+ }
+ return;
+ }
+ const delTag = e.target.closest('.um-delete-tag');
+ if (delTag) {
+ const list = delTag.dataset.list;
+ const index = parseInt(delTag.dataset.index, 10);
+ if (list === 'allowed') state.allowedTags.splice(index, 1);
+ if (list === 'blocked') state.blockedTags.splice(index, 1);
+ refreshTagLists(root, state);
+ return;
+ }
+ const delSched = e.target.closest('.um-delete-schedule');
+ if (delSched) {
+ const index = parseInt(delSched.dataset.index, 10);
+ state.schedules.splice(index, 1);
+ refreshSchedules(root, state);
+ }
+ });
+ }
+
+ function buildMainConfigHTML(config) {
+ const options = [`None `]
+ .concat(config.templates.map((t) =>
+ `${escapeHtml(t.name)} `
+ )).join('');
+
+ const list = config.templates.length
+ ? config.templates.map((t) => `
+
+
+
${escapeHtml(t.name)}
+
+ Modified ${escapeHtml(t.dateModified ? new Date(t.dateModified).toLocaleString() : '—')}
+
+
+
+ edit
+
+
+ delete
+
+
`).join('')
+ : `No templates yet. Create one to get started.
`;
+
+ return `
+
+
+ Create reusable user policy templates and apply them to new or existing accounts.
+ Import / Export user data will be added here later.
+
+
+
Default template for new users
+
${options}
+
Used as the initial selection on the Add User page.
+
+
+ Create Template
+
+
${list}
+
`;
+ }
+
+ async function persistTemplates(mutator) {
+ const config = loadConfig();
+ mutator(config);
+ const ok = await saveConfig(config);
+ if (!ok) {
+ alert('Error saving configuration. Ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ return ok;
+ }
+
+ async function openTemplateEditor(existingTemplate) {
+ const isEdit = !!(existingTemplate && existingTemplate.name);
+ const originalName = isEdit ? existingTemplate.name : null;
+ const folders = await fetchMediaFolders();
+
+ const state = {
+ allowedTags: (existingTemplate?.policy?.AllowedTags || []).slice(),
+ blockedTags: (existingTemplate?.policy?.BlockedTags || []).slice(),
+ schedules: clone(existingTemplate?.policy?.AccessSchedules || [])
+ };
+
+ const content = document.createElement('div');
+ content.innerHTML = buildEditorHTML(
+ existingTemplate || { name: '', policy: getSeedPolicy() },
+ folders
+ );
+ bindEditorInteractions(content, state);
+
+ const footer = document.createElement('div');
+ footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
+ footer.innerHTML = `
+ Cancel
+ Save Template `;
+
+ window.ModalSystem.create({
+ id: EDITOR_MODAL_ID,
+ title: isEdit ? `Edit Template: ${existingTemplate.name}` : 'Create Template',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onOpen: (modal) => {
+ modal.dialogFooter.querySelector('#um-editor-cancel')?.addEventListener('click', () => {
+ window.ModalSystem.close(EDITOR_MODAL_ID);
+ });
+
+ modal.dialogFooter.querySelector('#um-editor-save')?.addEventListener('click', async () => {
+ const root = modal.dialogContent;
+ const name = root.querySelector('#um-template-name')?.value?.trim();
+ if (!name) {
+ window.KefinTweaksToaster?.toast?.('Template name is required');
+ return;
+ }
+ const policy = collectPolicyFromEditor(root, state);
+ const config = loadConfig();
+ if (!isEdit && config.templates.some((t) => t.name === name)) {
+ window.KefinTweaksToaster?.toast?.('A template with that name already exists');
+ return;
+ }
+ if (isEdit && name !== originalName && config.templates.some((t) => t.name === name)) {
+ window.KefinTweaksToaster?.toast?.('A template with that name already exists');
+ return;
+ }
+
+ const next = {
+ name,
+ dateModified: new Date().toISOString(),
+ policy
+ };
+ const ok = await persistTemplates((cfg) => {
+ if (isEdit) {
+ const idx = cfg.templates.findIndex((t) => t.name === originalName);
+ if (idx >= 0) cfg.templates[idx] = next;
+ else cfg.templates.push(next);
+ if (cfg.defaultTemplateName === originalName) cfg.defaultTemplateName = name;
+ } else {
+ cfg.templates.push(next);
+ }
+ });
+
+ if (ok) {
+ window.KefinTweaksToaster?.toast?.('Template saved');
+ window.ModalSystem.close(EDITOR_MODAL_ID);
+ refreshMainConfigListIfOpen();
+ }
+ });
+ }
+ });
+ }
+
+ function refreshMainConfigListIfOpen() {
+ const listRoot = document.querySelector('.um-config-root');
+ if (!listRoot) return;
+ const wrap = listRoot.parentElement;
+ if (!wrap) return;
+ wrap.innerHTML = buildMainConfigHTML(loadConfig());
+ bindMainConfigActions(wrap);
+ }
+
+ function bindMainConfigActions(root) {
+ if (!root) return;
+ const scope = root;
+
+ scope.querySelector('#um-create-template')?.addEventListener('click', () => {
+ openTemplateEditor(null);
+ });
+
+ scope.querySelector('#um-default-template')?.addEventListener('change', async (e) => {
+ const value = e.target.value || '';
+ await persistTemplates((config) => {
+ config.defaultTemplateName = value;
+ });
+ });
+
+ scope.addEventListener('click', async (e) => {
+ const del = e.target.closest('.um-delete-template');
+ if (del) {
+ e.stopPropagation();
+ const name = del.dataset.name;
+ if (!name || !confirm(`Delete template "${name}"?`)) return;
+ const ok = await persistTemplates((config) => {
+ config.templates = config.templates.filter((t) => t.name !== name);
+ if (config.defaultTemplateName === name) config.defaultTemplateName = '';
+ });
+ if (ok) refreshMainConfigListIfOpen();
+ return;
+ }
+
+ const editBtn = e.target.closest('.um-edit-template');
+ const row = e.target.closest('.um-template-row');
+ const target = editBtn || row;
+ if (target) {
+ e.stopPropagation();
+ const name = target.dataset.name;
+ const template = loadConfig().templates.find((t) => t.name === name);
+ if (template) openTemplateEditor(clone(template));
+ }
+ });
+ }
+
+ async function openConfigModal() {
+ try {
+ const config = loadConfig();
+ const content = document.createElement('div');
+ content.innerHTML = buildMainConfigHTML(config);
+ bindMainConfigActions(content);
+
+ const footer = document.createElement('div');
+ footer.style.cssText = 'display:flex;gap:0.75em;justify-content:flex-end;';
+ footer.innerHTML = `Close `;
+
+ window.ModalSystem.create({
+ id: CONFIG_MODAL_ID,
+ title: 'User Manager',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined
+ });
+ LOG('User Manager config opened');
+ } catch (e) {
+ ERR('Error opening User Manager config', e);
+ alert('Error opening configuration: ' + e.message);
+ }
+ }
+
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.userManager = {
+ openConfigModal,
+ openTemplateEditor,
+ loadConfig,
+ getSeedPolicy,
+ clonePolicy: clone
+ };
+
+ // Shared helpers for runtime script
+ window.KefinUserManagerConfig = {
+ loadConfig,
+ getSeedPolicy,
+ clonePolicy: clone,
+ openTemplateEditor,
+ openConfigModal
+ };
+
+ console.log('[KefinTweaks UserManager Config] Script loaded');
+})();
diff --git a/configuration/versionPreferences-configuration.js b/configuration/versionPreferences-configuration.js
new file mode 100644
index 0000000..47c09d4
--- /dev/null
+++ b/configuration/versionPreferences-configuration.js
@@ -0,0 +1,485 @@
+// KefinTweaks Item Version Preferences Configuration UI
+
+(function () {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks VersionPreferences Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks VersionPreferences Config]', ...args);
+
+ const MODAL_ID = 'kefin-versionpreferences-config';
+ const NONE_SENTINEL = 'None';
+
+ const DEFAULT_VERSIONS = ['4k', '2160p', '1080p', '720p', '576p', '480p', '360p', NONE_SENTINEL];
+ const DEFAULT_EDITIONS = ['Theatrical', "Director's Cut", 'Extended Edition', 'Fan Edit', NONE_SENTINEL];
+
+ function escapeHtml(str) {
+ return String(str ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function isNoneSentinel(term) {
+ return typeof term === 'string' && term.trim().toLowerCase() === NONE_SENTINEL.toLowerCase();
+ }
+
+ function ensureNoneSentinel(list) {
+ const out = [];
+ let noneSeen = false;
+ (Array.isArray(list) ? list : []).forEach((term) => {
+ if (typeof term !== 'string' || !term.trim()) return;
+ if (isNoneSentinel(term)) {
+ if (!noneSeen) {
+ out.push(NONE_SENTINEL);
+ noneSeen = true;
+ }
+ return;
+ }
+ out.push(term.trim());
+ });
+ if (!noneSeen) out.push(NONE_SENTINEL);
+ return out;
+ }
+
+ function getDefaults() {
+ return {
+ versions: DEFAULT_VERSIONS.slice(),
+ editions: DEFAULT_EDITIONS.slice(),
+ showVersionBeforeEdition: false
+ };
+ }
+
+ async function loadConfig() {
+ const defaults = getDefaults();
+ const cfg = window.KefinTweaksConfig?.versionPreferences || {};
+ return {
+ versions: ensureNoneSentinel(
+ Array.isArray(cfg.versions) ? cfg.versions : defaults.versions
+ ),
+ editions: ensureNoneSentinel(
+ Array.isArray(cfg.editions) ? cfg.editions : defaults.editions
+ ),
+ showVersionBeforeEdition: cfg.showVersionBeforeEdition === true
+ };
+ }
+
+ async function saveConfig(versionPreferences) {
+ try {
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+ window.KefinTweaksConfig.versionPreferences = {
+ versions: ensureNoneSentinel(versionPreferences.versions),
+ editions: ensureNoneSentinel(versionPreferences.editions),
+ showVersionBeforeEdition: versionPreferences.showVersionBeforeEdition === true
+ };
+
+ if (window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ }
+
+ if (window.KefinTweaksConfiguration?.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ function buildTermRowHTML(term, index, listKey) {
+ const isNone = isNoneSentinel(term);
+ const termCell = isNone
+ ? `
+ ${escapeHtml(NONE_SENTINEL)}
+ (no match)
+ `
+ : ` `;
+
+ const removeBtn = isNone
+ ? ''
+ : `
+ delete
+ `;
+
+ return `
+
+ ${index + 1}.
+ ${termCell}
+
+ arrow_upward
+
+
+ arrow_downward
+
+ ${removeBtn}
+
+ `;
+ }
+
+ function buildListSectionHTML(title, hint, listKey, terms) {
+ return `
+
+
+
${escapeHtml(title)}
+
${escapeHtml(hint)}
+
+ ${terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join('')}
+
+
+
+
+ Add
+
+
+
+
+ `;
+ }
+
+ function ensureLayoutStyles() {
+ if (document.getElementById('kefin-vp-config-styles')) return;
+ const style = document.createElement('style');
+ style.id = 'kefin-vp-config-styles';
+ style.textContent = `
+ .kefin-vp-lists-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1em;
+ align-items: start;
+ height: calc(100% - 40px);
+ }
+ @media (max-width: 899px) {
+ .kefin-vp-lists-grid {
+ grid-template-columns: 1fr;
+ }
+ }
+ [data-name="kefin-modal-content"]:has(.kefin-vp-lists-grid) > div {
+ height: 100% !important;
+ }
+ .listItem.kefin-vp-list-section {
+ align-items: start;
+ }
+ .kefin-vp-label-order-card {
+ display: flex;
+ align-items: center;
+ gap: 1em;
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 4px;
+ padding: 0.85em 1em;
+ margin-bottom: 1em;
+ }
+ .kefin-vp-label-order-text {
+ flex: 1;
+ min-width: 0;
+ }
+ .kefin-vp-label-order-card .kefin-vp-toggle-switch {
+ position: relative;
+ width: 52px;
+ height: 28px;
+ border-radius: 14px;
+ border: none;
+ background: rgba(158, 158, 158, 0.5);
+ cursor: pointer;
+ flex-shrink: 0;
+ padding: 0;
+ transition: background-color 0.3s ease;
+ }
+ .kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"] {
+ background: rgba(0, 164, 220, 0.8);
+ }
+ .kefin-vp-label-order-card .kefin-vp-toggle-switch::after {
+ content: "";
+ position: absolute;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: white;
+ top: 3px;
+ left: 3px;
+ transition: left 0.3s ease;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ }
+ .kefin-vp-label-order-card .kefin-vp-toggle-switch[data-enabled="true"]::after {
+ left: calc(100% - 25px);
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ function getLabelOrderDescription(showVersionBeforeEdition) {
+ return showVersionBeforeEdition
+ ? 'Version will be listed before the edition (1080p - Theatrical)'
+ : 'Edition will be listed before the version (Theatrical - 1080p)';
+ }
+
+ function buildLabelOrderToggleHTML(showVersionBeforeEdition) {
+ const enabled = showVersionBeforeEdition === true;
+ return `
+
+
+
+
List Version Before Edition
+
+ ${escapeHtml(getLabelOrderDescription(enabled))}
+
+
+
+
+ `;
+ }
+
+ function updateLabelOrderToggleUI(root, checked) {
+ const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
+ const toggleBtn = root.querySelector('.kefin-vp-toggle-switch');
+ const desc = root.querySelector('.kefin-vp-label-order-desc');
+ if (checkbox) checkbox.checked = checked === true;
+ if (toggleBtn) {
+ toggleBtn.dataset.enabled = checked === true;
+ toggleBtn.setAttribute('aria-pressed', String(checked === true));
+ }
+ if (desc) desc.textContent = getLabelOrderDescription(checked === true);
+ }
+
+ function buildConfigHTML(config) {
+ return `
+
+
+ Manage how different versions and editions of your items are displayed.
+ Terms are matched as case-insensitive substrings of the media source label.
+ Higher list positions are prioritized first.
+
+ ${buildLabelOrderToggleHTML(config.showVersionBeforeEdition)}
+
+ ${buildListSectionHTML(
+ 'Version Terms',
+ 'Resolution / quality labels (e.g. 4k, 1080p).',
+ 'versions',
+ config.versions
+ )}
+ ${buildListSectionHTML(
+ 'Edition Terms',
+ 'Edition labels (e.g. Theatrical, Director\'s Cut).',
+ 'editions',
+ config.editions
+ )}
+
+
+ `;
+ }
+
+ function readTermsFromDom(root, listKey) {
+ const rows = root.querySelectorAll(`.kefin-vp-term-row[data-list="${listKey}"]`);
+ const terms = [];
+ rows.forEach((row) => {
+ if (row.dataset.isNone === 'true') {
+ terms.push(NONE_SENTINEL);
+ return;
+ }
+ const input = row.querySelector('.kefin-vp-term-input');
+ const value = input?.value?.trim();
+ if (value) terms.push(value);
+ });
+ return ensureNoneSentinel(terms);
+ }
+
+ function syncStateFromDom(root, state) {
+ state.versions = readTermsFromDom(root, 'versions');
+ state.editions = readTermsFromDom(root, 'editions');
+ const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
+ state.showVersionBeforeEdition = checkbox?.checked === true;
+ }
+
+ function refreshList(root, state, listKey) {
+ const listEl = root.querySelector(`.kefin-vp-term-list[data-list="${listKey}"]`);
+ if (!listEl) return;
+ state[listKey] = ensureNoneSentinel(state[listKey] || []);
+ const terms = state[listKey];
+ listEl.innerHTML = terms.map((term, i) => buildTermRowHTML(term, i, listKey)).join('');
+ const rows = listEl.querySelectorAll('.kefin-vp-term-row');
+ rows.forEach((row, i) => {
+ const down = row.querySelector('.kefin-vp-move-down');
+ if (down) down.disabled = i === rows.length - 1;
+ const up = row.querySelector('.kefin-vp-move-up');
+ if (up) up.disabled = i === 0;
+ });
+ }
+
+ function collectConfig(root) {
+ return {
+ versions: readTermsFromDom(root, 'versions'),
+ editions: readTermsFromDom(root, 'editions'),
+ showVersionBeforeEdition: root.querySelector('#kefin-vp-show-version-before-edition')?.checked === true
+ };
+ }
+
+ function bindLabelOrderToggle(root, state) {
+ root.addEventListener('click', (e) => {
+ const toggleBtn = e.target.closest('.kefin-vp-toggle-switch');
+ if (!toggleBtn || !root.contains(toggleBtn)) return;
+ e.preventDefault();
+ const checkbox = root.querySelector('#kefin-vp-show-version-before-edition');
+ if (!checkbox) return;
+ checkbox.checked = !checkbox.checked;
+ state.showVersionBeforeEdition = checkbox.checked;
+ updateLabelOrderToggleUI(root, checkbox.checked);
+ });
+ }
+
+ function bindListActions(root, state) {
+ root.addEventListener('click', (e) => {
+ const addBtn = e.target.closest('.kefin-vp-add-btn');
+ if (addBtn) {
+ const listKey = addBtn.dataset.list;
+ const input = root.querySelector(`.kefin-vp-add-input[data-list="${listKey}"]`);
+ const term = input?.value?.trim();
+ if (!term) return;
+ if (isNoneSentinel(term)) {
+ window.KefinTweaksToaster?.toast?.('"None" is a built-in priority slot and cannot be added as a term.');
+ if (input) input.value = '';
+ return;
+ }
+ syncStateFromDom(root, state);
+ state[listKey].push(term);
+ state[listKey] = ensureNoneSentinel(state[listKey]);
+ if (input) input.value = '';
+ refreshList(root, state, listKey);
+ return;
+ }
+
+ const removeBtn = e.target.closest('.kefin-vp-remove');
+ if (removeBtn) {
+ const listKey = removeBtn.dataset.list;
+ const index = parseInt(removeBtn.dataset.index, 10);
+ syncStateFromDom(root, state);
+ if (Number.isNaN(index)) return;
+ if (isNoneSentinel(state[listKey][index])) return;
+ state[listKey].splice(index, 1);
+ state[listKey] = ensureNoneSentinel(state[listKey]);
+ refreshList(root, state, listKey);
+ return;
+ }
+
+ const upBtn = e.target.closest('.kefin-vp-move-up');
+ if (upBtn) {
+ const listKey = upBtn.dataset.list;
+ const index = parseInt(upBtn.dataset.index, 10);
+ syncStateFromDom(root, state);
+ if (Number.isNaN(index) || index <= 0) return;
+ const list = state[listKey];
+ [list[index - 1], list[index]] = [list[index], list[index - 1]];
+ refreshList(root, state, listKey);
+ return;
+ }
+
+ const downBtn = e.target.closest('.kefin-vp-move-down');
+ if (downBtn) {
+ const listKey = downBtn.dataset.list;
+ const index = parseInt(downBtn.dataset.index, 10);
+ syncStateFromDom(root, state);
+ if (Number.isNaN(index) || index >= state[listKey].length - 1) return;
+ const list = state[listKey];
+ [list[index], list[index + 1]] = [list[index + 1], list[index]];
+ refreshList(root, state, listKey);
+ }
+ });
+
+ root.addEventListener('keydown', (e) => {
+ if (e.key !== 'Enter') return;
+ const addInput = e.target.closest('.kefin-vp-add-input');
+ if (!addInput) return;
+ e.preventDefault();
+ const listKey = addInput.dataset.list;
+ const btn = root.querySelector(`.kefin-vp-add-btn[data-list="${listKey}"]`);
+ btn?.click();
+ });
+ }
+
+ async function openConfigModal() {
+ try {
+ ensureLayoutStyles();
+ const config = await loadConfig();
+ const state = {
+ versions: config.versions.slice(),
+ editions: config.editions.slice(),
+ showVersionBeforeEdition: config.showVersionBeforeEdition === true
+ };
+
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(state);
+ bindLabelOrderToggle(content, state);
+ bindListActions(content, state);
+ refreshList(content, state, 'versions');
+ refreshList(content, state, 'editions');
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Item Version Preferences',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', height: '90vh' }
+ : undefined,
+ onOpen: (modal) => {
+ const saveBtn = modal.dialogFooter.querySelector('#save-versionpreferences-config-btn');
+ saveBtn?.addEventListener('click', async () => {
+ const root = modal.dialogContent || content;
+ syncStateFromDom(root, state);
+ const toSave = collectConfig(root);
+ const success = await saveConfig(toSave);
+ if (success) {
+ window.KefinTweaksToaster?.toast?.('Configuration saved!');
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ });
+
+ LOG('Version Preferences configuration modal opened');
+ } catch (error) {
+ ERR('Error opening version preferences config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.versionPreferences = { openConfigModal };
+
+ console.log('[KefinTweaks VersionPreferences Config] Script loaded');
+})();
diff --git a/configuration/watchTogether-configuration.js b/configuration/watchTogether-configuration.js
new file mode 100644
index 0000000..eea3eb7
--- /dev/null
+++ b/configuration/watchTogether-configuration.js
@@ -0,0 +1,127 @@
+// KefinTweaks Watch Together Configuration UI
+
+(function () {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks WatchTogether Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks WatchTogether Config]', ...args);
+
+ const MODAL_ID = 'kefin-watchtogether-config';
+ const DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
+
+ async function loadConfig() {
+ const config = window.KefinTweaksConfig || {};
+ return config.watchTogether || {};
+ }
+
+ async function saveConfig(watchTogetherConfig) {
+ try {
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+ window.KefinTweaksConfig.watchTogether = watchTogetherConfig;
+
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ }
+ if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ function buildConfigHTML(config) {
+ const timeout = Number.isFinite(config.sessionTimeout * 1)
+ ? config.sessionTimeout * 1
+ : DEFAULT_SESSION_TIMEOUT_MINUTES;
+
+ return `
+
+
+
+
Session timeout (minutes)
+
+ After selecting who is watching, skip the Watch Together prompt for this many minutes
+ when starting another item on this device. Playback that continues on the video OSD
+ (autoplay) never re-prompts.
+
+
+
+
+
+
+
+ `;
+ }
+
+ function collectConfig() {
+ const raw = parseInt(document.querySelector('#wt-session-timeout')?.value || String(DEFAULT_SESSION_TIMEOUT_MINUTES), 10);
+ const sessionTimeout = Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_TIMEOUT_MINUTES;
+ return { sessionTimeout };
+ }
+
+ async function openConfigModal() {
+ try {
+ const config = await loadConfig();
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(config);
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Watch Together',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: { maxWidth: '90vw', width: '560px' },
+ onOpen: (modal) => {
+ const saveBtn = modal.dialogFooter.querySelector('#save-watchtogether-config-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const next = collectConfig();
+ const success = await saveConfig(next);
+ if (success) {
+ window.KefinTweaksToaster?.toast?.('Watch Together configuration saved!');
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ }
+ });
+ LOG('Watch Together configuration modal opened');
+ } catch (error) {
+ ERR('Error opening Watch Together config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.watchTogether = { openConfigModal };
+
+ console.log('[KefinTweaks WatchTogether Config] Script loaded');
+})();
diff --git a/configuration/watchlist-configuration.js b/configuration/watchlist-configuration.js
new file mode 100644
index 0000000..d5825c9
--- /dev/null
+++ b/configuration/watchlist-configuration.js
@@ -0,0 +1,203 @@
+// KefinTweaks Watchlist Configuration UI
+
+(function () {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks Watchlist Config]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks Watchlist Config]', ...args);
+
+ const MODAL_ID = 'kefin-watchlist-config';
+
+ const DEFAULTS = {
+ sideMenu: true,
+ topNavigation: 'main',
+ userMenu: true,
+ order: 2
+ };
+
+ function normalizeConfig(raw) {
+ const cfg = raw && typeof raw === 'object' ? raw : {};
+ const orderRaw = parseInt(cfg.order, 10);
+ return {
+ sideMenu: cfg.sideMenu !== false,
+ topNavigation: typeof cfg.topNavigation === 'boolean'
+ ? (cfg.topNavigation ? 'main' : 'none')
+ : cfg.topNavigation === undefined ? 'main' : cfg.topNavigation,
+ userMenu: cfg.userMenu !== false,
+ order: Number.isFinite(orderRaw) ? orderRaw : DEFAULTS.order
+ };
+ }
+
+ async function loadConfig() {
+ const config = window.KefinTweaksConfig || {};
+ return normalizeConfig(config.watchlist);
+ }
+
+ async function saveConfig(watchlistConfig) {
+ try {
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
+ }
+ window.KefinTweaksConfig.watchlist = watchlistConfig;
+
+ if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
+ await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
+ LOG('Configuration saved to JS Injector');
+ return true;
+ }
+ if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) {
+ const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin();
+ if (pluginId) {
+ await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ LOG('Configuration saved to JS Injector (fallback)');
+ return true;
+ }
+ }
+ ERR('saveConfigToJavaScriptInjector not available');
+ return false;
+ } catch (error) {
+ ERR('Error saving config:', error);
+ return false;
+ }
+ }
+
+ function buildToggleCard(id, checked, label, description) {
+ if (typeof window.KefinTweaksUI?.buildToggleCard === 'function') {
+ return window.KefinTweaksUI.buildToggleCard(id, checked, label, description);
+ }
+ return `
+
+
+
+ ${label}
+
+ ${description ? `
${description}
` : ''}
+
+ `;
+ }
+
+ function buildConfigHTML(config) {
+ const order = Number.isFinite(config.order) ? config.order : DEFAULTS.order;
+ return `
+
+ ${buildToggleCard(
+ 'wl-side-menu',
+ config.sideMenu,
+ 'Side menu',
+ 'Show a Watchlist link in the left navigation drawer.'
+ )}
+ ${buildToggleCard(
+ 'wl-top-navigation',
+ config.topNavigation !== false && config.topNavigation !== 'none',
+ 'Top navigation',
+ 'Show Watchlist in the main top navigation bar (or via Custom Tabs when available).'
+ )}
+ ${buildToggleCard(
+ 'wl-user-menu',
+ config.userMenu,
+ 'User menu',
+ 'Show a Watchlist link in the user settings menu.'
+ )}
+
+
+
Menu order
+
+ Lower values appear earlier in the side menu and top navigation.
+
+
+
+
+
+
+
+ `;
+ }
+
+ function collectConfig() {
+ const side = document.getElementById('wl-side-menu');
+ const top = document.getElementById('wl-top-navigation');
+ const user = document.getElementById('wl-user-menu');
+ const orderRaw = parseInt(document.getElementById('wl-order')?.value || String(DEFAULTS.order), 10);
+ return {
+ sideMenu: side ? side.checked : DEFAULTS.sideMenu,
+ topNavigation: top
+ ? (top.checked ? 'main' : 'none')
+ : DEFAULTS.topNavigation,
+ userMenu: user ? user.checked : DEFAULTS.userMenu,
+ order: Number.isFinite(orderRaw) ? orderRaw : DEFAULTS.order
+ };
+ }
+
+ function attachToggleHandlers(root) {
+ if (typeof window.KefinTweaksUI?.bindToggleCards === 'function') {
+ window.KefinTweaksUI.bindToggleCards(root);
+ return;
+ }
+ root.querySelectorAll('.kefin-toggle-switch').forEach((btn) => {
+ if (btn.dataset.kefinToggleBound === 'true') return;
+ btn.dataset.kefinToggleBound = 'true';
+ btn.addEventListener('click', () => {
+ const next = btn.dataset.enabled !== 'true';
+ btn.dataset.enabled = String(next);
+ btn.setAttribute('aria-pressed', String(next));
+ const checkboxId = btn.dataset.checkboxId;
+ const checkbox = checkboxId ? document.getElementById(checkboxId) : null;
+ if (checkbox) checkbox.checked = next;
+ });
+ });
+ }
+
+ async function openConfigModal() {
+ try {
+ const config = await loadConfig();
+ const content = document.createElement('div');
+ content.innerHTML = buildConfigHTML(config);
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.style.justifyContent = 'flex-end';
+ footer.innerHTML = `
+ Close
+ Save
+ `;
+
+ window.ModalSystem.create({
+ id: MODAL_ID,
+ title: 'Watchlist',
+ content,
+ footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ showCloseButton: true,
+ dialogStyle: { maxWidth: '90vw', width: '560px' },
+ onOpen: (modal) => {
+ attachToggleHandlers(modal.dialogContent || content);
+ const saveBtn = modal.dialogFooter.querySelector('#save-watchlist-config-btn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const next = collectConfig();
+ const success = await saveConfig(next);
+ if (success) {
+ window.KefinTweaksToaster?.toast?.('Watchlist configuration saved!');
+ window.ModalSystem.close(MODAL_ID);
+ } else {
+ alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.');
+ }
+ });
+ }
+ }
+ });
+ LOG('Watchlist configuration modal opened');
+ } catch (error) {
+ ERR('Error opening Watchlist config modal:', error);
+ alert('Error opening configuration: ' + error.message);
+ }
+ }
+
+ window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
+ window.KefinTweaksFeatureConfigs.watchlist = { openConfigModal, DEFAULTS, normalizeConfig };
+
+ console.log('[KefinTweaks Watchlist Config] Script loaded');
+})();
diff --git a/helpers/dataHelper.js b/helpers/dataHelper.js
new file mode 100644
index 0000000..d28277e
--- /dev/null
+++ b/helpers/dataHelper.js
@@ -0,0 +1,292 @@
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks DataHelper]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks DataHelper]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks DataHelper]', ...args);
+
+ /**
+ * Ensure ApiClient is available before using helper methods
+ */
+ function ensureApiClient() {
+ if (typeof ApiClient === 'undefined' || !ApiClient) {
+ throw new Error('ApiClient is not available');
+ }
+ }
+
+ /** @type {Map} */
+ const filtersMemoryCache = new Map();
+
+ function parseMajorVersion(version) {
+ if (!version || typeof version !== 'string') return 0;
+ const major = parseInt(version.split('.')[0], 10);
+ return Number.isNaN(major) ? 0 : major;
+ }
+
+ function getServerMajorVersion() {
+ ensureApiClient();
+ const version = ApiClient._serverVersion || ApiClient._appVersion || '';
+ return parseMajorVersion(version);
+ }
+
+ function normalizeIncludeItemTypesKey(includeItemTypes) {
+ if (includeItemTypes == null) return 'ALL';
+ const list = Array.isArray(includeItemTypes)
+ ? includeItemTypes.map(t => String(t).trim()).filter(Boolean)
+ : String(includeItemTypes).split(',').map(s => s.trim()).filter(Boolean);
+ if (list.length === 0) return 'ALL';
+ return [...list].sort().join(',');
+ }
+
+ function buildIncludeItemTypesParam(includeItemTypes) {
+ const key = normalizeIncludeItemTypesKey(includeItemTypes);
+ if (key === 'ALL') return '';
+ return key;
+ }
+
+ async function unwrapGetQueryResult(result) {
+ if (!result) return null;
+ if (typeof result.ensureData === 'function') {
+ return await result.ensureData();
+ }
+ if (result.dataPromise != null) {
+ return await result.dataPromise;
+ }
+ return result;
+ }
+
+ function normalizeGenreEntry(entry) {
+ if (entry == null) return null;
+ if (typeof entry === 'string') {
+ const name = entry.trim();
+ return name ? { id: name, name } : null;
+ }
+ const id = entry.Id ?? entry.id;
+ const name = entry.Name ?? entry.name;
+ if (id != null && name != null) {
+ return { id: String(id), name: String(name) };
+ }
+ if (name != null) {
+ const n = String(name);
+ return { id: n, name: n };
+ }
+ return null;
+ }
+
+ function normalizeTagEntry(entry) {
+ if (entry == null) return null;
+ if (typeof entry === 'string') {
+ const name = entry.trim();
+ return name ? { id: name, name } : null;
+ }
+ const name = entry.Name ?? entry.name ?? entry.Id ?? entry.id;
+ if (name == null) return null;
+ const n = String(name);
+ return { id: n, name: n };
+ }
+
+ function normalizeFiltersPayload(raw) {
+ const genres = (Array.isArray(raw?.Genres) ? raw.Genres : [])
+ .map(normalizeGenreEntry)
+ .filter(Boolean);
+ const tags = (Array.isArray(raw?.Tags) ? raw.Tags : [])
+ .map(normalizeTagEntry)
+ .filter(Boolean);
+ return {
+ Genres: genres,
+ Tags: tags,
+ OfficialRatings: Array.isArray(raw?.OfficialRatings) ? raw.OfficialRatings : [],
+ Years: Array.isArray(raw?.Years) ? raw.Years : []
+ };
+ }
+
+ function buildFiltersUrl(serverAddress, userId, includeItemTypes) {
+ const typesParam = buildIncludeItemTypesParam(includeItemTypes);
+ const useFilters2 = getServerMajorVersion() >= 12;
+ const path = useFilters2 ? '/Items/Filters2' : '/Items/Filters';
+ const params = new URLSearchParams({ UserId: userId });
+ if (useFilters2) {
+ params.set('recursive', 'true');
+ }
+ if (typesParam) {
+ params.set('IncludeItemTypes', typesParam);
+ }
+ return `${serverAddress}${path}?${params.toString()}`;
+ }
+
+ /**
+ * API Helper functions for Jellyfin operations
+ */
+ const dataHelper = {
+ getLibraries: async function() {
+ ensureApiClient();
+
+ if (__libraries?.Items) {
+ return __libraries.Items;
+ }
+
+ const response = await ApiClient.getItems();
+ return response?.Items ?? [];
+ },
+
+ /**
+ * Get filter data (Genres, Tags, OfficialRatings, Years) for specified item types
+ * @param {string|Array} includeItemTypes - Item types to filter (e.g., 'Movie', ['Movie', 'Episode'])
+ * @param {boolean} useCache - Whether to use cached data (default: true)
+ * @param {boolean} forceRefresh - Whether to bypass cache (default: false)
+ * @returns {Promise} - Object with Genres, Tags, OfficialRatings, Years arrays
+ */
+ getFilters: async function(includeItemTypes, useCache = true, forceRefresh = false) {
+ ensureApiClient();
+
+ if (!window.apiHelper) {
+ throw new Error('apiHelper is not available');
+ }
+
+ const userId = ApiClient.getCurrentUserId();
+ const serverAddress = ApiClient.serverAddress();
+
+ if (!userId || !serverAddress) {
+ throw new Error('User ID or server address not available');
+ }
+
+ const cacheKey = normalizeIncludeItemTypesKey(includeItemTypes);
+ if (forceRefresh) {
+ filtersMemoryCache.delete(cacheKey);
+ }
+ if (useCache && !forceRefresh && filtersMemoryCache.has(cacheKey)) {
+ return filtersMemoryCache.get(cacheKey);
+ }
+
+ const url = buildFiltersUrl(serverAddress, userId, includeItemTypes);
+
+ const cacheOptions = {
+ useCache: useCache && !forceRefresh,
+ ttl: 24 * 60 * 60 * 1000,
+ forceRefresh: forceRefresh
+ };
+
+ const result = await window.apiHelper.getQuery(url, cacheOptions);
+ const raw = await unwrapGetQueryResult(result);
+ const normalized = normalizeFiltersPayload(raw);
+ filtersMemoryCache.set(cacheKey, normalized);
+ return normalized;
+ },
+
+ getFilterGenreItems: async function(includeItemTypes, options = {}) {
+ const { useCache = true, forceRefresh = false } = options;
+ const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh);
+ return filters.Genres || [];
+ },
+
+ getFilterTagItems: async function(includeItemTypes, options = {}) {
+ const { useCache = true, forceRefresh = false } = options;
+ const filters = await this.getFilters(includeItemTypes, useCache, forceRefresh);
+ return filters.Tags || [];
+ },
+
+ /**
+ * Get watched or in progress movies from Jellyfin API
+ * @returns {Promise} - Array of movies
+ */
+ getWatchedOrInProgressMovies: async function(options = {}) {
+
+ const { watchedMoviesResult, inProgressMoviesResult } = await Promise.all([this.getWatchedMovies(options), this.getInProgressMovies(options)]);
+
+ if (options.useCache) {
+ const watchedMovies = watchedMoviesResult.data.Items;
+ const inProgressMovies = inProgressMoviesResult.data.Items;
+ let isStale = Promise.all([watchedMoviesResult.isStalePromise, inProgressMoviesResult.isStalePromise]).then(([isWatchedStale, isInProgressStale]) => {
+ return isStale = isWatchedStale || isInProgressStale ? true : false;
+ });
+
+ let dataPromise = Promise.all([watchedMoviesResult.dataPromise, inProgressMoviesResult.dataPromise]).then(([watchedMoviesData, inProgressMoviesData]) => {
+ return {
+ data: {
+ Items: [...watchedMoviesData.Items, ...inProgressMoviesData.Items]
+ }
+ };
+ });
+
+ return {
+ data: {
+ Items: [...watchedMovies, ...inProgressMovies]
+ },
+ isStalePromise: isStalePromise,
+ dataPromise: dataPromise
+ };
+ }
+
+ // Otherwise, use the fetched data arrays directly
+ return {
+ Items: [...watchedMovies.Items, ...inProgressMovies.Items]
+ };
+ },
+
+ /**
+ * Get watched movies from Jellyfin API
+ * @param {Object} options - Options
+ * @param {boolean} options.useCache - Whether to use cache (default: false)
+ * @returns {Promise|Array} - Array of movies
+ */
+ getWatchedMovies: async function(options = {}) {
+ // default TTL is 1 day
+ const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options;
+
+ const serverUrl = ApiClient.serverAddress();
+ const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
+
+ // Use apiHelper.getQuery to get the data
+ if (!window.apiHelper) {
+ ERR('apiHelper is not available');
+ return [];
+ }
+
+ return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl });
+ },
+
+ getInProgressMovies: async function(options = {}) {
+ // default TTL is 1 day
+ const { useCache = false, ttl = 24 * 60 * 60 * 1000 } = options;
+
+ const serverUrl = ApiClient.serverAddress();
+ const url = `${serverUrl}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsResumable&Fields=UserData,People,ProviderIds&ImageTypeLimit=1&SortBy=DatePlayed&SortOrder=Descending`;
+
+ if (!window.apiHelper) {
+ ERR('apiHelper is not available');
+ return [];
+ }
+
+ return window.apiHelper.getQuery(url, { useCache: useCache, ttl: ttl });
+ },
+
+ getCachedLibraries: function() {
+ return __libraries?.Items ? __libraries.Items : null;
+ }
+ };
+
+ async function initialize() {
+ ensureApiClient();
+
+ // Wait for login
+ if (window.userHelper?.waitForLogin) {
+ await window.userHelper.waitForLogin();
+ }
+
+ if (__libraries) {
+ return;
+ }
+
+ const libraries = await ApiClient.getItems();
+ __libraries = libraries;
+ }
+
+ let __libraries = null;
+
+ initialize();
+
+ // Expose dataHelper to global window object
+ window.dataHelper = dataHelper;
+
+ console.log('[KefinTweaks DataHelper] Module loaded and available at window.dataHelper');
+})();
\ No newline at end of file
diff --git a/helpers/websocketHelper.js b/helpers/websocketHelper.js
new file mode 100644
index 0000000..32844fb
--- /dev/null
+++ b/helpers/websocketHelper.js
@@ -0,0 +1,265 @@
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks WebSocketHelper]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks WebSocketHelper]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks WebSocketHelper]', ...args);
+
+ // Registry of event listeners: MessageType -> array of callbacks
+ const listeners = {};
+ // messageType -> unsubscribe from ApiClient.subscribe
+ const subscribeUnsubs = {};
+ // messageType -> whether we already attached a subscribe fan-out
+ const subscribeAttached = {};
+
+ let hookedSocket = null;
+ let hookedOriginalOnMessage = null;
+ let pollingIntervalId = null;
+ let deliveryMode = null; // 'subscribe' | 'onmessage' | null
+
+ const POLL_INTERVAL_MS = 1500;
+
+ function getApiClient() {
+ return window.ApiClient || null;
+ }
+
+ function getSocket() {
+ const api = getApiClient();
+ if (!api) return null;
+ return api.webSocket || api._webSocket || null;
+ }
+
+ function canSubscribe() {
+ const api = getApiClient();
+ return !!(api && typeof api.subscribe === 'function');
+ }
+
+ function dispatchMessage(data) {
+ if (!data || typeof data !== 'object') return;
+ const messageType = data.MessageType;
+ if (!messageType || !listeners[messageType]) return;
+ listeners[messageType].forEach((callback) => {
+ try {
+ callback(data);
+ } catch (err) {
+ ERR(`Error in WebSocket listener for ${messageType}:`, err);
+ }
+ });
+ }
+
+ function ensureSubscribeForType(messageType) {
+ if (subscribeAttached[messageType]) return true;
+ if (!canSubscribe()) return false;
+
+ try {
+ const api = getApiClient();
+ const unsub = api.subscribe([messageType], (msg) => {
+ // SDK/subscribe may pass the outbound message object directly
+ const data = msg?.MessageType ? msg : { MessageType: messageType, Data: msg?.Data ?? msg };
+ if (!data.MessageType) data.MessageType = messageType;
+ dispatchMessage(data);
+ });
+ subscribeAttached[messageType] = true;
+ subscribeUnsubs[messageType] = typeof unsub === 'function' ? unsub : null;
+ deliveryMode = 'subscribe';
+ // Drop any legacy onmessage hook immediately — do not wait for the poll tick
+ unhookSocket();
+ LOG(`Subscribed via ApiClient.subscribe for ${messageType}`);
+ return true;
+ } catch (err) {
+ ERR(`ApiClient.subscribe failed for ${messageType}:`, err);
+ return false;
+ }
+ }
+
+ function ensureSubscribeForAllRegisteredTypes() {
+ if (!canSubscribe()) return false;
+ let any = false;
+ Object.keys(listeners).forEach((messageType) => {
+ if (listeners[messageType]?.length) {
+ if (ensureSubscribeForType(messageType)) any = true;
+ }
+ });
+ return any;
+ }
+
+ function unhookSocket() {
+ if (hookedSocket && hookedOriginalOnMessage !== undefined) {
+ try {
+ hookedSocket.onmessage = hookedOriginalOnMessage;
+ } catch (e) {
+ // ignore
+ }
+ }
+ hookedSocket = null;
+ hookedOriginalOnMessage = null;
+ }
+
+ /**
+ * Legacy path: wrap socket.onmessage, re-bind when the socket instance changes.
+ * Only used when ApiClient.subscribe is unavailable.
+ */
+ function hookSocketIfNeeded() {
+ if (canSubscribe()) return false;
+
+ const socket = getSocket();
+ if (!socket) return false;
+
+ if (hookedSocket === socket) return true;
+
+ unhookSocket();
+
+ try {
+ const originalHandler = socket.onmessage;
+ hookedOriginalOnMessage = originalHandler;
+ hookedSocket = socket;
+
+ socket.onmessage = function(event) {
+ // Keep JF handler isolated so its throws do not look like parse failures
+ // and do not block KefinTweaks listener delivery.
+ if (originalHandler) {
+ try {
+ originalHandler.call(this, event);
+ } catch (err) {
+ WARN('Original WebSocket onmessage handler error:', err);
+ }
+ }
+
+ try {
+ const messageData = event.Data || event.data;
+ const data = typeof messageData === 'string' ? JSON.parse(messageData) : messageData;
+ dispatchMessage(data);
+ } catch (err) {
+ WARN('WebSocket message parse error:', err);
+ }
+ };
+
+ deliveryMode = 'onmessage';
+ LOG('WebSocket onmessage hook attached (legacy fallback)', {
+ readyState: socket.readyState,
+ });
+ return true;
+ } catch (err) {
+ ERR('Error hooking WebSocket onmessage:', err);
+ hookedSocket = null;
+ hookedOriginalOnMessage = null;
+ return false;
+ }
+ }
+
+ function tryInitializeDelivery() {
+ // Prefer JF-web style subscribe (reconnect-safe). Never wrap onmessage when subscribe exists.
+ if (canSubscribe()) {
+ return ensureSubscribeForAllRegisteredTypes();
+ }
+
+ // Fallback: raw socket onmessage with rebind support
+ return hookSocketIfNeeded();
+ }
+
+ function stopPolling() {
+ if (pollingIntervalId == null) return;
+ clearInterval(pollingIntervalId);
+ pollingIntervalId = null;
+ }
+
+ function startPolling() {
+ if (pollingIntervalId != null) return;
+ if (tryInitializeDelivery()) {
+ // Still poll in legacy mode so we rebind if socket is replaced
+ if (deliveryMode === 'subscribe') return;
+ }
+
+ pollingIntervalId = setInterval(() => {
+ if (canSubscribe()) {
+ if (ensureSubscribeForAllRegisteredTypes() && deliveryMode === 'subscribe') {
+ unhookSocket();
+ stopPolling();
+ LOG('Delivery mode: ApiClient.subscribe');
+ return;
+ }
+ // subscribe exists but types not attached yet — keep polling, never hook onmessage
+ return;
+ }
+
+ const socket = getSocket();
+ if (!socket) return;
+
+ if (hookedSocket !== socket) {
+ hookSocketIfNeeded();
+ }
+ }, POLL_INTERVAL_MS);
+ }
+
+ /**
+ * Register a callback to listen for a specific WebSocket message type
+ * @param {string} messageType - e.g. 'UserDataChanged', 'LibraryChanged'
+ * @param {Function} callback - Receives the parsed message object
+ */
+ function listen(messageType, callback) {
+ if (typeof messageType !== 'string' || !messageType) {
+ ERR('listen() requires a non-empty string messageType');
+ return;
+ }
+
+ if (typeof callback !== 'function') {
+ ERR('listen() requires a callback function');
+ return;
+ }
+
+ if (!listeners[messageType]) {
+ listeners[messageType] = [];
+ }
+
+ if (listeners[messageType].includes(callback)) {
+ LOG(`Listener already registered for message type: ${messageType}`);
+ startPolling();
+ return;
+ }
+
+ listeners[messageType].push(callback);
+ LOG(`Registered listener for message type: ${messageType} (${listeners[messageType].length} total)`, {
+ deliveryMode: deliveryMode || 'pending',
+ canSubscribe: canSubscribe(),
+ });
+
+ if (canSubscribe()) {
+ ensureSubscribeForType(messageType);
+ }
+
+ startPolling();
+ }
+
+ /**
+ * Remove a callback registered with listen().
+ * @param {string} messageType
+ * @param {Function} callback
+ */
+ function unlisten(messageType, callback) {
+ if (!listeners[messageType]) return;
+ listeners[messageType] = listeners[messageType].filter((cb) => cb !== callback);
+ LOG(`Unregistered listener for message type: ${messageType} (${listeners[messageType].length} remaining)`);
+
+ if (!listeners[messageType].length && subscribeUnsubs[messageType]) {
+ try {
+ subscribeUnsubs[messageType]();
+ } catch (e) {
+ WARN(`Failed to unsubscribe ${messageType}:`, e);
+ }
+ delete subscribeUnsubs[messageType];
+ delete subscribeAttached[messageType];
+ }
+ }
+
+ window.websocketHelper = {
+ listen,
+ unlisten,
+ /** @returns {'subscribe'|'onmessage'|null} */
+ getDeliveryMode() {
+ return deliveryMode;
+ },
+ };
+
+ // Do not startPolling() here — wait until the first listen() so we never
+ // wrap JF's socket before any consumer needs delivery.
+})();
diff --git a/injector.js b/injector.js
index b0b16c5..faed3b2 100644
--- a/injector.js
+++ b/injector.js
@@ -2,28 +2,12 @@
// Dynamically loads scripts and CSS files based on user configuration
// Usage: Configuration is now managed in kefinTweaks.js - this script reads from window.KefinTweaksConfig
-(function () {
+(function() {
'use strict';
console.log('[KefinTweaks Injector] Initializing...');
- function getAuthHeader() {
- const token = ApiClient.accessToken();
- const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web';
- const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser');
- const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : '';
- const version = ApiClient._appVersion || ApiClient._serverVersion || '';
- const parts = [
- `Client="${encodeURIComponent(client)}"`,
- `Device="${encodeURIComponent(device)}"`,
- `DeviceId="${encodeURIComponent(deviceId)}"`,
- `Version="${encodeURIComponent(version)}"`,
- `Token="${encodeURIComponent(token)}"`
- ];
- return `MediaBrowser ${parts.join(', ')}`;
- }
-
- // Check for global configuration reset
+ // Check for global configuration reset (admin "Reset All Users Local Settings")
try {
const config = window.KefinTweaksConfig || {};
const remoteVersion = config.configVersion || 0;
@@ -31,20 +15,99 @@
if (remoteVersion > localVersion && remoteVersion > 0) {
console.log('[KefinTweaks] Global reset detected. Clearing user configuration.');
- // Only clear the user config to force defaults
- // Remove all keys starting with 'kefinTweaks'
Object.keys(localStorage)
.filter(key => key.startsWith('kefinTweaks'))
.forEach(key => localStorage.removeItem(key));
- // Update local version to avoid loop
localStorage.setItem('kefinTweaks_lastResetVersion', remoteVersion.toString());
}
} catch (e) {
console.error('[KefinTweaks] Error checking for global reset:', e);
}
+
// Cache for resolved root URL (to avoid multiple API calls)
let resolvedRootCache = null;
+ // Cached Jellyfin major (10 / 11 / 12 …); null until resolved or if unavailable
+ let cachedJellyfinMajorVersion = null;
+ function getMajorServerVersion(version) {
+ if (!version || typeof version !== 'string') return null;
+ const versionParts = version.split('.');
+
+ if (versionParts[0] !== '10') {
+ const majorVersion = parseInt(versionParts[0], 10);
+ return Number.isNaN(majorVersion) ? null : majorVersion;
+ }
+
+ if (versionParts.length >= 2) {
+ const majorVersion = parseInt(versionParts[1], 10);
+ if (!Number.isNaN(majorVersion)) {
+ return majorVersion;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Resolve Jellyfin major version (e.g. 10 for 10.10.x, 11 for 10.11.x, 12 for 10.12.x).
+ * Mirrors skinManager detection (Jellyfin Web app version, else server version with brief poll).
+ * @returns {Promise}
+ */
+ async function getCurrentMajorServerVersion() {
+ try {
+ if (cachedJellyfinMajorVersion !== null) {
+ return cachedJellyfinMajorVersion;
+ }
+
+ // Brief wait — injector init can run before ApiClient version fields exist
+ if (!window.ApiClient?._appVersion && !window.ApiClient?._serverVersion) {
+ const startTime = Date.now();
+ while (Date.now() - startTime < 5000) {
+ if (window.ApiClient?._appVersion || window.ApiClient?._serverVersion) {
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+ }
+
+ if (!window.ApiClient) {
+ return null;
+ }
+
+ if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) {
+ cachedJellyfinMajorVersion = getMajorServerVersion(window.ApiClient._appVersion);
+ return cachedJellyfinMajorVersion;
+ }
+
+ if (!window.ApiClient._serverVersion) {
+ const startTime = Date.now();
+ while (Date.now() - startTime < 10000) {
+ if (window.ApiClient._serverVersion) {
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ }
+
+ cachedJellyfinMajorVersion = getMajorServerVersion(window.ApiClient._serverVersion);
+ return cachedJellyfinMajorVersion;
+ } catch (error) {
+ console.warn('[KefinTweaks Injector] Error getting server version:', error);
+ return null;
+ }
+ }
+
+ function isScriptCompatible(scriptDef, majorVersion) {
+ if (!scriptDef?.versions || !scriptDef.versions.length) return true;
+ if (majorVersion == null) return false;
+ return scriptDef.versions.includes(majorVersion);
+ }
+
+ function syncCachedMajorOnApi(majorVersion) {
+ if (window.KefinTweaks) {
+ window.KefinTweaks._jellyfinMajorVersion = majorVersion;
+ }
+ }
+
/**
* Extracts version string from root URL
* @param {string} root - The root URL (e.g., "https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@v0.3.3/")
@@ -54,42 +117,47 @@
if (!root) {
return "Pending Install"; // Fallback
}
-
- // Match version patterns: @commitHash, @latest, @main, or @v0.3.3
+
+ // Match version patterns: @commitHash, @latest, @main, @experimental, or @v0.3.3
// Note: check for hash first to avoid partial numeric matches (e.g. hash starting with digits)
- const versionMatch = root.match(/@([a-f0-9]{7,}|latest|main|v?[\d.]+)/i);
-
+ const versionMatch = root.match(/@([a-f0-9]{7,}|latest|main|experimental|v?[\d.]+)/i);
+
if (versionMatch) {
const version = versionMatch[1];
-
+
// Handle version tags (v0.3.3 -> 0.3.3)
if (version.startsWith('v') && /^\d+\.\d+\.\d+/.test(version.substring(1))) {
return version.substring(1); // Remove 'v' prefix
}
-
- // Handle @main -> "Dev"
+
+ // Handle @main -> "development"
if (version.toLowerCase() === 'main') {
return 'development';
}
-
+
+ // Handle @experimental -> "experimental"
+ if (version.toLowerCase() === 'experimental') {
+ return 'experimental';
+ }
+
// Handle @latest -> "latest"
if (version.toLowerCase() === 'latest') {
return 'latest';
}
-
+
// Handle commit hash -> show as "Dev (#hash)"
if (/^[a-f0-9]{7,}$/i.test(version)) {
return `Dev (#${version.substring(0, 7)})`;
}
-
+
// Return as-is for other patterns
return version;
}
-
+
// No version found in URL, this is a custom install
return "Custom Install";
}
-
+
// Configuration: Start with defaults, then merge user config
// This allows new scripts to work out of the box without requiring config updates
const ENABLED_SCRIPTS = {
@@ -99,7 +167,7 @@
search: true, // Enhanced search functionality
headerTabs: true, // Header tab enhancements
customMenuLinks: true, // Custom menu links functionality
- exclusiveElsewhere: true, // Exclusive elsewhere branding
+ hamburgerMenu: false, // Desktop v12 hamburger / left drawer
updoot: false, // Upvote functionality
backdropLeakFix: true, // Memory leak fixes
dashboardButtonFix: true, // Dashboard button fix
@@ -110,23 +178,46 @@
itemDetailsCollections: true, // Add related collections to item details pages
flattenSingleSeasonShows: true, // Flatten series with only 1 season to show episodes directly
seriesInfo: true, // Add series and season information to details pages
+ versionPreferences: true, // Reorder/sanitize media source versions on details pages
collections: true, // Collection sorting functionality
skinManager: true, // Skin selection and management
-
+ thumbnailScrubber: false, // Trickplay thumbnail scrub on hover (bottom 20px of video cards)
+ watchTogether: false, // Sync playstate to Watch Together group accounts on this device
+ userManager: false, // User templates for new/existing accounts (admin dashboard)
+ games: true, // Sidebar Games link → pages/games.html hub
+
// Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal)
// are automatically enabled when needed by other scripts
-
+
// Merge user config on top of defaults (allows users to override defaults)
...window.KefinTweaksConfig?.scripts
};
+ // Third-party libraries. Relative paths load from {kefinTweaksRoot}/scripts/; http(s) URLs use the CDN loaders.
+ const THIRD_PARTY_SCRIPTS = {
+ pickr: {
+ js: [
+ 'thirdparty/pickr/pickr.min.js'
+ ],
+ css: [
+ 'thirdparty/pickr/pickr-nano.min.css'
+ ]
+ },
+ chartjs: {
+ js: [
+ 'thirdparty/chartjs/chart.js'
+ ],
+ css: []
+ }
+ };
+
// Script definitions with dependencies and metadata
const SCRIPT_DEFINITIONS = [
{
name: 'utils',
script: 'utils.js',
css: null,
- dependencies: ['apiHelper'],
+ dependencies: [],
description: 'Common utilities for page view management and MutationObserver conversion'
},
{
@@ -143,11 +234,195 @@
dependencies: [],
description: 'Default skin configuration for KefinTweaks'
},
+ {
+ name: 'ui',
+ script: 'ui.js',
+ css: null,
+ dependencies: [],
+ description: 'UI helper functions for KefinTweaks'
+ },
+ {
+ name: 'homeScreenConfig',
+ script: 'homeScreenConfig.js',
+ css: null,
+ dependencies: [],
+ description: 'Default home screen configuration for KefinTweaks'
+ },
+ {
+ name: 'homeScreenConfig2',
+ script: 'homeScreenConfig2.js',
+ css: null,
+ dependencies: [],
+ description: 'Default home screen configuration for KefinTweaks'
+ },
+ {
+ name: 'homeScreen-migration',
+ script: '../configuration/homeScreen-migration.js',
+ css: null,
+ dependencies: [],
+ description: 'Home screen migration for KefinTweaks'
+ },
+ {
+ name: 'homeScreen-user-configuration',
+ script: '../configuration/homeScreen-user-configuration.js',
+ css: null,
+ dependencies: ['ui', 'utils'],
+ description: 'User home screen section preferences and bulk order editor'
+ },
+ {
+ name: 'homeScreenSectionConfigure',
+ script: 'homeScreenSectionConfigure.js',
+ css: null,
+ dependencies: ['homeScreen-user-configuration', 'modal', 'ui', 'cardBuilder'],
+ thirdParty: ['pickr'],
+ description: 'Inline configure popover for user home screen sections'
+ },
+ {
+ name: 'homeScreenPin',
+ script: 'homeScreenPin.js',
+ css: null,
+ dependencies: ['homeScreen-user-configuration', 'modal', 'utils'],
+ description: 'Pin items to the home screen from context menus'
+ },
+ {
+ name: 'homeScreenCustomItemsEditor',
+ script: '../configuration/homeScreenCustomItemsEditor.js',
+ css: null,
+ dependencies: [],
+ description: 'Shared custom items editor (wizard + advanced)'
+ },
+ {
+ name: 'homeScreenSectionEditor',
+ script: '../configuration/homeScreenSectionEditor.js',
+ css: '../configuration/homeScreenSectionEditor.css',
+ dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenAdvancedEditor'],
+ description: 'Home screen section editor (wizard + advanced)'
+ },
+ {
+ name: 'homeScreenEditorProfiles',
+ script: '../configuration/homeScreenEditorProfiles.js',
+ css: null,
+ dependencies: [],
+ description: 'Editor profile registry for home screen section editor'
+ },
+ {
+ name: 'homeScreenAdvancedEditor',
+ script: '../configuration/homeScreenAdvancedEditor.js',
+ css: '../configuration/homeScreenAdvancedEditor.css',
+ dependencies: ['ui', 'homeScreenCustomItemsEditor', 'homeScreenEditorProfiles'],
+ thirdParty: ['pickr'],
+ description: 'Advanced section editor UI for custom sections'
+ },
+ {
+ name: 'homescreenBenchmark',
+ script: '../configuration/homescreenBenchmark.js',
+ css: null,
+ dependencies: ['apiHelper'],
+ description: 'Home screen section query benchmarks for admin config'
+ },
+ {
+ name: 'homeScreen-configuration',
+ script: '../configuration/homeScreen-configuration.js',
+ css: '../configuration/homeScreen-configuration.css',
+ dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui', 'homeScreenCustomItemsEditor', 'homeScreenSectionEditor', 'homescreenBenchmark', 'sectionHelper'],
+ description: 'Home screen configuration for KefinTweaks'
+ },
+ {
+ name: 'search-configuration',
+ script: '../configuration/search-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils'],
+ description: 'Search configuration for KefinTweaks'
+ },
+ {
+ name: 'seriesEpisodes-configuration',
+ script: '../configuration/seriesEpisodes-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils'],
+ description: 'Series episodes configuration for KefinTweaks'
+ },
+ {
+ name: 'seriesInfo-configuration',
+ script: '../configuration/seriesInfo-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils'],
+ description: 'Series info configuration for KefinTweaks'
+ },
+ {
+ name: 'versionPreferences-configuration',
+ script: '../configuration/versionPreferences-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils'],
+ description: 'Item version preferences configuration for KefinTweaks'
+ },
+ {
+ name: 'skinManager-configuration',
+ script: '../configuration/skinManager-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils'],
+ description: 'Skin manager configuration for KefinTweaks'
+ },
+ {
+ name: 'customMenuLinks-configuration',
+ script: '../configuration/customMenuLinks-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils'],
+ description: 'Custom menu links configuration for KefinTweaks'
+ },
+ {
+ name: 'homeScreenConfigCommunity',
+ script: 'homeScreenConfig-community.js',
+ css: null,
+ dependencies: [],
+ description: 'Community home screen sections for KefinTweaks'
+ },
+ {
+ name: 'peopleCache',
+ script: 'peopleCache.js',
+ css: null,
+ dependencies: ['libraryCacheUtils', 'userHelper', 'moviesCache', 'seriesCache', 'indexedDBCache', 'apiHelper'],
+ description: 'People cache for KefinTweaks'
+ },
+ {
+ name: 'studiosCache',
+ script: 'studiosCache.js',
+ css: null,
+ dependencies: [],
+ description: 'Studios cache for KefinTweaks'
+ },
+ {
+ name: 'libraryCacheUtils',
+ script: 'libraryCacheUtils.js',
+ css: null,
+ dependencies: [],
+ description: 'Shared strip/provider helpers for library caches'
+ },
+ {
+ name: 'moviesCache',
+ script: 'moviesCache.js',
+ css: null,
+ dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'],
+ description: 'Movies library cache for KefinTweaks'
+ },
+ {
+ name: 'seriesCache',
+ script: 'seriesCache.js',
+ css: null,
+ dependencies: ['libraryCacheUtils', 'userHelper', 'indexedDBCache', 'apiHelper'],
+ description: 'Series library cache for KefinTweaks'
+ },
+ {
+ name: 'libraryCache',
+ script: 'libraryCache.js',
+ css: null,
+ dependencies: ['moviesCache', 'seriesCache'],
+ description: 'Library cache facade for KefinTweaks'
+ },
{
name: 'skinManager',
script: 'skinManager.js',
css: 'defaultSkin.css',
- dependencies: ['utils', 'skinConfigLegacyDefaults', 'skinConfig', 'modal', 'apiHelper'],
+ dependencies: ['utils', 'skinConfigLegacyDefaults', 'skinConfig', 'modal'],
priority: true, // Load immediately after dependencies to reduce UI disruption
description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading'
},
@@ -155,14 +430,21 @@
name: 'apiHelper',
script: 'apiHelper.js',
css: null,
- dependencies: [],
+ dependencies: ['dataHelper'],
description: 'API helper functions for common Jellyfin operations'
},
+ {
+ name: 'sectionHelper',
+ script: 'sectionHelper.js',
+ css: null,
+ dependencies: ['apiHelper', 'cardBuilder', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'homeScreenConfig2', 'dataHelper'],
+ description: 'Section loading and progressive discovery resolve helpers'
+ },
{
name: 'cardBuilder',
script: 'cardBuilder.js',
css: 'cardBuilder.css',
- dependencies: ['apiHelper'],
+ dependencies: ['apiHelper', 'userHelper', 'websocketHelper'],
description: 'Core card building functionality (required by other scripts)'
},
{
@@ -186,6 +468,55 @@
dependencies: [],
description: 'Generic modal system for Jellyfin-style dialogs'
},
+ {
+ name: 'userHelper',
+ script: 'userHelper.js',
+ css: null,
+ dependencies: [],
+ description: 'User helper functions for Jellyfin operations'
+ },
+ {
+ name: 'dataHelper',
+ script: '../helpers/dataHelper.js',
+ css: null,
+ dependencies: [],
+ description: 'Data helper functions for Jellyfin operations'
+ },
+ {
+ name: 'websocketHelper',
+ script: '../helpers/websocketHelper.js',
+ css: null,
+ dependencies: [],
+ description: 'WebSocket helper functions for Jellyfin operations'
+ },
+ {
+ name: 'watchTogether-configuration',
+ script: '../configuration/watchTogether-configuration.js',
+ css: null,
+ dependencies: ['modal', 'ui'],
+ description: 'Configuration UI for Watch Together'
+ },
+ {
+ name: 'watchTogether',
+ script: 'watchTogether.js',
+ css: null,
+ dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'websocketHelper', 'ui', 'watchTogether-configuration'],
+ description: 'Watch Together: sync watched/in-progress state to selected group accounts'
+ },
+ {
+ name: 'userManager-configuration',
+ script: '../configuration/userManager-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils', 'apiHelper', 'ui'],
+ description: 'Configuration UI for User Manager templates'
+ },
+ {
+ name: 'userManager',
+ script: 'userManager.js',
+ css: 'userManager.css',
+ dependencies: ['utils', 'modal', 'apiHelper', 'userHelper', 'toaster', 'userManager-configuration'],
+ description: 'User Manager: apply policy templates on new user creation and bulk-edit existing users'
+ },
{
name: 'toaster',
script: 'toaster.js',
@@ -193,18 +524,34 @@
dependencies: [],
description: 'Toast notification system using Jellyfin\'s existing toast functionality'
},
+ {
+ name: 'statistics',
+ script: 'statistics.js',
+ css: null,
+ dependencies: [],
+ description: 'Watchlist statistics charts and activity analytics'
+ },
+ {
+ name: 'watchlist-configuration',
+ script: '../configuration/watchlist-configuration.js',
+ css: null,
+ dependencies: ['modal', 'ui'],
+ description: 'Configuration UI for Watchlist menu placements'
+ },
{
name: 'watchlist',
script: 'watchlist.js',
css: 'watchlist.css',
- dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils'],
+ dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics', 'watchlist-configuration', 'websocketHelper'],
+ thirdParty: ['chartjs'],
description: 'Adds watchlist functionality throughout Jellyfin interface'
},
{
name: 'homeScreen',
- script: 'homeScreen.js',
+ script: 'homeScreen3.js',
css: 'homeScreen.css',
- dependencies: ['cardBuilder', 'localStorageCache', 'utils'],
+ dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'userHelper', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'libraryCacheUtils', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'sectionHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'],
+ priority: true, // Load immediately after dependencies to reduce UI disruption
description: 'Adds custom home screen sections'
},
{
@@ -218,7 +565,7 @@
name: 'headerTabs',
script: 'headerTabs.js',
css: null,
- dependencies: ['apiHelper'],
+ dependencies: [],
description: 'Header tab improvements'
},
{
@@ -229,24 +576,33 @@
description: 'Load and add custom menu links from configuration'
},
{
- name: 'exclusiveElsewhere',
- script: 'exclusiveElsewhere.js',
+ name: 'hamburgerMenu',
+ script: 'hamburgerMenu.js',
css: null,
- dependencies: [],
- description: 'Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality to add custom branding when items are not available on streaming services'
+ dependencies: ['utils'],
+ versions: [12],
+ description: 'Desktop hamburger button that opens a v12-style left navigation drawer'
+ },
+ {
+ name: 'games',
+ script: 'games.js',
+ css: null,
+ dependencies: ['utils'],
+ description: 'Adds a Games sidebar link to the games hub page'
},
{
name: 'backdropLeakFix',
script: 'backdropLeakFix.js',
css: null,
dependencies: [],
+ versions: [10, 11],
description: 'Fixes issue that causes backdrop images to be continuously added to the page if the tab isn\'t focused.'
},
{
name: 'updoot',
script: 'updoot.js',
css: null,
- dependencies: ['apiHelper'],
+ dependencies: [],
description: 'Upvote functionality provided by https://github.com/BobHasNoSoul/jellyfin-updoot'
},
{
@@ -254,8 +610,23 @@
script: 'dashboardButtonFix.js',
css: null,
dependencies: [],
+ versions: [10, 11],
description: 'Fixes the dashboard button to redirect to the home page when the back button is clicked and there is no history to go back to'
},
+ {
+ name: 'thumbnailScrubber-configuration',
+ script: '../configuration/thumbnailScrubber-configuration.js',
+ css: null,
+ dependencies: ['modal', 'toaster', 'utils'],
+ description: 'Thumbnail scrubber configuration for KefinTweaks'
+ },
+ {
+ name: 'thumbnailScrubber',
+ script: 'thumbnailScrubber.js',
+ css: null,
+ dependencies: ['thumbnailScrubber-configuration'],
+ description: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card for 2+ seconds'
+ },
{
name: 'infiniteScroll',
script: 'infiniteScroll.js',
@@ -267,14 +638,14 @@
name: 'removeContinue',
script: 'removeContinue.js',
css: null,
- dependencies: ['apiHelper'],
+ dependencies: [],
description: 'Adds remove from continue watching functionality to cards with data-position-ticks'
},
{
name: 'subtitleSearch',
script: 'subtitleSearch.js',
css: 'subtitleSearch.css',
- dependencies: ['toaster', 'apiHelper'],
+ dependencies: ['toaster'],
description: 'Adds subtitle search functionality to the video OSD, allowing users to search and download subtitles from remote sources'
},
{
@@ -296,6 +667,7 @@
script: 'itemDetailsCollections.js',
css: null,
dependencies: ['indexedDBCache', 'utils', 'cardBuilder'],
+ versions: [10, 11],
description: 'Adds related collections to item details pages showing which collections contain the current item'
},
{
@@ -312,6 +684,13 @@
dependencies: ['utils'],
description: 'Adds series and season information (seasons count, episodes count, end time) to details pages'
},
+ {
+ name: 'versionPreferences',
+ script: 'versionPreferences.js',
+ css: null,
+ dependencies: ['utils'],
+ description: 'Reorders and sanitizes media source version/edition options on item details pages'
+ },
{
name: 'collections',
script: 'collections.js',
@@ -326,35 +705,44 @@
//const urlSuffix = `?v=${new Date().getTime()}`;
// Inject dashboard drawer version badge CSS using dynamic version number
+ function isFloatingKefinRootUrl(root) {
+ return /@(latest|main|master|experimental)(\/|$)/i.test(String(root || ''));
+ }
+
async function injectVersionBadgeCSS() {
const styleId = 'kefinTweaks-version-css';
if (document.getElementById(styleId)) return;
-
- // Get version from root URL in config
+
+ // Prefer pinned resolved root; GitHub resolve only for admins on floating symbolic roots
let displayVersion = "";
try {
- const configRoot = window.KefinTweaksConfig?.kefinTweaksRoot || '';
+ const cfg = window.KefinTweaksConfig || {};
+ let configRoot = cfg.kefinTweaksRootResolved || cfg.kefinTweaksRoot || '';
if (configRoot) {
- // Resolve the root to get the actual version (if @latest or @main)
- const resolvedRoot = await resolveRootVersion(configRoot);
- displayVersion = extractVersionFromRoot(resolvedRoot);
+ if (isFloatingKefinRootUrl(configRoot)) {
+ const isAdmin = await checkAdminWithTimeout(2000);
+ if (isAdmin) {
+ configRoot = await resolveRootVersion(configRoot);
+ }
+ }
+ displayVersion = extractVersionFromRoot(configRoot);
}
} catch (error) {
console.warn('[KefinTweaks Injector] Could not extract version, using fallback:', error);
}
-
+
// Format version for display (add 'v' prefix if it's a version number, not for 'latest' or 'development')
let versionDisplay = displayVersion;
- if (!displayVersion.startsWith('v') &&
- !displayVersion.includes('latest') &&
+ if (!displayVersion.startsWith('v') &&
+ !displayVersion.includes('latest') &&
!displayVersion.includes('development') &&
/^\d+\.\d+/.test(displayVersion)) {
versionDisplay = 'v' + displayVersion;
}
-
+
const style = document.createElement('style');
style.id = styleId;
- style.textContent = `
+/* style.textContent = `
body:not(:has(.libraryPage:not(.hide))) .MuiPaper-root.MuiDrawer-paperAnchorLeft::after {
content: 'KefinTweaks ${versionDisplay}';
font-style: italic;
@@ -365,7 +753,7 @@
display: block;
}
`;
- document.head.appendChild(style);
+ document.head.appendChild(style); */
}
// Auto-enable dependencies for enabled scripts (iteratively to handle transitive dependencies)
@@ -373,11 +761,11 @@
let hasChanges = false;
let maxIterations = 10; // Prevent infinite loops
let iterations = 0;
-
+
// Keep running until no more dependencies need to be enabled
while (iterations < maxIterations) {
let iterationChanges = false;
-
+
SCRIPT_DEFINITIONS.forEach(script => {
if (ENABLED_SCRIPTS[script.name]) {
script.dependencies.forEach(dep => {
@@ -390,31 +778,31 @@
});
}
});
-
+
if (!iterationChanges) {
break; // No more dependencies to enable
}
-
+
iterations++;
}
-
+
if (iterations >= maxIterations) {
console.warn('[KefinTweaks Injector] Reached max iterations while auto-enabling dependencies');
}
-
+
return hasChanges;
}
-
+
// Configuration validation
function validateConfiguration() {
const errors = [];
-
+
// Auto-enable dependencies first
const dependenciesEnabled = autoEnableDependencies();
if (dependenciesEnabled) {
console.log('[KefinTweaks Injector] Auto-enabled required dependencies');
}
-
+
// Check dependencies - only validate if the script is enabled
SCRIPT_DEFINITIONS.forEach(script => {
if (ENABLED_SCRIPTS[script.name]) {
@@ -425,21 +813,22 @@
});
}
});
-
+
if (errors.length > 0) {
console.error('[KefinTweaks Injector] Configuration errors:', errors);
return false;
}
-
+
return true;
}
-
- // Resolve @latest or @main to actual version/commit hash
+
+ // Resolve @latest, @main, or @experimental to actual version/commit hash (so user always loads latest from that ref)
async function resolveRootVersion(root) {
- // Check if root contains @latest or @main
+ // Check if root contains @latest, @main, or @experimental
const latestMatch = root.match(/@latest(\/|$)/);
const mainMatch = root.match(/@main(\/|$)/);
-
+ const experimentalMatch = root.match(/@experimental(\/|$)/);
+
if (latestMatch) {
// Fetch the latest release version number
try {
@@ -481,37 +870,64 @@
console.warn('[KefinTweaks Injector] Error fetching commit hash:', error);
return root;
}
+ } else if (experimentalMatch) {
+ // Fetch the latest commit hash from the experimental branch
+ try {
+ const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/experimental');
+ if (response.ok) {
+ const commit = await response.json();
+ if (commit && commit.sha) {
+ return root.replace('@experimental', `@${commit.sha}`);
+ } else {
+ console.warn('[KefinTweaks Injector] Could not fetch experimental commit hash, using @experimental');
+ return root;
+ }
+ } else {
+ console.warn('[KefinTweaks Injector] Failed to fetch experimental commit hash, using @experimental');
+ return root;
+ }
+ } catch (error) {
+ console.warn('[KefinTweaks Injector] Error fetching experimental commit hash:', error);
+ return root;
+ }
}
-
+
// No resolution needed
return root;
}
-
+
// Get the root path for scripts from configuration
+ // Prefer kefinTweaksRootResolved; GitHub floating resolve only for admins.
async function getScriptRoot() {
- const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot;
-
- // If kefinTweaksRoot is not set or empty, return null (scripts should not load)
+ const cfg = window.KefinTweaksConfig || {};
+ const kefinTweaksRoot = cfg.kefinTweaksRootResolved || cfg.kefinTweaksRoot;
+
+ // If root is not set or empty, return null (scripts should not load)
if (!kefinTweaksRoot || kefinTweaksRoot === '') {
console.warn('[KefinTweaks Injector] kefinTweaksRoot is not configured');
return null;
}
-
+
// Ensure root ends with /
let root = kefinTweaksRoot.endsWith('/') ? kefinTweaksRoot : kefinTweaksRoot + '/';
-
- // Resolve @latest or @main if needed (with caching)
+
+ // Resolve floating refs only when still floating and user is admin (with caching)
if (resolvedRootCache === null) {
- root = await resolveRootVersion(root);
+ if (isFloatingKefinRootUrl(root)) {
+ const isAdmin = await checkAdminWithTimeout(2000);
+ if (isAdmin) {
+ root = await resolveRootVersion(root);
+ }
+ }
resolvedRootCache = root;
} else {
root = resolvedRootCache;
}
-
+
// Return scripts path
return root + 'scripts/';
}
-
+
// Load a CSS file
async function loadCSS(filename) {
return new Promise(async (resolve, reject) => {
@@ -520,7 +936,7 @@
reject(new Error('kefinTweaksRoot is not configured'));
return;
}
-
+
// Check if CSS is already loaded (match by root and filename, ignore suffix)
const existingLink = document.querySelector(`link[href*="${scriptRoot}${filename}"]`);
if (existingLink) {
@@ -528,26 +944,26 @@
resolve();
return;
}
-
+
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = `${scriptRoot}${filename}${urlSuffix}`;
-
+
link.onload = () => {
console.log(`[KefinTweaks Injector] CSS loaded: ${filename}`);
resolve();
};
-
+
link.onerror = () => {
console.warn(`[KefinTweaks Injector] Failed to load CSS: ${filename}`);
reject(new Error(`Failed to load CSS: ${filename}`));
};
-
+
document.head.appendChild(link);
});
}
-
+
// Load a JavaScript file
async function loadScript(filename) {
return new Promise(async (resolve, reject) => {
@@ -556,7 +972,7 @@
reject(new Error('kefinTweaksRoot is not configured'));
return;
}
-
+
// Check if script is already loaded
const existingScript = document.querySelector(`script[src*="${scriptRoot}${filename}"]`);
if (existingScript) {
@@ -564,25 +980,137 @@
resolve();
return;
}
-
+
const script = document.createElement('script');
script.src = `${scriptRoot}${filename}${urlSuffix}`;
script.async = true;
-
+
script.onload = () => {
console.log(`[KefinTweaks Injector] Script loaded: ${filename}`);
resolve();
};
-
+
script.onerror = () => {
console.error(`[KefinTweaks Injector] Failed to load script: ${filename}`);
reject(new Error(`Failed to load script: ${filename}`));
};
+
+ document.head.appendChild(script);
+ });
+ }
+
+ /**
+ * Load an absolute-URL stylesheet (CDN / third-party). Dedupes via DOM.
+ * @param {string} url
+ */
+ async function loadExternalCSS(url) {
+ return new Promise((resolve, reject) => {
+ if (!url) {
+ resolve();
+ return;
+ }
+ const existingLink = document.querySelector(`link[href="${url}"]`);
+ if (existingLink) {
+ console.log(`[KefinTweaks Injector] External CSS already loaded: ${url}`);
+ resolve();
+ return;
+ }
+
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.type = 'text/css';
+ link.href = url;
+
+ link.onload = () => {
+ console.log(`[KefinTweaks Injector] External CSS loaded: ${url}`);
+ resolve();
+ };
+ link.onerror = () => {
+ console.warn(`[KefinTweaks Injector] Failed to load external CSS: ${url}`);
+ reject(new Error(`Failed to load external CSS: ${url}`));
+ };
+
+ document.head.appendChild(link);
+ });
+ }
+
+ /**
+ * Load an absolute-URL script (CDN / third-party). Dedupes via DOM.
+ * @param {string} url
+ */
+ async function loadExternalScript(url) {
+ return new Promise((resolve, reject) => {
+ if (!url) {
+ resolve();
+ return;
+ }
+ const existingScript = document.querySelector(`script[src="${url}"]`);
+ if (existingScript) {
+ console.log(`[KefinTweaks Injector] External script already loaded: ${url}`);
+ resolve();
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = url;
+ script.async = true;
+
+ script.onload = () => {
+ console.log(`[KefinTweaks Injector] External script loaded: ${url}`);
+ resolve();
+ };
+ script.onerror = () => {
+ console.error(`[KefinTweaks Injector] Failed to load external script: ${url}`);
+ reject(new Error(`Failed to load external script: ${url}`));
+ };
document.head.appendChild(script);
});
}
+ function isAbsoluteUrl(url) {
+ return /^https?:\/\//i.test(url);
+ }
+
+ /**
+ * Load a named third-party library from THIRD_PARTY_SCRIPTS (css then js).
+ * Relative paths load from the script root; http(s) URLs use the external loaders.
+ * @param {string} name
+ */
+ async function loadThirdParty(name) {
+ const def = THIRD_PARTY_SCRIPTS[name];
+ if (!def) {
+ console.warn(`[KefinTweaks Injector] Unknown thirdParty '${name}' — skipping`);
+ return;
+ }
+
+ const cssUrls = Array.isArray(def.css) ? def.css : [];
+ const jsUrls = Array.isArray(def.js) ? def.js : [];
+
+ for (const url of cssUrls) {
+ try {
+ if (isAbsoluteUrl(url)) {
+ await loadExternalCSS(url);
+ } else {
+ await loadCSS(url);
+ }
+ } catch (error) {
+ console.warn(`[KefinTweaks Injector] Third-party CSS failed for '${name}':`, error);
+ }
+ }
+ for (const url of jsUrls) {
+ try {
+ if (isAbsoluteUrl(url)) {
+ await loadExternalScript(url);
+ } else {
+ await loadScript(url);
+ }
+ } catch (error) {
+ console.warn(`[KefinTweaks Injector] Third-party script failed for '${name}':`, error);
+ }
+ }
+ }
+
// Recursively collect all dependencies for a given script
function collectAllDependencies(scriptDef, collected = new Set(), visited = new Set()) {
// Avoid infinite recursion
@@ -590,19 +1118,19 @@
return collected;
}
visited.add(scriptDef.name);
-
+
// For each dependency
for (const depName of scriptDef.dependencies) {
// Only process enabled dependencies
if (!ENABLED_SCRIPTS[depName]) {
continue;
}
-
+
const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName);
if (!depScript) {
continue;
}
-
+
// Add to collected if we haven't already
if (!collected.has(depScript.name)) {
collected.add(depScript.name);
@@ -610,10 +1138,10 @@
collectAllDependencies(depScript, collected, visited);
}
}
-
+
return collected;
}
-
+
// Load a single script (assumes dependencies are already loaded)
async function loadScriptSync(scriptDef) {
const scriptRoot = await getScriptRoot();
@@ -621,26 +1149,56 @@
throw new Error('kefinTweaksRoot is not configured');
}
+ for (const thirdPartyName of scriptDef.thirdParty || []) {
+ await loadThirdParty(thirdPartyName);
+ }
+
// Check if already loaded (match by root and filename, ignore suffix)
const isAlreadyLoaded = document.querySelector(`script[src*="${scriptRoot}${scriptDef.script}"]`);
if (isAlreadyLoaded) {
console.log(`[KefinTweaks Injector] Script already loaded: ${scriptDef.name}`);
return;
}
-
+
// Load CSS if specified
if (scriptDef.css) {
await loadCSS(scriptDef.css);
}
-
+
// Load the script
await loadScript(scriptDef.script);
-
+
console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`);
}
+
+ async function ensureKefinTweaksLoader(scriptRoot) {
+ if (window.KefinTweaksLoader) return window.KefinTweaksLoader;
+ const base = scriptRoot.endsWith('/') ? scriptRoot : scriptRoot + '/';
+ const url = base + 'kefinTweaks-loader.js';
+ await new Promise((resolve, reject) => {
+ if (document.querySelector('script[src="' + url + '"]')) {
+ const start = Date.now();
+ const wait = () => {
+ if (window.KefinTweaksLoader) return resolve();
+ if (Date.now() - start > 15000) return reject(new Error('Timed out waiting for KefinTweaksLoader'));
+ setTimeout(wait, 50);
+ };
+ return wait();
+ }
+ const script = document.createElement('script');
+ script.src = url;
+ script.async = false;
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error('Failed to load kefinTweaks-loader.js'));
+ document.head.appendChild(script);
+ });
+ if (!window.KefinTweaksLoader) throw new Error('KefinTweaksLoader not available');
+ return window.KefinTweaksLoader;
+ }
+
async function loadConfigurationJS() {
- const configDependencyNames = ['modal', 'toaster', 'apiHelper', 'utils', 'indexedDBCache'];
+ const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'watchlist-configuration', 'userManager-configuration', 'apiHelper', 'sectionHelper'];
for (const depName of configDependencyNames) {
const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName);
if (depScript) {
@@ -651,15 +1209,20 @@
}
}
}
-
+
// Load configuration UI so admins can re-enable KefinTweaks
await loadScript('configuration.js');
await loadCSS('configuration.css');
}
-
- // Main initialization function
+
+ // Main initialization: live load plan (fills gaps vs baked preload) + stamp sync
async function initialize() {
- console.log(`[KefinTweaks Injector] Starting KefinTweaks initialization...`);
+ console.log('[KefinTweaks Injector] Starting KefinTweaks initialization...');
+
+ const bakeAlreadyAttached = !!(
+ window.KefinTweaksScriptsPreloaded
+ || window.KefinTweaksInjectorAttached
+ );
const scriptRoot = await getScriptRoot();
if (!scriptRoot) {
@@ -667,150 +1230,102 @@
return;
}
- // Check if KefinTweaks is enabled in config
- const isKefinTweaksEnabled = window.KefinTweaksConfig?.enabled !== false;
-
- if (!isKefinTweaksEnabled) {
- console.log('[KefinTweaks Injector] KefinTweaks is disabled in configuration. Only loading configuration UI requirements for admin access.');
-
- try {
- // Ensure configuration dependencies are loaded so the admin UI works
- await loadConfigurationJS();
- console.log('[KefinTweaks Injector] Configuration script loaded (KefinTweaks is disabled)');
- } catch (error) {
- console.warn('[KefinTweaks Injector] Failed to load configuration UI while disabled:', error);
- }
+ let Loader;
+ try {
+ Loader = await ensureKefinTweaksLoader(scriptRoot);
+ } catch (err) {
+ console.error('[KefinTweaks Injector] Failed to load kefinTweaks-loader.js:', err);
return;
}
- // Validate configuration
- if (!validateConfiguration()) {
- console.error('[KefinTweaks Injector] Configuration validation failed. Aborting.');
- return;
- }
+ Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
+ const majorVersion = await window.KefinTweaks.getJellyfinMajorVersion();
+ console.log('[KefinTweaks Injector] Jellyfin major version:', majorVersion ?? 'unknown');
- // Get enabled scripts
- const enabledScripts = SCRIPT_DEFINITIONS.filter(script => ENABLED_SCRIPTS[script.name]);
-
- // Step 1: Collect all dependencies from all enabled scripts
- const allDependencyNames = new Set();
- for (const script of enabledScripts) {
- const deps = collectAllDependencies(script);
- deps.forEach(dep => allDependencyNames.add(dep));
- }
-
- // Step 2: Separate dependencies from non-dependencies
- const dependencyScripts = SCRIPT_DEFINITIONS.filter(script =>
- allDependencyNames.has(script.name) && ENABLED_SCRIPTS[script.name]
- );
-
- const nonDependencyScripts = enabledScripts.filter(script =>
- !allDependencyNames.has(script.name)
- );
-
- // Step 2.5: Separate priority scripts from regular non-dependency scripts
- const priorityScripts = nonDependencyScripts.filter(script => script.priority === true);
- const regularScripts = nonDependencyScripts.filter(script => !script.priority);
-
- console.log(`[KefinTweaks Injector] Found ${dependencyScripts.length} dependency scripts:`,
- dependencyScripts.map(s => s.name));
- console.log(`[KefinTweaks Injector] Found ${priorityScripts.length} priority scripts:`,
- priorityScripts.map(s => s.name));
- console.log(`[KefinTweaks Injector] Found ${regularScripts.length} regular scripts:`,
- regularScripts.map(s => s.name));
+ const config = window.KefinTweaksConfig || {};
+ const root = Loader.getResolvedKefinRoot
+ ? Loader.getResolvedKefinRoot(config)
+ : Loader.normalizeRoot(config.kefinTweaksRootResolved || config.kefinTweaksRoot || '');
try {
- // Step 3: Load all dependencies first
- console.log('[KefinTweaks Injector] Loading dependencies first...');
- console.log(`[KefinTweaks Injector] Dependency load order:`, dependencyScripts.map(s => s.name));
-
- let dependencyLoadPromises = [];
- dependencyLoadPromises.push(...dependencyScripts.map(script => loadScriptSync(script)));
- await Promise.all(dependencyLoadPromises);
-
- // Step 4: Load priority scripts immediately after dependencies
- if (priorityScripts.length > 0) {
- console.log('[KefinTweaks Injector] Loading priority scripts...');
- console.log(`[KefinTweaks Injector] Priority load order:`, priorityScripts.map(s => s.name));
- let priorityLoadPromises = [];
- priorityLoadPromises.push(...priorityScripts.map(script => loadScriptSync(script)));
- await Promise.all(priorityLoadPromises);
- }
-
- // Step 5: Load regular non-dependencies in parallel (their dependencies are already loaded)
- let loadPromises = [];
- console.log('[KefinTweaks Injector] Loading regular non-dependencies...');
- loadPromises.push(...regularScripts.map(script => loadScriptSync(script)));
- await Promise.all(loadPromises);
-
- console.log('[KefinTweaks Injector] All scripts loaded successfully!');
-
- // Always load configuration.js for admin UI (loads after other scripts)
- console.log('[KefinTweaks Injector] Loading configuration script...');
- try {
- await loadConfigurationJS();
- console.log('[KefinTweaks Injector] Configuration script loaded successfully');
- } catch (error) {
- console.warn('[KefinTweaks Injector] Failed to load configuration script:', error);
- }
-
- // Dispatch custom event when all scripts are loaded
- const event = new CustomEvent('kefinTweaksLoaded', {
- detail: {
- loadedScripts: enabledScripts.map(s => s.name),
- timestamp: new Date().toISOString()
- }
+ const plan = Loader.buildLoadPlan(config, majorVersion, {
+ root,
+ urlSuffix,
+ configOnly: config.enabled === false
});
- document.dispatchEvent(event);
+ if (bakeAlreadyAttached) {
+ // Belt-and-suspenders: never dual-inject when bake already ran.
+ // Tip re-pin / bake rewrite is owned by kefinTweaks-plugin tip-sync path.
+ console.log('[KefinTweaks Injector] Bake already attached; skipping applyAssetsToDocument (stamp=', plan.stamp, ')');
+ return;
+ }
+
+ console.log('[KefinTweaks Injector] Applying ordered assets:', plan.assets.length, 'stamp=', plan.stamp);
+ Loader.applyAssetsToDocument(plan.assets);
+ document.dispatchEvent(new CustomEvent('kefinTweaksLoaded', {
+ detail: {
+ loadedScripts: plan.scriptNames,
+ stamp: plan.stamp,
+ timestamp: new Date().toISOString(),
+ live: true
+ }
+ }));
+ console.log('[KefinTweaks Injector] Live ordered inject complete');
+
+ if (typeof Loader.syncKefinTweaksInjector === 'function') {
+ const syncResult = await Loader.syncKefinTweaksInjector(plan);
+ if (syncResult?.synced) {
+ console.log('[KefinTweaks Injector] Baked KefinTweaks-injector synced to stamp:', syncResult.stamp);
+ } else if (syncResult?.reason && syncResult.reason !== 'stamp match' && syncResult.reason !== 'not admin' && syncResult.reason !== 'not logged in') {
+ console.warn('[KefinTweaks Injector] Injector sync skipped:', syncResult.reason);
+ } else {
+ console.log('[KefinTweaks Injector] Injector sync:', syncResult?.reason || 'done');
+ }
+ }
} catch (error) {
console.error('[KefinTweaks Injector] Error during initialization:', error);
}
}
+
// Utility functions for debugging and configuration
- window.KefinTweaks = {
- // Get current configuration
- getConfig: () => ({ ...ENABLED_SCRIPTS }),
-
- // Get script definitions
- getScripts: () => [...SCRIPT_DEFINITIONS],
-
- // Check if a script is loaded
- isScriptLoaded: (scriptName) => {
- const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
- if (!scriptDef) return false;
-
- const scriptElement = document.querySelector(`script[src*="${scriptDef.script}"]`);
- return !!scriptElement;
- },
-
- // Reload all scripts (useful for development)
- reload: () => {
- console.log('[KefinTweaks Injector] Reloading all scripts...');
- initialize();
- },
-
- // Load a specific script (useful for dynamic loading)
- loadScript: async (scriptName) => {
- const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
- if (!scriptDef) {
- throw new Error(`Script '${scriptName}' not found`);
+ // Populated in initialize via KefinTweaksLoader.ensureKefinTweaksApi; stub for early callers
+ window.KefinTweaks = window.KefinTweaks || {
+ _jellyfinMajorVersion: null,
+ parseMajorVersion: function (version) {
+ if (!version || typeof version !== 'string') return null;
+ const versionParts = version.split('.');
+ if (versionParts[0] !== '10') {
+ const majorVersion = parseInt(versionParts[0], 10);
+ return Number.isNaN(majorVersion) ? null : majorVersion;
}
-
- if (!ENABLED_SCRIPTS[scriptName]) {
- throw new Error(`Script '${scriptName}' is disabled in configuration`);
+ if (versionParts.length >= 2) {
+ const majorVersion = parseInt(versionParts[1], 10);
+ if (!Number.isNaN(majorVersion)) return majorVersion;
}
-
- await loadScriptWithDependencies(scriptDef);
- }
+ return null;
+ },
+ getJellyfinMajorVersion: async function () {
+ if (window.KefinTweaksLoader) {
+ window.KefinTweaksLoader.ensureKefinTweaksApi();
+ return window.KefinTweaks.getJellyfinMajorVersion();
+ }
+ return this._jellyfinMajorVersion;
+ },
+ getScripts: function () {
+ return (window.KefinTweaksLoader && window.KefinTweaksLoader.SCRIPT_DEFINITIONS)
+ ? window.KefinTweaksLoader.SCRIPT_DEFINITIONS.slice()
+ : (typeof SCRIPT_DEFINITIONS !== 'undefined' ? SCRIPT_DEFINITIONS.slice() : []);
+ },
+ reload: function () { initialize(); }
};
// Check if user is admin (with timeout for login)
async function checkAdminWithTimeout(maxWaitMs = 5000) {
const startTime = Date.now();
const checkInterval = 100; // Check every 100ms
-
+
return new Promise((resolve) => {
const checkAdmin = async () => {
try {
@@ -831,18 +1346,18 @@
} catch (error) {
// User might not be logged in yet, continue waiting
}
-
+
// Check if we've exceeded max wait time
if (Date.now() - startTime >= maxWaitMs) {
console.log('[KefinTweaks Startup] Timeout waiting for user login');
resolve(false);
return;
}
-
+
// Continue checking
setTimeout(checkAdmin, checkInterval);
};
-
+
checkAdmin();
});
}
@@ -855,10 +1370,11 @@
}
const server = ApiClient._serverAddress;
+ const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins`, {
headers: {
- 'Authorization': getAuthHeader()
+ 'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -868,7 +1384,7 @@
const plugins = await response.json();
const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []);
-
+
const plugin = pluginsList.find(p => p.Name === pluginName);
return plugin ? plugin.Id : null;
} catch (error) {
@@ -885,10 +1401,11 @@
}
const server = ApiClient._serverAddress;
+ const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
headers: {
- 'Authorization': getAuthHeader()
+ 'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -911,11 +1428,12 @@
}
const server = ApiClient._serverAddress;
+ const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
method: 'POST',
headers: {
- 'Authorization': getAuthHeader(),
+ 'Authorization': window.apiHelper.getAuthHeader(),
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
@@ -934,6 +1452,7 @@
// Ensure Watchlist tab exists in CustomTabs
+ // Kept unused for now — Watchlist is hosted via addCustomPage('#/watchlist').
async function ensureWatchlistTab() {
try {
if (!window.KefinTweaksConfig) {
@@ -960,7 +1479,7 @@
}
console.log('[KefinTweaks Startup] Ensuring Watchlist tab exists in CustomTabs...');
-
+
const pluginId = await findPlugin('Custom Tabs');
if (!pluginId) {
console.warn('[KefinTweaks Startup] CustomTabs plugin not found');
@@ -969,10 +1488,11 @@
// Get current CustomTabs config
const server = ApiClient._serverAddress;
-
+ const token = ApiClient.accessToken();
+
const response = await fetch(`${server}/CustomTabs/Config`, {
headers: {
- 'Authorization': getAuthHeader()
+ 'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -987,7 +1507,7 @@
// Find watchlist tab by checking if the ContentHtml includes div class=\"sections watchlist\"
const watchlistTab = tabs.find(tab => tab.ContentHtml.includes('sections watchlist'));
-
+
if (watchlistTab) {
console.log('[KefinTweaks Startup] Watchlist tab already exists with correct ContentHtml');
return true;
@@ -1009,28 +1529,355 @@
}
}
+ /**
+ * Remove the Watchlist Custom Tabs entry (exact ContentHtml match).
+ * Watchlist is now hosted via addCustomPage('#/watchlist').
+ */
+ async function removeWatchlistFromCustomTabPlugin() {
+ try {
+ const pluginId = await findPlugin('Custom Tabs');
+ if (!pluginId) {
+ console.warn('[KefinTweaks Startup] CustomTabs plugin not found; nothing to remove');
+ return false;
+ }
+
+ const config = await getPluginConfig(pluginId);
+ const tabs = Array.isArray(config?.Tabs) ? config.Tabs : [];
+ const watchlistHtml = '
';
+ const filtered = tabs.filter((tab) => tab.ContentHtml !== watchlistHtml);
+
+ if (filtered.length === tabs.length) {
+ console.log('[KefinTweaks Startup] No exact Watchlist Custom Tab to remove');
+ return true;
+ }
+
+ await savePluginConfig(pluginId, { Tabs: filtered });
+ console.log('[KefinTweaks Startup] Removed Watchlist tab from CustomTabs');
+ return true;
+ } catch (error) {
+ console.error('[KefinTweaks Startup] Error removing Watchlist Custom Tab:', error);
+ return false;
+ }
+ }
+
+ function normalizeReleaseTag(tag) {
+ const t = String(tag || '').trim();
+ if (!t) return '';
+ return t.startsWith('v') || t.startsWith('V') ? `v${t.slice(1)}` : `v${t}`;
+ }
+
+ function normalizeUpdateState(config) {
+ if (!config || typeof config !== 'object') return { dismissed: true, latestVersion: '' };
+ if (!config.updateState || typeof config.updateState !== 'object') {
+ config.updateState = { dismissed: true, latestVersion: '' };
+ } else {
+ if (typeof config.updateState.dismissed !== 'boolean') {
+ config.updateState.dismissed = true;
+ }
+ if (typeof config.updateState.latestVersion !== 'string') {
+ config.updateState.latestVersion = '';
+ } else {
+ config.updateState.latestVersion = normalizeReleaseTag(config.updateState.latestVersion)
+ || config.updateState.latestVersion;
+ }
+ }
+ return config.updateState;
+ }
+
+ function isGithubJsdelivrKefinRoot(root) {
+ return /cdn\.jsdelivr\.net\/gh\/ranaldsgift\/KefinTweaks@/i.test(String(root || ''));
+ }
+
+ function isSymbolicLatestRoot(root) {
+ return /@latest(\/|$)/i.test(String(root || ''));
+ }
+
+ function parseGithubRootRef(root) {
+ const s = String(root || '');
+ const floating = s.match(/@(latest|main|master|experimental)(\/|$)/i);
+ if (floating) {
+ const name = floating[1].toLowerCase();
+ return { kind: name === 'master' ? 'main' : name, value: name === 'master' ? 'main' : name };
+ }
+ const semver = s.match(/@(v?\d+\.\d+\.\d+)(\/|$)/i);
+ if (semver) {
+ return { kind: 'semver', value: normalizeReleaseTag(semver[1]) };
+ }
+ const sha = s.match(/@([0-9a-f]{40})(\/|$)/i);
+ if (sha) {
+ return { kind: 'sha', value: sha[1].toLowerCase() };
+ }
+ return null;
+ }
+
+ function parseShaFromRoot(root) {
+ const m = String(root || '').match(/@([0-9a-f]{40})(\/|$)/i);
+ return m ? m[1].toLowerCase() : null;
+ }
+
+ async function waitForGlobal(getter, timeoutMs = 15000, intervalMs = 250) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const value = getter();
+ if (value) return value;
+ } catch (_) { /* ignore */ }
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+ return null;
+ }
+
+ async function persistConfigFromStartup(config) {
+ const saveFn = await waitForGlobal(
+ () => window.KefinTweaksUtils?.saveConfigToJavaScriptInjector,
+ 20000
+ );
+ if (!saveFn) {
+ console.warn('[KefinTweaks Startup] saveConfigToJavaScriptInjector unavailable; updateState not persisted');
+ return false;
+ }
+ window.KefinTweaksConfig = config;
+ try {
+ return !!(await saveFn(config));
+ } catch (e) {
+ console.warn('[KefinTweaks Startup] Failed to persist config:', e);
+ return false;
+ }
+ }
+
+ async function fetchGithubJson(url) {
+ const response = await fetch(url);
+ if (!response.ok) return null;
+ return response.json();
+ }
+
+ async function fetchLatestGithubRelease() {
+ const data = await fetchGithubJson('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest');
+ if (!data?.tag_name) return null;
+ const publishedAt = data.published_at ? new Date(data.published_at) : null;
+ if (!publishedAt || Number.isNaN(publishedAt.getTime())) return null;
+ return {
+ tag: normalizeReleaseTag(data.tag_name),
+ publishedAt
+ };
+ }
+
+ async function fetchReleasePublishedAt(tag) {
+ const normalized = normalizeReleaseTag(tag);
+ if (!normalized) return null;
+ const data = await fetchGithubJson(
+ `https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/tags/${encodeURIComponent(normalized)}`
+ );
+ if (!data?.published_at) return null;
+ const d = new Date(data.published_at);
+ return Number.isNaN(d.getTime()) ? null : d;
+ }
+
+ async function fetchCommitDate(shaOrRef) {
+ if (!shaOrRef) return null;
+ const data = await fetchGithubJson(
+ `https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/${encodeURIComponent(shaOrRef)}`
+ );
+ const iso = data?.commit?.committer?.date || data?.commit?.author?.date;
+ if (!iso) return null;
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? null : d;
+ }
+
+ async function resolveInstalledCommitDate(root, config, branchName) {
+ const resolvedSha = parseShaFromRoot(config?.kefinTweaksRootResolved)
+ || parseShaFromRoot(resolvedRootCache);
+ if (resolvedSha) {
+ const fromResolved = await fetchCommitDate(resolvedSha);
+ if (fromResolved) return fromResolved;
+ }
+ return fetchCommitDate(branchName || 'main');
+ }
+
+ /**
+ * True when the install should treat GitHub Latest as a "new update" vs current pin/tip.
+ */
+ async function isGithubInstallBehindLatest(root, config, latest) {
+ const ref = parseGithubRootRef(root);
+ if (!ref) return true;
+
+ if (ref.kind === 'semver') {
+ if (ref.value === latest.tag) return false;
+ const installedPublished = await fetchReleasePublishedAt(ref.value);
+ if (!installedPublished) return true;
+ return installedPublished.getTime() < latest.publishedAt.getTime();
+ }
+
+ if (ref.kind === 'sha') {
+ const commitDate = await fetchCommitDate(ref.value);
+ if (!commitDate) return true;
+ return commitDate.getTime() < latest.publishedAt.getTime();
+ }
+
+ if (ref.kind === 'main' || ref.kind === 'experimental') {
+ const tipDate = await resolveInstalledCommitDate(root, config, ref.kind);
+ if (!tipDate) return true;
+ // Tip newer than Latest ⇒ following branch tip, not a release-update prompt
+ if (tipDate.getTime() >= latest.publishedAt.getTime()) return false;
+ return true;
+ }
+
+ return true;
+ }
+
+ function buildConcreteLatestRoot(latestTag) {
+ const tag = normalizeReleaseTag(latestTag);
+ return `https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@${tag}/`;
+ }
+
+ async function showPinnedVersionUpdateToast(config, latestTag) {
+ const toastFn = await waitForGlobal(() => window.KefinTweaksToaster?.toast, 20000);
+ if (!toastFn) {
+ console.warn('[KefinTweaks Startup] Toaster unavailable; skipping update toast');
+ return;
+ }
+
+ const tag = normalizeReleaseTag(latestTag);
+ const githubInstall = isGithubJsdelivrKefinRoot(config?.kefinTweaksRoot);
+
+ const dismissAndSave = async () => {
+ normalizeUpdateState(config);
+ config.updateState.dismissed = true;
+ if (tag) config.updateState.latestVersion = tag;
+ await persistConfigFromStartup(config);
+ };
+
+ const handle = toastFn(
+ 'An update for KefinTweaks is available.',
+ '0',
+ false,
+ {
+ buttons: [
+ {
+ label: 'Update',
+ onClick: async () => {
+ try {
+ normalizeUpdateState(config);
+ config.updateState.dismissed = true;
+ if (tag) config.updateState.latestVersion = tag;
+
+ if (githubInstall && tag) {
+ const nextRoot = buildConcreteLatestRoot(tag);
+ config.kefinTweaksRoot = nextRoot;
+ config.kefinTweaksRootResolved = nextRoot;
+ await persistConfigFromStartup(config);
+ handle?.dismiss?.();
+ toastFn(
+ 'KefinTweaks updated. Please refresh the page for the changes to take effect.',
+ '8'
+ );
+ } else {
+ await persistConfigFromStartup(config);
+ handle?.dismiss?.();
+ const url = tag
+ ? `https://github.com/ranaldsgift/KefinTweaks/releases/tag/${encodeURIComponent(tag)}`
+ : 'https://github.com/ranaldsgift/KefinTweaks/releases/latest';
+ try {
+ window.open(url, '_blank', 'noopener');
+ } catch (_) { /* ignore */ }
+ }
+ } catch (e) {
+ console.error('[KefinTweaks Startup] Update action failed:', e);
+ }
+ }
+ },
+ {
+ label: 'Dismiss',
+ onClick: async () => {
+ try {
+ await dismissAndSave();
+ handle?.dismiss?.();
+ } catch (e) {
+ console.error('[KefinTweaks Startup] Dismiss action failed:', e);
+ }
+ }
+ }
+ ]
+ }
+ );
+ }
+
+ async function checkPinnedVersionUpdate() {
+ const config = window.KefinTweaksConfig;
+ const root = config?.kefinTweaksRoot;
+ if (!root) return;
+
+ // Floating @latest already auto-resolves to GitHub Latest — skip this prompt flow
+ if (isSymbolicLatestRoot(root)) {
+ normalizeUpdateState(config);
+ return;
+ }
+
+ const state = normalizeUpdateState(config);
+ let latest;
+ try {
+ latest = await fetchLatestGithubRelease();
+ } catch (e) {
+ console.warn('[KefinTweaks Startup] Failed to fetch latest release:', e);
+ return;
+ }
+ if (!latest?.tag) return;
+
+ const known = normalizeReleaseTag(state.latestVersion);
+ if (known !== latest.tag) {
+ let markNew = false;
+ if (isGithubJsdelivrKefinRoot(root)) {
+ try {
+ markNew = await isGithubInstallBehindLatest(root, config, latest);
+ } catch (e) {
+ console.warn('[KefinTweaks Startup] Install-vs-latest compare failed; treating as new:', e);
+ markNew = true;
+ }
+ } else {
+ // Self-hosted / custom root
+ markNew = true;
+ }
+
+ if (markNew) {
+ state.dismissed = false;
+ state.latestVersion = latest.tag;
+ await persistConfigFromStartup(config);
+ } else {
+ // Same or newer install relative to Latest — remember tag without prompting
+ state.latestVersion = latest.tag;
+ await persistConfigFromStartup(config);
+ }
+ }
+
+ if (!state.dismissed) {
+ await showPinnedVersionUpdateToast(config, latest.tag);
+ }
+ }
+
// Startup task - runs only for admin users
async function startupTask() {
console.log('[KefinTweaks Startup] Starting startup task...');
-
+
// Check if kefinTweaksRoot is configured - startup tasks only run after installation
if (!window.KefinTweaksConfig?.kefinTweaksRoot || window.KefinTweaksConfig.kefinTweaksRoot === '') {
console.log('[KefinTweaks Startup] kefinTweaksRoot is not configured, skipping startup tasks');
return;
}
-
+
// Check if user is admin (wait up to 5s for login)
const isAdmin = await checkAdminWithTimeout(5000);
-
+
if (!isAdmin) {
console.log('[KefinTweaks Startup] User is not admin or not logged in, skipping startup task');
return;
}
try {
- // Task: Ensure Watchlist tab exists in CustomTabs
- await ensureWatchlistTab();
+ // Fire-and-forget: remove leftover Watchlist Custom Tab (best-effort)
+ //removeWatchlistFromCustomTabPlugin();
+ await checkPinnedVersionUpdate();
+
console.log('[KefinTweaks Startup] Startup task completed successfully');
} catch (error) {
console.error('[KefinTweaks Startup] Error in startup task:', error);
@@ -1053,7 +1900,7 @@
setTimeout(startupTask, 1000);
})();
}
-
+
console.log('[KefinTweaks Injector] Injector script loaded. Available at window.KefinTweaks');
-
+
})();
diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js
index 9b00528..6b36803 100644
--- a/kefinTweaks-default-config.js
+++ b/kefinTweaks-default-config.js
@@ -1,15 +1,20 @@
window.KefinTweaksDefaultConfig = {
"kefinTweaksRoot": "",
+ "enabled": true,
"scripts": {
+ "watchlist": true,
"homeScreen": true,
"search": true,
- "watchlist": true,
"headerTabs": true,
"customMenuLinks": true,
+ "hamburgerMenu": false,
+ "games": true,
"breadcrumbs": true,
"playlist": true,
"itemDetailsCollections": true,
"flattenSingleSeasonShows": true,
+ "seriesInfo": true,
+ "versionPreferences": true,
"collections": true,
"exclusiveElsewhere": false,
"backdropLeakFix": true,
@@ -17,378 +22,10 @@ window.KefinTweaksDefaultConfig = {
"infiniteScroll": true,
"removeContinue": true,
"subtitleSearch": true,
- "skinManager": true
- },
- "homeScreen": {
- "defaultItemLimit": 16,
- "defaultSortOrder": "Random",
- "defaultCardFormat": "Poster",
- "recentlyAddedInLibrary": {},
- "recentlyReleased": {
- "enabled": true,
- "order": 30,
- "movies": {
- "name": "Recently Released Movies",
- "enabled": true,
- "itemLimit": 16,
- "sortOrder": "ReleaseDate",
- "sortOrderDirection": "Descending",
- "cardFormat": "Poster",
- "order": 30,
- "minAgeInDays": null,
- "maxAgeInDays": null
- },
- "episodes": {
- "name": "Recently Aired Episodes",
- "enabled": true,
- "itemLimit": 16,
- "sortOrder": "PremiereDate",
- "sortOrderDirection": "Descending",
- "cardFormat": "Backdrop",
- "order": 31,
- "minAgeInDays": null,
- "maxAgeInDays": null
- }
- },
- "trending": {
- "name": "Trending",
- "enabled": false,
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 32
- },
- "popularTVNetworks": {
- "name": "Popular TV Networks",
- "enabled": true,
- "minimumShowsForNetwork": 5,
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 61
- },
- "watchlist": {
- "name": "Watchlist",
- "enabled": true,
- "itemLimit": 16,
- "sortOrder": "DateAdded",
- "sortOrderDirection": "Descending",
- "cardFormat": "Poster",
- "order": 60
- },
- "watchAgain": {
- "name": "Watch Again",
- "enabled": true,
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 62
- },
- "upcoming": {
- "name": "Upcoming",
- "enabled": true,
- "itemLimit": 48,
- "cardFormat": "Thumb",
- "order": 20
- },
- "imdbTop250": {
- "name": "IMDb Top 250",
- "enabled": true,
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 21
- },
- "seasonal": {
- "enabled": true,
- "enableSeasonalAnimations": true,
- "enableSeasonalBackground": true,
- "defaultItemLimit": 16,
- "defaultSortOrder": "Random",
- "defaultCardFormat": "Poster",
- "seasons": [
- {
- "id": "halloween",
- "name": "Halloween",
- "enabled": true,
- "startDate": "10-01",
- "endDate": "10-31",
- "sections": [
- {
- "id": "halloween-tag",
- "enabled": true,
- "name": "Halloween Movies",
- "type": "Tag",
- "source": "halloween",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 50
- },
- {
- "id": "halloween-horror",
- "enabled": true,
- "name": "Horror Genre",
- "type": "Genre",
- "source": "Horror",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 51
- },
- {
- "id": "halloween-thriller",
- "enabled": true,
- "name": "Thriller Genre",
- "type": "Genre",
- "source": "Thriller",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 52
- }
- ]
- },
- {
- "id": "thanksgiving",
- "name": "Thanksgiving",
- "enabled": true,
- "startDate": "11-20",
- "endDate": "11-30",
- "order": 50,
- "sections": [
- {
- "id": "seasonal-2-section-0",
- "enabled": true,
- "name": "Thanksgiving Movies",
- "type": "Tag",
- "source": "thanksgiving",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 50,
- "spotlight": true,
- "discoveryEnabled": false,
- "searchTerm": "",
- "includeItemTypes": [
- "Movie"
- ]
- },
- {
- "id": "seasonal-2-section-1",
- "enabled": true,
- "name": "Thanksgiving Episodes",
- "type": "Parent",
- "source": "",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Thumb",
- "order": 51,
- "spotlight": false,
- "discoveryEnabled": false,
- "searchTerm": "thanksgiving",
- "includeItemTypes": [
- "Episode"
- ]
- }
- ]
- },
- {
- "id": "christmas",
- "name": "Christmas",
- "enabled": true,
- "startDate": "12-01",
- "endDate": "12-31",
- "sections": [
- {
- "id": "seasonal-1-section-0",
- "enabled": true,
- "name": "Christmas Movies",
- "type": "Tag",
- "source": "christmas",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "order": 50,
- "spotlight": true,
- "discoveryEnabled": false,
- "searchTerm": "",
- "includeItemTypes": [
- "Movie"
- ],
- "additionalQueryOptions": []
- },
- {
- "id": "seasonal-1-section-1",
- "enabled": true,
- "name": "Christmas Episodes",
- "type": "Parent",
- "source": "",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Thumb",
- "order": 51,
- "spotlight": false,
- "discoveryEnabled": false,
- "searchTerm": "christmas",
- "includeItemTypes": [
- "Episode"
- ],
- "additionalQueryOptions": []
- }
- ]
- }
- ]
- },
- "discovery": {
- "enabled": true,
- "infiniteScroll": true,
- "minPeopleAppearances": 10,
- "minGenreMovieCount": 50,
- "defaultItemLimit": 16,
- "defaultSortOrder": "Random",
- "defaultCardFormat": "Poster",
- "sectionTypes": {
- "spotlightGenre": {
- "enabled": true,
- "name": "Top Rated [Genre] Movies",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "spotlightNetwork": {
- "enabled": true,
- "name": "Top Rated Shows from [Studio]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "genreMovies": {
- "enabled": true,
- "name": "[Genre] Movies",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "studioShows": {
- "enabled": true,
- "name": "Shows from [Studio]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "collections": {
- "enabled": true,
- "name": "[Collection Name]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster",
- "minimumItems": 10
- },
- "becauseYouWatched": {
- "enabled": true,
- "name": "Because you watched [Movie]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "becauseYouLiked": {
- "enabled": true,
- "name": "Because you liked [Movie]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "starringTopActor": {
- "enabled": true,
- "name": "Starring [Actor]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "directedByTopDirector": {
- "enabled": true,
- "name": "Directed by [Director]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "writtenByTopWriter": {
- "enabled": true,
- "name": "Written by [Writer]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "becauseYouRecentlyWatched": {
- "enabled": true,
- "name": "Because you recently watched [Movie]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "starringActorRecentlyWatched": {
- "enabled": true,
- "name": "Starring [Actor] because you recently watched [Movie]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "directedByDirectorRecentlyWatched": {
- "enabled": true,
- "name": "Directed by [Director] because you recently watched [Movie]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- },
- "writtenByWriterRecentlyWatched": {
- "enabled": true,
- "name": "Written by [Writer] because you recently watched [Movie]",
- "itemLimit": 16,
- "sortOrder": "Random",
- "sortOrderDirection": "Ascending",
- "cardFormat": "Poster"
- }
- },
- "spotlightDiscoveryChance": 0.5,
- "renderSpotlightAboveMatching": false,
- "spotlight": {
- "autoPlay": true,
- "interval": 5000,
- "showDots": true,
- "showNavButtons": true,
- "showClearArt": true
- },
- "randomizeOrder": false
- },
- "customSections": []
- },
- "exclusiveElsewhere": {
- "hideServerName": false
+ "skinManager": true,
+ "thumbnailScrubber": false,
+ "watchTogether": false,
+ "userManager": false
},
"search": {
"enableJellyseerr": false
@@ -396,9 +33,40 @@ window.KefinTweaksDefaultConfig = {
"flattenSingleSeasonShows": {
"hideSingleSeasonContainer": false
},
+ "seriesInfo": {
+ "use24HourTimeFormat": false
+ },
+ "versionPreferences": {
+ "versions": ["4k", "2160p", "1080p", "720p", "576p", "480p", "360p", "__none__"],
+ "editions": ["Theatrical", "Director's Cut", "Extended Edition", "Fan Edit", "__none__"],
+ "showVersionBeforeEdition": false
+ },
+ "thumbnailScrubber": {
+ "PreviewOnHover": false,
+ "UseVideoStream": true,
+ "FallbackAfterMs": 500,
+ "HOVER_PREVIEW_TIMEOUT_MS": 800,
+ "HOVER_TIMEOUT": 800,
+ "HOVER_PREVIEW_FRAME_MS": 250,
+ "SCRUB_ACTIVATION_DELAY_MS": 300
+ },
+ "watchTogether": {
+ "sessionTimeout": 30
+ },
+ "userManager": {
+ "defaultTemplateName": "",
+ "templates": []
+ },
+ "exclusiveElsewhere": {
+ "hideServerName": false
+ },
"skins": [],
"defaultSkin": null,
"themes": [],
"customMenuLinks": [],
- "optionalIncludes": []
+ "optionalIncludes": [],
+ "updateState": {
+ "dismissed": true,
+ "latestVersion": ""
+ }
};
diff --git a/kefinTweaks-plugin.js b/kefinTweaks-plugin.js
index 3043505..60729a4 100644
--- a/kefinTweaks-plugin.js
+++ b/kefinTweaks-plugin.js
@@ -33,6 +33,9 @@
// Check if user is admin
async function isAdmin() {
+ // Wait for user to be logged in
+ await waitForLoginForInjectorWrite();
+
try {
if (window.ApiClient && window.ApiClient.getCurrentUser) {
const user = await window.ApiClient.getCurrentUser();
@@ -59,6 +62,11 @@
}
});
+ // Non-admins cannot list plugins (HTTP 403) — treat as no access, not a hard failure
+ if (response.status === 403 || response.status === 401) {
+ return null;
+ }
+
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
@@ -84,7 +92,7 @@
// Get current JavaScript Injector configuration
async function getJavaScriptInjectorConfig(pluginId) {
try {
- if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
+ if (!pluginId || !window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
return null;
}
@@ -96,6 +104,10 @@
}
});
+ if (response.status === 403 || response.status === 401) {
+ return null;
+ }
+
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
@@ -190,7 +202,15 @@
async function saveConfigToJavaScriptInjector(config) {
try {
const pluginId = await findJavaScriptInjectorPlugin();
+ if (!pluginId) {
+ console.warn('[KefinTweaks Installer] Cannot save config: JavaScript Injector plugin unavailable (non-admin or missing)');
+ return false;
+ }
const injectorConfig = await getJavaScriptInjectorConfig(pluginId);
+ if (!injectorConfig) {
+ console.warn('[KefinTweaks Installer] Cannot save config: could not read JS Injector configuration');
+ return false;
+ }
if (!injectorConfig.CustomJavaScripts) {
injectorConfig.CustomJavaScripts = [];
@@ -313,6 +333,223 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
}
+ function normalizeKefinRootUrl(root) {
+ if (!root || typeof root !== 'string') return '';
+ return root.endsWith('/') ? root : root + '/';
+ }
+
+ function isFloatingKefinRoot(root) {
+ return /@(latest|main|master|experimental)(\/|$)/i.test(String(root || ''));
+ }
+
+ /** Human label for a newly pinned resolved root (channel-aware). */
+ function formatResolvedUpdateLabel(symbolicRoot, resolvedRoot) {
+ const symbolic = String(symbolicRoot || '');
+ const resolved = String(resolvedRoot || '');
+ const verMatch = resolved.match(/@v?(\d+\.\d+\.\d+)/i);
+ if (verMatch) {
+ return `v${verMatch[1]}`;
+ }
+ const shaMatch = resolved.match(/@([0-9a-f]{7,40})(?:\/|$)/i);
+ const shortSha = shaMatch ? shaMatch[1].substring(0, 7).toLowerCase() : null;
+ if (/@experimental(\/|$)/i.test(symbolic)) {
+ return shortSha ? `Experimental (#${shortSha})` : 'Experimental';
+ }
+ if (/@(main|master)(\/|$)/i.test(symbolic)) {
+ return shortSha ? `Dev (#${shortSha})` : 'Development';
+ }
+ if (shortSha) {
+ return `Dev (#${shortSha})`;
+ }
+ return 'a new version';
+ }
+
+ function flushPendingRootUpdateToast() {
+ const pending = window.__kefinPendingRootUpdateToast;
+ if (!pending?.message) return false;
+ if (typeof window.KefinTweaksToaster?.toast !== 'function') return false;
+ window.__kefinPendingRootUpdateToast = null;
+ try {
+ // Sticky when a forced tip-reload countdown is active; otherwise auto-hide.
+ const sticky = !!pending.sticky || !!window.__kefinTipReloadScheduled;
+ window.KefinTweaksToaster.toast(pending.message, pending.duration || '5', !sticky);
+ } catch (e) {
+ console.warn('[KefinTweaks Installer] Failed to show root-update toast:', e);
+ return false;
+ }
+ return true;
+ }
+
+ function queueRootUpdateToast(message, options = null) {
+ if (!message) return;
+ window.__kefinPendingRootUpdateToast = {
+ message,
+ sticky: !!(options && options.sticky),
+ duration: (options && options.duration) || '5'
+ };
+ if (flushPendingRootUpdateToast()) return;
+ const start = Date.now();
+ const poll = () => {
+ if (flushPendingRootUpdateToast()) return;
+ if (Date.now() - start > 30000) return;
+ setTimeout(poll, 250);
+ };
+ setTimeout(poll, 250);
+ }
+
+ /**
+ * After tip re-pin is persisted to disk, force reload so Config/bake/scripts stay aligned.
+ * Single-flight per page session. Countdown updates the same toast in place (no reopen).
+ */
+ function scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved }) {
+ if (window.__kefinTipReloadScheduled) return;
+ window.__kefinTipReloadScheduled = true;
+
+ const label = formatResolvedUpdateLabel(symbolicRoot, nextResolved);
+ let remaining = 5;
+ const msg = (n) => `KefinTweaks updated to ${label} — refreshing in ${n}…`;
+
+ let toastHandle = null;
+
+ const setToastMessage = (el, text) => {
+ if (!el) return;
+ const messageEl = el.querySelector('.kefin-toast-message');
+ if (messageEl) messageEl.textContent = text;
+ else el.textContent = text;
+ };
+
+ const ensureToast = () => {
+ if (toastHandle?.element?.isConnected) return toastHandle;
+ if (typeof window.KefinTweaksToaster?.toast === 'function') {
+ toastHandle = window.KefinTweaksToaster.toast(msg(remaining), '6', false);
+ return toastHandle;
+ }
+ // Toaster not ready yet — queue once; flush will show sticky toast
+ queueRootUpdateToast(msg(remaining), { sticky: true, duration: '6' });
+ return null;
+ };
+
+ const update = (n) => {
+ const handle = ensureToast();
+ if (handle?.element?.isConnected) {
+ setToastMessage(handle.element, msg(n));
+ return;
+ }
+ // Pending toast may have been flushed by toaster init — adopt it if present
+ const existing = document.querySelector('.toastContainer .toast');
+ if (existing) {
+ toastHandle = { element: existing, dismiss: () => {} };
+ setToastMessage(existing, msg(n));
+ }
+ };
+
+ update(remaining);
+ const tick = setInterval(() => {
+ remaining -= 1;
+ if (remaining <= 0) {
+ clearInterval(tick);
+ try {
+ window.location.reload();
+ } catch (e) {
+ console.warn('[KefinTweaks Installer] Reload after tip update failed:', e);
+ }
+ return;
+ }
+ update(remaining);
+ }, 1000);
+ }
+
+ /**
+ * After a floating root's resolved URL was persisted (skip first pin): countdown + reload.
+ */
+ function maybeQueueResolvedRootUpdateToast({ symbolicRoot, previousResolved, nextResolved, persisted }) {
+ if (!persisted) return;
+ if (!isFloatingKefinRoot(symbolicRoot)) return;
+ const previous = normalizeKefinRootUrl(previousResolved);
+ const next = normalizeKefinRootUrl(nextResolved);
+ if (!previous || !next || previous === next) return;
+ scheduleForcedTipReloadCountdown({ symbolicRoot, nextResolved: next });
+ }
+
+ /**
+ * Admin + bake already on page: tip resolve/persist/sync without loading live injector.js.
+ * Tip advanced → persist Config+bake then forced reload. Tip unchanged → stamp sync only.
+ */
+ async function handleAdminAttachedBakeTipSync(config) {
+ const symbolic = config?.kefinTweaksRoot || '';
+ if (!symbolic) return;
+
+ await waitForLoginForInjectorWrite();
+
+ const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || '');
+ let tip = normalizeKefinRootUrl(symbolic);
+ if (isFloatingKefinRoot(symbolic)) {
+ tip = normalizeKefinRootUrl(await resolveRootVersion(symbolic));
+ }
+
+ if (tip && tip !== current) {
+ console.log('[KefinTweaks Installer] Tip advanced while bake attached:', current || '(none)', '->', tip);
+ config.kefinTweaksRootResolved = tip;
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = config;
+ } else {
+ window.KefinTweaksConfig.kefinTweaksRootResolved = tip;
+ }
+
+ try {
+ const persisted = await saveConfigToJavaScriptInjector(config);
+ if (persisted) {
+ scheduleForcedTipReloadCountdown({ symbolicRoot: symbolic, nextResolved: tip });
+ console.log('[KefinTweaks Installer] Tip persist+bake ok; scheduling reload');
+ } else {
+ console.warn('[KefinTweaks Installer] Tip advanced but persist failed; not reloading');
+ }
+ } catch (e) {
+ console.error('[KefinTweaks Installer] Tip persist failed; not reloading:', e);
+ }
+ return;
+ }
+
+ // Tip unchanged — stamp sync only (no live inject, no reload)
+ try {
+ const loadRoot = current || tip;
+ if (!loadRoot) return;
+ await loadKefinTweaksLoaderFromRoot(loadRoot);
+ const Loader = window.KefinTweaksLoader;
+ if (!Loader || typeof Loader.syncKefinTweaksInjector !== 'function') {
+ console.log('[KefinTweaks Installer] Loader sync unavailable; bake attached, tip unchanged');
+ return;
+ }
+ Loader.ensureKefinTweaksApi(Loader.SCRIPT_DEFINITIONS);
+ let major = null;
+ try {
+ major = await window.KefinTweaks.getJellyfinMajorVersion();
+ } catch (e) { /* ignore */ }
+ const planRoot = Loader.getResolvedKefinRoot
+ ? Loader.getResolvedKefinRoot(config)
+ : loadRoot;
+ const plan = Loader.buildLoadPlan(config, major, {
+ root: planRoot,
+ configOnly: config.enabled === false
+ });
+ const syncResult = await Loader.syncKefinTweaksInjector(plan);
+ console.log(
+ '[KefinTweaks Installer] Attached bake stamp sync:',
+ syncResult?.synced ? ('synced ' + syncResult.stamp) : (syncResult?.reason || 'done')
+ );
+ } catch (e) {
+ console.warn('[KefinTweaks Installer] Attached bake stamp sync failed:', e);
+ }
+ }
+
+ function isKefinInjectorAttachedOnPage() {
+ return !!(
+ window.KefinTweaksInjectorAttached
+ || window.KefinTweaksScriptsPreloaded
+ || window.KefinTweaksInjectorStamp
+ );
+ }
+
async function waitForLoginForInjectorWrite(maxWaitMs = 10000, checkInterval = 250) {
if (isLoggedInForInjectorWrite()) return true;
const start = Date.now();
@@ -337,20 +574,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return { ok: false, reason: 'not logged in' };
}
- try {
- const normalize = (r) => {
- if (!r || typeof r !== 'string') return '';
- return r.endsWith('/') ? r : r + '/';
- };
+ if (!(await isAdmin())) {
+ return { ok: false, reason: 'not admin' };
+ }
- const expected = normalize(await resolveRootVersion(rootRaw));
- const current = normalize(config.kefinTweaksRootResolved || '');
+ try {
+ const expected = normalizeKefinRootUrl(await resolveRootVersion(rootRaw));
+ const current = normalizeKefinRootUrl(config.kefinTweaksRootResolved || '');
+ const resolvedRootUpdated = !!(current && expected && current !== expected);
if (!current || current !== expected) {
config.kefinTweaksRootResolved = expected;
console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected);
}
- const loadRoot = normalize(config.kefinTweaksRootResolved || rootRaw);
+ const loadRoot = normalizeKefinRootUrl(config.kefinTweaksRootResolved || rootRaw);
await loadKefinTweaksLoaderFromRoot(loadRoot);
const Loader = window.KefinTweaksLoader;
if (!Loader) {
@@ -411,6 +648,15 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
};
}
+ if (resolvedRootUpdated) {
+ maybeQueueResolvedRootUpdateToast({
+ symbolicRoot: rootRaw,
+ previousResolved: current,
+ nextResolved: expected,
+ persisted: true
+ });
+ }
+
console.log('[KefinTweaks Installer] Auto-created KefinTweaks-injector with', plan.assets.length, 'assets');
return { ok: true, plan };
} catch (err) {
@@ -431,8 +677,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const LATEST_RELEASE_NAME = 'Latest';
const DEVELOPMENT_NAME = 'Development';
const EXPERIMENTAL_NAME = 'Experimental';
+ const SPECIFIC_BUILD_NAME = 'Specific Build';
const SELF_HOSTED_NAME = 'Self Hosted';
+ function isFullCommitSha(value) {
+ return typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value.trim());
+ }
+
// Parse source URL to determine source type and version
function parseKefinTweaksSource(url) {
if (!url || url === '') {
@@ -452,9 +703,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return { sourceType: 'github', version: DEVELOPMENT_NAME };
} else if (version === 'experimental') {
return { sourceType: 'github', version: EXPERIMENTAL_NAME };
- } else if (version && version.length === 40 && /^[0-9a-f]+$/.test(version)) {
- // Commit hash URL (e.g. from a previous Experimental selection)
- return { sourceType: 'github', version: EXPERIMENTAL_NAME };
+ } else if (isFullCommitSha(version)) {
+ return { sourceType: 'github', version: SPECIFIC_BUILD_NAME, commit: version.toLowerCase() };
} else {
return { sourceType: 'github', version: version.replace(/^v/, '') };
}
@@ -477,16 +727,16 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
.map(release => release.tag_name.replace(/^v/, ''))
.filter(tag => tag.match(/^\d+\.\d+\.\d+/)); // Only semantic versions
- versionsCache = [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, ...versions];
+ versionsCache = [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME, ...versions];
return versionsCache;
} catch (error) {
console.warn('[KefinTweaks Installer] Error fetching versions:', error);
- return [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME];
+ return [LATEST_RELEASE_NAME, DEVELOPMENT_NAME, EXPERIMENTAL_NAME, SPECIFIC_BUILD_NAME];
}
}
// Build kefinTweaksRoot URL from selections
- function buildKefinTweaksRootUrl(sourceType, source) {
+ function buildKefinTweaksRootUrl(sourceType, source, commitHash) {
if (sourceType === 'custom') {
// Ensure URL ends with /
return source.endsWith('/') ? source : source + '/';
@@ -498,6 +748,12 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
versionTag = 'main';
} else if (source === EXPERIMENTAL_NAME) {
versionTag = 'experimental';
+ } else if (source === SPECIFIC_BUILD_NAME) {
+ const sha = String(commitHash || '').trim().toLowerCase();
+ if (!isFullCommitSha(sha)) {
+ throw new Error('Specific Build requires a full 40-character commit hash');
+ }
+ versionTag = sha;
} else if (source !== LATEST_RELEASE_NAME) {
versionTag = source.startsWith('v') ? source : 'v' + source;
}
@@ -505,6 +761,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return `${JSDELIVR_BASE}@${versionTag}/`;
}
+ function formatSpecificBuildDisplay(sourceInfo) {
+ const sha = sourceInfo?.commit || '';
+ return sha
+ ? `${SPECIFIC_BUILD_NAME} (#${sha.slice(0, 7)})`
+ : SPECIFIC_BUILD_NAME;
+ }
+
// Simple modal creation (doesn't depend on modal.js)
function createSimpleModal(id, title, content, footer) {
// Remove existing modal
@@ -786,7 +1049,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const content = document.createElement('div');
content.style.display = 'flex';
content.style.flexDirection = 'column';
- content.style.gap = '1.5em';
+ content.style.gap = '0.5em';
// Enabled checkbox
const isEnabled = currentConfig?.enabled !== false;
@@ -794,6 +1057,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
enabledContainer.style.display = 'flex';
enabledContainer.style.alignItems = 'center';
enabledContainer.style.gap = '0.75em';
+ enabledContainer.style.marginBottom = '0.75em';
const enabledCheckbox = document.createElement('input');
enabledCheckbox.type = 'checkbox';
@@ -807,7 +1071,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
enabledLabel.style.margin = '0';
enabledLabel.style.display = 'flex';
enabledLabel.style.alignItems = 'center';
- enabledLabel.style.gap = '0.75em';
const enabledText = document.createElement('span');
enabledText.textContent = isEnabled ? 'Enabled' : 'Disabled';
@@ -828,7 +1091,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const sourceTypeLabel = document.createElement('label');
sourceTypeLabel.textContent = 'Install From:';
sourceTypeLabel.style.display = 'block';
- sourceTypeLabel.style.marginBottom = '0.5em';
const sourceTypeSelect = document.createElement('select');
sourceTypeSelect.className = 'fld emby-select emby-select-withcolor';
@@ -842,7 +1104,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
const sourceLabel = document.createElement('label');
sourceLabel.textContent = 'Version:';
sourceLabel.style.display = 'block';
- sourceLabel.style.marginBottom = '0.5em';
const sourceSelect = document.createElement('select');
sourceSelect.className = 'fld emby-select emby-select-withcolor';
@@ -860,6 +1121,86 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
customUrlInput.value = sourceInfo.sourceType === 'custom' ? currentRoot : '';
customUrlInput.style.display = sourceInfo.sourceType === 'custom' ? 'block' : 'none';
+ // Specific Build commit hash input
+ const commitHashLabel = document.createElement('label');
+ commitHashLabel.textContent = 'Commit Hash:';
+ commitHashLabel.style.display = 'block';
+ commitHashLabel.style.marginBottom = '0.5em';
+ commitHashLabel.style.marginTop = '1em';
+
+ const commitHashInput = document.createElement('input');
+ commitHashInput.type = 'text';
+ commitHashInput.className = 'fld emby-input';
+ commitHashInput.id = 'kefinTweaksCommitHash';
+ commitHashInput.placeholder = '40-character git commit SHA';
+ commitHashInput.spellcheck = false;
+ commitHashInput.autocomplete = 'off';
+ commitHashInput.value = sourceInfo.commit || '';
+
+ // Resolved hash readout for Development / Experimental
+ const resolvedHashEl = document.createElement('div');
+ resolvedHashEl.id = 'kefinTweaksResolvedHash';
+ resolvedHashEl.className = 'listItemBodyText secondary';
+ resolvedHashEl.style.display = 'none';
+ resolvedHashEl.style.marginTop = '0.35em';
+ resolvedHashEl.style.fontFamily = 'monospace';
+ resolvedHashEl.style.fontSize = '0.9em';
+ resolvedHashEl.style.wordBreak = 'break-all';
+
+ function extractShaFromRoot(root) {
+ if (!root || typeof root !== 'string') return null;
+ const m = root.match(/@([0-9a-f]{7,40})(?:\/|$)/i);
+ return m ? m[1].toLowerCase() : null;
+ }
+
+ async function updateResolvedHashVisibility() {
+ const show =
+ sourceTypeSelect.value === 'github' &&
+ (sourceSelect.value === DEVELOPMENT_NAME || sourceSelect.value === EXPERIMENTAL_NAME);
+ if (!show) {
+ resolvedHashEl.style.display = 'none';
+ resolvedHashEl.textContent = '';
+ return;
+ }
+
+ resolvedHashEl.style.display = 'block';
+ resolvedHashEl.textContent = 'Version: …';
+
+ let sha = extractShaFromRoot(currentConfig?.kefinTweaksRootResolved);
+ const symbolicMatchesSelection =
+ (sourceSelect.value === DEVELOPMENT_NAME && /@(main|master)(\/|$)/.test(currentRoot || '')) ||
+ (sourceSelect.value === EXPERIMENTAL_NAME && /@experimental(\/|$)/.test(currentRoot || ''));
+
+ // Prefer stored resolved hash when it matches the selected branch; otherwise resolve live
+ if (!sha || !symbolicMatchesSelection || sourceSelect.value !== sourceInfo.version) {
+ try {
+ const branchRoot =
+ sourceSelect.value === EXPERIMENTAL_NAME
+ ? `${JSDELIVR_BASE}@experimental/`
+ : `${JSDELIVR_BASE}@main/`;
+ const resolved = await resolveRootVersion(branchRoot);
+ sha = extractShaFromRoot(resolved) || sha;
+ } catch (e) {
+ console.warn('[KefinTweaks Installer] Could not resolve branch hash:', e);
+ }
+ }
+
+ if (sha) {
+ const short = sha.length > 12 ? sha.slice(0, 12) : sha;
+ resolvedHashEl.textContent = `Version: ${short}`;
+ } else {
+ resolvedHashEl.textContent = 'Version: unavailable';
+ }
+ }
+
+ const updateCommitHashVisibility = () => {
+ const showCommit = sourceTypeSelect.value === 'github'
+ && sourceSelect.value === SPECIFIC_BUILD_NAME;
+ commitHashLabel.style.display = showCommit ? 'block' : 'none';
+ commitHashInput.style.display = showCommit ? 'block' : 'none';
+ updateResolvedHashVisibility();
+ };
+
// Set initial visibility based on source type
if (sourceInfo.sourceType === 'custom') {
sourceSelect.style.display = 'none';
@@ -870,6 +1211,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
sourceLabel.style.display = 'block';
customUrlInput.style.display = 'none';
}
+ updateCommitHashVisibility();
// Toggle visibility based on source type
sourceTypeSelect.onchange = () => {
@@ -882,12 +1224,20 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
sourceLabel.style.display = 'block';
customUrlInput.style.display = 'none';
}
+ updateCommitHashVisibility();
+ };
+
+ sourceSelect.onchange = () => {
+ updateCommitHashVisibility();
};
content.appendChild(sourceTypeLabel);
content.appendChild(sourceTypeSelect);
content.appendChild(sourceLabel);
content.appendChild(sourceSelect);
+ content.appendChild(resolvedHashEl);
+ content.appendChild(commitHashLabel);
+ content.appendChild(commitHashInput);
content.appendChild(customUrlInput);
// Footer with buttons
@@ -914,7 +1264,19 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return;
}
- const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source);
+ let commitHash = '';
+ if (sourceType === 'github' && source === SPECIFIC_BUILD_NAME) {
+ commitHash = (commitHashInput.value || '').trim().toLowerCase();
+ if (!isFullCommitSha(commitHash)) {
+ showAlertModal(
+ 'Invalid Commit Hash',
+ 'Please enter a full 40-character git commit SHA for Specific Build.
'
+ );
+ return;
+ }
+ }
+
+ const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source, commitHash);
const kefinTweaksRootResolvedRaw = await resolveRootVersion(kefinTweaksRoot);
const kefinTweaksRootResolved = kefinTweaksRootResolvedRaw.endsWith('/')
? kefinTweaksRootResolvedRaw
@@ -979,6 +1341,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
let versionDisplay = '';
if (sourceInfo.sourceType === 'custom') {
versionDisplay = SELF_HOSTED_NAME;
+ } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) {
+ versionDisplay = formatSpecificBuildDisplay(sourceInfo);
} else if (sourceInfo.version === DEVELOPMENT_NAME) {
versionDisplay = DEVELOPMENT_NAME;
} else if (sourceInfo.version === LATEST_RELEASE_NAME) {
@@ -1171,6 +1535,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
if (root) {
if (sourceInfo.sourceType === 'custom') {
versionDisplay = SELF_HOSTED_NAME;
+ } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) {
+ versionDisplay = formatSpecificBuildDisplay(sourceInfo);
} else if (sourceInfo.version === DEVELOPMENT_NAME) {
versionDisplay = DEVELOPMENT_NAME;
} else if (sourceInfo.version === LATEST_RELEASE_NAME) {
@@ -1367,6 +1733,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
if (root) {
if (sourceInfo.sourceType === 'custom') {
versionDisplay = SELF_HOSTED_NAME;
+ } else if (sourceInfo.version === SPECIFIC_BUILD_NAME) {
+ versionDisplay = formatSpecificBuildDisplay(sourceInfo);
} else if (sourceInfo.version === DEVELOPMENT_NAME) {
versionDisplay = DEVELOPMENT_NAME;
} else if (sourceInfo.version === LATEST_RELEASE_NAME) {
@@ -1687,25 +2055,18 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
function injectCustomPageStyles(config) {
if (!shouldInjectCustomPageStyles(config)) return;
- if (document.getElementById('kefin-custom-page-styles')) return;
- const style = document.createElement('style');
- style.id = 'kefin-custom-page-styles';
- style.textContent = `
-#reactRoot .skinBody:has(.customPage:not(.hide)) #fallbackPage {
+ const css = `
+#reactRoot.kefin-custom-page-active .skinBody #fallbackPage {
display: none;
}
-#reactRoot:not(:has(.skinBody .customPage:not(.hide))) .pageTitle {
- display: none !important;
+#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready]) > * {
+ display: none;
}
-#reactRoot .skinBody:not(:has(.customPage:not(.hide))) #fallbackPage > * {
- display: none;
-}
-
-#reactRoot:not(:has(.skinBody .customPage:not(.hide))) #fallbackPage::after {
- content:'';
+#reactRoot:not(.kefin-custom-page-active) #fallbackPage:not([data-kefin-fallback-ready])::after {
+ content: '';
display: inline-block;
width: 20px;
height: 20px;
@@ -1719,11 +2080,46 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
top: 1em;
}
-#reactRoot:has(.MuiBox-root) .libraryPage:not(.noSecondaryNavPage).customPage {
- padding-top: 0 !important;
+#fallbackPage[data-kefin-fallback-ready]::after {
+ display: none !important;
+ content: none !important;
+}
+
+#reactRoot.kefin-custom-page-active .backdropImage {
+ background: none !important;
+}
+
+header.MuiPaper-root + main.MuiBox-root .customPage {
+ margin-top: 3rem;
+}
+
+.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :nth-child(n+2),
+.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon img {
+ display: none;
+}
+.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child .MuiButton-icon::after {
+ content: '\\e88a';
+ font-family: 'Material Icons';
+ font-size: 1.2rem;
+}
+.layout-mobile .kefin-custom-page-active .MuiToolbar-root > .MuiStack-root > :first-child {
+ font-size: 0px;
+}
+main.MuiBox-root .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] {
+ padding-top: 1rem !important;
+}
+.layout-mobile :not(main.MuiBox-root) > .skinBody > .customPage.libraryPage:not(.noSecondaryNavPage)[data-kefin-custom-page] {
+ padding-top: 5rem !important;
}
`;
- (document.head || document.documentElement).appendChild(style);
+ window.KefinTweaksCustomPageStyles = css;
+ let style = document.getElementById('kefin-custom-page-styles');
+ if (!style) {
+ style = document.createElement('style');
+ style.id = 'kefin-custom-page-styles';
+ (document.head || document.documentElement).appendChild(style);
+ }
+ style.textContent = css;
}
// Initialize the installer (no login/admin gate — admin is checked when rendering the card)
@@ -1800,46 +2196,76 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
return root;
}
- // Load injector.js from a specific root URL (resolves floating refs first)
+ // Load injector.js from a specific root URL (fallback when bake is absent).
+ // GitHub floating resolve + persist only for admins; non-admins use pinned/symbolic as-is.
async function loadInjectorFromRoot(root) {
if (!root || root === '') {
console.warn('[KefinTweaks Installer] Cannot load injector: kefinTweaksRoot is empty');
return false;
}
- // Ensure root ends with /
- const rootUrl = root.endsWith('/') ? root : root + '/';
- const resolvedRoot = await resolveRootVersion(rootUrl);
- const injectorUrl = `${resolvedRoot}injector.js`;
+ const rootUrl = normalizeKefinRootUrl(root);
+ const admin = await isAdmin();
+ let resolvedRoot = rootUrl;
+
+ if (isFloatingKefinRoot(rootUrl)) {
+ if (admin) {
+ resolvedRoot = normalizeKefinRootUrl(await resolveRootVersion(rootUrl));
+ } else {
+ const pinned = normalizeKefinRootUrl(window.KefinTweaksConfig?.kefinTweaksRootResolved || '');
+ resolvedRoot = pinned || rootUrl;
+ console.log('[KefinTweaks Installer] Non-admin: skipping GitHub resolve; using', resolvedRoot);
+ }
+ }
if (resolvedRoot !== rootUrl) {
console.log('[KefinTweaks Installer] Resolved root from', rootUrl, 'to', resolvedRoot);
}
- // Persist commit-SHA root when floating GitHub refs resolve to a new URL
- try {
- const normalize = (r) => {
- if (!r || typeof r !== 'string') return '';
- return r.endsWith('/') ? r : r + '/';
- };
- const expected = normalize(resolvedRoot);
- if (!window.KefinTweaksConfig) {
- window.KefinTweaksConfig = {};
- }
- const current = normalize(window.KefinTweaksConfig.kefinTweaksRootResolved || '');
- if (expected && expected !== current) {
- window.KefinTweaksConfig.kefinTweaksRootResolved = expected;
- console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected);
- if (isLoggedInForInjectorWrite()) {
- await saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
- } else {
- console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist');
+ // Admin: persist tip move; if replacing a previous pin, countdown reload (no tip injector this session)
+ if (admin && isFloatingKefinRoot(rootUrl)) {
+ try {
+ const expected = normalizeKefinRootUrl(resolvedRoot);
+ if (!window.KefinTweaksConfig) {
+ window.KefinTweaksConfig = {};
}
+ const symbolicRoot = window.KefinTweaksConfig.kefinTweaksRoot || rootUrl;
+ const current = normalizeKefinRootUrl(window.KefinTweaksConfig.kefinTweaksRootResolved || '');
+ if (expected && expected !== current) {
+ window.KefinTweaksConfig.kefinTweaksRootResolved = expected;
+ console.log('[KefinTweaks Installer] Updated kefinTweaksRootResolved:', current || '(none)', '->', expected);
+ if (isLoggedInForInjectorWrite()) {
+ const persisted = await saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
+ if (persisted && current) {
+ // Prior pin existed — force reload so scripts/Config stay aligned
+ maybeQueueResolvedRootUpdateToast({
+ symbolicRoot,
+ previousResolved: current,
+ nextResolved: expected,
+ persisted: true
+ });
+ return true;
+ }
+ if (persisted && !current) {
+ // First pin: continue and load injector from the new resolved URL
+ console.log('[KefinTweaks Installer] First resolved pin persisted; loading injector.js');
+ }
+ } else {
+ console.warn('[KefinTweaks Installer] kefinTweaksRootResolved updated in memory; not logged in to persist');
+ }
+ }
+ } catch (e) {
+ console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e);
}
- } catch (e) {
- console.warn('[KefinTweaks Installer] Failed to persist kefinTweaksRootResolved:', e);
}
+ if (window.__kefinTipReloadScheduled) {
+ console.log('[KefinTweaks Installer] Tip reload scheduled; skipping injector.js load');
+ return true;
+ }
+
+ const injectorUrl = `${resolvedRoot}injector.js`;
+
// Check if injector is already loaded from this URL
const existingScript = document.querySelector(`script[src="${injectorUrl}"]`);
if (existingScript) {
@@ -1868,41 +2294,45 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
});
}
- // Check if config exists; auto-create KefinTweaks-injector when missing; always load live injector.js
+ // Check if config exists; auto-create bake when missing (admin); never dual-inject when bake attached
async function checkAndLoadInjector() {
try {
- const config = await getKefinTweaksConfig();
- const root = config?.kefinTweaksRoot || '';
+ // Prefer in-page Config (already injected). Avoid /Plugins GET on normal page load for non-admins.
+ let config = window.KefinTweaksConfig || null;
+ if (!config) {
+ const admin = await isAdmin();
+ if (admin) {
+ await waitForLoginForInjectorWrite();
+ config = await getKefinTweaksConfig();
+ }
+ }
// Before injector/utils: hide fallback page chrome for custom routes
injectCustomPageStyles(config);
+ const root = config?.kefinTweaksRoot || '';
if (!root || root === '') {
console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.');
return;
}
- // Plugin config GET/POST needs a session; wait briefly so admin first-load can auto-create
- await waitForLoginForInjectorWrite();
-
- let injectorConfig = null;
- try {
- const pluginId = await findJavaScriptInjectorPlugin();
- if (pluginId) {
- injectorConfig = await getJavaScriptInjectorConfig(pluginId);
+ // Bake already on page: never load live injector.js (prevents dual SHA trees)
+ if (isKefinInjectorAttachedOnPage()) {
+ if (!(await isAdmin())) {
+ console.log('[KefinTweaks Installer] KefinTweaks-injector attached; non-admin skipping live injector.js');
+ return;
}
- } catch (e) {
- console.warn('[KefinTweaks Installer] Could not read JS Injector config:', e);
+ console.log('[KefinTweaks Installer] KefinTweaks-injector attached; admin tip sync without live inject');
+ await handleAdminAttachedBakeTipSync(config);
+ return;
}
- const hasPreload = !!(injectorConfig && hasEnabledKefinInjectorEntry(injectorConfig.CustomJavaScripts));
- if (hasPreload) {
- console.log('[KefinTweaks Installer] KefinTweaks-injector present; still loading injector.js for live plan + sync');
- } else {
- // Entry missing: try to create + apply assets now (admin/logged-in)
+ if (await isAdmin()) {
+ await waitForLoginForInjectorWrite();
const ensured = await ensureKefinTweaksInjectorEntry(config);
if (ensured.ok && ensured.plan) {
window.KefinTweaksScriptsPreloaded = true;
+ window.KefinTweaksInjectorAttached = true;
if (window.KefinTweaksLoader) {
window.KefinTweaksLoader.ensureKefinTweaksApi(window.KefinTweaksLoader.SCRIPT_DEFINITIONS);
window.KefinTweaksLoader.applyAssetsToDocument(ensured.plan.assets);
@@ -1917,9 +2347,13 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}));
} catch (e) { /* ignore */ }
console.log('[KefinTweaks Installer] Applied KefinTweaks-injector assets after auto-create');
- } else {
- console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js');
+ // Tip advance inside ensure may have scheduled reload; never also load tip injector.js
+ if (window.__kefinTipReloadScheduled) return;
+ return;
}
+ console.log('[KefinTweaks Installer] KefinTweaks-injector not available (' + (ensured.reason || 'unknown') + '); loading injector.js');
+ } else {
+ console.log('[KefinTweaks Installer] KefinTweaks-injector not on page (non-admin); loading injector.js');
}
await loadInjectorFromRoot(root);
@@ -1938,4 +2372,5 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
}
console.log('[KefinTweaks Installer] Installer script loaded');
-})();
\ No newline at end of file
+})();
+
diff --git a/kefinTweaks.minimal.js b/kefinTweaks.minimal.js
index 5f8ca4b..82045f5 100644
--- a/kefinTweaks.minimal.js
+++ b/kefinTweaks.minimal.js
@@ -352,7 +352,8 @@ window.KefinTweaksConfig = {
},
"starringActorRecentlyWatched": {
"enabled": true,
- "name": "Starring [Actor] because you recently watched [Movie]",
+ "name": "Starring {Person}",
+ "caption": "because you recently watched {Title}",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
@@ -360,7 +361,8 @@ window.KefinTweaksConfig = {
},
"directedByDirectorRecentlyWatched": {
"enabled": true,
- "name": "Directed by [Director] because you recently watched [Movie]",
+ "name": "Directed by {Person}",
+ "caption": "because you recently watched {Title}",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
@@ -368,7 +370,8 @@ window.KefinTweaksConfig = {
},
"writtenByWriterRecentlyWatched": {
"enabled": true,
- "name": "Written by [Writer] because you recently watched [Movie]",
+ "name": "Written by {Person}",
+ "caption": "because you recently watched {Title}",
"itemLimit": 16,
"sortOrder": "Random",
"sortOrderDirection": "Ascending",
diff --git a/logo.png b/logo.png
index ff6f9a9..85ef84a 100644
Binary files a/logo.png and b/logo.png differ
diff --git a/pages/3x3.html b/pages/3x3.html
new file mode 100644
index 0000000..8745112
--- /dev/null
+++ b/pages/3x3.html
@@ -0,0 +1,1063 @@
+
+
+
+
+
+
+
+
+
+
+
+
Loading...
+
+
+
+
01:00
+
Score: 0
+
Re-rolls: 3/3
+
+
+
+
+
+
+
+ Start Quiz
+
+
+
+
+ Enter a name to submit your score
+ Name:
+
+ Score:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Final Score:
+ 0
+
+
+ Correct Guesses:
+ 0
+
+
+
+
+
+
Great job! Your score has been submitted to the leaderboard.
+
+
+
+
+
+
+
diff --git a/pages/3x3.js b/pages/3x3.js
new file mode 100644
index 0000000..3c5619b
--- /dev/null
+++ b/pages/3x3.js
@@ -0,0 +1,894 @@
+// Jellyfin Quiz Game - Enhanced with animations and preloading
+(function() {
+ 'use strict';
+
+ if (window.__kefinQuizLoaded) {
+ console.log('[Quiz] Already initialized; skipping re-entry');
+ return;
+ }
+ window.__kefinQuizLoaded = true;
+
+ var itemList = [];
+ var currentItemList = [];
+ var titlesCorrect = [];
+ var serverId;
+ var timer;
+ var quizScore = 0;
+ var correctGuesses = 0;
+ var quickGuesses = 0;
+ var username = '';
+ var resetTimer = false;
+ var preloadedImages = [];
+ var imagePreloader = null;
+ var isQuizActive = false;
+ var rerollCount = 0;
+ var maxRerolls = 3;
+ var correctGuessesData = [];
+
+ // Initialize UI state
+ document.getElementById('quizInitializeButton').disabled = true;
+ document.getElementById("quizStartButton").disabled = true;
+ document.getElementById('quizInitializeButton').style.display = "none";
+ document.getElementById('quizStartButton').style.display = "none";
+ document.getElementById('quizRerollButton').disabled = true;
+ document.getElementById('quizRerollButton').style.display = "none";
+
+ function initializeQuiz(apiClient) {
+ console.log('initing quiz');
+ document.getElementById('quizLoading').classList.remove("hide");
+ apiClient.getItems(apiClient.getCurrentUserId(), {
+ IncludeItemTypes: 'Movie',
+ Recursive: true,
+ Fields: 'PrimaryImageAspectRatio,ImageTags,BackdropImageTags'
+ }).then((data) => {
+ itemList = (data && data.Items) || [];
+ currentItemList = itemList.slice();
+ document.getElementById('quizLoading').classList.add("hide");
+ initializeQuizData();
+ });
+
+ apiClient.getCurrentUser().then((user) => { username = user.Name; });
+
+ fetch('/files/KefinTweaks/pages/scripts/hiscores.php', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+ })
+ .then(response => response.text())
+ .then(data => {
+ var hiscores = data.split("\n");
+
+ hiscores.sort((s1, s2) => {
+ var s1Value = parseInt(s1.split(';')[1]);
+ var s2Value = parseInt(s2.split(';')[1]);
+ return s1Value < s2Value ? 1 : s1Value > s2Value ? -1 : 0;
+ });
+
+ var topScores = hiscores.slice(0,10);
+
+ // Create table HTML
+ var tableHtml = `
+
+
+
+
+
+ #
+ Player
+ Score
+ Guessed
+ Date
+
+
+
+ `;
+
+ topScores.forEach((score, index) => {
+ if (score.trim()) {
+ var scoreName = score.split(';')[0];
+ var scorePoints = score.split(';')[1];
+ var scoreGuesses = score.split(';')[2];
+ var scoreDate = score.split(';')[3];
+
+ // Format date
+ var date = new Date(scoreDate);
+ var formattedDate = date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ });
+
+ // Add rank styling
+ var rankClass = '';
+ if (index === 0) rankClass = 'rank-gold';
+ else if (index === 1) rankClass = 'rank-silver';
+ else if (index === 2) rankClass = 'rank-bronze';
+
+ tableHtml += `
+
+ ${index + 1}
+ ${scoreName}
+ ${parseInt(scorePoints).toLocaleString()}
+ ${scoreGuesses}
+ ${formattedDate}
+
+ `;
+ }
+ });
+
+ tableHtml += `
+
+
+
+ `;
+
+ document.getElementById("quizHiscoresContainer").innerHTML = tableHtml;
+ })
+ .catch(error => {
+ console.log('Error fetching hiscores:', error);
+ });
+ }
+
+ function initializeQuizData() {
+ document.getElementById('quizInputContainer').classList.add("hide");
+ document.getElementById('quizInitializeButton').disabled = true;
+ document.getElementById('quizInitializeButton').style.display = "none";
+ document.getElementById("quizStartButton").disabled = true;
+ document.getElementById('quizContainer').classList.remove("active");
+ document.getElementById('quizContainer').classList.add("inactive");
+ document.getElementById('quizContainer').classList.add("hide");
+ document.getElementById('quizContainer').innerHTML = '';
+ document.getElementById("quizTimer").textContent = "01:00";
+ document.getElementById("quizTimer").classList.remove("warning");
+ document.getElementById('quizScoreDisplay').classList.add("hide");
+ document.getElementById('quizRerollDisplay').classList.add("hide");
+ document.getElementById('quizControlButtons').classList.add("hide");
+ document.getElementById('quizControlButtons').style.display = "none";
+ quizScore = 0;
+ correctGuesses = 0;
+ quickGuesses = 0;
+ rerollCount = 0;
+ titlesCorrect = [];
+ correctGuessesData = [];
+ resetTimer = true;
+ isQuizActive = false;
+
+ // Clear any existing answer overlays
+ document.querySelectorAll('.answer-overlay').forEach((overlay) => {
+ overlay.remove();
+ });
+
+ currentItemList = [...itemList]; // Create a copy
+
+ console.log('initing quiz data');
+
+ // Preload images and add them to quiz container
+ preloadImages().then(() => {
+ // Add the preloaded images to the quiz container
+ for (var i = 0; i < 9; i++) {
+ addImageToQuiz(i);
+ }
+ document.getElementById("quizStartButton").disabled = false;
+ document.getElementById('quizStartButton').style.display = "inline-block";
+ });
+ }
+
+ async function preloadImages() {
+ console.log('Preloading images...');
+ preloadedImages = [];
+ imagePreloader = document.getElementById('imagePreloader');
+ imagePreloader.innerHTML = ''; // Clear previous images
+
+ const preloadPromises = [];
+
+ for (let i = 0; i < 9; i++) {
+ const randomItem = getRandomItem();
+ const imageUrl = getImageUrl(randomItem);
+ console.log(`Preloading image ${i}:`, imageUrl);
+
+ if (!imageUrl) {
+ console.warn(`No image URL generated for index ${i}, skipping`);
+ preloadedImages.push({
+ item: randomItem,
+ url: null,
+ loaded: false
+ });
+ continue;
+ }
+
+ const preloadPromise = new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ console.log(`Image ${i} loaded successfully`);
+ preloadedImages.push({
+ item: randomItem,
+ url: imageUrl,
+ loaded: true
+ });
+ resolve();
+ };
+ img.onerror = () => {
+ console.warn('Failed to load image:', imageUrl);
+ // Try to get a different image
+ const fallbackItem = getRandomItem();
+ const fallbackUrl = getImageUrl(fallbackItem);
+ if (!fallbackUrl) {
+ console.warn(`No fallback URL for index ${i}`);
+ preloadedImages.push({
+ item: randomItem,
+ url: imageUrl,
+ loaded: false
+ });
+ resolve();
+ return;
+ }
+ const fallbackImg = new Image();
+ fallbackImg.onload = () => {
+ console.log(`Fallback image ${i} loaded successfully`);
+ preloadedImages.push({
+ item: fallbackItem,
+ url: fallbackUrl,
+ loaded: true
+ });
+ resolve();
+ };
+ fallbackImg.onerror = () => {
+ console.warn(`Both original and fallback images failed for index ${i}`);
+ preloadedImages.push({
+ item: randomItem,
+ url: imageUrl,
+ loaded: false
+ });
+ resolve(); // Continue even if image fails
+ };
+ fallbackImg.src = fallbackUrl;
+ };
+ img.src = imageUrl;
+ });
+
+ preloadPromises.push(preloadPromise);
+ }
+
+ await Promise.all(preloadPromises);
+ console.log('Image preloading complete:', preloadedImages.length, 'images loaded');
+ console.log('Preloaded images array:', preloadedImages);
+ }
+
+ function getRandomItem() {
+ if (!itemList || itemList.length === 0) {
+ return null;
+ }
+
+ if (currentItemList.length === 0) {
+ currentItemList = itemList.slice();
+ }
+
+ function hasBanner(item) {
+ return !!(item && item.ImageTags && item.ImageTags.Banner);
+ }
+
+ function hasBackdrop(item) {
+ return !!(item && item.BackdropImageTags && item.BackdropImageTags.length > 0);
+ }
+
+ // Prefer Banner-tagged titles; fall back to any with Backdrop (Banner is often missing).
+ function pickFromList(predicate) {
+ var attempts = 0;
+ var maxAttempts = currentItemList.length * 2;
+ while (attempts < maxAttempts && currentItemList.length > 0) {
+ var randomIndex = Math.floor(Math.random() * currentItemList.length);
+ var candidateItem = currentItemList[randomIndex];
+ if (predicate(candidateItem)) {
+ currentItemList.splice(randomIndex, 1);
+ return candidateItem;
+ }
+ currentItemList.splice(randomIndex, 1);
+ attempts++;
+ }
+ return null;
+ }
+
+ var randomItem = pickFromList(hasBanner);
+ if (!randomItem) {
+ currentItemList = itemList.slice();
+ randomItem = pickFromList(hasBackdrop);
+ }
+ if (!randomItem) {
+ console.warn('[Quiz] No movies with Banner or Backdrop images available');
+ currentItemList = itemList.slice();
+ return null;
+ }
+
+ console.log('randomItem', randomItem);
+ return randomItem;
+ }
+
+ function getImageUrl(item) {
+ if (!item) return null;
+ console.log('getting img url for item:', item.Name);
+ console.log('Item backdrop tags:', item.BackdropImageTags);
+ var backdropCount = item.BackdropImageTags ? item.BackdropImageTags.length : 0;
+ if (backdropCount === 0) {
+ console.warn('No backdrop images for item:', item.Name);
+ var newItem = getRandomItem();
+ if (!newItem) {
+ console.error('No backdrop images available');
+ return null;
+ }
+ backdropCount = newItem.BackdropImageTags ? newItem.BackdropImageTags.length : 0;
+ if (backdropCount === 0) {
+ console.error('No backdrop images available');
+ return null;
+ }
+ item = newItem;
+ }
+ var imageUrl = ApiClient.getImageUrl(item.Id, { type: 'Backdrop/' + Math.floor(Math.random() * backdropCount) });
+ console.log('Generated image URL:', imageUrl);
+ return imageUrl;
+ }
+
+ function addImageToQuiz(index) {
+ console.log('Adding image to quiz at index:', index);
+ console.log('Preloaded images:', preloadedImages);
+
+ // Use preloaded image if available
+ if (preloadedImages[index] && preloadedImages[index].loaded && preloadedImages[index].url) {
+ var imageData = preloadedImages[index];
+ var imageUrl = imageData.url;
+ var randomItem = imageData.item;
+ console.log('Using preloaded image:', imageUrl);
+ } else {
+ // Fallback to generating new image
+ console.log('Using fallback image generation');
+ var randomItem = getRandomItem();
+ var imageUrl = getImageUrl(randomItem);
+ }
+
+ if (!randomItem || !imageUrl) {
+ console.error('No image URL available for index', index);
+ return;
+ }
+
+ var existingImage = document.getElementById('quizContainer').querySelectorAll('img')[index];
+ console.log('Existing image at index', index, ':', existingImage);
+
+ var timeStarted = new Date();
+
+ if (existingImage) {
+ // Smooth image replacement
+ console.log('Replacing existing image');
+ replaceImageWithAnimation(existingImage, imageUrl, randomItem, timeStarted);
+ return;
+ }
+
+ // Create new image container
+ console.log('Creating new image container');
+ var imageContainer = document.createElement("div");
+ imageContainer.className = "quiz-image-container";
+
+ var image = document.createElement("IMG");
+ image.src = imageUrl;
+ image.dataset.movieName = randomItem.Name;
+ image.dataset.timeStarted = timeStarted.toString();
+ image.className = "fade-in";
+
+ imageContainer.appendChild(image);
+ document.getElementById('quizContainer').appendChild(imageContainer);
+ console.log('Added image container to quiz container');
+
+ // Preload next image for this slot
+ preloadNextImage(index);
+ }
+
+ function replaceImageWithAnimation(oldImage, newImageUrl, newItem, timeStarted) {
+ const container = oldImage.parentElement;
+
+ // Create new image
+ const newImage = document.createElement("IMG");
+ newImage.src = newImageUrl;
+ newImage.dataset.movieName = newItem.Name;
+ newImage.dataset.timeStarted = timeStarted.toString();
+ newImage.className = "fade-in";
+ newImage.style.position = "absolute";
+ newImage.style.top = "0";
+ newImage.style.left = "0";
+ newImage.style.width = "100%";
+ newImage.style.height = "100%";
+ newImage.style.objectFit = "cover";
+ newImage.style.borderRadius = "8px";
+
+ // Add new image behind old one
+ container.appendChild(newImage);
+
+ // Animate old image out
+ oldImage.classList.add("fade-out");
+
+ // Remove old image after animation
+ setTimeout(() => {
+ if (oldImage.parentElement) {
+ oldImage.parentElement.removeChild(oldImage);
+ }
+ newImage.style.position = "static";
+ }, 500);
+
+ // Preload next image for this slot
+ preloadNextImage(Array.from(container.parentElement.children).indexOf(container));
+ }
+
+ function preloadNextImage(slotIndex) {
+ // Preload a new image for this slot
+ const randomItem = getRandomItem();
+ const imageUrl = getImageUrl(randomItem);
+ if (!randomItem || !imageUrl) return;
+
+ const img = new Image();
+ img.onload = () => {
+ // Store preloaded image for this slot
+ if (!preloadedImages[slotIndex]) {
+ preloadedImages[slotIndex] = [];
+ }
+ preloadedImages[slotIndex] = {
+ item: randomItem,
+ url: imageUrl,
+ loaded: true
+ };
+ };
+ img.src = imageUrl;
+ }
+
+ function quizListeners() {
+ console.log('adding listener');
+ document.getElementById('quizGuess').addEventListener('input', checkGuess);
+
+ document.getElementById('quizStartButton').addEventListener('click', startQuiz);
+ document.getElementById('quizInitializeButton').addEventListener('click', initializeQuizData);
+ document.getElementById('quizRerollButton').addEventListener('click', rerollQuiz);
+
+ // Modal listeners
+ document.getElementById('modalCloseButton').addEventListener('click', hideScoreModal);
+ document.getElementById('scoreModal').addEventListener('click', function(e) {
+ if (e.target === this) {
+ hideScoreModal();
+ }
+ });
+ }
+
+ function startQuiz() {
+ resetTimer = false;
+ isQuizActive = true;
+ document.getElementById('quizHeaderContainer').classList.add("hide");
+ document.getElementById('quizContainer').classList.add("active");
+ document.getElementById('quizContainer').classList.remove("inactive");
+ document.getElementById('quizContainer').classList.remove("hide");
+ document.getElementById('quizControlButtons').classList.remove("hide");
+ document.getElementById('quizTimerContainer').classList.remove("hide");
+ document.getElementById('quizScoreDisplay').classList.add("show");
+ document.getElementById('quizRerollDisplay').classList.add("show");
+ document.getElementById('quizInitializeButton').disabled = false;
+ document.getElementById('quizStartButton').disabled = false;
+ document.getElementById('quizRerollButton').disabled = false;
+ document.getElementById('quizStartButton').style.display = "none";
+ document.getElementById('quizControlButtons').style.display = "flex";
+
+ // Update reroll display and button
+ updateRerollDisplay();
+ updateRerollButton();
+
+ // Clear input field and focus
+ document.getElementById('quizGuess').value = '';
+ document.getElementById('quizGuess').focus();
+
+ startTimer(59, document.getElementById("quizTimer"));
+
+ var quizStartedTime = new Date();
+ console.log('setting time started');
+ document.querySelectorAll('#quizContainer img').forEach((item) => {
+ item.dataset["timeStarted"] = quizStartedTime.toString();
+ console.log('setting time started');
+ });
+ document.getElementById("quizGuess").focus();
+ }
+
+ function startTimer(duration, display) {
+ tickTimer(duration, display);
+ }
+
+ function tickTimer(duration, display) {
+ timer = duration;
+ var minutes, seconds;
+ setTimeout(function () {
+ minutes = parseInt(timer / 60, 10);
+ seconds = parseInt(timer % 60, 10);
+
+ minutes = minutes < 10 ? "0" + minutes : minutes;
+ seconds = seconds < 10 ? "0" + seconds : seconds;
+
+ display.textContent = minutes + ":" + seconds;
+
+ // Add warning animation when time is low
+ if (timer <= 10 && timer > 0) {
+ display.classList.add("warning");
+ }
+
+ // Update reroll cost display
+ if (isQuizActive) {
+ updateRerollButton();
+ updateRerollDisplay();
+ }
+
+ if (resetTimer) {
+ timer = 0;
+ display.textContent = "01:00";
+ display.classList.remove("warning");
+ resetTimer = false;
+ } else if (--timer < -1) {
+ timer = 0;
+ display.classList.remove("warning");
+ finishQuiz();
+ } else {
+ tickTimer(timer, display);
+ }
+ }, 1000);
+ }
+
+ function finishQuiz() {
+ isQuizActive = false;
+ document.getElementById("quizStartButton").disabled = true;
+ // Keep control buttons visible so user can reset quiz
+ // Don't hide the quiz container, just show answers overlay
+ showAnswers();
+
+ console.log('submitting data for ' + username + ' with total ' + correctGuesses + ' correct');
+
+ // Show modal immediately
+ showScoreModal();
+
+ const formData = new FormData();
+ formData.append('username', username);
+ formData.append('score', Math.round(quizScore));
+ formData.append('correctItemCount', correctGuesses);
+
+ fetch('/files/KefinTweaks/pages/scripts/quiz.php', {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => response.text())
+ .then(data => {
+ console.log(data);
+ // Update modal message with server response
+ if (quizScore > 0) {
+ document.getElementById('modalMessage').textContent = data;
+ } else {
+ document.getElementById('modalMessage').textContent = 'Try to finish with more than 0 points next time!';
+ }
+ })
+ .catch(error => {
+ console.log('Error submitting quiz score:', error);
+ document.getElementById('modalMessage').textContent = 'Error submitting score. Please try again.';
+ });
+
+ document.getElementById("quizTimer").textContent = "01:00";
+ document.getElementById("quizTimer").classList.remove("warning");
+ }
+
+ function showAnswers() {
+ // Add answer overlays directly to each image container
+ document.querySelectorAll('#quizContainer .quiz-image-container').forEach((container) => {
+ var img = container.querySelector('img');
+ if (img) {
+ // Check if answer overlay already exists
+ var existingAnswer = container.querySelector('.answer-overlay');
+ if (existingAnswer) {
+ existingAnswer.remove();
+ }
+
+ // Create answer overlay
+ var answerOverlay = document.createElement('div');
+ answerOverlay.className = 'answer-overlay';
+ answerOverlay.innerHTML = img.dataset.movieName;
+
+ // Style the answer overlay
+ answerOverlay.style.position = 'absolute';
+ answerOverlay.style.bottom = '0';
+ answerOverlay.style.left = '0';
+ answerOverlay.style.right = '0';
+ answerOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
+ answerOverlay.style.color = '#4ecdc4';
+ answerOverlay.style.fontSize = '14px';
+ answerOverlay.style.fontWeight = 'bold';
+ answerOverlay.style.textAlign = 'center';
+ answerOverlay.style.padding = '8px';
+ answerOverlay.style.borderRadius = '0 0 8px 8px';
+ answerOverlay.style.zIndex = '10';
+ answerOverlay.style.textShadow = '2px 2px 4px rgba(0,0,0,0.8)';
+ answerOverlay.style.lineHeight = '1.2';
+ answerOverlay.style.wordWrap = 'break-word';
+ answerOverlay.style.boxSizing = 'border-box';
+
+ // Add to container
+ container.appendChild(answerOverlay);
+ }
+ });
+ }
+
+ function checkGuess() {
+ if (!isQuizActive) return;
+
+ var index = 0;
+ var quizItems = document.querySelectorAll('#quizContainer img');
+ var foundMatch = false;
+
+ quizItems.forEach((item) => {
+ if (foundMatch) return;
+
+ var guessValue = this.value.replace(/[^a-zA-Z ]/g, "");
+ var movieName = item.dataset.movieName.replace(/[^a-zA-Z ]/g, "");
+ var similarityScore = similarity(guessValue, movieName);
+ if (similarityScore > 0.5) {
+ console.log(`Similarity Score ${similarityScore} for guess ${guessValue} compared to ${movieName}`);
+ if (similarityScore > 0.95) {
+ console.log('match based on similarity ' + similarityScore);
+
+ var currentTime = new Date();
+ var timeStarted = new Date(item.dataset.timeStarted);
+ var timePassed = (currentTime - timeStarted) / 1000;
+ var timeMultiplier = (120 - timePassed) / 120;
+ var pointsEarned = 120 * timeMultiplier;
+ titlesCorrect.push(item.dataset.movieName);
+ quizScore += pointsEarned;
+ correctGuesses++;
+
+ // Track correct guess data
+ correctGuessesData.push({
+ movieName: item.dataset.movieName,
+ timePassed: timePassed,
+ pointsEarned: pointsEarned,
+ timestamp: currentTime
+ });
+
+ console.log('Points earned: ' + pointsEarned + ' for ' + item.dataset.movieName + ' in ' + timePassed.toFixed(2) + ' seconds');
+
+ // Show points animation
+ showPointsAnimation(item, Math.round(pointsEarned));
+
+ // Update score display
+ updateScoreDisplay();
+
+ // Replace image
+ addImageToQuiz(index);
+ this.value = '';
+ foundMatch = true;
+ return;
+ }
+ }
+ index++;
+ });
+ }
+
+ function showPointsAnimation(imageElement, points) {
+ const container = imageElement.closest('.quiz-image-container') || imageElement.parentElement;
+ const rect = container.getBoundingClientRect();
+
+ const pointsElement = document.createElement('div');
+ pointsElement.className = 'points-animation';
+ pointsElement.textContent = `+${points}`;
+ pointsElement.style.left = (rect.left + rect.width / 2) + 'px';
+ pointsElement.style.top = (rect.top + rect.height / 2) + 'px';
+
+ document.body.appendChild(pointsElement);
+
+ // Remove after animation
+ setTimeout(() => {
+ if (pointsElement.parentElement) {
+ pointsElement.parentElement.removeChild(pointsElement);
+ }
+ }, 2000);
+ }
+
+ function showRerollAnimation() {
+ const remaining = maxRerolls - rerollCount;
+ const rerollButton = document.getElementById('quizRerollButton');
+ const rect = rerollButton.getBoundingClientRect();
+
+ const rerollElement = document.createElement('div');
+ rerollElement.className = 'reroll-animation';
+ rerollElement.textContent = `${remaining} rolls remaining`;
+ rerollElement.style.left = (rect.left + rect.width / 2) + 'px';
+ rerollElement.style.top = (rect.top - 20) + 'px';
+
+ document.body.appendChild(rerollElement);
+
+ // Remove after animation
+ setTimeout(() => {
+ if (rerollElement.parentElement) {
+ rerollElement.parentElement.removeChild(rerollElement);
+ }
+ }, 2500);
+ }
+
+ function updateScoreDisplay() {
+ const scoreDisplay = document.getElementById('quizScoreDisplay');
+ scoreDisplay.textContent = `Score: ${Math.round(quizScore)}`;
+ scoreDisplay.classList.add('show');
+ }
+
+ function calculateRerollCost() {
+ return 50;
+ // Cost scales from 200 at start to 120 at end (60 second timer)
+ // Formula: 200 - (timeElapsed / 60) * 80
+ const timeElapsed = 60 - timer; // timer counts down from 60
+ const cost = Math.max(50, 200 - (timeElapsed / 60) * 80);
+ return Math.round(cost);
+ }
+
+ function updateRerollDisplay() {
+ const rerollDisplay = document.getElementById('quizRerollDisplay');
+ const remaining = maxRerolls - rerollCount;
+ const currentCost = calculateRerollCost();
+ rerollDisplay.textContent = `Re-rolls: ${remaining}/${maxRerolls} (${currentCost} pts)`;
+ rerollDisplay.classList.add('show');
+ }
+
+ function updateRerollButton() {
+ const rerollButton = document.getElementById('quizRerollButton');
+ const currentCost = calculateRerollCost();
+ rerollButton.textContent = `Re-roll (-${currentCost} pts)`;
+ }
+
+ async function rerollQuiz() {
+ if (rerollCount >= maxRerolls || !isQuizActive) {
+ return;
+ }
+
+ // Calculate and deduct dynamic cost
+ const rerollCost = calculateRerollCost();
+ quizScore = Math.max(0, quizScore - rerollCost);
+ rerollCount++;
+
+ // Show reroll animation
+ showRerollAnimation();
+
+ // Update displays
+ updateScoreDisplay();
+ updateRerollDisplay();
+
+ // Clear and refocus input field
+ document.getElementById('quizGuess').value = '';
+ document.getElementById('quizGuess').focus();
+
+ // Disable button if no rerolls left
+ if (rerollCount >= maxRerolls) {
+ document.getElementById('quizRerollButton').disabled = true;
+ }
+
+ // Clear existing answer overlays
+ document.querySelectorAll('.answer-overlay').forEach((overlay) => {
+ overlay.remove();
+ });
+
+ // Preload new images
+ await preloadImages();
+
+ // Clear the entire quiz container
+ document.getElementById('quizContainer').innerHTML = '';
+
+ // Add all 9 new images to the grid
+ for (var i = 0; i < 9; i++) {
+ addImageToQuiz(i);
+ }
+
+ console.log('Quiz rerolled! All 9 images replaced.');
+ }
+
+ function similarity(s1, s2) {
+ var longer = s1;
+ var shorter = s2;
+ if (s1.length < s2.length) {
+ longer = s2;
+ shorter = s1;
+ }
+ var longerLength = longer.length;
+ if (longerLength == 0) {
+ return 1.0;
+ }
+ return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength);
+ }
+
+ function editDistance(s1, s2) {
+ s1 = s1.toLowerCase();
+ s2 = s2.toLowerCase();
+
+ var costs = new Array();
+ for (var i = 0; i <= s1.length; i++) {
+ var lastValue = i;
+ for (var j = 0; j <= s2.length; j++) {
+ if (i == 0)
+ costs[j] = j;
+ else {
+ if (j > 0) {
+ var newValue = costs[j - 1];
+ if (s1.charAt(i - 1) != s2.charAt(j - 1))
+ newValue = Math.min(Math.min(newValue, lastValue),
+ costs[j]) + 1;
+ costs[j - 1] = lastValue;
+ lastValue = newValue;
+ }
+ }
+ }
+ if (i > 0)
+ costs[s2.length] = lastValue;
+ }
+ return costs[s2.length];
+ }
+
+ function showScoreModal() {
+ // Update modal content
+ document.getElementById('modalFinalScore').textContent = Math.round(quizScore);
+ document.getElementById('modalCorrectGuesses').textContent = correctGuesses;
+
+ // Update correct guesses list
+ updateCorrectGuessesList();
+
+ // Show modal
+ document.getElementById('scoreModal').classList.add('show');
+ }
+
+ function updateCorrectGuessesList() {
+ const guessesList = document.getElementById('modalGuessesList');
+ if (!guessesList) return;
+
+ // Sort guesses by time (fastest first)
+ const sortedGuesses = correctGuessesData.sort((a, b) => a.timePassed - b.timePassed);
+
+ let html = '';
+ sortedGuesses.forEach((guess, index) => {
+ const timeFormatted = guess.timePassed < 1 ?
+ `${Math.round(guess.timePassed * 1000)}ms` :
+ `${guess.timePassed.toFixed(1)}s`;
+
+ html += `
+
+ ${index + 1}.
+ ${guess.movieName}
+ ${timeFormatted}
+ +${Math.round(guess.pointsEarned)}
+
+ `;
+ });
+
+ guessesList.innerHTML = html;
+ }
+
+ function hideScoreModal() {
+ document.getElementById('scoreModal').classList.remove('show');
+ }
+
+ // Main initialization function
+ function initializeQuizApp() {
+ console.log('Initializing quiz app');
+ serverId = ApiClient.serverId();
+ initializeQuiz(ApiClient);
+ quizListeners();
+ }
+
+ // Initialize the quiz when the script loads
+ if (typeof ApiClient !== 'undefined') {
+ // Wait for DOM to be ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializeQuizApp);
+ } else {
+ initializeQuizApp();
+ }
+ } else {
+ // Wait for ApiClient to be available
+ const checkApiClient = setInterval(() => {
+ if (typeof ApiClient !== 'undefined') {
+ clearInterval(checkApiClient);
+ initializeQuizApp();
+ }
+ }, 100);
+ }
+})();
\ No newline at end of file
diff --git a/pages/games.html b/pages/games.html
new file mode 100644
index 0000000..1228dcc
--- /dev/null
+++ b/pages/games.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+ KefinTweaks Games
+
+
+
+
+
Games
+
Pick a game. Add more entries here when new games ship.
+
+
+
+
diff --git a/pages/images/clients/afinity.png b/pages/images/clients/afinity.png
new file mode 100644
index 0000000..cf397b6
Binary files /dev/null and b/pages/images/clients/afinity.png differ
diff --git a/pages/images/clients/moonfin.png b/pages/images/clients/moonfin.png
new file mode 100644
index 0000000..c5cbdda
Binary files /dev/null and b/pages/images/clients/moonfin.png differ
diff --git a/pages/images/clients/neptune.png b/pages/images/clients/neptune.png
new file mode 100644
index 0000000..52e50a0
Binary files /dev/null and b/pages/images/clients/neptune.png differ
diff --git a/pages/images/clients/pelagica.png b/pages/images/clients/pelagica.png
new file mode 100644
index 0000000..a11da5a
Binary files /dev/null and b/pages/images/clients/pelagica.png differ
diff --git a/pages/images/clients/streamyfin.png b/pages/images/clients/streamyfin.png
new file mode 100644
index 0000000..ede5dc7
Binary files /dev/null and b/pages/images/clients/streamyfin.png differ
diff --git a/pages/images/clients/wholphin.png b/pages/images/clients/wholphin.png
new file mode 100644
index 0000000..4fa742d
Binary files /dev/null and b/pages/images/clients/wholphin.png differ
diff --git a/pages/images/collections/addiction.jpg b/pages/images/collections/addiction.jpg
new file mode 100644
index 0000000..7de812e
Binary files /dev/null and b/pages/images/collections/addiction.jpg differ
diff --git a/pages/images/collections/alien.jpg b/pages/images/collections/alien.jpg
new file mode 100644
index 0000000..6e4ade4
Binary files /dev/null and b/pages/images/collections/alien.jpg differ
diff --git a/pages/images/collections/alternative-reality.jpg b/pages/images/collections/alternative-reality.jpg
new file mode 100644
index 0000000..9c6192a
Binary files /dev/null and b/pages/images/collections/alternative-reality.jpg differ
diff --git a/pages/images/collections/anti-hero.jpg b/pages/images/collections/anti-hero.jpg
new file mode 100644
index 0000000..1acd099
Binary files /dev/null and b/pages/images/collections/anti-hero.jpg differ
diff --git a/pages/images/collections/artificial-intelligence.jpg b/pages/images/collections/artificial-intelligence.jpg
new file mode 100644
index 0000000..2936221
Binary files /dev/null and b/pages/images/collections/artificial-intelligence.jpg differ
diff --git a/pages/images/collections/based-on-comic-book.jpg b/pages/images/collections/based-on-comic-book.jpg
new file mode 100644
index 0000000..6cfdc49
Binary files /dev/null and b/pages/images/collections/based-on-comic-book.jpg differ
diff --git a/pages/images/collections/based-on-memoir.jpg b/pages/images/collections/based-on-memoir.jpg
new file mode 100644
index 0000000..8d48274
Binary files /dev/null and b/pages/images/collections/based-on-memoir.jpg differ
diff --git a/pages/images/collections/based-on-novel.jpg b/pages/images/collections/based-on-novel.jpg
new file mode 100644
index 0000000..6183cac
Binary files /dev/null and b/pages/images/collections/based-on-novel.jpg differ
diff --git a/pages/images/collections/based-on-play-musical.jpg b/pages/images/collections/based-on-play-musical.jpg
new file mode 100644
index 0000000..7c5e259
Binary files /dev/null and b/pages/images/collections/based-on-play-musical.jpg differ
diff --git a/pages/images/collections/based-on-true-story.jpg b/pages/images/collections/based-on-true-story.jpg
new file mode 100644
index 0000000..34f22b1
Binary files /dev/null and b/pages/images/collections/based-on-true-story.jpg differ
diff --git a/pages/images/collections/based-on-video-game.jpg b/pages/images/collections/based-on-video-game.jpg
new file mode 100644
index 0000000..ae3603c
Binary files /dev/null and b/pages/images/collections/based-on-video-game.jpg differ
diff --git a/pages/images/collections/biography.jpg b/pages/images/collections/biography.jpg
new file mode 100644
index 0000000..a69e3f7
Binary files /dev/null and b/pages/images/collections/biography.jpg differ
diff --git a/pages/images/collections/camp.jpg b/pages/images/collections/camp.jpg
new file mode 100644
index 0000000..af8af80
Binary files /dev/null and b/pages/images/collections/camp.jpg differ
diff --git a/pages/images/collections/class-differences.jpg b/pages/images/collections/class-differences.jpg
new file mode 100644
index 0000000..8d21753
Binary files /dev/null and b/pages/images/collections/class-differences.jpg differ
diff --git a/pages/images/collections/coming-of-age.jpg b/pages/images/collections/coming-of-age.jpg
new file mode 100644
index 0000000..e4d4f82
Binary files /dev/null and b/pages/images/collections/coming-of-age.jpg differ
diff --git a/pages/images/collections/conspiracy.jpg b/pages/images/collections/conspiracy.jpg
new file mode 100644
index 0000000..95e79d7
Binary files /dev/null and b/pages/images/collections/conspiracy.jpg differ
diff --git a/pages/images/collections/courtroom.jpg b/pages/images/collections/courtroom.jpg
new file mode 100644
index 0000000..0a6c61f
Binary files /dev/null and b/pages/images/collections/courtroom.jpg differ
diff --git a/pages/images/collections/criminal-mastermind.jpg b/pages/images/collections/criminal-mastermind.jpg
new file mode 100644
index 0000000..298a7f8
Binary files /dev/null and b/pages/images/collections/criminal-mastermind.jpg differ
diff --git a/pages/images/collections/cyberpunk.jpg b/pages/images/collections/cyberpunk.jpg
new file mode 100644
index 0000000..08179a6
Binary files /dev/null and b/pages/images/collections/cyberpunk.jpg differ
diff --git a/pages/images/collections/dark-comedy.jpg b/pages/images/collections/dark-comedy.jpg
new file mode 100644
index 0000000..3f59a1d
Binary files /dev/null and b/pages/images/collections/dark-comedy.jpg differ
diff --git a/pages/images/collections/dysfunctional-family.jpg b/pages/images/collections/dysfunctional-family.jpg
new file mode 100644
index 0000000..9d6dfa1
Binary files /dev/null and b/pages/images/collections/dysfunctional-family.jpg differ
diff --git a/pages/images/collections/dystopia.jpg b/pages/images/collections/dystopia.jpg
new file mode 100644
index 0000000..f4e8aec
Binary files /dev/null and b/pages/images/collections/dystopia.jpg differ
diff --git a/pages/images/collections/faith-religion.jpg b/pages/images/collections/faith-religion.jpg
new file mode 100644
index 0000000..12412e9
Binary files /dev/null and b/pages/images/collections/faith-religion.jpg differ
diff --git a/pages/images/collections/family.jpg b/pages/images/collections/family.jpg
new file mode 100644
index 0000000..e151d5e
Binary files /dev/null and b/pages/images/collections/family.jpg differ
diff --git a/pages/images/collections/female-protagonist.jpg b/pages/images/collections/female-protagonist.jpg
new file mode 100644
index 0000000..d6d2f84
Binary files /dev/null and b/pages/images/collections/female-protagonist.jpg differ
diff --git a/pages/images/collections/film-noir.jpg b/pages/images/collections/film-noir.jpg
new file mode 100644
index 0000000..294824e
Binary files /dev/null and b/pages/images/collections/film-noir.jpg differ
diff --git a/pages/images/collections/friendship.jpg b/pages/images/collections/friendship.jpg
new file mode 100644
index 0000000..f613af3
Binary files /dev/null and b/pages/images/collections/friendship.jpg differ
diff --git a/pages/images/collections/gangster.jpg b/pages/images/collections/gangster.jpg
new file mode 100644
index 0000000..501fd4b
Binary files /dev/null and b/pages/images/collections/gangster.jpg differ
diff --git a/pages/images/collections/ghost.jpg b/pages/images/collections/ghost.jpg
new file mode 100644
index 0000000..0864868
Binary files /dev/null and b/pages/images/collections/ghost.jpg differ
diff --git a/pages/images/collections/heist.jpg b/pages/images/collections/heist.jpg
new file mode 100644
index 0000000..71bf9d3
Binary files /dev/null and b/pages/images/collections/heist.jpg differ
diff --git a/pages/images/collections/high-school.jpg b/pages/images/collections/high-school.jpg
new file mode 100644
index 0000000..3cbbaf4
Binary files /dev/null and b/pages/images/collections/high-school.jpg differ
diff --git a/pages/images/collections/identity.jpg b/pages/images/collections/identity.jpg
new file mode 100644
index 0000000..6bdbcba
Binary files /dev/null and b/pages/images/collections/identity.jpg differ
diff --git a/pages/images/collections/island.jpg b/pages/images/collections/island.jpg
new file mode 100644
index 0000000..54b4fe9
Binary files /dev/null and b/pages/images/collections/island.jpg differ
diff --git a/pages/images/collections/kaiju.jpg b/pages/images/collections/kaiju.jpg
new file mode 100644
index 0000000..765f7b8
Binary files /dev/null and b/pages/images/collections/kaiju.jpg differ
diff --git a/pages/images/collections/lgbtq.jpg b/pages/images/collections/lgbtq.jpg
new file mode 100644
index 0000000..853b594
Binary files /dev/null and b/pages/images/collections/lgbtq.jpg differ
diff --git a/pages/images/collections/mafia.jpg b/pages/images/collections/mafia.jpg
new file mode 100644
index 0000000..fdee8ad
Binary files /dev/null and b/pages/images/collections/mafia.jpg differ
diff --git a/pages/images/collections/magic.jpg b/pages/images/collections/magic.jpg
new file mode 100644
index 0000000..cb91eb0
Binary files /dev/null and b/pages/images/collections/magic.jpg differ
diff --git a/pages/images/collections/medieval.jpg b/pages/images/collections/medieval.jpg
new file mode 100644
index 0000000..ada5047
Binary files /dev/null and b/pages/images/collections/medieval.jpg differ
diff --git a/pages/images/collections/mental-health.jpg b/pages/images/collections/mental-health.jpg
new file mode 100644
index 0000000..6b59066
Binary files /dev/null and b/pages/images/collections/mental-health.jpg differ
diff --git a/pages/images/collections/mentor.jpg b/pages/images/collections/mentor.jpg
new file mode 100644
index 0000000..f806009
Binary files /dev/null and b/pages/images/collections/mentor.jpg differ
diff --git a/pages/images/collections/mockumentary.jpg b/pages/images/collections/mockumentary.jpg
new file mode 100644
index 0000000..007cf4b
Binary files /dev/null and b/pages/images/collections/mockumentary.jpg differ
diff --git a/pages/images/collections/murder.jpg b/pages/images/collections/murder.jpg
new file mode 100644
index 0000000..b5a6291
Binary files /dev/null and b/pages/images/collections/murder.jpg differ
diff --git a/pages/images/collections/mythology.jpg b/pages/images/collections/mythology.jpg
new file mode 100644
index 0000000..cffc80c
Binary files /dev/null and b/pages/images/collections/mythology.jpg differ
diff --git a/pages/images/collections/neo-noir.jpg b/pages/images/collections/neo-noir.jpg
new file mode 100644
index 0000000..74cfe02
Binary files /dev/null and b/pages/images/collections/neo-noir.jpg differ
diff --git a/pages/images/collections/organized-crime.jpg b/pages/images/collections/organized-crime.jpg
new file mode 100644
index 0000000..10075ce
Binary files /dev/null and b/pages/images/collections/organized-crime.jpg differ
diff --git a/pages/images/collections/parallel-universe.jpg b/pages/images/collections/parallel-universe.jpg
new file mode 100644
index 0000000..33d5fa3
Binary files /dev/null and b/pages/images/collections/parallel-universe.jpg differ
diff --git a/pages/images/collections/period-piece.jpg b/pages/images/collections/period-piece.jpg
new file mode 100644
index 0000000..029fdae
Binary files /dev/null and b/pages/images/collections/period-piece.jpg differ
diff --git a/pages/images/collections/post-apocalyptic.jpg b/pages/images/collections/post-apocalyptic.jpg
new file mode 100644
index 0000000..d433dd7
Binary files /dev/null and b/pages/images/collections/post-apocalyptic.jpg differ
diff --git a/pages/images/collections/prison.jpg b/pages/images/collections/prison.jpg
new file mode 100644
index 0000000..7fa6919
Binary files /dev/null and b/pages/images/collections/prison.jpg differ
diff --git a/pages/images/collections/psychological-thriller.jpg b/pages/images/collections/psychological-thriller.jpg
new file mode 100644
index 0000000..0825b75
Binary files /dev/null and b/pages/images/collections/psychological-thriller.jpg differ
diff --git a/pages/images/collections/redemption.jpg b/pages/images/collections/redemption.jpg
new file mode 100644
index 0000000..58202f7
Binary files /dev/null and b/pages/images/collections/redemption.jpg differ
diff --git a/pages/images/collections/remake-reboot.jpg b/pages/images/collections/remake-reboot.jpg
new file mode 100644
index 0000000..bf12b83
Binary files /dev/null and b/pages/images/collections/remake-reboot.jpg differ
diff --git a/pages/images/collections/revenge.jpg b/pages/images/collections/revenge.jpg
new file mode 100644
index 0000000..0ddfce9
Binary files /dev/null and b/pages/images/collections/revenge.jpg differ
diff --git a/pages/images/collections/road-trip.jpg b/pages/images/collections/road-trip.jpg
new file mode 100644
index 0000000..502bc2a
Binary files /dev/null and b/pages/images/collections/road-trip.jpg differ
diff --git a/pages/images/collections/satire.jpg b/pages/images/collections/satire.jpg
new file mode 100644
index 0000000..5880e80
Binary files /dev/null and b/pages/images/collections/satire.jpg differ
diff --git a/pages/images/collections/serial-killer.jpg b/pages/images/collections/serial-killer.jpg
new file mode 100644
index 0000000..e330fc1
Binary files /dev/null and b/pages/images/collections/serial-killer.jpg differ
diff --git a/pages/images/collections/small-town.jpg b/pages/images/collections/small-town.jpg
new file mode 100644
index 0000000..d688f5b
Binary files /dev/null and b/pages/images/collections/small-town.jpg differ
diff --git a/pages/images/collections/space.jpg b/pages/images/collections/space.jpg
new file mode 100644
index 0000000..88326db
Binary files /dev/null and b/pages/images/collections/space.jpg differ
diff --git a/pages/images/collections/sports.jpg b/pages/images/collections/sports.jpg
new file mode 100644
index 0000000..f40ff41
Binary files /dev/null and b/pages/images/collections/sports.jpg differ
diff --git a/pages/images/collections/spy.jpg b/pages/images/collections/spy.jpg
new file mode 100644
index 0000000..6d06067
Binary files /dev/null and b/pages/images/collections/spy.jpg differ
diff --git a/pages/images/collections/super-power.jpg b/pages/images/collections/super-power.jpg
new file mode 100644
index 0000000..74eae38
Binary files /dev/null and b/pages/images/collections/super-power.jpg differ
diff --git a/pages/images/collections/superhero.jpg b/pages/images/collections/superhero.jpg
new file mode 100644
index 0000000..ccc101d
Binary files /dev/null and b/pages/images/collections/superhero.jpg differ
diff --git a/pages/images/collections/supernatural.jpg b/pages/images/collections/supernatural.jpg
new file mode 100644
index 0000000..4a65498
Binary files /dev/null and b/pages/images/collections/supernatural.jpg differ
diff --git a/pages/images/collections/surrealism.jpg b/pages/images/collections/surrealism.jpg
new file mode 100644
index 0000000..b3184b7
Binary files /dev/null and b/pages/images/collections/surrealism.jpg differ
diff --git a/pages/images/collections/survival.jpg b/pages/images/collections/survival.jpg
new file mode 100644
index 0000000..8e9572f
Binary files /dev/null and b/pages/images/collections/survival.jpg differ
diff --git a/pages/images/collections/time-loop.jpg b/pages/images/collections/time-loop.jpg
new file mode 100644
index 0000000..78fbf7b
Binary files /dev/null and b/pages/images/collections/time-loop.jpg differ
diff --git a/pages/images/collections/time-travel.jpg b/pages/images/collections/time-travel.jpg
new file mode 100644
index 0000000..26e4bb8
Binary files /dev/null and b/pages/images/collections/time-travel.jpg differ
diff --git a/pages/images/collections/true-crime.jpg b/pages/images/collections/true-crime.jpg
new file mode 100644
index 0000000..fdfe766
Binary files /dev/null and b/pages/images/collections/true-crime.jpg differ
diff --git a/pages/images/collections/true-story.jpg b/pages/images/collections/true-story.jpg
new file mode 100644
index 0000000..a172110
Binary files /dev/null and b/pages/images/collections/true-story.jpg differ
diff --git a/pages/images/collections/underdog.jpg b/pages/images/collections/underdog.jpg
new file mode 100644
index 0000000..912aab5
Binary files /dev/null and b/pages/images/collections/underdog.jpg differ
diff --git a/pages/images/collections/unreliable-narrator.jpg b/pages/images/collections/unreliable-narrator.jpg
new file mode 100644
index 0000000..1eca417
Binary files /dev/null and b/pages/images/collections/unreliable-narrator.jpg differ
diff --git a/pages/images/collections/vampire.jpg b/pages/images/collections/vampire.jpg
new file mode 100644
index 0000000..5169201
Binary files /dev/null and b/pages/images/collections/vampire.jpg differ
diff --git a/pages/images/collections/victorian-era.jpg b/pages/images/collections/victorian-era.jpg
new file mode 100644
index 0000000..698125d
Binary files /dev/null and b/pages/images/collections/victorian-era.jpg differ
diff --git a/pages/images/collections/vigilante.jpg b/pages/images/collections/vigilante.jpg
new file mode 100644
index 0000000..c945602
Binary files /dev/null and b/pages/images/collections/vigilante.jpg differ
diff --git a/pages/images/collections/villain.jpg b/pages/images/collections/villain.jpg
new file mode 100644
index 0000000..6658030
Binary files /dev/null and b/pages/images/collections/villain.jpg differ
diff --git a/pages/images/collections/virtual-reality.jpg b/pages/images/collections/virtual-reality.jpg
new file mode 100644
index 0000000..6dd0b65
Binary files /dev/null and b/pages/images/collections/virtual-reality.jpg differ
diff --git a/pages/images/collections/war.jpg b/pages/images/collections/war.jpg
new file mode 100644
index 0000000..cd1ec75
Binary files /dev/null and b/pages/images/collections/war.jpg differ
diff --git a/pages/images/collections/werewolf.jpg b/pages/images/collections/werewolf.jpg
new file mode 100644
index 0000000..dd71f35
Binary files /dev/null and b/pages/images/collections/werewolf.jpg differ
diff --git a/pages/images/collections/western.jpg b/pages/images/collections/western.jpg
new file mode 100644
index 0000000..8c10cfe
Binary files /dev/null and b/pages/images/collections/western.jpg differ
diff --git a/pages/images/collections/witchcraft.jpg b/pages/images/collections/witchcraft.jpg
new file mode 100644
index 0000000..5799cff
Binary files /dev/null and b/pages/images/collections/witchcraft.jpg differ
diff --git a/pages/images/collections/workplace.jpg b/pages/images/collections/workplace.jpg
new file mode 100644
index 0000000..5254d13
Binary files /dev/null and b/pages/images/collections/workplace.jpg differ
diff --git a/pages/images/collections/zombie.jpg b/pages/images/collections/zombie.jpg
new file mode 100644
index 0000000..d81cfb4
Binary files /dev/null and b/pages/images/collections/zombie.jpg differ
diff --git a/pages/images/corner-decoration.svg b/pages/images/corner-decoration.svg
new file mode 100644
index 0000000..01e7edd
--- /dev/null
+++ b/pages/images/corner-decoration.svg
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/images/devices-transparent.png b/pages/images/devices-transparent.png
new file mode 100644
index 0000000..65f670c
Binary files /dev/null and b/pages/images/devices-transparent.png differ
diff --git a/pages/images/devices.png b/pages/images/devices.png
new file mode 100644
index 0000000..fde9135
Binary files /dev/null and b/pages/images/devices.png differ
diff --git a/pages/images/faq/disablecache.png b/pages/images/faq/disablecache.png
new file mode 100644
index 0000000..aa47ec4
Binary files /dev/null and b/pages/images/faq/disablecache.png differ
diff --git a/pages/images/faq/pending.png b/pages/images/faq/pending.png
new file mode 100644
index 0000000..43ce73a
Binary files /dev/null and b/pages/images/faq/pending.png differ
diff --git a/pages/images/faq/publicjs.png b/pages/images/faq/publicjs.png
new file mode 100644
index 0000000..585aa93
Binary files /dev/null and b/pages/images/faq/publicjs.png differ
diff --git a/pages/images/releases/v0.5/borders.webp b/pages/images/releases/v0.5/borders.webp
new file mode 100644
index 0000000..c748a9f
Binary files /dev/null and b/pages/images/releases/v0.5/borders.webp differ
diff --git a/pages/images/releases/v0.5/cardformats.webp b/pages/images/releases/v0.5/cardformats.webp
new file mode 100644
index 0000000..20a086c
Binary files /dev/null and b/pages/images/releases/v0.5/cardformats.webp differ
diff --git a/pages/images/releases/v0.5/spotlights.webp b/pages/images/releases/v0.5/spotlights.webp
new file mode 100644
index 0000000..479d542
Binary files /dev/null and b/pages/images/releases/v0.5/spotlights.webp differ
diff --git a/pages/images/releases/v0.5/v0.5.png b/pages/images/releases/v0.5/v0.5.png
new file mode 100644
index 0000000..1858f98
Binary files /dev/null and b/pages/images/releases/v0.5/v0.5.png differ
diff --git a/pages/images/releases/v0.5/v0.5.webp b/pages/images/releases/v0.5/v0.5.webp
new file mode 100644
index 0000000..1bef724
Binary files /dev/null and b/pages/images/releases/v0.5/v0.5.webp differ
diff --git a/pages/images/releases/v0.5/wizard.webp b/pages/images/releases/v0.5/wizard.webp
new file mode 100644
index 0000000..338be8c
Binary files /dev/null and b/pages/images/releases/v0.5/wizard.webp differ
diff --git a/pages/images/statistics.png b/pages/images/statistics.png
index b0fb6f3..02a6b0a 100644
Binary files a/pages/images/statistics.png and b/pages/images/statistics.png differ
diff --git a/pages/images/studios/AMC.svg b/pages/images/studios/AMC.svg
new file mode 100644
index 0000000..87b39e4
--- /dev/null
+++ b/pages/images/studios/AMC.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/images/studios/Apple_TV.svg b/pages/images/studios/Apple_TV.svg
new file mode 100644
index 0000000..ebe0487
--- /dev/null
+++ b/pages/images/studios/Apple_TV.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/images/studios/Columbia_Pictures.svg b/pages/images/studios/Columbia_Pictures.svg
new file mode 100644
index 0000000..6be66a9
--- /dev/null
+++ b/pages/images/studios/Columbia_Pictures.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/images/studios/Disney+.svg b/pages/images/studios/Disney+.svg
new file mode 100644
index 0000000..98248e4
--- /dev/null
+++ b/pages/images/studios/Disney+.svg
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/images/studios/DreamWorks_Animation.svg b/pages/images/studios/DreamWorks_Animation.svg
new file mode 100644
index 0000000..222ff08
--- /dev/null
+++ b/pages/images/studios/DreamWorks_Animation.svg
@@ -0,0 +1,51 @@
+
+image/svg+xml
+
+
+
diff --git a/pages/images/studios/FX.svg b/pages/images/studios/FX.svg
new file mode 100644
index 0000000..499b87e
--- /dev/null
+++ b/pages/images/studios/FX.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/images/studios/HBO.svg b/pages/images/studios/HBO.svg
new file mode 100644
index 0000000..46f5122
--- /dev/null
+++ b/pages/images/studios/HBO.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/images/studios/HBO_Max.svg b/pages/images/studios/HBO_Max.svg
new file mode 100644
index 0000000..e2e662a
--- /dev/null
+++ b/pages/images/studios/HBO_Max.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/images/studios/Hulu.svg b/pages/images/studios/Hulu.svg
new file mode 100644
index 0000000..f616925
--- /dev/null
+++ b/pages/images/studios/Hulu.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/images/studios/Lucasfilm.svg b/pages/images/studios/Lucasfilm.svg
new file mode 100644
index 0000000..1fbcb7e
--- /dev/null
+++ b/pages/images/studios/Lucasfilm.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/images/studios/Marvel_Studios.svg b/pages/images/studios/Marvel_Studios.svg
new file mode 100644
index 0000000..15feb9d
--- /dev/null
+++ b/pages/images/studios/Marvel_Studios.svg
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/images/studios/New_Line_Cinema.svg b/pages/images/studios/New_Line_Cinema.svg
new file mode 100644
index 0000000..d9b5be8
--- /dev/null
+++ b/pages/images/studios/New_Line_Cinema.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/images/studios/Paramount+_with_Showtime.svg b/pages/images/studios/Paramount+_with_Showtime.svg
new file mode 100644
index 0000000..a93324f
--- /dev/null
+++ b/pages/images/studios/Paramount+_with_Showtime.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/images/studios/Paramount_Pictures.svg b/pages/images/studios/Paramount_Pictures.svg
new file mode 100644
index 0000000..322e6f6
--- /dev/null
+++ b/pages/images/studios/Paramount_Pictures.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/images/studios/Peacock.svg b/pages/images/studios/Peacock.svg
new file mode 100644
index 0000000..51f8f00
--- /dev/null
+++ b/pages/images/studios/Peacock.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/images/studios/Pixar.svg b/pages/images/studios/Pixar.svg
new file mode 100644
index 0000000..1ba7e4a
--- /dev/null
+++ b/pages/images/studios/Pixar.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/pages/images/studios/Prime_Video.svg b/pages/images/studios/Prime_Video.svg
new file mode 100644
index 0000000..50a77e7
--- /dev/null
+++ b/pages/images/studios/Prime_Video.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/pages/images/studios/Showtime.svg b/pages/images/studios/Showtime.svg
new file mode 100644
index 0000000..7a8987f
--- /dev/null
+++ b/pages/images/studios/Showtime.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Showtime_logo
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/images/studios/Universal_Pictures.svg b/pages/images/studios/Universal_Pictures.svg
new file mode 100644
index 0000000..a4582c0
--- /dev/null
+++ b/pages/images/studios/Universal_Pictures.svg
@@ -0,0 +1,416 @@
+
+
+
+
diff --git a/pages/images/studios/Walt_Disney_Pictures.svg b/pages/images/studios/Walt_Disney_Pictures.svg
new file mode 100644
index 0000000..1440f76
--- /dev/null
+++ b/pages/images/studios/Walt_Disney_Pictures.svg
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/images/studios/Warner_Bros..svg b/pages/images/studios/Warner_Bros..svg
new file mode 100644
index 0000000..591118a
--- /dev/null
+++ b/pages/images/studios/Warner_Bros..svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/images/valentines.jpg b/pages/images/valentines.jpg
new file mode 100644
index 0000000..d1ffa02
Binary files /dev/null and b/pages/images/valentines.jpg differ
diff --git a/pages/images/wood.png b/pages/images/wood.png
new file mode 100644
index 0000000..52b264a
Binary files /dev/null and b/pages/images/wood.png differ
diff --git a/pages/movieLut.css b/pages/movieLut.css
new file mode 100644
index 0000000..42e57da
--- /dev/null
+++ b/pages/movieLut.css
@@ -0,0 +1,313 @@
+/* Movie LUT – mockup-aligned card layout */
+
+:root {
+ --mlut-bg: #2c2e33;
+ --mlut-card: #f4f1ea;
+ --mlut-text: #1a1b1e;
+ --mlut-muted: #5c5f66;
+ --mlut-border: #1a1b1e;
+ --mlut-btn: #1c7ed6;
+ --mlut-btn-text: #fff;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ min-height: 100%;
+}
+
+body.mlut-body {
+ font-family:
+ "Segoe UI",
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background: var(--mlut-bg);
+ color: #e9ecef;
+ padding: 1.5rem 0.75rem 3rem;
+}
+
+.mlut-app {
+ max-width: 1000px;
+ margin: 0 auto;
+ overflow: visible;
+}
+
+.mlut-header {
+ text-align: center;
+ margin: 1.25rem 0;
+}
+
+.mlut-header h1 {
+ margin: 0 0 0.35em;
+ font-size: 1.5rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+
+.mlut-scorebar {
+ display: flex;
+ justify-content: center;
+ gap: 1.25rem;
+ margin: 0 0 1rem;
+ font-size: 0.9rem;
+ color: #ced4da;
+}
+
+.mlut-scorebar strong {
+ color: #fff;
+}
+
+.mlut-status {
+ text-align: center;
+ min-height: 1.4em;
+ margin: 0 0 1rem;
+ font-size: 0.9rem;
+ color: #ffd43b;
+}
+
+.mlut-status[data-tone="muted"] {
+ color: #adb5bd;
+}
+
+.mlut-status[data-tone="error"] {
+ color: #ffa8a8;
+}
+
+.mlut-status[data-tone="ok"] {
+ color: #8ce99a;
+}
+
+/* Stage: nav outside card (sides of swatch), full-width barcode under options */
+.mlut-stage {
+ display: grid;
+ grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem;
+ grid-template-rows: auto auto;
+ column-gap: 0.4rem;
+ row-gap: 0.35rem;
+ align-items: stretch;
+ overflow: visible;
+}
+
+.mlut-card {
+ grid-column: 2;
+ grid-row: 1;
+ background: var(--mlut-card);
+ color: var(--mlut-text);
+ border-radius: 14px;
+ overflow: hidden;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
+ display: none;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.mlut-card.is-visible {
+ display: flex;
+}
+
+.mlut-options {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.mlut-option {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: none;
+ border-bottom: 1px solid var(--mlut-border);
+ background: transparent;
+ color: var(--mlut-text);
+ font: inherit;
+ font-size: 0.95rem;
+ font-weight: 600;
+ padding: 0.9rem 1.1rem;
+ cursor: pointer;
+ transition: background 0.12s ease;
+}
+
+.mlut-option:last-of-type {
+ border-bottom: 1px solid var(--mlut-border);
+}
+
+.mlut-option:hover:not(:disabled),
+.mlut-option:focus-visible:not(:disabled) {
+ background: rgba(0, 0, 0, 0.05);
+ outline: none;
+}
+
+.mlut-option:disabled {
+ cursor: default;
+}
+
+.mlut-option .mlut-option-letter {
+ font-weight: 800;
+ margin-right: 0.35em;
+}
+
+.mlut-option.is-correct {
+ background: rgba(85, 62, 242, 0.8) !important;
+}
+
+.mlut-option.is-wrong {
+ background: rgba(197, 14, 14, 0.8) !important;
+}
+
+.mlut-nav-btn {
+ grid-row: 1;
+ width: 100%;
+ height: 100%
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.35);
+ color: #f1f3f5;
+ cursor: pointer;
+ padding: 0;
+ transition:
+ background 0.12s ease,
+ color 0.12s ease;
+}
+
+#mlut-prev {
+ grid-column: 1;
+}
+
+#mlut-next {
+ grid-column: 3;
+}
+
+.mlut-nav-btn:hover,
+.mlut-nav-btn:focus-visible {
+ background: rgba(28, 126, 214, 0.55);
+ color: #fff;
+ outline: none;
+}
+
+.mlut-nav-btn[hidden] {
+ /* Keep the side columns so card width stays stable */
+ display: flex;
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.mlut-swatch-wrap {
+ position: relative;
+ width: 100%;
+ height: 10rem;
+ background: #111;
+}
+
+.mlut-swatch {
+ display: block;
+ width: 100%;
+ height: 100%;
+ image-rendering: pixelated;
+}
+
+.mlut-swatch-placeholder {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #868e96;
+ font-size: 0.85rem;
+ padding: 1rem;
+ text-align: center;
+}
+
+/* Post-guess stills under card; vertical pointers up to barcode stripes */
+.mlut-reveal {
+ grid-column: 2;
+ grid-row: 2;
+ position: relative;
+ padding: 0 0 0.5rem;
+ overflow: visible;
+}
+
+.mlut-reveal[hidden] {
+ display: none !important;
+}
+
+.mlut-reveal-connectors {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ pointer-events: none;
+ overflow: visible;
+ z-index: 0;
+}
+
+.mlut-reveal-connectors line {
+ stroke: #dee2e6;
+ stroke-width: 2;
+ stroke-linecap: round;
+}
+
+.mlut-reveal-connectors polygon {
+ fill: #dee2e6;
+}
+
+.mlut-reveal-tiles {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ justify-content: stretch;
+ align-items: flex-start;
+ gap: 0.5rem;
+ width: 100%;
+ padding-top: 2.75rem;
+ overflow: visible;
+}
+
+.mlut-reveal-tile {
+ /* Equal thirds under the swatch — left/right edges match barcode */
+ flex: 1 1 0;
+ min-width: 0;
+ margin: 0;
+}
+
+.mlut-reveal-tile canvas,
+.mlut-reveal-tile img,
+.mlut-reveal-tile .mlut-reveal-ph {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ object-fit: cover;
+ border-radius: 8px;
+ border: 2px solid rgba(255, 255, 255, 0.4);
+ background: #111;
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
+}
+
+@media (min-width: 480px) {
+ .mlut-stage {
+ grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
+ }
+
+ .mlut-swatch-wrap {
+ height: 12rem;
+ }
+
+ .mlut-reveal-tiles {
+ padding-top: 3rem;
+ gap: 0.55rem;
+ }
+}
+
+.mlut-footer {
+ position: fixed;
+ bottom: 5vh;
+ left: 50%;
+ transform: translateX(-50%);
+}
\ No newline at end of file
diff --git a/pages/movieLut.html b/pages/movieLut.html
new file mode 100644
index 0000000..0b17d84
--- /dev/null
+++ b/pages/movieLut.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+ Movie LUT Game
+
+
+
+
+
+
+
+ Score: 0
+ Streak: 0
+
+
+
Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/movieLut.js b/pages/movieLut.js
new file mode 100644
index 0000000..d1d7297
--- /dev/null
+++ b/pages/movieLut.js
@@ -0,0 +1,887 @@
+// Movie LUT — guess the movie from a trickplay color barcode
+(function () {
+ 'use strict';
+
+ if (window.__kefinMovieLutLoaded) {
+ console.log('[MovieLUT] Already initialized; skipping re-entry');
+ return;
+ }
+ window.__kefinMovieLutLoaded = true;
+
+ const LOG = (...args) => console.log('[MovieLUT]', ...args);
+ const WARN = (...args) => console.warn('[MovieLUT]', ...args);
+
+ const SAMPLE_COUNT = 256;
+ const RUNTIME_END_FRAC = 0.95;
+ const TICKS_PER_MS = 10000;
+ const MAX_ACTOR_TRIES = 24;
+ const MAX_MOVIE_PROBES_PER_ACTOR = 40;
+ const LETTERS = ['A', 'B', 'C'];
+
+ const els = {
+ status: document.getElementById('mlut-status'),
+ card: document.getElementById('mlut-card'),
+ options: document.getElementById('mlut-options'),
+ swatch: document.getElementById('mlut-swatch'),
+ placeholder: document.getElementById('mlut-swatch-placeholder'),
+ prev: document.getElementById('mlut-prev'),
+ next: document.getElementById('mlut-next'),
+ score: document.getElementById('mlut-score'),
+ streak: document.getElementById('mlut-streak'),
+ reveal: document.getElementById('mlut-reveal'),
+ revealConnectors: document.getElementById('mlut-reveal-connectors'),
+ revealTiles: document.getElementById('mlut-reveal-tiles')
+ };
+
+ let score = 0;
+ let streak = 0;
+ let currentRound = null;
+ let roundBusy = false;
+
+ /** Full session history of rounds (answered + current). */
+ let history = [];
+ let historyIndex = -1;
+
+ function setStatus(text, tone) {
+ if (!els.status) return;
+ els.status.textContent = text || '';
+ els.status.dataset.tone = tone || 'muted';
+ }
+
+ function sleep(ms) {
+ return new Promise((r) => setTimeout(r, ms));
+ }
+
+ function shuffle(arr) {
+ const a = arr.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const t = a[i];
+ a[i] = a[j];
+ a[j] = t;
+ }
+ return a;
+ }
+
+ function pickRandom(arr) {
+ if (!arr || !arr.length) return null;
+ return arr[Math.floor(Math.random() * arr.length)];
+ }
+
+ function getApiClient() {
+ if (typeof ApiClient !== 'undefined' && ApiClient) return ApiClient;
+ if (window.parent && window.parent !== window && window.parent.ApiClient) {
+ return window.parent.ApiClient;
+ }
+ return null;
+ }
+
+ function getPeopleCache() {
+ if (window.PeopleCache) return window.PeopleCache;
+ if (window.parent && window.parent !== window && window.parent.PeopleCache) {
+ return window.parent.PeopleCache;
+ }
+ return null;
+ }
+
+ function getMoviesCache() {
+ if (window.MoviesCache) return window.MoviesCache;
+ if (window.parent && window.parent !== window && window.parent.MoviesCache) {
+ return window.parent.MoviesCache;
+ }
+ return null;
+ }
+
+ function getApiHelper() {
+ if (window.apiHelper || window.ApiHelper) return window.apiHelper || window.ApiHelper;
+ if (window.parent && window.parent !== window) {
+ return window.parent.apiHelper || window.parent.ApiHelper || null;
+ }
+ return null;
+ }
+
+ async function waitForApiClient(maxMs) {
+ const limit = maxMs || 15000;
+ const start = Date.now();
+ while (Date.now() - start < limit) {
+ const client = getApiClient();
+ if (client && typeof client.getCurrentUserId === 'function') {
+ try {
+ if (client.getCurrentUserId() && client.accessToken && client.accessToken()) {
+ return client;
+ }
+ } catch (e) { /* not ready */ }
+ }
+ await sleep(150);
+ }
+ return getApiClient();
+ }
+
+ async function waitForPeopleCache(maxMs) {
+ const limit = maxMs || 60000;
+ const start = Date.now();
+ setStatus('Waiting for PeopleCache…', 'muted');
+ while (Date.now() - start < limit) {
+ const cache = getPeopleCache();
+ if (cache && typeof cache.getTopActors === 'function') {
+ try {
+ const actors = await cache.getTopActors();
+ if (Array.isArray(actors) && actors.length > 0) {
+ return { cache, actors };
+ }
+ if (typeof cache.isComplete === 'function' && cache.isComplete()) {
+ setStatus('No movies/actors available for Movie LUT.', 'error');
+ return null;
+ }
+ setStatus('Building people cache (this may take a while on first run)…', 'muted');
+ } catch (e) {
+ WARN('getTopActors failed', e);
+ }
+ }
+ await sleep(500);
+ }
+ return null;
+ }
+
+ function getTrickplayInfoFromItem(item) {
+ const raw = item && item.Trickplay;
+ if (!raw || typeof raw !== 'object') return null;
+ if (typeof raw.Width !== 'undefined' && typeof raw.Interval !== 'undefined') {
+ return raw;
+ }
+ const firstKey = Object.keys(raw)[0];
+ if (!firstKey) return null;
+ const inner = raw[firstKey];
+ if (!inner || typeof inner !== 'object') return null;
+ if (typeof inner.Width !== 'undefined' && typeof inner.Interval !== 'undefined') {
+ return inner;
+ }
+ const secondKey = Object.keys(inner)[0];
+ if (!secondKey) return null;
+ const info = inner[secondKey];
+ if (info && typeof info.Width !== 'undefined' && typeof info.Interval !== 'undefined') {
+ return info;
+ }
+ return null;
+ }
+
+ /**
+ * Resolve movie stubs for an actor on demand (PeopleCache no longer stores item ids).
+ * Prefers MoviesCache People arrays; falls back to Items?PersonIds=.
+ * @returns {Promise>}
+ */
+ async function actorFilmography(actor) {
+ const actorId = actor?.Id;
+ if (!actorId) return [];
+
+ const moviesCache = getMoviesCache();
+ if (moviesCache?.getMovies) {
+ try {
+ const movies = await moviesCache.getMovies();
+ const fromCache = (movies || [])
+ .filter((m) => {
+ if (!m?.Id || !Array.isArray(m.People)) return false;
+ return m.People.some((p) => p?.Id === actorId
+ && (!p.Type || p.Type === 'Actor'));
+ })
+ .map((m) => ({ Id: m.Id }));
+ if (fromCache.length) return fromCache;
+ } catch (e) {
+ WARN('MoviesCache filmography lookup failed', e);
+ }
+ }
+
+ const api = getApiHelper();
+ const apiClient = getApiClient();
+ if (api?.getItems) {
+ try {
+ const data = await api.getItems({
+ PersonIds: actorId,
+ IncludeItemTypes: 'Movie',
+ Recursive: true,
+ Fields: '',
+ EnableTotalRecordCount: false,
+ Limit: 100
+ }, false);
+ const items = data?.Items || [];
+ return items.filter((m) => m?.Id).map((m) => ({ Id: m.Id }));
+ } catch (e) {
+ WARN('PersonIds filmography fetch failed', e);
+ }
+ }
+
+ if (apiClient?.getItems) {
+ try {
+ const userId = apiClient.getCurrentUserId();
+ const data = await apiClient.getItems(userId, {
+ PersonIds: actorId,
+ IncludeItemTypes: 'Movie',
+ Recursive: true,
+ Fields: '',
+ EnableTotalRecordCount: false,
+ Limit: 100
+ });
+ const items = data?.Items || [];
+ return items.filter((m) => m?.Id).map((m) => ({ Id: m.Id }));
+ } catch (e) {
+ WARN('ApiClient filmography fetch failed', e);
+ }
+ }
+
+ return [];
+ }
+
+ function actorLikelyHasEnoughMovies(actor) {
+ const movieCount = actor?.movies?.actorCount;
+ if (Number.isFinite(movieCount) && movieCount >= 3) return true;
+ const count = actor?.count;
+ if (Number.isFinite(count) && count >= 3) return true;
+ return false;
+ }
+
+ async function fetchFullItem(apiClient, itemId) {
+ const userId = apiClient.getCurrentUserId();
+ if (!userId) return null;
+ try {
+ return await apiClient.getItem(userId, itemId);
+ } catch (e) {
+ WARN('getItem failed', itemId, e);
+ return null;
+ }
+ }
+
+ function yearOf(item) {
+ if (item.ProductionYear) return item.ProductionYear;
+ if (item.PremiereDate) {
+ const y = new Date(item.PremiereDate).getFullYear();
+ if (!Number.isNaN(y)) return y;
+ }
+ return null;
+ }
+
+ function formatTitle(item) {
+ const y = yearOf(item);
+ return y != null ? `${item.Name} (${y})` : String(item.Name || 'Unknown');
+ }
+
+ function tileRef(trickplayInfo, positionTicks) {
+ const interval = trickplayInfo.Interval || 10000;
+ const tileWidth = trickplayInfo.TileWidth || 10;
+ const tileHeight = trickplayInfo.TileHeight || 10;
+ const currentTimeMs = positionTicks / TICKS_PER_MS;
+ const currentTile = Math.floor(currentTimeMs / interval);
+ const tileSize = tileWidth * tileHeight;
+ const sheetIndex = Math.floor(currentTile / tileSize);
+ const tileOffset = currentTile % tileSize;
+ const tileOffsetX = tileOffset % tileWidth;
+ const tileOffsetY = Math.floor(tileOffset / tileWidth);
+ return { sheetIndex, tileOffsetX, tileOffsetY };
+ }
+
+ function positionTicksForStripe(item, stripeIndex) {
+ const runEnd = Math.floor(item.RunTimeTicks * RUNTIME_END_FRAC);
+ const t = SAMPLE_COUNT <= 1 ? 0 : stripeIndex / (SAMPLE_COUNT - 1);
+ return Math.floor(runEnd * t);
+ }
+
+ /**
+ * One random stripe per segment (frac of barcode width), not equal thirds —
+ * keeps connectors over the matching full-width tile:
+ * 0–30% / 35–65% / 70–100%.
+ */
+ function pickThirdStripeIndices() {
+ const ranges = [
+ [0.05, 0.3],
+ [0.35, 0.65],
+ [0.7, 0.95]
+ ];
+ return ranges.map(([startFrac, endFrac]) => {
+ const lo = Math.max(0, Math.floor(startFrac * SAMPLE_COUNT));
+ const hi = Math.min(SAMPLE_COUNT - 1, Math.ceil(endFrac * SAMPLE_COUNT) - 1);
+ const a = Math.min(lo, hi);
+ const b = Math.max(lo, hi);
+ return a + Math.floor(Math.random() * (b - a + 1));
+ });
+ }
+
+ function loadImage(url) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error('Failed to load sheet'));
+ img.src = url;
+ });
+ }
+
+ function averageTileRgb(ctx, sx, sy, tw, th) {
+ let data;
+ try {
+ data = ctx.getImageData(sx, sy, tw, th).data;
+ } catch (e) {
+ WARN('getImageData blocked (CORS?)', e);
+ return null;
+ }
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ let n = 0;
+ const step = Math.max(1, Math.floor((tw * th) / 400));
+ for (let i = 0; i < data.length; i += 4 * step) {
+ r += data[i];
+ g += data[i + 1];
+ b += data[i + 2];
+ n++;
+ }
+ if (!n) return { r: 0, g: 0, b: 0 };
+ return {
+ r: Math.round(r / n),
+ g: Math.round(g / n),
+ b: Math.round(b / n)
+ };
+ }
+
+ async function loadSheetMap(apiClient, item, sheetIndexes) {
+ const trickplayInfo = getTrickplayInfoFromItem(item);
+ const mediaSourceId = item.MediaSourceId || item.Id;
+ const width = trickplayInfo.Width || 320;
+ const sheetCanvas = new Map();
+
+ for (const sheetIndex of sheetIndexes) {
+ if (sheetCanvas.has(sheetIndex)) continue;
+ const imgSrc = apiClient.getUrl(
+ 'Videos/' + item.Id + '/Trickplay/' + width + '/' + sheetIndex + '.jpg',
+ {
+ ApiKey: apiClient.accessToken(),
+ MediaSourceId: mediaSourceId
+ }
+ );
+ try {
+ const img = await loadImage(imgSrc);
+ const c = document.createElement('canvas');
+ c.width = img.naturalWidth || img.width;
+ c.height = img.naturalHeight || img.height;
+ const ctx = c.getContext('2d', { willReadFrequently: true });
+ ctx.drawImage(img, 0, 0);
+ sheetCanvas.set(sheetIndex, { canvas: c, ctx, width: c.width, height: c.height });
+ } catch (e) {
+ WARN('sheet load failed', sheetIndex, e);
+ }
+ }
+ return sheetCanvas;
+ }
+
+ function cropTileToCanvas(sheet, trickplayInfo, ref) {
+ const width = trickplayInfo.Width || 320;
+ const height = trickplayInfo.Height || 180;
+ const tileWidth = trickplayInfo.TileWidth || 10;
+ const tileHeight = trickplayInfo.TileHeight || 10;
+ let sx = ref.tileOffsetX * width;
+ let sy = ref.tileOffsetY * height;
+ let tw = width;
+ let th = height;
+ if (sx + tw > sheet.width || sy + th > sheet.height) {
+ const cellW = sheet.width / tileWidth;
+ const cellH = sheet.height / tileHeight;
+ sx = Math.floor(ref.tileOffsetX * cellW);
+ sy = Math.floor(ref.tileOffsetY * cellH);
+ tw = Math.max(1, Math.floor(cellW));
+ th = Math.max(1, Math.floor(cellH));
+ }
+ const out = document.createElement('canvas');
+ out.width = tw;
+ out.height = th;
+ const octx = out.getContext('2d');
+ octx.drawImage(sheet.canvas, sx, sy, tw, th, 0, 0, tw, th);
+ return out;
+ }
+
+ async function sampleColorsFromTrickplay(apiClient, item) {
+ const trickplayInfo = getTrickplayInfoFromItem(item);
+ if (!trickplayInfo || !item.RunTimeTicks) {
+ throw new Error('No trickplay data');
+ }
+
+ const width = trickplayInfo.Width || 320;
+ const height = trickplayInfo.Height || 180;
+ const samples = [];
+
+ for (let i = 0; i < SAMPLE_COUNT; i++) {
+ const positionTicks = positionTicksForStripe(item, i);
+ const ref = tileRef(trickplayInfo, positionTicks);
+ samples.push({
+ sheetIndex: ref.sheetIndex,
+ tileOffsetX: ref.tileOffsetX,
+ tileOffsetY: ref.tileOffsetY
+ });
+ }
+
+ const sheetsNeeded = [...new Set(samples.map((s) => s.sheetIndex))];
+ const sheetCanvas = await loadSheetMap(apiClient, item, sheetsNeeded);
+
+ if (!sheetCanvas.size) {
+ throw new Error('Could not load any trickplay sheets');
+ }
+
+ const tileWidth = trickplayInfo.TileWidth || 10;
+ const tileHeight = trickplayInfo.TileHeight || 10;
+ const colors = [];
+
+ for (const sample of samples) {
+ const sheet = sheetCanvas.get(sample.sheetIndex);
+ if (!sheet) {
+ colors.push({ r: 20, g: 20, b: 20 });
+ continue;
+ }
+ let sx = sample.tileOffsetX * width;
+ let sy = sample.tileOffsetY * height;
+ let tw = width;
+ let th = height;
+ if (sx + tw > sheet.width || sy + th > sheet.height) {
+ const cellW = sheet.width / tileWidth;
+ const cellH = sheet.height / tileHeight;
+ sx = Math.floor(sample.tileOffsetX * cellW);
+ sy = Math.floor(sample.tileOffsetY * cellH);
+ tw = Math.max(1, Math.floor(cellW));
+ th = Math.max(1, Math.floor(cellH));
+ }
+ const rgb = averageTileRgb(sheet.ctx, sx, sy, tw, th);
+ colors.push(rgb || { r: 30, g: 30, b: 30 });
+ }
+ return colors;
+ }
+
+ /**
+ * Extract visible stills for three stripe indices (one per third of runtime / barcode).
+ */
+ async function extractRevealStills(apiClient, item, stripeIndices) {
+ const trickplayInfo = getTrickplayInfoFromItem(item);
+ if (!trickplayInfo) return [];
+
+ const refs = stripeIndices.map((stripeIndex) => {
+ const positionTicks = positionTicksForStripe(item, stripeIndex);
+ const ref = tileRef(trickplayInfo, positionTicks);
+ return { stripeIndex, ...ref };
+ });
+
+ const sheets = await loadSheetMap(
+ apiClient,
+ item,
+ refs.map((r) => r.sheetIndex)
+ );
+
+ return refs.map((ref) => {
+ const sheet = sheets.get(ref.sheetIndex);
+ const canvas = sheet ? cropTileToCanvas(sheet, trickplayInfo, ref) : null;
+ const frac = SAMPLE_COUNT <= 1 ? 0.5 : (ref.stripeIndex + 0.5) / SAMPLE_COUNT;
+ return {
+ stripeIndex: ref.stripeIndex,
+ frac,
+ canvas
+ };
+ });
+ }
+
+ function paintBarcode(canvas, colors) {
+ if (!canvas || !colors || !colors.length) return;
+ const dpr = window.devicePixelRatio || 1;
+ const cssW = canvas.clientWidth || 640;
+ const cssH = canvas.clientHeight || 200;
+ canvas.width = Math.max(1, Math.floor(cssW * dpr));
+ canvas.height = Math.max(1, Math.floor(cssH * dpr));
+ const ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ const w = cssW;
+ const h = cssH;
+ const n = colors.length;
+ for (let i = 0; i < n; i++) {
+ const c = colors[i];
+ ctx.fillStyle = `rgb(${c.r},${c.g},${c.b})`;
+ const x0 = (i / n) * w;
+ const x1 = ((i + 1) / n) * w;
+ ctx.fillRect(x0, 0, Math.ceil(x1 - x0) + 0.5, h);
+ }
+ }
+
+ function updateNavButtons() {
+ const round = history[historyIndex] || currentRound;
+ if (els.prev) {
+ els.prev.hidden = historyIndex <= 0;
+ }
+ if (els.next) {
+ // Forward when current view is already answered
+ els.next.hidden = !(round && round.guessed);
+ }
+ }
+
+ function clearReveal() {
+ if (els.reveal) els.reveal.hidden = true;
+ if (els.revealConnectors) els.revealConnectors.innerHTML = '';
+ if (els.revealTiles) els.revealTiles.innerHTML = '';
+ }
+
+ /**
+ * Straight vertical lines from each stripe on the barcode down to the tile below.
+ * Arrowheads sit on the barcode edge and point at the stripe.
+ */
+ function layoutRevealConnectors(stills) {
+ if (!els.reveal || !els.revealConnectors || !els.swatch || !els.revealTiles) return;
+ const svg = els.revealConnectors;
+ svg.innerHTML = '';
+
+ const revealRect = els.reveal.getBoundingClientRect();
+ const swatchRect = els.swatch.getBoundingClientRect();
+ if (!revealRect.width || !swatchRect.width) return;
+
+ // Extend SVG upward so connectors can reach the swatch above the reveal block
+ const lift = Math.max(0, revealRect.top - swatchRect.bottom + 1);
+ const svgH = revealRect.height + lift;
+ const svgW = revealRect.width;
+
+ svg.style.top = `${-lift}px`;
+ svg.style.height = `${svgH}px`;
+ svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`);
+ svg.setAttribute('width', String(svgW));
+ svg.setAttribute('height', String(svgH));
+
+ const tileEls = els.revealTiles.querySelectorAll('.mlut-reveal-tile');
+ const NS = 'http://www.w3.org/2000/svg';
+ const arrowH = 10;
+ const arrowW = 7;
+ const originTop = revealRect.top - lift;
+ const originLeft = revealRect.left;
+
+ stills.forEach((still, i) => {
+ const tileEl = tileEls[i];
+ if (!tileEl) return;
+
+ const tileRect = tileEl.getBoundingClientRect();
+ // Pure vertical: stripe X, from barcode bottom down to tile top
+ const x = swatchRect.left + still.frac * swatchRect.width - originLeft;
+ const yStripe = swatchRect.bottom - originTop;
+ const yTile = tileRect.top - originTop;
+
+ const line = document.createElementNS(NS, 'line');
+ line.setAttribute('x1', String(x));
+ line.setAttribute('y1', String(yTile));
+ line.setAttribute('x2', String(x));
+ line.setAttribute('y2', String(yStripe + arrowH));
+ svg.appendChild(line);
+
+ // Arrow pointing up at the stripe (tip on barcode, base toward tile)
+ const poly = document.createElementNS(NS, 'polygon');
+ poly.setAttribute(
+ 'points',
+ `${x},${yStripe} ${x - arrowW},${yStripe + arrowH} ${x + arrowW},${yStripe + arrowH}`
+ );
+ svg.appendChild(poly);
+ });
+ }
+
+ function showReveal(stills) {
+ if (!els.reveal || !els.revealTiles) return;
+ if (els.revealConnectors) els.revealConnectors.innerHTML = '';
+ els.revealTiles.innerHTML = '';
+
+ stills.forEach((still) => {
+ const tile = document.createElement('figure');
+ tile.className = 'mlut-reveal-tile';
+ if (still.canvas) {
+ // Clone canvas so history restore can re-append without detaching cached ref
+ const c = document.createElement('canvas');
+ c.width = still.canvas.width;
+ c.height = still.canvas.height;
+ c.getContext('2d').drawImage(still.canvas, 0, 0);
+ tile.appendChild(c);
+ } else {
+ const ph = document.createElement('div');
+ ph.className = 'mlut-reveal-ph';
+ tile.appendChild(ph);
+ }
+ els.revealTiles.appendChild(tile);
+ });
+
+ els.reveal.hidden = false;
+ requestAnimationFrame(() => {
+ layoutRevealConnectors(stills);
+ });
+ }
+
+ async function gatherThreeTrickplayMovies(apiClient, actor) {
+ const filmography = shuffle(await actorFilmography(actor));
+ const found = [];
+ let probes = 0;
+ for (const entry of filmography) {
+ if (found.length >= 3) break;
+ if (probes >= MAX_MOVIE_PROBES_PER_ACTOR) break;
+ probes++;
+ const item = await fetchFullItem(apiClient, entry.Id);
+ if (!item || !item.RunTimeTicks || item.RunTimeTicks <= 0) continue;
+ if (!getTrickplayInfoFromItem(item)) continue;
+ if (found.some((f) => f.Id === item.Id)) continue;
+ found.push(item);
+ }
+ return found;
+ }
+
+ async function pickRound(apiClient, actors) {
+ const preferred = actors.filter(actorLikelyHasEnoughMovies);
+ const actorPool = shuffle(preferred.length ? preferred : actors);
+ let tries = 0;
+ for (const actor of actorPool) {
+ if (tries >= MAX_ACTOR_TRIES) break;
+ tries++;
+ setStatus(`Checking films for ${actor.Name || 'actor'}…`, 'muted');
+ const three = await gatherThreeTrickplayMovies(apiClient, actor);
+ if (three.length < 3) continue;
+ const answer = pickRandom(three);
+ const options = shuffle(three);
+ return { actor, answer, options };
+ }
+ throw new Error(
+ 'Could not find an actor with three movies that have trickplay. Generate trickplay for more titles or wait for PeopleCache to finish.'
+ );
+ }
+
+ function escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function promptForActor(actor) {
+ const name = (actor && actor.Name) || 'this actor';
+ return `Which ${name} film is this?`;
+ }
+
+ function renderOptions(round) {
+ els.options.innerHTML = '';
+
+ round.options.forEach((item, i) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'mlut-option';
+ btn.dataset.itemId = item.Id;
+ btn.innerHTML =
+ `${LETTERS[i] || String(i + 1)}) ` +
+ escapeHtml(formatTitle(item));
+
+ if (round.guessed) {
+ btn.disabled = true;
+ if (item.Id === round.answer.Id) btn.classList.add('is-correct');
+ if (round.selectedItemId && item.Id === round.selectedItemId && item.Id !== round.answer.Id) {
+ btn.classList.add('is-wrong');
+ }
+ } else {
+ btn.addEventListener('click', () => onGuess(item.Id));
+ }
+ els.options.appendChild(btn);
+ });
+ }
+
+ function restoreRound(round) {
+ currentRound = round;
+ if (!round) {
+ els.card.classList.remove('is-visible');
+ clearReveal();
+ updateNavButtons();
+ return;
+ }
+
+ renderOptions(round);
+ els.card.classList.add('is-visible');
+
+ if (els.placeholder) {
+ els.placeholder.style.display = round._colors ? 'none' : 'flex';
+ }
+
+ requestAnimationFrame(() => {
+ if (round._colors) paintBarcode(els.swatch, round._colors);
+ if (round.guessed && round.stills && round.stills.length) {
+ showReveal(round.stills);
+ const correct = round.selectedItemId === round.answer.Id;
+ setStatus(
+ correct ? 'Correct!' : `Wrong — it was ${formatTitle(round.answer)}.`,
+ correct ? 'ok' : 'error'
+ );
+ } else {
+ clearReveal();
+ setStatus(promptForActor(round.actor), 'muted');
+ }
+ updateNavButtons();
+ });
+ }
+
+ async function onGuess(itemId) {
+ if (!currentRound || currentRound.guessed || roundBusy) return;
+ currentRound.guessed = true;
+ currentRound.selectedItemId = itemId;
+
+ const correctId = currentRound.answer.Id;
+ const correct = itemId === correctId;
+
+ Array.from(els.options.querySelectorAll('.mlut-option')).forEach((btn) => {
+ btn.disabled = true;
+ const id = btn.dataset.itemId;
+ if (id === correctId) btn.classList.add('is-correct');
+ if (id === itemId && !correct) btn.classList.add('is-wrong');
+ });
+
+ if (correct) {
+ score += 1;
+ streak += 1;
+ setStatus('Correct!', 'ok');
+ } else {
+ streak = 0;
+ setStatus(`Wrong — it was ${formatTitle(currentRound.answer)}.`, 'error');
+ }
+ els.score.textContent = String(score);
+ els.streak.textContent = String(streak);
+ updateNavButtons();
+
+ try {
+ const apiClient = getApiClient() || currentRound.apiClient;
+ if (apiClient) {
+ setStatus(
+ (correct ? 'Correct! ' : `Wrong — it was ${formatTitle(currentRound.answer)}. `) +
+ 'Loading stills…',
+ correct ? 'ok' : 'error'
+ );
+ const stripeIndices = pickThirdStripeIndices();
+ const stills = await extractRevealStills(apiClient, currentRound.answer, stripeIndices);
+ currentRound.stills = stills;
+ // Only show if user is still on this round
+ if (history[historyIndex] === currentRound) {
+ showReveal(stills);
+ setStatus(
+ correct ? 'Correct!' : `Wrong — it was ${formatTitle(currentRound.answer)}.`,
+ correct ? 'ok' : 'error'
+ );
+ }
+ }
+ } catch (e) {
+ WARN('reveal stills failed', e);
+ setStatus(
+ correct ? 'Correct!' : `Wrong — it was ${formatTitle(currentRound.answer)}.`,
+ correct ? 'ok' : 'error'
+ );
+ }
+
+ updateNavButtons();
+ }
+
+ async function startRound() {
+ if (roundBusy) return;
+ roundBusy = true;
+ clearReveal();
+ if (els.placeholder) {
+ els.placeholder.style.display = 'flex';
+ els.placeholder.textContent = 'Preparing swatch…';
+ }
+ if (els.next) els.next.hidden = true;
+
+ try {
+ const apiClient = await waitForApiClient(15000);
+ if (!apiClient) {
+ throw new Error('ApiClient not available. Open this page from within Jellyfin while logged in.');
+ }
+
+ setStatus('Loading top actors…', 'muted');
+ const people = await waitForPeopleCache(90000);
+ if (!people || !people.actors.length) {
+ throw new Error(
+ 'You don\'t have enough movies in your library to play this game. Add more movies to your library and try again.'
+ );
+ }
+
+ setStatus('Picking actor and films…', 'muted');
+ const round = await pickRound(apiClient, people.actors);
+
+ setStatus(`Building swatch from still frames…`, 'muted');
+ const colors = await sampleColorsFromTrickplay(apiClient, round.answer);
+
+ const entry = {
+ ...round,
+ guessed: false,
+ selectedItemId: null,
+ stills: null,
+ _colors: colors,
+ apiClient
+ };
+
+ // Drop any future after branching (shouldn't exist); append new
+ history = history.slice(0, historyIndex + 1);
+ history.push(entry);
+ historyIndex = history.length - 1;
+ currentRound = entry;
+
+ renderOptions(entry);
+ els.card.classList.add('is-visible');
+
+ requestAnimationFrame(() => {
+ paintBarcode(els.swatch, colors);
+ if (els.placeholder) els.placeholder.style.display = 'none';
+ });
+
+ setStatus(promptForActor(round.actor), 'muted');
+ updateNavButtons();
+ } catch (e) {
+ WARN(e);
+ setStatus(e && e.message ? e.message : String(e), 'error');
+ currentRound = history[historyIndex] || null;
+ // Allow next only if we already finished a round before
+ updateNavButtons();
+ if (!history.length) {
+ if (els.next) els.next.hidden = false; // retry start via next when cold-fail
+ }
+ } finally {
+ roundBusy = false;
+ }
+ }
+
+ function goPrev() {
+ if (historyIndex <= 0 || roundBusy) return;
+ historyIndex -= 1;
+ restoreRound(history[historyIndex]);
+ }
+
+ async function goNext() {
+ if (roundBusy) return;
+ const round = history[historyIndex];
+ if (!round || !round.guessed) return;
+
+ if (historyIndex < history.length - 1) {
+ historyIndex += 1;
+ restoreRound(history[historyIndex]);
+ return;
+ }
+
+ // At tip of history → load a new round
+ await startRound();
+ }
+
+ function wire() {
+ if (els.prev) els.prev.addEventListener('click', () => goPrev());
+ if (els.next) els.next.addEventListener('click', () => goNext());
+ window.addEventListener('resize', () => {
+ if (currentRound && currentRound._colors) {
+ paintBarcode(els.swatch, currentRound._colors);
+ }
+ if (currentRound && currentRound.guessed && currentRound.stills) {
+ layoutRevealConnectors(currentRound.stills);
+ }
+ });
+
+ startRound();
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', wire);
+ } else {
+ wire();
+ }
+
+ LOG('ready');
+})();
diff --git a/pages/quiz.html b/pages/quiz.html
new file mode 100644
index 0000000..1c45f15
--- /dev/null
+++ b/pages/quiz.html
@@ -0,0 +1,1070 @@
+
+
+
+
+
+
+
+
+
+
+
+
Loading...
+
+
+
+
01:00
+
Score: 0
+
Re-rolls: 3/3
+
+
+
+
+
+
+
+ Start Quiz
+
+
+
+
+ Enter a name to submit your score
+ Name:
+
+ Score:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Final Score:
+ 0
+
+
+ Correct Guesses:
+ 0
+
+
+
+
+
+
Great job! Your score has been submitted to the leaderboard.
+
+
+
+
+
+
+
diff --git a/pages/quiz.js b/pages/quiz.js
new file mode 100644
index 0000000..3c5619b
--- /dev/null
+++ b/pages/quiz.js
@@ -0,0 +1,894 @@
+// Jellyfin Quiz Game - Enhanced with animations and preloading
+(function() {
+ 'use strict';
+
+ if (window.__kefinQuizLoaded) {
+ console.log('[Quiz] Already initialized; skipping re-entry');
+ return;
+ }
+ window.__kefinQuizLoaded = true;
+
+ var itemList = [];
+ var currentItemList = [];
+ var titlesCorrect = [];
+ var serverId;
+ var timer;
+ var quizScore = 0;
+ var correctGuesses = 0;
+ var quickGuesses = 0;
+ var username = '';
+ var resetTimer = false;
+ var preloadedImages = [];
+ var imagePreloader = null;
+ var isQuizActive = false;
+ var rerollCount = 0;
+ var maxRerolls = 3;
+ var correctGuessesData = [];
+
+ // Initialize UI state
+ document.getElementById('quizInitializeButton').disabled = true;
+ document.getElementById("quizStartButton").disabled = true;
+ document.getElementById('quizInitializeButton').style.display = "none";
+ document.getElementById('quizStartButton').style.display = "none";
+ document.getElementById('quizRerollButton').disabled = true;
+ document.getElementById('quizRerollButton').style.display = "none";
+
+ function initializeQuiz(apiClient) {
+ console.log('initing quiz');
+ document.getElementById('quizLoading').classList.remove("hide");
+ apiClient.getItems(apiClient.getCurrentUserId(), {
+ IncludeItemTypes: 'Movie',
+ Recursive: true,
+ Fields: 'PrimaryImageAspectRatio,ImageTags,BackdropImageTags'
+ }).then((data) => {
+ itemList = (data && data.Items) || [];
+ currentItemList = itemList.slice();
+ document.getElementById('quizLoading').classList.add("hide");
+ initializeQuizData();
+ });
+
+ apiClient.getCurrentUser().then((user) => { username = user.Name; });
+
+ fetch('/files/KefinTweaks/pages/scripts/hiscores.php', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+ })
+ .then(response => response.text())
+ .then(data => {
+ var hiscores = data.split("\n");
+
+ hiscores.sort((s1, s2) => {
+ var s1Value = parseInt(s1.split(';')[1]);
+ var s2Value = parseInt(s2.split(';')[1]);
+ return s1Value < s2Value ? 1 : s1Value > s2Value ? -1 : 0;
+ });
+
+ var topScores = hiscores.slice(0,10);
+
+ // Create table HTML
+ var tableHtml = `
+
+
+
+
+
+ #
+ Player
+ Score
+ Guessed
+ Date
+
+
+
+ `;
+
+ topScores.forEach((score, index) => {
+ if (score.trim()) {
+ var scoreName = score.split(';')[0];
+ var scorePoints = score.split(';')[1];
+ var scoreGuesses = score.split(';')[2];
+ var scoreDate = score.split(';')[3];
+
+ // Format date
+ var date = new Date(scoreDate);
+ var formattedDate = date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ });
+
+ // Add rank styling
+ var rankClass = '';
+ if (index === 0) rankClass = 'rank-gold';
+ else if (index === 1) rankClass = 'rank-silver';
+ else if (index === 2) rankClass = 'rank-bronze';
+
+ tableHtml += `
+
+ ${index + 1}
+ ${scoreName}
+ ${parseInt(scorePoints).toLocaleString()}
+ ${scoreGuesses}
+ ${formattedDate}
+
+ `;
+ }
+ });
+
+ tableHtml += `
+
+
+
+ `;
+
+ document.getElementById("quizHiscoresContainer").innerHTML = tableHtml;
+ })
+ .catch(error => {
+ console.log('Error fetching hiscores:', error);
+ });
+ }
+
+ function initializeQuizData() {
+ document.getElementById('quizInputContainer').classList.add("hide");
+ document.getElementById('quizInitializeButton').disabled = true;
+ document.getElementById('quizInitializeButton').style.display = "none";
+ document.getElementById("quizStartButton").disabled = true;
+ document.getElementById('quizContainer').classList.remove("active");
+ document.getElementById('quizContainer').classList.add("inactive");
+ document.getElementById('quizContainer').classList.add("hide");
+ document.getElementById('quizContainer').innerHTML = '';
+ document.getElementById("quizTimer").textContent = "01:00";
+ document.getElementById("quizTimer").classList.remove("warning");
+ document.getElementById('quizScoreDisplay').classList.add("hide");
+ document.getElementById('quizRerollDisplay').classList.add("hide");
+ document.getElementById('quizControlButtons').classList.add("hide");
+ document.getElementById('quizControlButtons').style.display = "none";
+ quizScore = 0;
+ correctGuesses = 0;
+ quickGuesses = 0;
+ rerollCount = 0;
+ titlesCorrect = [];
+ correctGuessesData = [];
+ resetTimer = true;
+ isQuizActive = false;
+
+ // Clear any existing answer overlays
+ document.querySelectorAll('.answer-overlay').forEach((overlay) => {
+ overlay.remove();
+ });
+
+ currentItemList = [...itemList]; // Create a copy
+
+ console.log('initing quiz data');
+
+ // Preload images and add them to quiz container
+ preloadImages().then(() => {
+ // Add the preloaded images to the quiz container
+ for (var i = 0; i < 9; i++) {
+ addImageToQuiz(i);
+ }
+ document.getElementById("quizStartButton").disabled = false;
+ document.getElementById('quizStartButton').style.display = "inline-block";
+ });
+ }
+
+ async function preloadImages() {
+ console.log('Preloading images...');
+ preloadedImages = [];
+ imagePreloader = document.getElementById('imagePreloader');
+ imagePreloader.innerHTML = ''; // Clear previous images
+
+ const preloadPromises = [];
+
+ for (let i = 0; i < 9; i++) {
+ const randomItem = getRandomItem();
+ const imageUrl = getImageUrl(randomItem);
+ console.log(`Preloading image ${i}:`, imageUrl);
+
+ if (!imageUrl) {
+ console.warn(`No image URL generated for index ${i}, skipping`);
+ preloadedImages.push({
+ item: randomItem,
+ url: null,
+ loaded: false
+ });
+ continue;
+ }
+
+ const preloadPromise = new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ console.log(`Image ${i} loaded successfully`);
+ preloadedImages.push({
+ item: randomItem,
+ url: imageUrl,
+ loaded: true
+ });
+ resolve();
+ };
+ img.onerror = () => {
+ console.warn('Failed to load image:', imageUrl);
+ // Try to get a different image
+ const fallbackItem = getRandomItem();
+ const fallbackUrl = getImageUrl(fallbackItem);
+ if (!fallbackUrl) {
+ console.warn(`No fallback URL for index ${i}`);
+ preloadedImages.push({
+ item: randomItem,
+ url: imageUrl,
+ loaded: false
+ });
+ resolve();
+ return;
+ }
+ const fallbackImg = new Image();
+ fallbackImg.onload = () => {
+ console.log(`Fallback image ${i} loaded successfully`);
+ preloadedImages.push({
+ item: fallbackItem,
+ url: fallbackUrl,
+ loaded: true
+ });
+ resolve();
+ };
+ fallbackImg.onerror = () => {
+ console.warn(`Both original and fallback images failed for index ${i}`);
+ preloadedImages.push({
+ item: randomItem,
+ url: imageUrl,
+ loaded: false
+ });
+ resolve(); // Continue even if image fails
+ };
+ fallbackImg.src = fallbackUrl;
+ };
+ img.src = imageUrl;
+ });
+
+ preloadPromises.push(preloadPromise);
+ }
+
+ await Promise.all(preloadPromises);
+ console.log('Image preloading complete:', preloadedImages.length, 'images loaded');
+ console.log('Preloaded images array:', preloadedImages);
+ }
+
+ function getRandomItem() {
+ if (!itemList || itemList.length === 0) {
+ return null;
+ }
+
+ if (currentItemList.length === 0) {
+ currentItemList = itemList.slice();
+ }
+
+ function hasBanner(item) {
+ return !!(item && item.ImageTags && item.ImageTags.Banner);
+ }
+
+ function hasBackdrop(item) {
+ return !!(item && item.BackdropImageTags && item.BackdropImageTags.length > 0);
+ }
+
+ // Prefer Banner-tagged titles; fall back to any with Backdrop (Banner is often missing).
+ function pickFromList(predicate) {
+ var attempts = 0;
+ var maxAttempts = currentItemList.length * 2;
+ while (attempts < maxAttempts && currentItemList.length > 0) {
+ var randomIndex = Math.floor(Math.random() * currentItemList.length);
+ var candidateItem = currentItemList[randomIndex];
+ if (predicate(candidateItem)) {
+ currentItemList.splice(randomIndex, 1);
+ return candidateItem;
+ }
+ currentItemList.splice(randomIndex, 1);
+ attempts++;
+ }
+ return null;
+ }
+
+ var randomItem = pickFromList(hasBanner);
+ if (!randomItem) {
+ currentItemList = itemList.slice();
+ randomItem = pickFromList(hasBackdrop);
+ }
+ if (!randomItem) {
+ console.warn('[Quiz] No movies with Banner or Backdrop images available');
+ currentItemList = itemList.slice();
+ return null;
+ }
+
+ console.log('randomItem', randomItem);
+ return randomItem;
+ }
+
+ function getImageUrl(item) {
+ if (!item) return null;
+ console.log('getting img url for item:', item.Name);
+ console.log('Item backdrop tags:', item.BackdropImageTags);
+ var backdropCount = item.BackdropImageTags ? item.BackdropImageTags.length : 0;
+ if (backdropCount === 0) {
+ console.warn('No backdrop images for item:', item.Name);
+ var newItem = getRandomItem();
+ if (!newItem) {
+ console.error('No backdrop images available');
+ return null;
+ }
+ backdropCount = newItem.BackdropImageTags ? newItem.BackdropImageTags.length : 0;
+ if (backdropCount === 0) {
+ console.error('No backdrop images available');
+ return null;
+ }
+ item = newItem;
+ }
+ var imageUrl = ApiClient.getImageUrl(item.Id, { type: 'Backdrop/' + Math.floor(Math.random() * backdropCount) });
+ console.log('Generated image URL:', imageUrl);
+ return imageUrl;
+ }
+
+ function addImageToQuiz(index) {
+ console.log('Adding image to quiz at index:', index);
+ console.log('Preloaded images:', preloadedImages);
+
+ // Use preloaded image if available
+ if (preloadedImages[index] && preloadedImages[index].loaded && preloadedImages[index].url) {
+ var imageData = preloadedImages[index];
+ var imageUrl = imageData.url;
+ var randomItem = imageData.item;
+ console.log('Using preloaded image:', imageUrl);
+ } else {
+ // Fallback to generating new image
+ console.log('Using fallback image generation');
+ var randomItem = getRandomItem();
+ var imageUrl = getImageUrl(randomItem);
+ }
+
+ if (!randomItem || !imageUrl) {
+ console.error('No image URL available for index', index);
+ return;
+ }
+
+ var existingImage = document.getElementById('quizContainer').querySelectorAll('img')[index];
+ console.log('Existing image at index', index, ':', existingImage);
+
+ var timeStarted = new Date();
+
+ if (existingImage) {
+ // Smooth image replacement
+ console.log('Replacing existing image');
+ replaceImageWithAnimation(existingImage, imageUrl, randomItem, timeStarted);
+ return;
+ }
+
+ // Create new image container
+ console.log('Creating new image container');
+ var imageContainer = document.createElement("div");
+ imageContainer.className = "quiz-image-container";
+
+ var image = document.createElement("IMG");
+ image.src = imageUrl;
+ image.dataset.movieName = randomItem.Name;
+ image.dataset.timeStarted = timeStarted.toString();
+ image.className = "fade-in";
+
+ imageContainer.appendChild(image);
+ document.getElementById('quizContainer').appendChild(imageContainer);
+ console.log('Added image container to quiz container');
+
+ // Preload next image for this slot
+ preloadNextImage(index);
+ }
+
+ function replaceImageWithAnimation(oldImage, newImageUrl, newItem, timeStarted) {
+ const container = oldImage.parentElement;
+
+ // Create new image
+ const newImage = document.createElement("IMG");
+ newImage.src = newImageUrl;
+ newImage.dataset.movieName = newItem.Name;
+ newImage.dataset.timeStarted = timeStarted.toString();
+ newImage.className = "fade-in";
+ newImage.style.position = "absolute";
+ newImage.style.top = "0";
+ newImage.style.left = "0";
+ newImage.style.width = "100%";
+ newImage.style.height = "100%";
+ newImage.style.objectFit = "cover";
+ newImage.style.borderRadius = "8px";
+
+ // Add new image behind old one
+ container.appendChild(newImage);
+
+ // Animate old image out
+ oldImage.classList.add("fade-out");
+
+ // Remove old image after animation
+ setTimeout(() => {
+ if (oldImage.parentElement) {
+ oldImage.parentElement.removeChild(oldImage);
+ }
+ newImage.style.position = "static";
+ }, 500);
+
+ // Preload next image for this slot
+ preloadNextImage(Array.from(container.parentElement.children).indexOf(container));
+ }
+
+ function preloadNextImage(slotIndex) {
+ // Preload a new image for this slot
+ const randomItem = getRandomItem();
+ const imageUrl = getImageUrl(randomItem);
+ if (!randomItem || !imageUrl) return;
+
+ const img = new Image();
+ img.onload = () => {
+ // Store preloaded image for this slot
+ if (!preloadedImages[slotIndex]) {
+ preloadedImages[slotIndex] = [];
+ }
+ preloadedImages[slotIndex] = {
+ item: randomItem,
+ url: imageUrl,
+ loaded: true
+ };
+ };
+ img.src = imageUrl;
+ }
+
+ function quizListeners() {
+ console.log('adding listener');
+ document.getElementById('quizGuess').addEventListener('input', checkGuess);
+
+ document.getElementById('quizStartButton').addEventListener('click', startQuiz);
+ document.getElementById('quizInitializeButton').addEventListener('click', initializeQuizData);
+ document.getElementById('quizRerollButton').addEventListener('click', rerollQuiz);
+
+ // Modal listeners
+ document.getElementById('modalCloseButton').addEventListener('click', hideScoreModal);
+ document.getElementById('scoreModal').addEventListener('click', function(e) {
+ if (e.target === this) {
+ hideScoreModal();
+ }
+ });
+ }
+
+ function startQuiz() {
+ resetTimer = false;
+ isQuizActive = true;
+ document.getElementById('quizHeaderContainer').classList.add("hide");
+ document.getElementById('quizContainer').classList.add("active");
+ document.getElementById('quizContainer').classList.remove("inactive");
+ document.getElementById('quizContainer').classList.remove("hide");
+ document.getElementById('quizControlButtons').classList.remove("hide");
+ document.getElementById('quizTimerContainer').classList.remove("hide");
+ document.getElementById('quizScoreDisplay').classList.add("show");
+ document.getElementById('quizRerollDisplay').classList.add("show");
+ document.getElementById('quizInitializeButton').disabled = false;
+ document.getElementById('quizStartButton').disabled = false;
+ document.getElementById('quizRerollButton').disabled = false;
+ document.getElementById('quizStartButton').style.display = "none";
+ document.getElementById('quizControlButtons').style.display = "flex";
+
+ // Update reroll display and button
+ updateRerollDisplay();
+ updateRerollButton();
+
+ // Clear input field and focus
+ document.getElementById('quizGuess').value = '';
+ document.getElementById('quizGuess').focus();
+
+ startTimer(59, document.getElementById("quizTimer"));
+
+ var quizStartedTime = new Date();
+ console.log('setting time started');
+ document.querySelectorAll('#quizContainer img').forEach((item) => {
+ item.dataset["timeStarted"] = quizStartedTime.toString();
+ console.log('setting time started');
+ });
+ document.getElementById("quizGuess").focus();
+ }
+
+ function startTimer(duration, display) {
+ tickTimer(duration, display);
+ }
+
+ function tickTimer(duration, display) {
+ timer = duration;
+ var minutes, seconds;
+ setTimeout(function () {
+ minutes = parseInt(timer / 60, 10);
+ seconds = parseInt(timer % 60, 10);
+
+ minutes = minutes < 10 ? "0" + minutes : minutes;
+ seconds = seconds < 10 ? "0" + seconds : seconds;
+
+ display.textContent = minutes + ":" + seconds;
+
+ // Add warning animation when time is low
+ if (timer <= 10 && timer > 0) {
+ display.classList.add("warning");
+ }
+
+ // Update reroll cost display
+ if (isQuizActive) {
+ updateRerollButton();
+ updateRerollDisplay();
+ }
+
+ if (resetTimer) {
+ timer = 0;
+ display.textContent = "01:00";
+ display.classList.remove("warning");
+ resetTimer = false;
+ } else if (--timer < -1) {
+ timer = 0;
+ display.classList.remove("warning");
+ finishQuiz();
+ } else {
+ tickTimer(timer, display);
+ }
+ }, 1000);
+ }
+
+ function finishQuiz() {
+ isQuizActive = false;
+ document.getElementById("quizStartButton").disabled = true;
+ // Keep control buttons visible so user can reset quiz
+ // Don't hide the quiz container, just show answers overlay
+ showAnswers();
+
+ console.log('submitting data for ' + username + ' with total ' + correctGuesses + ' correct');
+
+ // Show modal immediately
+ showScoreModal();
+
+ const formData = new FormData();
+ formData.append('username', username);
+ formData.append('score', Math.round(quizScore));
+ formData.append('correctItemCount', correctGuesses);
+
+ fetch('/files/KefinTweaks/pages/scripts/quiz.php', {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => response.text())
+ .then(data => {
+ console.log(data);
+ // Update modal message with server response
+ if (quizScore > 0) {
+ document.getElementById('modalMessage').textContent = data;
+ } else {
+ document.getElementById('modalMessage').textContent = 'Try to finish with more than 0 points next time!';
+ }
+ })
+ .catch(error => {
+ console.log('Error submitting quiz score:', error);
+ document.getElementById('modalMessage').textContent = 'Error submitting score. Please try again.';
+ });
+
+ document.getElementById("quizTimer").textContent = "01:00";
+ document.getElementById("quizTimer").classList.remove("warning");
+ }
+
+ function showAnswers() {
+ // Add answer overlays directly to each image container
+ document.querySelectorAll('#quizContainer .quiz-image-container').forEach((container) => {
+ var img = container.querySelector('img');
+ if (img) {
+ // Check if answer overlay already exists
+ var existingAnswer = container.querySelector('.answer-overlay');
+ if (existingAnswer) {
+ existingAnswer.remove();
+ }
+
+ // Create answer overlay
+ var answerOverlay = document.createElement('div');
+ answerOverlay.className = 'answer-overlay';
+ answerOverlay.innerHTML = img.dataset.movieName;
+
+ // Style the answer overlay
+ answerOverlay.style.position = 'absolute';
+ answerOverlay.style.bottom = '0';
+ answerOverlay.style.left = '0';
+ answerOverlay.style.right = '0';
+ answerOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
+ answerOverlay.style.color = '#4ecdc4';
+ answerOverlay.style.fontSize = '14px';
+ answerOverlay.style.fontWeight = 'bold';
+ answerOverlay.style.textAlign = 'center';
+ answerOverlay.style.padding = '8px';
+ answerOverlay.style.borderRadius = '0 0 8px 8px';
+ answerOverlay.style.zIndex = '10';
+ answerOverlay.style.textShadow = '2px 2px 4px rgba(0,0,0,0.8)';
+ answerOverlay.style.lineHeight = '1.2';
+ answerOverlay.style.wordWrap = 'break-word';
+ answerOverlay.style.boxSizing = 'border-box';
+
+ // Add to container
+ container.appendChild(answerOverlay);
+ }
+ });
+ }
+
+ function checkGuess() {
+ if (!isQuizActive) return;
+
+ var index = 0;
+ var quizItems = document.querySelectorAll('#quizContainer img');
+ var foundMatch = false;
+
+ quizItems.forEach((item) => {
+ if (foundMatch) return;
+
+ var guessValue = this.value.replace(/[^a-zA-Z ]/g, "");
+ var movieName = item.dataset.movieName.replace(/[^a-zA-Z ]/g, "");
+ var similarityScore = similarity(guessValue, movieName);
+ if (similarityScore > 0.5) {
+ console.log(`Similarity Score ${similarityScore} for guess ${guessValue} compared to ${movieName}`);
+ if (similarityScore > 0.95) {
+ console.log('match based on similarity ' + similarityScore);
+
+ var currentTime = new Date();
+ var timeStarted = new Date(item.dataset.timeStarted);
+ var timePassed = (currentTime - timeStarted) / 1000;
+ var timeMultiplier = (120 - timePassed) / 120;
+ var pointsEarned = 120 * timeMultiplier;
+ titlesCorrect.push(item.dataset.movieName);
+ quizScore += pointsEarned;
+ correctGuesses++;
+
+ // Track correct guess data
+ correctGuessesData.push({
+ movieName: item.dataset.movieName,
+ timePassed: timePassed,
+ pointsEarned: pointsEarned,
+ timestamp: currentTime
+ });
+
+ console.log('Points earned: ' + pointsEarned + ' for ' + item.dataset.movieName + ' in ' + timePassed.toFixed(2) + ' seconds');
+
+ // Show points animation
+ showPointsAnimation(item, Math.round(pointsEarned));
+
+ // Update score display
+ updateScoreDisplay();
+
+ // Replace image
+ addImageToQuiz(index);
+ this.value = '';
+ foundMatch = true;
+ return;
+ }
+ }
+ index++;
+ });
+ }
+
+ function showPointsAnimation(imageElement, points) {
+ const container = imageElement.closest('.quiz-image-container') || imageElement.parentElement;
+ const rect = container.getBoundingClientRect();
+
+ const pointsElement = document.createElement('div');
+ pointsElement.className = 'points-animation';
+ pointsElement.textContent = `+${points}`;
+ pointsElement.style.left = (rect.left + rect.width / 2) + 'px';
+ pointsElement.style.top = (rect.top + rect.height / 2) + 'px';
+
+ document.body.appendChild(pointsElement);
+
+ // Remove after animation
+ setTimeout(() => {
+ if (pointsElement.parentElement) {
+ pointsElement.parentElement.removeChild(pointsElement);
+ }
+ }, 2000);
+ }
+
+ function showRerollAnimation() {
+ const remaining = maxRerolls - rerollCount;
+ const rerollButton = document.getElementById('quizRerollButton');
+ const rect = rerollButton.getBoundingClientRect();
+
+ const rerollElement = document.createElement('div');
+ rerollElement.className = 'reroll-animation';
+ rerollElement.textContent = `${remaining} rolls remaining`;
+ rerollElement.style.left = (rect.left + rect.width / 2) + 'px';
+ rerollElement.style.top = (rect.top - 20) + 'px';
+
+ document.body.appendChild(rerollElement);
+
+ // Remove after animation
+ setTimeout(() => {
+ if (rerollElement.parentElement) {
+ rerollElement.parentElement.removeChild(rerollElement);
+ }
+ }, 2500);
+ }
+
+ function updateScoreDisplay() {
+ const scoreDisplay = document.getElementById('quizScoreDisplay');
+ scoreDisplay.textContent = `Score: ${Math.round(quizScore)}`;
+ scoreDisplay.classList.add('show');
+ }
+
+ function calculateRerollCost() {
+ return 50;
+ // Cost scales from 200 at start to 120 at end (60 second timer)
+ // Formula: 200 - (timeElapsed / 60) * 80
+ const timeElapsed = 60 - timer; // timer counts down from 60
+ const cost = Math.max(50, 200 - (timeElapsed / 60) * 80);
+ return Math.round(cost);
+ }
+
+ function updateRerollDisplay() {
+ const rerollDisplay = document.getElementById('quizRerollDisplay');
+ const remaining = maxRerolls - rerollCount;
+ const currentCost = calculateRerollCost();
+ rerollDisplay.textContent = `Re-rolls: ${remaining}/${maxRerolls} (${currentCost} pts)`;
+ rerollDisplay.classList.add('show');
+ }
+
+ function updateRerollButton() {
+ const rerollButton = document.getElementById('quizRerollButton');
+ const currentCost = calculateRerollCost();
+ rerollButton.textContent = `Re-roll (-${currentCost} pts)`;
+ }
+
+ async function rerollQuiz() {
+ if (rerollCount >= maxRerolls || !isQuizActive) {
+ return;
+ }
+
+ // Calculate and deduct dynamic cost
+ const rerollCost = calculateRerollCost();
+ quizScore = Math.max(0, quizScore - rerollCost);
+ rerollCount++;
+
+ // Show reroll animation
+ showRerollAnimation();
+
+ // Update displays
+ updateScoreDisplay();
+ updateRerollDisplay();
+
+ // Clear and refocus input field
+ document.getElementById('quizGuess').value = '';
+ document.getElementById('quizGuess').focus();
+
+ // Disable button if no rerolls left
+ if (rerollCount >= maxRerolls) {
+ document.getElementById('quizRerollButton').disabled = true;
+ }
+
+ // Clear existing answer overlays
+ document.querySelectorAll('.answer-overlay').forEach((overlay) => {
+ overlay.remove();
+ });
+
+ // Preload new images
+ await preloadImages();
+
+ // Clear the entire quiz container
+ document.getElementById('quizContainer').innerHTML = '';
+
+ // Add all 9 new images to the grid
+ for (var i = 0; i < 9; i++) {
+ addImageToQuiz(i);
+ }
+
+ console.log('Quiz rerolled! All 9 images replaced.');
+ }
+
+ function similarity(s1, s2) {
+ var longer = s1;
+ var shorter = s2;
+ if (s1.length < s2.length) {
+ longer = s2;
+ shorter = s1;
+ }
+ var longerLength = longer.length;
+ if (longerLength == 0) {
+ return 1.0;
+ }
+ return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength);
+ }
+
+ function editDistance(s1, s2) {
+ s1 = s1.toLowerCase();
+ s2 = s2.toLowerCase();
+
+ var costs = new Array();
+ for (var i = 0; i <= s1.length; i++) {
+ var lastValue = i;
+ for (var j = 0; j <= s2.length; j++) {
+ if (i == 0)
+ costs[j] = j;
+ else {
+ if (j > 0) {
+ var newValue = costs[j - 1];
+ if (s1.charAt(i - 1) != s2.charAt(j - 1))
+ newValue = Math.min(Math.min(newValue, lastValue),
+ costs[j]) + 1;
+ costs[j - 1] = lastValue;
+ lastValue = newValue;
+ }
+ }
+ }
+ if (i > 0)
+ costs[s2.length] = lastValue;
+ }
+ return costs[s2.length];
+ }
+
+ function showScoreModal() {
+ // Update modal content
+ document.getElementById('modalFinalScore').textContent = Math.round(quizScore);
+ document.getElementById('modalCorrectGuesses').textContent = correctGuesses;
+
+ // Update correct guesses list
+ updateCorrectGuessesList();
+
+ // Show modal
+ document.getElementById('scoreModal').classList.add('show');
+ }
+
+ function updateCorrectGuessesList() {
+ const guessesList = document.getElementById('modalGuessesList');
+ if (!guessesList) return;
+
+ // Sort guesses by time (fastest first)
+ const sortedGuesses = correctGuessesData.sort((a, b) => a.timePassed - b.timePassed);
+
+ let html = '';
+ sortedGuesses.forEach((guess, index) => {
+ const timeFormatted = guess.timePassed < 1 ?
+ `${Math.round(guess.timePassed * 1000)}ms` :
+ `${guess.timePassed.toFixed(1)}s`;
+
+ html += `
+
+ ${index + 1}.
+ ${guess.movieName}
+ ${timeFormatted}
+ +${Math.round(guess.pointsEarned)}
+
+ `;
+ });
+
+ guessesList.innerHTML = html;
+ }
+
+ function hideScoreModal() {
+ document.getElementById('scoreModal').classList.remove('show');
+ }
+
+ // Main initialization function
+ function initializeQuizApp() {
+ console.log('Initializing quiz app');
+ serverId = ApiClient.serverId();
+ initializeQuiz(ApiClient);
+ quizListeners();
+ }
+
+ // Initialize the quiz when the script loads
+ if (typeof ApiClient !== 'undefined') {
+ // Wait for DOM to be ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializeQuizApp);
+ } else {
+ initializeQuizApp();
+ }
+ } else {
+ // Wait for ApiClient to be available
+ const checkApiClient = setInterval(() => {
+ if (typeof ApiClient !== 'undefined') {
+ clearInterval(checkApiClient);
+ initializeQuizApp();
+ }
+ }, 100);
+ }
+})();
\ No newline at end of file
diff --git a/scripts/HomeScreenSection.ts b/scripts/HomeScreenSection.ts
new file mode 100644
index 0000000..d3af570
--- /dev/null
+++ b/scripts/HomeScreenSection.ts
@@ -0,0 +1,213 @@
+interface HomeScreenSection {
+ // Identification
+ id: string;
+ name: string;
+ description?: string; // Description of the section, useful when the Section Name is insufficient at describing the section or for sharing Custom Sections
+ 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";
+ spotlight?: boolean; // Legacy: Use renderMode instead. Kept for backward compatibility.
+ renderMode?: "Normal" | "Spotlight" | "Random"; // Preferred: Controls rendering mode. "Spotlight" = spotlight section, "Random" = random spotlight/cards, "Normal" = cards only.
+ spotlightConfig?: object;
+ discoveryEnabled?: boolean;
+ flattenSeries?: boolean;
+ minimumItems?: number;
+ viewMoreUrl?: string;
+ captionUrl?: string;
+ hidden?: boolean; // legacy, use userConfigurable instead
+ hideName?: boolean;
+
+ hideCardTitles?: boolean; // Legacy: use cardTitleVisibility instead
+ borderStyle?: string; // Applied as data-border on the section container
+ borderColor?: string; // CSS color for card borders (--kefin-card-border-color)
+ sectionCssClass?: string; // Extra CSS classes on the section container
+ itemsLayout?: "row" | "grid";
+ cardTitleVisibility?: "visible" | "hidden";
+ cardTitlePosition?: string;
+ cardTitleCapitalization?: "normal" | "uppercase" | "lowercase" | "capitalize";
+ cardTitleFontFamily?: string;
+ 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
+ externalListUrls?: string[];
+
+ // Multi-Query (only used when queries.length > 1)
+ sortBy?: string; // Field to sort merged results by (e.g., 'DatePlayed', 'DateCreated')
+ sortOrder?: "Ascending" | "Descending"; // Sort direction for merged results
+ minPlayCount?: number; // Minimum play count to include in the section
+ maxPlayCount?: number; // Maximum play count to include in the section
+ useRandomQuery?: boolean; // Pick one query at load instead of merging; with useMultiQueryPicker, randomizes the initial picker selection
+ useMultiQueryPicker?: boolean; // Show a control to pick which query the section displays
+ multiQueryPickerLabel?: string; // Fixed label for the picker button; when unset, button shows the selected query name
+ useQueryNamesForSection?: boolean; // Use the selected query name in place of the section title
+
+ // Optionally provide pre-defined items to display in the section, instead of using queries
+ items?: Array<{
+ Name: string;
+ Id?: string;
+ Type?: string;
+ cardFooter?: string;
+ imageUrl: string;
+ backdropUrl?: string;
+ bannerUrl?: string;
+ logoUrl?: string;
+ thumbUrl?: string;
+ squareUrl?: string;
+ cardUrl: string;
+ hidden?: boolean; // Whether to hide the item from the section
+ }>;
+
+ // Query Construction
+ queries: Array;
+
+ // Surface / page kind: home | seasonal | discovery on the home catalog;
+ // other pages use series-episodes, search, watchlist, etc. Never "custom".
+ type?: string;
+ /** True for user-created sections stored in custom groups (surface type still home/seasonal/discovery). */
+ isCustom?: boolean;
+ /** How a discovery section resolves dynamically: Person, Genre, Studio, Similar, Collection, … */
+ discoveryType?: string;
+ discoveryItemType?: string;
+ discoveryPersonType?: string;
+ source?: string;
+
+ /** Pool query used to resolve a dynamic discovery entity (optional on non-discovery sections). */
+ discoverySourceQuery?: Query;
+
+ /** Runtime stamp for custom discovery paging (1..N per custom group); not a persisted admin field. */
+ pageNumber?: number;
+
+ // Seasonal Config
+ startDate?: string;
+ endDate?: string;
+
+ dataMonitors?: string[]; // A list of data monitor names to use for this section
+
+ // Cache Settings
+ ttl?: number;
+}
+
+interface Query {
+ name?: string; // Optional display name for multi-query picker and section title overrides
+ viewMoreUrl?: string; // Optional per-query title link when using multi-query picker
+ _sourceType?: "jellyfin" | "cache" | "static";
+ path?: string; // Custom endpoint path (e.g., '/Shows/NextUp', '/Shows/Upcoming')
+ dataSource?: string; // Cache/custom data source (e.g., 'MoviesCache.getImdbTop250Movies')
+ /** Args passed to the cache method (e.g. ItemType, MinCount) — not post-processing */
+ dataSourceOptions?: {
+ ItemType?: string;
+ MinCount?: number;
+ [key: string]: any;
+ };
+ minAge?: number; // Minimum age in days
+ maxAge?: number; // Maximum age in days
+ queryOptions?: {
+ // Query parameters for /Items or custom endpoints / Client Cache post-processing
+ Ids?: string[];
+ IncludeItemTypes?: string[];
+ SearchTerm?: string;
+ SortBy?: string;
+ SortOrder?: "Ascending" | "Descending";
+ Limit?: number;
+ Filters?: string;
+ Genres?: string;
+ GenreIds?: string;
+ Tags?: string;
+ PersonIds?: string;
+ Studios?: string;
+ StudioIds?: string;
+ ParentId?: string;
+ ExcludeItemIds?: string;
+ Fields?: string;
+ [key: string]: any; // Allow additional query params
+ };
+}
+
+interface HomeScreenSectionGroup {
+ author?: string;
+ name?: string;
+ description?: string;
+ sections: Array;
+}
+
+interface SpotlightConfig {
+ spotlightLayout: string;
+ spotlightSize: string;
+ tileCount: number;
+ autoPlay: boolean;
+ interval: number;
+ showDots: boolean;
+ showNavButtons: boolean;
+ showClearArt: boolean;
+ panAnimation: boolean;
+ entranceAnimationFirst: string;
+ entranceAnimationSecond: string;
+ entranceAnimationThird: string;
+ slideAnimationFirst: string;
+ slideAnimationSecond: string;
+ slideAnimationThird: string;
+ cycleBackdrops: boolean;
+ cycleBackdropsTime?: number; // Legacy: use backdropsCount + interval instead
+ backdropsCount?: number;
+}
+
+// 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;renderMode(Normal|Spotlight|Random)
+ pinnedLists: Array; // name;id1;id2;id3;...
+ pinnedParents: Array; // name;id;type
+}
+
+/** Non-home layout/appearance prefs (same semicolon section strings as homeScreen.sections). */
+interface KefinSectionState {
+ sections: Array;
+}
+
+/*
+User configuration for KefinTweaks Home Screen sections will be stored in the user's custom display preferences as kefinHomeScreen
+Additionally, kefinPinnedItems and kefinPinnedParents will store an array of item IDs that the user has pinned to the home screen.
+The kefinPinnedItems are individual items intended to appear in a Home Screen Section called "Pinned"
+The kefinPinnedParents are parent items that should each have their own Home Screen Section which loads their child items.
+
+For example, a user could pin an MCU collection to their home screen as an item, and it would appear in the Pinned row with the MCU Collection poster.
+By contrast, the user could choose to pin the MCU collection as a parent item, which would make the child items of the MCU Collection appear in a section called "MCU Collection".
+*/
+
+// An array of these sections is saved to the user's preferences as kefinHomeScreen
+// These can be mapped by ID to override the section configuration provided by the server
+interface KefinHomeScreenSectionUserConfig {
+ id: string;
+ enabled?: boolean;
+ order?: number;
+ ttl?: number;
+ cardFormat?: string;
+ animationEnabled?: boolean;
+ hideName?: boolean;
+ hideCardTitles?: boolean;
+ cardTitlePosition?: string;
+ borderStyle?: string;
+ borderColor?: string;
+ spotlightLayout?: string;
+ spotlightSize?: string;
+ spotlightTileCount?: number;
+ /** Items layout: row (default) or grid. Replaces legacy gridExpanded. */
+ itemsLayout?: "row" | "grid";
+ /** @deprecated Prefer itemsLayout; true maps to itemsLayout 'grid' */
+ gridExpanded?: boolean;
+ /** When true, remove gaps between cards (row or grid). */
+ useGaplessCards?: boolean;
+ cardTitleCapitalization?: string;
+ cardTitleFontFamily?: string;
+ cardTitleFontSize?: string;
+ /** User override of the section's render mode. */
+ renderMode?: "Normal" | "Spotlight" | "Random";
+}
diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js
index c614e2e..7bdff8d 100644
--- a/scripts/apiHelper.js
+++ b/scripts/apiHelper.js
@@ -13,6 +13,87 @@
throw new Error('ApiClient is not available');
}
}
+
+ const DEFAULT_ITEM_FIELDS = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount';
+ const SPOTLIGHT_ITEM_FIELDS = `${DEFAULT_ITEM_FIELDS},People,Genres,ParentBackdropImageTags,Studios`;
+ const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
+
+ function getDefaultFields(isSpotlight) {
+ return isSpotlight ? SPOTLIGHT_ITEM_FIELDS : DEFAULT_ITEM_FIELDS;
+ }
+
+ /**
+ * Resolve Fields param for a query based on section type and explicit queryOptions.Fields.
+ * @param {Object} queryOptions
+ * @param {{ isSpotlight?: boolean, sectionType?: string }} context
+ * @returns {string}
+ */
+ function resolveQueryFields(queryOptions, { isSpotlight = false, sectionType = '' } = {}) {
+ const explicit = queryOptions?.Fields;
+ if (!explicit) {
+ return getDefaultFields(isSpotlight);
+ }
+ const isDefaultHome = sectionType === 'home';
+ if (isDefaultHome && !isSpotlight) {
+ return mergeFields(null, explicit);
+ }
+ if (isDefaultHome && isSpotlight) {
+ return mergeFields(SPOTLIGHT_ITEM_FIELDS, explicit);
+ }
+ return mergeFields(getDefaultFields(isSpotlight), explicit);
+ }
+
+ function getNextUpDateCutoff(userId) {
+ const raw = userId ? localStorage.getItem(`${userId}-maxDaysForNextUp`) : null;
+ const days = raw != null && raw !== '' ? parseInt(raw, 10) : 365;
+ const maxDays = Number.isFinite(days) && days >= 0 ? days : 365;
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - maxDays);
+ return cutoff.toISOString().split('T')[0];
+ }
+
+ function getEnableRewatchingInNextUp(userId) {
+ if (!userId) return false;
+ return localStorage.getItem(`${userId}-enableRewatchingInNextUp`) === 'true';
+ }
+
+ function mergeFields(base, extra) {
+ const seen = new Set();
+ const result = [];
+ const add = (value) => {
+ if (value == null || value === '') return;
+ const parts = Array.isArray(value) ? value : String(value).split(',');
+ for (const part of parts) {
+ const field = String(part).trim();
+ if (!field) continue;
+ const key = field.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ result.push(field);
+ }
+ };
+ add(base);
+ add(extra);
+ return result.join(',');
+ }
+
+ function applyQueryOptionsToParams(params, queryOptions, { skipFields = false } = {}) {
+ Object.entries(queryOptions || {}).forEach(([key, value]) => {
+ if (value === undefined || value === null) return;
+ if (key === 'Limit' && value === 0) return;
+ if (key === 'Fields') {
+ if (skipFields) return;
+ params.set('Fields', mergeFields(params.get('Fields'), value));
+ return;
+ }
+ if (Array.isArray(value)) {
+ const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ',';
+ params.set(key, value.join(delimiter));
+ return;
+ }
+ params.set(key, value);
+ });
+ }
/**
* Retrieves the active playback session for this device
@@ -154,24 +235,38 @@
}
/**
- * Build MediaBrowser Authorization header
- * @returns {string} - Authorization header
+ * Build MediaBrowser Authorization header for an explicit access token.
+ * When token is empty/missing, Token is omitted (required for AuthenticateByName).
+ * @param {string} [token]
+ * @returns {string}
*/
- function getAuthHeader() {
- const token = ApiClient.accessToken();
- const client = typeof ApiClient.applicationName === 'function' ? ApiClient.applicationName() : 'Jellyfin Web';
- const device = typeof ApiClient.deviceName === 'function' ? ApiClient.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser');
- const deviceId = typeof ApiClient.deviceId === 'function' ? ApiClient.deviceId() : '';
- const version = ApiClient._appVersion || ApiClient._serverVersion || '';
+ function getAuthHeaderForToken(token) {
+ const api = (typeof ApiClient !== 'undefined' && ApiClient) || window.ApiClient || null;
+ const client = api && typeof api.applicationName === 'function' ? api.applicationName() : 'Jellyfin Web';
+ const device = api && typeof api.deviceName === 'function' ? api.deviceName() : (navigator.userAgent.includes('Chrome') ? 'Chrome' : 'Browser');
+ const deviceId = api && typeof api.deviceId === 'function' ? api.deviceId() : '';
+ const version = (api && (api._appVersion || api._serverVersion)) || '';
const parts = [
`Client="${encodeURIComponent(client)}"`,
`Device="${encodeURIComponent(device)}"`,
`DeviceId="${encodeURIComponent(deviceId)}"`,
- `Version="${encodeURIComponent(version)}"`,
- `Token="${encodeURIComponent(token)}"`
+ `Version="${encodeURIComponent(version)}"`
];
+ if (token) {
+ parts.push(`Token="${encodeURIComponent(token)}"`);
+ }
return `MediaBrowser ${parts.join(', ')}`;
}
+
+ /**
+ * Build MediaBrowser Authorization header for the current ApiClient session.
+ * @returns {string} - Authorization header
+ */
+ function getAuthHeader() {
+ return getAuthHeaderForToken(typeof ApiClient !== 'undefined' && ApiClient.accessToken
+ ? ApiClient.accessToken()
+ : '');
+ }
/**
* Query cache for API responses
@@ -244,11 +339,367 @@
return `${serverAddress}/Users/${userId}/Items?${params.toString()}`;
}
+
+ const state = {
+ cachedServerVersion: null
+ }
+
+ /**
+ * Fields used by progressive Watchlist sections. Must match getWatchlistQuery /
+ * invalidateWatchlistQueries so cache clear hits the same URL as fetch.
+ */
+ const WATCHLIST_PROGRESSIVE_FIELDS =
+ 'PrimaryImageAspectRatio,DateCreated,ProductionYear,PremiereDate,ProviderIds,UserData';
+
+ /**
+ * Build a single top-level Likes query def (shared by getWatchlistItems / getWatchlistQuery).
+ * Callers pass IncludeItemTypes (and other options) to narrow results.
+ * Default Fields match progressive sections so invalidate clears the fetched URL.
+ * @param {Object} options
+ * @returns {{ queryOptions: Object }}
+ */
+ function buildWatchlistQueryDef(options = {}) {
+ return {
+ queryOptions: {
+ Recursive: true,
+ ImageTypeLimit: 1,
+ EnableImageTypes: 'Primary,Backdrop,Thumb',
+ Fields: WATCHLIST_PROGRESSIVE_FIELDS,
+ ...options,
+ Filters: 'Likes'
+ }
+ };
+ }
/**
* API Helper functions for Jellyfin operations
*/
const apiHelper = {
+ /**
+ * Get the MediaBrowser Authorization header for an explicit access token
+ * @param {string} [token]
+ * @returns {string}
+ */
+ getAuthHeaderForToken: getAuthHeaderForToken,
+
+ /**
+ * Get the MediaBrowser Authorization header
+ * @returns {string} - Authorization header
+ */
+ getAuthHeader: getAuthHeader,
+
+ /**
+ * Generic data fetcher with universal caching and progressive loading support
+ * @param {string} url - Full URL to fetch
+ * @param {Object} options - Cache options
+ * @param {boolean} options.useCache - Whether to return cached data first (default: false)
+ * @param {number} options.ttl - Cache time to live in milliseconds (default: 300000)
+ * @returns {Object|Promise} - If useCache is true, returns progressive stub { data, ensureData, isStalePromise } synchronously. Otherwise returns Promise.
+ */
+ getQuery: function(url, options = {}) {
+ ensureApiClient();
+
+ const useCache = options.useCache || false;
+ let ttl = 60000;
+ if (Number(options.ttl) >= 0) {
+ ttl = Number(options.ttl);
+ }
+ const forceRefresh = options.forceRefresh || false;
+ const cacheKey = url;
+
+ // Standard fetch function
+ const fetchData = async () => {
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'Authorization': getAuthHeader(),
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // Always cache successful responses
+ if (data) {
+ setCachedQuery(cacheKey, data);
+ // Also persist to IndexedDB if available
+ if (window.IndexedDBCache) {
+ const cache = window.IndexedDBCache;
+ // We use the full URL as part of the key, but hashed or sanitized might be safer.
+ // For now using URL as is common in this codebase.
+ // IndexedDBCache expects a "name" which it prefixes.
+ // We'll use a hash of the URL to keep keys clean?
+ // Or just pass the URL as the cacheName but it might get messy.
+ // Let's use a simple mapping or just stringify.
+ // Actually, let's just stick to the in-memory cache for 'getQuery' unless we want persistent storage for home screen.
+ // The requirement says "use existing indexeddbcache functionality".
+ // So let's use it.
+ await cache.set(url, data, null, ttl);
+ }
+ }
+
+ return data;
+ } catch (error) {
+ ERR('Failed to fetch URL:', error);
+ throw error;
+ }
+ };
+
+ // If not using cache, just fetch
+ if (!useCache) {
+ return fetchData();
+ }
+
+ // Progressive loading Logic
+ const result = {
+ data: null,
+ dataPromise: null,
+ isStalePromise: null
+ };
+
+ // 1. Get cached data immediately (async but fast)
+ const cachePromise = (async () => {
+ // Try memory cache first
+ let cached = getCachedQuery(cacheKey, ttl);
+
+ // If not in memory, try IndexedDB
+ if (!cached && window.IndexedDBCache) {
+ const dbCache = window.IndexedDBCache;
+ const entry = await dbCache.getCacheEntry(url);
+ if (entry) {
+ cached = entry; // Keep structure { payload, timestamp, ttl }
+ LOG(`[Cache] Found in IndexedDB: ${url.substring(0, 60)}...`);
+ }
+ } else if (cached) {
+ // Wrap memory cache to match structure
+ cached = { payload: cached, timestamp: Date.now(), ttl: ttl };
+ LOG(`[Cache] Found in Memory: ${url.substring(0, 60)}...`);
+ } else {
+ LOG(`[Cache] Miss: ${url.substring(0, 60)}...`);
+ }
+
+ return cached;
+ })();
+
+ // 2. Define staleness check
+ result.isStalePromise = cachePromise.then(async (entry) => {
+ if (forceRefresh) {
+ LOG(`[StaleCheck] Force Refresh -> Stale`);
+ return true; // Force Refresh = stale
+ }
+
+ if (!entry) {
+ LOG(`[StaleCheck] No cache entry -> Stale`);
+ return true; // No cache = stale
+ }
+
+ const now = Date.now();
+ const age = now - entry.timestamp;
+ const isExpired = age > ttl;
+
+ result.data = entry.payload;
+
+ if (!isExpired) {
+ LOG(`[StaleCheck] Cache valid (${(age/1000).toFixed(1)}s old) -> Not Stale`);
+ return false; // Valid
+ }
+
+ LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Refreshing...`);
+ return true;
+
+ /* LOG(`[StaleCheck] Cache expired (${(age/1000).toFixed(1)}s old). Validating...`);
+
+ // Cache is expired, check if we can validate it
+ // Logic: Check SortBy
+ const urlObj = new URL(url);
+ const sortBy = urlObj.searchParams.get('SortBy');
+ const isRandom = !sortBy || sortBy.includes('Random');
+
+ if (isRandom) {
+ LOG(`[StaleCheck] Random sort detected -> Stale (Force Refresh)`);
+ return true; // Random + Expired = Stale
+ }
+
+ // Fixed Sort: Fetch first 3 items
+ try {
+ // Clone URL and modify params
+ const checkUrl = new URL(url);
+ checkUrl.searchParams.set('Limit', '3');
+ //checkUrl.searchParams.set('StartIndex', '0'); // Ensure start
+
+ const checkResponse = await fetch(checkUrl.toString(), {
+ headers: {
+ 'Authorization': getAuthHeader(),
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!checkResponse.ok) {
+ LOG(`[StaleCheck] Validation fetch failed -> Stale`);
+ return true;
+ }
+
+ const checkData = await checkResponse.json();
+
+ // Compare IDs
+ const cachedItems = entry.payload.Items || entry.payload || [];
+ const freshItems = checkData.Items || checkData || [];
+
+ if (freshItems.length === 0 && cachedItems.length === 0) {
+ LOG(`[StaleCheck] Both empty -> Not Stale`);
+ return false;
+ }
+ if (freshItems.length !== Math.min(cachedItems.length, 3)) {
+ LOG(`[StaleCheck] Count mismatch (Fresh: ${freshItems.length}, Cached: ${Math.min(cachedItems.length, 3)}) -> Stale`);
+ return true;
+ }
+
+ for (let i = 0; i < freshItems.length; i++) {
+ if (freshItems[i].Id !== cachedItems[i].Id) {
+ LOG(`[StaleCheck] Item mismatch at index ${i} -> Stale`);
+ return true; // Mismatch found
+ }
+
+ // Also check the UserData for the item
+ if (freshItems[i].UserData) {
+ // Check each key in the UserData object
+ for (const key in freshItems[i].UserData) {
+ if (freshItems[i].UserData[key] !== cachedItems[i].UserData[key]) {
+ LOG(`[StaleCheck] UserData mismatch for key ${key} -> Stale`);
+ return true; // Mismatch found
+ }
+ }
+ }
+
+ // Also check the ImageTags for the item
+ if (freshItems[i].ImageTags) {
+ // Check each key in the ImageTags object
+ for (const key in freshItems[i].ImageTags) {
+ if (freshItems[i].ImageTags[key] !== cachedItems[i].ImageTags[key]) {
+ LOG(`[StaleCheck] ImageTags mismatch for key ${key} -> Stale`);
+ return true; // Mismatch found
+ }
+ }
+ }
+ }
+
+ // Validation Successful: Update timestamp
+ LOG(`[StaleCheck] Content matches -> Not Stale (Updating Timestamp)`);
+ if (window.IndexedDBCache) {
+ const dbCache = window.IndexedDBCache;
+ // Update with new timestamp but same data
+ dbCache.set(url, entry.payload, null, ttl);
+ }
+
+ result.data = entry.payload; // Use cached data
+ return false; // Not stale
+
+ } catch (e) {
+ WARN('Error validating cache:', e);
+ return true; // Assume stale on error
+ } */
+ });
+
+ // 3. Lazy data promise — do not start fetchData until ensureData()/dataPromise is used
+ // Stale-while-revalidate: return usable cache immediately; refresh in _refreshPromise
+ const hasUsableCachedPayload = (data) => {
+ if (!data) return false;
+ if (Array.isArray(data)) return data.length > 0;
+ if (Array.isArray(data.Items)) return data.Items.length > 0;
+ return false;
+ };
+
+ result.ensureData = function() {
+ if (!result._dataPromise) {
+ result._dataPromise = result.isStalePromise.then(isStale => {
+ if (!isStale) {
+ LOG(`[Fetch] Using cached data for: ${url}...`);
+ return result.data;
+ }
+
+ const refreshPromise = fetchData().then((fresh) => {
+ if (fresh != null) result.data = fresh;
+ return fresh;
+ });
+
+ if (hasUsableCachedPayload(result.data)) {
+ result._refreshPromise = refreshPromise;
+ LOG(`[Fetch] Returning stale cache; refreshing in background: ${url}...`);
+ return result.data;
+ }
+
+ LOG(`[Fetch] Refreshing data for: ${url}...`);
+ return refreshPromise;
+ });
+ }
+ return result._dataPromise;
+ };
+ Object.defineProperty(result, 'dataPromise', {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return result.ensureData();
+ }
+ });
+
+ // Sync stub: do not await IndexedDB before return (home first paint).
+ // cachePromise / isStalePromise hydrate result.data in the background.
+ // Thenable waits for hydrate but must NOT fulfill with `result` itself
+ // (Promise assimilation would recurse on result.then forever).
+ result.data = { Items: [] };
+ result.isStale = true;
+ const readyPromise = cachePromise.then((entry) => {
+ if (entry) {
+ result.data = entry.payload;
+ result.isStale = forceRefresh || (Date.now() - entry.timestamp > ttl);
+ } else {
+ // Cache miss: clear placeholder so data?.Items cannot fake a hit
+ result.data = null;
+ result.isStale = true;
+ }
+ }).catch(() => {
+ result.data = null;
+ result.isStale = true;
+ });
+
+ function toSettledView() {
+ return {
+ data: result.data,
+ isStale: result.isStale,
+ isStalePromise: result.isStalePromise,
+ ensureData: result.ensureData,
+ get dataPromise() {
+ return result.ensureData();
+ }
+ };
+ }
+
+ result.then = function (onFulfilled, onRejected) {
+ return readyPromise.then(
+ () => (typeof onFulfilled === 'function' ? onFulfilled(toSettledView()) : toSettledView()),
+ onRejected
+ );
+ };
+ result.catch = function (onRejected) {
+ return result.then(undefined, onRejected);
+ };
+ return result;
+ },
+
+ invalidateCache: function(url) {
+ const dbCache = window.IndexedDBCache;
+ if (dbCache) {
+ dbCache.clear(url);
+ }
+ if (queryCache) {
+ queryCache.delete(url);
+ }
+ },
+
/**
* Generic data fetcher with universal caching
* Always caches successful responses, but only checks cache when useCache is true
@@ -300,52 +751,94 @@
},
getWatchlistItems: async function(options = {}, useCache = false) {
- // First fetch the Library Items that contain items of the supported types from ApiClient.getJSON(ApiClient.getUrl("Library/MediaFolders"))
- const libraryItems = await ApiClient.getItems();
-
- // We now want to include the library items with ColletionType "tvshows", or "movies"
- const supportedCollectionTypes = ['tvshows', 'movies', 'homevideos', 'boxsets', 'playlists'];
- const supportedLibraryItems = libraryItems.Items.filter(item => supportedCollectionTypes.includes(item.CollectionType) || !item.CollectionType);
-
- // Now use these support library items as the parent ids for the Items query, since the Items endpoint doesn't support ParentIds as an array we need to make a different call for each library. Querying with the parent id will dramatically reduce the time for the request to return so this is fine
- let watchlistItems = [];
- for (const libraryItem of supportedLibraryItems.reverse()) {
- // Get the item types to include based on the library type. if its a Movies library only use the Movie type. If it's tvshows then use Series, Season and Episode
- let itemTypes = options.IncludeItemTypes.split(',');
- if (itemTypes.length > 1) {
- if (libraryItem.CollectionType === 'movies') {
- itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Movie');
- } else if (libraryItem.CollectionType === 'tvshows') {
- itemTypes = options.IncludeItemTypes.split(',').filter(item => item === 'Series' || item === 'Season' || item === 'Episode');
- }
- }
-
- // Use getItems to fetch the items for the library item
- const data = await this.getItems({
- ParentId: libraryItem.Id,
- Filters: 'Likes',
- IncludeItemTypes: itemTypes.join(','),
- Recursive: true,
- ImageTypeLimit: 1,
- EnableImageTypes: 'Primary,Backdrop,Thumb',
- ...options
- }, useCache);
-
- //const url = `${ApiClient.serverAddress()}/Items?Filters=Likes&IncludeItemTypes=${type}&UserId=${ApiClient.getCurrentUserId()}&Recursive=true&ImageTypeLimit=1&EnableImageTypes=Primary,Backdrop,Thumb&ParentId=${libraryItem.Id}`;
- //const data = await this.getData(url, useCache);
- watchlistItems.push(...data.Items);
- }
-
- // Deuplicate any duplicate Id's in the watchlistItems array
- watchlistItems = watchlistItems.filter((item, index, self) =>
- index === self.findIndex((t) => t.Id === item.Id)
- );
+ const query = buildWatchlistQueryDef(options);
+ const data = await this.getItems({ ...query.queryOptions }, useCache);
+ const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
return {
- Items: watchlistItems,
- TotalRecordCount: watchlistItems.length
+ Items: items,
+ TotalRecordCount: data?.TotalRecordCount ?? items.length
};
},
+
+ /**
+ * Progressive watchlist query (single top-level Likes getQuery).
+ * @param {Object} options - Same shape as getWatchlistItems (IncludeItemTypes, Fields, ...)
+ * @param {Object} [cacheOptions]
+ * @param {number} [cacheOptions.ttl=300000]
+ * @param {boolean} [cacheOptions.forceRefresh=false]
+ * @returns {Promise} Progressive result: { data, isStale, isStalePromise, ensureData, dataPromise, urls }
+ */
+ getWatchlistQuery: async function(options = {}, cacheOptions = {}) {
+ ensureApiClient();
+
+ let ttl = 300000;
+ if (Number(cacheOptions.ttl) >= 0) {
+ ttl = Number(cacheOptions.ttl);
+ }
+ const forceRefresh = !!cacheOptions.forceRefresh;
+ const userId = ApiClient.getCurrentUserId();
+ const serverUrl = ApiClient.serverAddress();
+ const query = buildWatchlistQueryDef(options);
+ const url = this.buildQueryFromSection(query, userId, serverUrl);
+ if (typeof url !== 'string') {
+ throw new Error('Invalid watchlist query URL');
+ }
+
+ const queryResult = this.getQuery(url, {
+ useCache: true,
+ ttl,
+ forceRefresh
+ });
+
+ const normalize = (data) => {
+ const items = Array.isArray(data?.Items) ? data.Items : (Array.isArray(data) ? data : []);
+ return { Items: items, TotalRecordCount: data?.TotalRecordCount ?? items.length };
+ };
+
+ let mappedDataPromise = null;
+ const ensureData = () => {
+ if (!mappedDataPromise) {
+ const raw = typeof queryResult.ensureData === 'function'
+ ? queryResult.ensureData()
+ : queryResult.dataPromise;
+ mappedDataPromise = Promise.resolve(raw).then(normalize);
+ }
+ return mappedDataPromise;
+ };
+
+ const result = {
+ data: { Items: [] },
+ isStale: true,
+ isStalePromise: queryResult.isStalePromise || Promise.resolve(false),
+ ensureData,
+ urls: [url]
+ };
+ Object.defineProperty(result, 'dataPromise', {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return ensureData();
+ }
+ });
+
+ return result;
+ },
+
+ /**
+ * Invalidate cached watchlist query URL for the given IncludeItemTypes options.
+ * @param {Object} [options]
+ */
+ invalidateWatchlistQueries: async function(options = {}) {
+ ensureApiClient();
+ const query = buildWatchlistQueryDef(options);
+ const userId = ApiClient.getCurrentUserId();
+ const serverUrl = ApiClient.serverAddress();
+ const url = this.buildQueryFromSection(query, userId, serverUrl);
+ if (typeof url === 'string') {
+ this.invalidateCache(url);
+ }
+ },
/**
* Get items from Jellyfin API with optional caching
@@ -529,6 +1022,319 @@
LOG('Queue updated successfully');
},
+ /**
+ * Lookup a genre ID from the local movie-genres catalog (localStorage / memory).
+ * Never awaits network — schedules a background sync when missing or older than 24h.
+ * @param {string} genreName - Name of the genre to find
+ * @returns {Promise} - Genre ID or null if not found / not yet cached
+ */
+ getGenreId: async function(genreName) {
+ if (!genreName) return null;
+
+ hydrateMovieGenresFromStorage();
+ const empty = !cachedMovieGenres || cachedMovieGenres.length === 0;
+ if (empty || isMovieGenresSyncDue()) {
+ ensureGenreSyncScheduled();
+ }
+ if (!cachedMovieGenres || cachedMovieGenres.length === 0) return null;
+
+ const needle = String(genreName).toLowerCase();
+ const genre = cachedMovieGenres.find((g) => g?.Name && g.Name.toLowerCase() === needle);
+ return genre ? genre.Id : null;
+ },
+
+ /**
+ * Subscribe to movie-genres catalog sync completion (localStorage + memory refreshed).
+ * @param {Function} callback
+ * @returns {Function} unsubscribe
+ */
+ onMovieGenresSynced: function(callback) {
+ if (typeof callback !== 'function') return () => {};
+ movieGenresSyncedListeners.add(callback);
+ return () => movieGenresSyncedListeners.delete(callback);
+ },
+
+ /**
+ * Build query URL from section query configuration
+ * Handles minAge/maxAge conversion to MinPremiereDate/MaxPremiereDate
+ * @param {Object} query - Query object with optional path, dataSource, minAge, maxAge, and queryOptions
+ * @param {string} userId - User ID
+ * @param {string} serverUrl - Server URL
+ * @returns {string|Object} - Query URL string or dataSource marker object
+ */
+ buildQueryFromSection: function(query, userId, serverUrl, isSpotlight = false, options = {}) {
+ ensureApiClient();
+
+ const sectionType = options.sectionType || '';
+ const queryOptions = { ...(query.queryOptions || {}) };
+
+ // Convert minAge/maxAge to premiere date range
+ if (query.minAge !== undefined || query.maxAge !== undefined) {
+ const now = new Date();
+
+ if (query.maxAge !== undefined) {
+ const minDate = new Date(now);
+ minDate.setDate(minDate.getDate() - query.maxAge);
+ queryOptions.MinPremiereDate = minDate.toISOString().split('T')[0];
+ }
+
+ if (query.minAge !== undefined) {
+ const maxDate = new Date(now);
+ maxDate.setDate(maxDate.getDate() - query.minAge);
+ queryOptions.MaxPremiereDate = maxDate.toISOString().split('T')[0];
+ }
+ }
+
+ const buildContext = { isSpotlight, sectionType };
+
+ if (query.path) {
+ return this.buildCustomEndpoint(query.path, queryOptions, userId, serverUrl, buildContext);
+ }
+
+ if (query.dataSource) {
+ return {
+ dataSource: query.dataSource,
+ options: queryOptions,
+ dataSourceOptions: query.dataSourceOptions || {}
+ };
+ }
+
+ return this.buildStandardQuery(queryOptions, userId, serverUrl, buildContext);
+ },
+
+ /**
+ * Build standard /Items query URL
+ * @param {Object} queryOptions - Query options
+ * @param {string} userId - User ID
+ * @param {string} serverUrl - Server URL
+ * @param {{ isSpotlight?: boolean, sectionType?: string }} context
+ * @returns {string} - Query URL
+ */
+ buildStandardQuery: function(queryOptions, userId, serverUrl, context = {}) {
+ if (context === true || context === false) {
+ context = { isSpotlight: context === true };
+ }
+ const params = new URLSearchParams({
+ Recursive: 'true',
+ Fields: resolveQueryFields(queryOptions, context),
+ });
+
+ applyQueryOptionsToParams(params, queryOptions, { skipFields: true });
+
+ return userId ? `${serverUrl}/Users/${userId}/Items?${params.toString()}` : `${serverUrl}/Items?${params.toString()}`;
+ },
+
+ /**
+ * Build custom endpoint URL
+ * @param {string} path - Custom endpoint path (e.g., '/Shows/NextUp')
+ * @param {Object} queryOptions - Query options
+ * @param {string} userId - User ID
+ * @param {string} serverUrl - Server URL
+ * @param {{ isSpotlight?: boolean, sectionType?: string }} context
+ * @returns {string} - Query URL
+ */
+ buildCustomEndpoint: function(path, queryOptions, userId, serverUrl, context = {}) {
+ if (context === true || context === false) {
+ context = { isSpotlight: context === true };
+ }
+ const params = new URLSearchParams({
+ UserId: userId,
+ Fields: resolveQueryFields(queryOptions, context),
+ });
+
+ applyQueryOptionsToParams(params, queryOptions, { skipFields: true });
+
+ if (path === '/Shows/NextUp') {
+ // If NextUpDateCutoff is not set, set it to the current date
+ if (!params.has('NextUpDateCutoff') && queryOptions.NextUpDateCutoff === undefined) {
+ params.set('NextUpDateCutoff', getNextUpDateCutoff(userId));
+ }
+
+ // If EnableRewatching is not set, set it to the current value
+ if (!params.has('EnableRewatching') && queryOptions.EnableRewatching === undefined) {
+ params.set('EnableRewatching', getEnableRewatchingInNextUp(userId) ? 'true' : 'false');
+ }
+
+ // If EnableResumable is not set, set it to false
+ if (!params.has('EnableResumable') && queryOptions.EnableResumable === undefined) {
+ params.set('EnableResumable', 'false');
+ }
+ }
+
+ if (!params.has('EnableTotalRecordCount')) {
+ params.set('EnableTotalRecordCount', 'false');
+ }
+
+ return `${serverUrl}${path}?${params.toString()}`;
+ },
+
+ /**
+ * Resolve args for a cache dataSource method.
+ * Prefers explicit dataSourceOptions; falls back to legacy ItemType/MinCount in queryOptions.
+ */
+ resolveDataSourceMethodArgs: function(dataSourceOptions, queryOptions) {
+ if (dataSourceOptions && typeof dataSourceOptions === 'object') {
+ const keys = Object.keys(dataSourceOptions);
+ if (keys.length) return { ...dataSourceOptions };
+ }
+ const qo = queryOptions || {};
+ const out = {};
+ if (qo.ItemType != null && qo.ItemType !== '') out.ItemType = qo.ItemType;
+ else if (qo.itemType != null && qo.itemType !== '') out.ItemType = qo.itemType;
+ if (qo.MinCount != null && qo.MinCount !== '') out.MinCount = qo.MinCount;
+ else if (qo.minCount != null && qo.minCount !== '') out.MinCount = qo.minCount;
+ else if (qo.minimumSeries != null && qo.minimumSeries !== '') out.minimumSeries = qo.minimumSeries;
+ return out;
+ },
+
+ /**
+ * Fetch from data source (cache-based)
+ * @param {string} dataSource - Data source in format "CacheName.methodName"
+ * @param {Object} queryOptions - Post-processing options (SortBy, SortOrder, Limit, etc.)
+ * @param {boolean} [useCache=true]
+ * @param {Object} [dataSourceOptions] - Args passed to the cache method (e.g. ItemType, MinCount)
+ * @returns {Object} - Object with data, dataPromise, and isStalePromise
+ */
+ fetchFromDataSource: async function(dataSource, queryOptions, useCache = true, dataSourceOptions) {
+ const [cacheName, methodName] = dataSource.split('.');
+ const cache = window[cacheName];
+ const options = queryOptions || {};
+ const methodArgs = this.resolveDataSourceMethodArgs(dataSourceOptions, options);
+
+ if (!cache || !cache[methodName]) {
+ WARN(`Data source not available: ${dataSource}`);
+ return {
+ data: { Items: [] },
+ dataPromise: Promise.resolve({ Items: [] }),
+ isStalePromise: Promise.resolve(false)
+ };
+ }
+
+ try {
+ let dataPromise = Promise.resolve(cache[methodName](methodArgs));
+ dataPromise = dataPromise.then(data => {
+ if (!Array.isArray(data)) {
+ data = [];
+ }
+
+ // Apply options (sorting, limits, etc.)
+ if (options.SortBy === 'Random') {
+ data = data.sort(() => Math.random() - 0.5);
+ } else if (options.SortBy) {
+ data = data.sort((a, b) => {
+ const aVal = a[options.SortBy];
+ const bVal = b[options.SortBy];
+ if (aVal === bVal) return 0;
+ const comparison = aVal > bVal ? 1 : -1;
+ return options.SortOrder === 'Descending' ? -comparison : comparison;
+ });
+ }
+
+ if (options.Limit) {
+ data = data.slice(0, options.Limit);
+ }
+ return {
+ Items: data
+ };
+ });
+
+ if (!useCache) {
+ return await dataPromise;
+ }
+
+ return {
+ data: { Items: [] },
+ dataPromise: dataPromise,
+ isStalePromise: Promise.resolve(false)
+ };
+ } catch (error) {
+ ERR(`Failed to fetch from ${dataSource}:`, error);
+ return {
+ data: { Items: [] },
+ dataPromise: Promise.resolve({ Items: [] }),
+ isStalePromise: Promise.resolve(false)
+ };
+ }
+ },
+
+ /**
+ * Merge results from multiple queries
+ * @param {Array} results - Array of query result objects
+ * @param {Object} sectionConfig - Section configuration object
+ * @returns {Object} - Merged result object with config, result.data, result.dataPromise, result.isStalePromise
+ */
+ mergeMultiQueryResults: function(results, sectionConfig) {
+ // Combine all items from all queries
+ const allItems = [];
+ const itemsByQuery = [];
+
+ results.forEach(result => {
+ if (result.data?.Items) {
+ allItems.push(...result.data.Items);
+ itemsByQuery.push(result.data.Items);
+ }
+ });
+
+ // Sort merged items if sortBy specified
+ /* if (sectionConfig.sortBy) {
+ allItems.sort((a, b) => {
+ const aVal = a[sectionConfig.sortBy] || (a.UserData?.[sectionConfig.sortBy] ? new Date(a.UserData[sectionConfig.sortBy]).getTime() : 0);
+ const bVal = b[sectionConfig.sortBy] || (b.UserData?.[sectionConfig.sortBy] ? new Date(b.UserData[sectionConfig.sortBy]).getTime() : 0);
+
+ // Handle DatePlayed specifically for merged Continue/NextUp
+ if (sectionConfig.sortBy === 'DatePlayed') {
+ const aDate = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0;
+ const bDate = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0;
+ const comparison = aDate > bDate ? 1 : aDate < bDate ? -1 : 0;
+ return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison;
+ }
+
+ const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
+ return sectionConfig.sortOrder === 'Descending' ? -comparison : comparison;
+ });
+ } */
+
+ let dataPromise = null;
+ const ensureData = function() {
+ if (!dataPromise) {
+ dataPromise = Promise.all(results.map((r) => (
+ typeof r.ensureData === 'function' ? r.ensureData() : r.dataPromise
+ ))).then((allData) => {
+ const itemsByFreshQuery = [];
+ allData.forEach((data) => {
+ if (data?.Items) {
+ itemsByFreshQuery.push(data.Items);
+ }
+ });
+ return window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByFreshQuery);
+ });
+ }
+ return dataPromise;
+ };
+
+ const mergedResult = {
+ data: { Items: window.cardBuilder.postProcessItemsByQuery(sectionConfig, itemsByQuery) },
+ isStale: results.some((r) => r.isStale),
+ isStalePromise: Promise.all(results.map(r => r.isStalePromise || Promise.resolve(false)))
+ .then(staleFlags => staleFlags.some(s => s)),
+ ensureData
+ };
+ Object.defineProperty(mergedResult, 'dataPromise', {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return ensureData();
+ }
+ });
+
+ return {
+ config: sectionConfig,
+ result: mergedResult
+ };
+ },
+
+ isAdmin: isAdmin,
+
/**
* Get the list of plugins from the server
* @returns {Promise} - Array of plugin objects
@@ -550,12 +1356,168 @@
return await response.json();
},
-
- getAuthHeader,
};
-
+
+ const GENRE_TTL = 24 * 60 * 60 * 1000; // 24 hours
+ const GENRE_SETTLE_DELAY_MS = 1500;
+ let cachedMovieGenres = null;
+ let movieGenresLastSyncedAt = null;
+ let genreSyncScheduledPromise = null;
+ let genreSyncInFlightPromise = null;
+ const movieGenresSyncedListeners = new Set();
+
+ function normalizeServerAddressForStorage(serverAddress) {
+ if (!serverAddress) return '';
+ return String(serverAddress).replace(/\/+$/, '');
+ }
+
+ function getMovieGenresStorageKey() {
+ try {
+ const serverAddress = normalizeServerAddressForStorage(
+ (typeof ApiClient !== 'undefined' && ApiClient?.serverAddress?.()) || ''
+ );
+ if (!serverAddress) return null;
+ return `kefinTweaks_movieGenres_${encodeURIComponent(serverAddress)}`;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function readMovieGenresFromStorage() {
+ const key = getMovieGenresStorageKey();
+ if (!key) return null;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (!parsed || !Array.isArray(parsed.items)) return null;
+ return {
+ items: parsed.items.filter((g) => g && g.Id && g.Name),
+ lastSyncedAt: Number(parsed.lastSyncedAt) || 0
+ };
+ } catch (err) {
+ WARN('Failed to read movie genres from localStorage:', err);
+ return null;
+ }
+ }
+
+ function writeMovieGenresToStorage(items, lastSyncedAt) {
+ const key = getMovieGenresStorageKey();
+ if (!key) return;
+ try {
+ localStorage.setItem(key, JSON.stringify({
+ items: (items || []).map((g) => ({ Id: g.Id, Name: g.Name })),
+ lastSyncedAt: lastSyncedAt || Date.now()
+ }));
+ } catch (err) {
+ WARN('Failed to write movie genres to localStorage:', err);
+ }
+ }
+
+ function hydrateMovieGenresFromStorage() {
+ if (cachedMovieGenres && cachedMovieGenres.length > 0) return true;
+ const stored = readMovieGenresFromStorage();
+ if (!stored?.items?.length) return false;
+ cachedMovieGenres = stored.items;
+ movieGenresLastSyncedAt = stored.lastSyncedAt || movieGenresLastSyncedAt;
+ LOG(`Hydrated movie genres from localStorage: ${cachedMovieGenres.length} items`);
+ return true;
+ }
+
+ function isMovieGenresSyncDue() {
+ const stored = readMovieGenresFromStorage();
+ const last = movieGenresLastSyncedAt || stored?.lastSyncedAt || 0;
+ const hasItems = (cachedMovieGenres && cachedMovieGenres.length > 0) || (stored?.items?.length > 0);
+ if (!hasItems) return true;
+ if (!last) return true;
+ return (Date.now() - last) >= GENRE_TTL;
+ }
+
+ function waitForDocumentComplete() {
+ if (document.readyState === 'complete') return Promise.resolve();
+ return new Promise((resolve) => {
+ window.addEventListener('load', () => resolve(), { once: true });
+ });
+ }
+
+ function notifyMovieGenresSynced() {
+ try {
+ window.dispatchEvent(new CustomEvent('kefinTweaksMovieGenresSynced'));
+ } catch (_) { /* ignore */ }
+ movieGenresSyncedListeners.forEach((cb) => {
+ try {
+ cb();
+ } catch (err) {
+ WARN('onMovieGenresSynced listener failed:', err);
+ }
+ });
+ }
+
+ async function runMovieGenresSync() {
+ if (genreSyncInFlightPromise) return genreSyncInFlightPromise;
+
+ genreSyncInFlightPromise = (async () => {
+ try {
+ if (window.userHelper?.waitForLogin) {
+ await window.userHelper.waitForLogin();
+ }
+ ensureApiClient();
+ const url = `${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`;
+ const genres = await apiHelper.getData(url, true, GENRE_TTL);
+ const items = Array.isArray(genres?.Items) ? genres.Items : [];
+ cachedMovieGenres = items;
+ movieGenresLastSyncedAt = Date.now();
+ writeMovieGenresToStorage(items, movieGenresLastSyncedAt);
+ LOG(`Synced movie genres: ${items.length} items`);
+ notifyMovieGenresSynced();
+ } catch (err) {
+ ERR('Failed to sync movie genres:', err);
+ } finally {
+ genreSyncInFlightPromise = null;
+ }
+ })();
+
+ return genreSyncInFlightPromise;
+ }
+
+ /**
+ * Deduped: after settle, sync Genres when cache is missing or older than 24h.
+ */
+ function ensureGenreSyncScheduled() {
+ hydrateMovieGenresFromStorage();
+ if (!isMovieGenresSyncDue()) {
+ return genreSyncScheduledPromise || Promise.resolve();
+ }
+ if (genreSyncScheduledPromise) return genreSyncScheduledPromise;
+
+ genreSyncScheduledPromise = (async () => {
+ try {
+ if (window.userHelper?.waitForLogin) {
+ await window.userHelper.waitForLogin();
+ }
+ await waitForDocumentComplete();
+ await new Promise((resolve) => setTimeout(resolve, GENRE_SETTLE_DELAY_MS));
+ hydrateMovieGenresFromStorage();
+ if (!isMovieGenresSyncDue()) return;
+ await runMovieGenresSync();
+ } catch (err) {
+ ERR('Failed to schedule movie genres sync:', err);
+ } finally {
+ genreSyncScheduledPromise = null;
+ }
+ })();
+
+ return genreSyncScheduledPromise;
+ }
+
+ // Shared with watchlist progressive sections / invalidate (same Fields → same cache URL)
+ apiHelper.WATCHLIST_PROGRESSIVE_FIELDS = WATCHLIST_PROGRESSIVE_FIELDS;
+
// Expose apiHelper to global window object
window.apiHelper = apiHelper;
-
+
+ // Daily / empty-cache sync without waiting for a getGenreId caller
+ ensureGenreSyncScheduled();
+
console.log('[KefinTweaks APIHelper] Module loaded and available at window.apiHelper');
})();
\ No newline at end of file
diff --git a/scripts/backdropLeakFix.js b/scripts/backdropLeakFix.js
index 752f216..d5d673c 100644
--- a/scripts/backdropLeakFix.js
+++ b/scripts/backdropLeakFix.js
@@ -9,15 +9,11 @@
LOG('Script loaded - JS Injector mode');
- // Step 1: watch for .backdropContainer to appear
- const waitForContainer = new MutationObserver(() => {
- const container = document.querySelector('.backdropContainer');
- if (!container) return;
+ function attachPruneObserver(container) {
+ if (!container || container.dataset.kefinBackdropPrune === 'true') return;
+ container.dataset.kefinBackdropPrune = 'true';
LOG('Found .backdropContainer, attaching prune observer');
- waitForContainer.disconnect();
-
- // Step 2: prune children as needed
const pruneObserver = new MutationObserver(() => {
LOG('Mutation detected, checking children count:', container.children.length);
while (container.children.length > 2) {
@@ -25,16 +21,35 @@
container.removeChild(container.firstElementChild);
}
});
-
pruneObserver.observe(container, { childList: true });
LOG('Prune observer is now active');
- });
+ }
- waitForContainer.observe(document.body, {
- childList: true,
- subtree: true
- });
+ function startWait() {
+ const utils = window.KefinTweaksUtils;
+ const existing = document.querySelector('.backdropContainer');
+ if (existing) {
+ attachPruneObserver(existing);
+ return;
+ }
+ if (utils?.waitForElement) {
+ utils.waitForElement('.backdropContainer', { timeout: 60000, quiet: true }).then((container) => {
+ if (container) attachPruneObserver(container);
+ else WARN_MISSING();
+ });
+ return;
+ }
+
+ // Fallback if utils not ready yet
+ setTimeout(startWait, 500);
+ }
+
+ function WARN_MISSING() {
+ console.warn('[KefinTweaks BackdropLeakFix] .backdropContainer not found within timeout');
+ }
+
+ startWait();
LOG('Waiting for .backdropContainer to appear...');
LOG('Backdrop leak fix functionality initialized');
})();
diff --git a/scripts/breadcrumbNav.css b/scripts/breadcrumbNav.css
index f7f23fb..5267e7c 100644
--- a/scripts/breadcrumbNav.css
+++ b/scripts/breadcrumbNav.css
@@ -1,103 +1,149 @@
/* KefinTweaks Breadcrumbs Styles */
.kefinTweaks-breadcrumbs-wrapper {
- display: none;
+ display: none;
}
@media (min-width: 768px) {
- .kefinTweaks-breadcrumbs-wrapper {
- display: block;
- margin-left: -.5em;
- transition: background 0.2s ease;
- }
+ .kefinTweaks-breadcrumbs-wrapper {
+ display: block;
+ margin-left: -0.5em;
+ transition: background 0.2s ease;
+ }
- .kefinTweaks-breadcrumbs-wrapper:hover {
- background: rgba(0, 0, 0, 0.5);
- border-radius: 0.5em;
- }
+ .kefinTweaks-breadcrumbs-wrapper:hover {
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 0.5em;
+ }
- .kefinTweaks-breadcrumbs {
- color: rgba(255, 255, 255, 0.8);
- font-size: 1.17em;
- font-family: inherit;
- pointer-events: auto;
- position: relative;
- padding: 8px 12px;
- border-radius: 4px;
- word-wrap: break-word;
- display: inline-block;
- }
+ .MuiToolbar-root .kefinTweaks-breadcrumbs-wrapper:hover {
+ background: transparent;
+ border-radius: 0;
+ }
- .kefinTweaks-popover-container {
- position: absolute;
- top: 100%;
- left: 0;
- z-index: 1001;
- }
+ .kefinTweaks-breadcrumbs {
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 1.17em;
+ font-family: inherit;
+ pointer-events: auto;
+ position: relative;
+ padding: 8px 12px;
+ border-radius: 4px;
+ word-wrap: break-word;
+ display: inline-block;
+ }
- .kefinTweaks-breadcrumbs span {
- display: inline;
- }
+ .MuiToolbar-root .kefinTweaks-breadcrumbs,
+ .kefinTweaks-breadcrumbs-wrapper--modern .kefinTweaks-breadcrumbs {
+ font-size: 1em;
+ padding: 0px 12px 8px;
+ }
- .kefinTweaks-breadcrumbs span:hover {
- color: rgba(255, 255, 255, 1);
- }
+ .MuiToolbar-root .kefinTweaks-popover {
+ margin-top: 20px;
+ }
- /* Hover effects for clickable breadcrumbs only */
- .kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
- text-decoration: underline !important;
- }
+ .kefinTweaks-popover-container {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 1001;
+ pointer-events: all;
+ }
- /* Popover styles for seasons and albums */
- .kefinTweaks-popover {
- position: relative;
- background-color: rgba(29, 38, 53, 0.95);
- border: 1px solid rgba(255, 255, 255, 0.2);
- border-radius: 4px;
- padding: 0;
- min-width: 200px;
- max-height: 300px;
- overflow-y: auto;
- z-index: 1001;
- display: block;
- margin-top: 8px;
- }
+ /* When breadcrumbs are at bottom of page, open popover above */
+ .kefinTweaks-popover-container.kefinTweaks-popover-container--above {
+ top: auto;
+ bottom: 100%;
+ }
- .kefinTweaks-popover-item {
- padding: 8px 16px 8px 24px;
- cursor: pointer;
- color: rgba(255, 255, 255, 0.8);
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- position: relative;
- }
+ .kefinTweaks-popover.kefinTweaks-popover--above {
+ margin-top: 0;
+ margin-bottom: 8px;
+ }
- .kefinTweaks-popover-item:hover {
- background-color: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 1);
- }
+ .kefinTweaks-breadcrumbs span {
+ display: inline;
+ }
- .kefinTweaks-popover-item:last-child {
- border-bottom: none;
- }
+ .kefinTweaks-breadcrumbs span:hover {
+ color: rgba(255, 255, 255, 1);
+ }
- .kefinTweaks-popover-item.selected {
- color: rgba(255, 255, 255, 1);
- font-weight: bold;
- }
+ /* Hover effects for clickable breadcrumbs only */
+ .kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover {
+ text-decoration: underline !important;
+ }
- .kefinTweaks-popover-item.selected::before {
- content: '>';
- position: absolute;
- left: 8px;
- top: calc(50% - 1px);
- transform: translateY(-50%);
- font-weight: bold;
- }
+ /* Popover styles for seasons and albums */
+ .kefinTweaks-popover {
+ position: relative;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ padding: 0;
+ max-height: 300px;
+ overflow-y: auto !important;
+ z-index: 1001;
+ display: block;
+ margin-top: 8px;
+ }
- .skinHeader {
- contain: none;
- }
+ .kefinTweaks-popover-item {
+ padding: 8px 16px 8px 24px;
+ cursor: pointer;
+ color: rgba(255, 255, 255, 0.8);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ position: relative;
+ }
- .headerLeft {
- overflow: visible;
- }
-}
\ No newline at end of file
+ .kefinTweaks-popover-item:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 1);
+ }
+
+ .kefinTweaks-popover-item:last-child {
+ border-bottom: none;
+ }
+
+ .kefinTweaks-popover-item.selected {
+ color: rgba(255, 255, 255, 1);
+ font-weight: bold;
+ }
+
+ .kefinTweaks-popover-item.selected::before {
+ content: ">";
+ position: absolute;
+ left: 8px;
+ top: calc(50% - 1px);
+ transform: translateY(-50%);
+ font-weight: bold;
+ }
+
+ .skinHeader {
+ contain: none;
+ }
+
+ .headerLeft {
+ overflow: visible;
+ }
+}
+
+.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) {
+ padding: 0 !important;
+ overflow-y: auto !important;
+ backdrop-filter: blur(50px);
+ margin-top: 0 !important;
+}
+.kefinTweaks-popover.itemDetailsGroup:has(.detailsGroupItem:not(.hide)) .kefinTweaks-popover-item.detailsGroupItem {
+ padding: 0.5em 2em;
+ margin: 0 !important;
+ border-radius: 0 !important;
+ width: initial !important;
+ max-width: initial !important;
+ white-space: nowrap;
+}
+header > .MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) {
+ flex-wrap: wrap;
+}
+header > .MuiToolbar-root:has(.kefinTweaks-breadcrumbs-wrapper) #kefinTweaks-breadcrumbs-wrapper {
+ width: 100%;
+}
diff --git a/scripts/breadcrumbs.js b/scripts/breadcrumbs.js
index 2088317..2fc3bc4 100644
--- a/scripts/breadcrumbs.js
+++ b/scripts/breadcrumbs.js
@@ -3,7 +3,7 @@
// Supports: Movie, Series, Season, Episode, MusicArtist, MusicAlbum, Audio
// Requires: utils.js module to be loaded before this script
-(function() {
+(function () {
'use strict';
console.log('[KefinTweaks Breadcrumbs] Initializing...');
@@ -72,18 +72,26 @@
async function getTargetBreadcrumbStructure(item) {
const structure = {
elements: [],
- item: item
+ item: item,
};
-
+
// Don't append .html for server versions 10.11 and above
- const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html'
-
+ const urlSuffix = ApiClient._appVersion.split('.')[1] > 10 ? '' : '.html';
+
let queryParams = '';
if (item.ParentId) {
// Get the top parent item from the ancestors with a Type = CollectionFolder
- const ancestors = await ApiClient.getAncestorItems(item.Id);
- const collectionFolder = ancestors.find(ancestor => ancestor.Type === 'CollectionFolder');
- const userRootFolder = ancestors.find(ancestor => ancestor.Type === 'UserRootFolder');
+ // Use apiHelper.getQuery rather than getAncestorItems so we can use the cached data
+ const ancestorsUrl = `${ApiClient.serverAddress()}/Items/${item.Id}/Ancestors`;
+ const queryResult = await window.apiHelper.getQuery(ancestorsUrl, { useCache: true });
+ let ancestors = queryResult.data;
+
+ if (!ancestors || !Array.isArray(ancestors)) {
+ ancestors = await queryResult.dataPromise;
+ }
+
+ const collectionFolder = ancestors.find((ancestor) => ancestor.Type === 'CollectionFolder');
+ const userRootFolder = ancestors.find((ancestor) => ancestor.Type === 'UserRootFolder');
if (collectionFolder) {
queryParams = `?topParentId=${collectionFolder.Id}&serverId=${ApiClient.serverId()}`;
} else if (userRootFolder) {
@@ -91,21 +99,20 @@
} else {
queryParams = `?serverId=${ApiClient.serverId()}`;
}
- }
- else if (ApiClient.serverId()) {
+ } else if (ApiClient.serverId()) {
queryParams = `?serverId=${ApiClient.serverId()}`;
}
if (item.Type === 'Movie') {
structure.elements = [
{ text: 'Movies', url: `#/movies${urlSuffix}${queryParams}&tab=0`, clickable: true },
- { text: item.Name, url: null, clickable: false }
+ { text: item.Name, url: null, clickable: false },
];
} else if (item.Type === 'Series') {
structure.elements = [
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: item.Name, url: null, clickable: false },
- { text: 'All Seasons', url: null, clickable: true, popover: true }
+ { text: 'All Seasons', url: null, clickable: true, popover: true },
];
} else if (item.Type === 'Season') {
// Get series details for the series name
@@ -114,8 +121,12 @@
structure.elements = [
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true },
- { text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
- { text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true }
+ {
+ text: seriesName,
+ url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`,
+ clickable: true,
+ },
+ { text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true },
];
} else if (item.Type === 'Episode') {
// Get series details for the series name
@@ -124,26 +135,36 @@
// Get season details for the season name
const seasonDetails = await getItemDetails(item.ParentId);
- const seasonName = seasonDetails ? (seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`) : `Season ${item.ParentIndexNumber}`;
+ const seasonName = seasonDetails
+ ? seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`
+ : `Season ${item.ParentIndexNumber}`;
structure.elements = [
{ text: 'Shows', url: `#/tv${urlSuffix}${queryParams}&tab=0`, clickable: true },
- { text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true },
+ {
+ text: seriesName,
+ url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`,
+ clickable: true,
+ },
{ text: seasonName, url: null, clickable: true, popover: true },
- { text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false }
+ { text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false },
];
} else if (item.Type === 'MusicArtist') {
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true },
{ text: item.Name, url: null, clickable: false },
- { text: 'All Albums', url: null, clickable: true, popover: true }
+ { text: 'All Albums', url: null, clickable: true, popover: true },
];
} else if (item.Type === 'MusicAlbum') {
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true },
- { text: item.AlbumArtist, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true },
+ {
+ text: item.AlbumArtist,
+ url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`,
+ clickable: true,
+ },
{ text: item.Name, url: null, clickable: true, popover: true },
- { text: 'All Songs', url: null, clickable: true, popover: true }
+ { text: 'All Songs', url: null, clickable: true, popover: true },
];
} else if (item.Type === 'Audio') {
// Get album details for the album name and artist info
@@ -154,9 +175,13 @@
structure.elements = [
{ text: 'Music', url: `#/music${urlSuffix}${queryParams}&tab=0`, clickable: true },
- { text: artistName, url: artistId ? `${ApiClient._serverAddress}/web/#/details?id=${artistId}&serverId=${ApiClient.serverId()}` : null, clickable: !!artistId },
+ {
+ text: artistName,
+ url: artistId ? `${ApiClient._serverAddress}/web/#/details?id=${artistId}&serverId=${ApiClient.serverId()}` : null,
+ clickable: !!artistId,
+ },
{ text: albumName, url: null, clickable: true, popover: true },
- { text: item.Name, url: null, clickable: false }
+ { text: item.Name, url: null, clickable: false },
];
}
@@ -168,8 +193,8 @@
const differences = [];
// Check if we have the right number of elements
- const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
- child.classList.contains('kefinTweaks-breadcrumb-element')
+ const existingElements = Array.from(breadcrumbContainer.children).filter((child) =>
+ child.classList.contains('kefinTweaks-breadcrumb-element'),
);
log('Comparing breadcrumbs - existing:', existingElements.length, 'target:', target.elements.length);
@@ -192,14 +217,14 @@
const targetClickable = targetElement.clickable;
// Check if popover functionality needs to be updated
- const needsPopoverUpdate = targetElement.popover && (
- existingText !== targetElement.text ||
- existingClickable !== targetClickable ||
- // Force update for "All Seasons", "All Albums", and "All Songs" elements since the underlying data changes
- targetElement.text === 'All Seasons' ||
- targetElement.text === 'All Albums' ||
- targetElement.text === 'All Songs'
- );
+ const needsPopoverUpdate =
+ targetElement.popover &&
+ (existingText !== targetElement.text ||
+ existingClickable !== targetClickable ||
+ // Force update for "All Seasons", "All Albums", and "All Songs" elements since the underlying data changes
+ targetElement.text === 'All Seasons' ||
+ targetElement.text === 'All Albums' ||
+ targetElement.text === 'All Songs');
if (existingText !== targetElement.text || existingClickable !== targetClickable || needsPopoverUpdate) {
differences.push({ action: 'update', index: i, element: targetElement });
@@ -220,8 +245,8 @@
log('Applying breadcrumb differences:', differences.length, 'changes needed');
// Get existing elements
- const existingElements = Array.from(breadcrumbContainer.children).filter(child =>
- child.classList.contains('kefinTweaks-breadcrumb-element')
+ const existingElements = Array.from(breadcrumbContainer.children).filter((child) =>
+ child.classList.contains('kefinTweaks-breadcrumb-element'),
);
// Process differences in reverse order to maintain indices
@@ -248,7 +273,6 @@
}
log('Added new breadcrumb element:', diff.element.text);
-
} else if (diff.action === 'remove') {
// Remove element and its separator
const elementToRemove = existingElements[diff.index];
@@ -260,7 +284,6 @@
breadcrumbContainer.removeChild(elementToRemove);
log('Removed breadcrumb element at index:', diff.index);
}
-
} else if (diff.action === 'update') {
// Update existing element
const elementToUpdate = existingElements[diff.index];
@@ -378,7 +401,7 @@
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`;
});
} else {
- const currentSeason = seasons.find(season => season.Id === item.ParentId);
+ const currentSeason = seasons.find((season) => season.Id === item.ParentId);
element.addEventListener('click', (e) => {
e.stopPropagation();
const popover = createPopover(seasons, currentSeason, (selectedSeason) => {
@@ -393,14 +416,12 @@
let albumArtists = item.AlbumArtists;
if (!albumArtists || !Array.isArray(albumArtists) || albumArtists.length === 0) {
const albumDetails = await getItemDetails(item.Id);
- albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : [];
+ albumArtists = albumDetails && albumDetails.AlbumArtists ? albumDetails.AlbumArtists : [];
}
-
- const artistIds = (albumArtists || []).map(a => a.Id).filter(Boolean);
- const albums = artistIds.length > 0
- ? await getAlbumsByArtistIds(artistIds)
- : await getAlbumsIfNeeded(item.ParentId);
-
+
+ const artistIds = (albumArtists || []).map((a) => a.Id).filter(Boolean);
+ const albums = artistIds.length > 0 ? await getAlbumsByArtistIds(artistIds) : await getAlbumsIfNeeded(item.ParentId);
+
if (albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
@@ -417,12 +438,13 @@
} else if (item.Type === 'Audio') {
// Audio (Song) page - clicking album name should show albums from all AlbumArtists of the album
const albumDetails = await getItemDetails(item.ParentId);
- const albumArtists = (albumDetails && albumDetails.AlbumArtists) ? albumDetails.AlbumArtists : [];
- const artistIds = albumArtists.map(a => a.Id).filter(Boolean);
- const albums = artistIds.length > 0
- ? await getAlbumsByArtistIds(artistIds)
- : await getAlbumsIfNeeded(albumDetails ? albumDetails.ParentId : null);
-
+ const albumArtists = albumDetails && albumDetails.AlbumArtists ? albumDetails.AlbumArtists : [];
+ const artistIds = albumArtists.map((a) => a.Id).filter(Boolean);
+ const albums =
+ artistIds.length > 0
+ ? await getAlbumsByArtistIds(artistIds)
+ : await getAlbumsIfNeeded(albumDetails ? albumDetails.ParentId : null);
+
if (albums && albums.length === 1) {
element.addEventListener('click', () => {
window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`;
@@ -463,15 +485,30 @@
}
// DOM manipulation
+ function isModernUI() {
+ if (ApiClient._appVersion && ApiClient._appVersion.startsWith('12') && localStorage.getItem('layout')?.length === 0) {
+ return true;
+ }
+
+ return !!document.querySelector('main.MuiBox-root');
+ }
+
+ function resolveBreadcrumbMount() {
+ if (isModernUI()) {
+ return document.querySelector('.MuiToolbar-root');
+ }
+ return document.querySelector('.skinHeader .headerLeft');
+ }
+
function createBreadcrumbContainer() {
if (breadcrumbContainer) {
return breadcrumbContainer;
}
- // Find the header left container
- const headerLeft = document.querySelector('.skinHeader .headerLeft');
- if (!headerLeft) {
- error('Could not find .skinHeader .headerLeft element');
+ const isModern = isModernUI();
+ const mount = resolveBreadcrumbMount();
+ if (!mount) {
+ error(isModern ? 'Could not find .MuiToolbar-root for breadcrumbs' : 'Could not find .skinHeader .headerLeft element');
return null;
}
@@ -479,6 +516,9 @@
const wrapper = document.createElement('div');
wrapper.id = 'kefinTweaks-breadcrumbs-wrapper';
wrapper.className = 'kefinTweaks-breadcrumbs-wrapper';
+ if (isModern) {
+ wrapper.classList.add('kefinTweaks-breadcrumbs-wrapper--modern');
+ }
wrapper.style.cssText = `
display: none;
`;
@@ -501,12 +541,39 @@
wrapper.appendChild(breadcrumbContainer);
wrapper.appendChild(popoverContainer);
- headerLeft.appendChild(wrapper);
+ mount.appendChild(wrapper);
- log('Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft');
+ log(
+ isModern
+ ? 'Created breadcrumb container inside .MuiToolbar-root'
+ : 'Created breadcrumb container with wrapper structure inside .skinHeader .headerLeft',
+ );
return breadcrumbContainer;
}
+ function ensureBreadcrumbContainer(retries = 20) {
+ const existing = createBreadcrumbContainer();
+ if (existing) return Promise.resolve(existing);
+
+ return new Promise((resolve) => {
+ let attempts = 0;
+ const tryMount = () => {
+ const container = createBreadcrumbContainer();
+ if (container) {
+ resolve(container);
+ return;
+ }
+ attempts += 1;
+ if (attempts >= retries) {
+ resolve(null);
+ return;
+ }
+ setTimeout(tryMount, 100);
+ };
+ tryMount();
+ });
+ }
+
function showBreadcrumbs() {
const wrapper = document.getElementById('kefinTweaks-breadcrumbs-wrapper');
const wideScreen = isWideScreen();
@@ -538,7 +605,7 @@
}
closePopover();
}
-
+
function closePopover() {
if (activePopover) {
activePopover.remove();
@@ -548,16 +615,16 @@
function createPopover(items, currentItem, onItemClick) {
const popover = document.createElement('div');
- popover.className = 'kefinTweaks-popover';
+ popover.className = 'kefinTweaks-popover itemDetailsGroup';
popover.style.display = 'block'; // Ensure it's visible
log('Creating popover with', items.length, 'items');
let selectedItemElement = null;
- items.forEach(item => {
+ items.forEach((item) => {
const itemElement = document.createElement('div');
- itemElement.className = 'kefinTweaks-popover-item';
+ itemElement.className = 'kefinTweaks-popover-item detailsGroupItem';
if (currentItem && item.Id === currentItem.Id) {
itemElement.classList.add('selected');
@@ -590,7 +657,7 @@
selectedItemElement.scrollIntoView({
behavior: 'instant',
block: 'nearest',
- inline: 'nearest'
+ inline: 'nearest',
});
log('Auto-scrolled to selected item:', selectedItemElement.textContent);
}, 10);
@@ -610,16 +677,31 @@
const popoverContainer = document.getElementById('kefinTweaks-popover-container');
if (popoverContainer) {
// Calculate the left offset to align with the trigger element
- // Since breadcrumbs are now in the header, we need to calculate relative to the page
const triggerRect = triggerElement.getBoundingClientRect();
const popoverContainerRect = popoverContainer.getBoundingClientRect();
-
- // Calculate offset from popover container to trigger element
const leftOffset = triggerRect.left - popoverContainerRect.left - 22;
popover.style.left = `${leftOffset}px`;
+ // Decide whether to open above or below based on viewport space
+ // When breadcrumbs are at the bottom of the page, open above
+ const viewportHeight = window.innerHeight;
+ const spaceBelow = viewportHeight - triggerRect.bottom;
+ const spaceAbove = triggerRect.top;
+ const showAbove = spaceBelow < spaceAbove;
+
+ popoverContainer.classList.toggle('kefinTweaks-popover-container--above', showAbove);
+ popover.classList.toggle('kefinTweaks-popover--above', showAbove);
+ // Clear inline top when above so CSS bottom: 100% takes effect; restore when below
+ if (showAbove) {
+ popoverContainer.style.top = '';
+ popoverContainer.style.bottom = '100%';
+ } else {
+ popoverContainer.style.top = 'calc(100% - 15px)';
+ popoverContainer.style.bottom = '';
+ }
+
popoverContainer.appendChild(popover);
- log('Popover added to popover container with left offset:', leftOffset);
+ log('Popover added to popover container with left offset:', leftOffset, 'showAbove:', showAbove);
} else {
log('Popover container not found, falling back to trigger element');
triggerElement.style.position = 'relative';
@@ -646,11 +728,17 @@
if (!currentUserId) {
throw new Error('No user ID found');
}
-
+
const url = ApiClient.getUrl(`Users/${currentUserId}/Items/${itemId}`);
- const response = await ApiClient.getJSON(url);
- log('Retrieved item details:', response.Name, response.Type);
- return response;
+ const response = await window.apiHelper.getQuery(url, { useCache: true });
+
+ if (!response.data) {
+ response.data = await response.dataPromise;
+ }
+
+ const item = response.data;
+ log('Retrieved item details:', item.Name, item.Type);
+ return item;
} catch (err) {
console.error('Failed to get item details:', err);
return null;
@@ -659,13 +747,14 @@
async function getSeasons(seriesId) {
try {
- const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`, {
- userId: ApiClient.getCurrentUserId(),
- imageTypeLimit: 1
- });
- const response = await ApiClient.getJSON(url);
- log('Retrieved seasons:', response.Items?.length || 0);
- return response.Items || [];
+ const url = ApiClient.getUrl(`Shows/${seriesId}/Seasons`);
+ const response = await window.apiHelper.getQuery(url, { useCache: true });
+ if (!response.data) {
+ response.data = await response.dataPromise;
+ }
+ const seasons = response.data.Items || response.data || [];
+ log('Retrieved seasons:', seasons.length);
+ return seasons;
} catch (err) {
error('Failed to get seasons:', err);
return [];
@@ -683,11 +772,15 @@
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: artistId,
- SortBy: 'PremiereDate,ProductionYear,Sortname'
+ SortBy: 'PremiereDate,ProductionYear,Sortname',
});
- const response = await ApiClient.getJSON(url);
- log('Retrieved albums:', response.Items?.length || 0);
- return response.Items || [];
+ const response = await window.apiHelper.getQuery(url, { useCache: true });
+ if (!response.data) {
+ response.data = await response.dataPromise;
+ }
+ const albums = response.data.Items || response.data || [];
+ log('Retrieved albums:', albums.length);
+ return albums;
} catch (err) {
error('Failed to get albums:', err);
return [];
@@ -706,10 +799,13 @@
StartIndex: 0,
CollapseBoxSetItems: false,
AlbumArtistIds: idsParam,
- SortBy: 'PremiereDate,ProductionYear,Sortname'
+ SortBy: 'PremiereDate,ProductionYear,Sortname',
});
- const response = await ApiClient.getJSON(url);
- const items = response.Items || [];
+ const response = await window.apiHelper.getQuery(url, { useCache: true });
+ if (!response.data) {
+ response.data = await response.dataPromise;
+ }
+ const items = response.data.Items || response.data || [];
// Deduplicate by Id when multiple artists overlap
const unique = [];
const seen = new Set();
@@ -736,11 +832,15 @@
Fields: 'ParentId',
Limit: 100,
StartIndex: 0,
- SortBy: 'IndexNumber,SortName'
+ SortBy: 'IndexNumber,SortName',
});
- const response = await ApiClient.getJSON(url);
- log('Retrieved songs:', response.Items?.length || 0);
- return response.Items || [];
+ const response = await window.apiHelper.getQuery(url, { useCache: true });
+ if (!response.data) {
+ response.data = await response.dataPromise;
+ }
+ const songs = response.data.Items || response.data || [];
+ log('Retrieved songs:', songs.length);
+ return songs;
} catch (err) {
error('Failed to get songs:', err);
return [];
@@ -755,6 +855,26 @@
return separator;
}
+ // Serialize page-change work so overlapping view callbacks cannot race the DOM.
+ // Latest-wins pending: overwrite the single pending slot; drain runs one at a time.
+ let pageChangePending = undefined; // undefined = empty; null/item = pending job
+ let pageChangeDrain = Promise.resolve();
+
+ function enqueuePageChange(item = null) {
+ pageChangePending = item;
+ pageChangeDrain = pageChangeDrain
+ .then(async () => {
+ if (pageChangePending === undefined) return;
+ const next = pageChangePending;
+ pageChangePending = undefined;
+ await handlePageChange(next);
+ })
+ .catch((err) => {
+ error('Breadcrumb page change queue failed:', err);
+ });
+ return pageChangeDrain;
+ }
+
// Page change handler
// Simplified page change handler
async function handlePageChange(item = null) {
@@ -770,7 +890,7 @@
hideBreadcrumbs();
return;
}
-
+
// Check if the user is logged in
if (!ApiClient._loggedIn) {
clearBreadcrumbs();
@@ -779,7 +899,7 @@
}
// Extract item ID
- if (!item || !item.Id) {
+ if (!item || !item.Id || (item.Type !== 'Series' && item.Type !== 'Season' && item.Type !== 'Episode')) {
clearBreadcrumbs();
hideBreadcrumbs();
return;
@@ -794,7 +914,12 @@
// Determine target breadcrumb structure
const targetStructure = await getTargetBreadcrumbStructure(item);
- log('Target breadcrumb structure:', targetStructure.elements.map(e => e.text));
+ log(
+ 'Target breadcrumb structure:',
+ targetStructure.elements.map((e) => e.text),
+ );
+
+ const breadcrumbContainer = getBreadcrumbContainer();
// Check if we need to create breadcrumbs from scratch
if (!breadcrumbContainer || !currentBreadcrumbs) {
@@ -807,13 +932,16 @@
}
// Update state
- currentBreadcrumbs = targetStructure;
+ // Update the currentBreadcrumbs from the current page
+ if (breadcrumbContainer && breadcrumbContainer.children) {
+ currentBreadcrumbs = Array.from(breadcrumbContainer.children);
+ }
+ //currentBreadcrumbs = targetStructure;
currentItemId = item.Id;
log('About to show breadcrumbs...');
showBreadcrumbs();
log('Breadcrumbs updated successfully');
-
} catch (err) {
error('Error handling page change:', err);
clearBreadcrumbs();
@@ -823,9 +951,13 @@
// Create breadcrumbs from scratch
async function createBreadcrumbsFromScratch(targetStructure) {
- // Create container if it doesn't exist
+ // Create container if it doesn't exist (retry briefly on ModernUI if toolbar not ready)
if (!breadcrumbContainer) {
- breadcrumbContainer = createBreadcrumbContainer();
+ breadcrumbContainer = await ensureBreadcrumbContainer();
+ }
+ if (!breadcrumbContainer) {
+ error('Breadcrumb container unavailable');
+ return;
}
// Build all elements first
@@ -846,11 +978,15 @@
breadcrumbContainer.innerHTML = '';
// Append all elements at once
- elements.forEach(el => breadcrumbContainer.appendChild(el));
+ elements.forEach((el) => breadcrumbContainer.appendChild(el));
log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements');
}
+ function getBreadcrumbContainer() {
+ return document.getElementById('kefinTweaks-breadcrumbs');
+ }
+
// Window resize handler
function handleResize() {
if (currentBreadcrumbs && breadcrumbContainer) {
@@ -871,18 +1007,19 @@
log('Registering breadcrumb handler with KefinTweaksUtils');
// Register handler for all pages (breadcrumbs can appear on any detail page)
- window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => {
- try {
- // Await the item promise to get the actual item data
- const item = await itemPromise;
- // Run our custom code
- handlePageChange(item);
- } catch (err) {
- error('Breadcrumb page change handler failed:', err);
- }
- }, {
- pages: [] // Empty array means all pages
- });
+ window.KefinTweaksUtils.onViewPage(
+ async (view, element, hash, itemPromise) => {
+ try {
+ const item = await itemPromise;
+ await enqueuePageChange(item);
+ } catch (err) {
+ error('Breadcrumb page change handler failed:', err);
+ }
+ },
+ {
+ pages: [], // Empty array means all pages
+ },
+ );
} else {
error('KefinTweaksUtils not available, breadcrumbs may not work correctly');
}
@@ -891,7 +1028,7 @@
window.addEventListener('resize', handleResize);
// Initial check
- handlePageChange();
+ enqueuePageChange(null);
log('Breadcrumbs initialized successfully');
}
@@ -902,5 +1039,4 @@
} else {
initialize();
}
-
})();
diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css
index 88c9bd6..27ef324 100644
--- a/scripts/cardBuilder.css
+++ b/scripts/cardBuilder.css
@@ -1,410 +1,4797 @@
/* Spotlight Section Styles */
+@import url("https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Anton&family=Changa+One:ital@0;1&family=Exo:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&family=Oswald:wght@200..700&family=Prata&family=Roboto:ital,wght@0,100..900;1,100..900&family=Unbounded:wght@200..900&family=VT323&display=swap");
.spotlight-section {
- position: relative;
- width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%)));
- margin-bottom: 2em;
+ position: relative;
+ width: calc(100% - (1 * max(env(safe-area-inset-left), 3.3%)));
+ margin: 2em 0;
}
.spotlight-banner-container {
- position: relative;
- width: 100%;
- height: 450px;
- overflow: hidden;
- border-radius: 8px;
+ position: relative;
+ border-radius: 8px;
+}
+
+.layout-mobile .spotlight-banner-container {
+ display: flex;
+ justify-items: center;
+ align-items: center;
+ justify-content: center;
+}
+
+.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-banner-container {
+ height: 40vh;
+}
+
+/* Size variants */
+/* .spotlight-section[data-layout="Borderless"][data-size="full"][data-order="1"] .spotlight-banner-container {
+ height: 94vh !important;
+} */
+
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-banner-container {
+ height: 50vh;
+}
+
+.spotlight-section[data-size="full"] .spotlight-banner-container {
+ height: 94vh !important;
+}
+
+.layout-mobile .spotlight-section[data-size="full"] .spotlight-banner-container {
+ height: 85vh !important;
}
.spotlight-items-container {
- position: relative;
- width: 100%;
- height: 100%;
+ position: relative;
+ width: 100%;
+ height: 100%;
}
.spotlight-item {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-size: cover;
- background-position: center;
- transition: opacity 0.8s ease-in-out;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+ background-position: center;
+ transition: opacity 0.8s ease-in-out;
+ isolation: isolate;
}
-.spotlight-item[data-index="0"] {
- opacity: 1;
+.spotlight-item:not([data-active]):not([data-fade-out]) {
+ display: none;
}
-.spotlight-item:not([data-index="0"]) {
- opacity: 0;
+/* Crossfade: outgoing slide fades to 0 */
+.spotlight-item[data-fade-out] {
+ opacity: 0;
+ pointer-events: none;
+}
+
+/* Crossfade: incoming slide starts at 0, then transitions to 1 when data-entering is removed */
+.spotlight-item[data-active][data-entering] {
+ opacity: 0;
+}
+
+.spotlight-item[data-active]:not([data-entering]) {
+ opacity: 1;
+}
+
+/* Skeleton spotlight: single full-container placeholder; fill area and match layout/size */
+.skeleton-spotlight-item {
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+}
+.spotlight-section[data-layout="Border"] .skeleton-spotlight-item {
+ border-radius: 8px;
+}
+
+.spotlight-section[data-layout="Border"] .spotlight-background-dual {
+ border-radius: 8px;
+}
+
+.spotlight-background-layer {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ overflow: hidden;
+}
+
+.spotlight-background-layer img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ pointer-events: none;
+}
+
+.spotlight-background-layer img.animate,
+.spotlight-background-single img.animate {
+ filter: blur(0);
+ transition: filter 0.3s ease-in-out;
+ backface-visibility: hidden;
+ -webkit-backface-visibility: hidden;
+ transform-origin: center center;
+ animation: var(--spotlight-slide-animation, kenBurnsZoomIn) 15s linear both;
+}
+
+/* Single backdrop (when tileCount is 1 or only one image) */
+.spotlight-background-single,
+.spotlight-background-dual {
+ position: absolute;
+ z-index: 1;
+ overflow: hidden;
+ width: 100%;
+ height: 160%;
+ transform: translateY(-15%);
+ -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 2)), transparent 100% );
+ mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 2)), transparent 100% );
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+ -webkit-mask-position: 0 0;
+ mask-position: 0 0;
+}
+
+.spotlight-section[data-size="large"] .spotlight-background-single,
+.spotlight-section[data-size="large"] .spotlight-background-dual {
+ height: 140%;
+ transform: translateY(-10%);
+ -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 4), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% );
+ mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 4), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% );
+}
+
+.spotlight-section[data-size="full"] .spotlight-background-single,
+.spotlight-section[data-size="full"] .spotlight-background-dual {
+ height: 125%;
+ transform: translateY(-5%);
+ -webkit-mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% );
+ mask-image: linear-gradient( to bottom, transparent 0%, black calc(var(--spotlight-fullscreen-vertical) * 2), black calc(100% - calc(var(--spotlight-fullscreen-vertical) * 4)), transparent 100% );
+}
+
+.spotlight-background-single img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ pointer-events: none;
+}
+
+/* Cycle backdrops: two stacked layers for crossfade (same as slide transition) */
+.spotlight-background-single.cycle-backdrops img {
+ position: absolute;
+ inset: 0;
+}
+.spotlight-background-single.cycle-backdrops img:first-child {
+ z-index: 1;
+}
+.spotlight-background-single.cycle-backdrops img:last-child {
+ z-index: 2;
+}
+
+@keyframes kenBurnsZoomIn {
+ from {
+ transform: scale3d(1, 1, 0);
+ }
+ to {
+ transform: scale3d(1.12, 1.12, 1);
+ }
+}
+
+@keyframes kenBurnsZoomOut {
+ from {
+ transform: scale3d(1.15, 1.15, 0);
+ }
+ to {
+ transform: scale3d(1.02, 1.02, 1);
+ }
+}
+
+/* Full-screen: start images pre-scaled so they extend past edges from the start (no hard edge before zoom) */
+@keyframes kenBurnsZoomInFullscreen {
+ from {
+ transform: scale3d(1.08, 1.08, 0);
+ }
+ to {
+ transform: scale3d(1.12, 1.12, 1);
+ }
+}
+
+@keyframes kenBurnsZoomOutFullscreen {
+ from {
+ transform: scale3d(1.15, 1.15, 0);
+ }
+ to {
+ transform: scale3d(1.08, 1.08, 1);
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: scale3d(1, 1, 0) translateY(30px);
+ }
+ to {
+ opacity: 1;
+ transform: scale3d(1, 1, 1) translateY(0);
+ }
+}
+
+@keyframes fadeInDown {
+ from {
+ opacity: 0;
+ transform: scale3d(1, 1, 0) translateY(-30px);
+ }
+ to {
+ opacity: 1;
+ transform: scale3d(1, 1, 1) translateY(0);
+ }
+}
+
+@keyframes kenBurnsPanRight {
+ from {
+ transform: scale3d(1.1, 1.1, 0) translateX(-5%);
+ }
+ to {
+ transform: scale3d(1.1, 1.1, 1) translateX(5%);
+ }
+}
+
+@keyframes kenBurnsPanLeft {
+ from {
+ transform: scale3d(1.1, 1.1, 0) translateX(5%);
+ }
+ to {
+ transform: scale3d(1.1, 1.1, 0) translateX(-5%);
+ }
+}
+
+@keyframes kenBurnsPanUp {
+ from {
+ transform: scale3d(1.1, 1.1, 0) translateY(5%);
+ }
+ to {
+ transform: scale3d(1.1, 1.1, 0) translateY(-5%);
+ }
+}
+
+@keyframes kenBurnsDiagonal {
+ from {
+ transform: scale3d(1.08, 1.08, 0) translate(-5%, 5%);
+ }
+ to {
+ transform: scale3d(1.13, 1.13, 0) translate(5%, -5%);
+ }
+}
+
+@keyframes fadeInScale {
+ from {
+ opacity: 0;
+ transform: scale3d(0.95, 0.95, 0);
+ }
+ to {
+ opacity: 1;
+ transform: scale3d(1, 1, 0);
+ }
+}
+
+@keyframes parallaxFloat {
+ 0%,
+ 100% {
+ transform: scale3d(1, 1, 0) translateY(0);
+ }
+ 50% {
+ transform: scale3d(1, 1, 1) translateY(-12px);
+ }
+}
+
+@keyframes depthPulse {
+ 0%,
+ 100% {
+ transform: scale3d(1, 1, 0);
+ }
+ 50% {
+ transform: scale3d(1.03, 1.03, 1);
+ }
+}
+
+@keyframes slowRotate {
+ from {
+ transform: scale3d(1.15, 1.15, 0) rotate(0deg);
+ }
+ to {
+ transform: scale3d(1.15, 1.15, 1) rotate(3deg);
+ }
+}
+
+@keyframes breathe {
+ 0%,
+ 100% {
+ transform: scale3d(1, 1, 0);
+ opacity: 0.9;
+ }
+ 50% {
+ transform: scale3d(1.04, 1.04, 1);
+ opacity: 1;
+ }
+}
+
+@keyframes heatHaze {
+ 0%,
+ 100% {
+ transform: scale3d(1.05, 1.05, 1) skewX(0deg);
+ }
+ 25% {
+ transform: scale3d(1.06, 1.06, 1) skewX(0.3deg);
+ }
+ 75% {
+ transform: scale3d(1.05, 1.05, 1) skewX(-0.3deg);
+ }
+}
+
+@keyframes colorWash {
+ 0% {
+ filter: saturate(1) brightness(1);
+ }
+ 50% {
+ filter: saturate(1.5) brightness(1.25);
+ }
+ 100% {
+ filter: saturate(1) brightness(1);
+ }
+}
+
+@keyframes vignetteIn {
+ from {
+ box-shadow: inset 0 0 0px rgba(0, 0, 0, 0);
+ }
+ to {
+ box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6);
+ }
+}
+
+/* Layer masks for elegant crossfade - overlap in center (45-55%) */
+.spotlight-background-layer-left {
+ left: 0;
+ width: 55%;
+ -webkit-mask-image: linear-gradient(to right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 1) 75%, rgba(0, 0, 0, 0.5) 90%, rgba(0, 0, 0, 0) 100%);
+ mask-image: linear-gradient(to right, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 1) 75%, rgba(0, 0, 0, 0.5) 90%, rgba(0, 0, 0, 0) 100%);
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+}
+
+.spotlight-background-layer-right {
+ right: 0;
+ width: 55%;
+ -webkit-mask-image: linear-gradient(
+ to right,
+ rgba(0, 0, 0, 0) 0%,
+ rgba(0, 0, 0, 0.25) 8%,
+ rgba(0, 0, 0, 0.5) 18%,
+ rgba(0, 0, 0, 1) 35%,
+ rgba(0, 0, 0, 1) 100%
+ );
+ mask-image: linear-gradient(
+ to right,
+ rgba(0, 0, 0, 0) 0%,
+ rgba(0, 0, 0, 0.25) 8%,
+ rgba(0, 0, 0, 0.5) 18%,
+ rgba(0, 0, 0, 1) 35%,
+ rgba(0, 0, 0, 1) 100%
+ );
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+}
+
+/* Three-tile: center layer – symmetric soft fade on both edges for seamless left/center and center/right blend */
+.spotlight-background-layer-center {
+ left: 31.25%;
+ width: 33.334%;
+ -webkit-mask-image: linear-gradient(
+ to right,
+ rgba(0, 0, 0, 0) 0%,
+ rgba(0, 0, 0, 0.25) 5%,
+ rgba(0, 0, 0, 0.5) 10%,
+ rgba(0, 0, 0, 1) 15%,
+ rgba(0, 0, 0, 1) 85%,
+ rgba(0, 0, 0, 0.5) 90%,
+ rgba(0, 0, 0, 0.25) 95%,
+ rgba(0, 0, 0, 0) 100%
+ );
+ mask-image: linear-gradient(
+ to right,
+ rgba(0, 0, 0, 0) 0%,
+ rgba(0, 0, 0, 0.25) 5%,
+ rgba(0, 0, 0, 0.5) 10%,
+ rgba(0, 0, 0, 1) 15%,
+ rgba(0, 0, 0, 1) 85%,
+ rgba(0, 0, 0, 0.5) 90%,
+ rgba(0, 0, 0, 0.25) 95%,
+ rgba(0, 0, 0, 0) 100%
+ );
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+}
+
+/* Three-tile: give the center more visual weight */
+.spotlight-section[data-tile-count="3"] .spotlight-background-layer-left,
+.spotlight-section[data-tile-count="3"] .spotlight-background-layer-right {
+ width: 35%;
+}
+
+.spotlight-section[data-tile-count="3"] .spotlight-background-layer-center {
+ left: 30%;
+ width: 40%;
+}
+
+.spotlight-section .spotlight-item .spotlight-background-dual {
+ background: black;
+}
+
+/* Mobile: show only left backdrop (full width) */
+.layout-mobile .spotlight-background-layer-center,
+.layout-mobile .spotlight-background-layer-right {
+ display: none !important;
+}
+.layout-mobile .spotlight-background-layer-left {
+ width: 100% !important;
+ mask-image: none !important;
+}
+
+/* Logo section heights per size - fixed to prevent layout shift when sliding between items */
+.spotlight-section {
+ --spotlight-logo-height: 118px;
+}
+.spotlight-section[data-size="large"] {
+ --spotlight-logo-height: 180px;
+}
+.spotlight-section[data-size="full"] {
+ --spotlight-logo-height: 240px;
}
.spotlight-overlay {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- width: 45%;
- background: linear-gradient(to right, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.7) 70%, transparent 100%);
- padding: 40px;
- color: white;
- display: flex;
- flex-direction: column;
- z-index: 2;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 45%;
+ padding: 40px;
+ color: white;
+ display: grid;
+ grid-template-rows: var(--spotlight-logo-height) auto 1fr minmax(48px, auto);
+ grid-template-areas: "logo" "metadata" "overview" "buttons";
+ align-content: start;
+ gap: 1em;
+ z-index: 2;
+ pointer-events: none;
+}
+
+.spotlight-overlay a,
+.spotlight-overlay button,
+.spotlight-overlay .spotlight-buttons-section,
+.spotlight-overlay .spotlight-buttons-container,
+.spotlight-overlay .spotlight-info-button,
+.spotlight-overlay .emby-button,
+.spotlight-overlay button.metadata-container {
+ pointer-events: auto;
+}
+
+.spotlight-overlay .spotlight-logo-section {
+ grid-area: logo;
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ align-items: flex-end;
+ overflow: hidden;
+ align-content: end;
+ flex-wrap: wrap;
+ max-width: 700px;
+}
+.spotlight-overlay .spotlight-metadata-section {
+ grid-area: metadata;
+}
+.spotlight-overlay .spotlight-overview-section {
+ grid-area: overview;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ overflow: hidden;
+ gap: 0;
+}
+.spotlight-overlay .spotlight-buttons-section {
+ grid-area: buttons;
+ display: flex;
+ align-items: start;
}
.spotlight-overlay.has-title {
- padding: 70px 40px 20px 40px;
+ padding: 50px 40px 20px 40px;
}
.spotlight-section-title {
- position: absolute;
- top: 20px;
- left: 20px;
- color: white;
- font-size: 18px;
- font-weight: 600;
- z-index: 10;
- background: rgba(0,0,0,0.4);
- padding: 8px 16px;
- border-radius: 4px;
- backdrop-filter: blur(4px);
+ font-size: 18px;
+ font-weight: 600;
+ z-index: 2;
+ letter-spacing: 2px;
+ word-spacing: 2px;
+}
+
+.layout-mobile .spotlight-section-title {
+ letter-spacing: 1px;
+}
+
+.spotlight-section-title a {
+ font-size: 1.1rem;
}
.spotlight-item-title {
- margin: 0 0 16px 0;
- font-size: 1.6em;
- font-weight: bold;
- height: 80px;
- align-content: end;
+ margin: 0;
+ font-size: 3.5em;
+ font-weight: bold;
+ min-height: 0;
+ display: flex;
+ align-items: flex-end;
+ overflow: hidden;
+ max-height: 100%;
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-item-title {
+ font-size: 3rem;
+}
+
+.layout-mobile .spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-item-title {
+ font-size: 2rem;
}
.spotlight-item-logo {
- max-width: 206.8px;
- height: 80px;
- object-fit: contain;
- margin-bottom: 16px;
- margin-top: 10px;
+ height: 100%;
+ width: 100%;
+ object-fit: contain;
+ margin: 0;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: left;
+ max-height: 300px;
+}
+
+.layout-mobile .spotlight-item-logo {
+ background-position: bottom;
}
.spotlight-clearart {
- position: absolute;
- bottom: 0px;
- right: 0px;
- max-height: 70%;
- object-fit: contain;
- z-index: 5;
- opacity: 0.9;
- pointer-events: none;
+ position: absolute;
+ bottom: 0px;
+ right: 0px;
+ max-height: 70%;
+ object-fit: contain;
+ z-index: 5;
+ opacity: 0.9;
+ pointer-events: none;
}
.spotlight-rating-container {
- display: flex;
- align-items: center;
- gap: 8px;
+ -webkit-align-items: center;
+ align-items: center;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-justify-content: center;
+ justify-content: center;
+ vertical-align: middle;
}
.spotlight-rating-star {
- color: #4A9EFF;
- font-size: 24px;
+ color: #f2b01e;
+ font-size: 1.4em;
+ height: auto !important;
+ width: auto !important;
+ margin-left: 0em !important;
+ margin-right: 0em !important;
}
.spotlight-rating-value {
- font-size: 20px;
- font-weight: 500;
+ font-size: 20px;
+ font-weight: 500;
}
.spotlight-rating-value.na {
- opacity: 0.7;
+ opacity: 0.7;
}
.spotlight-metadata-row {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 12px;
- font-size: 14px;
- opacity: 0.9;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ font-size: 14px;
+ opacity: 0.9;
+ overflow: visible;
+ min-width: 0;
}
-.spotlight-metadata-row .spotlight-end-time {
- opacity: 0.8;
+.spotlight-metadata-row .sectionTabs {
+ font-size: inherit;
+ }
+
+.spotlight-metadata-row .itemDetailsGroup,
+.spotlight-metadata-row .sectionTabs {
+ flex-shrink: 0;
+}
+
+.spotlight-credits-row .spotlight-truncated-list {
+ flex-shrink: 1;
+ min-width: 0;
+}
+
+/* Keep each name on one line (no mid-name wrapping) */
+.spotlight-credits-row .spotlight-person-link,
+.spotlight-credits-row
+ .spotlight-truncated-list
+ .detailsGroupItem
+ > span:not(.spotlight-list-separator):not(.spotlight-truncated-list-label),
+.spotlight-credits-row
+ .spotlight-truncated-list
+ .emby-tab-button
+ > span:not(.spotlight-list-separator):not(.spotlight-truncated-list-label) {
+ white-space: nowrap;
+}
+
+/* Metadata row icons (end time, etc.) - same pattern as truncated list icons */
+.spotlight-metadata-row .emby-tab-button[data-icon] {
+ display: inline-flex;
+ align-items: center;
+}
+.spotlight-metadata-row .emby-tab-button[data-icon]::before {
+ content: attr(data-icon);
+ font-family: "Material Icons";
+ font-size: 1.1em;
+ margin-right: 6px;
+ opacity: 0.9;
+ text-transform: none;
+}
+
+.spotlight-metadata-row .spotlight-studio {
+ display: inline-flex;
+ align-items: center;
+}
+.spotlight-metadata-row .spotlight-studio::before {
+ content: "\ea40"; /* Material Icons 'apartment' codepoint */
+ font-family: "Material Icons";
+ font-weight: normal;
+ font-style: normal;
+ font-size: 1.1em;
+ margin-right: 6px;
+ opacity: 0.9;
}
.metadata-container {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-bottom: 2em;
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ gap: 8px;
+ overflow: visible;
+ min-width: 0;
+ width: 100%;
}
.spotlight-truncated-list {
- font-size: 14px;
- opacity: 0.9;
- position: relative;
+ font-size: 14px;
+ opacity: 0.9;
+ position: relative;
+ overflow: hidden;
+ min-width: 0;
+ max-width: 100%;
}
-.spotlight-truncated-list .spotlight-ellipsis {
- cursor: pointer;
+/* Tooltip for truncated lists - full list on hover after 1s, no layout shift */
+.spotlight-truncated-list-tooltip {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ margin-bottom: 8px;
+ padding: 12px 16px;
+ background: rgba(0, 0, 0, 0.95);
+ color: white;
+ border-radius: 8px;
+ font-size: 14px;
+ line-height: 1.5;
+ max-width: 400px;
+ min-width: 200px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease;
+ z-index: 1000;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+ word-wrap: break-word;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
}
-.spotlight-tagline {
- margin: 0 0 24px 0;
- font-size: 18px;
- font-style: italic;
- opacity: 0.9;
- line-height: 1.4;
- max-width: 90%;
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
- text-overflow: ellipsis;
- min-height: 75px;
- max-height: 75px;
+.spotlight-truncated-list-tooltip-items {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.spotlight-truncated-list-tooltip-item {
+ display: block;
+}
+
+.spotlight-truncated-list-tooltip.visible {
+ opacity: 1;
+ pointer-events: auto; /* Allow hovering tooltip to keep it visible */
+}
+
+.spotlight-truncated-list-tooltip .spotlight-person-link,
+.spotlight-truncated-list-tooltip .spotlight-genre-link,
+.spotlight-truncated-list-tooltip .spotlight-studio-link {
+ color: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.spotlight-list-separator {
+ opacity: 0.75;
+ margin: 0 0.4em;
+ user-select: none;
+}
+
+/* Overview: shown for all layouts with line clamping to avoid cut-off text */
+.spotlight-overview {
+ display: -webkit-box;
+ margin: 0;
+ font-size: 15px;
+ font-style: normal;
+ opacity: 0.9;
+ line-height: 1.45;
+ max-width: min(700px, 75%);
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.spotlight-section[data-size="large"] .spotlight-overview {
+ -webkit-line-clamp: 4;
+}
+
+.spotlight-section[data-size="full"] .spotlight-overview {
+ font-size: 1.05em;
+ -webkit-line-clamp: 4;
}
.spotlight-buttons-container {
- display: flex;
- gap: 12px;
- align-items: center;
- margin-top: auto;
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ margin-top: auto;
+ position: relative !important;
}
.spotlight-play-button {
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background: rgba(255,255,255,0.9);
- color: #000;
- border: none;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- transition: background 0.2s;
+ width: 48px;
+ height: 48px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ transition: background 0.2s;
}
.spotlight-play-button:hover {
- background: rgba(255,255,255,1);
+ background: rgba(255, 255, 255, 1);
}
.spotlight-play-button .material-icons {
- font-size: 28px;
+ font-size: 28px;
}
-.spotlight-watchlist-button {
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background: rgba(255,255,255,0.2);
- color: white;
- border: 2px solid rgba(255,255,255,0.5);
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- transition: all 0.2s;
+.spotlight-buttons-container button:not(.btnPlay) {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ transition: all 0.2s;
}
-.spotlight-watchlist-button:hover {
- background: rgba(255,255,255,0.3);
-}
-
-.spotlight-watchlist-button.watchlisted {
- border-color: #4CAF50;
-}
-
-.spotlight-watchlist-button.watchlisted .material-icons {
- color: #4CAF50;
-}
-
-.spotlight-watchlist-button:not(.watchlisted):hover {
- border-color: rgba(255,255,255,0.7);
+.spotlight-watchlist-button[data-active="true"] .material-icons {
+ color: #4caf50;
}
.spotlight-watchlist-button .material-icons {
- font-size: 28px;
- color: white;
+ font-size: 28px;
}
.spotlight-info-button {
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background: rgba(255,255,255,0.2);
- color: white;
- border: 2px solid rgba(255,255,255,0.5);
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- transition: all 0.2s;
- position: relative;
-}
-
-.spotlight-info-button:hover {
- background: rgba(255,255,255,0.3);
- border-color: rgba(255,255,255,0.7);
-}
-
-.spotlight-info-button .material-icons {
- font-size: 28px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+ position: relative;
+ overflow: visible;
}
.spotlight-overview-tooltip {
- position: absolute;
- text-align: left;
- bottom: 100%;
- left: 50%;
- transform: translateX(-50%);
- margin-bottom: 12px;
- padding: 16px;
- background: rgba(0,0,0,0.95);
- color: white;
- border-radius: 8px;
- font-size: 14px;
- line-height: 1.6;
- max-width: 400px;
- min-width: 300px;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.2s;
- z-index: 1000;
- box-shadow: 0 4px 12px rgba(0,0,0,0.5);
- word-wrap: break-word;
+ position: absolute !important;
+ text-align: left;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%) !important;
+ margin-bottom: 12px !important;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.95);
+ color: white;
+ border-radius: 8px;
+ font-size: 14px;
+ line-height: 1.6;
+ max-width: 400px;
+ min-width: 300px;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: opacity 0.2s ease, visibility 0s linear 0.2s;
+ transition-delay: 0s;
+ z-index: 1000 !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+ word-wrap: break-word;
+ max-height: unset !important;
}
-.spotlight-info-button:hover .spotlight-overview-tooltip {
- opacity: 1;
+/* Show only while hovering the info icon glyph — not the button/tooltip footprint */
+.spotlight-info-button:is(.emby-button):hover .spotlight-overview-tooltip {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: none;
+ transition-delay: 0.5s, 0.5s;
+ display: block;
}
-.spotlight-nav-container {
- position: absolute;
- top: 20px;
- right: 20px;
- display: flex;
- gap: 8px;
- z-index: 10;
- background: rgba(0,0,0,0.4);
- border-radius: 4px;
- padding: 4px;
+/* Hover overview only on Normal desktop spotlights */
+.spotlight-section[data-size="large"] .spotlight-overview-tooltip,
+.spotlight-section[data-size="full"] .spotlight-overview-tooltip,
+.layout-mobile .spotlight-overview-tooltip {
+ 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 {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
}
.spotlight-nav-button {
- width: 36px;
- height: 36px;
- background: transparent;
- color: white;
- border: none;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- transition: background 0.2s;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ margin: 0 !important;
+ transition: background 0.2s;
}
.spotlight-nav-button:hover {
- background: rgba(255,255,255,0.2);
-}
-
-.spotlight-nav-button .material-icons {
- font-size: 24px;
-}
-
-.spotlight-pause-button {
- width: 36px;
- height: 36px;
- background: transparent;
- color: white;
- border: none;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 10;
- padding: 0;
- transition: background 0.2s;
-}
-
-.spotlight-pause-button .material-icons {
- font-size: 24px;
+ background: rgba(255, 255, 255, 0.2);
}
.spotlight-dots {
- position: absolute;
- bottom: 20px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- gap: 8px;
- z-index: 10;
+ position: absolute;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ gap: 8px;
+ z-index: 2;
}
.spotlight-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- border: none;
- background: rgba(255,255,255,0.5);
- cursor: pointer;
- padding: 0;
- transition: background 0.2s;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ border: none;
+ background: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ padding: 0;
+ transition: background 0.2s;
}
.spotlight-dot.active {
- background: white;
+ background: white;
+}
+
+/* Numeric slide state: "N / X" via data attributes */
+.spotlight-dots.spotlight-dots-numeric {
+ background: rgb(0 0 0 / 40%);
+ border-radius: 5px;
+ padding: 0.25rem 0.5rem;
+ gap: 0;
+}
+
+.spotlight-dots.spotlight-dots-numeric .spotlight-dot {
+ display: none;
+}
+
+.spotlight-dots.spotlight-dots-numeric::after {
+ content: attr(data-index) " / " attr(data-total-items);
+ font-size: 14px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.9);
+ letter-spacing: 0.5px;
}
.spotlight-genre-link,
-.spotlight-person-link {
- color: inherit;
- text-decoration: underline;
- cursor: pointer;
+.spotlight-person-link,
+.spotlight-studio-link {
+ color: inherit;
+ text-decoration: underline;
+ cursor: pointer;
}
.spotlight-empty-writer-container {
- font-size: 14px;
- opacity: 0.9;
- height: 1em;
+ font-size: 14px;
+ opacity: 0.9;
+ height: 1em;
}
/* Mobile Styles */
@media (max-width: 768px) {
- .metadata-container {
- display: none !important;
- }
-
- .spotlight-nav-container {
- display: none !important;
- }
-
- .spotlight-pause-button {
- display: none !important;
- }
+ .metadata-container {
+ display: none !important;
+ }
- .spotlight-clearart {
- display: none !important;
- }
-
- .spotlight-overlay {
- width: calc(100% - 2rem);
- padding: 5rem 1rem 3rem !important;
- align-items: center;
- background: rgba(0, 0, 0, 0.7);
- }
-
- .spotlight-tagline {
- max-height: 175px !important;
- -webkit-line-clamp: 4;
- }
+ .spotlight-nav-container {
+ display: none !important;
+ }
- .spotlight-item-logo {
- max-width: 100%;
- height: 125px;
- }
+ .spotlight-pause-button {
+ display: none !important;
+ }
+
+ .spotlight-clearart {
+ display: none !important;
+ }
+
+ .spotlight-overlay {
+ width: calc(100% - 2rem);
+ padding: 5rem 1rem 3rem !important;
+ align-items: center;
+ }
+
+ .spotlight-tagline {
+ -webkit-line-clamp: 1;
+ bottom: 60px;
+ }
}
+
+/* Match .cardText line boxes so skeleton→real swap does not shift section height */
+.skeleton-text-line {
+ display: inline-block;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.12);
+}
+.cardText-first .skeleton-text-line {
+ padding: 0 22.5% !important;
+}
+.cardText-secondary .skeleton-text-line {
+ padding: 0 15% !important;
+}
+.cardText-secondary:nth-child(3) .skeleton-text-line {
+ padding: 0 10% !important;
+}
+.skeleton-card .cardText-secondary {
+ opacity: 0.8;
+}
+
+.skeleton-section-title {
+ width: 50%;
+ min-width: 8rem;
+}
+
+.skeleton-section-caption {
+ width: 35%;
+ min-width: 6rem;
+ margin-top: 0.15em;
+}
+
+/* One shimmer overlay per skeleton section — real sibling node, gated by .skeleton-shimmer-active (IO) */
+.itemsContainer,
+.emby-scroller,
+.custom-scroller,
+.spotlight-banner-container {
+ position: relative;
+}
+
+.skeleton-shimmer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 2;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ rgba(255, 255, 255, 0.08) 45%,
+ rgba(255, 255, 255, 0.16) 50%,
+ rgba(255, 255, 255, 0.08) 55%,
+ transparent 100%
+ );
+ transform: translateX(-100%);
+ opacity: 0;
+}
+
+.skeleton-section.skeleton-shimmer-active .skeleton-shimmer {
+ opacity: 1;
+ animation: skeleton-shimmer-sweep 1.4s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skeleton-section.skeleton-shimmer-active .skeleton-shimmer {
+ animation: none !important;
+ opacity: 0 !important;
+ }
+}
+
+/* Localize style/layout invalidation to section shells during enhance/lazy paint */
+.emby-scroller-container.custom-scroller-container,
+.spotlight-section {
+ contain: layout style;
+}
+
+/* Tagline always 1 line - Movie-specific override kept for any edge cases */
+[data-item-type="Movie"] .spotlight-tagline {
+ -webkit-line-clamp: 1;
+}
+
+// Remove this if we switch back to Jellyfin's default scroll buttons
+.emby-scrollbuttons.padded-right {
+ -webkit-align-items: center;
+ align-items: center;
+ color: #fff;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-justify-content: center;
+ justify-content: center;
+ min-height: 24px;
+ min-width: 104px;
+ padding-top: .85em;
+ position: absolute;
+ top: 0;
+ z-index: 1;
+}
+
+/* Blurhash placeholder canvas: fill card image area so it's visible until the real image loads */
+/* .cardScalable {
+ position: relative;
+}
+.cardScalable .blurhash-canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ pointer-events: none;
+}
+.cardScalable .blurhash-canvas.lazy-hidden {
+ display: none !important;
+} */
+
+/* Progressive Section Reveal Animation */
+.cardbuilder-section-reveal {
+ opacity: 0;
+ transform: translateY(20px);
+ transition:
+ opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1),
+ transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* When section enters viewport, trigger animation */
+.cardbuilder-section-reveal.in-viewport {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* Shared section control buttons (show-all / refresh / configure) */
+.show-all-button,
+.section-refresh-button,
+.section-configure-button {
+ padding: 4px;
+ min-width: auto;
+ width: 32px;
+ height: 32px;
+ background: transparent;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ display: inline-block;
+ align-items: center;
+ justify-content: center;
+ align-self: center;
+ color: var(--main-text, rgba(255, 255, 255, 0.87));
+ font-size: 18px;
+ line-height: 1;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.25s ease,
+ visibility 0s linear 0.25s,
+ transform 0.3s ease,
+ color 0.3s ease,
+ background 0.2s ease !important;
+}
+
+/* Show controls only while the section is hovered */
+.custom-scroller-container:hover .show-all-button,
+.custom-scroller-container:hover .section-refresh-button,
+.custom-scroller-container:hover .section-configure-button,
+.emby-scroller-container:hover .show-all-button,
+.emby-scroller-container:hover .section-refresh-button,
+.emby-scroller-container:hover .section-configure-button,
+.spotlight-section:hover .section-refresh-button,
+.spotlight-section:hover .section-configure-button {
+ opacity: 0.65;
+ visibility: visible;
+ pointer-events: auto;
+ transition:
+ opacity 0.25s ease,
+ visibility 0s linear 0s,
+ transform 0.3s ease,
+ color 0.3s ease,
+ background 0.2s ease !important;
+}
+
+.custom-scroller-container:hover .show-all-button:hover,
+.custom-scroller-container:hover .section-refresh-button:hover,
+.custom-scroller-container:hover .section-configure-button:hover,
+.emby-scroller-container:hover .show-all-button:hover,
+.emby-scroller-container:hover .section-refresh-button:hover,
+.emby-scroller-container:hover .section-configure-button:hover,
+.spotlight-section:hover .section-refresh-button:hover,
+.spotlight-section:hover .section-configure-button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ opacity: 1;
+}
+
+.custom-scroller-container h2.sectionTitle.sectionTitle-cards {
+ align-items: center;
+ text-align: left;
+}
+
+.custom-scroller-container .sectionTitleContainer {
+ display: flex;
+ align-items: center;
+ gap: 0.35em;
+}
+
+.layout-mobile .custom-scroller-container .section-controls {
+ margin-left: auto !important;
+}
+
+.section-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.15em;
+ flex-shrink: 0;
+ position: relative;
+}
+
+.section-controls-inline {
+ display: flex;
+ align-items: center;
+ gap: 0.15em;
+}
+
+.section-controls-more {
+ padding: 4px;
+ min-width: auto;
+ width: 32px;
+ height: 32px;
+ background: transparent;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ align-self: center;
+ color: var(--main-text, rgba(255, 255, 255, 0.87));
+ font-size: 18px;
+ line-height: 1;
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.25s ease,
+ visibility 0s linear 0.25s,
+ transform 0.3s ease,
+ color 0.3s ease,
+ background 0.2s ease;
+}
+
+.custom-scroller-container:hover .section-controls-more,
+.emby-scroller-container:hover .section-controls-more,
+.spotlight-section:hover .section-controls-more {
+ opacity: 0.65;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+.custom-scroller-container:hover .section-controls-more:hover,
+.emby-scroller-container:hover .section-controls-more:hover,
+.spotlight-section:hover .section-controls-more:hover {
+ background: rgba(255, 255, 255, 0.1);
+ opacity: 1;
+}
+
+.section-controls-more-popover {
+ position: absolute !important;
+ z-index: 1001;
+ display: block;
+ background-color: rgba(0, 0, 0, 0.95);
+ right: 0;
+ left: auto;
+ min-width: 12rem;
+}
+
+/* Modal-hosted more menu: normal flow so the dialog body does not collapse */
+.section-controls-more-menu {
+ position: static;
+ display: block;
+ min-width: 12rem;
+ background: transparent;
+}
+
+.section-controls-more-popover .section-controls-more-item,
+.section-controls-more-menu .section-controls-more-item {
+ align-items: center;
+}
+
+.section-controls-more-item {
+ display: flex;
+ align-items: center;
+ gap: 0.65em;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.section-controls-more-item-icon {
+ font-size: 1.15em;
+ opacity: 0.9;
+}
+
+.section-controls-more-item-label {
+ flex: 1;
+}
+
+:not(.layout-mobile) .section-controls-more {
+ display: none !important;
+}
+
+@media (max-width: 899px) {
+ :not(.layout-mobile) .section-controls-more {
+ display: inline-flex !important;
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ }
+
+ :not(.layout-mobile) .section-controls-inline .show-all-button,
+ :not(.layout-mobile) .section-controls-inline .section-refresh-button,
+ :not(.layout-mobile) .section-controls-inline .section-configure-button {
+ display: none !important;
+ }
+
+ .spotlight-section {
+ position: relative;
+ }
+
+ .spotlight-section .section-controls {
+ position: absolute;
+ right: 0.5rem;
+ bottom: 0.5rem;
+ z-index: 5;
+ margin: 0;
+ }
+}
+
+.layout-mobile .spotlight-section {
+ position: relative;
+}
+
+.layout-mobile .spotlight-section .section-controls {
+ position: absolute;
+ right: -0.5rem;
+ bottom: unset;
+ top: -4px;
+ z-index: 5;
+ margin: 0;
+}
+
+.layout-mobile .section-controls-inline .show-all-button,
+.layout-mobile .section-controls-inline .section-refresh-button,
+.layout-mobile .section-controls-inline .section-configure-button {
+ display: none !important;
+}
+
+.layout-mobile .spotlight-section .section-controls-more {
+ opacity: 0;
+}
+
+.layout-mobile .section-controls-more {
+ display: inline-flex !important;
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+.layout-mobile .spotlight-section:hover .section-controls-more {
+ opacity: 1;
+}
+
+.kefinTweaks-popover.section-controls-more-menu > .kefinTweaks-popover-item {
+ align-items: center;
+ justify-content: space-between;
+ margin: 0 !important;
+ padding: 0.5em 0.9em;
+ max-width: unset !important;
+}
+
+/* Grid (non-scrolling) item layouts — CSS Grid with JF card.scss column breakpoints.
+ Shape inferred from data-card-format (see resolveGridShape); default = portrait.
+ Columns use @container against the scroller width (not viewport), so inset /
+ max-width sections (e.g. 67vw series episodes) get fewer, correctly sized cards. */
+.custom-scroller-container > .custom-scroller {
+ container-type: inline-size;
+ container-name: kefin-items;
+}
+
+.itemsContainer[data-layout="grid"] {
+ display: grid !important;
+ flex-wrap: unset;
+ white-space: normal !important;
+ transform: none !important;
+ transition: none !important;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1em !important;
+}
+
+/* Cards fill tracks; override JF portraitCard/backdropCard % widths and Banner 33vw */
+.itemsContainer[data-layout="grid"] > .card,
+.itemsContainer[data-layout="grid"] > .homeLibraryButton,
+.emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] > .card {
+ width: unset !important;
+ max-width: 100% !important;
+ min-width: 0;
+ box-sizing: border-box;
+ align-self: start;
+}
+
+.itemsContainer[data-layout="grid"] > .card > .cardBox,
+.itemsContainer[data-layout="grid"] > .card .cardScalable {
+ width: unset !important;
+ max-width: 100% !important;
+ height: auto;
+}
+
+.itemsContainer[data-layout="grid"] > .card > .cardBox {
+ margin-right: 0 !important;
+ }
+
+/* Portrait / Square column table (JF .portraitCard / .squareCard) — default on grid */
+@container kefin-items (min-width: 31.25em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+@container kefin-items (min-width: 43.75em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+@container kefin-items (min-width: 50em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(5, 1fr);
+ }
+}
+@container kefin-items (min-width: 75em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(6, 1fr);
+ }
+}
+@container kefin-items (min-width: 87.5em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(7, 1fr);
+ }
+}
+@container kefin-items (min-width: 100em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(8, 1fr);
+ }
+}
+@container kefin-items (min-width: 120em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(9, 1fr);
+ }
+}
+@container kefin-items (min-width: 131.25em) {
+ .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(10, 1fr);
+ }
+}
+
+/* Backdrop shape: Backdrop, Thumb, Series Thumb, Logo, Clear Art, Button */
+.emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"],
+.emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"],
+.emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"],
+.emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"],
+.emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"],
+.emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(1, 1fr);
+}
+@container kefin-items (min-width: 25em) {
+ .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+@container kefin-items (min-width: 48.125em) {
+ .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+@container kefin-items (min-width: 75em) {
+ .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+@container kefin-items (min-width: 100em) {
+ .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(5, 1fr);
+ }
+}
+@container kefin-items (min-width: 156.25em) {
+ .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"],
+ .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(6, 1fr);
+ }
+}
+
+/* Banner shape */
+.emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(1, 1fr);
+}
+@container kefin-items (min-width: 50em) {
+ .emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+@container kefin-items (min-width: 75em) {
+ .emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+@container kefin-items (min-width: 131.25em) {
+ .emby-scroller-container[data-card-format="Banner"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+/* TV: portrait/square → 6, backdrop → 4 (fixed, not container-width) */
+html.layout-tv .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(6, 1fr);
+}
+html.layout-tv .emby-scroller-container[data-card-format="Backdrop"] .itemsContainer[data-layout="grid"],
+html.layout-tv .emby-scroller-container[data-card-format="Thumb"] .itemsContainer[data-layout="grid"],
+html.layout-tv .emby-scroller-container[data-card-format="Series Thumb"] .itemsContainer[data-layout="grid"],
+html.layout-tv .emby-scroller-container[data-card-format="Logo"] .itemsContainer[data-layout="grid"],
+html.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .itemsContainer[data-layout="grid"],
+html.layout-tv .emby-scroller-container[data-card-format="Button"] .itemsContainer[data-layout="grid"] {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+.itemsContainer[data-gapless="true"],
+[data-border-style="film-reel"] .itemsContainer,
+[data-border-style="wood"] .itemsContainer {
+ gap: 0 !important;
+}
+
+.emby-scroller-container:not([data-card-title-visibility="hidden"]) .itemsContainer[data-layout="grid"]:not([data-gapless="true"]) {
+ gap: 1.5rem !important;
+}
+
+.itemsContainer[data-gapless="true"] .card,
+[data-border-style="film-reel"] .itemsContainer .card,
+[data-border-style="wood"] .itemsContainer .card {
+ padding: 0 !important;
+}
+
+.itemsContainer[data-gapless="true"] > .card > .cardBox,
+[data-border-style="film-reel"] .itemsContainer .card > .cardBox,
+[data-border-style="wood"] .itemsContainer .card > .cardBox {
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+}
+
+.itemsContainer[data-gapless="true"] .cardContent,
+[data-border-style="film-reel"] .itemsContainer .cardContent,
+[data-border-style="wood"] .itemsContainer .cardContent {
+ border-radius: 0 !important;
+}
+
+/* Hide section name text only; keep refresh/configure/show-all controls (incl. inside wrapper) */
+[data-hide-section-name="true"] .sectionTitleContainer > .sectionTitle,
+[data-hide-section-name="true"] .sectionTitleContainer > .sectionTitle-link,
+[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionTitle,
+[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionTitle-link,
+[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionTitle.sectionTitle-cards,
+[data-hide-section-name="true"] .sectionTitle-wrapper > .sectionCaption,
+[data-hide-section-name="true"] .spotlight-section-title-container > .spotlight-section-title,
+[data-hide-section-name="true"] .spotlight-section-title,
+.spotlight-section[data-hide-section-name="true"] .spotlight-section-title {
+ display: none !important;
+}
+
+.layout-mobile .sectionTitle.sectionCaption {
+ font-size: 0.9em !important;
+}
+
+.sectionTitle.sectionCaption {
+ font-size: 0.9em !important;
+ text-align: left;
+ grid-column: 1 / -1;
+ grid-row: 2;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: inline-block !important;
+ overflow: hidden;
+ max-width: calc(100% - max(env(safe-area-inset-left), 3.3%));
+ justify-self: start;
+}
+
+.sectionTitle-wrapper {
+ display: grid;
+ align-items: center;
+ /* title | picker (optional; collapses when absent) | controls */
+ grid-template-columns: auto auto 1fr;
+ flex: 1;
+ min-width: 0;
+ width: 100%;
+}
+
+.sectionTitle-wrapper > .sectionTitle-link,
+.sectionTitle-wrapper > .sectionTitle.sectionTitle-cards {
+ grid-column: 1;
+ grid-row: 1;
+ min-width: 0;
+}
+
+.sectionTitle-wrapper > .multi-query-picker-button {
+ grid-column: 2;
+ grid-row: 1;
+ align-self: center;
+}
+
+.sectionTitle-wrapper > .section-controls {
+ grid-column: 3;
+ grid-row: 1;
+ align-self: center;
+}
+
+/* Spinning animation when refreshing */
+[data-refreshing="true"] .section-refresh-button {
+ animation: spin 1s linear infinite;
+}
+
+/* Checkmark state - green color to indicate success */
+.section-refresh-button.check_circle {
+ animation: none;
+}
+
+/* Keep icon-state transitions without overriding the shared hover fade */
+.section-refresh-button.refresh,
+.section-refresh-button.check_circle {
+ transition:
+ opacity 0.25s ease,
+ visibility 0s linear 0.25s,
+ transform 0.3s ease,
+ color 0.3s ease,
+ background 0.2s ease;
+}
+
+.custom-scroller-container:hover .section-refresh-button.refresh,
+.custom-scroller-container:hover .section-refresh-button.check_circle,
+.emby-scroller-container:hover .section-refresh-button.refresh,
+.emby-scroller-container:hover .section-refresh-button.check_circle,
+.spotlight-section:hover .section-refresh-button.refresh,
+.spotlight-section:hover .section-refresh-button.check_circle {
+ transition:
+ opacity 0.25s ease,
+ visibility 0s linear 0s,
+ transform 0.3s ease,
+ color 0.3s ease,
+ background 0.2s ease;
+}
+
+/* Fade in/out transitions for icon changes (glyph lives on child .material-icons) */
+.section-refresh-button .material-icons {
+ transition: opacity 0.2s ease;
+}
+
+.section-refresh-button.icon-fade-out .material-icons,
+.section-refresh-button .material-icons.icon-fade-out {
+ opacity: 0;
+}
+
+.section-refresh-button.icon-fade-in .material-icons,
+.section-refresh-button .material-icons.icon-fade-in {
+ opacity: 1;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Borderless layout: full width, no border, negative margins for overlap.
+ Banner has NO padding so the background image overflows edge-to-edge; only content is inset. */
+.spotlight-section[data-layout="Borderless"] {
+ --spotlight-fullscreen-vertical: 20px;
+ --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%);
+ padding: 0;
+ width: 100%;
+ margin-top: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 1));
+ margin-bottom: calc(-1 * calc(var(--spotlight-fullscreen-vertical) * 2));
+}
+
+.layout-mobile .spotlight-section[data-layout="Borderless"] {
+ margin-top: -4rem;
+ margin-bottom: -6rem;
+}
+
+.spotlight-section[data-layout="Borderless"] .spotlight-banner-container {
+ border-radius: 0;
+ position: relative;
+}
+
+.spotlight-section[data-layout="Border"] .spotlight-banner-container {
+ overflow: hidden;
+}
+
+/* Dark gradient overlays at top and bottom (like left-side overlay): graceful fade and better visibility when content overlaps.
+ z-index above cycle-backdrops imgs (1/2) so vignettes do not flash during backdrop crossfade. */
+.spotlight-section .spotlight-item .spotlight-background-single::before,
+.spotlight-section .spotlight-item .spotlight-background-single::after,
+.spotlight-section .spotlight-item .spotlight-background-dual::before,
+.spotlight-section .spotlight-item .spotlight-background-dual::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ pointer-events: none;
+ z-index: 5;
+}
+
+.spotlight-background-single::before,
+.spotlight-background-dual::before {
+ width: 55%;
+ background: radial-gradient(farthest-corner at bottom left, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.8) 50%, rgba(0, 0, 0, 0) 70%);
+}
+
+.layout-mobile .spotlight-background-single::before,
+.layout-mobile .spotlight-background-dual::before {
+ width: 100%;
+ background: linear-gradient(to bottom, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.65) 50%, rgba(0, 0, 0, 0.85) 100%) !important;
+}
+
+.spotlight-background-single::after,
+.spotlight-background-dual::after {
+ width: 100%;
+ background: linear-gradient( 180deg, black 0%, transparent 35%, transparent 65%, black 100% );
+}
+
+/* .spotlight-section[data-layout="Borderless"] .spotlight-item .spotlight-background-single:::before {
+ top: 0;
+ background: linear-gradient(0deg, transparent, black);
+}
+
+.spotlight-section[data-layout="Borderless"] .spotlight-item .spotlight-background-single::after {
+ bottom: 0;
+ background: linear-gradient(180deg, transparent, black);
+} */
+
+.spotlight-section[data-layout="Borderless"] .spotlight-items-container,
+.spotlight-section[data-layout="Borderless"] .spotlight-item {
+ /* Full bleed: fill banner so background extends to edges (and past negative margin) */
+ left: 0;
+ right: 0;
+ width: 100%;
+}
+
+/* Borderless: use pre-scaled animations so image never shows hard edge at top */
+/* .spotlight-section[data-layout="Borderless"] .spotlight-background-single img.animate {
+ animation: kenBurnsZoomInFullscreen 20s ease-out forwards;
+}
+.spotlight-section[data-layout="Borderless"] .spotlight-background-layer-left img.animate {
+ animation: kenBurnsZoomOutFullscreen 20s ease-out forwards;
+}
+.spotlight-section[data-layout="Borderless"] .spotlight-background-layer-right img.animate {
+ animation: kenBurnsZoomInFullscreen 20s ease-out forwards;
+} */
+
+/* Overlay: same ~45% width as smaller spotlights for consistent gradient and content area. */
+.spotlight-section[data-layout="Borderless"] .spotlight-overlay {
+ left: 0;
+ width: 45%;
+ z-index: 3;
+ grid-template-rows: 1fr auto auto auto;
+ gap: 1.5em;
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-overlay {
+ padding: calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal)
+ calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal);
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-overlay {
+ padding: calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal)
+ calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal);
+ grid-template-rows: 1fr auto minmax(6rem, auto) minmax(48px, auto);
+ align-content: center;
+ gap: 1.5em;
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="full"] .spotlight-credits-row {
+ flex-wrap: nowrap;
+ flex-direction: row;
+}
+
+/* Duplicate removed - already defined above for Borderless */
+
+.spotlight-section[data-size="full"] .spotlight-item-title {
+ width: auto;
+ max-width: none;
+ font-size: 4rem;
+}
+
+/* Duplicate removed - clearart display:none already set for Borderless above */
+
+.spotlight-section[data-layout="Borderless"] .spotlight-header-container {
+ left: var(--spotlight-fullscreen-horizontal);
+ top: calc(var(--spotlight-fullscreen-vertical) * 2.5);
+ width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal));
+}
+
+.spotlight-section[data-layout="Borderless"] .spotlight-dots {
+ bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5);
+}
+
+.spotlight-section .spotlight-background-layer img,
+.spotlight-section .spotlight-background-single img {
+ object-position: center 0%;
+}
+.spotlight-section[data-layout="Borderless"] .spotlight-clearart {
+ display: none;
+}
+
+/* Tagline at bottom center above navigation dots (all spotlights) - always 1 line max */
+.spotlight-section:not(.hide) > .spotlight-banner-container .spotlight-tagline {
+ position: absolute !important;
+ left: 50% !important;
+ transform: translateX(-50%) !important;
+ width: fit-content !important;
+ max-width: min(85%, 700px);
+ text-align: left !important;
+ font-style: italic;
+ font-size: 1.05em;
+ margin: 0 !important;
+ padding: 0 !important;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border-radius: 6px;
+ z-index: 4;
+ pointer-events: none;
+ height: unset !important;
+}
+.spotlight-section:not(.hide) .spotlight-tagline p {
+ font-weight: initial !important;
+ transform: initial !important;
+ width: 100%;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ cursor: default;
+ height: unset;
+ line-height: 1.5;
+}
+
+/* Normal and large: tagline above dots (dots at bottom: 20px) */
+.spotlight-section[data-layout="Border"] .spotlight-tagline {
+ bottom: 40px;
+}
+
+.spotlight-section[data-layout="Borderless"] .spotlight-tagline {
+ bottom: calc(var(--spotlight-fullscreen-vertical) * 4.5 + 40px);
+}
+
+/* Size full: modern stream-site metadata styles */
+.spotlight-section[data-size="full"] .metadata-container {
+ gap: 10px;
+}
+
+.spotlight-section[data-size="full"] .spotlight-metadata-row {
+ font-size: 15px;
+ gap: 10px;
+}
+
+/* Full-screen: uniform padding for all metadata pills (year, duration, genres, director, writer, series) */
+/* .spotlight-section[data-size="full"] .spotlight-metadata-row .detailsGroupItem,
+.spotlight-section[data-size="full"] .metadata-container .detailsGroupItem {
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+} */
+
+.spotlight-section[data-size="full"] .spotlight-genre-link {
+ display: inline-block;
+ transition:
+ background 0.2s,
+ border-color 0.2s;
+}
+
+/* Full-screen: director/writer use material icon instead of label text */
+/* Director/writer: always use icon, never show "Directed by"/"Written by" text */
+.spotlight-truncated-list[data-icon] .spotlight-truncated-list-label {
+ display: none;
+}
+.spotlight-truncated-list .detailsGroupItem[data-icon],
+.spotlight-truncated-list .emby-tab-button[data-icon] {
+ display: inline-flex;
+ align-items: center;
+}
+.spotlight-truncated-list .detailsGroupItem[data-icon]::before,
+.spotlight-truncated-list .emby-tab-button[data-icon]::before {
+ content: attr(data-icon);
+ font-family: "Material Icons";
+ font-size: 1.1em;
+ margin-right: 6px;
+ opacity: 0.9;
+ text-transform: none; /* Prevent parent text-transform: capitalize (e.g. .spotlight-series-info) from breaking icon */
+}
+.spotlight-truncated-list[data-icon] .detailsGroupItem[data-icon]::before,
+.spotlight-truncated-list[data-icon] .emby-tab-button[data-icon]::before {
+ content: attr(data-icon);
+}
+
+.spotlight-section a:not(:hover) {
+ text-decoration: none !important;
+}
+
+.spotlight-section[data-size="full"] .spotlight-buttons-container {
+ margin-top: 0px !important;
+ margin-bottom: 0px !important;
+ display: flex;
+ flex-direction: row;
+ justify-content: start;
+}
+
+.spotlight-section[data-size="full"] .spotlight-item-logo {
+ width: 95%;
+}
+
+/* Mobile spotlight: uniform layout TITLE -> gap -> LOGO -> METADATA -> OVERVIEW -> BUTTONS -> DOTS */
+.layout-mobile .spotlight-tagline {
+ display: none !important;
+}
+
+.layout-mobile .spotlight-overlay {
+ width: calc(100% - 2rem) !important;
+ padding: 4rem 1rem 1rem 1rem !important;
+ align-items: start;
+ align-content: start;
+ grid-template-rows: auto auto 1fr minmax(48px, auto);
+ grid-template-areas:
+ "logo"
+ "metadata"
+ "overview"
+ "buttons";
+ gap: 0.75rem;
+}
+
+.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-overlay {
+ padding: 9rem 1rem 6rem 1rem !important;
+}
+
+.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-header-container {
+ top: 5rem;
+}
+
+.layout-mobile .spotlight-section .spotlight-header-container {
+ top: 15px;
+ max-width: calc(100% - 2rem);
+ width: calc(100% - 2rem);
+ left: unset !important;
+ justify-content: center;
+ margin: 0 auto !important;
+ display: grid;
+}
+
+.layout-mobile .metadata-container {
+ display: flex !important;
+}
+
+.layout-mobile .spotlight-logo-section {
+ height: 100% !important;
+ min-height: 0 !important;
+ align-content: end;
+}
+
+.layout-mobile .spotlight-overview {
+ max-width: 100%;
+}
+
+.spotlight-series-info {
+ text-transform: capitalize;
+}
+
+.spotlight-section[data-size="full"] .metadata-container * {
+ font-size: 1.1rem;
+}
+
+.spotlight-section .itemDetailsGroup:not(.hide) .detailsGroupItem,
+.spotlight-section .itemDetailsGroup:not(.hide) .emby-tab-button {
+ margin: 0px !important;
+ gap: 0px !important;
+}
+
+.layout-mobile .spotlight-buttons-container .starRatingContainer {
+ display: none !important;
+}
+
+.spotlight-section .spotlight-buttons-container.mainDetailButtons > button {
+ right: 0px !important;
+ margin-top: 0 !important;
+ margin-left: 0 !important;
+ margin-bottom: 0 !important;
+ margin-right: 0 !important;
+ min-width: unset;
+ flex: unset !important;
+}
+.spotlight-buttons-container > * {
+ margin: 0 !important;
+}
+.spotlight-section .spotlight-buttons-container.mainDetailButtons {
+ margin: 0px !important;
+ padding: 0px !important;
+ background: transparent !important;
+ backdrop-filter: none !important;
+}
+.spotlight-section .mainDetailButtons > :is(button.emby-button) {
+ padding: 0em !important;
+}
+.spotlight-section .mainDetailButtons > .btnPlay:is(.emby-button) {
+ padding: 0.25em 1em !important;
+ display: flex;
+ flex-direction: row;
+ height: auto;
+}
+.spotlight-section .mainDetailButtons > .btnPlay:is(.emby-button)::after {
+ content: attr(title);
+ padding-right: 0.5em;
+}
+.spotlight-section .spotlight-buttons-container.mainDetailButtons {
+ gap: 2em;
+}
+.spotlight-section .spotlight-buttons-container.mainDetailButtons > * {
+ padding: 0 !important;
+}
+.spotlight-section .spotlight-buttons-container.mainDetailButtons > * span {
+ font-size: 2em;
+}
+
+.spotlight-section:not(.hide) .headerTabs,
+.spotlight-section:not(.hide) .sectionTabs {
+ display: flex !important;
+ height: initial !important;
+ padding: 0px !important;
+ margin-top: 0px !important;
+ margin-bottom: 0px !important;
+ margin-left: 0px !important;
+ margin-right: 0px !important;
+ width: auto !important;
+ position: relative !important;
+ transform: unset !important;
+}
+
+.spotlight-section:not(.hide) .emby-tab-button {
+ padding: 0.5em 1em !important;
+ margin: 0px !important;
+ top: initial !important;
+ transform: initial !important;
+ align-items: center;
+ height: auto !important;
+}
+
+.spotlight-section-title.headerTabs.sectionTabs,
+.spotlight-nav-container.headerTabs.sectionTabs {
+ display: flex !important;
+ flex-direction: row !important;
+ align-self: start;
+ align-items: start;
+ justify-content: start;
+ width: auto;
+ font-size: revert !important;
+ align-items: center;
+}
+
+.spotlight-metadata-row .emby-tab-button {
+ color: #c0c0c0;
+}
+
+:where(.spotlight-section-title.headerTabs.sectionTabs),
+:where(.spotlight-section .headerTabs.sectionTabs),
+:where(.spotlight-nav-container.headerTabs.sectionTabs) {
+ background-color: rgba(20, 20, 20, 0.85);
+ border-radius: 0.2em;
+}
+
+.spotlight-section[data-hide-section-name="true"] .spotlight-section-title,
+.spotlight-section[data-hide-section-name="true"] .spotlight-section-title.headerTabs.sectionTabs,
+.layout-mobile .spotlight-section[data-hide-section-name="true"] .spotlight-section-title.sectionTabs {
+ display: none !important;
+}
+
+.spotlight-section:not(.hide) .spotlight-nav-buttons-container {
+ display: flex !important;
+ padding: 0 0 !important;
+ height: auto !important;
+}
+.spotlight-section:not(.hide) .spotlight-nav-buttons-container button {
+ padding: 0.5em;
+}
+.spotlight-nav-button span.material-icons {
+ display: inline-block !important;
+ top: initial !important;
+}
+.spotlight-section-title > * {
+ font-size: 1em !important;
+}
+
+.spotlight-metadata-row *:not(a) {
+ cursor: default;
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-banner-container {
+ height: 55vh;
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overview-section {
+ display: none !important;
+}
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-buttons-section {
+ margin-top: -1.5em;
+}
+.spotlight-section[data-layout="Borderless"][data-size="large"] .spotlight-banner-container {
+ height: 72vh;
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay.has-title {
+ padding: calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal)
+ calc(var(--spotlight-fullscreen-vertical) * 5) var(--spotlight-fullscreen-horizontal);
+}
+
+.spotlight-section[data-layout="Borderless"][data-size="normal"] {
+ --spotlight-fullscreen-horizontal: max(env(safe-area-inset-left), env(safe-area-inset-right), 4%);
+}
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-tagline {
+ bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5 + 40px);
+}
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-dots {
+ bottom: calc(var(--spotlight-fullscreen-vertical) * 1.5);
+}
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-header-container {
+ left: var(--spotlight-fullscreen-horizontal);
+ width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal));
+}
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay {
+ grid-template-rows: 1fr auto auto auto;
+}
+.spotlight-section[data-size="normal"] .spotlight-overlay {
+ grid-template-rows: var(--spotlight-logo-height) auto 1fr auto;
+}
+.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-overlay {
+ grid-template-rows: 3fr auto minmax(6rem, auto) auto;
+}
+.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-logo-section {
+ align-self: end;
+}
+.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-buttons-section {
+ align-self: start;
+}
+.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overlay {
+ display: flex;
+ flex-direction: column;
+ padding: 100px 40px 25px 40px;
+}
+.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overlay .spotlight-overview-section {
+ display: none;
+}
+.layout-mobile .spotlight-section[data-size="normal"] .spotlight-overlay .spotlight-overview-section,
+.layout-mobile .spotlight-section[data-size="normal"] .spotlight-overlay .spotlight-metadata-section {
+ display: none;
+}
+.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-dots {
+ display: none;
+}
+.spotlight-section[data-size="large"] .spotlight-overlay {
+ grid-template-rows: 2fr auto minmax(auto, 6rem) auto;
+}
+.spotlight-section[data-size="large"] .spotlight-overlay .spotlight-item-logo {
+ background-position: left bottom;
+}
+.spotlight-section[data-size="large"] .spotlight-overlay .spotlight-buttons-section {
+ align-items: start;
+}
+.spotlight-section[data-layout="Border"] .spotlight-overlay {
+ padding: 85px 40px 45px 40px;
+}
+.spotlight-section[data-layout="Borderless"][data-size="normal"] .spotlight-overlay {
+ padding: 85px var(--spotlight-fullscreen-horizontal) 45px;
+}
+.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-overlay {
+ padding: 100px 40px 45px 40px;
+}
+.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-overlay {
+ padding: 110px 40px 85px 40px;
+}
+.spotlight-section[data-layout="Border"] .spotlight-header-container {
+ top: 15px;
+ left: 40px;
+ width: calc(100% - 80px);
+}
+.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-header-container,
+.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-header-container {
+ top: 2rem;
+ left: 2rem;
+ width: calc(100% - 4rem);
+}
+.spotlight-section[data-layout="Border"] .spotlight-banner-container {
+ border: 1px solid rgb(255 255 255 / 10%);
+}
+.spotlight-section[data-layout="Border"][data-size="large"] .spotlight-banner-container {
+ height: 60vh;
+}
+
+.spotlight-section[data-layout="Border"][data-size="full"] .spotlight-banner-container {
+ height: 80vh !important;
+}
+
+.spotlight-section[data-layout="Border"][data-size="normal"] .spotlight-overview {
+ -webkit-line-clamp: 2;
+}
+
+.layout-mobile .sectionTitleContainer-cards .sectionTitle-link > * {
+ padding-top: 0 !important;
+}
+
+.layout-mobile .emby-scroller-container .sectionTitleContainer .section-controls {
+ order: 1;
+}
+
+.layout-mobile .section-controls-inline .show-all-button,
+.layout-mobile .section-controls-inline .section-refresh-button {
+ opacity: 1;
+ visibility: visible;
+}
+
+.layout-mobile .spotlight-section .spotlight-nav-container {
+ display: none !important;
+}
+
+.layout-mobile .spotlight-section .spotlight-overlay {
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ align-items: center;
+ grid-gap: 3em;
+}
+
+.layout-mobile .spotlight-overview-section,
+.layout-mobile .spotlight-credits-row,
+.layout-mobile .spotlight-series-info {
+ display: none;
+}
+
+.layout-mobile .spotlight-logo-section {
+ width: 100%;
+ justify-content: center;
+}
+
+.layout-mobile .spotlight-dots {
+ bottom: 1rem !important;
+ display: none;
+}
+
+.layout-mobile .spotlight-section[data-layout="Borderless"] .spotlight-dots {
+ bottom: 5rem !important;
+}
+
+.layout-mobile .spotlight-section .spotlight-buttons-container {
+ align-content: center;
+ align-items: center;
+ justify-content: center;
+ justify-items: center;
+}
+
+.layout-mobile .spotlight-section .spotlight-section-title > * {
+ max-width: 100%;
+ min-width: 0;
+ text-align: center;
+}
+.layout-mobile .spotlight-section .emby-tab-button,
+.layout-mobile .spotlight-metadata-section span a,
+.layout-mobile .spotlight-metadata-section span {
+ font-size: 1em !important;
+}
+
+.layout-mobile .spotlight-section .spotlight-item-title {
+ font-size: 2em !important;
+ text-align: center;
+}
+
+.layout-mobile .spotlight-section:not(.hide) .spotlight-tagline {
+ display: none !important;
+}
+
+.layout-mobile .spotlight-section .spotlight-metadata-row {
+ justify-content: center;
+ align-self: center;
+}
+
+.layout-mobile .spotlight-buttons-section {
+ justify-self: center;
+}
+
+.emby-scroller-container[data-card-format="Button"] .cardScalable {
+ display: none;
+}
+.emby-scroller-container[data-card-format="Button"] .cardText {
+ display: flex !important;
+ padding: 0 !important;
+}
+.emby-scroller-container[data-card-format="Button"] .card > .cardBox {
+ width: 100%;
+ justify-content: center;
+ flex-direction: column;
+ display: flex;
+ padding: 1rem !important;
+ margin-right: 0 !important;
+ margin-left: 0 !important;
+}
+.emby-scroller-container[data-card-format="Button"] .card {
+ width: auto !important;
+}
+.emby-scroller-container[data-card-format="Button"] .itemsContainer {
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+.emby-scroller-container[data-card-format="Button"] {
+ margin-bottom: 1rem;
+}
+
+.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-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;
+ background-color: transparent !important;
+}
+
+/* Skeletons need a visible padder even when Logo/Clear Art/Disc force transparent cards */
+.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardPadder,
+.emby-scroller-container[data-card-format="Logo"] .skeleton-card .cardScalable,
+.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardPadder,
+.emby-scroller-container[data-card-format="Clear Art"] .skeleton-card .cardScalable,
+.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardPadder,
+.emby-scroller-container[data-card-format="Disc"] .skeleton-card .cardScalable,
+.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardPadder,
+.emby-scroller-container[data-section-id="liveTv-channels"] .skeleton-card .cardScalable {
+ background: rgba(255, 255, 255, 0.12) !important;
+}
+
+.emby-scroller-container[data-card-format="Logo"] .cardImageContainer,
+.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer,
+.emby-scroller-container[data-section-id="liveTv-channels"] .cardImageContainer {
+ background-size: contain !important;
+ box-shadow: none !important;
+}
+
+.emby-scroller-container[data-card-format="Banner"] .card {
+ width: 33vw !important;
+}
+
+.layout-mobile .emby-scroller-container[data-card-format="Banner"] .card {
+ width: 95vw !important;
+}
+
+/* .emby-scroller-container[data-card-format="Logo"] .cardImageContainer,
+.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer,
+.emby-scroller-container[data-card-format="Disc"] .cardImageContainer {
+ transition: opacity 0.15s ease-in-out;
+}
+
+.emby-scroller-container[data-card-format="Logo"] .cardImageContainer.lazy-hidden,
+.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer.lazy-hidden,
+.emby-scroller-container[data-card-format="Disc"] .cardImageContainer.lazy-hidden {
+ opacity: 0;
+}
+
+.emby-scroller-container[data-card-format="Logo"] .cardImageContainer.lazy-loaded,
+.emby-scroller-container[data-card-format="Clear Art"] .cardImageContainer.lazy-loaded,
+.emby-scroller-container[data-card-format="Disc"] .cardImageContainer.lazy-loaded {
+ opacity: 1;
+} */
+
+:not(.layout-mobile) .emby-scroller-container[data-card-format="Logo"]:not([data-border-style]) .itemsContainer,
+:not(.layout-mobile) .emby-scroller-container[data-card-format="Clear Art"]:not([data-border-style]) .itemsContainer,
+:not(.layout-mobile) .emby-scroller-container[data-card-format="Disc"]:not([data-border-style]) .itemsContainer {
+ gap: 4rem !important;
+}
+
+.layout-mobile .emby-scroller-container[data-card-format="Logo"]:not([data-border-style]) .itemsContainer,
+.layout-mobile .emby-scroller-container[data-card-format="Clear Art"]:not([data-border-style]) .itemsContainer,
+.layout-mobile .emby-scroller-container[data-card-format="Disc"]:not([data-border-style]) .itemsContainer {
+ gap: 2rem !important;
+}
+
+.emby-scroller-container[data-hide-card-footer] .card .cardText {
+ display: none;
+}
+
+.spotlight-header-container {
+ position: absolute;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: calc(100% - 2 * var(--spotlight-fullscreen-horizontal, max(env(safe-area-inset-left), 3.3%)));
+ left: var(--spotlight-fullscreen-horizontal, max(env(safe-area-inset-left), 3.3%));
+ isolation: isolate;
+ transform: translateZ(0);
+ pointer-events: none;
+}
+
+.spotlight-section-title-container {
+ display: flex;
+ align-items: center;
+ gap: 0.35em;
+ min-width: 0;
+}
+
+.layout-mobile .spotlight-section-title-container .section-controls {
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+.spotlight-rating-container:hover {
+ background-color: transparent !important;
+ cursor: auto;
+}
+
+.spotlight-section .emby-tab-button {
+ text-shadow: none !important;
+}
+
+.custom-scroller-container {
+ position: relative;
+ z-index: 1;
+}
+
+/* Desktop custom path: clip frame; slidee uses translateX. Mobile .scrollX keeps native overflow. */
+.custom-scroller-container .custom-scroller:not(.scrollX),
+.emby-scroller-container .custom-scroller:not(.scrollX) {
+ overflow: hidden;
+}
+
+.custom-scroller-container .scrollSlider,
+.emby-scroller-container .scrollSlider {
+ will-change: transform;
+}
+
+[dir="ltr"] .emby-scrollbuttons {
+ right: 0;
+}
+
+.emby-scrollbuttons {
+ -webkit-align-items: center;
+ align-items: center;
+ color: #fff;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-justify-content: center;
+ justify-content: center;
+ min-height: 24px;
+ min-width: 104px;
+ padding-top: 0.85em;
+ position: absolute;
+ top: 0;
+ z-index: 1;
+}
+
+/* Section-level card title presentation */
+.verticalSection .cardText,
+.emby-scroller-container .cardText {
+ color: var(--kefin-card-title-color, inherit) !important;
+}
+
+.verticalSection[data-card-title-visibility="hidden"] .cardText,
+.verticalSection[data-card-title-visibility="hidden"] .cardFooter,
+.verticalSection[data-card-title-visibility="hidden"] .cardTextStack,
+.emby-scroller-container[data-card-title-visibility="hidden"] .cardText,
+.emby-scroller-container[data-card-title-visibility="hidden"] .cardFooter,
+.emby-scroller-container[data-card-title-visibility="hidden"] .cardTextStack {
+ display: none !important;
+}
+
+.verticalSection[data-card-title-capitalization="uppercase"] .cardText,
+.emby-scroller-container[data-card-title-capitalization="uppercase"] .cardText {
+ text-transform: uppercase;
+}
+
+.verticalSection[data-card-title-capitalization="lowercase"] .cardText,
+.emby-scroller-container[data-card-title-capitalization="lowercase"] .cardText {
+ text-transform: lowercase;
+}
+
+.verticalSection[data-card-title-capitalization="capitalize"] .cardText,
+.emby-scroller-container[data-card-title-capitalization="capitalize"] .cardText {
+ text-transform: capitalize;
+}
+
+.verticalSection[data-font-family="alfa-slab-one"] .cardText,
+.emby-scroller-container[data-font-family="alfa-slab-one"] .cardText {
+ font-family: "Alfa Slab One", serif;
+}
+
+.verticalSection[data-font-family="anton"] .cardText,
+.emby-scroller-container[data-font-family="anton"] .cardText {
+ font-family: Anton, sans-serif;
+}
+
+.verticalSection[data-font-family="changa-one"] .cardText,
+.emby-scroller-container[data-font-family="changa-one"] .cardText {
+ font-family: "Changa One", cursive;
+}
+
+.verticalSection[data-font-family="exo"] .cardText,
+.emby-scroller-container[data-font-family="exo"] .cardText {
+ font-family: Exo, sans-serif;
+}
+
+.verticalSection[data-font-family="orbitron"] .cardText,
+.emby-scroller-container[data-font-family="orbitron"] .cardText {
+ font-family: Orbitron, sans-serif;
+}
+
+.verticalSection[data-font-family="oswald"] .cardText,
+.emby-scroller-container[data-font-family="oswald"] .cardText {
+ font-family: Oswald, sans-serif;
+}
+
+.verticalSection[data-font-family="prata"] .cardText,
+.emby-scroller-container[data-font-family="prata"] .cardText {
+ font-family: Prata, serif;
+}
+
+.verticalSection[data-font-family="roboto"] .cardText,
+.emby-scroller-container[data-font-family="roboto"] .cardText {
+ font-family: Roboto, sans-serif;
+}
+
+.verticalSection[data-font-family="unbounded"] .cardText,
+.emby-scroller-container[data-font-family="unbounded"] .cardText {
+ font-family: Unbounded, sans-serif;
+}
+
+.verticalSection[data-font-family="vt323"] .cardText,
+.emby-scroller-container[data-font-family="vt323"] .cardText {
+ font-family: VT323, monospace;
+}
+
+.verticalSection[data-font-family="verdana"] .cardText,
+.emby-scroller-container[data-font-family="verdana"] .cardText {
+ font-family: Verdana, Geneva, sans-serif;
+}
+
+.verticalSection[data-font-family="courier-new"] .cardText,
+.emby-scroller-container[data-font-family="courier-new"] .cardText {
+ font-family: "Courier New", Courier, monospace;
+}
+
+.verticalSection[data-font-size="small"] .cardText,
+.emby-scroller-container[data-font-size="small"] .cardText {
+ font-size: 0.8em;
+}
+
+.verticalSection[data-font-size="large"] .cardText,
+.emby-scroller-container[data-font-size="large"] .cardText {
+ font-size: 1.25em;
+}
+
+.verticalSection[data-card-title-position="left"] .cardText,
+.verticalSection[data-card-title-position$="-left"] .cardText,
+.emby-scroller-container[data-card-title-position="left"] .cardText,
+.emby-scroller-container[data-card-title-position$="-left"] .cardText {
+ text-align: left !important;
+}
+
+.verticalSection[data-card-title-position="center"] .cardText,
+.verticalSection[data-card-title-position="overlay-center"] .cardText,
+.verticalSection[data-card-title-position$="-center"] .cardText,
+.emby-scroller-container[data-card-title-position="center"] .cardText,
+.emby-scroller-container[data-card-title-position="overlay-center"] .cardText,
+.emby-scroller-container[data-card-title-position$="-center"] .cardText {
+ text-align: center !important;
+}
+
+.verticalSection[data-card-title-position="right"] .cardText,
+.verticalSection[data-card-title-position$="-right"] .cardText,
+.emby-scroller-container[data-card-title-position="right"] .cardText,
+.emby-scroller-container[data-card-title-position$="-right"] .cardText {
+ text-align: right !important;
+}
+
+/* Overlay titles: real .cardTextStack over the image (title + footers) */
+.verticalSection[data-card-title-position^="overlay-"] .card > .cardBox,
+.emby-scroller-container[data-card-title-position^="overlay-"] .card > .cardBox {
+ position: relative;
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardBox.cardBox-bottompadded,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardBox.cardBox-bottompadded {
+ padding-bottom: 0 !important;
+}
+
+/* Keep cardScalable without a z-index so overlay chrome (z-index 3) can sit above the text stack (z-index 2). */
+.verticalSection[data-card-title-position^="overlay-"] .cardScalable,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable {
+ position: relative;
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardScalable > .cardOverlayContainer,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable > .cardOverlayContainer {
+ z-index: 3;
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardScalable > .cardBorder,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable > .cardBorder {
+ z-index: 1;
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardTextStack,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack {
+ position: absolute;
+ z-index: 2;
+ left: 0;
+ right: 0;
+ width: 100%;
+ max-width: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25em;
+ margin: 0;
+ padding: 1.35em 0.85em;
+ box-sizing: border-box;
+ pointer-events: none;
+ color: var(--kefin-card-title-color, #fff);
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardScalable::after,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardScalable::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 0) 75%);
+}
+
+.verticalSection[data-card-title-position^="overlay-top"] .cardScalable::after,
+.emby-scroller-container[data-card-title-position^="overlay-top"] .cardScalable::after {
+ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 50%);
+}
+
+.verticalSection[data-card-title-position^="overlay-bottom"] .cardScalable::after,
+.emby-scroller-container[data-card-title-position^="overlay-bottom"] .cardScalable::after {
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 50%);
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardTextStack .cardText,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack .cardText {
+ position: static;
+ margin: 0;
+ padding: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+ color: inherit;
+ overflow: visible;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ background: none;
+ text-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.75),
+ 0 0 10px rgba(0, 0, 0, 0.45);
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardTextStack > .cardFooter,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack > .cardFooter {
+ display: flex;
+ flex-direction: column;
+ align-items: inherit;
+ gap: 0.1em;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ background: none;
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardTextStack .cardText-first,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack .cardText-first {
+ font-size: 1.5rem;
+ line-height: 1.5rem;
+ white-space: pre-line;
+ border-radius: 0 !important;
+}
+
+.verticalSection[data-card-title-position^="overlay-"] .cardTextStack .cardText-secondary,
+.emby-scroller-container[data-card-title-position^="overlay-"] .cardTextStack .cardText-secondary {
+ font-size: 0.95rem;
+ opacity: 0.92;
+ border-radius: 0 !important;
+}
+
+.verticalSection[data-card-title-position^="overlay-"][data-font-size="small"] .cardTextStack .cardText-first,
+.emby-scroller-container[data-card-title-position^="overlay-"][data-font-size="small"] .cardTextStack .cardText-first {
+ font-size: 1.2rem;
+}
+
+.verticalSection[data-card-title-position^="overlay-"][data-font-size="large"] .cardTextStack .cardText-first,
+.emby-scroller-container[data-card-title-position^="overlay-"][data-font-size="large"] .cardTextStack .cardText-first {
+ font-size: 1.875rem;
+}
+
+.verticalSection[data-card-title-position="overlay-top-left"] .cardTextStack,
+.verticalSection[data-card-title-position="overlay-top-center"] .cardTextStack,
+.verticalSection[data-card-title-position="overlay-top-right"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-top-left"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-top-center"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-top-right"] .cardTextStack {
+ /* Solid at top → fade out toward bottom */
+ padding: 0.9em 1.1em;
+ border-top-left-radius: var(--kefin-card-radius, 0);
+ border-top-right-radius: var(--kefin-card-radius, 0);
+ overflow: hidden;
+}
+
+.verticalSection[data-card-title-position="overlay-bottom-left"] .cardTextStack,
+.verticalSection[data-card-title-position="overlay-bottom-center"] .cardTextStack,
+.verticalSection[data-card-title-position="overlay-bottom-right"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-bottom-left"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-bottom-center"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-bottom-right"] .cardTextStack {
+ /* Solid at bottom → fade out toward top */
+ padding: 0.9em 1.1em;
+ border-bottom-left-radius: var(--kefin-card-radius, 0);
+ border-bottom-right-radius: var(--kefin-card-radius, 0);
+ overflow: hidden;
+}
+
+.verticalSection[data-card-title-position="overlay-center-left"] .cardTextStack,
+.verticalSection[data-card-title-position="overlay-center-center"] .cardTextStack,
+.verticalSection[data-card-title-position="overlay-center-right"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-center-left"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-center-center"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-center-right"] .cardTextStack {
+ /* Solid at bottom → fade out toward top */
+ padding: 0 1.1em;
+ overflow: hidden;
+}
+
+.verticalSection[data-card-title-position="overlay-top-left"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-top-left"] .cardTextStack {
+ top: 0;
+}
+
+.verticalSection[data-card-title-position="overlay-top-center"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-top-center"] .cardTextStack {
+ top: 0;
+ align-items: center;
+ text-align: center;
+}
+
+.verticalSection[data-card-title-position="overlay-top-right"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-top-right"] .cardTextStack {
+ top: 0;
+ align-items: flex-end;
+ text-align: right;
+}
+
+.verticalSection[data-card-title-position="overlay-center-left"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-center-left"] .cardTextStack {
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.verticalSection[data-card-title-position="overlay-center"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-center"] .cardTextStack {
+ top: 50%;
+ transform: translateY(-50%);
+ align-items: center;
+ text-align: center;
+}
+
+.verticalSection[data-card-title-position="overlay-center-right"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-center-right"] .cardTextStack {
+ top: 50%;
+ transform: translateY(-50%);
+ align-items: flex-end;
+ text-align: right;
+}
+
+.verticalSection[data-card-title-position="overlay-bottom-left"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-bottom-left"] .cardTextStack {
+ bottom: 0;
+}
+
+.verticalSection[data-card-title-position="overlay-bottom-center"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-bottom-center"] .cardTextStack {
+ bottom: 0;
+ align-items: center;
+ text-align: center;
+}
+
+.verticalSection[data-card-title-position="overlay-bottom-right"] .cardTextStack,
+.emby-scroller-container[data-card-title-position="overlay-bottom-right"] .cardTextStack {
+ bottom: 0;
+ align-items: flex-end;
+ text-align: right;
+}
+
+/* ========== Card border overlay host (.cardBorder) ========== */
+.card .cardScalable > .cardBorder {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ box-sizing: border-box;
+ /* Section may override via --kefin-card-border-color */
+ --card-border-cream: var(--kefin-card-border-color, #e8dab2);
+ --card-border-fancy: var(--kefin-card-border-color, rgb(231 255 123 / 35%));
+}
+
+.card .cardScalable > .cardBorder > * {
+ box-sizing: border-box;
+ pointer-events: none;
+}
+
+[data-border="double"] .card .cardBorder[data-style="double"],
+[data-border-style="double"] .card .cardBorder[data-style="double"],
+.card .cardBorder[data-style="double"],
+[data-border="slim-fancy"] .card .cardBorder[data-style="slim-fancy"],
+[data-border-style="slim-fancy"] .card .cardBorder[data-style="slim-fancy"],
+.card .cardBorder[data-style="slim-fancy"] {
+ border: 10px solid transparent;
+ outline: 1px solid var(--card-border-cream) !important;
+ outline-offset: -5px;
+ background: conic-gradient(from 90deg at 1px 1px, #0000 25%, var(--card-border-cream) 0) 0 0 / calc(100% - 2px) calc(100% - 2px)
+ padding-box border-box !important;
+}
+
+/*
+ * Decorative borders that are always square — override skin radius on the card
+ * hosts and --kefin-card-radius so overlay text stacks stay square too.
+ */
+[data-border="retro-poster"],
+[data-border-style="retro-poster"],
+[data-border="modern-poster"],
+[data-border-style="modern-poster"],
+[data-border="picture-frame"],
+[data-border-style="picture-frame"],
+[data-border="art-gallery"],
+[data-border-style="art-gallery"],
+[data-border="wood"],
+[data-border-style="wood"],
+[data-border="profile"],
+[data-border-style="profile"],
+[data-border="film-reel"],
+[data-border-style="film-reel"] {
+ --kefin-card-radius: 0px;
+}
+
+[data-border="retro-poster"] .card .cardBox,
+[data-border="retro-poster"] .card .cardScalable,
+[data-border-style="retro-poster"] .card .cardBox,
+[data-border-style="retro-poster"] .card .cardScalable,
+[data-border="modern-poster"] .card .cardBox,
+[data-border="modern-poster"] .card .cardScalable,
+[data-border-style="modern-poster"] .card .cardBox,
+[data-border-style="modern-poster"] .card .cardScalable,
+[data-border="picture-frame"] .card .cardBox,
+[data-border="picture-frame"] .card .cardScalable,
+[data-border-style="picture-frame"] .card .cardBox,
+[data-border-style="picture-frame"] .card .cardScalable,
+[data-border="art-gallery"] .card .cardBox,
+[data-border="art-gallery"] .card .cardScalable,
+[data-border-style="art-gallery"] .card .cardBox,
+[data-border-style="art-gallery"] .card .cardScalable,
+[data-border="wood"] .card .cardBox,
+[data-border="wood"] .card .cardScalable,
+[data-border-style="wood"] .card .cardBox,
+[data-border-style="wood"] .card .cardScalable,
+[data-border="profile"] .card .cardBox,
+[data-border="profile"] .card .cardScalable,
+[data-border-style="profile"] .card .cardBox,
+[data-border-style="profile"] .card .cardScalable,
+[data-border="film-reel"] .card .cardBox,
+[data-border="film-reel"] .card .cardScalable,
+[data-border-style="film-reel"] .card .cardBox,
+[data-border-style="film-reel"] .card .cardScalable {
+ border-radius: 0 !important;
+}
+
+/*
+ * Retro Poster — bulb frame around marquee strip + poster.
+ * Solid red mid-band; top/bottom bulbs own corners; theater-track title above image.
+ */
+[data-border="retro-poster"],
+[data-border-style="retro-poster"] {
+ --rp-thin: 2px;
+ --rp-band: 16px;
+ --rp-inset: calc(var(--rp-thin) + var(--rp-band) + var(--rp-thin));
+ --rp-marquee-h: 2.15em;
+ /* Gold frames stay fixed; only the inner band follows border color */
+ --rp-gold-hi: #ffe9a0;
+ --rp-gold-lo: #c9a227;
+ --rp-red: var(--kefin-card-border-color, #c41e26);
+}
+
+/* Poster clears the placard + bulb frame; padder padding-top restores full poster height */
+[data-border="retro-poster"] .card .cardScalable > .cardImageContainer,
+[data-border-style="retro-poster"] .card .cardScalable > .cardImageContainer,
+[data-border="retro-poster"] .card .cardScalable > .cardOverlayContainer,
+[data-border-style="retro-poster"] .card .cardScalable > .cardOverlayContainer,
+[data-border="retro-poster"] .card .cardScalable > .blurhash-canvas,
+[data-border-style="retro-poster"] .card .cardScalable > .blurhash-canvas {
+ top: calc(var(--rp-inset) + var(--rp-marquee-h)) !important;
+ right: var(--rp-inset) !important;
+ bottom: var(--rp-inset) !important;
+ left: var(--rp-inset) !important;
+ width: calc(100% - var(--rp-inset) * 2) !important;
+ height: calc(100% - var(--rp-inset) * 2 - var(--rp-marquee-h)) !important;
+ border-radius: 0 !important;
+}
+
+[data-border="retro-poster"] .card,
+[data-border-style="retro-poster"] .card,
+[data-border="retro-poster"] .card .cardScalable,
+[data-border-style="retro-poster"] .card .cardScalable,
+[data-border="retro-poster"] .card .cardPadder,
+[data-border-style="retro-poster"] .card .cardPadder {
+ border-radius: 0 !important;
+}
+
+/* Grow the aspect box so the placard sits above the poster instead of shrinking it */
+[data-border="retro-poster"] .card .cardPadder,
+[data-border-style="retro-poster"] .card .cardPadder {
+ padding-top: var(--rp-marquee-h);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, #1e2029 0%, #0f1015 100%) !important;
+ backdrop-filter: blur(12px);
+}
+[data-card-format="Thumb"][data-border="retro-poster"] .card .cardPadder,
+[data-card-format="Thumb"][data-border-style="retro-poster"] .card .cardPadder,
+[data-card-format="Backdrop"][data-border="retro-poster"] .card .cardPadder,
+[data-card-format="Backdrop"][data-border-style="retro-poster"] .card .cardPadder,
+[data-card-format="Logo"][data-border="retro-poster"] .card .cardPadder,
+[data-card-format="Logo"][data-border-style="retro-poster"] .card .cardPadder,
+[data-card-format="Clear Art"][data-border="retro-poster"] .card .cardPadder,
+[data-card-format="Clear Art"][data-border-style="retro-poster"] .card .cardPadder {
+ padding-top: calc(var(--rp-marquee-h) + var(--rp-inset));
+}
+[data-card-format="Poster"][data-border="retro-poster"] .card .cardPadder,
+[data-card-format="Poster"][data-border-style="retro-poster"] .card .cardPadder {
+ padding-top: initial;
+}
+[data-card-format="Banner"][data-border="retro-poster"] .card .cardPadder,
+[data-card-format="Banner"][data-border-style="retro-poster"] .card .cardPadder {
+ padding-top: calc(var(--rp-marquee-h) + calc(var(--rp-inset) * 2));
+}
+
+/* Theater letter-board strip above the poster, inside the frame */
+[data-border="retro-poster"] .card .cardScalable > .retroPosterMarquee,
+[data-border-style="retro-poster"] .card .cardScalable > .retroPosterMarquee {
+ position: absolute;
+ top: var(--rp-inset);
+ left: var(--rp-inset);
+ right: var(--rp-inset);
+ height: var(--rp-marquee-h);
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ box-sizing: border-box;
+ padding: 0 0.35em;
+ container-type: inline-size;
+ container-name: retro-marquee;
+ /* Off-white board + vertical posts + horizontal letter tracks */
+ background-color: #e6e2d8;
+ background-image:
+ repeating-linear-gradient(90deg, transparent 0, transparent 22px, rgba(60, 45, 20, 0.12) 22px, rgba(60, 45, 20, 0.12) 24px),
+ repeating-linear-gradient(180deg, transparent 0, transparent 3px, #1c1c1c78 3px, #1c1c1c17 4px);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.2);
+}
+
+[data-border="retro-poster"] .card .retroPosterMarquee-label,
+[data-border-style="retro-poster"] .card .retroPosterMarquee-label {
+ position: relative;
+ z-index: 1;
+ margin: 0;
+ font-family: Oswald, "Arial Narrow", sans-serif;
+ font-weight: 700;
+ font-size: 16px;
+ letter-spacing: 0.12em;
+ line-height: 1;
+ text-transform: uppercase;
+ color: #1e3a8a;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+}
+
+.card .cardBorder[data-style="retro-poster"] {
+ box-shadow:
+ inset 0 0 0 1px var(--rp-gold-hi),
+ inset 0 0 0 var(--rp-thin) var(--rp-gold-lo);
+}
+
+.card .cardBorder[data-style="retro-poster"]::before {
+ content: "";
+ position: absolute;
+ inset: var(--rp-thin);
+ padding: var(--rp-band);
+ box-sizing: border-box;
+ pointer-events: none;
+ background: var(--rp-red);
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+}
+
+.card .cardBorder[data-style="retro-poster"]::after {
+ content: "";
+ position: absolute;
+ inset: calc(var(--rp-thin) + var(--rp-band));
+ pointer-events: none;
+ box-shadow:
+ inset 0 0 0 1px var(--rp-gold-hi),
+ inset 0 0 0 var(--rp-thin) var(--rp-gold-lo);
+}
+
+/* Corner + edge circles: bulb on each corner, even spacing between on each side */
+.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulbs {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulb-glow {
+ fill: rgba(255, 120, 20, 0.45);
+}
+
+.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulb-mid {
+ fill: rgba(255, 236, 160, 0.95);
+}
+
+.card .cardBorder[data-style="retro-poster"] .cardBorder-rp-bulb-core {
+ fill: #ffffff;
+}
+
+/* Stairstep — triple-layer stepped corners (CodePen stairstep geometry) */
+.card .cardBorder[data-style="stairstep"] {
+ display: grid;
+ grid-template-columns:
+ [outer-start] 5px
+ [mid-start] 5px
+ [inner-start] 1fr
+ [inner-end] 5px
+ [mid-end] 5px
+ [outer-end];
+ grid-template-rows:
+ [inner-start] 5px
+ [mid-start] 5px
+ [outer-start] 1fr
+ [outer-end] 5px
+ [mid-end] 5px
+ [inner-end];
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-outer-left {
+ grid-column: outer-start / mid-start;
+ grid-row: outer-start / outer-end;
+ border-left: 1px solid var(--card-border-cream);
+ border-top: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-outer-right {
+ grid-column: mid-end / outer-end;
+ grid-row: outer-start / outer-end;
+ border-right: 1px solid var(--card-border-cream);
+ border-top: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-mid-left {
+ grid-column: mid-start / inner-start;
+ grid-row: mid-start / mid-end;
+ border-top: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-mid-right {
+ grid-column: inner-end / mid-end;
+ grid-row: mid-start / mid-end;
+ border-top: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-mid-top {
+ grid-column: mid-start / mid-end;
+ grid-row: outer-start / mid-start;
+ border-left: 1px solid var(--card-border-cream);
+ border-right: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-mid-bottom {
+ grid-column: mid-start / mid-end;
+ grid-row: mid-end / outer-end;
+ border-left: 1px solid var(--card-border-cream);
+ border-right: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-inner-top {
+ grid-column: inner-start / inner-end;
+ grid-row: inner-start / mid-start;
+ border-left: 1px solid var(--card-border-cream);
+ border-right: 1px solid var(--card-border-cream);
+ border-top: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="stairstep"] .cardBorder-inner-bottom {
+ grid-column: inner-start / inner-end;
+ grid-row: mid-end / inner-end;
+ border-left: 1px solid var(--card-border-cream);
+ border-right: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+/* Square — dual rails + nested corner squares (CodePen square geometry) */
+.card .cardBorder[data-style="square"] {
+ display: grid;
+ grid-template-columns:
+ [outer-start] 5px
+ [mid-start] 5px
+ [inner-start] 2px
+ [gap-start] 1fr
+ [gap-end] 2px
+ [inner-end] 5px
+ [mid-end] 5px
+ [outer-end];
+ grid-template-rows:
+ [outer-start] 5px
+ [mid-start] 5px
+ [inner-start] 2px
+ [gap-start] 1fr
+ [gap-end] 2px
+ [inner-end] 5px
+ [mid-end] 5px
+ [outer-end];
+}
+
+.card .cardBorder[data-style="square"] .cardBorder-inner {
+ grid-area: mid;
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="square"] .cardBorder-square-left-top {
+ grid-column: outer-start / inner-start;
+ grid-row: outer-start / inner-start;
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="square"] .cardBorder-square-left-bottom {
+ grid-column: outer-start / inner-start;
+ grid-row: inner-end / outer-end;
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="square"] .cardBorder-square-right-top {
+ grid-column: inner-end / outer-end;
+ grid-row: outer-start / inner-start;
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="square"] .cardBorder-square-right-bottom {
+ grid-column: inner-end / outer-end;
+ grid-row: inner-end / outer-end;
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="square"] .cardBorder-tall {
+ grid-column: gap-start / gap-end;
+ grid-row: outer-start / outer-end;
+ border-top: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="square"] .cardBorder-wide {
+ grid-column: outer-start / outer-end;
+ grid-row: gap-start / gap-end;
+ border-left: 1px solid var(--card-border-cream);
+ border-right: 1px solid var(--card-border-cream);
+}
+
+/* Triple — concentric tall/mid/wide frames */
+.card .cardBorder[data-style="triple"] {
+ display: grid;
+ grid-template-columns:
+ [wide-start] 5px
+ [mid-start] 5px
+ [tall-start] 1fr
+ [tall-end] 5px
+ [mid-end] 5px
+ [wide-end];
+ grid-template-rows:
+ [tall-start] 5px
+ [mid-start] 5px
+ [wide-start] 1fr
+ [wide-end] 5px
+ [mid-end] 5px
+ [tall-end];
+}
+
+.card .cardBorder[data-style="triple"] .cardBorder-tall {
+ grid-area: tall;
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="triple"] .cardBorder-mid {
+ grid-area: mid;
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="triple"] .cardBorder-wide {
+ grid-area: wide;
+ border: 1px solid var(--card-border-cream);
+}
+
+/* Diamond — corner diamonds + dual tall/wide rails */
+.card .cardBorder[data-style="diamond"] {
+ display: grid;
+ grid-template-columns:
+ [outer-start] 5px
+ [mid-start] 5px
+ [inner-start] 3px
+ [gap-start] 1fr
+ [gap-end] 3px
+ [inner-end] 5px
+ [mid-end] 5px
+ [outer-end];
+ grid-template-rows:
+ [outer-start] 5px
+ [mid-start] 5px
+ [inner-start] 3px
+ [gap-start] 1fr
+ [gap-end] 3px
+ [inner-end] 5px
+ [mid-end] 5px
+ [outer-end];
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-square-left-top {
+ grid-column: outer-start / inner-start;
+ grid-row: outer-start / inner-start;
+ transform: rotate(45deg);
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-square-left-bottom {
+ grid-column: outer-start / inner-start;
+ grid-row: inner-end / outer-end;
+ transform: rotate(45deg);
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-square-right-top {
+ grid-column: inner-end / outer-end;
+ grid-row: outer-start / inner-start;
+ transform: rotate(45deg);
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-square-right-bottom {
+ grid-column: inner-end / outer-end;
+ grid-row: inner-end / outer-end;
+ transform: rotate(45deg);
+ border: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-tall-outer {
+ grid-column: gap-start / gap-end;
+ grid-row: outer-start / outer-end;
+ border-top: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-tall-inner {
+ grid-column: gap-start / gap-end;
+ grid-row: inner-start / inner-end;
+ border-top: 1px solid var(--card-border-cream);
+ border-bottom: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-wide-outer {
+ grid-column: outer-start / outer-end;
+ grid-row: gap-start / gap-end;
+ border-left: 1px solid var(--card-border-cream);
+ border-right: 1px solid var(--card-border-cream);
+}
+
+.card .cardBorder[data-style="diamond"] .cardBorder-wide-inner {
+ grid-column: inner-start / inner-end;
+ grid-row: gap-start / gap-end;
+ border-left: 1px solid var(--card-border-cream);
+ border-right: 1px solid var(--card-border-cream);
+}
+
+/* ========== Corner ==========
+ Inset overlay frame + mirrored corner flourishes (stays inside the card). */
+.card .cardBorder[data-style="corner"] {
+ --corner-inset: 0.5rem;
+ --corner-sq: 0.4rem;
+ --corner-arm: 1.25rem;
+ --corner-gap: 0.7rem;
+ --corner-line: var(--card-border-cream);
+ /* Shrink host so the frame sits inside the poster as an overlay */
+ inset: var(--corner-inset);
+ border: 1px solid var(--corner-line);
+ overflow: visible;
+ background: transparent;
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner {
+ position: absolute;
+ width: var(--corner-sq);
+ height: var(--corner-sq);
+ /* content-box keeps abspos math identical on left vs right */
+ box-sizing: content-box;
+ border: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner::after,
+.card .cardBorder[data-style="corner"] .cardBorder-corner::before {
+ content: "";
+ position: absolute;
+ box-sizing: border-box;
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner::after {
+ width: var(--corner-arm);
+ height: var(--corner-gap);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner::before {
+ width: var(--corner-gap);
+ height: var(--corner-arm);
+}
+
+/* Center each square on the inset frame corner (symmetric L/R + T/B) */
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tl {
+ top: calc(var(--corner-sq) / -2 - 1px);
+ left: calc(var(--corner-sq) / -2 - 1px);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tr {
+ top: calc(var(--corner-sq) / -2 - 1px);
+ right: calc(var(--corner-sq) / -2 - 1px);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-bl {
+ bottom: calc(var(--corner-sq) / -2 - 1px);
+ left: calc(var(--corner-sq) / -2 - 1px);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-br {
+ bottom: calc(var(--corner-sq) / -2 - 1px);
+ right: calc(var(--corner-sq) / -2 - 1px);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::after,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::after {
+ left: calc(var(--corner-gap) - 1px);
+ border-left: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::before,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::before {
+ left: -1px;
+ border-left: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::after,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-br::after {
+ right: calc(var(--corner-gap) - 1px);
+ border-right: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::before,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-br::before {
+ right: -1px;
+ border-right: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::after,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::after {
+ top: -1px;
+ border-top: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tl::before,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-tr::before {
+ top: calc(var(--corner-gap) - 1px);
+ border-top: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::after,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-br::after {
+ bottom: -1px;
+ border-bottom: 1px solid var(--corner-line);
+}
+
+.card .cardBorder[data-style="corner"] .cardBorder-corner-bl::before,
+.card .cardBorder[data-style="corner"] .cardBorder-corner-br::before {
+ bottom: calc(var(--corner-gap) - 1px);
+ border-bottom: 1px solid var(--corner-line);
+}
+
+/* ========== Picture Frame ==========
+ Frame stays inside the card; poster shrinks into the mat opening. */
+[data-border="picture-frame"],
+[data-border-style="picture-frame"] {
+ --pf-frame: 0.85rem;
+ --pf-mat: 0.55rem;
+ --pf-lip: 0.35rem;
+ /* Extra gap so the poster sits deeper in the mat opening */
+ --pf-gap: 0.45rem;
+ --pf-inset: calc(var(--pf-lip) + var(--pf-frame) + var(--pf-mat) + var(--pf-gap));
+ /* Raised lip/mat use border color; recessed rings use a slightly darker mix */
+ --pf-raised: var(--kefin-card-border-color, #f8f8f8);
+ --pf-recessed: color-mix(in srgb, var(--pf-raised) 78%, #000);
+}
+
+[data-border="picture-frame"] .card .cardScalable > .cardImageContainer,
+[data-border-style="picture-frame"] .card .cardScalable > .cardImageContainer,
+[data-border="picture-frame"] .card .cardScalable > .cardOverlayContainer,
+[data-border-style="picture-frame"] .card .cardScalable > .cardOverlayContainer,
+[data-border="picture-frame"] .card .cardScalable > .blurhash-canvas,
+[data-border-style="picture-frame"] .card .cardScalable > .blurhash-canvas {
+ top: calc(var(--pf-inset) + 0.25rem) !important;
+ right: calc(var(--pf-inset) + 0.25rem) !important;
+ bottom: calc(var(--pf-inset) + 0.25rem) !important;
+ left: calc(var(--pf-inset) + 0.25rem) !important;
+ width: calc(100% - calc(calc(var(--pf-inset) + 0.25rem) * 2)) !important;
+ height: calc(100% - calc(calc(var(--pf-inset) + 0.25rem) * 2)) !important;
+ border-radius: 0 !important;
+ z-index: 3 !important;
+ background-size: cover;
+}
+
+[data-border="picture-frame"] .card,
+[data-border-style="picture-frame"] .card,
+[data-border="picture-frame"] .card .cardScalable,
+[data-border-style="picture-frame"] .card .cardScalable,
+[data-border="picture-frame"] .card .cardPadder,
+[data-border-style="picture-frame"] .card .cardPadder {
+ border-radius: 0 !important;
+}
+
+[data-border="picture-frame"] .card .cardScalable {
+ padding: var(--pf-inset) !important;
+}
+
+.card .cardBorder[data-style="picture-frame"] {
+ --pf-frame: 1.25rem;
+ --pf-mat: 0.55rem;
+ --pf-lip: 0.5rem;
+ --pf-raised: var(--kefin-card-border-color, #f8f8f8);
+ --pf-recessed: color-mix(in srgb, var(--pf-raised) 95%, #000);
+ overflow: hidden;
+ /* Above padder so the frame is visible in the inset margins */
+ z-index: 2;
+}
+
+/* Mid frame / mat bed (1st inner) — recessed, slightly darker than border color */
+.card .cardBorder[data-style="picture-frame"]::before {
+ content: "";
+ position: absolute;
+ inset: var(--pf-lip);
+ z-index: 0;
+ background-color: var(--pf-recessed);
+ background-image: radial-gradient(
+ circle farthest-corner at 50% 50%,
+ color-mix(in srgb, var(--pf-recessed) 88%, #fff),
+ var(--pf-recessed) 100%
+ );
+ box-shadow:
+ inset 0 10px 0.5em rgba(0, 0, 0, 0.25),
+ inset 0.1em 0 0.1em rgba(0, 0, 0, 0.1),
+ inset -0.1em 0 0.1em rgba(0, 0, 0, 0.05),
+ 0 0.3em 0.2em color-mix(in srgb, var(--pf-raised) 55%, transparent);
+}
+
+/* Outer lip — fills the cardBorder host (no outside overflow).
+ Border color tints this raised outer band. */
+.card .cardBorder[data-style="picture-frame"]::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ background: var(--pf-raised);
+ box-shadow:
+ 0 1em 2em -1em rgba(0, 0, 0, 0.4),
+ 0 2em 2em -1em rgba(0, 0, 0, 0.3),
+ 0 3em 2em -1em rgba(0, 0, 0, 0.2),
+ 0 4em 1.5em -1em rgba(0, 0, 0, 0.15),
+ 0 2em 4em 0.5em rgba(0, 0, 0, 0.1),
+ inset 0 0.2em 0.1em color-mix(in srgb, var(--pf-raised) 70%, #fff);
+}
+
+/* Inner mat (2nd inner raised) + opening bed (3rd inner recessed) */
+.card .cardBorder[data-style="picture-frame"] .cardBorder-pf-inner {
+ position: absolute;
+ inset: calc(var(--pf-lip) + var(--pf-frame));
+ box-shadow: inset 0 0.3em 0.1em rgba(0, 0, 0, 0.2);
+ background: var(--pf-recessed);
+ box-sizing: border-box;
+}
+
+/* ========== Art Gallery ==========
+ Demo .Frame look: dark beveled rail, light mat, diagonal gloss, thin photo lip. */
+[data-border="art-gallery"],
+[data-border-style="art-gallery"] {
+ --ag-rail: 1.1rem;
+ --ag-mat: 0.5rem;
+ --ag-inset: calc(var(--ag-rail) + var(--ag-mat));
+ --ag-base: var(--kefin-card-border-color, #434040);
+ --ag-rail-top: color-mix(in srgb, var(--ag-base) 72%, #000);
+ --ag-rail-side: var(--ag-base);
+ --ag-rail-bottom: color-mix(in srgb, var(--ag-base) 88%, #fff);
+}
+
+[data-border="art-gallery"] .card .cardScalable > .cardImageContainer,
+[data-border-style="art-gallery"] .card .cardScalable > .cardImageContainer,
+[data-border="art-gallery"] .card .cardScalable > .cardOverlayContainer,
+[data-border-style="art-gallery"] .card .cardScalable > .cardOverlayContainer,
+[data-border="art-gallery"] .card .cardScalable > .blurhash-canvas,
+[data-border-style="art-gallery"] .card .cardScalable > .blurhash-canvas {
+ top: var(--ag-inset) !important;
+ right: var(--ag-inset) !important;
+ bottom: var(--ag-inset) !important;
+ left: var(--ag-inset) !important;
+ width: calc(100% - var(--ag-inset) * 2) !important;
+ height: calc(100% - var(--ag-inset) * 2) !important;
+ border-radius: 0 !important;
+ z-index: 3 !important;
+ background-size: cover;
+}
+
+[data-border="art-gallery"] .card .cardScalable > .cardImageContainer,
+[data-border-style="art-gallery"] .card .cardScalable > .cardImageContainer {
+ border-width: 2px !important;
+ border-style: solid !important;
+ border-color: #bbbab4 #c7c7bf #e5e4df #c7c7bf !important;
+ box-shadow:
+ 0 -1px 1px rgba(0, 0, 0, 0.1),
+ 0 1px 1px 1px rgba(255, 255, 255, 0.7) !important;
+ box-sizing: border-box !important;
+}
+
+[data-border="art-gallery"] .card,
+[data-border-style="art-gallery"] .card,
+[data-border="art-gallery"] .card .cardScalable,
+[data-border-style="art-gallery"] .card .cardScalable,
+[data-border="art-gallery"] .card .cardPadder,
+[data-border-style="art-gallery"] .card .cardPadder {
+ border-radius: 0 !important;
+}
+
+[data-border="art-gallery"] .card .cardScalable {
+ padding: calc(var(--ag-inset) * 2) !important;
+}
+
+.emby-scroller-container[data-border-style="art-gallery"] .card .cardBorder {
+ --ag-rail: 0.5rem;
+ --ag-base: var(--kefin-card-border-color, #434040);
+ --ag-rail-top: color-mix(in srgb, var(--ag-base) 72%, #000);
+ --ag-rail-side: var(--ag-base);
+ --ag-rail-bottom: color-mix(in srgb, var(--ag-base) 88%, #fff);
+ z-index: 2;
+ overflow: hidden;
+ box-sizing: border-box;
+ border-width: var(--ag-rail);
+ border-style: solid;
+ border-color: var(--ag-rail-top) var(--ag-rail-side) var(--ag-rail-bottom) var(--ag-rail-side);
+ background: #f5f5f5;
+ background-image: linear-gradient(#e5e4df, #cdcdc6);
+ box-shadow:
+ inset 0 2px 5px rgba(0, 0, 0, 0.45),
+ 0 3px 2px rgba(0, 0, 0, 0.08),
+ 0 8px 16px rgba(0, 0, 0, 0.45);
+}
+
+/* Diagonal gloss highlight over the mat */
+.emby-scroller-container[data-border-style="art-gallery"] .card .cardImageContainer::before,
+.emby-scroller-container[data-border-style="art-gallery"] .card .cardBorder::before {
+ content: "";
+ position: absolute;
+ top: -175px;
+ right: -20%;
+ height: 400px;
+ z-index: 27;
+ pointer-events: none;
+ transform: rotate(-40deg);
+ background-image: linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0));
+}
+
+.emby-scroller-container[data-border-style="art-gallery"] .card .cardBorder::before {
+ left: calc(50% - 14px);
+}
+
+.emby-scroller-container[data-border-style="art-gallery"] .cardImageContainer::before {
+ left: 50%;
+}
+
+/* ========== Modern Poster ==========
+ Bevel colors match demo: lighten(#000, 20%) / #000 */
+[data-border="modern-poster"],
+[data-border-style="modern-poster"] {
+ --mp-inset: 10px;
+}
+
+/* Shrink poster so the 10px bevel does not clip the image */
+[data-border="modern-poster"] .card .cardScalable > .cardImageContainer,
+[data-border-style="modern-poster"] .card .cardScalable > .cardImageContainer,
+[data-border="modern-poster"] .card .cardScalable > .cardOverlayContainer,
+[data-border-style="modern-poster"] .card .cardScalable > .cardOverlayContainer,
+[data-border="modern-poster"] .card .cardScalable > .blurhash-canvas,
+[data-border-style="modern-poster"] .card .cardScalable > .blurhash-canvas {
+ width: calc(100% - var(--mp-inset) * 4) !important;
+ height: calc(100% - var(--mp-inset) * 4) !important;
+ top: calc(var(--mp-inset) * 2) !important;
+ left: calc(var(--mp-inset) * 2) !important;
+ border-radius: 0 !important;
+ background-size: cover;
+}
+
+[data-border="modern-poster"] .cardPadder {
+ background: white !important;
+}
+
+/* Square corners so the poster meets the bevel flush */
+[data-border="modern-poster"] .card,
+[data-border-style="modern-poster"] .card,
+[data-border="modern-poster"] .card .cardScalable,
+[data-border-style="modern-poster"] .card .cardScalable,
+[data-border="modern-poster"] .card .cardPadder,
+[data-border-style="modern-poster"] .card .cardPadder {
+ border-radius: 0 !important;
+}
+
+[data-border="modern-poster"] .card .cardScalable {
+ padding: calc(var(--mp-inset) * 2) !important;
+ background: white !important;
+}
+
+.card .cardBorder[data-style="modern-poster"] {
+ border-style: solid;
+ border-width: var(--mp-inset, 15px);
+ border-top-color: #333;
+ border-right-color: #000;
+ border-bottom-color: #333;
+ border-left-color: #000;
+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.6);
+ background: transparent;
+}
+
+/* ========== Vintage ========== */
+.card .cardBorder[data-style="vintage"] {
+ overflow: visible;
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-outer {
+ position: absolute;
+ inset: 0;
+ border: 2px solid var(--card-border-cream);
+ padding: 4px;
+ box-sizing: border-box;
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-mid {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border: 4px solid var(--card-border-cream);
+ padding: 4px;
+ box-sizing: border-box;
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-inner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border: 2px solid var(--card-border-cream);
+ box-sizing: border-box;
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-corner {
+ position: absolute;
+ width: 3em;
+ height: 3em;
+ margin: -3px;
+ background-color: var(--card-border-cream);
+ background-image: none;
+ -webkit-mask-image: var(--v-corner-deco);
+ mask-image: var(--v-corner-deco);
+ -webkit-mask-size: contain;
+ mask-size: contain;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ mask-position: center;
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-tl {
+ left: 0;
+ top: 0;
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-tr {
+ top: 0;
+ right: 0;
+ transform: scaleX(-1);
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-br {
+ right: 0;
+ bottom: 0;
+ transform: scale(-1);
+}
+
+.card .cardBorder[data-style="vintage"] .cardBorder-v-corner-bl {
+ left: 0;
+ bottom: 0;
+ transform: scaleY(-1);
+}
+
+/* ========== Film Reel ==========
+ Black film frame by default; poster inset into the center “hole”
+ so top/bottom sprocket bands stay fully visible. */
+[data-border="film-reel"],
+[data-border-style="film-reel"] {
+ --fr-film: var(--kefin-card-border-color, #000000);
+ --fr-band: 22px;
+ --fr-side: 10px;
+ /* Sprocket perforations: taller than wide; spaced + inset so none clip */
+ --fr-hole-w: 8px;
+ --fr-hole-h: 12px;
+ --fr-hole-gap: 18px;
+ --fr-edge-inset: -8px;
+ --fr-hole-fill: color-mix(in srgb, var(--fr-film) 12%, #f2ead8);
+}
+
+/* Shrink poster into the center frame hole */
+[data-border="film-reel"] .card .cardScalable > .cardImageContainer,
+[data-border-style="film-reel"] .card .cardScalable > .cardImageContainer,
+[data-border="film-reel"] .card .cardScalable > .cardOverlayContainer,
+[data-border-style="film-reel"] .card .cardScalable > .cardOverlayContainer {
+ top: var(--fr-band) !important;
+ right: var(--fr-side) !important;
+ bottom: var(--fr-band) !important;
+ left: var(--fr-side) !important;
+ width: auto !important;
+ height: auto !important;
+ border-radius: 0 !important;
+}
+
+[data-border="film-reel"] .card,
+[data-border-style="film-reel"] .card,
+[data-border="film-reel"] .card .cardScalable,
+[data-border-style="film-reel"] .card .cardScalable,
+[data-border="film-reel"] .card .cardPadder,
+[data-border-style="film-reel"] .card .cardPadder {
+ border-radius: 0 !important;
+ border: none !important;
+}
+
+.card .cardBorder[data-style="film-reel"] {
+ /* Transparent center so the inset poster shows through; sides are solid rails */
+ background: transparent;
+ border-top: none;
+ border-bottom: none;
+ border-left: var(--fr-side) solid var(--fr-film);
+ border-right: var(--fr-side) solid var(--fr-film);
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+/* Top / bottom sprocket bands — corner holes inset, evenly spaced, never clipped */
+.card .cardBorder[data-style="film-reel"]::before,
+.card .cardBorder[data-style="film-reel"]::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: var(--fr-band);
+ box-sizing: border-box;
+ pointer-events: none;
+ z-index: 3;
+ margin: 0 var(--fr-edge-inset);
+ /* Hole centered in each tile so first/last stay inset; `space` evens the rest */
+ background-image:
+ linear-gradient(
+ 90deg,
+ transparent 0,
+ transparent calc(var(--fr-hole-gap) / 2),
+ var(--fr-hole-fill) calc(var(--fr-hole-gap) / 2) calc(var(--fr-hole-gap) / 2 + var(--fr-hole-w)),
+ transparent calc(var(--fr-hole-gap) / 2 + var(--fr-hole-w))
+ ),
+ linear-gradient(var(--fr-film), var(--fr-film));
+ background-size:
+ calc(var(--fr-hole-w) + var(--fr-hole-gap)) var(--fr-hole-h),
+ auto;
+ background-repeat:
+ space no-repeat,
+ no-repeat;
+ background-position:
+ left center,
+ center;
+ background-origin: content-box, border-box;
+ background-clip: content-box, border-box;
+}
+
+.card .cardBorder[data-style="film-reel"]::before {
+ top: 0;
+}
+
+.card .cardBorder[data-style="film-reel"]::after {
+ bottom: 0;
+}
+
+/* ========== Wood (bookshelf) ==========
+ wood.png under the poster; top/bottom shelf bars only (no side rails).
+ Tiles: half shelf height + container half padding so outer edges sum to full. */
+[data-border="wood"],
+[data-border-style="wood"] {
+ --wood-shelf-h-full: 14px;
+ --wood-shelf-h: var(--wood-shelf-h-full);
+ --wood-vert-img: url("../pages/images/wood.png");
+ --wood-shelf: color-mix(in srgb, var(--kefin-card-border-color, #6b492b) 40%, #6b492b);
+ /* Top-of-top and bottom-of-bottom share --wood-shelf-edge for seamless tiling */
+ --wood-shelf-edge: color-mix(in srgb, var(--wood-shelf) 82%, #8a6340);
+ --wood-shelf-mid: color-mix(in srgb, var(--wood-shelf) 70%, #3d2818);
+ --wood-shelf-top: linear-gradient(to bottom, var(--wood-shelf-edge), var(--wood-shelf-mid));
+ --wood-shelf-bottom: linear-gradient(to bottom, var(--wood-shelf-mid), var(--wood-shelf-edge));
+ --wood-bevel-hi: color-mix(in srgb, var(--wood-shelf) 40%, #e8d4b0);
+ --wood-bevel-lo: color-mix(in srgb, var(--wood-shelf) 40%, #1a0f08);
+}
+
+[data-border="wood"] .emby-scroller,
+[data-border-style="wood"] .emby-scroller {
+ margin-top: 0.5rem;
+}
+
+/* Row/grid: grain on each card. Tiles: container owns continuous grain (below). */
+[data-border="wood"] .card .cardScalable,
+[data-border-style="wood"] .card .cardScalable {
+ background-image: var(--wood-vert-img);
+ background-size: cover;
+ background-repeat: repeat;
+ background-position: center top;
+ border-radius: 0 !important;
+}
+
+[data-border="wood"] .card,
+[data-border-style="wood"] .card {
+ padding: 0;
+}
+
+[data-border="wood"] .itemsContainer[data-gapless="true"] .card .cardScalable,
+[data-border-style="wood"] .itemsContainer[data-gapless="true"] .card .cardScalable {
+ background-image: none;
+ background-color: transparent;
+}
+
+/* Gapless: half shelves + outer half-height shelf ring on the container */
+[data-border="wood"] .itemsContainer[data-gapless="true"],
+[data-border-style="wood"] .itemsContainer[data-gapless="true"] {
+ box-sizing: border-box;
+ background-color: transparent;
+ background-image: var(--wood-shelf-bottom), var(--wood-shelf-top), transparent;
+ background-size:
+ 100% var(--wood-shelf-h),
+ 100% var(--wood-shelf-h),
+ cover;
+ background-position:
+ top,
+ bottom,
+ center top;
+ background-repeat: no-repeat, no-repeat, repeat;
+}
+
+[data-card-title-visibility="hidden"][data-border-style="wood"] .itemsContainer[data-gapless="true"],
+[data-card-title-visibility="hidden"][data-border-style="wood"] .itemsContainer[data-layout="grid"] {
+ --wood-shelf-h: calc(var(--wood-shelf-h-full) / 2);
+ padding: var(--wood-shelf-h) 0;
+ background: var(--wood-shelf-edge);
+}
+
+/* Poster inset — top/bottom shelves only */
+[data-border="wood"] .card .cardScalable > .cardImageContainer,
+[data-border-style="wood"] .card .cardScalable > .cardImageContainer,
+[data-border="wood"] .card .cardScalable > .cardOverlayContainer,
+[data-border-style="wood"] .card .cardScalable > .cardOverlayContainer,
+[data-border="wood"] .card .cardScalable > .blurhash-canvas,
+[data-border-style="wood"] .card .cardScalable > .blurhash-canvas {
+ top: calc(var(--wood-shelf-h-full) * 2) !important;
+ right: var(--wood-shelf-h-full) !important;
+ bottom: var(--wood-shelf-h) !important;
+ left: var(--wood-shelf-h-full) !important;
+ width: calc(100% - var(--wood-shelf-h-full) * 2) !important;
+ height: calc(100% - calc(calc(var(--wood-shelf-h-full) * 2) + var(--wood-shelf-h))) !important;
+ border-radius: 0 !important;
+ z-index: 1 !important;
+ box-shadow: 0px -4px 10px 0px #000c;
+}
+
+[data-border-style="wood"] .card .cardScalable {
+ border: none !important;
+ box-shadow: none !important;
+}
+
+[data-border-style="wood"][data-card-format="Disc"] .card .cardScalable > .cardImageContainer,
+[data-border-style="wood"][data-card-format="Clear Art"] .card .cardScalable > .cardImageContainer,
+[data-border-style="wood"][data-card-format="Logo"] .card .cardScalable > .cardImageContainer {
+ box-shadow: none !important;
+ background-size: contain;
+ background-position: bottom center;
+}
+
+[data-border="wood"] .card,
+[data-border-style="wood"] .card,
+[data-border="wood"] .card .cardPadder,
+[data-border-style="wood"] .card .cardPadder {
+ border-radius: 0 !important;
+}
+
+/* Shelves only — full width, no side rails */
+.card .cardBorder[data-style="wood"] {
+ overflow: hidden;
+ z-index: 0;
+ box-sizing: border-box;
+ border: none;
+ background-image: var(--wood-vert-img);
+ background-size: cover;
+}
+
+.card .cardBorder[data-style="wood"]::before,
+.card .cardBorder[data-style="wood"]::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ width: auto;
+ height: var(--wood-shelf-h);
+ z-index: 3;
+ pointer-events: none;
+ box-sizing: border-box;
+}
+
+.card .cardBorder[data-style="wood"]::before {
+ top: 0;
+ background: var(--wood-shelf-top);
+ /* Bevel shadow along the inner (bottom) edge */
+ box-shadow: inset 0 -2px 0 var(--wood-bevel-lo);
+}
+
+.card .cardBorder[data-style="wood"]::after {
+ bottom: 0;
+ background: var(--wood-shelf-bottom);
+ /* Bevel highlight along the inner (top) edge */
+ box-shadow: inset 0 1px 0 var(--wood-bevel-hi);
+}
+
+/* ========== Profile ==========
+ Broken outer frame around the whole card; poster inset with its own thin
+ border; title sits in the side gaps; secondary sits in the bottom gap.
+ Card chrome is transparent (no white/cream fill). */
+[data-border="profile"],
+[data-border-style="profile"] {
+ --pr-c: var(--kefin-card-border-color, #c45c4a);
+ --pr-w: 2px;
+ --pr-pad: 1.85rem;
+ /* Vertical gap in the side rails where the primary title sits */
+ --pr-side-gap: 2.35em;
+ /* Height of the short side stubs that meet the bottom corners */
+ --pr-side-stub: 1.55em;
+ /* Half-width gap in the bottom rail for secondary text */
+ --pr-bottom-gap: 3.25em;
+}
+
+[data-border="profile"] .itemsContainer,
+[data-border-style="profile"] .itemsContainer,
+[data-border-style="profile"] .itemsContainer {
+ gap: 1.5rem;
+}
+
+[data-border="profile"] .card,
+[data-border-style="profile"] .card {
+ box-shadow: none;
+ background: transparent !important;
+ contain: none;
+ overflow: hidden;
+}
+
+[data-border="profile"] .card .cardBox,
+[data-border-style="profile"] .card .cardBox {
+ position: relative;
+ background-color: transparent !important;
+ /* Broken rectangle: top + upper sides, bottom stubs + bottom rail with center gap */
+ background-image:
+ linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)),
+ linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)), linear-gradient(var(--pr-c), var(--pr-c)),
+ linear-gradient(var(--pr-c), var(--pr-c));
+ background-size:
+ 100% var(--pr-w),
+ var(--pr-w) calc(100% - var(--pr-side-stub) - var(--pr-side-gap)),
+ var(--pr-w) calc(100% - var(--pr-side-stub) - var(--pr-side-gap)),
+ var(--pr-w) var(--pr-side-stub),
+ var(--pr-w) var(--pr-side-stub),
+ calc(50% - var(--pr-bottom-gap)) var(--pr-w),
+ calc(50% - var(--pr-bottom-gap)) var(--pr-w);
+ background-position:
+ top left,
+ top left,
+ top right,
+ bottom left,
+ bottom right,
+ bottom left,
+ bottom right;
+ background-repeat: no-repeat;
+ padding: var(--pr-pad) var(--pr-pad) 0;
+ box-sizing: border-box;
+ display: grid;
+ grid-template-rows: auto 1fr;
+}
+
+[data-border="profile"] .card .cardScalable,
+[data-border-style="profile"] .card .cardScalable {
+ padding: var(--pr-pad) !important;
+ background: transparent !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ border: none !important;
+}
+
+[data-border="profile"] .card .cardPadder,
+[data-border-style="profile"] .card .cardPadder {
+ background: transparent;
+}
+
+[data-border="profile"] .card .cardBox,
+[data-border-style="profile"] .cardBox {
+ height: calc(100% - var(--pr-pad));
+}
+
+[data-border="profile"] .card .cardPadder,
+[data-border-style="profile"] .card .cardPadder,
+[data-border="profile"] .card .cardScalable > .cardImageContainer,
+[data-border-style="profile"] .card .cardScalable > .cardImageContainer,
+[data-border="profile"] .card .cardScalable > .cardOverlayContainer,
+[data-border-style="profile"] .card .cardScalable > .cardOverlayContainer,
+[data-border="profile"] .card .cardScalable > .blurhash-canvas,
+[data-border-style="profile"] .card .cardScalable > .blurhash-canvas {
+ border-radius: 0 !important;
+}
+
+[data-border="profile"] .card .cardScalable > .blurhash-canvas,
+[data-border-style="profile"] .card .cardScalable > .blurhash-canvas {
+ top: 1rem;
+ left: 1rem;
+ width: calc(100% - 2rem);
+ height: calc(100% - 2rem);
+}
+
+[data-border="profile"] .card .cardScalable > .cardImageContainer,
+[data-border-style="profile"] .card .cardScalable > .cardImageContainer {
+ box-sizing: border-box !important;
+ clip-path: none !important;
+ box-shadow: none !important;
+ width: calc(100% - 2rem);
+ height: calc(100% - 2rem);
+ background-size: cover;
+ top: 1rem;
+ left: 1rem;
+}
+
+/* Frame is painted on .cardBox; hide the scalable overlay host */
+[data-border="profile"] .card .cardBorder,
+[data-border-style="profile"] .card .cardBorder {
+ display: none;
+}
+
+[data-border="profile"] .card .cardTextStack,
+[data-border-style="profile"] .card .cardTextStack {
+ display: grid;
+}
+
+[data-border="profile"] .card .cardText-first,
+[data-border-style="profile"] .card .cardText-first {
+ position: relative;
+ order: 2;
+ z-index: 1;
+ background: transparent !important;
+ color: var(--pr-c);
+ font-size: 1.05em;
+ width: calc(100% - 2 * var(--pr-pad));
+ margin: -0.55em auto 0;
+ padding: 0.35em 0;
+ text-align: center;
+ text-transform: uppercase;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ box-sizing: border-box;
+ /* Sit in the side-rail gap */
+ line-height: 1.2;
+ text-align: center !important;
+ white-space: normal;
+}
+
+[data-border="profile"] .card .cardText-first a,
+[data-border-style="profile"] .card .cardText-first a {
+ color: inherit !important;
+ font-weight: inherit;
+}
+
+[data-border="profile"] .card .cardText-secondary,
+[data-border-style="profile"] .card .cardText-secondary {
+ position: relative;
+ z-index: 1;
+ margin: 0.35em calc(-1 * var(--pr-pad)) 0;
+ width: calc(100% + 2 * var(--pr-pad));
+ padding: 0.15em var(--pr-pad) 0.65em;
+ text-align: center;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.78em;
+ font-weight: 500;
+ color: var(--pr-c);
+ opacity: 0.72;
+ background: transparent !important;
+ box-sizing: border-box;
+ /* Centered in the bottom-rail gap */
+ line-height: 1;
+ text-align: center !important;
+}
+
+[data-border="profile"] .card .cardText-secondary a,
+[data-border-style="profile"] .card .cardText-secondary a {
+ color: inherit !important;
+}
+
+[data-border-style="profile"] .cardTextStack .cardText-secondary:nth-child(2) {
+ order: 3;
+ margin-top: auto;
+}
+
+[data-border-style="profile"] .cardTextStack {
+ display: flex;
+ flex-direction: column;
+}
+
+[data-border="profile"] .card .cardFooter,
+[data-border-style="profile"] .card .cardFooter {
+ visibility: visible !important;
+ background: transparent !important;
+}
+
+.cardbuilder-items-fade {
+ transition: opacity 0.28s ease;
+}
+
+.cardbuilder-items-fade.is-hidden {
+ opacity: 0;
+}
+
+.cardbuilder-empty-message {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: 4.5em;
+ padding: 1em 1.25em;
+ box-sizing: border-box;
+ opacity: 0.55;
+ font-size: 1.05em;
+ text-align: center;
+}
+
+.cardbuilder-card-collapse,
+.cardbuilder-card-insert {
+ overflow: hidden !important;
+ pointer-events: none;
+}
+
+.itemsContainer.cardbuilder-row-animating .cardbuilder-card-collapse,
+.itemsContainer.cardbuilder-row-animating .cardbuilder-card-insert {
+ transition:
+ opacity 0.32s ease,
+ width 0.32s ease,
+ min-width 0.32s ease,
+ max-width 0.32s ease,
+ margin 0.32s ease;
+}
+
+.itemProgressBarForeground {
+ height: 100%;
+}
+
+.custom-scroller-container .sectionTitleContainer h2,
+.sectionTitleContainer span {
+ margin: 0 !important;
+}
+
+.custom-scroller .homeLibraryButton {
+ min-width: unset;
+}
+
+.missingIndicator,
+.unairedIndicator {
+ background: #c33;
+ border-radius: 100em;
+ color: #fff;
+ font-size: 84%;
+ font-weight: 500;
+ margin: 0 0.25em;
+ padding: 0.25em 0.5em;
+}
+
+.custom-scroller-container .sectionCaption.sectionTitleTextButton:not(.padded-left) {
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+}
+
+.emby-scroller-container[data-layout="grid"] .emby-scrollbuttons {
+ display: none !important;
+}
+
+.spotlight-section[data-tile-count="3"] .spotlight-background-layer {
+ width: 37.5%;
+}
+
+.emby-scroller-container[data-no-scroll="true"] .emby-scrollbuttons {
+ display: none;
+}
+
+.emby-scroller-container[data-layout="grid"] .emby-scrollbuttons {
+ display: none !important;
+}
+
+/*
+.spotlight-truncated-list:has(.spotlight-truncated-list-tooltip) {
+ overflow: visible;
+}
+
+.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:not(.hide) span.emby-tab-button.emby-tab-button-active:not(:has(a)) {
+ cursor: default;
+}
+
+.layout-mobile .spotlight-section .spotlight-section-title.sectionTabs:has(.emby-tab-button:not(.hide)) {
+ 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;
+ padding: 0.25em 0.5em !important;
+}
+
+.emby-scroller-container[data-border-style="profile"] .card .cardTextStack:has(.cardText:nth-child(2):last-child) .cardText-first {
+ margin-top: auto;
+} */
+
+.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;
+}
+
+.spotlight-section .spotlight-header-container > *,
+.spotlight-section .spotlight-section-title-container > *,
+.spotlight-section .spotlight-section-title-container .section-controls,
+.spotlight-section .spotlight-section-title-container a,
+.spotlight-section .spotlight-section-title-container button,
+.spotlight-section .spotlight-nav-container {
+ pointer-events: auto;
+}
+
+.sectionTitleContainer {
+ display: flex !important;
+}
+.cardOverlayContainer > .button-link {
+ width: 100%;
+ height: 100%;
+}
+
+.card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(3) {
+ display: none;
+}
+[data-section-id="liveTv-on-now"] .card[data-type="Program"] .cardTextStack .cardText-secondary:nth-child(2) {
+ display: none;
+}
+[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 .show-all-button,
+.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 .show-all-button,
+.layout-tv .spotlight-section .section-refresh-button,
+.layout-tv .spotlight-section .section-configure-button {
+ opacity: 0.65;
+ visibility: visible;
+ pointer-events: auto;
+}
+
+/* TV: title + controls share focuscontainer-x on .sectionTitleContainer / .sectionTitle-wrapper */
+.layout-tv .section-controls-inline {
+ display: inline-flex;
+ align-items: center;
+}
+
+.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;
+}
+
+.emby-scroller-container[data-section-id] {
+ z-index: 1;
+}
+
+/* TV metadata stop: look like the pill group, not a raised Jellyfin button */
+.layout-tv button.metadata-container.emby-button {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ gap: 8px;
+ overflow: visible;
+ min-width: 0;
+ width: auto;
+ background: transparent;
+ border: 0;
+ padding: 0;
+ margin: 0;
+ box-shadow: none;
+ font: inherit;
+ color: inherit;
+ text-align: inherit;
+ cursor: default;
+ -webkit-appearance: none;
+ appearance: none;
+ min-height: 0;
+ transform: none;
+}
+
+.section-controls span.material-icons {
+ font-size: 18px !important;
+}
+
+/* --- TV spotlight focus: solid accent pills --- */
+.layout-tv .spotlight-section-title .emby-button.show-focus:focus,
+.layout-tv .spotlight-section-title .emby-tab-button.show-focus:focus,
+.layout-tv .spotlight-section-title-container .show-all-button.show-focus:focus,
+.layout-tv .spotlight-section-title-container .section-refresh-button.show-focus:focus,
+.layout-tv .spotlight-section-title-container .section-configure-button.show-focus:focus,
+.layout-tv .sectionTitleContainer .show-all-button.show-focus:focus,
+.layout-tv .sectionTitleContainer .section-refresh-button.show-focus:focus,
+.layout-tv .sectionTitleContainer .section-configure-button.show-focus:focus,
+.layout-tv .spotlight-nav-container .spotlight-nav-button.show-focus:focus,
+.layout-tv .metadata-container a.spotlight-genre-link.show-focus:focus,
+.layout-tv .metadata-container a.spotlight-person-link.show-focus:focus,
+.layout-tv .metadata-container a.spotlight-studio-link.show-focus:focus {
+ background-color: var(--theme-primary-color, #00a4dc) !important;
+ background: var(--theme-primary-color, #00a4dc) !important;
+ color: #fff !important;
+ opacity: 1;
+ outline: none;
+ box-shadow: none;
+}
+
+.layout-tv .spotlight-section-title-container .show-all-button.show-focus:focus .material-icons,
+.layout-tv .spotlight-section-title-container .section-refresh-button.show-focus:focus .material-icons,
+.layout-tv .spotlight-section-title-container .section-configure-button.show-focus:focus .material-icons,
+.layout-tv .sectionTitleContainer .show-all-button.show-focus:focus .material-icons,
+.layout-tv .sectionTitleContainer .section-refresh-button.show-focus:focus .material-icons,
+.layout-tv .sectionTitleContainer .section-configure-button.show-focus:focus .material-icons,
+.layout-tv .sectionTitle-wrapper .show-all-button.show-focus:focus .material-icons,
+.layout-tv .sectionTitle-wrapper .section-refresh-button.show-focus:focus .material-icons,
+.layout-tv .sectionTitle-wrapper .section-configure-button.show-focus:focus .material-icons {
+ color: #fff !important;
+}
+
+.layout-tv .metadata-container.show-focus:focus,
+.layout-tv button.metadata-container.emby-button.show-focus:focus,
+.layout-tv button.metadata-container.emby-button.show-focus:focus:not(.skip),
+.layout-tv button.metadata-container.emby-button:focus {
+ border-radius: 0.2em;
+ outline: 2px solid var(--theme-primary-color, #00a4dc) !important;
+ outline-offset: 2px;
+ background: transparent !important;
+ box-shadow: none !important;
+ color: inherit !important;
+ /* Override Jellyfin emby-button TV focus scale (block is too large to grow) */
+ transform: none !important;
+ -webkit-transform: none !important;
+}
+
+.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-genre-link.show-focus:focus,
+.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-person-link.show-focus:focus,
+.layout-tv .metadata-container[data-metadata-drilled="true"] a.spotlight-studio-link.show-focus:focus {
+ border-radius: 0.15em;
+ padding: 0.1em 0.25em;
+}
+
+/* Transparent card formats: visible TV focus ring (cardBox chrome is forced transparent) */
+.layout-tv .emby-scroller-container[data-card-format="Logo"] .card.show-focus:focus .cardScalable,
+.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .card.show-focus:focus .cardScalable,
+.layout-tv .emby-scroller-container[data-card-format="Disc"] .card.show-focus:focus .cardScalable,
+.layout-tv .emby-scroller-container[data-section-id="liveTv-channels"] .card.show-focus:focus .cardScalable,
+.layout-tv .emby-scroller-container[data-card-format="Logo"] .cardButton-tv.show-focus:focus .cardScalable,
+.layout-tv .emby-scroller-container[data-card-format="Clear Art"] .cardButton-tv.show-focus:focus .cardScalable,
+.layout-tv .emby-scroller-container[data-card-format="Disc"] .cardButton-tv.show-focus:focus .cardScalable {
+ outline: 3px solid #fff;
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+/* --- JMP / no flex-gap: scoped margin fallbacks --- */
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-metadata-row {
+ gap: 0;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-metadata-row > * + * {
+ margin-left: 12px !important;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .metadata-container {
+ gap: 0;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .metadata-container > * + * {
+ margin-top: 8px !important;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-overlay {
+ gap: 0;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-overlay > * + * {
+ margin-top: 1em !important;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-buttons-container,
+html.no-flex-gap .spotlight-section:not(.hide) .mainDetailButtons.spotlight-buttons-container {
+ gap: 0;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-buttons-container > * + *,
+html.no-flex-gap .spotlight-section:not(.hide) .mainDetailButtons.spotlight-buttons-container > * + * {
+ margin-left: 2em !important;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .section-controls,
+html.no-flex-gap .spotlight-section:not(.hide) .section-controls-inline {
+ gap: 0;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .section-controls-inline > * + * {
+ margin-left: 0.15em !important;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .custom-scroller-container .sectionTitleContainer,
+html.no-flex-gap .spotlight-section:not(.hide) .emby-scroller-container .sectionTitleContainer {
+ gap: 0;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .sectionTitleContainer > * + * {
+ margin-left: 0.35em !important;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-nav-buttons-container {
+ gap: 0;
+}
+html.no-flex-gap .spotlight-section:not(.hide) .spotlight-nav-buttons-container > * + * {
+ margin-left: 8px !important;
+}
+html.no-flex-gap .itemsContainer:not([data-gapless="true"]) > * + * {
+ margin-left: 1em !important;
+}
+
+html.no-flex-gap .emby-scroller-container[data-card-format="Logo"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + *,
+html.no-flex-gap .emby-scroller-container[data-card-format="Clear Art"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + *,
+html.no-flex-gap .emby-scroller-container[data-card-format="Disc"]:not([data-border-style]) .itemsContainer:not([data-gapless="true"]) > * + * {
+ margin-left: 4em !important;
+}
+
+html.layout-tv .spotlight-section:not([data-layout="Borderless"]) .spotlight-overlay {
+ gap: 20px;
+ padding: 130px 40px 45px 40px !important;
+}
+html.layout-tv .spotlight-section[data-layout="Borderless"] .spotlight-overlay {
+ padding-top: 130px !important;
+}
+
+html.layout-tv .spotlight-section .spotlight-overlay span,
+html.layout-tv .spotlight-section .spotlight-overlay p {
+ font-size: 1em;
+}
+
+html.layout-tv .spotlight-section .spotlight-overlay .spotlight-truncated-list,
+html.layout-tv .spotlight-section .spotlight-overlay .spotlight-metadata-row {
+ font-size: revert;
+}
+
+html.layout-tv .spotlight-section .spotlight-overview {
+ display: block !important;
+}
+
+html.layout-tv .spotlight-section .spotlight-metadata-section {
+ display: none !important;
+}
+
+html.layout-tv .spotlight-section .spotlight-overlay .spotlight-overview {
+ max-width: unset;
+}
+
+html.layout-tv .spotlight-section:not([data-size="full"]) .spotlight-overlay .spotlight-overview {
+ -webkit-line-clamp: 2;
+}
+
+html.layout-tv .spotlight-section .spotlight-overlay {
+ grid-template-rows: 1fr auto auto;
+ grid-template-areas:
+ "logo"
+ "overview"
+ "buttons";
+}
+
+html.layout-tv .spotlight-section .spotlight-header-container {
+ top: 45px !important;
+}
+
+html.layout-tv .spotlight-section .spotlight-overview {
+ display: block !important;
+}
+
+html.layout-tv .spotlight-section .spotlight-metadata-section {
+ display: none !important;
+}
+
+html.layout-tv .spotlight-section .spotlight-tagline {
+ display: none !important;
+}
+
+/* --- Variant Row Layouts --- */
+
+.rows-2.focus-1 {
+ --r2-gap: 1.2em;
+ --r2-small: 16.5vw;
+ --r2-focus: calc(var(--r2-small) * 2 + var(--r2-gap) * 2);
+}
+
+.rows-2.focus-1.emby-scroller-container[data-card-format="Logo"],
+.rows-2.focus-1.emby-scroller-container[data-card-format="Clear Art"] {
+ --r2-gap: 5em;
+}
+
+.rows-2.focus-1.emby-scroller-container[data-card-format="Poster"],
+.rows-2.focus-1.emby-scroller-container[data-card-format="Series Poster"],
+.rows-2.focus-1.emby-scroller-container[data-card-format="Default"] {
+ --r2-small: 6.2vw;
+}
+
+.rows-2.focus-1.emby-scroller-container[data-card-format="Square"],
+.rows-2.focus-1.emby-scroller-container[data-card-format="Disc"] {
+ --r2-small: 9.3vw;
+}
+
+.rows-2.focus-1.emby-scroller-container[data-card-format="Banner"] {
+ --r2-small: 28vw;
+}
+
+@media (max-width: 900px) {
+ .rows-2.focus-1 {
+ --r2-gap: 0.4em;
+ --r2-small: 38.5vw;
+ --r2-focus: calc(var(--r2-small) * 2 + var(--r2-gap) * 2);
+ }
+
+ .rows-2.focus-1.emby-scroller-container[data-card-format="Poster"],
+ .rows-2.focus-1.emby-scroller-container[data-card-format="Series Poster"],
+ .rows-2.focus-1.emby-scroller-container[data-card-format="Default"] {
+ --r2-small: 25vw;
+ }
+
+ .rows-2.focus-1.emby-scroller-container[data-card-format="Square"],
+ .rows-2.focus-1.emby-scroller-container[data-card-format="Disc"] {
+ --r2-small: 32vw;
+ }
+
+ .rows-2.focus-1.emby-scroller-container[data-card-format="Banner"] {
+ --r2-small: 55vw;
+ }
+}
+
+.rows-2.focus-1.emby-scroller-container[data-card-format] .itemsContainer {
+ display: grid !important;
+ grid-template-rows: repeat(2, auto);
+ grid-auto-flow: column;
+ grid-auto-columns: var(--r2-small);
+ grid-template-columns: var(--r2-focus);
+ gap: var(--r2-gap) !important;
+ align-items: end;
+ white-space: normal;
+}
+
+.rows-2.focus-1 .sectionTitleContainer {
+ margin-bottom: 0.6em;
+}
+
+.rows-2.focus-1 .itemsContainer > .card {
+ width: var(--r2-small) !important;
+ min-width: 0;
+ max-width: none;
+ margin: 0;
+ padding: 0;
+ contain: none;
+}
+
+.rows-2.focus-1 .itemsContainer > .card:first-child {
+ grid-row: 1 / span 2;
+ width: auto !important; /* outer track; box grows via --r2-focus */
+ overflow: visible;
+ height: 100%;
+}
+
+.rows-2.focus-1 .itemsContainer > .card > .cardBox {
+ position: relative;
+ margin: 0 !important;
+}
+
+.rows-2.focus-1 .itemsContainer > .card:first-child > .cardBox {
+ width: var(--r2-focus);
+ contain: none !important;
+ overflow: visible !important;
+ height: 100%;
+ display: grid;
+ grid-template-rows: 1fr auto;
+}
+
+.rows-2.focus-1 .itemsContainer > .card:first-child .cardScalable {
+ height: 100%;
+ min-height: 0;
+}
+.rows-2.focus-1 .itemsContainer > .card:first-child .cardPadder {
+ background-color: transparent !important;
+ height: 100%;
+}
+.rows-2.focus-1 .itemsContainer > .card:first-child .cardImageContainer,
+.rows-2.focus-1 .itemsContainer > .card:first-child .coveredImage {
+ position: absolute;
+ inset: 0;
+ height: 100% !important;
+ width: 100% !important;
+}
+
+.rows-2.focus-1.divider-1 .itemsContainer > .card:first-child .cardBox::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: -0.18em;
+ border: 1px solid #e1e1e16b;
+ height: 100%;
+ border-radius: 5px;
+}
+
+.rows-2.focus-1.divider-1 .itemsContainer > .card:first-child .cardScalable {
+ width: calc(100% - var(--r2-gap));
+}
+
+.rows-2.focus-1.text-overlay-1 .itemsContainer > .card:first-child .cardText {
+ position: static;
+ margin: 0;
+ padding: 0;
+ max-width: 100%;
+ box-sizing: border-box;
+ color: inherit;
+ overflow: visible;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ background: none;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.75), 0 0 10px rgba(0, 0, 0, 0.45);
+ font-size: 1.5rem;
+ line-height: 1.5rem;
+ white-space: pre-line;
+ border-radius: 0 !important;
+}
+
+.rows-2.focus-1.text-overlay-1 .itemsContainer > .card:first-child .cardTextStack {
+ position: absolute;
+ z-index: 2;
+ left: 0;
+ right: 0;
+ width: 100%;
+ max-width: none;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25em;
+ margin: 0;
+ padding: 1.35em 0.85em;
+ box-sizing: border-box;
+ pointer-events: none;
+ color: var(--kefin-card-title-color, #fff);
+ overflow: hidden;
+ bottom: 0;
+}
+
+ /* --- End Variant Row Layouts --- */
+
+[data-active-page="home"] #homeTab {
+ margin-top: 1em;
+}
\ No newline at end of file
diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js
index d5e26c3..3414400 100644
--- a/scripts/cardBuilder.js
+++ b/scripts/cardBuilder.js
@@ -2,9 +2,69 @@
// This module provides a main entry point function to build Jellyfin cards
// Usage: window.cardBuilder.buildCard(jellyfinItem)
-(function() {
+(function () {
'use strict';
-
+
+ // Global toggle for progressive section fade-in.
+ // Developers can set this to false to disable the behavior.
+ const FADE_IN_SECTIONS = true;
+
+ // Detect flex gap after DOM is ready (JMP/Qt often lacks flex gap).
+ // ensureNoFlexGapClass is a function declaration (hoisted in this IIFE).
+ function scheduleFlexGapDetect() {
+ if (!document.body) {
+ document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true });
+ return;
+ }
+ ensureNoFlexGapClass();
+ }
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => ensureNoFlexGapClass(), { once: true });
+ } else {
+ scheduleFlexGapDetect();
+ }
+
+ // Sliding window: render current slide + 4 prev + 4 next (9 total). As user navigates, remove/add slides.
+ const SPOTLIGHT_WINDOW_PREV = 4;
+ const SPOTLIGHT_WINDOW_NEXT = 4;
+
+ const state = {
+ defaultCardBackgroundNumber: 1,
+ useBlurhash: true,
+ watchlist: false,
+ };
+
+ /** In-memory scroller metrics (avoid CSS var style writes for JS-only caches). */
+ const scrollerMetricsByEl = new WeakMap();
+ const cardPaddingByEl = new WeakMap();
+
+ function getScrollerMetrics(scroller) {
+ if (!scroller) return null;
+ let m = scrollerMetricsByEl.get(scroller);
+ if (!m) {
+ m = { scrollX: 0, pad: null, cardPad: null, maxScroll: null };
+ scrollerMetricsByEl.set(scroller, m);
+ }
+ return m;
+ }
+
+ function invalidateScrollerMetrics(scroller) {
+ if (!scroller) return;
+ scrollerMetricsByEl.delete(scroller);
+ }
+
+ function getSpotlightWindowIndices(currentIndex, totalLength) {
+ if (totalLength <= 9) {
+ return Array.from({ length: totalLength }, (_, i) => i);
+ }
+ const indices = new Set();
+ for (let i = -SPOTLIGHT_WINDOW_PREV; i <= SPOTLIGHT_WINDOW_NEXT; i++) {
+ const idx = (currentIndex + i + totalLength) % totalLength;
+ indices.add(idx);
+ }
+ return Array.from(indices);
+ }
+
/**
* Helper function to shuffle array (Fisher-Yates)
*/
@@ -16,7 +76,652 @@
}
return arr;
}
-
+
+ /** Element.replaceChildren polyfill (missing in JMP Qt5 WebEngine / Chromium ~83). */
+ function replaceChildren(node, ...nodes) {
+ if (!node) return;
+ if (typeof node.replaceChildren === 'function') {
+ node.replaceChildren(...nodes);
+ return;
+ }
+ while (node.firstChild) node.removeChild(node.firstChild);
+ for (const child of nodes) {
+ if (child == null) continue;
+ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
+ }
+ }
+
+ /** Cream deco border styles that need injected child pieces on `.cardBorder`. */
+ const CARD_BORDER_DECO_CHILDREN = {
+ triple: ['cardBorder-tall', 'cardBorder-mid', 'cardBorder-wide'],
+ stairstep: [
+ 'cardBorder-outer-left',
+ 'cardBorder-outer-right',
+ 'cardBorder-mid-left',
+ 'cardBorder-mid-right',
+ 'cardBorder-mid-top',
+ 'cardBorder-mid-bottom',
+ 'cardBorder-inner-top',
+ 'cardBorder-inner-bottom',
+ ],
+ square: [
+ 'cardBorder-inner',
+ 'cardBorder-square-left-top',
+ 'cardBorder-square-left-bottom',
+ 'cardBorder-square-right-top',
+ 'cardBorder-square-right-bottom',
+ 'cardBorder-tall',
+ 'cardBorder-wide',
+ ],
+ diamond: [
+ 'cardBorder-square-left-top',
+ 'cardBorder-square-left-bottom',
+ 'cardBorder-square-right-top',
+ 'cardBorder-square-right-bottom',
+ 'cardBorder-tall-outer',
+ 'cardBorder-tall-inner',
+ 'cardBorder-wide-outer',
+ 'cardBorder-wide-inner',
+ ],
+ corner: [
+ 'cardBorder-corner cardBorder-corner-tl',
+ 'cardBorder-corner cardBorder-corner-tr',
+ 'cardBorder-corner cardBorder-corner-bl',
+ 'cardBorder-corner cardBorder-corner-br',
+ ],
+ 'picture-frame': ['cardBorder-pf-inner'],
+ };
+
+ /** Legacy art-deco / slim-fancy / vintage-wide prefs map onto current equivalents. */
+ const CARD_BORDER_ALIASES = {
+ 'art-deco': 'stairstep',
+ 'art-deco-2': 'square',
+ 'slim-fancy': 'double',
+ 'vintage-wide': 'vintage',
+ };
+
+ const CARD_BORDER_CSS_ONLY = new Set(['fancy', 'slim', 'double', 'modern-poster', 'film-reel', 'profile', 'wood', 'art-gallery']);
+
+ function normalizeCardBorderStyle(style) {
+ if (!style) return '';
+ return CARD_BORDER_ALIASES[style] || style;
+ }
+
+ function getCardBorderAssetUrl(filename) {
+ const root = String(window.KefinTweaksConfig?.kefinTweaksRoot || '').replace(/\/$/, '');
+ return `${root}/pages/images/${filename}`;
+ }
+
+ function getRetroPosterMarqueeLabel(item) {
+ if (item?.PremiereDate) {
+ const premiere = new Date(item.PremiereDate);
+ if (!Number.isNaN(premiere.getTime()) && premiere.getTime() > Date.now()) {
+ return 'COMING SOON';
+ }
+ }
+ return 'NOW PLAYING';
+ }
+
+ function createCardBorderHost() {
+ const host = document.createElement('div');
+ host.className = 'cardBorder';
+ host.setAttribute('aria-hidden', 'true');
+ return host;
+ }
+
+ function ensureRetroPosterMarquee(scalable) {
+ if (!scalable) return;
+ const card = scalable.closest('.card');
+ let marquee = scalable.querySelector(':scope > .retroPosterMarquee');
+ if (!marquee) {
+ marquee = document.createElement('div');
+ marquee.className = 'retroPosterMarquee';
+ marquee.setAttribute('aria-hidden', 'true');
+ const label = document.createElement('span');
+ label.className = 'retroPosterMarquee-label';
+ marquee.appendChild(label);
+ const border = scalable.querySelector(':scope > .cardBorder');
+ if (border) scalable.insertBefore(marquee, border);
+ else scalable.appendChild(marquee);
+ }
+ const labelEl = marquee.querySelector('.retroPosterMarquee-label');
+ if (labelEl) {
+ labelEl.textContent = card?.dataset.retroMarquee || 'NOW PLAYING';
+ }
+ }
+
+ function removeRetroPosterMarquee(scalable) {
+ scalable?.querySelector(':scope > .retroPosterMarquee')?.remove();
+ }
+
+ function fillVintageBorder(host) {
+ host.style.setProperty('--v-corner-deco', `url("${getCardBorderAssetUrl("corner-decoration.svg")}")`);
+
+ const outer = document.createElement('div');
+ outer.className = 'cardBorder-v-outer';
+ const mid = document.createElement('div');
+ mid.className = 'cardBorder-v-mid';
+ const inner = document.createElement('div');
+ inner.className = 'cardBorder-v-inner';
+
+ ['tl', 'tr', 'br', 'bl'].forEach((pos) => {
+ const corner = document.createElement('div');
+ corner.className = `cardBorder-v-corner cardBorder-v-corner-${pos}`;
+ inner.appendChild(corner);
+ });
+
+ mid.appendChild(inner);
+ outer.appendChild(mid);
+ host.appendChild(outer);
+ }
+
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ /** Target px between bulb centers along each edge. */
+ const RP_BULB_PITCH = 16;
+ const RP_BULB_LAYERS = [
+ { cls: 'cardBorder-rp-bulb-glow', r: 5.5 },
+ { cls: 'cardBorder-rp-bulb-mid', r: 3.5 },
+ { cls: 'cardBorder-rp-bulb-core', r: 1.75 },
+ ];
+ const RP_DEFAULT_THIN = 2;
+ const RP_DEFAULT_BAND = 16;
+ /** Cards to sync bulbs for per animation frame (keeps style switches snappy). */
+ const RP_SYNC_BATCH = 3;
+
+ let rpBulbSyncQueue = [];
+ let rpBulbSyncRaf = 0;
+
+ function disconnectRetroPosterBulbs(host) {
+ if (!host) return;
+ if (host._rpRoRaf) {
+ cancelAnimationFrame(host._rpRoRaf);
+ host._rpRoRaf = 0;
+ }
+ if (host._rpBulbRo) {
+ host._rpBulbRo.disconnect();
+ host._rpBulbRo = null;
+ }
+ host._rpBulbPending = false;
+ host._rpBulbW = null;
+ host._rpBulbH = null;
+ if (rpBulbSyncQueue.length) {
+ rpBulbSyncQueue = rpBulbSyncQueue.filter((svg) => svg?.parentElement !== host);
+ }
+ }
+
+ function getRetroPosterBandMetrics(sectionEl) {
+ if (sectionEl?._rpBandMetrics) return sectionEl._rpBandMetrics;
+ let thin = RP_DEFAULT_THIN;
+ let band = RP_DEFAULT_BAND;
+ try {
+ const cs = getComputedStyle(sectionEl || document.documentElement);
+ const t = parseFloat(cs.getPropertyValue('--rp-thin'));
+ const b = parseFloat(cs.getPropertyValue('--rp-band'));
+ if (Number.isFinite(t) && t > 0) thin = t;
+ if (Number.isFinite(b) && b > 0) band = b;
+ } catch {
+ /* keep defaults */
+ }
+ const metrics = { thin, band };
+ if (sectionEl) sectionEl._rpBandMetrics = metrics;
+ return metrics;
+ }
+
+ function enqueueRetroPosterBulbSync(svg) {
+ if (!svg?.isConnected) return;
+ const host = svg.parentElement;
+ if (!host || host._rpBulbPending) return;
+ host._rpBulbPending = true;
+ rpBulbSyncQueue.push(svg);
+ if (!rpBulbSyncRaf) {
+ rpBulbSyncRaf = requestAnimationFrame(flushRetroPosterBulbSyncQueue);
+ }
+ }
+
+ function flushRetroPosterBulbSyncQueue() {
+ rpBulbSyncRaf = 0;
+ let n = 0;
+ while (n < RP_SYNC_BATCH && rpBulbSyncQueue.length) {
+ const svg = rpBulbSyncQueue.shift();
+ const host = svg?.parentElement;
+ if (host) host._rpBulbPending = false;
+ if (svg?.isConnected && host?.dataset?.style === 'retro-poster') {
+ syncRetroPosterBulbs(svg);
+ }
+ n += 1;
+ }
+ if (rpBulbSyncQueue.length) {
+ rpBulbSyncRaf = requestAnimationFrame(flushRetroPosterBulbSyncQueue);
+ }
+ }
+
+ function syncRetroPosterBulbs(svg) {
+ const host = svg?.parentElement;
+ if (!host) return;
+ const w = host.clientWidth;
+ const h = host.clientHeight;
+ if (w < 8 || h < 8) return;
+
+ // Skip rebuild when size unchanged and bulbs already exist
+ if (host._rpBulbW === w && host._rpBulbH === h && svg.querySelector('circle')) return;
+ host._rpBulbW = w;
+ host._rpBulbH = h;
+
+ const sectionEl = host.closest('[data-border], [data-border-style]');
+ const { thin, band } = getRetroPosterBandMetrics(sectionEl);
+ const mid = thin + band / 2;
+ const x1 = mid;
+ const y1 = mid;
+ const x2 = w - mid;
+ const y2 = h - mid;
+ const rw = Math.max(0, x2 - x1);
+ const rh = Math.max(0, y2 - y1);
+
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
+
+ /* Independent edge spacing so each side has a corner bulb + even steps between. */
+ const kW = Math.max(1, Math.round(rw / RP_BULB_PITCH));
+ const kH = Math.max(1, Math.round(rh / RP_BULB_PITCH));
+ const sW = rw / kW;
+ const sH = rh / kH;
+
+ const points = [
+ [x1, y1],
+ [x2, y1],
+ [x2, y2],
+ [x1, y2],
+ ];
+ for (let i = 1; i < kW; i++) {
+ points.push([x1 + i * sW, y1], [x1 + i * sW, y2]);
+ }
+ for (let i = 1; i < kH; i++) {
+ points.push([x1, y1 + i * sH], [x2, y1 + i * sH]);
+ }
+
+ RP_BULB_LAYERS.forEach(({ cls, r }) => {
+ const g = svg.querySelector(`g.${cls}`);
+ if (!g) return;
+ const frag = document.createDocumentFragment();
+ points.forEach(([cx, cy]) => {
+ const circle = document.createElementNS(SVG_NS, 'circle');
+ circle.setAttribute('cx', String(cx));
+ circle.setAttribute('cy', String(cy));
+ circle.setAttribute('r', String(r));
+ frag.appendChild(circle);
+ });
+ replaceChildren(g, frag);
+ });
+ }
+
+ function fillRetroPosterBorder(host) {
+ disconnectRetroPosterBulbs(host);
+
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('class', 'cardBorder-rp-bulbs');
+ svg.setAttribute('aria-hidden', 'true');
+
+ RP_BULB_LAYERS.forEach(({ cls }) => {
+ const g = document.createElementNS(SVG_NS, 'g');
+ g.setAttribute('class', cls);
+ svg.appendChild(g);
+ });
+
+ host.appendChild(svg);
+
+ // Coalesce RO storms; do not sync synchronously on attach (causes border-switch hitch).
+ const ro = new ResizeObserver(() => {
+ if (host._rpRoRaf) return;
+ host._rpRoRaf = requestAnimationFrame(() => {
+ host._rpRoRaf = 0;
+ host._rpBulbW = null;
+ host._rpBulbH = null;
+ enqueueRetroPosterBulbSync(svg);
+ });
+ });
+ ro.observe(host);
+ host._rpBulbRo = ro;
+ enqueueRetroPosterBulbSync(svg);
+ }
+
+ function fillCardBorder(host, style) {
+ if (!host) return;
+ const next = normalizeCardBorderStyle(style);
+ const isVintage = next === 'vintage';
+ const isRetro = next === 'retro-poster';
+ const needsChildren = next && !CARD_BORDER_CSS_ONLY.has(next) && (isVintage || isRetro || !!CARD_BORDER_DECO_CHILDREN[next]);
+ if (host.dataset.style === next) {
+ const vintageNeedsRefresh = isVintage && !host.style.getPropertyValue('--v-corner-deco');
+ if (!vintageNeedsRefresh && (!needsChildren || host.childElementCount > 0)) return;
+ }
+ disconnectRetroPosterBulbs(host);
+ host.dataset.style = next;
+ replaceChildren(host);
+ if (!needsChildren) return;
+
+ if (isVintage) {
+ fillVintageBorder(host);
+ return;
+ }
+
+ if (isRetro) {
+ fillRetroPosterBorder(host);
+ return;
+ }
+
+ for (const cls of CARD_BORDER_DECO_CHILDREN[next]) {
+ const piece = document.createElement('div');
+ piece.className = cls;
+ host.appendChild(piece);
+ }
+ }
+
+ /**
+ * Sync `.cardBorder` hosts under a section from its data-border / data-border-style.
+ * Creates hosts when missing; removes them when border is cleared.
+ */
+ function ensureCardBorders(sectionEl) {
+ if (!sectionEl) return;
+ const raw = sectionEl.dataset.border || sectionEl.dataset.borderStyle || '';
+ const style = normalizeCardBorderStyle(raw);
+ if (style && style !== raw) {
+ sectionEl.dataset.border = style;
+ sectionEl.dataset.borderStyle = style;
+ }
+ // Refresh cached band metrics when (re)applying so CSS vars stay current
+ delete sectionEl._rpBandMetrics;
+
+ if (style === 'wood') {
+ sectionEl.style.setProperty('--wood-vert-img', `url("${getCardBorderAssetUrl("wood.png")}")`);
+ } else {
+ sectionEl.style.removeProperty('--wood-vert-img');
+ }
+ const cards = sectionEl.querySelectorAll('.card .cardScalable');
+ cards.forEach((scalable) => {
+ let host = scalable.querySelector(':scope > .cardBorder');
+ if (!style) {
+ if (host) {
+ disconnectRetroPosterBulbs(host);
+ host.remove();
+ }
+ removeRetroPosterMarquee(scalable);
+ return;
+ }
+ if (!host) {
+ host = createCardBorderHost();
+ scalable.appendChild(host);
+ }
+ fillCardBorder(host, style);
+ if (style === 'retro-poster') ensureRetroPosterMarquee(scalable);
+ else removeRetroPosterMarquee(scalable);
+ });
+ }
+
+ /** Skin-wide card corner radius; measured once and exposed as --kefin-card-radius. */
+ let cachedCardRadius = null;
+
+ const SQUARE_CARD_BORDER_STYLES = new Set([
+ 'retro-poster',
+ 'modern-poster',
+ 'picture-frame',
+ 'art-gallery',
+ 'wood',
+ 'profile',
+ 'film-reel',
+ ]);
+
+ function sectionHasSquareCardBorder(el) {
+ const section = el?.closest?.('[data-border], [data-border-style]');
+ if (!section) return false;
+ const style = section.dataset.borderStyle || section.dataset.border || '';
+ return SQUARE_CARD_BORDER_STYLES.has(style);
+ }
+
+ function getCardRadius() {
+ if (cachedCardRadius != null) return cachedCardRadius;
+
+ const candidates = document.querySelectorAll('.card .cardScalable, .card .cardContent, .card');
+ let sample = null;
+ for (const el of candidates) {
+ if (!sectionHasSquareCardBorder(el)) {
+ sample = el;
+ break;
+ }
+ }
+
+ if (!sample) return '0px';
+
+ const style = getComputedStyle(sample);
+ let radius = (style.borderBottomLeftRadius || style.borderRadius || '').trim();
+ if (!radius || radius === '0' || radius === '0px') {
+ const themeRound = (
+ style.getPropertyValue('--theme-roundness') ||
+ getComputedStyle(document.documentElement).getPropertyValue('--theme-roundness') ||
+ ''
+ ).trim();
+ radius = themeRound || radius || '0px';
+ }
+
+ cachedCardRadius = radius;
+ return cachedCardRadius;
+ }
+
+ function ensureCardRadiusCssVar() {
+ document.documentElement.style.setProperty('--kefin-card-radius', getCardRadius());
+ }
+
+ function invalidateCardRadiusCache() {
+ cachedCardRadius = null;
+ document.documentElement.style.removeProperty('--kefin-card-radius');
+ }
+
+ function applySectionPresentationAttrs(element, sectionConfig, options = {}) {
+ if (!element || !sectionConfig) return;
+ const forceBorders = options.forceBorders === true;
+
+ if (sectionConfig.sectionCssClass) {
+ sectionConfig.sectionCssClass
+ .split(/\s+/)
+ .filter(Boolean)
+ .forEach((cls) => element.classList.add(cls));
+ }
+
+ const desiredBorderRaw = sectionConfig.borderStyle || '';
+ const desiredBorder = normalizeCardBorderStyle(desiredBorderRaw) || '';
+ const currentBorder = normalizeCardBorderStyle(element.dataset.border || element.dataset.borderStyle || '') || '';
+ const borderChanged = desiredBorder !== currentBorder;
+
+ const desiredBorderColor = (sectionConfig.borderColor && String(sectionConfig.borderColor).trim()) || '';
+ const currentBorderColor = (element.style.getPropertyValue('--kefin-card-border-color') || '').trim();
+ const borderColorChanged = desiredBorderColor !== currentBorderColor;
+
+ const desiredTitleColor = (sectionConfig.cardTitleColor && String(sectionConfig.cardTitleColor).trim()) || '';
+ const currentTitleColor = (element.style.getPropertyValue('--kefin-card-title-color') || '').trim();
+ const titleColorChanged = desiredTitleColor !== currentTitleColor;
+
+ const desiredHideName = sectionConfig.hideName === true || sectionConfig.hideName === 'true';
+ const currentHideName = element.dataset.hideSectionName === 'true';
+ const hideNameChanged = desiredHideName !== currentHideName;
+
+ const desiredTitleVisibility = sectionConfig.cardTitleVisibility || (sectionConfig.hideCardTitles === true ? 'hidden' : '');
+ const currentTitleVisibility = element.dataset.cardTitleVisibility || '';
+ const titleVisChanged =
+ (desiredTitleVisibility && desiredTitleVisibility !== 'visible' ? desiredTitleVisibility : '') !== currentTitleVisibility;
+
+ const desiredTitlePosition = sectionConfig.cardTitlePosition || '';
+ const titlePosChanged = (element.dataset.cardTitlePosition || '') !== desiredTitlePosition;
+
+ const desiredCap = sectionConfig.cardTitleCapitalization && sectionConfig.cardTitleCapitalization !== 'normal'
+ ? sectionConfig.cardTitleCapitalization
+ : '';
+ const capChanged = (element.dataset.cardTitleCapitalization || '') !== desiredCap;
+
+ const desiredFontFamily =
+ sectionConfig.cardTitleFontFamily && sectionConfig.cardTitleFontFamily !== 'default'
+ ? sectionConfig.cardTitleFontFamily
+ : '';
+ const fontFamilyChanged = (element.dataset.fontFamily || '') !== desiredFontFamily;
+
+ const desiredFontSize =
+ sectionConfig.cardTitleFontSize && sectionConfig.cardTitleFontSize !== 'normal' ? sectionConfig.cardTitleFontSize : '';
+ const fontSizeChanged = (element.dataset.fontSize || '') !== desiredFontSize;
+
+ const desiredType = sectionConfig.type || '';
+ const typeChanged = (element.getAttribute('data-section-type') || '') !== desiredType;
+
+ const desiredCategory = resolveSectionCategory(sectionConfig) || '';
+ const categoryChanged = (element.getAttribute('data-category') || '') !== desiredCategory;
+
+ const sectionType = String(sectionConfig.type || '').toLowerCase();
+ const isCustomSection =
+ sectionConfig.isCustom === true ||
+ sectionType === 'custom' ||
+ sectionType === 'custom-discovery' ||
+ String(sectionConfig.id || '').startsWith('custom');
+ const isDiscoverySection =
+ sectionConfig.discoveryEnabled === true ||
+ sectionConfig.discoverySection === true ||
+ sectionType === 'discovery' ||
+ sectionType === 'custom-discovery' ||
+ !!sectionConfig.discoveryType;
+ const customChanged = (element.dataset.customSection === 'true') !== isCustomSection;
+ const discoveryChanged = (element.dataset.discoverySection === 'true') !== isDiscoverySection;
+
+ const itemsLayout = resolveItemsLayout(sectionConfig);
+ const gapless = resolveUseGaplessCards(sectionConfig);
+ const itemsContainer = element.querySelector('.itemsContainer');
+ const layoutChanged =
+ !itemsContainer ||
+ itemsContainer.getAttribute('data-layout') !== itemsLayout ||
+ (itemsContainer.getAttribute('data-gapless') === 'true') !== gapless;
+
+ const presentationChanged =
+ borderChanged ||
+ borderColorChanged ||
+ titleColorChanged ||
+ hideNameChanged ||
+ titleVisChanged ||
+ titlePosChanged ||
+ capChanged ||
+ fontFamilyChanged ||
+ fontSizeChanged ||
+ typeChanged ||
+ categoryChanged ||
+ customChanged ||
+ discoveryChanged ||
+ layoutChanged;
+
+ if (!presentationChanged && !forceBorders) return;
+
+ if (borderChanged) {
+ if (desiredBorder) {
+ element.dataset.border = desiredBorder;
+ element.dataset.borderStyle = desiredBorder;
+ } else {
+ delete element.dataset.border;
+ delete element.dataset.borderStyle;
+ }
+ }
+ if (borderColorChanged) {
+ if (desiredBorderColor) element.style.setProperty('--kefin-card-border-color', desiredBorderColor);
+ else element.style.removeProperty('--kefin-card-border-color');
+ }
+ if (titleColorChanged) {
+ if (desiredTitleColor) element.style.setProperty('--kefin-card-title-color', desiredTitleColor);
+ else element.style.removeProperty('--kefin-card-title-color');
+ }
+ if (hideNameChanged) {
+ if (desiredHideName) element.dataset.hideSectionName = 'true';
+ else delete element.dataset.hideSectionName;
+ }
+ delete element.dataset.hideName;
+
+ if (titleVisChanged) {
+ if (desiredTitleVisibility && desiredTitleVisibility !== 'visible') {
+ element.dataset.cardTitleVisibility = desiredTitleVisibility;
+ } else {
+ delete element.dataset.cardTitleVisibility;
+ }
+ }
+ if (titlePosChanged) {
+ if (desiredTitlePosition) element.dataset.cardTitlePosition = desiredTitlePosition;
+ else delete element.dataset.cardTitlePosition;
+ }
+ if (capChanged) {
+ if (desiredCap) element.dataset.cardTitleCapitalization = desiredCap;
+ else delete element.dataset.cardTitleCapitalization;
+ }
+ if (fontFamilyChanged) {
+ if (desiredFontFamily) element.dataset.fontFamily = desiredFontFamily;
+ else delete element.dataset.fontFamily;
+ }
+ if (fontSizeChanged) {
+ if (desiredFontSize) element.dataset.fontSize = desiredFontSize;
+ else delete element.dataset.fontSize;
+ }
+
+ if (layoutChanged) {
+ applyItemsLayoutState(element, itemsLayout, gapless);
+ }
+
+ if (typeChanged) {
+ if (desiredType) element.setAttribute('data-section-type', desiredType);
+ else element.removeAttribute('data-section-type');
+ }
+ if (categoryChanged) {
+ if (desiredCategory) element.setAttribute('data-category', desiredCategory);
+ else element.removeAttribute('data-category');
+ }
+ if (customChanged) {
+ if (isCustomSection) element.dataset.customSection = 'true';
+ else delete element.dataset.customSection;
+ }
+ if (discoveryChanged) {
+ if (isDiscoverySection) element.dataset.discoverySection = 'true';
+ else delete element.dataset.discoverySection;
+ }
+
+ if (borderChanged || forceBorders) {
+ ensureCardBorders(element);
+ }
+ }
+
+ 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
+ */
+ function updateScrollableContainerScrollButtons(scroller) {
+ const verticalSection = scroller.parentElement;
+ if (!verticalSection) return;
+
+ const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons');
+ if (!scrollButtons) return;
+
+ const leftButton = scrollButtons.querySelector('button[data-direction="left"]');
+ const rightButton = scrollButtons.querySelector('button[data-direction="right"]');
+ if (!leftButton || !rightButton) return;
+
+ const pos = getScrollerPosition(scroller);
+ const maxPosition = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
+ leftButton.disabled = pos <= 20;
+ rightButton.disabled = pos >= maxPosition;
+ scrollButtons.classList.toggle('hide', leftButton.disabled && rightButton.disabled);
+ }
+
/**
* Sorts items based on sort order and direction
* @param {Array} items - Array of Jellyfin items
@@ -26,11 +731,11 @@
*/
function sortItems(items, sortOrder = 'Random', sortOrderDirection = 'Ascending') {
if (!items || items.length === 0) return items;
-
+
const ascending = sortOrderDirection === 'Ascending';
const sorted = [...items];
-
- switch(sortOrder) {
+
+ switch (sortOrder) {
case 'Random':
return shuffle(sorted);
case 'ReleaseDate':
@@ -67,7 +772,7 @@
return sorted;
}
}
-
+
/**
* Picks a random card format for a section
* @returns {string} - Random card format: 'portrait', 'thumb'
@@ -76,21 +781,2272 @@
const formats = ['portrait', 'thumb'];
return formats[Math.floor(Math.random() * formats.length)];
}
-
+
+ /**
+ * Map section cardFormat / data-card-format to JF grid column shape.
+ * Used for CSS grid column tables (no data-grid-shape attribute).
+ * @param {string|null|undefined} cardFormat
+ * @returns {'portrait'|'square'|'backdrop'|'banner'}
+ */
+ function resolveGridShape(cardFormat) {
+ const f = String(cardFormat || '')
+ .trim()
+ .toLowerCase();
+ if (f === 'banner') return 'banner';
+ if (f === 'square' || f === 'sqaure' || f === 'disc') return 'square';
+ if (
+ f === 'backdrop' ||
+ f === 'thumb' ||
+ f === 'series thumb' ||
+ f === 'logo' ||
+ f === 'clear art' ||
+ f === 'button'
+ ) {
+ return 'backdrop';
+ }
+ // Poster, Series Poster, portrait, Random-resolved concrete formats, default
+ return 'portrait';
+ }
+
+ function sectionHasRandomQuerySort(sectionConfig) {
+ // Treat as random if any query has an undefined/null/non-existent SortBy without a path
+ if (String(sectionConfig?.sortBy || '').toLowerCase() === 'random') {
+ return true;
+ }
+
+ if (sectionConfig?.queries?.some((q) => !q.queryOptions?.SortBy && !q.path)) {
+ return true;
+ }
+ return (sectionConfig?.queries || []).some((q) => String(q.queryOptions?.SortBy || '').toLowerCase() === 'random');
+ }
+
+ function flattenSeriesEpisodes(items) {
+ if (!items || items.length === 0) return items;
+
+ const seenSeries = new Set();
+ const result = [];
+
+ for (const item of items) {
+ if (item.Type === 'Episode') {
+ // Only keep the first episode seen for a given SeriesId
+ if (item.SeriesId && !seenSeries.has(item.SeriesId)) {
+ seenSeries.add(item.SeriesId);
+ result.push(item);
+ }
+ } else {
+ // Keep non-Episode items as-is, in order
+ result.push(item);
+ }
+ }
+
+ return result;
+ }
+
+ function formatAirDate(premiereDate) {
+ if (!premiereDate) return '';
+
+ // Ignore timezone offset
+ const date = new Date(premiereDate.replace('Z', ''));
+
+ // Use format: Tues Jan 4
+ const options = { weekday: 'short', month: 'short', day: 'numeric' };
+ return date.toLocaleDateString('en-US', options);
+ }
+
+ function countAvailableArtwork(item) {
+ let count = 0;
+ const tags = item?.ImageTags;
+ if (tags && typeof tags === 'object') {
+ for (const value of Object.values(tags)) {
+ if (value) count++;
+ }
+ }
+ if (item?.SeriesPrimaryImageTag) count++;
+ if (item?.ParentBackdropImageTags?.length) count++;
+ if (item?.ParentThumbImageTag) count++;
+ if (item?.ParentLogoImageTag) count++;
+ return count;
+ }
+
+ function getProviderKeys(item) {
+ const ids = item?.ProviderIds;
+ if (!ids) return [];
+ return [ids.Imdb, ids.Tmdb, ids.Tvdb].filter(Boolean);
+ }
+
+ function deduplicateItems(items) {
+ const deduplicated = [];
+ const idToIndex = new Map();
+
+ for (const item of items) {
+ const keys = getProviderKeys(item);
+ if (keys.length === 0) {
+ deduplicated.push(item);
+ continue;
+ }
+
+ let existingIndex = -1;
+ for (const key of keys) {
+ if (idToIndex.has(key)) {
+ existingIndex = idToIndex.get(key);
+ break;
+ }
+ }
+
+ if (existingIndex === -1) {
+ const index = deduplicated.length;
+ deduplicated.push(item);
+ for (const key of keys) {
+ idToIndex.set(key, index);
+ }
+ continue;
+ }
+
+ const existing = deduplicated[existingIndex];
+ if (countAvailableArtwork(item) > countAvailableArtwork(existing)) {
+ deduplicated[existingIndex] = item;
+ }
+ for (const key of keys) {
+ idToIndex.set(key, existingIndex);
+ }
+ }
+
+ return deduplicated;
+ }
+
+ /**
+ * Merges NextUp and ContinueWatching items chronologically by LastPlayedDate
+ * Preserves NextUp's internal order and assigns LastPlayedDate to items without it
+ * @param {Array} nextUpItems - Array of Episode items from NextUp endpoint (already ordered)
+ * @param {Array} continueWatchingItems - Array of Movie items from Continue Watching endpoint
+ * @returns {Array} - Merged array sorted by LastPlayedDate (descending)
+ */
+ function mergeNextUpAndContinueWatching(nextUpItems, continueWatchingItems) {
+ if (!nextUpItems || nextUpItems.length === 0) {
+ return continueWatchingItems || [];
+ }
+ if (!continueWatchingItems || continueWatchingItems.length === 0) {
+ return nextUpItems || [];
+ }
+
+ // 1. Assign LastPlayedDate to NextUp items without it
+ // Items without LastPlayedDate inherit the date from the next item that has one
+ const processedNextUp = [...nextUpItems];
+ for (let i = processedNextUp.length - 1; i >= 0; i--) {
+ const item = processedNextUp[i];
+ if (!item.UserData || !item.UserData.LastPlayedDate) {
+ // Look ahead to find next item with LastPlayedDate
+ for (let j = i + 1; j < processedNextUp.length; j++) {
+ if (processedNextUp[j].UserData && processedNextUp[j].UserData.LastPlayedDate) {
+ // Assign the date from the next item
+ if (!item.UserData) item.UserData = {};
+ item.UserData.LastPlayedDate = processedNextUp[j].UserData.LastPlayedDate;
+ break;
+ }
+ }
+ // If no date found ahead, assign null (will be placed at end)
+ if (!item.UserData || !item.UserData.LastPlayedDate) {
+ if (!item.UserData) item.UserData = {};
+ item.UserData.LastPlayedDate = null;
+ }
+ }
+ }
+
+ // 2. Sort ContinueWatching items by LastPlayedDate (descending)
+ const sortedContinueWatching = [...continueWatchingItems, ...processedNextUp].sort((a, b) => {
+ const dateA = a.UserData?.LastPlayedDate ? new Date(a.UserData.LastPlayedDate).getTime() : 0;
+ const dateB = b.UserData?.LastPlayedDate ? new Date(b.UserData.LastPlayedDate).getTime() : 0;
+ return dateB - dateA; // Descending
+ });
+
+ return sortedContinueWatching;
+ }
+
+ function postProcessItemsByQuery(sectionConfig, queryResults) {
+ let processed = [];
+ for (let i = 0; i < queryResults.length; i++) {
+ const queryConfig = sectionConfig.queries[i];
+ const queryResult = queryResults[i];
+ const items = queryResult?.Items || queryResult || [];
+ processed.push(...postProcessItems(queryConfig, items));
+ }
+
+ if (sectionConfig.id === 'continueWatchingAndNextUp') {
+ // Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them
+ const movies = processed.filter((item) => item.Type === 'Movie');
+ const episodes = processed.filter((item) => item.Type !== 'Movie');
+ processed = mergeNextUpAndContinueWatching(episodes, movies);
+ }
+
+ return processed;
+ }
+
+ function postProcessItems(sectionConfig, itemsData) {
+ let processed = itemsData?.Items || itemsData || [];
+
+ //Check if imageTypes is used in the query to ensure only items with these image types are included
+ const imageTypes = sectionConfig.queries?.[0]?.queryOptions?.ImageTypes;
+ if (imageTypes && Array.isArray(imageTypes)) {
+ // Filter out items which do not have any of the image types
+ processed = processed.filter((item) => {
+ const itemImageTypes = imageTypes.filter(
+ (imageType) => item.ImageTags?.[imageType] || (imageType === 'Backdrop' && item.BackdropImageTags?.[0]),
+ );
+ return itemImageTypes.length > 0;
+ });
+ }
+
+ // When ParentId is on the query (e.g. Popular Genres per library), assign it to each item for genre card links
+ const queryParentId = sectionConfig.queries?.[0]?.ParentId;
+ if (queryParentId && Array.isArray(processed)) {
+ processed.forEach((item) => {
+ item.ParentId = queryParentId;
+ });
+ }
+
+ // Check if the IsUnplayed filter is set on any of the queries
+ const isUnplayedFilter = sectionConfig.queries?.some(
+ (query) => query.queryOptions?.IsUnplayed === true || query.queryOptions?.Filters?.includes('IsUnplayed'),
+ );
+ if (isUnplayedFilter) {
+ processed = processed.filter((item) => item.UserData?.Played === false);
+ }
+
+ // Check if the IsPlayed filter is set on any of the queries
+ const isPlayedFilter = sectionConfig.queries?.some(
+ (query) => query.queryOptions?.IsPlayed === true || query.queryOptions?.Filters?.includes('IsPlayed'),
+ );
+ if (isPlayedFilter) {
+ processed = processed.filter((item) => item.UserData?.Played === true);
+ }
+
+ // Check if the IsResumable filter is set on any of the queries
+ const isResumableFilter = sectionConfig.queries?.some(
+ (query) => query.queryOptions?.IsResumable === true || query.queryOptions?.Filters?.includes('IsResumable'),
+ );
+ if (isResumableFilter) {
+ processed = processed.filter(
+ (item) => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage !== 100 && item.UserData?.PlayedPercentage > 0,
+ );
+ }
+
+ // Check if the MinPlayCount filter is set on any of the queries
+ const minPlayCount = sectionConfig.minPlayCount || sectionConfig.queries?.[0]?.queryOptions?.MinPlayCount || 0;
+ if (minPlayCount > 0) {
+ processed = processed.filter((item) => item.UserData?.PlayCount && item.UserData?.PlayCount >= minPlayCount);
+ }
+
+ // Check if the MaxPlayCount filter is set on any of the queries
+ const maxPlayCount = sectionConfig.maxPlayCount || sectionConfig.queries?.[0]?.queryOptions?.MaxPlayCount || 0;
+ if (maxPlayCount > 0) {
+ processed = processed.filter((item) => item.UserData?.PlayCount && item.UserData?.PlayCount <= maxPlayCount);
+ }
+
+ // Flatten Series Episodes
+ if (sectionConfig.flattenSeries === true) {
+ processed = flattenSeriesEpisodes(processed);
+ }
+
+ if (sectionConfig.type === 'series-episodes' && Array.isArray(processed)) {
+ processed = [...processed].sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
+ }
+
+ /* if (sectionConfig.id === 'popularTVNetworks') {
+ // Sort items randomly
+ processed = shuffle(processed);
+ } */
+
+ if (sectionConfig.id === 'continueWatchingAndNextUp') {
+ // Get the movies from the items, and the rest as the episodes, then use mergeNextUpAndContinueWatching to merge them
+ const movies = processed.filter((item) => item.Type === 'Movie');
+ const episodes = processed.filter((item) => item.Type !== 'Movie');
+ processed = mergeNextUpAndContinueWatching(episodes, movies);
+ }
+
+ if (sectionConfig.id === 'upcoming') {
+ // Remove items which aired before today
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ processed = processed.filter((item) => {
+ if (!item.PremiereDate || item.LocationType !== 'Virtual') return false;
+ const premiereDate = new Date(item.PremiereDate);
+ premiereDate.setHours(0, 0, 0, 0);
+ return premiereDate >= today;
+ });
+ }
+
+ // Add Custom Secondary Text for Recently Released Movies
+ if (
+ sectionConfig.id === 'recentlyReleased.movies' ||
+ sectionConfig.id === 'upcoming' ||
+ sectionConfig.id === 'recentlyReleased.episodes'
+ ) {
+ processed.forEach((item) => {
+ if (item.PremiereDate) {
+ item.CustomFooterText = formatAirDate(item.PremiereDate);
+ }
+ });
+ }
+
+ // Sort items by UserData.PlayedPercentage if PlayedPercentage is set as the SortBy
+ if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'PlayedPercentage') {
+ processed = processed.sort((a, b) => {
+ const playedPercentageA = a.UserData?.PlayedPercentage || 0;
+ const playedPercentageB = b.UserData?.PlayedPercentage || 0;
+ return playedPercentageB - playedPercentageA;
+ });
+
+ // Remove any items which have no UserData.PlayedPercentage
+ processed = processed.filter((item) => item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0);
+ }
+
+ // Check for SortBy ChildCount and use ChildCount to sort the items
+ // ChildCount = ChildCount + SeriesCount + EpisodeCount + AlbumCount + ArtistCount + ProgramCount + MovieCount + SongCount
+ if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'ChildCount') {
+ processed = processed.sort((a, b) => {
+ const childCountA =
+ (a.ChildCount || 0) +
+ (a.SeriesCount || 0) +
+ (a.EpisodeCount || 0) +
+ (a.AlbumCount || 0) +
+ (a.ArtistCount || 0) +
+ (a.ProgramCount || 0) +
+ (a.MovieCount || 0) +
+ (a.SongCount || 0);
+ const childCountB =
+ (b.ChildCount || 0) +
+ (b.SeriesCount || 0) +
+ (b.EpisodeCount || 0) +
+ (b.AlbumCount || 0) +
+ (b.ArtistCount || 0) +
+ (b.ProgramCount || 0) +
+ (b.MovieCount || 0) +
+ (b.SongCount || 0);
+
+ if (sectionConfig.queries?.[0]?.queryOptions?.SortOrder === 'Descending') {
+ return childCountB - childCountA;
+ }
+ return childCountA - childCountB;
+ });
+ }
+
+ const limit = sectionConfig.itemLimit || sectionConfig.queries?.[0]?.queryOptions?.Limit || 0;
+
+ if (sectionConfig.queries?.[0]?.queryOptions?.SortBy === 'Random' || sectionConfig.sortBy === 'Random') {
+ if (limit > 0 && sectionConfig.limitBeforeSort === true) {
+ processed = processed.slice(0, limit);
+ }
+ processed = shuffle(processed);
+ }
+
+ // Apply local limits for non-API sources
+ if (limit > 0) {
+ processed = processed.slice(0, limit);
+ }
+
+ // Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids
+ processed = deduplicateItems(processed);
+
+ return processed;
+ }
+
+ function getQueryDisplayName(query, index) {
+ const trimmed = query?.name?.trim?.();
+ if (trimmed) return trimmed;
+ return `Query ${index + 1}`;
+ }
+
+ function resolveActiveQueryIndex(sectionConfig, options = {}) {
+ const queries = sectionConfig?.queries || [];
+ if (queries.length <= 1) return 0;
+
+ const stored = sectionConfig._selectedQueryIndex;
+ if (Number.isInteger(stored) && stored >= 0 && stored < queries.length) {
+ return stored;
+ }
+
+ if (options.initialize !== false) {
+ let index = 0;
+ if (sectionConfig.useRandomQuery === true) {
+ index = Math.floor(Math.random() * queries.length);
+ }
+ sectionConfig._selectedQueryIndex = index;
+ return index;
+ }
+
+ return 0;
+ }
+
+ function resolveQueriesToLoad(sectionConfig, options = {}) {
+ const queries = sectionConfig?.queries || [];
+ if (queries.length <= 1) return queries;
+
+ if (sectionConfig.useMultiQueryPicker === true) {
+ const index = resolveActiveQueryIndex(sectionConfig, options);
+ return [queries[index]];
+ }
+
+ if (sectionConfig.useRandomQuery === true) {
+ const randomIndex = Math.floor(Math.random() * queries.length);
+ return [queries[randomIndex]];
+ }
+
+ return queries;
+ }
+
+ function getMultiQueryPickerButtonLabel(sectionConfig) {
+ const customLabel = sectionConfig?.multiQueryPickerLabel?.trim?.();
+ if (customLabel) return customLabel;
+
+ const queries = sectionConfig?.queries || [];
+ if (queries.length <= 1) return getQueryDisplayName(queries[0], 0);
+
+ const index = resolveActiveQueryIndex(sectionConfig, { initialize: false });
+ return getQueryDisplayName(queries[index], index);
+ }
+
+ function getSectionTitleForMultiQuery(sectionConfig) {
+ if (
+ sectionConfig?.useMultiQueryPicker === true &&
+ sectionConfig?.useQueryNamesForSection === true &&
+ (sectionConfig?.queries?.length || 0) > 1
+ ) {
+ const index = resolveActiveQueryIndex(sectionConfig, { initialize: true });
+ return getQueryDisplayName(sectionConfig.queries[index], index);
+ }
+ return sectionConfig?.name || '';
+ }
+
+ function getActiveViewMoreUrl(sectionConfig) {
+ const queries = sectionConfig?.queries || [];
+ if (sectionConfig?.useMultiQueryPicker === true && queries.length > 1) {
+ const index = resolveActiveQueryIndex(sectionConfig, { initialize: true });
+ const queryUrl = queries[index]?.viewMoreUrl;
+ if (queryUrl) return queryUrl;
+ }
+ return sectionConfig?.viewMoreUrl || null;
+ }
+
+ function getSectionCaption(sectionConfig) {
+ return sectionConfig?.caption?.trim() || null;
+ }
+
+ function getSectionCaptionUrl(sectionConfig) {
+ return sectionConfig?.captionUrl || null;
+ }
+
+ function updateSectionTitleText(sectionElement, titleText, captionText) {
+ if (!sectionElement) return;
+ if (titleText != null) {
+ const titleEl =
+ sectionElement.querySelector('.sectionTitleContainer .sectionTitle.sectionTitle-cards') ||
+ sectionElement.querySelector('.spotlight-section-title .emby-tab-button');
+ if (titleEl) titleEl.textContent = titleText;
+ }
+ if (captionText != null) {
+ const captionEl = sectionElement.querySelector('.sectionTitle.sectionCaption');
+ if (captionEl) captionEl.textContent = captionText;
+ }
+ }
+
+ function updateSectionViewMoreLink(sectionElement, url) {
+ if (!sectionElement) return;
+ const titleLink =
+ sectionElement.querySelector('a.sectionTitle-link') ||
+ sectionElement.querySelector('.spotlight-section-title a.emby-tab-button');
+ if (!titleLink) return;
+ if (url) {
+ titleLink.href = url;
+ return;
+ }
+ titleLink.removeAttribute('href');
+ }
+
+ /**
+ * Rebuild title/caption/view-more under an existing sectionTitleContainer without
+ * wiping .section-controls. Used for discovery-pending → resolved and enhance.
+ */
+ function rebuildSectionTitleInPlace(sectionElement, sectionConfig) {
+ if (!sectionElement || !sectionConfig) return;
+ const titleContainer = sectionElement.querySelector('.sectionTitleContainer');
+ if (!titleContainer) {
+ // Spotlight titles live elsewhere — update text/link only
+ const title = getSectionTitleForMultiQuery(sectionConfig);
+ const viewMore = getActiveViewMoreUrl(sectionConfig);
+ updateSectionTitleText(sectionElement, title, getSectionCaption(sectionConfig));
+ updateSectionViewMoreLink(sectionElement, viewMore);
+ return;
+ }
+
+ const controls = titleContainer.querySelector('.section-controls');
+ Array.from(titleContainer.children).forEach((child) => {
+ if (child.classList?.contains('section-controls')) return;
+ // Also keep controls nested under sectionTitle-wrapper
+ if (child.querySelector?.(':scope > .section-controls')) {
+ const nested = child.querySelector(':scope > .section-controls');
+ if (nested) titleContainer.appendChild(nested);
+ }
+ child.remove();
+ });
+
+ appendSectionTitleContent(titleContainer, {
+ title: getSectionTitleForMultiQuery(sectionConfig),
+ caption: getSectionCaption(sectionConfig),
+ captionUrl: getSectionCaptionUrl(sectionConfig),
+ viewMoreUrl: getActiveViewMoreUrl(sectionConfig),
+ discoveryPending: false,
+ });
+
+ if (controls) {
+ const wrapper = titleContainer.querySelector(':scope > .sectionTitle-wrapper');
+ (wrapper || titleContainer).appendChild(controls);
+ }
+ }
+
+ function resolveSectionQueryTtl(sectionConfig) {
+ if (Number(sectionConfig?.ttl) >= 0) return Number(sectionConfig.ttl);
+ const fromConfig2 = window.KefinHomeConfig2?.CACHE?.DEFAULT_TTL;
+ if (Number(fromConfig2) >= 0) return Number(fromConfig2);
+ const fromConfig = window.KefinHomeConfig?.CACHE?.DEFAULT_TTL;
+ if (Number(fromConfig) >= 0) return Number(fromConfig);
+ return 60 * 60 * 1000;
+ }
+
+ function extractItemsFromQueryPayload(data) {
+ if (!data) return [];
+ if (Array.isArray(data)) return data;
+ if (Array.isArray(data.Items)) return data.Items;
+ return [];
+ }
+
+ /**
+ * Cache-aware load of the active query set (multi-query picker switches).
+ * Respects section TTL — does not force-bypass cache.
+ * @param {Object} sectionConfig
+ * @returns {Promise<{ items: Array, canPaintCached: boolean, itemsPromise: Promise }>}
+ */
+ async function loadActiveSectionQueryItems(sectionConfig) {
+ if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) {
+ console.warn(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} has no queries array`);
+ return {
+ items: [],
+ canPaintCached: false,
+ itemsPromise: Promise.resolve([]),
+ };
+ }
+
+ const userId = ApiClient.getCurrentUserId();
+ const serverUrl = ApiClient.serverAddress();
+ const ApiHelper = window.apiHelper;
+ const results = [];
+ const queriesToLoad = resolveQueriesToLoad(sectionConfig, { initialize: false });
+ const sectionTtl = resolveSectionQueryTtl(sectionConfig);
+
+ for (const query of queriesToLoad) {
+ let queryResult;
+
+ if (query.dataSource) {
+ queryResult = await ApiHelper.fetchFromDataSource(
+ query.dataSource,
+ query.queryOptions || {},
+ true,
+ query.dataSourceOptions,
+ );
+ } else {
+ const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', {
+ sectionType: sectionConfig.type,
+ });
+
+ if (typeof queryUrl === 'string') {
+ queryResult = await ApiHelper.getQuery(queryUrl, {
+ useCache: true,
+ ttl: sectionTtl,
+ });
+ } else {
+ console.warn(`[KefinTweaks CardBuilder] Invalid query URL for section ${sectionConfig.id}`);
+ continue;
+ }
+ }
+
+ results.push(queryResult);
+ }
+
+ const resolveFreshItems = async () => {
+ if (results.length > 1) {
+ const merged = ApiHelper.mergeMultiQueryResults(results, sectionConfig);
+ // Kick SWR so _refreshPromise is attached when cache is stale
+ const raw =
+ typeof merged.result?.ensureData === 'function'
+ ? await merged.result.ensureData()
+ : await (merged.result?.dataPromise ?? merged.result?.data);
+
+ const hasRefresh = results.some((r) => r && r._refreshPromise);
+ if (hasRefresh) {
+ const allData = await Promise.all(
+ results.map((r) => r._refreshPromise || Promise.resolve(r.data)),
+ );
+ const itemsByFreshQuery = [];
+ allData.forEach((data) => {
+ if (data?.Items) itemsByFreshQuery.push(data.Items);
+ else if (Array.isArray(data)) itemsByFreshQuery.push(data);
+ });
+ return postProcessItemsByQuery(sectionConfig, itemsByFreshQuery);
+ }
+
+ return extractItemsFromQueryPayload(raw);
+ }
+ if (results.length === 1) {
+ const qr = results[0];
+ // Kick SWR so _refreshPromise is attached when cache is stale
+ const raw =
+ typeof qr.ensureData === 'function'
+ ? await qr.ensureData()
+ : await (qr.dataPromise ?? qr.data);
+
+ if (qr._refreshPromise) {
+ const fresh = await qr._refreshPromise;
+ return postProcessItems(sectionConfig, extractItemsFromQueryPayload(fresh));
+ }
+
+ return postProcessItems(sectionConfig, extractItemsFromQueryPayload(raw));
+ }
+ return [];
+ };
+
+ if (results.length > 1) {
+ const merged = ApiHelper.mergeMultiQueryResults(results, sectionConfig);
+ const syncItems = extractItemsFromQueryPayload(merged.result?.data);
+ // Multi-merge is usable only when every source reported a fresh cache hit.
+ const canPaintCached = results.every((r) => r && r.isStale === false && r.data != null);
+ return {
+ items: canPaintCached ? syncItems : [],
+ canPaintCached,
+ itemsPromise: canPaintCached ? Promise.resolve(syncItems) : resolveFreshItems(),
+ };
+ }
+
+ if (results.length === 1) {
+ const qr = results[0];
+ const syncItems = postProcessItems(sectionConfig, extractItemsFromQueryPayload(qr?.data));
+ // Strict: only paint when apiHelper reported a non-stale cache hit with a payload.
+ const canPaintCached = qr?.isStale === false && qr?.data != null;
+ return {
+ items: canPaintCached ? syncItems : [],
+ canPaintCached,
+ itemsPromise: canPaintCached ? Promise.resolve(syncItems) : resolveFreshItems(),
+ };
+ }
+
+ return {
+ items: [],
+ canPaintCached: false,
+ itemsPromise: Promise.resolve([]),
+ };
+ }
+
+ /**
+ * Refresh section queries by re-executing them without cache
+ * @param {Object} sectionConfig - Section configuration with queries array
+ * @returns {Promise} - Processed items array
+ */
+ async function refreshSectionQueries(sectionConfig) {
+ if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) {
+ console.warn(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} has no queries array`);
+ return [];
+ }
+
+ const userId = ApiClient.getCurrentUserId();
+ const serverUrl = ApiClient.serverAddress();
+ const ApiHelper = window.apiHelper;
+ const results = [];
+ const queriesToLoad = resolveQueriesToLoad(sectionConfig, { initialize: false });
+
+ // Process each query in the active set
+ for (const query of queriesToLoad) {
+ let queryResult;
+
+ if (query.dataSource) {
+ // Handle cache-based data sources
+ queryResult = await ApiHelper.fetchFromDataSource(
+ query.dataSource,
+ query.queryOptions || {},
+ false,
+ query.dataSourceOptions,
+ );
+ } else {
+ // Build and execute query
+ const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl, sectionConfig.renderMode === 'Spotlight', {
+ sectionType: sectionConfig.type,
+ });
+
+ if (typeof queryUrl === 'string') {
+ // Standard query - use useCache: false to bypass cache
+ // When useCache is false, getQuery returns a Promise directly
+ const data = await ApiHelper.getQuery(queryUrl, {
+ useCache: false,
+ });
+ // Wrap in expected format for consistency
+ queryResult = {
+ data: data,
+ dataPromise: Promise.resolve(data),
+ isStalePromise: Promise.resolve(false),
+ };
+ } else {
+ console.warn(`[KefinTweaks CardBuilder] Invalid query URL for section ${sectionConfig.id}`);
+ continue;
+ }
+ }
+
+ results.push(queryResult);
+ }
+
+ // If multiple queries, merge results using section-level sortBy/sortOrder
+ let finalResult;
+ if (results.length > 1) {
+ finalResult = ApiHelper.mergeMultiQueryResults(results, sectionConfig);
+ // Extract items from merged result
+ const items = finalResult.result?.data?.Items || finalResult.result?.data || [];
+ return items;
+ } else if (results.length === 1) {
+ // Single query result
+ const data = results[0].data || results[0];
+ const items = data?.Items || data || [];
+ return postProcessItems(sectionConfig, items);
+ }
+
+ return [];
+ }
+
+ /**
+ * Toggle data-refreshing (drives refresh-button spin CSS). Manual refresh only —
+ * do not set true during enhance / stale revalidate / multi-query switch.
+ * @param {HTMLElement|null|undefined} el
+ * @param {boolean} refreshing
+ */
+ function setSectionRefreshing(el, refreshing) {
+ if (!el?.dataset) return;
+ el.dataset.refreshing = refreshing ? 'true' : 'false';
+ }
+
+ /**
+ * Paper-icon button with Material Icon on a child span (native Jellyfin pattern).
+ * Keep material-icons off the button — paper-icon-button-light sets font-family: inherit.
+ */
+ function createPaperIconButton({ className = '', icon = '', title = '', ariaLabel = '', showFocus = false } = {}) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = `${className} paper-icon-button-light`.trim();
+ if (showFocus) button.classList.add('show-focus');
+ if (title) button.title = title;
+ if (ariaLabel || title) button.setAttribute('aria-label', ariaLabel || title);
+
+ const iconSpan = document.createElement('span');
+ iconSpan.className = `material-icons ${icon}`.trim();
+ iconSpan.setAttribute('aria-hidden', 'true');
+ button.appendChild(iconSpan);
+ return button;
+ }
+
+ /**
+ * Show checkmark icon temporarily, then revert to refresh icon
+ * @param {HTMLElement} button - The refresh button element
+ */
+ async function showRefreshComplete(button) {
+ // Wait a brief moment for the spinning animation to complete smoothly
+ // This ensures the transition from spinning to checkmark looks natural
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+
+ const icon = button.querySelector('.material-icons') || button;
+
+ // Change to checkmark icon (state classes on button for CSS; glyph on icon span)
+ button.classList.remove('refresh', 'icon-fade-out');
+ button.classList.add('check_circle', 'icon-fade-in');
+ icon.classList.remove('refresh', 'icon-fade-out');
+ icon.classList.add('check_circle', 'icon-fade-in');
+
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ // Wait for fade in to complete
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ button.classList.remove('icon-fade-in');
+ icon.classList.remove('icon-fade-in');
+
+ // After 1.5 seconds, fade out checkmark and fade in refresh icon
+ setTimeout(async () => {
+ // Fade out checkmark
+ button.classList.add('icon-fade-out');
+ icon.classList.add('icon-fade-out');
+ await new Promise((resolve) => setTimeout(resolve, 200));
+
+ // Change to refresh icon
+ button.classList.remove('check_circle', 'icon-fade-out');
+ button.classList.add('refresh', 'icon-fade-in');
+ icon.classList.remove('check_circle', 'icon-fade-out');
+ icon.classList.add('refresh', 'icon-fade-in');
+
+ // Wait for fade in to complete
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ button.classList.remove('icon-fade-in');
+ icon.classList.remove('icon-fade-in');
+ }, 1500);
+ }
+
+ /** After an empty section is removed, let discovery infinite-scroll refill lead margin. */
+ function notifyDiscoveryLeadFillAfterSectionRemove() {
+ window.homeScreen3?.scheduleMaybeFillDiscoveryViewport?.();
+ }
+
+ async function replaceSectionContent(sectionConfig, sectionElement, freshItems) {
+ if (freshItems.length === 0) {
+ sectionElement.remove();
+ notifyDiscoveryLeadFillAfterSectionRemove();
+ return null;
+ }
+
+ let finalCardFormat = sectionConfig.cardFormat;
+ const currentCardFormat = sectionElement.getAttribute('data-card-format');
+ if (currentCardFormat) {
+ finalCardFormat = currentCardFormat;
+ }
+
+ const displayTitle = getSectionTitleForMultiQuery(sectionConfig);
+ const activeViewMoreUrl = getActiveViewMoreUrl(sectionConfig);
+
+ if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
+ const refreshed = refreshSpotlight(sectionElement, freshItems, {
+ name: displayTitle,
+ viewMoreUrl: activeViewMoreUrl,
+ spotlightConfig: sectionConfig.spotlightConfig,
+ dataItems: [],
+ keepCurrentSlide: false,
+ sectionConfig,
+ });
+ const content = refreshed.content;
+ ensureCardBorders(content);
+ setSectionRefreshing(content, false);
+ attachSectionControlButtons(sectionConfig, content, freshItems);
+ ensureSectionRevealObservation(content);
+ return content;
+ }
+
+ // In-place card refresh: keep section shell + sticky is=emby-itemscontainer
+ rebuildSectionTitleInPlace(sectionElement, {
+ ...sectionConfig,
+ name: displayTitle,
+ viewMoreUrl: activeViewMoreUrl,
+ });
+ // Ensure title text reflects multi-query resolution even if config.name lags
+ updateSectionTitleText(sectionElement, displayTitle, getSectionCaption(sectionConfig));
+ updateSectionViewMoreLink(sectionElement, activeViewMoreUrl);
+
+ if (!sectionElement.querySelector('.itemsContainer')) {
+ const content = createSectionScrollableContainer(
+ freshItems,
+ displayTitle,
+ activeViewMoreUrl,
+ sectionConfig.overflowCard,
+ finalCardFormat,
+ sectionConfig,
+ );
+ content.style.cssText = sectionElement.style.cssText;
+ content.className = sectionElement.className;
+ Array.from(sectionElement.attributes).forEach((attr) => {
+ content.setAttribute(attr.name, attr.value);
+ });
+ stripCopiedSkeletonSectionClasses(content);
+ unobserveSkeletonShimmer(sectionElement);
+ sectionElement.replaceWith(content);
+ ensureCardBorders(content);
+ attachSectionControlButtons(sectionConfig, content, freshItems);
+ setSectionRefreshing(content, false);
+ registerLazyImagesInSection(content);
+ ensureSectionRevealObservation(content);
+ return content;
+ }
+
+ replaceItemsContainerContents(sectionElement, freshItems, {
+ ...sectionConfig,
+ cardFormat: finalCardFormat,
+ });
+ unobserveSkeletonShimmer(sectionElement);
+ setSectionRefreshing(sectionElement, false);
+ ensureSectionRevealObservation(sectionElement);
+ return sectionElement;
+ }
+
+ /**
+ * Immediately swap section body to skeleton cards while a query fetch is in flight.
+ * @returns {HTMLElement} The skeleton section element now in the DOM
+ */
+ function showSectionQuerySkeletons(sectionConfig, sectionElement) {
+ let finalCardFormat = sectionConfig.cardFormat;
+ const currentCardFormat = sectionElement.getAttribute('data-card-format');
+ if (currentCardFormat) {
+ finalCardFormat = currentCardFormat;
+ }
+ if (finalCardFormat === 'random' || finalCardFormat === 'Random') {
+ finalCardFormat = getRandomCardFormat();
+ }
+
+ const displayTitle = getSectionTitleForMultiQuery(sectionConfig);
+ const activeViewMoreUrl = getActiveViewMoreUrl(sectionConfig);
+
+ let skeleton;
+ if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
+ const spotlightSettings = { ...(sectionConfig.spotlightConfig || {}) };
+ if (activeViewMoreUrl) spotlightSettings.viewMoreUrl = activeViewMoreUrl;
+ spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
+ spotlightSettings.caption = sectionConfig.caption;
+ skeleton = createSkeletonSpotlightSection(displayTitle, spotlightSettings);
+ } else {
+ skeleton = createProgressivelyEnhancedScrollableContainer(
+ displayTitle,
+ activeViewMoreUrl,
+ finalCardFormat,
+ sectionConfig.overflowCard,
+ sectionConfig,
+ );
+ }
+
+ skeleton.style.cssText = sectionElement.style.cssText;
+ skeleton.className = sectionElement.className;
+ Array.from(sectionElement.attributes).forEach((attr) => {
+ skeleton.setAttribute(attr.name, attr.value);
+ });
+ if (finalCardFormat) {
+ skeleton.setAttribute('data-card-format', finalCardFormat);
+ }
+ applySectionPresentationAttrs(skeleton, sectionConfig);
+ // Preserve data-refreshing from the replaced section if already set (manual refresh);
+ // do not force true here — multi-query / enhance must not spin the button.
+
+ const oldItemsContainer = sectionElement.querySelector('.itemsContainer');
+ const layoutFromDom = oldItemsContainer?.getAttribute('data-layout');
+ const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
+ const gapless = oldItemsContainer
+ ? oldItemsContainer.getAttribute('data-gapless') === 'true'
+ : resolveUseGaplessCards(sectionConfig);
+
+ sectionElement.replaceWith(skeleton);
+ applyItemsLayoutState(skeleton, layout, gapless);
+ attachSectionControlButtons(sectionConfig, skeleton, []);
+ ensureSectionRevealObservation(skeleton);
+ observeSkeletonShimmer(skeleton);
+ return skeleton;
+ }
+
+ function attachMultiQueryPicker(sectionConfig, sectionElement, titleContainer) {
+ const queries = sectionConfig?.queries || [];
+ if (queries.length <= 1 || sectionConfig.useMultiQueryPicker !== true) {
+ titleContainer.querySelectorAll('.multi-query-picker-button').forEach((btn) => btn.remove());
+ return;
+ }
+
+ titleContainer.querySelectorAll('.multi-query-picker-button').forEach((btn) => btn.remove());
+
+ resolveActiveQueryIndex(sectionConfig, { initialize: true });
+ updateSectionTitleText(sectionElement, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
+ updateSectionViewMoreLink(sectionElement, getActiveViewMoreUrl(sectionConfig));
+
+ 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');
+
+ const controlsMount = titleContainer.querySelector('.section-controls');
+ const titleLink = titleContainer.querySelector('a.sectionTitle-link');
+ const sectionTitle = titleContainer.querySelector('.sectionTitle');
+ // controls may live under .sectionTitle-wrapper when a caption is present
+ if (controlsMount?.parentNode) {
+ controlsMount.parentNode.insertBefore(selectButton, controlsMount);
+ } else if (titleLink?.parentNode) {
+ titleLink.parentNode.insertBefore(selectButton, titleLink.nextSibling);
+ } else if (sectionTitle?.parentNode) {
+ sectionTitle.parentNode.insertBefore(selectButton, sectionTitle.nextSibling);
+ } else {
+ titleContainer.appendChild(selectButton);
+ }
+
+ const MULTI_QUERY_MODAL_ID = 'kefin-multi-query-picker';
+
+ const multiQueryPopoverCss = `
+ .kefin-multi-query-modal {
+ background-color: rgba(0, 0, 0, 0.95) !important;
+ z-index: 1001;
+ min-width: 12rem;
+ max-width: min(90vw, 320px);
+ max-height: min(60vh, 420px);
+ overflow: hidden;
+ padding: 0 !important;
+ min-width: initial !important;
+ }
+ .kefin-multi-query-modal [data-name="kefin-modal-content"] {
+ overflow-y: auto !important;
+ max-height: min(60vh, 420px);
+ }
+ .kefinTweaks-popover.multiQueryPopover {
+ margin-top: 0 !important;
+ display: grid;
+ }
+ `;
+ let multiQueryStyleEl = document.getElementById('kefinTweaks-multiQueryPopover-style');
+ if (!multiQueryStyleEl) {
+ multiQueryStyleEl = document.createElement('style');
+ multiQueryStyleEl.id = 'kefinTweaks-multiQueryPopover-style';
+ document.head.appendChild(multiQueryStyleEl);
+ }
+ multiQueryStyleEl.textContent = multiQueryPopoverCss;
+
+ selectButton.addEventListener('click', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (!window.ModalSystem?.create) return;
+ if (window.ModalSystem.isOpen?.(MULTI_QUERY_MODAL_ID)) {
+ window.ModalSystem.close(MULTI_QUERY_MODAL_ID);
+ return;
+ }
+
+ 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;
+ // Clear cached section URL so resolve uses the newly selected query
+ sectionConfig.viewMoreUrl = null;
+
+ 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));
+
+ try {
+ let viewMoreUrl = getActiveViewMoreUrl(sectionConfig);
+ if (!viewMoreUrl && typeof window.sectionHelper?.resolveViewMoreUrl === 'function') {
+ viewMoreUrl = await window.sectionHelper.resolveViewMoreUrl(sectionConfig);
+ }
+ if (viewMoreUrl) {
+ sectionConfig.viewMoreUrl = viewMoreUrl;
+ }
+ updateSectionViewMoreLink(activeSectionEl, viewMoreUrl || null);
+
+ 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;
+ setSectionRefreshing(live, false);
+ }
+ };
+
+ queries.forEach((query, index) => {
+ 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', 'is-active');
+ selectedItemElement = itemElement;
+ }
+ itemElement.textContent = getQueryDisplayName(query, index);
+ itemElement.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ selectQuery(index);
+ });
+ list.appendChild(itemElement);
+ });
+
+ 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',
+ // Match breadcrumb season popover: page scrolls unless the list itself overflows
+ lockPageScroll: false,
+ onOpen: (modal) => {
+ if (modal.backdrop) modal.backdrop.style.background = 'transparent';
+ // Document-absolute shell via ModalSystem.positionNearAnchor.
+ if (modal.dialogContent) {
+ modal.dialogContent.style.padding = '0';
+ modal.dialogContent.style.overflowY = 'auto';
+ modal.dialogContent.style.minHeight = '0';
+ modal.dialogContent.style.flex = '0 1 auto';
+ modal.dialogContent.style.overscrollBehavior = 'auto';
+ }
+ if (modal.dialog) {
+ modal.dialog.style.overscrollBehavior = 'auto';
+ }
+ if (selectedItemElement) {
+ requestAnimationFrame(() => {
+ selectedItemElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ });
+ }
+ },
+ });
+ });
+ }
+
+ function ensureSectionControlsMount(titleContainer) {
+ const wrapper = titleContainer.querySelector(':scope > .sectionTitle-wrapper');
+ const mountParent = wrapper || titleContainer;
+
+ let controls = mountParent.querySelector(':scope > .section-controls');
+ if (!controls) {
+ controls = titleContainer.querySelector(':scope > .section-controls');
+ }
+
+ if (!controls) {
+ controls = document.createElement('div');
+ controls.className = 'section-controls';
+
+ const inline = document.createElement('div');
+ inline.className = 'section-controls-inline';
+
+ const moreButton = createPaperIconButton({
+ className: 'section-controls-more',
+ icon: 'more_vert',
+ title: 'More',
+ showFocus: isTvLayout(),
+ });
+
+ controls.appendChild(inline);
+ controls.appendChild(moreButton);
+ mountParent.appendChild(controls);
+ } else if (controls.parentNode !== mountParent) {
+ mountParent.appendChild(controls);
+ }
+
+ const inline = controls.querySelector('.section-controls-inline');
+ // Controls-only group must not trap L/R — title + controls share one row (below).
+ inline?.classList.remove('focuscontainer-x');
+
+ // TV: title link + section controls in one focuscontainer-x (spotlight uses headerContainer).
+ if (isTvLayout() && titleContainer.classList.contains('sectionTitleContainer')) {
+ mountParent.classList.add('focuscontainer-x');
+ }
+
+ return {
+ controls,
+ inline,
+ moreButton: controls.querySelector('.section-controls-more'),
+ };
+ }
+
+ function normalizeStaticSectionItems(sectionConfig) {
+ const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot || '';
+ const serverId = typeof ApiClient !== 'undefined' && typeof ApiClient.serverId === 'function' ? ApiClient.serverId() : '';
+ const normalizeTemplate = (value) =>
+ (value || '').replace(/\$\{kefinTweaksRoot\}/g, kefinTweaksRoot).replace(/\$\{serverId\}/g, serverId);
+
+ const visibleItems = (sectionConfig.items || []).filter((item) => item && item.hidden !== true);
+ return visibleItems.map((item, index) => ({
+ Name: item.Name,
+ Id: item.Id || 'static-' + sectionConfig.id + '-' + index,
+ Type: item.Type || 'Folder',
+ posterUrl: normalizeTemplate(item.posterUrl),
+ thumbUrl: normalizeTemplate(item.thumbUrl),
+ squareUrl: normalizeTemplate(item.squareUrl),
+ imageUrl: normalizeTemplate(item.imageUrl),
+ cardUrl: normalizeTemplate(item.cardUrl),
+ backdropUrl: normalizeTemplate(item.backdropUrl),
+ bannerUrl: normalizeTemplate(item.bannerUrl),
+ logoUrl: normalizeTemplate(item.logoUrl),
+ CustomFooterText: item.cardFooter || undefined,
+ }));
+ }
+
+ /**
+ * Refresh section content in place (used by section control registry).
+ * @returns {Promise} refresh button on the new section, if any
+ */
+ async function executeSectionRefresh(sectionConfig, sectionElement) {
+ setSectionRefreshing(sectionElement, true);
+
+ try {
+ const hasStaticItems = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
+ let freshItems;
+
+ if (hasStaticItems) {
+ if (!sectionHasRandomQuerySort(sectionConfig)) {
+ setSectionRefreshing(sectionElement, false);
+ const refreshButton = sectionElement.querySelector('.section-refresh-button');
+ if (refreshButton) {
+ showRefreshComplete(refreshButton);
+ }
+ return refreshButton || null;
+ }
+
+ freshItems = postProcessItems(sectionConfig, normalizeStaticSectionItems(sectionConfig));
+ if (freshItems.length === 0) {
+ setSectionRefreshing(sectionElement, false);
+ return null;
+ }
+ } else {
+ freshItems = await refreshSectionQueries(sectionConfig);
+ if (freshItems.length === 0) {
+ sectionElement.remove();
+ notifyDiscoveryLeadFillAfterSectionRemove();
+ return null;
+ }
+ }
+
+ let finalCardFormat = sectionConfig.cardFormat;
+ const currentCardFormat = sectionElement.getAttribute('data-card-format');
+ if (currentCardFormat) {
+ finalCardFormat = currentCardFormat;
+ }
+
+ let content = null;
+ if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
+ const refreshed = refreshSpotlight(sectionElement, freshItems, {
+ name: getSectionTitleForMultiQuery(sectionConfig),
+ viewMoreUrl: getActiveViewMoreUrl(sectionConfig),
+ spotlightConfig: sectionConfig.spotlightConfig,
+ dataItems: [],
+ keepCurrentSlide: false,
+ sectionConfig,
+ });
+ content = refreshed.content;
+ ensureCardBorders(content);
+ setSectionRefreshing(content, false);
+ const newButton = attachSectionControlButtons(sectionConfig, content, freshItems);
+ if (newButton) {
+ showRefreshComplete(newButton);
+ }
+ return newButton;
+ }
+
+ // In-place card refresh: keep section shell + sticky is=emby-itemscontainer
+ if (sectionElement.querySelector('.itemsContainer')) {
+ rebuildSectionTitleInPlace(sectionElement, sectionConfig);
+ updateSectionTitleText(sectionElement, getSectionTitleForMultiQuery(sectionConfig), getSectionCaption(sectionConfig));
+ updateSectionViewMoreLink(sectionElement, getActiveViewMoreUrl(sectionConfig));
+ replaceItemsContainerContents(sectionElement, freshItems, {
+ ...sectionConfig,
+ cardFormat: finalCardFormat,
+ });
+ unobserveSkeletonShimmer(sectionElement);
+ setSectionRefreshing(sectionElement, false);
+ ensureSectionRevealObservation(sectionElement);
+ const newButton = sectionElement.querySelector('.section-refresh-button');
+ if (newButton) {
+ showRefreshComplete(newButton);
+ }
+ return newButton;
+ }
+
+ content = createSectionScrollableContainer(
+ freshItems,
+ getSectionTitleForMultiQuery(sectionConfig),
+ getActiveViewMoreUrl(sectionConfig),
+ sectionConfig.overflowCard,
+ finalCardFormat,
+ sectionConfig,
+ );
+
+ content.style.cssText = sectionElement.style.cssText;
+ content.className = sectionElement.className;
+ Array.from(sectionElement.attributes).forEach((attr) => {
+ content.setAttribute(attr.name, attr.value);
+ });
+
+ invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller'));
+ stripCopiedSkeletonSectionClasses(content);
+ unobserveSkeletonShimmer(sectionElement);
+ sectionElement.replaceWith(content);
+ applyItemsLayoutState(content, resolveItemsLayout(sectionConfig), resolveUseGaplessCards(sectionConfig));
+ ensureCardBorders(content);
+
+ const newButton = attachSectionControlButtons(sectionConfig, content, freshItems);
+ setSectionRefreshing(content, false);
+ registerLazyImagesInSection(content);
+ ensureSectionRevealObservation(content);
+ if (newButton) {
+ showRefreshComplete(newButton);
+ }
+ return newButton;
+ } catch (error) {
+ console.error('[KefinTweaks CardBuilder] Error refreshing section:', error);
+ setSectionRefreshing(sectionElement, false);
+ return null;
+ }
+ }
+
+ function attachSectionControlButtons(sectionConfig, sectionElement, cachedItems) {
+ const titleContainer =
+ sectionElement.querySelector('.sectionTitleContainer') || sectionElement.querySelector('.spotlight-section-title-container');
+ if (!titleContainer) return null;
+
+ titleContainer
+ .querySelectorAll(':scope > .show-all-button, :scope > .section-refresh-button, :scope > .section-configure-button')
+ .forEach((el) => el.remove());
+
+ ensureSectionControlsMount(titleContainer);
+ const definitions = getSectionControlDefinitions(sectionConfig, sectionElement, cachedItems);
+ const refreshButton = renderSectionControls(sectionConfig, sectionElement, titleContainer, definitions);
+
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ const layout = itemsContainer?.getAttribute('data-layout') || resolveItemsLayout(sectionConfig);
+ const showAllBtn = titleContainer.querySelector('.show-all-button');
+ if (showAllBtn) {
+ const resolved = layout === 'grid' ? layout : 'row';
+ showAllBtn.title = ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row;
+ showAllBtn.setAttribute('aria-label', showAllBtn.title);
+ setShowAllIcon(showAllBtn, nextItemsLayout(resolved));
+ }
+
+ attachMultiQueryPicker(sectionConfig, sectionElement, titleContainer);
+ return refreshButton;
+ }
+
+ let cardUserDataListenerStarted = false;
+ let cardLibraryChangedListenerStarted = false;
+ let pendingLibraryUpdatedIds = new Set();
+ let libraryUpdatedFlushTimer = null;
+ let libraryUpdatedFlushRunning = false;
+ const LIBRARY_UPDATED_DEBOUNCE_MS = 750;
+ const LIVE_SYNC_REBUILD_BATCH_SIZE = 8;
+ // Match apiHelper DEFAULT_ITEM_FIELDS so rebuilt cards keep aspect/overlays/indicators.
+ const LIVE_SYNC_ITEM_FIELDS =
+ 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,ProviderIds,MediaSourceCount';
+
+ function scheduleLibraryUpdatedFlush() {
+ if (libraryUpdatedFlushTimer) clearTimeout(libraryUpdatedFlushTimer);
+ libraryUpdatedFlushTimer = setTimeout(() => {
+ void flushVisibleCardLibraryUpdates();
+ }, LIBRARY_UPDATED_DEBOUNCE_MS);
+ }
+
+ function yieldToMainThread() {
+ return new Promise((resolve) => {
+ if (typeof requestAnimationFrame === 'function') {
+ requestAnimationFrame(() => resolve());
+ } else {
+ setTimeout(resolve, 0);
+ }
+ });
+ }
+
+ function escapeCssIdentForCardSync(value) {
+ return typeof CSS !== 'undefined' && CSS.escape
+ ? CSS.escape(String(value))
+ : String(value).replace(/["\\]/g, '\\$&');
+ }
+
+ function getPrimaryUserIdForCardSync() {
+ return typeof ApiClient !== 'undefined' && typeof ApiClient.getCurrentUserId === 'function' ? ApiClient.getCurrentUserId() : null;
+ }
+
+ function isPrimaryUserDataChange(userId) {
+ const primaryId = getPrimaryUserIdForCardSync();
+ if (!primaryId) return false;
+ if (!userId) return true;
+ return userId === primaryId;
+ }
+
+ const RESUME_OR_NEXTUP_SECTION_IDS = new Set([
+ 'continueWatching',
+ 'nextUp',
+ 'continueWatchingAndNextUp',
+ ]);
+ const NEXTUP_REFRESH_SECTION_IDS = ['nextUp', 'continueWatchingAndNextUp'];
+ const NEXTUP_PROGRESSIVE_REFRESH_DEBOUNCE_MS = 400;
+ let nextUpProgressiveRefreshTimer = null;
+
+ function getCardHomeSectionId(card) {
+ const sectionEl = card?.closest?.('[data-section-id]');
+ return sectionEl?.getAttribute('data-section-id') || sectionEl?.dataset?.sectionId || null;
+ }
+
+ function findMountedHomeSectionElement(sectionId) {
+ if (!sectionId) return null;
+ const escaped = escapeCssIdentForCardSync(sectionId);
+ return document.querySelector(`.homePage:not(.hide) #homeTab .sections [data-section-id="${escaped}"]`)
+ || document.querySelector(`[data-section-id="${escaped}"]`);
+ }
+
+ function maybeDismissEmptyResumeSection(sectionElement) {
+ if (!sectionElement?.isConnected) return;
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ const remaining = itemsContainer
+ ? getRealLayoutCards(itemsContainer).filter((card) => {
+ return card.getAttribute('data-id')
+ && !card.classList.contains('skeleton-card')
+ && !card.classList.contains('cardbuilder-card-collapse');
+ })
+ : [];
+ if (remaining.length) return;
+
+ const holdMs = getDismissEmptySectionTimerMs();
+ if (holdMs > 0) {
+ dismissEmptyProgressiveSection(sectionElement, { holdMs });
+ } else {
+ sectionElement.remove();
+ notifyDiscoveryLeadFillAfterSectionRemove();
+ }
+ }
+
+ /**
+ * Collapse cards with the same animation as reconcileRowItems outgoing cards, then remove.
+ * @param {HTMLElement[]} cards
+ * @param {Function} [onComplete]
+ */
+ function collapseAndRemoveCards(cards, onComplete) {
+ const uniqueCards = [];
+ const seen = new Set();
+ (cards || []).forEach((card) => {
+ if (!card?.isConnected || seen.has(card)) return;
+ seen.add(card);
+ uniqueCards.push(card);
+ });
+ if (!uniqueCards.length) {
+ if (typeof onComplete === 'function') onComplete();
+ return;
+ }
+
+ const bySection = new Map();
+ uniqueCards.forEach((card) => {
+ const sectionEl = card.closest?.('[data-section-id]');
+ if (!sectionEl) {
+ card.remove();
+ return;
+ }
+ if (!bySection.has(sectionEl)) bySection.set(sectionEl, []);
+ bySection.get(sectionEl).push(card);
+ });
+
+ let pending = bySection.size;
+ if (!pending) {
+ if (typeof onComplete === 'function') onComplete();
+ return;
+ }
+
+ const finishOne = () => {
+ pending -= 1;
+ if (pending <= 0 && typeof onComplete === 'function') onComplete();
+ };
+
+ bySection.forEach((sectionCards, sectionElement) => {
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ if (!itemsContainer) {
+ sectionCards.forEach((card) => card.remove());
+ maybeDismissEmptyResumeSection(sectionElement);
+ finishOne();
+ return;
+ }
+
+ const layouts = sectionCards.map(captureCardLayout);
+ layouts.forEach((layout) => {
+ layout.card.classList.add('cardbuilder-card-collapse');
+ applyCardExpandedSize(layout);
+ });
+ // Force layout before collapsing (same as reconcileRowItems)
+ itemsContainer.offsetHeight;
+ itemsContainer.classList.add('cardbuilder-row-animating');
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ layouts.forEach(({ card }) => applyCardCollapsedSize(card));
+ const animTarget = sectionCards[0] || itemsContainer;
+ runAfterTransition(animTarget, 450, () => {
+ sectionCards.forEach((card) => {
+ if (card.isConnected) card.remove();
+ });
+ itemsContainer.classList.remove('cardbuilder-row-animating');
+ reindexSectionCards(itemsContainer);
+ maybeDismissEmptyResumeSection(sectionElement);
+ finishOne();
+ });
+ });
+ });
+ });
+ }
+
+ /**
+ * Invalidate-free progressive refresh for currently mounted home sections.
+ * @param {string[]} sectionIds
+ */
+ async function refreshMountedSectionsProgressive(sectionIds) {
+ const ids = Array.isArray(sectionIds) ? sectionIds.filter(Boolean) : [];
+ for (const sectionId of ids) {
+ const sectionElement = findMountedHomeSectionElement(sectionId);
+ if (!sectionElement?.isConnected) continue;
+
+ const runtime = window.KefinHomeScreenSectionRuntime?.get?.(sectionId);
+ const cfg = runtime?.effectiveConfig || null;
+ if (!cfg) continue;
+
+ let items = [];
+ try {
+ items = await refreshSectionQueries(cfg);
+ } catch (e) {
+ console.warn('[KefinTweaks CardBuilder] Progressive refresh failed for', sectionId, e);
+ continue;
+ }
+ if (!Array.isArray(items)) items = [];
+
+ if (runtime) {
+ runtime.cachedItems = items;
+ runtime.element = sectionElement;
+ }
+
+ applyProgressiveEnhancement(
+ sectionElement,
+ { config: cfg, result: { data: { Items: items } } },
+ items,
+ { updateScrollButtons: true },
+ );
+ await yieldToMainThread();
+ }
+ }
+
+ function scheduleNextUpSectionsProgressiveRefresh() {
+ if (nextUpProgressiveRefreshTimer) clearTimeout(nextUpProgressiveRefreshTimer);
+ nextUpProgressiveRefreshTimer = setTimeout(() => {
+ nextUpProgressiveRefreshTimer = null;
+ void refreshMountedSectionsProgressive(NEXTUP_REFRESH_SECTION_IDS);
+ }, NEXTUP_PROGRESSIVE_REFRESH_DEBOUNCE_MS);
+ }
+
+ /**
+ * Keep data-positionticks on visible cards aligned with primary-user playstate.
+ * Driven by UserDataChanged (same event Jellyfin uses for progress bars) — not DOM observation.
+ * @param {string} itemId
+ * @param {Object} userData
+ * @param {HTMLElement[]} [cardsHint] optional pre-found cards (avoids a second DOM scan)
+ * @returns {HTMLElement[]}
+ */
+ function updateCardResumeAttributes(itemId, userData, cardsHint) {
+ if (!itemId || !userData) return [];
+
+ const cards = Array.isArray(cardsHint) ? cardsHint : findVisibleCardsForItemId(itemId);
+ if (!cards.length) return cards;
+
+ const ticks = userData.PlaybackPositionTicks;
+
+ cards.forEach((card) => {
+ if (userData.Played === true) {
+ card.removeAttribute('data-positionticks');
+ return;
+ }
+ if (typeof ticks === 'number' && ticks > 0) {
+ card.setAttribute('data-positionticks', String(ticks));
+ } else {
+ card.removeAttribute('data-positionticks');
+ }
+ });
+ return cards;
+ }
+
+ function updateCardWatchlistButtons(itemId, userData) {
+ if (!state.watchlist) return;
+ if (!itemId || !userData || !('Likes' in userData)) return;
+
+ const isLiked = userData.Likes === true;
+ const likedHelper = window.KefinWatchlistLikedIds;
+ if (likedHelper?.set) {
+ likedHelper.set(itemId, isLiked);
+ }
+
+ itemIdDomVariants(itemId).forEach((variant) => {
+ const escapedId = escapeCssIdentForCardSync(variant);
+ document.querySelectorAll(`.watchlist-button[data-id="${escapedId}"]`).forEach((button) => {
+ button.dataset.active = isLiked ? 'true' : 'false';
+ button.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
+ });
+ });
+ }
+
+ function updateCardFavoriteButtons(itemId, userData) {
+ if (!itemId || !userData || !('IsFavorite' in userData)) return;
+
+ const isFavorite = userData.IsFavorite === true;
+ itemIdDomVariants(itemId).forEach((variant) => {
+ const escapedId = escapeCssIdentForCardSync(variant);
+ document.querySelectorAll(
+ `[is="emby-ratingbutton"][data-id="${escapedId}"], button[data-isfavorite][data-id="${escapedId}"]`
+ ).forEach((button) => {
+ button.setAttribute('data-isfavorite', isFavorite ? 'true' : 'false');
+ button.title = isFavorite ? 'Remove from favorites' : 'Add to favorites';
+ });
+ });
+ }
+
+ function applyUserDataEntriesToCards(entries, userId) {
+ if (!isPrimaryUserDataChange(userId)) return;
+
+ const cardsToCollapse = [];
+ entries.forEach((entry) => {
+ if (!entry?.ItemId) return;
+
+ const cards = findVisibleCardsForItemId(entry.ItemId);
+ updateCardResumeAttributes(entry.ItemId, entry, cards);
+ updateCardWatchlistButtons(entry.ItemId, entry);
+ updateCardFavoriteButtons(entry.ItemId, entry);
+
+ if (entry.Played === true) {
+ cards.forEach((card) => {
+ const sectionId = getCardHomeSectionId(card);
+ if (sectionId && RESUME_OR_NEXTUP_SECTION_IDS.has(sectionId)) {
+ cardsToCollapse.push(card);
+ }
+ });
+ }
+ });
+
+ if (!cardsToCollapse.length) return;
+
+ // Refresh after collapse so progressive enhance does not race outgoing animation
+ collapseAndRemoveCards(cardsToCollapse, () => {
+ scheduleNextUpSectionsProgressiveRefresh();
+ });
+ }
+
+ function startCardUserDataListener() {
+ if (cardUserDataListenerStarted) return;
+
+ if (!window.websocketHelper?.listen) {
+ setTimeout(startCardUserDataListener, 1000);
+ return;
+ }
+
+ window.websocketHelper.listen('UserDataChanged', (msg) => {
+ const userId = msg?.Data?.UserId;
+ const entries = msg?.Data?.UserDataList;
+ if (!Array.isArray(entries) || !entries.length) return;
+ applyUserDataEntriesToCards(entries, userId);
+ });
+ cardUserDataListenerStarted = true;
+ console.log('[KefinTweaks CardBuilder] Listening for UserDataChanged to sync card resume attributes');
+ }
+
+ function liveSyncLog(...args) {
+ console.log('[KefinTweaks CardBuilder][LiveSync]', ...args);
+ }
+
+ function liveSyncWarn(...args) {
+ console.warn('[KefinTweaks CardBuilder][LiveSync]', ...args);
+ }
+
+ /** Strip GUID dashes for comparison (server LibraryChanged often uses "N" format). */
+ function normalizeItemIdKey(id) {
+ return String(id || '')
+ .replace(/-/g, '')
+ .toLowerCase();
+ }
+
+ /** Candidate data-id strings for DOM queries (dashed + undashed). */
+ function itemIdDomVariants(id) {
+ const raw = String(id || '').trim();
+ if (!raw) return [];
+ const variants = new Set([raw]);
+ const naked = normalizeItemIdKey(raw);
+ if (/^[0-9a-f]{32}$/.test(naked)) {
+ variants.add(naked);
+ variants.add(
+ `${naked.slice(0, 8)}-${naked.slice(8, 12)}-${naked.slice(12, 16)}-${naked.slice(16, 20)}-${naked.slice(20)}`,
+ );
+ }
+ return Array.from(variants);
+ }
+
+ function findVisibleCardsForItemId(itemId) {
+ if (!itemId) return [];
+ const found = new Set();
+ itemIdDomVariants(itemId).forEach((variant) => {
+ const escapedId = escapeCssIdentForCardSync(variant);
+ document.querySelectorAll(`.card[data-id="${escapedId}"]:not(.skeleton-card)`).forEach((card) => {
+ found.add(card);
+ });
+ });
+ return Array.from(found);
+ }
+
+ function resolveCardRebuildArgs(card) {
+ const sectionEl = card?.closest?.('[data-section-id]');
+ const sectionId = sectionEl?.getAttribute('data-section-id') || sectionEl?.dataset?.sectionId || null;
+ const runtimeCfg = sectionId ? window.KefinHomeScreenSectionRuntime?.get?.(sectionId)?.effectiveConfig : null;
+
+ const cardFormat = sectionEl?.getAttribute('data-card-format') || runtimeCfg?.cardFormat || null;
+
+ let overflowCard;
+ if (runtimeCfg && typeof runtimeCfg.overflowCard === 'boolean') {
+ overflowCard = runtimeCfg.overflowCard;
+ } else if (/\boverflow(?:Portrait|Backdrop|Square|Banner)Card\b/.test(card?.className || '')) {
+ overflowCard = true;
+ } else if (/\b(?:portrait|backdrop|square|banner)Card\b/.test(card?.className || '')) {
+ overflowCard = false;
+ } else {
+ // Home rows default to overflow sizing.
+ overflowCard = true;
+ }
+
+ const useParentCard = !!runtimeCfg?.useParentCard;
+ const borderStyle =
+ runtimeCfg?.borderStyle || sectionEl?.dataset?.borderStyle || sectionEl?.dataset?.border || null;
+
+ return { cardFormat, overflowCard, useParentCard, borderStyle, sectionId };
+ }
+
+ /** Image tags the card builder may put in data-src (any format / fallback path). */
+ function collectItemImageTagsForLiveSync(item) {
+ const tags = new Set();
+ const add = (t) => {
+ if (t != null && t !== '') tags.add(String(t));
+ };
+ const it = item?.ImageTags;
+ if (it) {
+ add(it.Primary);
+ add(it.Thumb);
+ add(it.Logo);
+ add(it.Banner);
+ add(it.Art);
+ add(it.Disc);
+ }
+ add(item?.BackdropImageTags?.[0]);
+ add(item?.PrimaryImageTag);
+ add(item?.SeriesPrimaryImageTag);
+ add(item?.SeriesThumbImageTag);
+ add(item?.ParentThumbImageTag);
+ add(item?.ParentBackdropImageTags?.[0]);
+ add(item?.ParentPrimaryImageTag);
+ add(item?.ParentLogoImageTag);
+ return tags;
+ }
+
+ function extractTagFromImageUrl(url) {
+ if (!url) return '';
+ try {
+ const m = String(url).match(/[?&]tag=([^&]*)/i);
+ return m?.[1] ? decodeURIComponent(m[1]) : '';
+ } catch (e) {
+ return '';
+ }
+ }
+
+ /**
+ * Skip DOM replace when painted card signals already match the fresh item.
+ * Compares name, image tag in data-src, resume ticks, played / unplayed indicators.
+ */
+ function cardNeedsLiveSyncRebuild(oldCard, freshItem) {
+ if (!oldCard || !freshItem) return false;
+
+ const img =
+ oldCard.querySelector?.('.cardImageContainer[data-src]') ||
+ oldCard.querySelector?.('.cardImageContainer') ||
+ null;
+ const dataSrc = img?.getAttribute('data-src') || '';
+ const paintedTag = extractTagFromImageUrl(dataSrc);
+ const paintedName = (img?.getAttribute('aria-label') || '').trim();
+ const paintedTicks = oldCard.getAttribute('data-positionticks') || '';
+ const paintedPlayed = !!oldCard.querySelector?.('.playedIndicator');
+ const paintedUnplayed = (oldCard.querySelector?.('.countIndicator.indicator')?.textContent || '').trim();
+ const paintedMediaSources = (oldCard.querySelector?.('.mediaSourceIndicator')?.textContent || '').trim();
+
+ const expectedName = String(freshItem.Name || 'Unknown').trim();
+ const nameChanged = paintedName !== expectedName && paintedName !== String(freshItem.Name || '').trim();
+
+ const freshTags = collectItemImageTagsForLiveSync(freshItem);
+ const hadImage = !!dataSrc;
+ const hasImageTags = freshTags.size > 0;
+ let imageChanged = false;
+ if (paintedTag) {
+ // Painted tag no longer on the item → artwork changed for this card.
+ imageChanged = !freshTags.has(paintedTag);
+ } else if (!hadImage && hasImageTags) {
+ imageChanged = true;
+ } else if (hadImage && !hasImageTags) {
+ imageChanged = true;
+ }
+
+ const freshPlayed = freshItem.UserData?.Played === true;
+ const freshTicks =
+ !freshPlayed && typeof freshItem.UserData?.PlaybackPositionTicks === 'number' && freshItem.UserData.PlaybackPositionTicks > 0
+ ? String(freshItem.UserData.PlaybackPositionTicks)
+ : '';
+ const ticksChanged = paintedTicks !== freshTicks;
+ const playedChanged = paintedPlayed !== freshPlayed;
+
+ const freshUnplayed =
+ freshItem.UserData?.UnplayedItemCount && freshItem.UserData.UnplayedItemCount > 0
+ ? String(freshItem.UserData.UnplayedItemCount)
+ : '';
+ const unplayedChanged = paintedUnplayed !== freshUnplayed;
+
+ const freshMediaSources =
+ typeof freshItem.MediaSourceCount === 'number' && freshItem.MediaSourceCount > 1
+ ? String(freshItem.MediaSourceCount)
+ : '';
+ const mediaSourcesChanged = paintedMediaSources !== freshMediaSources;
+
+ const needs = nameChanged || imageChanged || ticksChanged || playedChanged || unplayedChanged || mediaSourcesChanged;
+ if (!needs) {
+ liveSyncLog('Skipping rebuild — no significant painted changes', {
+ itemId: freshItem.Id,
+ name: expectedName,
+ paintedTag,
+ });
+ }
+ return needs;
+ }
+
+ function replaceVisibleCardWithFreshItem(oldCard, freshItem) {
+ if (!oldCard || !freshItem) return false;
+ if (!cardNeedsLiveSyncRebuild(oldCard, freshItem)) return false;
+
+ const { cardFormat, overflowCard, useParentCard, borderStyle, sectionId } = resolveCardRebuildArgs(oldCard);
+ liveSyncLog('Rebuilding card', {
+ itemId: freshItem.Id,
+ name: freshItem.Name,
+ sectionId,
+ cardFormat,
+ overflowCard,
+ useParentCard,
+ borderStyle,
+ imagePrimaryTag: freshItem.ImageTags?.Primary || null,
+ });
+ const next = createCardElement(
+ freshItem,
+ overflowCard,
+ cardFormat,
+ freshItem.cardFooter || freshItem.CustomFooterText || null,
+ useParentCard,
+ borderStyle,
+ );
+ const index = oldCard.getAttribute('data-index');
+ if (index != null) next.setAttribute('data-index', index);
+
+ const hadFocus = document.activeElement === oldCard;
+ oldCard.replaceWith(next);
+ // New card has data-src + .lazy but is not in any IntersectionObserver yet.
+ registerLazyImagesIn(next);
+ if (hadFocus && typeof next.focus === 'function') {
+ try {
+ next.focus({ preventScroll: true });
+ } catch (e) {
+ next.focus();
+ }
+ }
+ return true;
+ }
+
+ async function flushVisibleCardLibraryUpdates() {
+ libraryUpdatedFlushTimer = null;
+ if (libraryUpdatedFlushRunning) {
+ // Keep pending ids; schedule one follow-up after the in-flight flush
+ if (pendingLibraryUpdatedIds.size) {
+ liveSyncLog('Flush already running — deferring follow-up', {
+ pendingSize: pendingLibraryUpdatedIds.size,
+ });
+ scheduleLibraryUpdatedFlush();
+ }
+ return;
+ }
+
+ const ids = Array.from(pendingLibraryUpdatedIds);
+ pendingLibraryUpdatedIds.clear();
+ liveSyncLog('Flush start', { pendingIds: ids });
+ if (!ids.length) return;
+
+ libraryUpdatedFlushRunning = true;
+ try {
+ const matchCounts = {};
+ const idsToFetch = ids.filter((id) => {
+ const count = findVisibleCardsForItemId(id).length;
+ matchCounts[id] = count;
+ return count > 0;
+ });
+ liveSyncLog('Visible card matches', matchCounts);
+
+ if (!idsToFetch.length) {
+ liveSyncLog('No on-page cards for updated ids — skipping fetch');
+ return;
+ }
+
+ if (typeof ApiClient === 'undefined' || typeof ApiClient.getItems !== 'function') {
+ liveSyncWarn('ApiClient.getItems unavailable — cannot refresh cards');
+ return;
+ }
+
+ const userId = ApiClient.getCurrentUserId?.();
+ liveSyncLog('Fetching items', { userId, idsToFetch, fields: LIVE_SYNC_ITEM_FIELDS });
+
+ const response = await ApiClient.getItems(userId, {
+ Ids: idsToFetch.join(','),
+ Recursive: false,
+ Fields: LIVE_SYNC_ITEM_FIELDS,
+ });
+ const itemsById = new Map();
+ (response?.Items || []).forEach((item) => {
+ if (!item?.Id) return;
+ itemsById.set(normalizeItemIdKey(item.Id), item);
+ });
+ liveSyncLog('Fetch result', {
+ requested: idsToFetch.length,
+ returned: itemsById.size,
+ itemSummaries: Array.from(itemsById.values()).map((item) => ({
+ id: item.Id,
+ name: item.Name,
+ primaryTag: item.ImageTags?.Primary || null,
+ })),
+ });
+
+ // Flatten id → cards into a rebuild work list, then process in rAF batches
+ const work = [];
+ idsToFetch.forEach((id) => {
+ const freshItem = itemsById.get(normalizeItemIdKey(id));
+ if (!freshItem) {
+ liveSyncWarn('Item missing from getItems response', id);
+ return;
+ }
+ const cards = findVisibleCardsForItemId(id);
+ liveSyncLog('Replacing cards for item', { id, cardCount: cards.length });
+ cards.forEach((card) => {
+ work.push({ card, freshItem });
+ });
+ });
+
+ let rebuilt = 0;
+ let skipped = 0;
+ for (let i = 0; i < work.length; i += LIVE_SYNC_REBUILD_BATCH_SIZE) {
+ if (i > 0) await yieldToMainThread();
+ const batch = work.slice(i, i + LIVE_SYNC_REBUILD_BATCH_SIZE);
+ batch.forEach(({ card, freshItem }) => {
+ if (replaceVisibleCardWithFreshItem(card, freshItem)) rebuilt += 1;
+ else skipped += 1;
+ });
+ }
+ liveSyncLog('Flush complete', { rebuilt, skipped, workItems: work.length });
+ } catch (err) {
+ console.error('[KefinTweaks CardBuilder][LiveSync] Visible card live sync failed:', err);
+ } finally {
+ libraryUpdatedFlushRunning = false;
+ // Ids queued during this flush — schedule another pass
+ if (pendingLibraryUpdatedIds.size) {
+ scheduleLibraryUpdatedFlush();
+ }
+ }
+ }
+
+ function queueLibraryUpdatedItemIds(ids, source = 'unknown') {
+ if (!Array.isArray(ids) || !ids.length) return;
+ const before = pendingLibraryUpdatedIds.size;
+ ids.forEach((id) => {
+ if (id == null || id === '') return;
+ pendingLibraryUpdatedIds.add(String(id));
+ });
+ liveSyncLog('Queued item ids', {
+ source,
+ incoming: ids.map(String),
+ pendingSizeBefore: before,
+ pendingSizeAfter: pendingLibraryUpdatedIds.size,
+ debounceMs: LIBRARY_UPDATED_DEBOUNCE_MS,
+ flushRunning: libraryUpdatedFlushRunning,
+ });
+ scheduleLibraryUpdatedFlush();
+ }
+
+ function handleLibraryChangedMessage(msg) {
+ const data = msg?.Data ?? msg;
+ const updated = data?.ItemsUpdated;
+ const dataKeys = data && typeof data === 'object' ? Object.keys(data) : [];
+ liveSyncLog('LibraryChanged received', {
+ deliveryMode: window.websocketHelper?.getDeliveryMode?.() || null,
+ messageType: msg?.MessageType,
+ dataKeys,
+ itemsUpdatedType: Array.isArray(updated) ? 'array' : typeof updated,
+ itemsUpdatedCount: Array.isArray(updated) ? updated.length : null,
+ itemsUpdated: Array.isArray(updated) ? updated : updated,
+ itemsAdded: data?.ItemsAdded,
+ itemsRemoved: data?.ItemsRemoved,
+ });
+ if (!Array.isArray(updated) || !updated.length) {
+ liveSyncLog('Ignoring LibraryChanged — ItemsUpdated empty or not an array');
+ return;
+ }
+ queueLibraryUpdatedItemIds(updated, 'LibraryChanged.ItemsUpdated');
+ }
+
+ function startCardLibraryChangedListener() {
+ if (cardLibraryChangedListenerStarted) return;
+
+ if (!window.websocketHelper?.listen) {
+ liveSyncLog('websocketHelper not ready — retrying listener in 1s');
+ setTimeout(startCardLibraryChangedListener, 1000);
+ return;
+ }
+
+ window.websocketHelper.listen('LibraryChanged', handleLibraryChangedMessage);
+ cardLibraryChangedListenerStarted = true;
+ liveSyncLog('Listening for LibraryChanged to sync visible card metadata', {
+ deliveryMode: window.websocketHelper?.getDeliveryMode?.() || 'pending',
+ });
+ }
+
+ let cardEditorCloseLiveSyncStarted = false;
+ const editorDialogDirty = new WeakMap();
+
+ function isImageEditorDialog(dlg) {
+ if (!dlg?.querySelector) return false;
+ return !!(dlg.querySelector('.imageEditorCard') || dlg.querySelector('#images') || dlg.querySelector('#backdrops'));
+ }
+
+ function isMetadataEditorDialog(dlg) {
+ if (!dlg?.querySelector) return false;
+ if (isImageEditorDialog(dlg)) return false;
+ return !!(
+ dlg.querySelector('.metadataEditor') ||
+ dlg.querySelector('#metadataEditor') ||
+ dlg.querySelector('.metadataEditorContent') ||
+ dlg.querySelector('form.metadataEditorForm') ||
+ dlg.querySelector('.btnSaveMetadata, #btnSaveMetadata')
+ );
+ }
+
+ function resolveEditorDialogItemId(dlg) {
+ if (!dlg) return null;
+ const fromCard = dlg.querySelector?.('.imageEditorCard[data-id], [data-id][data-imagetype], [data-itemid]');
+ const attrId =
+ fromCard?.getAttribute('data-id') ||
+ fromCard?.getAttribute('data-itemid') ||
+ dlg.getAttribute?.('data-itemid') ||
+ dlg.getAttribute?.('data-id') ||
+ dlg.dataset?.itemId ||
+ null;
+ if (attrId) return attrId;
+
+ try {
+ const hash = String(window.location.hash || '');
+ const match = hash.match(/[?&]id=([^&]+)/i);
+ if (match?.[1]) return decodeURIComponent(match[1]);
+ } catch (e) {
+ // ignore
+ }
+ return null;
+ }
+
+ function markEditorDialogDirty(dlg) {
+ if (!dlg) return;
+ editorDialogDirty.set(dlg, true);
+ }
+
+ function handleEditorDialogClose(dlg) {
+ if (!dlg) return;
+ const isImage = isImageEditorDialog(dlg);
+ const isMeta = isMetadataEditorDialog(dlg);
+ if (!isImage && !isMeta) return;
+
+ const dirty = editorDialogDirty.get(dlg) === true;
+ // Mirror imageeditor hasChanges: only rebuild when the dialog was dirtied while open.
+ if (!dirty) {
+ liveSyncLog('Editor closed without changes — skip rebuild', {
+ kind: isImage ? 'image' : 'metadata',
+ });
+ return;
+ }
+
+ const itemId = resolveEditorDialogItemId(dlg);
+ if (!itemId) {
+ liveSyncWarn('Editor closed but could not resolve item id');
+ return;
+ }
+
+ liveSyncLog('Editor closed — queueing live sync', {
+ itemId,
+ kind: isImage ? 'image' : 'metadata',
+ dirty,
+ });
+ queueLibraryUpdatedItemIds([itemId], isImage ? 'imageEditor.close' : 'metadataEditor.close');
+ }
+
+ function watchEditorDialog(dlg) {
+ if (!dlg || dlg.__kefinLiveSyncWatched) return;
+ if (!isImageEditorDialog(dlg) && !isMetadataEditorDialog(dlg)) return;
+
+ dlg.__kefinLiveSyncWatched = true;
+ editorDialogDirty.set(dlg, false);
+
+ if (isImageEditorDialog(dlg)) {
+ // imageeditor.js mutates #images during initial reload — ignore until settled.
+ let armed = false;
+ const armTimer = setTimeout(() => {
+ armed = true;
+ editorDialogDirty.set(dlg, false);
+ }, 800);
+ dlg.__kefinLiveSyncArmTimer = armTimer;
+
+ const mo = new MutationObserver(() => {
+ if (!armed) return;
+ markEditorDialogDirty(dlg);
+ });
+ const images = dlg.querySelector('#images') || dlg.querySelector('#backdrops') || dlg;
+ try {
+ mo.observe(images, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['data-src', 'style', 'src'],
+ });
+ } catch (e) {
+ mo.observe(dlg, { childList: true, subtree: true });
+ }
+ dlg.__kefinLiveSyncMo = mo;
+ } else if (isMetadataEditorDialog(dlg)) {
+ const onFieldEdit = () => markEditorDialogDirty(dlg);
+ dlg.addEventListener('input', onFieldEdit, true);
+ dlg.addEventListener('change', onFieldEdit, true);
+ dlg.__kefinLiveSyncFieldEdit = onFieldEdit;
+ }
+
+ const onClose = () => {
+ if (dlg.__kefinLiveSyncArmTimer) {
+ clearTimeout(dlg.__kefinLiveSyncArmTimer);
+ }
+ if (dlg.__kefinLiveSyncFieldEdit) {
+ dlg.removeEventListener('input', dlg.__kefinLiveSyncFieldEdit, true);
+ dlg.removeEventListener('change', dlg.__kefinLiveSyncFieldEdit, true);
+ }
+ try {
+ dlg.__kefinLiveSyncMo?.disconnect?.();
+ } catch (e) {
+ // ignore
+ }
+ handleEditorDialogClose(dlg);
+ dlg.removeEventListener('close', onClose);
+ };
+ dlg.addEventListener('close', onClose);
+
+ liveSyncLog('Watching editor dialog for close', {
+ image: isImageEditorDialog(dlg),
+ metadata: isMetadataEditorDialog(dlg),
+ itemId: resolveEditorDialogItemId(dlg),
+ });
+ }
+
+ function startCardEditorCloseLiveSync() {
+ if (cardEditorCloseLiveSyncStarted) return;
+ cardEditorCloseLiveSyncStarted = true;
+
+ document.querySelectorAll('.formDialog').forEach((dlg) => watchEditorDialog(dlg));
+
+ const bodyObserver = new MutationObserver((mutations) => {
+ mutations.forEach((m) => {
+ m.addedNodes.forEach((node) => {
+ if (!(node instanceof HTMLElement)) return;
+ if (node.matches?.('.formDialog')) watchEditorDialog(node);
+ node.querySelectorAll?.('.formDialog').forEach((dlg) => watchEditorDialog(dlg));
+ });
+ });
+ });
+ bodyObserver.observe(document.body, { childList: true, subtree: true });
+ liveSyncLog('Editor-close live sync observer started');
+ }
+
// Main card builder object
const cardBuilder = {
+ /**
+ * Post-processes items for a section
+ * @param {Object} sectionConfig - Section configuration
+ * @param {Array} itemsData - Array of Jellyfin items
+ * @returns {Array} - Post-processed array of Jellyfin items
+ */
+ postProcessItems: postProcessItems,
+ postProcessItemsByQuery: postProcessItemsByQuery,
+ getQueryDisplayName: getQueryDisplayName,
+ resolveActiveQueryIndex: resolveActiveQueryIndex,
+ resolveQueriesToLoad: resolveQueriesToLoad,
+ getSectionTitleForMultiQuery: getSectionTitleForMultiQuery,
+ getActiveViewMoreUrl: getActiveViewMoreUrl,
+ refreshSectionQueries: refreshSectionQueries,
+ refreshMountedSectionsProgressive: refreshMountedSectionsProgressive,
+ loadActiveSectionQueryItems: loadActiveSectionQueryItems,
+ createSectionScrollableContainer: createSectionScrollableContainer,
+ applySectionPresentationAttrs: applySectionPresentationAttrs,
+ attachSectionControlButtons: attachSectionControlButtons,
+ ensureCardBorders: ensureCardBorders,
+ applyItemsLayoutState: applyItemsLayoutState,
+ resolveItemsLayout: resolveItemsLayout,
+ resolveGridShape: resolveGridShape,
+ resolveSpotlightPresentation: resolveSpotlightPresentation,
+ resolveUseGaplessCards: resolveUseGaplessCards,
+ invalidateCardRadiusCache: invalidateCardRadiusCache,
+ ensureCardRadiusCssVar: ensureCardRadiusCssVar,
+ getScrollerPosition: getScrollerPosition,
+ setScrollerPosition: setScrollerPosition,
+ scrollToCardIndex: scrollToCardIndex,
+ invalidateScrollerMetrics: invalidateScrollerMetrics,
+ getItemMaterialIcon: getItemMaterialIcon,
+ createLibraryButtonElement: createLibraryButtonElement,
/**
* Main entry point function to build a Jellyfin card
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square'
* @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes)
+ * @param {boolean} useParentCard - When true, the parent item will be rendered as the card image and link
+ * @param {string|null} borderStyle - Section card border style (e.g. 'retro-poster')
* @returns {HTMLElement} - The constructed card element
*/
- buildCard: function(item, overflowCard = false, cardFormat = null, customFooterText = null) {
- return createJellyfinCardElement(item, overflowCard, cardFormat, customFooterText);
+ buildCard: function (
+ item,
+ overflowCard = false,
+ cardFormat = null,
+ customFooterText = null,
+ useParentCard = false,
+ borderStyle = null,
+ ) {
+ return createCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
},
-
+
+ /** Push-register lazy images under an attached root (parallel native path). */
+ registerLazyImagesIn: registerLazyImagesIn,
+ registerLazyImagesInSection: registerLazyImagesInSection,
+
/**
* Renders a scrollable container with cards
* @param {Array} items - Array of Jellyfin item objects
@@ -102,22 +3058,33 @@
* @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
* @returns {HTMLElement} - The constructed scrollable container
*/
- renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') {
- // Handle Random card format - pick one format for entire section
+ renderCards: function (
+ items,
+ title,
+ viewMoreUrl = null,
+ overflowCard = false,
+ cardFormat = null,
+ sortOrder = null,
+ sortOrderDirection = 'Ascending',
+ ) {
let finalCardFormat = cardFormat;
if (cardFormat === 'random' || cardFormat === 'Random') {
finalCardFormat = getRandomCardFormat();
}
-
- // Sort items if sortOrder is provided
+
let sortedItems = items;
if (sortOrder && sortOrder !== 'Random') {
sortedItems = sortItems(items, sortOrder, sortOrderDirection);
} else if (sortOrder === 'Random') {
sortedItems = shuffle([...items]);
}
-
- return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
+
+ const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
+ return container;
+ },
+
+ renderSpotlightSection: function (items, title, options = {}) {
+ return createSpotlightSection(items, title, options);
},
/**
@@ -131,9 +3098,472 @@
* @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
* @returns {Promise} - The constructed scrollable container
*/
- renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null, sortOrder = null, sortOrderDirection = 'Ascending') {
+ /**
+ * Renders cards progressively with skeleton loading state
+ * @param {Promise} itemsPromise - Promise that resolves to array of Jellyfin items
+ * @param {string} title - Title for the scrollable container
+ * @param {string|Function} viewMoreUrl - Optional URL to make title clickable
+ * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
+ * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', or 'random'
+ * @param {string} sortOrder - Sort order: 'Random', 'ReleaseDate', 'CriticRating', 'CommunityRating', 'SortTitle', 'DateAdded'
+ * @param {string} sortOrderDirection - Direction: 'Ascending' or 'Descending'
+ * @returns {HTMLElement} - The constructed scrollable container (initially with skeletons)
+ */
+ renderProgressivelyEnhancedCards: async function (
+ itemsPromise,
+ title,
+ viewMoreUrl = null,
+ overflowCard = false,
+ cardFormat = null,
+ sortOrder = null,
+ sortOrderDirection = 'Ascending',
+ minimumItems = -1,
+ ) {
+ // Handle Random card format - Must resolve ONCE for both skeleton and real cards
+ let resolvedFormat = cardFormat;
+ if (cardFormat === 'random' || cardFormat === 'Random') {
+ resolvedFormat = getRandomCardFormat();
+ }
+
+ // Check if promise is already fulfilled
+ if ((await promiseState(itemsPromise)) === 'fulfilled') {
+ // Resolve the promise and get the items
+ const items = await itemsPromise;
+ // Replace the skeleton with the real cards
+ const container = createScrollableContainer(items, title, viewMoreUrl, overflowCard, resolvedFormat);
+ return container;
+ }
+
+ // Create skeleton container immediately using the resolved format
+ const skeletonContainer = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard);
+ const randomId = Math.random().toString(36).substring(2, 15);
+ skeletonContainer.setAttribute('data-skeleton-id', randomId);
+
+ // When promise resolves, replace skeletons with real cards
+ itemsPromise
+ .then(async (items) => {
+ // Remove the skeleton container if it exists
+ let skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
+
+ // Let's try to find the skeleton 20 times every 100ms
+ for (let i = 0; i < 20; i++) {
+ skeletonContaineElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
+ if (skeletonContaineElement) {
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ }
+
+ if (!items || items.length === 0) {
+ unobserveSkeletonShimmer(skeletonContaineElement);
+ skeletonContaineElement.remove();
+ return;
+ }
+
+ if (items.length < minimumItems) {
+ unobserveSkeletonShimmer(skeletonContaineElement);
+ skeletonContaineElement.remove();
+ return;
+ }
+
+ // Sort items if needed
+ let sortedItems = items;
+ if (sortOrder && sortOrder !== 'Random') {
+ sortedItems = sortItems(items, sortOrder, sortOrderDirection);
+ } else if (sortOrder === 'Random') {
+ sortedItems = shuffle([...items]);
+ }
+
+ // Generate real cards container (off-DOM) using the SAME resolved format
+ const realContainer = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat);
+
+ // Copy critical attributes to preserve layout/order
+ if (skeletonContaineElement.style.order) {
+ realContainer.style.order = skeletonContainer.style.order;
+ }
+ if (skeletonContaineElement.hasAttribute('data-section-id')) {
+ realContainer.setAttribute('data-section-id', skeletonContainer.getAttribute('data-section-id'));
+ }
+ if (skeletonContaineElement.dataset.border) {
+ realContainer.dataset.border = skeletonContaineElement.dataset.border;
+ realContainer.dataset.borderStyle =
+ skeletonContaineElement.dataset.borderStyle || skeletonContaineElement.dataset.border;
+ }
+
+ // Replace content of skeleton container with real content
+ // This preserves the container element itself and its layout properties (like order)
+ unobserveSkeletonShimmer(skeletonContaineElement);
+ skeletonContaineElement.replaceWith(realContainer);
+ ensureCardBorders(realContainer);
+ })
+ .catch((error) => {
+ console.error('[KefinTweaks CardBuilder] Error loading items for progressive enhancement:', error);
+ // Optionally remove skeleton on error or show error state
+ if (skeletonContaineElement) {
+ unobserveSkeletonShimmer(skeletonContaineElement);
+ skeletonContaineElement.remove();
+ }
+ });
+
+ return skeletonContainer;
+ },
+
+ /**
+ * Renders a progressive section (Cards or Spotlight)
+ * @param {HTMLElement} container - The parent container to append the section to
+ * @param {Array|Promise} itemsOrPromises - Data array or Promise resolving to items
+ * @param {string} title - Section title
+ * @param {Object} options - Configuration options
+ */
+ renderProgressiveSections: async function (container, sections, options = {}) {
+ const {
+ revealSectionsSequentially = false,
+ waitForContainerClass = null,
+ enhanceOnVisible = false,
+ enhanceRootMargin = '30% 0px 30% 0px',
+ showStaleDataBeforeRefresh = true,
+ insertBefore = null,
+ } = options;
+
+ const renderProgressiveSectionsStartTime = performance.now();
+
+ const sectionEnhancementTargets = [];
+ const fragment = document.createDocumentFragment();
+ for (const sectionPromise of sections) {
+ const sectionPromiseStartTime = performance.now();
+ const section = await sectionPromise;
+ const sectionPromiseEndTime = performance.now();
+ const sectionPromiseDuration = sectionPromiseEndTime - sectionPromiseStartTime;
+ console.log(`Section promise initialization time: ${sectionPromiseDuration.toFixed(2)}ms`);
+
+ if (!section) continue;
+
+ const sectionStartTime = performance.now();
+
+ const sectionConfig = section.config;
+
+ // Skip if the section is not enabled
+ if (!sectionConfig.enabled) {
+ console.log(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} is not enabled, skipping...`);
+ continue;
+ }
+
+ // Skip if this section id is already in the container (overlapping home renders)
+ const sectionIdAttr =
+ typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
+ ? CSS.escape(String(sectionConfig.id))
+ : String(sectionConfig.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ if (container.querySelector(`[data-section-id="${sectionIdAttr}"]`)) {
+ console.log(`[KefinTweaks CardBuilder] Section ${sectionConfig.id} already exists in the container, skipping...`);
+ continue;
+ }
+
+ // Handle Random card format - pick one format for entire section
+ let finalCardFormat = sectionConfig.cardFormat;
+ if (finalCardFormat === 'random' || finalCardFormat === 'Random') {
+ finalCardFormat = getRandomCardFormat();
+ }
+
+ let dataItems = section.result?.data?.Items ?? section.result?.data;
+ const isRandomQuerySort = sectionHasRandomQuerySort(sectionConfig);
+
+ // If the section has items, it is a static query
+ const isStaticQuery = Array.isArray(sectionConfig.items) && sectionConfig.items.length > 0;
+
+ const renderCachedData =
+ (isStaticQuery ||
+ (!section.result?.isStale && !isRandomQuerySort) ||
+ (showStaleDataBeforeRefresh && (!isRandomQuerySort || sectionConfig.renderMode === 'Spotlight'))) &&
+ dataItems.length > 0;
+
+ // Paint cached cards when valid, or when stale display is opted in
+ if (renderCachedData) {
+ let content = null;
+
+ if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
+ content = createSpotlightSection(dataItems, getSectionTitleForMultiQuery(sectionConfig), {
+ viewMoreUrl: getActiveViewMoreUrl(sectionConfig),
+ progressiveEnhancement: true,
+ ...sectionConfig.spotlightConfig,
+ });
+ } else {
+ // Stale cached paint may still refresh — defer scroll-button register until final apply
+ const registerScrollButtons = isStaticQuery || section.result?.isStale !== true;
+ content = createScrollableContainer(
+ dataItems,
+ getSectionTitleForMultiQuery(sectionConfig),
+ getActiveViewMoreUrl(sectionConfig),
+ sectionConfig.overflowCard,
+ finalCardFormat,
+ sectionConfig,
+ registerScrollButtons,
+ );
+ }
+
+ content.setAttribute('data-section-id', sectionConfig.id);
+ content.style.order = sectionConfig.order;
+ content.dataset.order = sectionConfig.order;
+
+ if (sectionConfig.hideCardFooter === true) {
+ content.dataset.hideCardFooter = 'true';
+ }
+ applySectionPresentationAttrs(content, sectionConfig);
+
+ // Add section control buttons to rendered section
+ attachSectionControlButtons(sectionConfig, content, dataItems);
+
+ sectionEnhancementTargets.push({ element: content, section });
+ //container.appendChild(content);
+ //content.style.contentVisibility = 'hidden';
+
+ if (revealSectionsSequentially) {
+ content.classList.add('cardbuilder-section-reveal');
+ }
+
+ fragment.appendChild(content);
+ const sectionEndTime = performance.now();
+ const sectionDuration = sectionEndTime - sectionStartTime;
+ console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
+ continue;
+ }
+
+ // Otherwise, we need to create a skeleton section
+
+ let sectionElement = null;
+ if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
+ const spotlightSettings = sectionConfig.spotlightConfig ?? {};
+ if (getActiveViewMoreUrl(sectionConfig)) {
+ spotlightSettings.viewMoreUrl = getActiveViewMoreUrl(sectionConfig);
+ }
+ spotlightSettings.discoveryPending = sectionConfig.discoveryPending === true;
+ spotlightSettings.caption = sectionConfig.caption;
+ sectionElement = createSkeletonSpotlightSection(getSectionTitleForMultiQuery(sectionConfig), spotlightSettings);
+ } else {
+ sectionElement = createProgressivelyEnhancedScrollableContainer(
+ getSectionTitleForMultiQuery(sectionConfig),
+ getActiveViewMoreUrl(sectionConfig),
+ finalCardFormat,
+ sectionConfig.overflowCard,
+ sectionConfig,
+ );
+ }
+ sectionElement.setAttribute('data-section-id', sectionConfig.id);
+ sectionElement.style.order = sectionConfig.order;
+ sectionElement.dataset.order = sectionConfig.order;
+
+ if (sectionConfig.hideCardFooter === true) {
+ sectionElement.dataset.hideCardFooter = 'true';
+ }
+ applySectionPresentationAttrs(sectionElement, sectionConfig);
+ //container.appendChild(sectionElement);
+
+ // set section element content-visibility to hidden
+ //sectionElement.style.contentVisibility = 'hidden';
+
+ if (revealSectionsSequentially) {
+ sectionElement.classList.add('cardbuilder-section-reveal');
+ }
+
+ sectionEnhancementTargets.push({ element: sectionElement, section });
+ fragment.appendChild(sectionElement);
+ const sectionEndTime = performance.now();
+ const sectionDuration = sectionEndTime - sectionStartTime;
+ console.log(`Section ${sectionConfig.id} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
+ }
+
+ // Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds
+ // Append the container to the page for now invisibly so that Jellyfin handles enhancing it with the typical Scroller functionality
+ // Finally when the container has the class specified in waitForContainerClass, we can move the pre-rendered sections into the container
+ if (waitForContainerClass && !container.classList.contains(waitForContainerClass)) {
+ // Wait for the container to have the class specified in waitForContainerClass with a timeout of 10 seconds
+ console.log(`Waiting for container to have class ${waitForContainerClass}...`);
+ const maxAttempts = 100;
+ let attempts = 0;
+ while (!container.classList.contains(waitForContainerClass) && attempts < maxAttempts) {
+ attempts++;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ if (container.classList.contains(waitForContainerClass)) {
+ break;
+ }
+ }
+ if (!container.classList.contains(waitForContainerClass)) {
+ console.log(`Container did not have class ${waitForContainerClass} after ${maxAttempts} attempts, continuing...`);
+ }
+ }
+
+ // Append in batches so style/layout work is spread across frames.
+ // Register lazy images per section .itemsContainer only (never the home container).
+ const BATCH_SIZE = 2;
+ const sectionNodes = Array.from(fragment.childNodes);
+ for (let i = 0; i < sectionNodes.length; i += BATCH_SIZE) {
+ const batch = sectionNodes.slice(i, i + BATCH_SIZE);
+ const batchFragment = document.createDocumentFragment();
+ batch.forEach((node) => batchFragment.appendChild(node));
+ if (insertBefore && insertBefore.parentNode === container) {
+ container.insertBefore(batchFragment, insertBefore);
+ } else {
+ container.appendChild(batchFragment);
+ }
+ batch.forEach((sectionEl) => {
+ if (sectionEl?.nodeType === 1) registerLazyImagesInSection(sectionEl);
+ });
+ if (i + BATCH_SIZE < sectionNodes.length) {
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ }
+
+ // Set up section reveal animations if enabled (opacity/transform only — no display toggle)
+ if (revealSectionsSequentially) {
+ initializeSectionRevealObserver();
+ sectionEnhancementTargets.forEach(({ element }) => {
+ observeSectionForReveal(element);
+ });
+ }
+
+ const enhanceOptions = {
+ enhanceOnVisible,
+ enhanceRootMargin,
+ revealSectionsSequentially,
+ };
+
+ for (let index = 0; index < sectionEnhancementTargets.length; index++) {
+ const sectionStartTime = performance.now();
+ const { element: sectionElement, section } = sectionEnhancementTargets[index];
+
+ if (Array.isArray(section.config.items) && section.config.items.length > 0) {
+ continue;
+ }
+ scheduleSectionProgressiveEnhancement(sectionElement, section, enhanceOptions);
+
+ const sectionEndTime = performance.now();
+ const sectionDuration = sectionEndTime - sectionStartTime;
+ console.log(`Section ${index} progressive initialization time: ${sectionDuration.toFixed(2)}ms`);
+ }
+ const renderProgressiveSectionsEndTime = performance.now();
+ const renderProgressiveSectionsDuration = renderProgressiveSectionsEndTime - renderProgressiveSectionsStartTime;
+ console.log(`Render progressive sections initialization time: ${renderProgressiveSectionsDuration.toFixed(2)}ms`);
+ },
+
+ /**
+ * Unified function to render progressive sections (Cards or Spotlight)
+ * @param {HTMLElement} container - The parent container to append the section to
+ * @param {Array|Promise} itemsOrPromise - Data array or Promise resolving to items
+ * @param {string} title - Section title
+ * @param {Object} options - Configuration options
+ */
+ renderProgressiveCards: function (container, itemsOrPromise, title, options = {}) {
+ const {
+ spotlight = false,
+ viewMoreUrl = null,
+ cardFormat = null,
+ overflowCard = false,
+ sortOrder = null,
+ sortOrderDirection = 'Ascending',
+ sectionId = null,
+ order = null,
+ expectedItemType = null,
+ minimumItems = 1,
+ } = options;
+
+ // Handle Random card format once
+ let resolvedFormat = cardFormat;
+ if (cardFormat === 'random' || cardFormat === 'Random') {
+ resolvedFormat = getRandomCardFormat();
+ }
+
+ // Function to setup attributes
+ const setupElement = (el) => {
+ if (sectionId) el.setAttribute('data-section-id', sectionId);
+ if (order !== null) el.style.order = order;
+ // Stamp resolved format for consistency on updates
+ if (resolvedFormat && !spotlight) el.setAttribute('data-card-format', resolvedFormat);
+ };
+
+ // Helper to render final content
+ const renderContent = (items) => {
+ if (!items || items.length < minimumItems) return null;
+
+ // Sort items
+ let sortedItems = items;
+ if (sortOrder && sortOrder !== 'Random') {
+ sortedItems = sortItems(items, sortOrder, sortOrderDirection);
+ } else if (sortOrder === 'Random') {
+ sortedItems = shuffle([...items]);
+ }
+
+ if (spotlight) {
+ return createSpotlightSection(sortedItems, title, { viewMoreUrl });
+ } else {
+ return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, resolvedFormat);
+ }
+ };
+
+ // Handle Promise
+ if (itemsOrPromise && typeof itemsOrPromise.then === 'function') {
+ // Render Skeleton
+ let skeleton;
+ if (spotlight) {
+ skeleton = createSkeletonSpotlightSection(title, { viewMoreUrl });
+ } else {
+ skeleton = createProgressivelyEnhancedScrollableContainer(title, viewMoreUrl, resolvedFormat, overflowCard);
+ }
+ setupElement(skeleton);
+ container.appendChild(skeleton);
+
+ // Handle Resolution
+ itemsOrPromise
+ .then((items) => {
+ const content = renderContent(items);
+ if (content) {
+ setupElement(content);
+ // Copy of any existing data attributes to the new content
+ Array.from(skeleton.attributes).forEach((attr) => {
+ if (attr.name === 'class') return;
+ content.setAttribute(attr.name, attr.value);
+ });
+ stripCopiedSkeletonSectionClasses(content);
+ unobserveSkeletonShimmer(skeleton);
+ skeleton.replaceWith(content);
+ ensureCardBorders(content);
+ ensureSectionRevealObservation(content);
+ } else {
+ unobserveSkeletonShimmer(skeleton);
+ skeleton.remove();
+ }
+ })
+ .catch((err) => {
+ console.error('[CardBuilder] Error loading items:', err);
+ unobserveSkeletonShimmer(skeleton);
+ skeleton.remove();
+ });
+
+ return skeleton;
+ }
+
+ // Handle Direct Array
+ else {
+ const items = Array.isArray(itemsOrPromise) ? itemsOrPromise : [];
+ const content = renderContent(items);
+ if (content) {
+ setupElement(content);
+ container.appendChild(content);
+ return content;
+ }
+ }
+ },
+
+ renderCardsFromIds: async function (
+ itemIds,
+ title,
+ viewMoreUrl = null,
+ overflowCard = false,
+ cardFormat = null,
+ sortOrder = null,
+ sortOrderDirection = 'Ascending',
+ ) {
const LOG = (...args) => console.log('[KefinTweaks CardBuilder]', ...args);
-
+
if (!itemIds || itemIds.length === 0) {
LOG(`No item IDs provided for section: ${title}`);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
@@ -143,18 +3573,18 @@
// Fetch all items at once
const response = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
Ids: itemIds.join(','),
- Recursive: false
+ Recursive: false,
});
const items = response.Items;
-
+
LOG(`Fetched ${items.length} items for section: ${title}`);
-
+
// Handle Random card format - pick one format for entire section
let finalCardFormat = cardFormat;
if (cardFormat === 'random' || cardFormat === 'Random') {
finalCardFormat = getRandomCardFormat();
}
-
+
// Sort items if sortOrder is provided
let sortedItems = items;
if (sortOrder && sortOrder !== 'Random') {
@@ -162,25 +3592,80 @@
} else if (sortOrder === 'Random') {
sortedItems = shuffle([...items]);
}
-
- return createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
+
+ const container = createScrollableContainer(sortedItems, title, viewMoreUrl, overflowCard, finalCardFormat);
+ return container;
} catch (error) {
console.error('[KefinTweaks CardBuilder] Error fetching items:', error);
return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat);
}
},
-
+
/**
* Renders a spotlight section (Netflix-style slim banner carousel)
- * @param {Array} items - Array of Jellyfin item objects
+ * @param {Array|Promise} items - Array of Jellyfin item objects or Promise resolving to array
* @param {string} title - Title for the spotlight section
* @param {Object} options - Options for the spotlight carousel
+ * @param {boolean} options.progressiveEnhancement - If true and items is a Promise, render skeleton first
* @returns {HTMLElement} - The constructed spotlight container
*/
- renderSpotlightSection: function(items, title, options = {}) {
- return createSpotlightSection(items, title, options);
+ renderSpotlightSection: function (items, title, options = {}) {
+ const { progressiveEnhancement = false } = options;
+
+ // If progressive enhancement is enabled and items is a Promise
+ if (progressiveEnhancement && items && typeof items.then === 'function') {
+ // Create skeleton spotlight section immediately
+ const skeletonSpotlightContainer = createSkeletonSpotlightSection(title, options);
+
+ // Add identifying data-id
+ const randomId = Math.random().toString(36).substring(2, 15);
+ skeletonSpotlightContainer.setAttribute('data-skeleton-id', randomId);
+
+ // When promise resolves, replace skeleton with real spotlight
+ items
+ .then(async (items) => {
+ // Remove the skeleton container if it exists
+ let skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
+
+ // Let's try to find the skeleton 20 times every 100ms
+ for (let i = 0; i < 20; i++) {
+ skeletonContainerElement = document.querySelector(`[data-skeleton-id="${randomId}"]`);
+ if (skeletonContainerElement) {
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ }
+
+ // Generate real spotlight section
+ const realContainer = createSpotlightSection(items, title, options);
+
+ // Copy critical attributes to preserve layout/order
+ if (skeletonContainerElement.style.order) {
+ realContainer.style.order = skeletonContainer.style.order;
+ }
+ if (skeletonContainerElement.hasAttribute('data-section-id')) {
+ realContainer.setAttribute('data-section-id', skeletonContainerElement.getAttribute('data-section-id'));
+ }
+
+ // Replace skeleton container with real container
+ unobserveSkeletonShimmer(skeletonContainerElement);
+ skeletonContainerElement.replaceWith(realContainer);
+ })
+ .catch((error) => {
+ console.error('[KefinTweaks CardBuilder] Error loading items for spotlight progressive enhancement:', error);
+ // Optionally remove skeleton on error or show error state
+ unobserveSkeletonShimmer(skeletonSpotlightContainer);
+ skeletonSpotlightContainer.remove();
+ });
+
+ return skeletonSpotlightContainer;
+ }
+
+ // Normal rendering (items is already an array)
+ const container = createSpotlightSection(items, title, options);
+ return container;
},
-
+
/**
* Sorts items based on sort order and direction (exposed for use in homeScreen)
* @param {Array} items - Array of Jellyfin items
@@ -188,50 +3673,692 @@
* @param {string} sortOrderDirection - Direction
* @returns {Array} - Sorted array
*/
- sortItems: function(items, sortOrder, sortOrderDirection) {
+ sortItems: function (items, sortOrder, sortOrderDirection) {
return sortItems(items, sortOrder, sortOrderDirection);
- }
+ },
+
+ updateCardResumeAttributes: updateCardResumeAttributes,
};
+ /**
+ * Blurhash base83 character set (must match official blurhash package: includes comma and period)
+ * https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts
+ */
+ const BLURHASH_B83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~';
+
+ function blurhashDecode83(str, from, length) {
+ if (from + length > str.length) return NaN;
+ let value = 0;
+ for (let i = 0; i < length; i++) {
+ const idx = BLURHASH_B83.indexOf(str[from + i]);
+ if (idx < 0) return NaN;
+ value = value * 83 + idx;
+ }
+ return value;
+ }
+
+ function blurhashSignPow(val, exp) {
+ const sign = val < 0 ? -1 : 1;
+ return sign * Math.pow(Math.abs(val), exp);
+ }
+
+ function blurhashLinearToSrgb(value) {
+ const v = Math.max(0, Math.min(1, value));
+ return v <= 0.0031308 ? Math.trunc(v * 12.92 * 255 + 0.5) : Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
+ }
+
+ function blurhashSrgbToLinear(value) {
+ const v = value / 255;
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ }
+
+ /**
+ * Decode a blurhash string to RGBA pixel data (Uint8ClampedArray).
+ * Matches the official blurhash package (same as Jellyfin's worker uses).
+ * Layout: 1 char size, 1 char quantised max, 4 chars DC (24-bit), then 2 chars per AC (base-19 packed).
+ */
+ function blurhashDecode(blurhash, width, height) {
+ if (!blurhash || blurhash.length < 6) return null;
+ try {
+ const sizeVal = blurhashDecode83(blurhash, 0, 1);
+ if (isNaN(sizeVal)) return null;
+ const numX = (sizeVal % 9) + 1;
+ const numY = Math.floor(sizeVal / 9) + 1;
+ const expectedLen = 4 + 2 * numX * numY;
+ if (blurhash.length !== expectedLen) return null;
+
+ const quantisedMax = blurhashDecode83(blurhash, 1, 1);
+ if (isNaN(quantisedMax)) return null;
+ const maxVal = (quantisedMax + 1) / 166;
+
+ const components = [];
+ for (let i = 0; i < numX * numY; i++) {
+ if (i === 0) {
+ const dc = blurhashDecode83(blurhash, 2, 4);
+ if (isNaN(dc)) return null;
+ const intR = dc >> 16;
+ const intG = (dc >> 8) & 255;
+ const intB = dc & 255;
+ components.push([blurhashSrgbToLinear(intR), blurhashSrgbToLinear(intG), blurhashSrgbToLinear(intB)]);
+ } else {
+ const acVal = blurhashDecode83(blurhash, 4 + i * 2, 2);
+ if (isNaN(acVal)) return null;
+ const quantR = Math.floor(acVal / (19 * 19));
+ const quantG = Math.floor(acVal / 19) % 19;
+ const quantB = acVal % 19;
+ components.push([
+ blurhashSignPow((quantR - 9) / 9, 2) * maxVal,
+ blurhashSignPow((quantG - 9) / 9, 2) * maxVal,
+ blurhashSignPow((quantB - 9) / 9, 2) * maxVal,
+ ]);
+ }
+ }
+
+ const pixels = new Uint8ClampedArray(width * height * 4);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ let r = 0,
+ g = 0,
+ b = 0;
+ for (let j = 0; j < numY; j++) {
+ const basisY = Math.cos((Math.PI * y * j) / height);
+ for (let i = 0; i < numX; i++) {
+ const basis = Math.cos((Math.PI * x * i) / width) * basisY;
+ const color = components[i + j * numX];
+ r += color[0] * basis;
+ g += color[1] * basis;
+ b += color[2] * basis;
+ }
+ }
+ const off = (y * width + x) * 4;
+ pixels[off] = blurhashLinearToSrgb(r);
+ pixels[off + 1] = blurhashLinearToSrgb(g);
+ pixels[off + 2] = blurhashLinearToSrgb(b);
+ pixels[off + 3] = 255;
+ }
+ }
+ return pixels;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ /**
+ * Draw decoded blurhash into an existing canvas (e.g. 20x20).
+ * @param {HTMLCanvasElement} canvas - Canvas to draw into
+ * @param {string} blurhashString - Blurhash string
+ * @returns {boolean} - True if drawn successfully
+ */
+ function drawBlurhashToCanvas(canvas, blurhashString) {
+ if (!canvas || !blurhashString) return false;
+ const w = canvas.width || 20;
+ const h = canvas.height || 20;
+ const cacheKey = blurhashCacheKey(blurhashString, w, h);
+ let pixels = blurhashCacheGet(cacheKey);
+ if (!pixels) {
+ pixels = blurhashDecode(blurhashString, w, h);
+ if (pixels) blurhashCacheSet(cacheKey, pixels);
+ }
+ if (!pixels) return false;
+ return applyBlurhashPixelsToCanvas(canvas, pixels);
+ }
+
+ // --- Deferred blurhash: cache + worker + near-viewport filler ---
+ const BLURHASH_CACHE_MAX = 200;
+ const blurhashPixelCache = new Map();
+ let blurhashFillObserver = null;
+ let blurhashWorker = null;
+ let blurhashWorkerReqId = 0;
+ const blurhashWorkerPending = new Map();
+ const blurhashIdleQueue = [];
+ let blurhashIdleScheduled = false;
+
+ function blurhashCacheKey(blurhash, width, height) {
+ return blurhash + '|' + width + 'x' + height;
+ }
+
+ function blurhashCacheGet(key) {
+ if (!blurhashPixelCache.has(key)) return null;
+ const val = blurhashPixelCache.get(key);
+ blurhashPixelCache.delete(key);
+ blurhashPixelCache.set(key, val);
+ return val;
+ }
+
+ function blurhashCacheSet(key, pixels) {
+ if (blurhashPixelCache.has(key)) blurhashPixelCache.delete(key);
+ blurhashPixelCache.set(key, pixels);
+ while (blurhashPixelCache.size > BLURHASH_CACHE_MAX) {
+ const oldest = blurhashPixelCache.keys().next().value;
+ blurhashPixelCache.delete(oldest);
+ }
+ }
+
+ function shouldSkipBlurhashDecode() {
+ if (!state.useBlurhash) return true;
+ try {
+ if (navigator.connection && navigator.connection.saveData === true) return true;
+ } catch (e) {
+ /* ignore */
+ }
+ try {
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return true;
+ } catch (e) {
+ /* ignore */
+ }
+ return false;
+ }
+
+ function getBlurhashFillRootMargin() {
+ return isMobileLayout() ? '120px' : '200px';
+ }
+
+ /**
+ * Put pixels on canvas and show blurhash until the real image loads.
+ * @returns {boolean}
+ */
+ function applyBlurhashPixelsToCanvas(canvas, pixels) {
+ if (!canvas || !pixels || !canvas.isConnected) return false;
+ const w = canvas.width || 20;
+ const h = canvas.height || 20;
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
+ if (!ctx) return false;
+
+ const imageData = ctx.createImageData(w, h);
+ imageData.data.set(pixels);
+ ctx.putImageData(imageData, 0, 0);
+ canvas.setAttribute('data-blurhash-painted', 'true');
+ canvas.removeAttribute('data-blurhash-pending');
+
+ const cardImageContainer = canvas.nextElementSibling;
+ const imageAlreadyLoaded =
+ cardImageContainer && cardImageContainer.classList && cardImageContainer.classList.contains('lazy-loaded');
+
+ if (imageAlreadyLoaded) {
+ canvas.classList.add('lazy-hidden');
+ return true;
+ }
+
+ canvas.classList.remove('lazy-hidden');
+ if (cardImageContainer && cardImageContainer.classList.contains('cardImageContainer')) {
+ cardImageContainer.classList.add('lazy-hidden');
+ }
+ return true;
+ }
+
+ function getBlurhashWorkerSource() {
+ return [
+ 'const BLURHASH_B83 = ' + JSON.stringify(BLURHASH_B83) + ';',
+ blurhashDecode83.toString(),
+ blurhashSignPow.toString(),
+ blurhashLinearToSrgb.toString(),
+ blurhashSrgbToLinear.toString(),
+ blurhashDecode.toString(),
+ 'self.onmessage = function(e) {',
+ ' var data = e.data || {};',
+ ' var id = data.id;',
+ ' try {',
+ ' var pixels = blurhashDecode(data.blurhash, data.width || 20, data.height || 20);',
+ ' if (!pixels) { self.postMessage({ id: id, ok: false }); return; }',
+ ' self.postMessage({ id: id, ok: true, pixels: pixels.buffer }, [pixels.buffer]);',
+ ' } catch (err) {',
+ ' self.postMessage({ id: id, ok: false, error: String(err && err.message || err) });',
+ ' }',
+ '};',
+ ].join('\n');
+ }
+
+ function ensureBlurhashWorker() {
+ if (blurhashWorker) return blurhashWorker;
+ if (typeof Worker === 'undefined') return null;
+ try {
+ const blob = new Blob([getBlurhashWorkerSource()], { type: 'application/javascript' });
+ const url = URL.createObjectURL(blob);
+ const worker = new Worker(url);
+ worker.onmessage = (e) => {
+ const data = e.data || {};
+ const pending = blurhashWorkerPending.get(data.id);
+ blurhashWorkerPending.delete(data.id);
+ if (!pending) return;
+ if (!data.ok || !data.pixels) {
+ pending.reject(new Error(data.error || 'Blurhash worker decode failed'));
+ return;
+ }
+ pending.resolve(new Uint8ClampedArray(data.pixels));
+ };
+ worker.onerror = (err) => {
+ console.warn('[KefinTweaks CardBuilder] Blurhash worker error:', err);
+ blurhashWorkerPending.forEach((p) => p.reject(err));
+ blurhashWorkerPending.clear();
+ try {
+ worker.terminate();
+ } catch (e) {
+ /* ignore */
+ }
+ try {
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ /* ignore */
+ }
+ blurhashWorker = null;
+ };
+ blurhashWorker = worker;
+ return worker;
+ } catch (e) {
+ console.warn('[KefinTweaks CardBuilder] Could not create blurhash worker:', e);
+ return null;
+ }
+ }
+
+ function decodeBlurhashInWorker(blurhash, width, height) {
+ return new Promise((resolve, reject) => {
+ const worker = ensureBlurhashWorker();
+ if (!worker) {
+ reject(new Error('Blurhash worker unavailable'));
+ return;
+ }
+ const id = ++blurhashWorkerReqId;
+ blurhashWorkerPending.set(id, { resolve, reject });
+ try {
+ worker.postMessage({ id, blurhash, width, height });
+ } catch (e) {
+ blurhashWorkerPending.delete(id);
+ reject(e);
+ }
+ });
+ }
+
+ function scheduleIdleBlurhashDrain() {
+ if (blurhashIdleScheduled) return;
+ blurhashIdleScheduled = true;
+ const run = (deadline) => {
+ blurhashIdleScheduled = false;
+ let n = 0;
+ while (blurhashIdleQueue.length && n < 4) {
+ if (deadline && typeof deadline.timeRemaining === 'function' && deadline.timeRemaining() <= 0 && n > 0) {
+ break;
+ }
+ const job = blurhashIdleQueue.shift();
+ n++;
+ if (!job || !job.canvas || !job.canvas.isConnected) continue;
+ if (!job.canvas.hasAttribute('data-blurhash-pending')) continue;
+ const cacheKey = blurhashCacheKey(job.blurhash, job.w, job.h);
+ let pixels = blurhashCacheGet(cacheKey);
+ if (!pixels) {
+ pixels = blurhashDecode(job.blurhash, job.w, job.h);
+ if (pixels) blurhashCacheSet(cacheKey, pixels);
+ }
+ if (pixels) applyBlurhashPixelsToCanvas(job.canvas, pixels);
+ else job.canvas.removeAttribute('data-blurhash-pending');
+ }
+ if (blurhashIdleQueue.length) scheduleIdleBlurhashDrain();
+ };
+ if (typeof requestIdleCallback === 'function') {
+ requestIdleCallback(run, { timeout: 500 });
+ } else {
+ requestAnimationFrame(() => run({ timeRemaining: () => 5 }));
+ }
+ }
+
+ function enqueueIdleBlurhashDecode(canvas, blurhash, width, height) {
+ blurhashIdleQueue.push({ canvas, blurhash, w: width, h: height });
+ scheduleIdleBlurhashDrain();
+ }
+
+ async function fillBlurhashCanvas(canvas) {
+ if (!canvas || !canvas.isConnected) return;
+ const blurhash = canvas.getAttribute('data-blurhash-pending');
+ if (!blurhash) return;
+ if (shouldSkipBlurhashDecode()) {
+ canvas.removeAttribute('data-blurhash-pending');
+ canvas.classList.add('lazy-hidden');
+ return;
+ }
+
+ const w = canvas.width || 20;
+ const h = canvas.height || 20;
+ const cacheKey = blurhashCacheKey(blurhash, w, h);
+ const cached = blurhashCacheGet(cacheKey);
+ if (cached) {
+ applyBlurhashPixelsToCanvas(canvas, cached);
+ return;
+ }
+
+ try {
+ const pixels = await decodeBlurhashInWorker(blurhash, w, h);
+ if (!canvas.isConnected || canvas.getAttribute('data-blurhash-pending') !== blurhash) return;
+ blurhashCacheSet(cacheKey, pixels);
+ applyBlurhashPixelsToCanvas(canvas, pixels);
+ } catch (e) {
+ enqueueIdleBlurhashDecode(canvas, blurhash, w, h);
+ }
+ }
+
+ function observeBlurhashCanvas(canvas) {
+ if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return;
+ if (shouldSkipBlurhashDecode()) {
+ canvas.removeAttribute('data-blurhash-pending');
+ canvas.classList.add('lazy-hidden');
+ return;
+ }
+ initBlurhashFillObserver();
+ if (blurhashFillObserver) {
+ blurhashFillObserver.observe(canvas);
+ } else {
+ fillBlurhashCanvas(canvas);
+ }
+ }
+
+ function initBlurhashFillObserver() {
+ if (blurhashFillObserver) return;
+ if (typeof IntersectionObserver === 'undefined') return;
+
+ blurhashFillObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) return;
+ const canvas = entry.target;
+ blurhashFillObserver.unobserve(canvas);
+ fillBlurhashCanvas(canvas);
+ });
+ },
+ {
+ threshold: 0,
+ rootMargin: getBlurhashFillRootMargin(),
+ },
+ );
+ }
+
+ function observePendingBlurhashInNode(node) {
+ if (!node || node.nodeType !== 1) return;
+ if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) {
+ observeBlurhashCanvas(node);
+ }
+ if (node.querySelectorAll) {
+ node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvas);
+ }
+ }
+
+ /**
+ * Get blurhash string for the card image when the image is from the current item.
+ * Mirrors the image URL logic; returns undefined when image comes from parent/series (we don't have their ImageBlurHashes).
+ * @param {Object} item - Jellyfin item
+ * @param {string|null} cardFormat - 'backdrop'|'square'|'portrait'|'poster'|'thumb'|'series thumb'|'series poster'|null
+ * @returns {string|undefined} - Blurhash string or undefined
+ */
+ function getBlurhashForCard(item, cardFormat) {
+ const hashes = item.ImageBlurHashes;
+ if (!hashes || typeof hashes !== 'object') return undefined;
+ cardFormat = cardFormat?.toLowerCase() || null;
+
+ if (cardFormat === 'backdrop') {
+ if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
+ if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ return undefined;
+ }
+ if (cardFormat === 'square') {
+ if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
+ if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ return undefined;
+ }
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
+ if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
+ return undefined;
+ }
+ if (cardFormat === 'portrait' || cardFormat === 'poster') {
+ if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) return undefined;
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag];
+ if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
+ return undefined;
+ }
+ if (cardFormat === 'series poster') {
+ if (item.SeriesPrimaryImageTag) return undefined;
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ return undefined;
+ }
+ if (cardFormat === 'series thumb') {
+ if (item.ParentThumbImageTag) return undefined;
+ if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) return undefined;
+ if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
+ if (item.BackdropImageTags && item.BackdropImageTags[0]) return hashes.Backdrop?.[item.BackdropImageTags[0]];
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ return undefined;
+ }
+ if (cardFormat === 'thumb') {
+ if (item.Type === 'Episode') {
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ if (item.PrimaryImageTag) return hashes.Primary?.[item.PrimaryImageTag];
+ if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
+ return undefined;
+ }
+ if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ return undefined;
+ }
+ if (item.IsJellyseerr) return undefined;
+ if (item.ImageTags?.Primary) return hashes.Primary?.[item.ImageTags.Primary];
+ if (item.ImageTags?.Thumb) return hashes.Thumb?.[item.ImageTags.Thumb];
+ if (item.BackdropImageTags && item.BackdropImageTags.length > 0) return hashes.Backdrop?.[item.BackdropImageTags[0]];
+ return undefined;
+ }
+
+ function useEpisodeImagesInNextUpAndResume(userId) {
+ return localStorage.getItem(`${userId}-useEpisodeImagesInNextUpAndResume`) === 'true';
+ }
+
+ function getItemMaterialIcon(item) {
+ const itemType = item?.Type;
+ if (itemType === 'CollectionFolder' || item?.CollectionType) {
+ switch ((item.CollectionType || '').toLowerCase()) {
+ case 'tvshows':
+ return 'tv';
+ case 'movies':
+ return 'movie';
+ case 'boxsets':
+ return 'video_library';
+ case 'playlists':
+ return 'queue';
+ case 'books':
+ return 'book';
+ case 'music':
+ return 'music_note';
+ case 'homevideos':
+ return 'photo';
+ case 'folders':
+ return 'folder';
+ case 'livetv':
+ return 'live_tv';
+ default:
+ return 'folder';
+ }
+ }
+ if (itemType === 'Movie') return 'movie';
+ if (itemType === 'Series' || itemType === 'Season') return 'tv';
+ if (itemType === 'Episode' || itemType === 'Video') return 'play_circle';
+ if (itemType === 'TvChannel' || itemType === 'Program') return 'live_tv';
+ if (itemType === 'Playlist') return 'queue';
+ if (itemType === 'BoxSet') return 'video_library';
+ if (itemType === 'Book') return 'book';
+ if (itemType === 'Person' || itemType === 'MusicArtist' || itemType === 'Artist') return 'person';
+ if (itemType === 'MusicAlbum') return 'album';
+ if (itemType === 'Audio') return 'music_note';
+ return 'folder';
+ }
+
+ function buildCollectionFolderHref(item, serverId, options = {}) {
+ const { useHtmlExtension = false } = options;
+ if (!item?.Id || (item.Type !== 'CollectionFolder' && !item.CollectionType)) return null;
+
+ const id = item.Id;
+ const ext = useHtmlExtension ? '.html' : '';
+ switch ((item.CollectionType || '').toLowerCase()) {
+ case 'movies':
+ return `#/movies${ext}?topParentId=${id}&collectionType=movies`;
+ case 'tvshows':
+ return `#/tv${ext}?topParentId=${id}&collectionType=tvshows`;
+ case 'music':
+ return `#/music${ext}?topParentId=${id}&collectionType=music`;
+ case 'livetv':
+ return `#/livetv${ext}?collectionType=livetv`;
+ default:
+ return `#/list${ext}?parentId=${id}&serverId=${serverId}`;
+ }
+ }
+
+ function getCollectionFolderUrl(item, serverId) {
+ return buildCollectionFolderHref(item, serverId, { useHtmlExtension: false });
+ }
+
+ function createLibraryButtonElement(item) {
+ const serverId = ApiClient.serverId();
+ const icon = getItemMaterialIcon(item);
+ const href = item.cardUrl || getCollectionFolderUrl(item, serverId) || `#/details?id=${item.Id}&serverId=${serverId}`;
+
+ const link = document.createElement('a');
+ link.setAttribute('is', 'emby-linkbutton');
+ link.href = href;
+ link.className = 'raised homeLibraryButton emby-button';
+ if (item.Id) {
+ link.setAttribute('data-id', item.Id);
+ }
+
+ const iconSpan = document.createElement('span');
+ iconSpan.className = `material-icons homeLibraryIcon ${icon}`;
+ iconSpan.setAttribute('aria-hidden', 'true');
+
+ const textSpan = document.createElement('span');
+ textSpan.className = 'homeLibraryText';
+ textSpan.textContent = item.Name || '';
+
+ link.appendChild(iconSpan);
+ link.appendChild(textSpan);
+ return link;
+ }
+
+ /**
+ * Hi-res image request for a card format: ~2× the classic fillHeight/fillWidth
+ * sizes (backdrop 267×474, square 297, banner 100×540, portrait 446×297).
+ * @param {string|null} cardFormat
+ * @returns {string} query params (no leading ?)
+ */
+ function getDoubledAspectRatioImageParams(cardFormat) {
+ const format = (cardFormat || '').toLowerCase();
+ let width;
+ let height;
+ switch (format) {
+ case 'backdrop':
+ case 'thumb':
+ case 'series thumb':
+ case 'logo':
+ case 'clear art':
+ // fillHeight=267&fillWidth=474 → ×2
+ width = 948;
+ height = 534;
+ break;
+ case 'square':
+ case 'disc':
+ // fillHeight=297&fillWidth=297 → ×2
+ width = 594;
+ height = 594;
+ break;
+ case 'banner':
+ // fillHeight=100&fillWidth=540 → ×2
+ width = 1080;
+ height = 200;
+ break;
+ case 'portrait':
+ case 'poster':
+ case 'series poster':
+ default:
+ // fillHeight=446&fillWidth=297 → ×2
+ width = 594;
+ height = 892;
+ break;
+ }
+ return `fillHeight=${height}&fillWidth=${width}`;
+ }
+
/**
* Creates a Jellyfin card element from an item
* @param {Object} item - The Jellyfin item object
* @param {boolean} overflowCard - Use overflow card classes instead of normal card classes
- * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', or 'square'
+ * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', 'thumb', 'square', 'series thumb', 'series poster'
* @param {string} customFooterText - Optional custom footer text (e.g., air date for episodes)
+ * @param {boolean} useParentCard - When true, the parent item will be rendered as the card image and link
+ * @param {string|null} borderStyle - Section card border style; used to gate retro marquee attrs
* @returns {HTMLElement} - The constructed card element
*/
- function createJellyfinCardElement(item, overflowCard = false, cardFormat = null, customFooterText = null) {
+ function createJellyfinCardElement(
+ item,
+ overflowCard = false,
+ cardFormat = null,
+ customFooterText = null,
+ useParentCard = false,
+ borderStyle = null,
+ cardImageParams = null
+ ) {
+ cardFormat = cardFormat?.toLowerCase() || null;
+ if (cardFormat === 'button') {
+ return createLibraryButtonElement(item);
+ }
+
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
-
+ const itemId = item.Id || '';
+ const itemType = item.Type || 'Folder';
+ const isCustomCard = !!(item.cardUrl || item.posterUrl || item.thumbUrl || item.squareUrl || item.imageUrl);
+ let parentItem = null;
+
+ if (item.Type === 'Timer') {
+ parentItem = item;
+ item = parentItem.ProgramInfo;
+ }
+
// Determine card type based on cardFormat override or item type
let cardClass, padderClass, imageParams;
- cardFormat = cardFormat?.toLowerCase() || null;
-
+
+ // Use custom footer text from item property if not explicitly provided
+ if (!customFooterText && item.CustomFooterText) {
+ customFooterText = item.CustomFooterText;
+ }
+
if (cardFormat) {
// Use specified cardFormat
- if (cardFormat === 'backdrop' || cardFormat === 'thumb') {
+ if (
+ cardFormat === 'backdrop' ||
+ cardFormat === 'thumb' ||
+ cardFormat === 'series thumb' ||
+ cardFormat === 'logo' ||
+ cardFormat === 'clear art'
+ ) {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
- } else if (cardFormat === 'square') {
+ } else if (cardFormat === 'square' || cardFormat === 'disc') {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
+ } else if (cardFormat === 'banner') {
+ cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard';
+ padderClass = 'cardPadder-banner';
+ imageParams = 'fillHeight=100&fillWidth=540';
} else {
- // portrait (default)
+ // portrait / poster / series poster (default)
cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
padderClass = 'cardPadder-portrait';
imageParams = 'fillHeight=446&fillWidth=297';
}
} else {
// Use item type to determine card type
- if (item.Type === 'Episode' || item.Type === 'TvChannel') {
+ if (itemType === 'Episode' || itemType === 'TvChannel') {
cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
padderClass = 'cardPadder-backdrop';
imageParams = 'fillHeight=267&fillWidth=474';
- } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) {
+ } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(itemType)) {
cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
padderClass = 'cardPadder-square';
imageParams = 'fillHeight=297&fillWidth=297';
@@ -242,21 +4369,57 @@
imageParams = 'fillHeight=446&fillWidth=297';
}
}
-
+
+ if (cardImageParams) {
+ imageParams = cardImageParams;
+ }
+
+ const cardLinkId = useParentCard && (item.SeriesId || item.ParentId) ? item.SeriesId || item.ParentId : itemId;
+
// Create the main card container
const card = document.createElement('div');
- card.className = `card ${cardClass} card-hoverable card-withuserdata`;
+ card.className = `card ${cardClass}${!isMobileLayout() ? " card-hoverable" : ""} card-withuserdata`;
card.setAttribute('data-index', '0');
- card.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' ? 'true' : 'false');
+ card.setAttribute('data-isfolder', itemType === 'MusicAlbum' || itemType === 'Artist' || item.IsFolder ? 'true' : 'false');
card.setAttribute('data-serverid', serverId);
- card.setAttribute('data-id', item.Id);
- card.setAttribute('data-type', item.Type);
- card.setAttribute('data-mediatype', item.MediaType || 'Video');
+ if (!isCustomCard) card.setAttribute('data-id', cardLinkId);
+ if (isCustomCard) card.setAttribute('data-custom-card', 'true');
+ card.setAttribute('data-type', itemType);
+ if (normalizeCardBorderStyle(borderStyle) === 'retro-poster') {
+ card.setAttribute('data-retro-marquee', getRetroPosterMarqueeLabel(item));
+ }
+ const mediaType = item.MediaType === 'Unknown' && item.ChannelId ? 'Video' : item.MediaType;
+ card.setAttribute('data-mediatype', mediaType || 'Video');
+
+ if (item.CollectionType) {
+ card.setAttribute('data-collectiontype', item.CollectionType);
+ }
+
+ if (item.IsFolder && item.Path) {
+ card.setAttribute('data-path', item.Path);
+ }
+
+ if (item.ChannelId) {
+ card.setAttribute('data-channelid', item.ChannelId);
+ }
+
+ if (item.StartDate) {
+ card.setAttribute('data-startdate', item.StartDate);
+ }
+
+ if (item.EndDate) {
+ card.setAttribute('data-enddate', item.EndDate);
+ }
+
card.setAttribute('data-prefix', item.Name?.startsWith('The ') ? 'THE' : '');
+ if (item.UserData?.PlaybackPositionTicks && item.UserData?.PlaybackPositionTicks > 0) {
+ card.setAttribute('data-positionticks', item.UserData?.PlaybackPositionTicks);
+ }
+
// Card box container
const cardBox = document.createElement('div');
- cardBox.className = 'cardBox cardBox-bottompadded';
+ cardBox.className = `cardBox cardBox-bottompadded ${cardFormat === "button" ? "emby-button emby-button-foreground raised" : ""}`;
// Card scalable container
const cardScalable = document.createElement('div');
@@ -265,48 +4428,64 @@
// Card padder with icon
const cardPadder = document.createElement('div');
cardPadder.className = `cardPadder ${padderClass} lazy-hidden-children`;
-
+
const cardIcon = document.createElement('span');
cardIcon.className = 'cardImageIcon material-icons';
cardIcon.setAttribute('aria-hidden', 'true');
-
+
// Set icon based on item type
- if (item.Type === 'Movie') {
- cardIcon.textContent = 'movie';
- } else if (item.Type === 'Series') {
- cardIcon.textContent = 'tv';
- } else if (item.Type === 'Episode') {
- cardIcon.textContent = 'play_circle';
- } else if (item.Type === 'MusicAlbum') {
- cardIcon.textContent = 'album';
- } else if (item.Type === 'Audio') {
- cardIcon.textContent = 'music_note';
- } else if (item.Type === 'Artist') {
- cardIcon.textContent = 'person';
- } else {
- cardIcon.textContent = 'folder';
- }
-
+ cardIcon.textContent = getItemMaterialIcon(item);
+
cardPadder.appendChild(cardIcon);
- // Blurhash canvas (placeholder)
+ // Blurhash canvas (placeholder) – will be filled and shown when we have a blurhash
const blurhashCanvas = document.createElement('canvas');
blurhashCanvas.setAttribute('aria-hidden', 'true');
blurhashCanvas.width = 20;
blurhashCanvas.height = 20;
- blurhashCanvas.className = 'blurhash-canvas lazy-hidden';
+ // lazy-hidden only when we have no blurhash (grey card until image loads)
+ blurhashCanvas.className = 'blurhash-canvas';
- // Card image container
- const cardImageContainer = document.createElement('a');
- cardImageContainer.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
- cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast';
- cardImageContainer.setAttribute('data-action', 'link');
- cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown');
+ let libraryUrl = '';
+ if (item.Type === 'CollectionFolder') {
+ libraryUrl = buildCollectionFolderHref(item, serverId, { useHtmlExtension: true }) || '';
+ }
- // Force specific image if card format is specified
- if (cardFormat === 'backdrop') {
- let imageUrl = '';
- if (item.BackdropImageTags[0]) {
+ let cardUrl = item.cardUrl || libraryUrl || `#/details?id=${cardLinkId}&serverId=${serverId}`;
+
+ if (!item.cardUrl && itemType === 'Genre') {
+ const parentParam = item.ParentId ? `&parentId=${item.ParentId}` : '';
+ cardUrl = `#/list.html?genreId=${item.Id}&serverId=${serverId}${parentParam}`;
+ }
+
+ let imageUrl = '';
+
+ const hasCustomImages =
+ item.posterUrl || item.thumbUrl || item.squareUrl || item.imageUrl || item.backdropUrl || item.bannerUrl || item.logoUrl;
+
+ const imageItem = item.Type === 'Timer' ? item.ProgramInfo : item;
+
+ if (hasCustomImages) {
+ if (cardFormat === 'backdrop' || cardFormat === 'thumb' || cardFormat === 'series thumb') {
+ imageUrl = `${item.backdropUrl || item.thumbUrl || item.imageUrl || item.posterUrl || item.squareUrl}`;
+ } else if (cardFormat === 'banner') {
+ imageUrl = `${item.bannerUrl || item.backdropUrl || item.thumbUrl || item.imageUrl || item.posterUrl || item.squareUrl}`;
+ } else if (cardFormat === 'logo' || cardFormat === 'clear art') {
+ imageUrl = `${item.logoUrl || item.imageUrl || item.posterUrl || item.thumbUrl || item.squareUrl}`;
+ } else if (cardFormat === 'square') {
+ imageUrl = `${item.squareUrl || item.imageUrl || item.posterUrl || item.thumbUrl}`;
+ } else {
+ // portrait/poster/series poster or default
+ imageUrl = `${item.posterUrl || item.imageUrl || item.thumbUrl || item.squareUrl}`;
+ }
+ } else if (cardFormat === 'backdrop') {
+ if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ } else if (useParentCard && item.ParentThumbImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
+ } else if (useParentCard && item.SeriesPrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ } else if (item.BackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
@@ -315,195 +4494,465 @@
} else {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
}
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (cardFormat === 'square') {
- if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
- const imageUrl = item.BackdropImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
- } else {
- const imageUrl = item.PrimaryImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
- }
- } else if (cardFormat === 'portrait') {
- if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) {
- const imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
- } else {
- const imageUrl = item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
- }
- } else if (cardFormat === 'thumb') {
- let imageUrl = '';
- if (item.ImageTags?.Thumb) {
+ if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ } else if (useParentCard && item.ParentThumbImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
+ } else if (useParentCard && item.SeriesPrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ } else if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') {
+ imageUrl =
+ item.BackdropImageTags && item.BackdropImageTags[0]
+ ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`
+ : item.ImageTags?.Primary
+ ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`
+ : '';
+ } else if (item.ImageTags?.Primary) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
+ } else if (item.PrimaryImageTag && item.Type === 'Episode') {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
+ } else if (item.BackdropImageTags && item.BackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
+ } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ } else if (item.ImageTags?.Thumb) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
} else if (item.ParentThumbImageTag) {
- imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
- } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
- imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (item.SeriesPrimaryImageTag) {
imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ } else if (item.PrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
+ }
+ } else if (cardFormat === 'portrait' || cardFormat === 'poster') {
+ if ((useParentCard || item.Type === 'Episode') && item.SeriesPrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ } else if (item.ImageTags?.Primary) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`;
+ } else if (item.PrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
+ } else if (item.ImageTags?.Thumb) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
+ }
+ } else if (cardFormat === 'series poster') {
+ if (item.SeriesPrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
} else if (item.ImageTags?.Primary) {
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ } else if (cardFormat === 'series thumb') {
+ if (item.ParentThumbImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId || item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
+ } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ } else if (item.ImageTags?.Thumb) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
+ } else if (item.BackdropImageTags && item.BackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
+ } else if (item.ImageTags?.Primary) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
+ }
+ } else if (cardFormat === 'thumb') {
+ if (item.Type === 'Episode' && !useParentCard) {
+ if (item.ImageTags?.Primary) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
+ } else if (item.PrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTag}`;
+ } else if (item.ImageTags?.Thumb) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
+ } else if (item.ParentThumbImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId || item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
+ } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ } else if (item.SeriesPrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ }
+ } else if (useParentCard && item.ParentThumbImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
+ } else if (useParentCard && item.ParentBackdropImageTags && item.ParentBackdropImageTags[0]) {
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ } else if (item.ImageTags?.Thumb) {
+ // Prefer explicit thumb on the item
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`;
+ } else if (item.ParentThumbImageTag) {
+ // Fall back to a parent thumb (e.g. series thumb for episodes)
+ imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
+ } else {
+ // No item or parent thumb – decide between primary vs backdrop based on primary orientation
+ const primaryAspect = typeof item.PrimaryImageAspectRatio === 'number' ? item.PrimaryImageAspectRatio : null;
+ const hasPrimary = !!item.ImageTags?.Primary;
+ const primaryLooksLikeThumb = hasPrimary && primaryAspect !== null && primaryAspect >= 1.5;
+
+ if (primaryLooksLikeThumb) {
+ // Primary is a wide (e.g. 16:9) thumbnail – use it directly
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
+ } else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
+ // Primary looks like a poster – prefer the item's backdrop
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
+ } else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
+ // Fall back to a parent backdrop if the item has none
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ } else if (hasPrimary) {
+ // No usable backdrop – final fallback to the item's primary (likely poster)
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
+ } else if (item.SeriesPrimaryImageTag) {
+ // As an absolute last resort, try the series primary
+ imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ }
+ }
+ } else if (cardFormat === 'logo' && (item.ImageTags?.Logo || item.ParentLogoImageTag)) {
+ const logoTag = item.ImageTags?.Logo || item.ParentLogoImageTag;
+ const itemId = item.ImageTags?.Logo ? item.Id : item.ParentLogoItemId;
+ imageUrl = `${serverAddress}/Items/${itemId}/Images/Logo?${imageParams}&quality=96&tag=${logoTag}`;
+ } else if (cardFormat === 'clear art' && item.ImageTags?.Art) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Art?${imageParams}&quality=96&tag=${item.ImageTags.Art}`;
+ } else if (cardFormat === 'banner' && item.ImageTags?.Banner) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Banner?${imageParams}&quality=96&tag=${item.ImageTags.Banner}`;
+ } else if (cardFormat === 'disc' && item.ImageTags?.Disc) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Disc?${imageParams}&quality=96&tag=${item.ImageTags.Disc}`;
} else if (item.ImageTags?.Primary) {
- let imageUrl;
if (item.IsJellyseerr) {
// Jellyseerr items use external image URLs
- imageUrl = item.ImageTags.Primary.startsWith('http')
- ? item.ImageTags.Primary
+ imageUrl = item.ImageTags.Primary.startsWith('http')
+ ? item.ImageTags.Primary
: `https://image.tmdb.org/t/p/w500${item.ImageTags.Primary}`;
} else {
// Regular Jellyfin items
imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags.Primary}`;
}
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
} else if (item.ImageTags?.Thumb) {
- const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
- } else if ((item.Type === 'Season') && item.SeriesPrimaryImageTag) {
- const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
- } else if ((item.Type === 'Episode') && item.ParentThumbImageTag) {
- const imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags.Thumb}`;
+ } else if (item.Type === 'Season' && item.SeriesPrimaryImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`;
+ } else if (item.Type === 'Episode' && item.ParentThumbImageTag) {
+ imageUrl = `${serverAddress}/Items/${item.ParentThumbItemId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`;
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
- const imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`;
} else if (item.ParentBackdropImageTags && item.ParentBackdropImageTags.length > 0) {
- const imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
- cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
- } else {
- // No image - add icon as inner element
+ imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
+ }
+
+ // Card image container
+ const cardImageContainer = isCustomCard ? document.createElement('div') : document.createElement('a');
+ if (!isCustomCard) {
+ cardImageContainer.href = cardUrl;
+ cardImageContainer.setAttribute('data-action', 'link');
+ }
+
+ const defaultBgClass = !imageUrl ? `defaultCardBackground${state.defaultCardBackgroundNumber++} ` : '';
+ cardImageContainer.className =
+ `cardImageContainer ${!imageUrl || imageUrl.length === 0 ? "defaultCardBackground " : ""}${defaultBgClass}coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast`
+ .replace(/\s+/g, ' ')
+ .trim();
+ cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown');
+
+ if (state.defaultCardBackgroundNumber > 5) {
+ state.defaultCardBackgroundNumber = 1;
+ }
+
+ // No image - add icon as inner element
+ if (!imageUrl) {
const iconSpan = document.createElement('span');
iconSpan.className = 'cardImageIcon material-icons';
iconSpan.setAttribute('aria-hidden', 'true');
-
+
// Set icon based on item type
- if (item.Type === 'Movie') {
- iconSpan.textContent = 'movie';
- } else if (item.Type === 'Series') {
- iconSpan.textContent = 'tv';
- } else if (item.Type === 'Episode') {
- iconSpan.textContent = 'tv';
- } else if (item.Type === 'Person') {
- iconSpan.textContent = 'person';
- } else if (item.Type === 'MusicAlbum') {
- iconSpan.textContent = 'album';
- } else if (item.Type === 'Audio') {
- iconSpan.textContent = 'music_note';
- } else if (item.Type === 'Artist' || item.Type === 'MusicArtist') {
- iconSpan.textContent = 'person';
- } else {
- iconSpan.textContent = 'folder';
- }
-
+ iconSpan.textContent = getItemMaterialIcon(item);
cardImageContainer.appendChild(iconSpan);
}
- if (item.UserData?.Played) {
- const cardIndicators = document.createElement('div');
- cardIndicators.className = 'cardIndicators';
- const playedIndicator = document.createElement('div');
- playedIndicator.className = 'playedIndicator indicator';
- const playedIndicatorIcon = document.createElement('span');
- playedIndicatorIcon.className = 'material-icons indicatorIcon check';
- playedIndicatorIcon.setAttribute('aria-hidden', 'true');
- playedIndicator.appendChild(playedIndicatorIcon);
- cardIndicators.appendChild(playedIndicator);
- cardImageContainer.appendChild(cardIndicators);
+ const blurhashStr = !item.imageUrl ? getBlurhashForCard(item, cardFormat) : null;
+ let includeKefinBlurhashCanvas = true;
+ if (!blurhashStr || cardFormat === 'logo' || cardFormat === 'clear art' || cardFormat === 'disc') {
+ blurhashCanvas.classList.add('lazy-hidden');
+ } else if (shouldSkipBlurhashDecode()) {
+ // Skip CPU decode; keep default chrome / lazy image path only
+ blurhashCanvas.classList.add('lazy-hidden');
+ } else {
+ // Defer decode off the construction path — near-viewport filler paints later
+ blurhashCanvas.classList.add('lazy-hidden');
+ blurhashCanvas.setAttribute('data-blurhash-pending', blurhashStr);
}
- // Card overlay container
- const cardOverlayContainer = document.createElement('div');
- cardOverlayContainer.className = 'cardOverlayContainer itemAction';
- cardOverlayContainer.setAttribute('data-action', 'link');
+ if (imageUrl) {
+ // Use data-src for lazy loading instead of immediate backgroundImage
+ cardImageContainer.setAttribute('data-src', imageUrl);
+ if (blurhashStr && state.useBlurhash && cardFormat !== 'logo' && cardFormat !== 'clear art' && cardFormat !== 'disc') {
+ cardImageContainer.setAttribute('data-blurhash', blurhashStr);
+ }
+ // Add lazy class for styling/selection
+ cardImageContainer.classList.add('lazy');
+ }
+ //countIndicator indicator
- // Overlay link
- const overlayLink = document.createElement('a');
- overlayLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
- overlayLink.className = 'cardImageContainer';
+ if (!isCustomCard) {
+ const cardIndicators = document.createElement('div');
+ cardIndicators.className = 'cardIndicators';
- // Play button
- const playButton = document.createElement('button');
- playButton.setAttribute('is', 'paper-icon-button-light');
- playButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary';
- playButton.setAttribute('data-action', 'resume');
-
- const playIcon = document.createElement('span');
- playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow';
- playIcon.setAttribute('aria-hidden', 'true');
- playButton.appendChild(playIcon);
+ if (item.UserData?.UnplayedItemCount && item.UserData?.UnplayedItemCount > 0) {
+ const unplayedIndicator = document.createElement('div');
+ unplayedIndicator.className = 'countIndicator indicator';
+ unplayedIndicator.textContent = item.UserData?.UnplayedItemCount;
+ cardIndicators.appendChild(unplayedIndicator);
+ }
- // Button container for additional overlay buttons (watchlist, etc.)
- const buttonContainer = document.createElement('div');
- buttonContainer.className = 'cardOverlayButton-br flex';
+ if (item.LocationType === 'Virtual') {
+ if (item.PremiereDate && new Date(item.PremiereDate) > new Date()) {
+ const premiereIndicator = document.createElement('div');
+ premiereIndicator.className = 'unairedIndicator';
+ premiereIndicator.textContent = 'Unaired';
+ cardIndicators.appendChild(premiereIndicator);
+ } else {
+ const virtualIndicator = document.createElement('div');
+ virtualIndicator.className = 'missingIndicator';
+ virtualIndicator.textContent = 'Missing';
+ cardIndicators.appendChild(virtualIndicator);
+ }
+ }
- // Watched button
- const watchedButton = document.createElement('button');
- watchedButton.setAttribute('is', 'emby-playstatebutton');
- watchedButton.type = 'button';
- watchedButton.setAttribute('data-action', 'none');
- watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
- watchedButton.setAttribute('data-id', item.Id);
- watchedButton.setAttribute('data-serverid', serverId);
- watchedButton.setAttribute('data-itemtype', item.Type);
- watchedButton.setAttribute('data-played', item.UserData?.Played || 'false');
- watchedButton.title = 'Mark played';
-
- const watchedIcon = document.createElement('span');
- watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed';
- watchedIcon.setAttribute('aria-hidden', 'true');
- watchedButton.appendChild(watchedIcon);
- buttonContainer.appendChild(watchedButton);
+ if (item.UserData?.Played) {
+ const playedIndicator = document.createElement('div');
+ playedIndicator.className = 'playedIndicator indicator';
+ const playedIndicatorIcon = document.createElement('span');
+ playedIndicatorIcon.className = 'material-icons indicatorIcon check';
+ playedIndicatorIcon.setAttribute('aria-hidden', 'true');
+ playedIndicator.appendChild(playedIndicatorIcon);
+ cardIndicators.appendChild(playedIndicator);
+ }
- // Favorite button
- const favoriteButton = document.createElement('button');
- favoriteButton.setAttribute('is', 'emby-ratingbutton');
- favoriteButton.type = 'button';
- favoriteButton.setAttribute('data-action', 'none');
- favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
- favoriteButton.setAttribute('data-id', item.Id);
- favoriteButton.setAttribute('data-serverid', serverId);
- favoriteButton.setAttribute('data-itemtype', item.Type);
- favoriteButton.setAttribute('data-likes', '');
- favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false');
- favoriteButton.title = 'Add to favorites';
-
- const favoriteIcon = document.createElement('span');
- favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite';
- favoriteIcon.setAttribute('aria-hidden', 'true');
- favoriteButton.appendChild(favoriteIcon);
- buttonContainer.appendChild(favoriteButton);
+ if (parentItem && parentItem.Type === 'Timer') {
+ const timerIndicator = document.createElement('span');
+ timerIndicator.className = 'material-icons timerIndicator indicatorIcon fiber_manual_record';
+ timerIndicator.setAttribute('aria-hidden', 'true');
+ cardIndicators.appendChild(timerIndicator);
+ }
- const moreButton = document.createElement('button');
- moreButton.setAttribute('is', 'paper-icon-button-light');
- moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
- moreButton.setAttribute('data-action', 'menu');
- moreButton.title = 'More';
- const moreIcon = document.createElement('span');
- moreIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert';
- moreIcon.setAttribute('aria-hidden', 'true');
- moreButton.appendChild(moreIcon);
+ if (cardIndicators.childElementCount > 0) {
+ cardImageContainer.appendChild(cardIndicators);
+ }
- buttonContainer.appendChild(moreButton);
+ if (item.MediaSourceCount > 1) {
+ const mediaSourceIndicator = document.createElement('div');
+ mediaSourceIndicator.className = 'mediaSourceIndicator';
+ mediaSourceIndicator.textContent = item.MediaSourceCount;
+ cardImageContainer.appendChild(mediaSourceIndicator);
+ }
+ if (item.UserData?.PlayedPercentage && item.UserData?.PlayedPercentage > 0) {
+ const innerCardFooter = document.createElement('div');
+ innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear';
+ cardBox.appendChild(innerCardFooter);
+ const itemProgressBar = document.createElement('div');
+ itemProgressBar.className = 'itemProgressBar';
+ const itemProgressBarForeground = document.createElement('div');
+ itemProgressBarForeground.className = 'itemProgressBarForeground';
+ itemProgressBarForeground.style.width = `${item.UserData?.PlayedPercentage}%`;
+ itemProgressBar.appendChild(itemProgressBarForeground);
+ innerCardFooter.appendChild(itemProgressBar);
+ cardImageContainer.appendChild(innerCardFooter);
+ } else if (item.Type === 'Program') {
+ const isOnNow = Date.now() >= new Date(item.StartDate).getTime() && Date.now() <= new Date(item.EndDate).getTime()
+ if (isOnNow) {
+ const progress = (Date.now() - new Date(item.StartDate).getTime()) / (new Date(item.EndDate).getTime() - new Date(item.StartDate).getTime());
+ const progressPercentage = Math.round(progress * 100);
+ const innerCardFooter = document.createElement('div');
+ innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear';
+ cardBox.appendChild(innerCardFooter);
+ const itemProgressBar = document.createElement('div');
+ itemProgressBar.className = 'itemProgressBar';
+ const itemProgressBarForeground = document.createElement('div');
+ itemProgressBarForeground.className = 'itemProgressBarForeground';
+ itemProgressBarForeground.style.width = `${progressPercentage}%`;
+ itemProgressBar.appendChild(itemProgressBarForeground);
+ innerCardFooter.appendChild(itemProgressBar);
+ cardImageContainer.appendChild(innerCardFooter);
+ }
+ }
+ }
- // Assemble overlay
- cardOverlayContainer.appendChild(overlayLink);
- cardOverlayContainer.appendChild(playButton);
- cardOverlayContainer.appendChild(buttonContainer);
+ // Overlays: desktop = hover FAB + br actions; mobile = native bottom-right play only
+ const NON_PLAYABLE_TYPES = ['CollectionFolder', 'Folder', 'UserView', 'Virtual', 'Studio', 'Genre', 'Person'];
+ const NO_RATE_TYPES = ['UserView', 'CollectionFolder', 'Folder', 'Program', 'Timer', 'SeriesTimer', 'Channel'];
+ const canShowPlayOverlay =
+ !isCustomCard
+ && !NON_PLAYABLE_TYPES.includes(item.Type)
+ && item.LocationType !== 'Virtual'
+ && item.Type !== 'Person';
+ // Match jellyfin-web itemHelper.canMarkPlayed (cards: no Folder/CollectionFolder/UserView)
+ const canMarkPlayed =
+ item.Type !== 'Program'
+ && (
+ (item.MediaType === 'Video' && item.Type !== 'TvChannel')
+ || (item.MediaType === 'Audio' && item.Type === 'AudioBook')
+ || item.Type === 'Series'
+ || item.Type === 'Season'
+ || item.Type === 'BoxSet'
+ || item.MediaType === 'Book'
+ )
+ && !NON_PLAYABLE_TYPES.includes(item.Type);
+ // Match jellyfin-web itemHelper.canRate type denylist (do not gate on missing UserData)
+ const canRate = !NO_RATE_TYPES.includes(item.Type);
+
+ let cardOverlayContainer = null;
+ let mobileOverlayPlayButton = null;
+
+ if (isCustomCard) {
+ cardOverlayContainer = document.createElement('div');
+ cardOverlayContainer.className = 'cardOverlayContainer';
+ const overlayLink = document.createElement('a');
+ overlayLink.href = cardUrl;
+ overlayLink.className = 'button-link';
+ overlayLink.setAttribute('is', 'emby-linkbutton');
+ if (!cardUrl.startsWith('/') && !cardUrl.startsWith('#')) {
+ overlayLink.setAttribute('target', '_blank');
+ }
+ cardOverlayContainer.appendChild(overlayLink);
+ } else if (isMobileLayout()) {
+ // Match jellyfin-web layoutManager.mobile: play button with cardOverlayButton-br on the button
+ if (canShowPlayOverlay) {
+ mobileOverlayPlayButton = document.createElement('button');
+ mobileOverlayPlayButton.type = 'button';
+ mobileOverlayPlayButton.className =
+ 'cardOverlayButton cardOverlayButton-br itemAction paper-icon-button-light';
+ mobileOverlayPlayButton.setAttribute('data-action', 'play');
+ mobileOverlayPlayButton.title = 'Play';
+ const playIcon = document.createElement('span');
+ playIcon.className = 'material-icons cardOverlayButtonIcon play_arrow';
+ playIcon.setAttribute('aria-hidden', 'true');
+ mobileOverlayPlayButton.appendChild(playIcon);
+ }
+ } else {
+ cardOverlayContainer = document.createElement('div');
+ cardOverlayContainer.className = 'cardOverlayContainer itemAction';
+ cardOverlayContainer.setAttribute('data-action', 'link');
+
+ const playButton = document.createElement('button');
+ playButton.className =
+ 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light cardOverlayFab-primary';
+ playButton.setAttribute('data-action', 'resume');
+
+ const playIcon = document.createElement('span');
+ playIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover play_arrow';
+ playIcon.setAttribute('aria-hidden', 'true');
+ playButton.appendChild(playIcon);
+
+ const buttonContainer = document.createElement('div');
+ buttonContainer.className = 'cardOverlayButton-br flex';
+
+ const watchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
+ if (state.watchlist && watchlistSupportedTypes.includes(itemType)) {
+ const isLiked = item.UserData?.Likes === true;
+ const watchlistButton = document.createElement('button');
+ watchlistButton.type = 'button';
+ watchlistButton.className =
+ 'watchlist-button cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button button-flat';
+ watchlistButton.setAttribute('data-action', 'none');
+ watchlistButton.setAttribute('data-id', itemId);
+ watchlistButton.setAttribute('data-active', isLiked ? 'true' : 'false');
+ watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
+
+ const watchlistIcon = document.createElement('span');
+ watchlistIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover watchlist';
+ watchlistIcon.setAttribute('aria-hidden', 'true');
+ watchlistButton.appendChild(watchlistIcon);
+ buttonContainer.appendChild(watchlistButton);
+ }
+
+ if (canMarkPlayed) {
+ const watchedButton = document.createElement('button');
+ watchedButton.setAttribute('is', 'emby-playstatebutton');
+ watchedButton.type = 'button';
+ watchedButton.setAttribute('data-action', 'none');
+ watchedButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
+ watchedButton.setAttribute('data-id', itemId);
+ watchedButton.setAttribute('data-serverid', serverId);
+ watchedButton.setAttribute('data-itemtype', itemType);
+ watchedButton.setAttribute('data-played', item.UserData?.Played || 'false');
+ watchedButton.title = 'Mark played';
+
+ const watchedIcon = document.createElement('span');
+ watchedIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover check playstatebutton-icon-unplayed';
+ watchedIcon.setAttribute('aria-hidden', 'true');
+ watchedButton.appendChild(watchedIcon);
+ buttonContainer.appendChild(watchedButton);
+ }
+
+ if (canRate) {
+ const favoriteButton = document.createElement('button');
+ favoriteButton.setAttribute('is', 'emby-ratingbutton');
+ favoriteButton.type = 'button';
+ favoriteButton.setAttribute('data-action', 'none');
+ favoriteButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light emby-button';
+ favoriteButton.setAttribute('data-id', itemId);
+ favoriteButton.setAttribute('data-serverid', serverId);
+ favoriteButton.setAttribute('data-itemtype', itemType);
+ favoriteButton.setAttribute('data-likes', '');
+ favoriteButton.setAttribute('data-isfavorite', item.UserData?.IsFavorite || 'false');
+ favoriteButton.title = 'Add to favorites';
+
+ const favoriteIcon = document.createElement('span');
+ favoriteIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover favorite';
+ favoriteIcon.setAttribute('aria-hidden', 'true');
+ favoriteButton.appendChild(favoriteIcon);
+ buttonContainer.appendChild(favoriteButton);
+ }
+
+ const moreButton = document.createElement('button');
+ moreButton.className = 'cardOverlayButton cardOverlayButton-hover itemAction paper-icon-button-light';
+ moreButton.setAttribute('data-action', 'menu');
+ moreButton.title = 'More';
+ const moreIcon = document.createElement('span');
+ moreIcon.className = 'material-icons cardOverlayButtonIcon cardOverlayButtonIcon-hover more_vert';
+ moreIcon.setAttribute('aria-hidden', 'true');
+ moreButton.appendChild(moreIcon);
+ buttonContainer.appendChild(moreButton);
+
+ if (canShowPlayOverlay) {
+ cardOverlayContainer.appendChild(playButton);
+ }
+ cardOverlayContainer.appendChild(buttonContainer);
+ }
// Card text container - different structure for episodes
+ const cardTextFragment = document.createDocumentFragment();
+
const cardTextContainer = document.createElement('div');
cardTextContainer.className = 'cardText cardTextCentered cardText-first';
- if (item.Type === 'Episode') {
+ if (parentItem && parentItem.Type === 'Timer') {
+ const cardFooter = document.createElement('div');
+ cardFooter.className = 'cardFooter cardFooter-withlogo';
+ const cardFooterLogo = document.createElement('div');
+ cardFooterLogo.className = 'lazy cardFooterLogo lazy-image-fadein-fast';
+ const imageUrl = `${serverAddress}/Items/${parentItem.ChannelId}/Images/Primary?height=40&tag=${parentItem.ChannelPrimaryImageTag}&quality=90`;
+ cardFooterLogo.style.backgroundImage = `url(${imageUrl})`;
+ cardFooter.appendChild(cardFooterLogo);
+
+ const cardFooterText = document.createElement('div');
+ cardFooterText.className = 'cardText cardText-first';
+ cardFooterText.textContent = item.Name || 'Unknown';
+ cardFooter.appendChild(cardFooterText);
+
+ const cardFooterSecondaryText = document.createElement('div');
+ cardFooterSecondaryText.className = 'cardText cardText-secondary';
+ // format the start and end time to 12:21 AM - 2:39 AM
+ const startTime = new Date(parentItem.StartDate).toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: true,
+ });
+ const endTime = new Date(parentItem.EndDate).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
+ cardFooterSecondaryText.textContent = startTime + ' - ' + endTime;
+ cardFooter.appendChild(cardFooterSecondaryText);
+
+ cardTextFragment.appendChild(cardFooter);
+ } else if (itemType === 'Episode') {
// Episodes: Series name as primary, episode title as secondary
const seriesLink = document.createElement('a');
- seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`;
+ seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || itemId}&serverId=${serverId}`;
seriesLink.className = 'itemAction textActionButton';
- seriesLink.setAttribute('data-id', item.SeriesId || item.Id);
+ if (!isCustomCard) seriesLink.setAttribute('data-id', item.SeriesId || itemId);
seriesLink.setAttribute('data-serverid', serverId);
seriesLink.setAttribute('data-type', 'Series');
seriesLink.setAttribute('data-mediatype', 'undefined');
@@ -516,71 +4965,17 @@
const seriesBdi = document.createElement('bdi');
seriesBdi.appendChild(seriesLink);
cardTextContainer.appendChild(seriesBdi);
+ cardTextFragment.appendChild(cardTextContainer);
// Episode title as secondary
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const episodeLink = document.createElement('a');
- episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
+ const episodeName =
+ item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name;
+ episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`;
episodeLink.className = 'itemAction textActionButton';
- episodeLink.setAttribute('data-id', item.Id);
- episodeLink.setAttribute('data-serverid', serverId);
- episodeLink.setAttribute('data-type', 'Episode');
- episodeLink.setAttribute('data-mediatype', 'undefined');
- episodeLink.setAttribute('data-channelid', 'undefined');
- episodeLink.setAttribute('data-isfolder', 'false');
- episodeLink.setAttribute('data-action', 'link');
- episodeLink.title = item.Name || 'Unknown Episode';
- episodeLink.textContent = item.Name || 'Unknown Episode';
-
- const episodeBdi = document.createElement('bdi');
- episodeBdi.appendChild(episodeLink);
- secondaryText.appendChild(episodeBdi);
- } else {
- // Default: Item name as primary, year as secondary
- const titleLink = document.createElement('a');
- titleLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
- titleLink.className = 'itemAction textActionButton';
- titleLink.setAttribute('data-id', item.Id);
- titleLink.setAttribute('data-serverid', serverId);
- titleLink.setAttribute('data-type', item.Type);
- titleLink.setAttribute('data-mediatype', 'undefined');
- titleLink.setAttribute('data-channelid', 'undefined');
- titleLink.setAttribute('data-isfolder', item.Type === 'MusicAlbum' || item.Type === 'Artist' || item.Type === 'MusicArtist' ? 'true' : 'false');
- titleLink.setAttribute('data-action', 'link');
- titleLink.title = item.Name || 'Unknown';
- titleLink.textContent = item.Name || 'Unknown';
-
- const titleBdi = document.createElement('bdi');
- titleBdi.appendChild(titleLink);
- cardTextContainer.appendChild(titleBdi);
-
- // Secondary text (year)
- const secondaryText = document.createElement('div');
- secondaryText.className = 'cardText cardTextCentered cardText-secondary';
- const yearBdi = document.createElement('bdi');
- yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
- secondaryText.appendChild(yearBdi);
- }
-
- // Assemble card
- cardScalable.appendChild(cardPadder);
- cardScalable.appendChild(blurhashCanvas);
- cardScalable.appendChild(cardImageContainer);
- cardScalable.appendChild(cardOverlayContainer);
-
- cardBox.appendChild(cardScalable);
- cardBox.appendChild(cardTextContainer);
-
- // Add secondary text after primary text
- if (item.Type === 'Episode') {
- const secondaryText = document.createElement('div');
- secondaryText.className = 'cardText cardTextCentered cardText-secondary';
- const episodeLink = document.createElement('a');
- const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name;
- episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
- episodeLink.className = 'itemAction textActionButton';
- episodeLink.setAttribute('data-id', item.Id);
+ if (!isCustomCard) episodeLink.setAttribute('data-id', itemId);
episodeLink.setAttribute('data-serverid', serverId);
episodeLink.setAttribute('data-type', 'Episode');
episodeLink.setAttribute('data-mediatype', 'undefined');
@@ -593,8 +4988,8 @@
const episodeBdi = document.createElement('bdi');
episodeBdi.appendChild(episodeLink);
secondaryText.appendChild(episodeBdi);
- cardBox.appendChild(secondaryText);
-
+ cardTextFragment.appendChild(secondaryText);
+
// Add custom footer text if provided (e.g., air date)
if (customFooterText) {
const footerText = document.createElement('div');
@@ -602,450 +4997,1134 @@
const footerBdi = document.createElement('bdi');
footerBdi.textContent = customFooterText;
footerText.appendChild(footerBdi);
- cardBox.appendChild(footerText);
+ cardTextFragment.appendChild(footerText);
+ }
+ } else if (itemType === 'Season') {
+ // Seasons: Series name as primary, season title as secondary
+ const seriesLink = document.createElement('a');
+ seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || itemId}&serverId=${serverId}`;
+ seriesLink.className = 'itemAction textActionButton';
+ if (!isCustomCard) seriesLink.setAttribute('data-id', item.SeriesId || itemId);
+ seriesLink.setAttribute('data-serverid', serverId);
+ seriesLink.setAttribute('data-type', 'Series');
+ seriesLink.setAttribute('data-mediatype', 'undefined');
+ seriesLink.setAttribute('data-channelid', 'undefined');
+ seriesLink.setAttribute('data-isfolder', 'true');
+ seriesLink.setAttribute('data-action', 'link');
+ seriesLink.title = item.SeriesName || 'Unknown Series';
+ seriesLink.textContent = item.SeriesName || 'Unknown Series';
+
+ const seriesBdi = document.createElement('bdi');
+ seriesBdi.appendChild(seriesLink);
+ cardTextContainer.appendChild(seriesBdi);
+ cardTextFragment.appendChild(cardTextContainer);
+
+ // Season title as secondary
+ const secondaryText = document.createElement('div');
+ secondaryText.className = 'cardText cardTextCentered cardText-secondary';
+ const seasonLink = document.createElement('a');
+ seasonLink.href = `${ApiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`;
+ seasonLink.className = 'itemAction textActionButton';
+ if (!isCustomCard) seasonLink.setAttribute('data-id', itemId);
+ seasonLink.setAttribute('data-serverid', serverId);
+ seasonLink.setAttribute('data-type', 'Season');
+ seasonLink.setAttribute('data-mediatype', 'undefined');
+ seasonLink.setAttribute('data-channelid', 'undefined');
+ seasonLink.setAttribute('data-isfolder', 'false');
+ seasonLink.setAttribute('data-action', 'link');
+ seasonLink.title = item.Name || 'Unknown Season';
+ seasonLink.textContent = item.Name || 'Unknown Season';
+
+ const seasonBdi = document.createElement('bdi');
+ seasonBdi.appendChild(seasonLink);
+ secondaryText.appendChild(seasonBdi);
+ cardTextFragment.appendChild(secondaryText);
+
+ // Add custom footer text if provided (e.g., air date)
+ if (customFooterText) {
+ const footerText = document.createElement('div');
+ footerText.className = 'cardText cardTextCentered cardText-secondary';
+ const footerBdi = document.createElement('bdi');
+ footerBdi.textContent = customFooterText;
+ footerText.appendChild(footerBdi);
+ cardTextFragment.appendChild(footerText);
}
} else {
+ // Default: Item name as primary, year as secondary
+ const titleLink = document.createElement('a');
+ titleLink.href = cardUrl;
+ titleLink.className = 'itemAction textActionButton';
+ if (!isCustomCard) titleLink.setAttribute('data-id', itemId);
+ titleLink.setAttribute('data-serverid', serverId);
+ titleLink.setAttribute('data-type', itemType);
+ titleLink.setAttribute('data-mediatype', 'undefined');
+ titleLink.setAttribute('data-channelid', 'undefined');
+ titleLink.setAttribute(
+ 'data-isfolder',
+ itemType === 'MusicAlbum' || itemType === 'Artist' || itemType === 'MusicArtist' || item.IsFolder ? 'true' : 'false',
+ );
+
+ if (item.CollectionType) {
+ titleLink.setAttribute('data-collectiontype', item.CollectionType);
+ }
+
+ titleLink.setAttribute('data-action', 'link');
+ titleLink.title = item.Name || 'Unknown';
+ titleLink.textContent = item.Name || 'Unknown';
+
+ const titleBdi = document.createElement('bdi');
+ titleBdi.appendChild(titleLink);
+ cardTextContainer.appendChild(titleBdi);
+ cardTextFragment.appendChild(cardTextContainer);
+
+ // Secondary text (year)
+ // For Series items, also list their end date or Present if it's continuing aka Start Year - End Year
+ let secondaryTextContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
+
+ if (itemType === 'Series' && item.Status === 'Continuing') {
+ secondaryTextContent = item.ProductionYear + ' - Present';
+ } else if (itemType === 'Series' && item.EndDate && item.ProductionYear && item.EndDate.substring(0, 4) !== String(item.ProductionYear)) {
+ secondaryTextContent = item.ProductionYear + ' - ' + item.EndDate.substring(0, 4);
+ } else if (item.Type === 'Program') {
+ secondaryTextContent = new Date(item.StartDate).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: true });
+ }
+
const secondaryText = document.createElement('div');
secondaryText.className = 'cardText cardTextCentered cardText-secondary';
const yearBdi = document.createElement('bdi');
- yearBdi.textContent = item.ProductionYear || item.PremiereDate?.substring(0, 4) || '';
+ yearBdi.textContent = secondaryTextContent || item.CustomFooterText;
secondaryText.appendChild(yearBdi);
- cardBox.appendChild(secondaryText);
+ cardTextFragment.appendChild(secondaryText);
+
+ if (secondaryTextContent && item.CustomFooterText) {
+ const footerText = document.createElement('div');
+ footerText.className = 'cardText cardTextCentered cardText-secondary';
+ const footerBdi = document.createElement('bdi');
+ footerBdi.textContent = item.CustomFooterText;
+ footerText.appendChild(footerBdi);
+ cardTextFragment.appendChild(footerText);
+ } else if (item.Type === 'Program') {
+ // Format the HH:mm - HH:mm based on local time
+ const timeOptions = { hour: '2-digit', minute: '2-digit', hour12: true };
+ let startTime = new Date(item.StartDate).toLocaleTimeString('en-US', timeOptions).replace('AM', 'a.m.').replace('PM', 'p.m.').toLowerCase();
+ let endTime = new Date(item.EndDate).toLocaleTimeString('en-US', timeOptions).replace('AM', 'a.m.').replace('PM', 'p.m.').toLowerCase();
+
+ // Strip leading zero from hour (e.g., 01:00 a.m. -> 1:00 a.m.)
+ startTime = startTime.replace(/^0(\d:)/, '$1');
+ endTime = endTime.replace(/^0(\d:)/, '$1');
+
+ secondaryTextContent = startTime + ' - ' + endTime;
+
+ const footerText = document.createElement('div');
+ footerText.className = 'cardText cardTextCentered cardText-secondary';
+ const footerBdi = document.createElement('bdi');
+ footerBdi.textContent = secondaryTextContent;
+ footerText.appendChild(footerBdi);
+ cardTextFragment.appendChild(footerText);
+ }
}
-
+
+ // Assemble card (blurhash already drawn above when blurhashStr was set)
+ cardScalable.appendChild(cardPadder);
+ if (includeKefinBlurhashCanvas) {
+ cardScalable.appendChild(blurhashCanvas);
+ }
+ cardScalable.appendChild(cardImageContainer);
+ if (mobileOverlayPlayButton) {
+ cardScalable.appendChild(mobileOverlayPlayButton);
+ } else if (cardOverlayContainer) {
+ cardScalable.appendChild(cardOverlayContainer);
+ }
+ cardScalable.appendChild(createCardBorderHost());
+
+ cardBox.appendChild(cardScalable);
+ if (cardTextFragment.childNodes.length) {
+ const cardTextStack = document.createElement('div');
+ cardTextStack.className = 'cardTextStack';
+ cardTextStack.appendChild(cardTextFragment);
+ cardBox.appendChild(cardTextStack);
+ } else {
+ cardBox.appendChild(cardTextFragment);
+ }
+
card.appendChild(cardBox);
+ /* ensureCardRadiusCssVar();
+ if (cachedCardRadius == null) {
+ requestAnimationFrame(() => ensureCardRadiusCssVar());
+ } */
+
return card;
}
/**
- * Renders a spotlight section (Netflix-style slim banner carousel)
- * @param {Array} items - Array of Jellyfin item objects
+ * TV layout detection (Jellyfin sets layout-tv on ).
+ * Evaluated at call time so Display Mode changes are respected after re-render.
+ */
+ function isTvLayout() {
+ return document.documentElement.classList.contains('layout-tv');
+ }
+
+ /** Feature-detect flex gap once; JMP/Qt WebEngine often lacks it. */
+ function ensureNoFlexGapClass() {
+ const root = document.documentElement;
+ if (root.dataset.kefinFlexGapChecked === 'true') return;
+ root.dataset.kefinFlexGapChecked = 'true';
+ try {
+ const probe = document.createElement('div');
+ probe.style.cssText =
+ 'display:flex;gap:1px;position:absolute;left:-9999px;top:0;width:auto;height:auto;visibility:hidden';
+ const a = document.createElement('div');
+ const b = document.createElement('div');
+ a.style.cssText = 'width:1px;height:1px';
+ b.style.cssText = 'width:1px;height:1px';
+ probe.appendChild(a);
+ probe.appendChild(b);
+ document.body.appendChild(probe);
+ const supported = probe.scrollWidth >= 3;
+ probe.remove();
+ root.classList.toggle('no-flex-gap', !supported);
+ } catch (_) {
+ root.classList.add('no-flex-gap');
+ }
+ }
+
+ function isFocusableTvEl(el) {
+ if (!el || el.nodeType !== 1) return false;
+ if (el.getAttribute('tabindex') === '-1') return false;
+ if (el.disabled || el.getAttribute('aria-hidden') === 'true') return false;
+ const tag = el.tagName;
+ if (tag === 'A' && el.hasAttribute('href')) return true;
+ if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return true;
+ const ti = el.getAttribute('tabindex');
+ return ti != null && ti !== '' && Number(ti) >= 0;
+ }
+
+ function getTvFocusables(root) {
+ if (!root?.querySelectorAll) return [];
+ return Array.from(root.querySelectorAll('a[href], button, [tabindex]')).filter(isFocusableTvEl);
+ }
+
+ /** Leftmost TV focus stop in spotlight header (skips plain section labels). */
+ function getFirstSpotlightHeaderFocusable(headerCluster) {
+ if (!headerCluster) return null;
+ return getTvFocusables(headerCluster)[0] || null;
+ }
+
+ function focusTvEl(el) {
+ if (!el || typeof el.focus !== 'function') return false;
+ try {
+ el.focus({ preventScroll: true });
+ } catch (_) {
+ el.focus();
+ }
+ return document.activeElement === el;
+ }
+
+ function getMetadataLinkFocusables(metaContainer) {
+ if (!metaContainer) return [];
+ return Array.from(
+ metaContainer.querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ ).filter((el) => el.getAttribute('tabindex') !== '-1' || metaContainer.dataset.metadataDrilled === 'true');
+ }
+
+ function getMetadataRows(metaContainer) {
+ if (!metaContainer) return [];
+ return Array.from(metaContainer.querySelectorAll(':scope > .spotlight-metadata-row'));
+ }
+
+ function setMetadataDrilled(metaContainer, drilled) {
+ if (!metaContainer) return;
+ metaContainer.dataset.metadataDrilled = drilled ? 'true' : 'false';
+ const links = metaContainer.querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ );
+ links.forEach((link) => {
+ if (drilled) {
+ link.classList.add('show-focus');
+ if (link.getAttribute('tabindex') === '-1') link.removeAttribute('tabindex');
+ } else {
+ link.setAttribute('tabindex', '-1');
+ }
+ });
+ if (drilled) {
+ metaContainer.setAttribute('tabindex', '-1');
+ } else {
+ metaContainer.setAttribute('tabindex', '0');
+ metaContainer.classList.add('show-focus');
+ }
+ }
+
+ function collapseAllMetadataDrillIn(root) {
+ root?.querySelectorAll?.('.metadata-container').forEach((meta) => {
+ setMetadataDrilled(meta, false);
+ });
+ }
+
+ /**
+ * Prefer TV button cards when layout-tv is active; otherwise desktop cards.
+ */
+ function createCardElement(
+ item,
+ overflowCard = false,
+ cardFormat = null,
+ customFooterText = null,
+ useParentCard = false,
+ borderStyle = null,
+ cardImageParams = null,
+ ) {
+ if (isTvLayout()) {
+ return buildTvCardElement(item, overflowCard, cardFormat, customFooterText, useParentCard, borderStyle);
+ }
+ return createJellyfinCardElement(
+ item,
+ overflowCard,
+ cardFormat,
+ customFooterText,
+ useParentCard,
+ borderStyle,
+ cardImageParams,
+ );
+ }
+
+ /**
+ * 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.
+ * @param {Object} [options]
+ * @param {boolean} [options.capForViewport=true] - Cap tileCount by viewport width
+ * @returns {{ layout: string, size: string, tileCount: number }}
+ */
+ function resolveSpotlightPresentation(options = {}) {
+ const fullScreen = options.fullScreen === true;
+ const layout = options.spotlightLayout ?? (fullScreen ? 'Borderless' : 'Border');
+ const size = options.spotlightSize ?? (fullScreen ? 'full' : 'normal');
+ let tileCount = options.tileCount;
+ if (tileCount == null || tileCount < 1 || tileCount > 3) {
+ tileCount = size === 'full' ? 1 : size === 'large' ? 2 : 3;
+ }
+ tileCount = Math.max(1, Math.min(3, Math.floor(tileCount)));
+ if (options.capForViewport !== false) {
+ const vw = window.innerWidth || 0;
+ if (vw < 900) tileCount = Math.min(tileCount, 1);
+ else if (vw < 1600) tileCount = Math.min(tileCount, 2);
+ }
+ return { layout, size, tileCount };
+ }
+
+ /**
+ * Creates a Netflix-style slim banner carousel (spotlight)
+ * @param {Array} itemsInput - 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: 5000)
- * @param {boolean} options.showDots - Show dot indicators (default: true)
- * @param {boolean} options.showNavButtons - Show prev/next buttons (default: true)
* @returns {HTMLElement} - The constructed spotlight container
*/
- function createSpotlightSection(items, title, options = {}) {
- if (!items || items.length === 0) {
+ function createSpotlightSection(itemsInput, title, options = {}) {
+ let items = Array.isArray(itemsInput) ? itemsInput.slice() : [];
+ if (!items.length) {
return document.createElement('div');
}
-
+
const {
autoPlay = true,
- interval = 5000,
+ interval = 10000,
+ showSlideState = true,
showDots = true,
showNavButtons = true,
showClearArt = false,
- viewMoreUrl = null
+ panAnimation = true,
+ tileCount: tileCountOpt,
+ cycleBackdrops = false,
+ cycleBackdropsTime,
+ backdropsCount: backdropsCountOpt,
+ entranceAnimationFirst = 'fadeIn',
+ entranceAnimationSecond = 'fadeIn',
+ entranceAnimationThird = 'fadeIn',
+ slideAnimationFirst = 'kenBurnsZoomIn',
+ slideAnimationSecond = 'kenBurnsZoomIn',
+ slideAnimationThird = 'kenBurnsZoomIn',
+ viewMoreUrl = null,
+ initialIndex = 0,
+ /** Build DOM only — no timers/observers (for harvesting slides during in-place refresh). */
+ inert = false,
} = options;
-
+ const { layout, size, tileCount } = resolveSpotlightPresentation({
+ ...options,
+ tileCount: tileCountOpt,
+ });
+ let backdropsCount = backdropsCountOpt;
+ if (backdropsCount == null && cycleBackdropsTime > 0 && interval > 0) {
+ backdropsCount = Math.max(1, Math.round(interval / cycleBackdropsTime));
+ }
+ backdropsCount = Math.max(1, parseInt(backdropsCount, 10) || 1);
+ const perBackdropMs = Math.max(500, Math.round(interval / backdropsCount));
const serverId = ApiClient.serverId();
const serverAddress = ApiClient.serverAddress();
- let currentIndex = 0;
+ const sectionKey = 'spotlight_' + Date.now() + '_' + Math.random().toString(36).slice(2, 9);
+ const cycleBackdropMap = new Map();
+ let currentIndex = Math.max(0, Math.min(items.length - 1, parseInt(initialIndex, 10) || 0));
let autoPlayTimer = null;
+ let cycleBackdropTimer = null;
+ let advanceDue = false;
let isPaused = false;
-
+ let isVisible = true; // updated by Intersection Observer; start true so initial startAutoPlay runs
+ let currentSlideAnimationComplete = false;
+
+ function shuffleArray(arr) {
+ const a = arr.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+ }
+
// Create main container
const container = document.createElement('div');
container.className = 'spotlight-section padded-left';
-
+ // Live toggle target for section configure (setSpotlightAnimation); read via isPanAnimationEnabled()
+ container.dataset.panAnimation = panAnimation !== false ? 'true' : 'false';
+
// Create banner container
const bannerContainer = document.createElement('div');
bannerContainer.className = 'spotlight-banner-container';
-
- // Add section title in top left corner (absolute positioning, aligned with controls)
- if (title) {
- let sectionTitleEl;
-
- if (viewMoreUrl) {
- // Create clickable title
- const titleLink = document.createElement('a');
- titleLink.className = 'spotlight-section-title spotlight-title-link';
- titleLink.textContent = title;
- titleLink.title = 'See All';
- titleLink.style.textDecoration = 'none';
- const cssStyle = document.createElement('style');
- cssStyle.textContent = `
- .spotlight-title-link:hover {
- text-decoration: underline !important;
+ // Header wraps title + nav so TV Left/Right stays in-family (one focuscontainer-x).
+ // Title container hosts section name + refresh/configure controls.
+ // Always create it so controls can attach even when the section has no name.
+ {
+ const headerContainer = document.createElement('div');
+ headerContainer.className = 'spotlight-header-container';
+ if (isTvLayout()) headerContainer.classList.add('focuscontainer-x');
+
+ const sectionTitleContainer = document.createElement('div');
+ sectionTitleContainer.className = 'spotlight-section-title-container';
+
+ if (title) {
+ let sectionTitleEl;
+
+ if (viewMoreUrl) {
+ const titleLink = document.createElement('a');
+ titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground emby-button';
+ 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();
+ viewMoreUrl();
+ });
+ } else {
+ titleLink.href = viewMoreUrl;
+ titleLink.addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
}
- `;
- titleLink.appendChild(cssStyle);
-
- // Handle both URL and function
- if (typeof viewMoreUrl === 'function') {
- titleLink.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
- viewMoreUrl();
- });
+
+ sectionTitleEl = titleLink;
} else {
- titleLink.href = viewMoreUrl;
- titleLink.addEventListener('click', (e) => {
- e.stopPropagation();
- });
+ // Label only — not a focus stop; section-controls / nav are first when entering header
+ sectionTitleEl = document.createElement('div');
+ sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground emby-button';
+ sectionTitleEl.textContent = title;
}
-
- sectionTitleEl = titleLink;
- } else {
- // Regular non-clickable title
- sectionTitleEl = document.createElement('div');
- sectionTitleEl.className = 'spotlight-section-title';
- sectionTitleEl.textContent = title;
+
+ const sectionTitleWrapper = document.createElement('div');
+ sectionTitleWrapper.className = `spotlight-section-title ${viewMoreUrl ? "" : "spotlight-title-link "}headerTabs sectionTabs`;
+ sectionTitleWrapper.appendChild(sectionTitleEl);
+ sectionTitleContainer.appendChild(sectionTitleWrapper);
}
-
- bannerContainer.appendChild(sectionTitleEl);
+
+ headerContainer.appendChild(sectionTitleContainer);
+ bannerContainer.appendChild(headerContainer);
}
-
+
// Create items container (for fade transitions)
const itemsContainer = document.createElement('div');
itemsContainer.className = 'spotlight-items-container';
-
- // Create items
- items.forEach((item, index) => {
- // Get item type early for use throughout
+
+ function applyAnimationToImg(img, entranceName, slideName) {
+ img.style.setProperty('--spotlight-entrance-animation', entranceName);
+ img.style.setProperty('--spotlight-slide-animation', slideName);
+ img.setAttribute('data-entrance-animation', entranceName);
+ img.setAttribute('data-slide-animation', slideName);
+ }
+
+ // Sliding window: render current + 4 prev + 4 next (9 slides). Update DOM as user navigates.
+ let currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length);
+
+ function createAndAppendSlide(item, index, eagerLoadImages) {
const itemType = item.Type || 'Movie';
-
+
const itemDiv = document.createElement('div');
itemDiv.className = 'spotlight-item';
- itemDiv.setAttribute('data-index', index);
- if (index === 0) {
- itemDiv.style.opacity = '1';
- } else {
- itemDiv.style.opacity = '0';
- }
+ itemDiv.setAttribute('data-id', item.Id);
+ itemDiv.setAttribute('data-index', String(index));
+ itemDiv.setAttribute('data-item-type', itemType);
let imageUrl = '';
- if (item.Type === 'Episode' && item.ParentBackdropImageTags?.[0]) {
- // Series backdrop image
- imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ParentBackdropImageTags[0]}`;
- } else if (item.BackdropImageTags?.[0]) {
- imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?fillHeight=450&fillWidth=1920&quality=96&tag=${item.BackdropImageTags[0]}`;
- } else if (item.ImageTags?.Primary) {
- imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?fillHeight=450&fillWidth=1920&quality=96&tag=${item.ImageTags.Primary}`;
+ // Build list of available backdrop sources for this item
+ let backdropSources = [];
+
+ if (item.Type === 'Episode' && Array.isArray(item.ParentBackdropImageTags) && item.ParentBackdropImageTags.length) {
+ backdropSources = item.ParentBackdropImageTags.map((tag) => ({
+ itemId: item.ParentBackdropItemId,
+ tag,
+ }));
+ } else if (Array.isArray(item.BackdropImageTags) && item.BackdropImageTags.length) {
+ backdropSources = item.BackdropImageTags.map((tag) => ({
+ itemId: item.Id,
+ tag,
+ }));
+ } else {
+ // Handle Custom items with static images
+ const customImageOrder = [
+ "backdropUrl",
+ "thumbUrl",
+ "bannerUrl",
+ "squareUrl",
+ "posterUrl",
+ "imageUrl",
+ "logoUrl"
+ ];
+ for (const key of customImageOrder) {
+ const url = item[key];
+ if (typeof url === "string" && url.trim()) {
+ // Use custom URL as only 'backdrop' for static slides
+ backdropSources = [{
+ itemId: null,
+ tag: null,
+ url: url.trim(),
+ key
+ }];
+ break;
+ }
+ }
+ }
+
+ // Set backdrop width based on layout-mobile presence
+ let backdropWidth = 2560;
+ if (document.querySelector('.layout-mobile')) {
+ backdropWidth = 1920;
}
-
- if (imageUrl) {
- itemDiv.style.backgroundImage = `url("${imageUrl}")`;
+
+ const buildBackdropUrl = (src, idx) => src.url ? src.url :
+ `${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?maxWidth=${backdropWidth}&quality=96&tag=${src.tag}`;
+
+ /* Cap layers by available backdrops so we never build dual/triple with undefined sources. */
+ const effectiveTileCount = Math.min(tileCount, Math.max(1, backdropSources.length));
+
+ if (effectiveTileCount >= 2 && backdropSources.length >= 2) {
+ const indices = shuffleArray(backdropSources.map((_, i) => i));
+ const pick = (i) => backdropSources[indices[i]];
+ const dualBackgroundContainer = document.createElement('div');
+ dualBackgroundContainer.className = 'spotlight-background-dual';
+
+ const leftLayer = document.createElement('div');
+ leftLayer.className = 'spotlight-background-layer spotlight-background-layer-left';
+ const leftImg = document.createElement('img');
+ const leftUrl = buildBackdropUrl(pick(0), indices[0]);
+ leftImg.setAttribute('data-src', leftUrl);
+ leftImg.alt = '';
+ leftImg.draggable = false;
+ applyAnimationToImg(leftImg, entranceAnimationFirst, slideAnimationFirst);
+ if (eagerLoadImages) {
+ leftImg.src = leftUrl;
+ leftImg.loading = 'eager';
+ }
+ leftLayer.appendChild(leftImg);
+ dualBackgroundContainer.appendChild(leftLayer);
+
+ if (effectiveTileCount >= 3) {
+ const centerLayer = document.createElement('div');
+ centerLayer.className = 'spotlight-background-layer spotlight-background-layer-center';
+ const centerImg = document.createElement('img');
+ const centerUrl = buildBackdropUrl(pick(1), indices[1]);
+ centerImg.setAttribute('data-src', centerUrl);
+ centerImg.alt = '';
+ centerImg.draggable = false;
+ applyAnimationToImg(centerImg, entranceAnimationSecond, slideAnimationSecond);
+ if (eagerLoadImages) {
+ centerImg.src = centerUrl;
+ centerImg.loading = 'eager';
+ }
+ centerLayer.appendChild(centerImg);
+ dualBackgroundContainer.appendChild(centerLayer);
+ }
+
+ const rightLayer = document.createElement('div');
+ rightLayer.className = 'spotlight-background-layer spotlight-background-layer-right';
+ const rightImg = document.createElement('img');
+ const rightIdx = effectiveTileCount >= 3 ? 2 : 1;
+ const rightUrl = buildBackdropUrl(pick(rightIdx), indices[rightIdx]);
+ rightImg.setAttribute('data-src', rightUrl);
+ rightImg.alt = '';
+ rightImg.draggable = false;
+ applyAnimationToImg(
+ rightImg,
+ effectiveTileCount >= 3 ? entranceAnimationThird : entranceAnimationSecond,
+ effectiveTileCount >= 3 ? slideAnimationThird : slideAnimationSecond,
+ );
+ if (eagerLoadImages) {
+ rightImg.src = rightUrl;
+ rightImg.loading = 'eager';
+ }
+ rightLayer.appendChild(rightImg);
+ dualBackgroundContainer.appendChild(rightLayer);
+ itemDiv.appendChild(dualBackgroundContainer);
+ } else {
+ let singleUrl = '';
+ const shuffled = shuffleArray(backdropSources.map((s, i) => ({ src: s, idx: i })));
+ if (shuffled.length > 0 && shuffled.some(s => s.src.url)) {
+ singleUrl = shuffled.find(s => s.src.url).src.url;
+ } else if (cycleBackdrops && tileCount === 1 && backdropSources.length > 0) {
+ const urls = shuffled.map(
+ ({ src, idx }) =>
+ `${serverAddress}/Items/${src.itemId}/Images/Backdrop/${idx}?maxWidth=${backdropWidth}&quality=96&tag=${src.tag}`,
+ );
+ const mapKey = sectionKey + '_' + item.Id;
+ cycleBackdropMap.set(mapKey, { shuffledUrls: urls, currentIndex: 0 });
+ singleUrl = urls[0];
+ } else if (backdropSources.length) {
+ const first = backdropSources[0];
+ singleUrl = `${serverAddress}/Items/${first.itemId}/Images/Backdrop?maxWidth=${backdropWidth}&quality=96&tag=${first.tag}`;
+ } else if (item.ImageTags?.Primary) {
+ singleUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?maxWidth=${backdropWidth}&quality=96&tag=${item.ImageTags.Primary}`;
+ }
+ if (singleUrl) {
+ const singleBackdropContainer = document.createElement('div');
+ singleBackdropContainer.className =
+ 'spotlight-background-single' +
+ (cycleBackdrops && tileCount === 1 && backdropSources.length > 0 ? ' cycle-backdrops' : '');
+ const singleImg = document.createElement('img');
+ singleImg.setAttribute('data-src', singleUrl);
+ singleImg.alt = '';
+ singleImg.draggable = false;
+ applyAnimationToImg(singleImg, entranceAnimationFirst, slideAnimationFirst);
+ if (eagerLoadImages) {
+ singleImg.src = singleUrl;
+ singleImg.loading = 'eager';
+ }
+ singleBackdropContainer.appendChild(singleImg);
+ if (cycleBackdrops && tileCount === 1 && backdropSources.length > 0) {
+ const singleImgNext = document.createElement('img');
+ singleImgNext.setAttribute('data-src', singleUrl);
+ singleImgNext.alt = '';
+ singleImgNext.draggable = false;
+ singleImgNext.style.opacity = '0';
+ applyAnimationToImg(singleImgNext, entranceAnimationFirst, slideAnimationFirst);
+ singleBackdropContainer.appendChild(singleImgNext);
+ }
+ itemDiv.appendChild(singleBackdropContainer);
+ }
}
-
+
// Create left overlay (40-50% width, dark semi-transparent)
// Add top padding to prevent content from overlapping with section title
const overlay = document.createElement('div');
overlay.className = 'spotlight-overlay' + (title ? ' has-title' : '');
-
+
// Item title or logo
let titleEl = null;
const hasLogo = item.ImageTags?.Logo;
-
+
if (hasLogo) {
// Use logo image instead of text title
- const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?fillHeight=200&quality=96&tag=${item.ImageTags.Logo}`;
- titleEl = document.createElement('img');
+ const logoHeight = size === 'full' ? '300' : size === 'large' ? '250' : '200';
+ const logoUrl = `${serverAddress}/Items/${item.Id}/Images/Logo?maxHeight=${logoHeight}&quality=96&tag=${item.ImageTags.Logo}`;
+ titleEl = document.createElement('div');
titleEl.className = 'spotlight-item-logo';
- titleEl.src = logoUrl;
+ titleEl.setAttribute('data-background-url', logoUrl);
+ if (eagerLoadImages) titleEl.style.backgroundImage = `url("${logoUrl}")`;
titleEl.alt = item.Name || 'Unknown';
} else {
// Use text title as fallback
- titleEl = document.createElement('h3');
+ titleEl = document.createElement('div');
titleEl.className = 'spotlight-item-title';
titleEl.textContent = item.Name || 'Unknown';
}
-
+
+ // Helper: wrap content in native Jellyfin itemDetailsGroup > detailsGroupItem structure
+ // icon: optional Material Icons name (e.g. 'schedule') - same pattern as genres/director/writer
+ function createMetadataItem(content, tag = 'span', extraClasses = '', icon = null) {
+ const group = document.createElement('div');
+ group.className = 'headerTabs sectionTabs emby-tabs-slider';
+ const item = document.createElement(tag);
+ item.className = 'emby-tab-button emby-button-foreground' + (extraClasses ? ' ' + extraClasses : '');
+ if (icon) item.setAttribute('data-icon', icon);
+ if (typeof content === 'string') {
+ item.textContent = content;
+ } else {
+ item.appendChild(content);
+ }
+ group.appendChild(item);
+ return group;
+ }
+
// Combined: Year + Time + Ends At + Genres (all on one line)
const metadataRow = document.createElement('div');
metadataRow.className = 'spotlight-metadata-row';
-
+
// Year
const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null);
if (year) {
- const yearEl = document.createElement('span');
- yearEl.textContent = year;
- metadataRow.appendChild(yearEl);
+ metadataRow.appendChild(createMetadataItem(String(year)));
}
-
- // Runtime
- if (item.RunTimeTicks) {
+
+ // Runtime and estimated end time (Movies only - not for Series/Season/Episode)
+ const isSeriesType = itemType === 'Series' || itemType === 'Season' || itemType === 'Episode';
+ if (!isSeriesType && item.RunTimeTicks) {
const runtimeMinutes = Math.round(item.RunTimeTicks / 10000000 / 60);
const hours = Math.floor(runtimeMinutes / 60);
const minutes = runtimeMinutes % 60;
let runtimeText = '';
if (hours > 0) {
- runtimeText = `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim();
+ runtimeText = `${hours}h ${minutes > 0 ? minutes + "m" : ""}`.trim();
} else {
runtimeText = `${minutes}m`;
}
-
- const runtimeEl = document.createElement('span');
- runtimeEl.textContent = runtimeText;
- metadataRow.appendChild(runtimeEl);
-
+
+ metadataRow.appendChild(createMetadataItem(runtimeText));
+
// End time (when it would end if started now)
const now = new Date();
- // RunTimeTicks is in 100-nanosecond intervals, convert to milliseconds
const runtimeMs = item.RunTimeTicks / 10000;
const endTime = new Date(now.getTime() + runtimeMs);
- const endTimeEl = document.createElement('span');
- endTimeEl.className = 'spotlight-end-time';
- endTimeEl.textContent = `Ends at ${endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
- metadataRow.appendChild(endTimeEl);
+ const endTimeItem = createMetadataItem(
+ endTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
+ 'span',
+ 'spotlight-end-time',
+ 'schedule',
+ );
+ metadataRow.appendChild(endTimeItem);
}
-
- // Genres (on same line) - make clickable if IDs are available
- if (item.Genres && item.Genres.length > 0) {
- const genresContainer = document.createElement('span');
- if (item.GenreItems && item.GenreItems.length > 0) {
- item.GenreItems.forEach((genreItem, index) => {
- const genreName = genreItem.Name;
- const genreId = genreItem.Id;
-
- if (genreName) {
- const genreLink = document.createElement('a');
- genreLink.className = 'spotlight-genre-link';
- genreLink.href = `${serverAddress}/web/#/list.html?genreId=${genreId}&serverId=${serverId}`;
- genreLink.textContent = genreName;
- genreLink.addEventListener('click', (e) => {
- e.stopPropagation();
- });
- if (index > 0) {
- const comma = document.createElement('span');
- comma.textContent = ', ';
- genresContainer.appendChild(comma);
- }
-
- genresContainer.appendChild(genreLink);
- }
- });
- }
-
- if (genresContainer.children.length > 0) {
- metadataRow.appendChild(genresContainer);
+ // Genres - merged into single container, max 3 visible, tooltip shows full list on hover
+ if (item.Genres && item.Genres.length > 0 && item.GenreItems && item.GenreItems.length > 0) {
+ const genreContainer = createTruncatedList('', item.GenreItems, 3, false, 'theater_comedy', 'genre');
+ if (genreContainer) {
+ metadataRow.appendChild(genreContainer);
}
}
-
- // Helper function to create truncated list that expands on ellipsis hover
+
+ // Helper function to create truncated list with tooltip for overflow items
// items can be array of strings (names) or array of objects with Name and Id
- function createTruncatedList(label, items, maxItems = 3, isPeople = false) {
+ // icon: optional Material Icons name for full-screen display (e.g. 'movie', 'edit')
+ // linkType: 'person' | 'genre' | 'studio' | null - when set, create links
+ function createTruncatedList(label, items, maxItems = 3, isPeople = false, icon = null, linkType = null) {
const container = document.createElement('div');
- container.className = 'spotlight-truncated-list';
+ container.className = 'spotlight-truncated-list headerTabs sectionTabs emby-tabs-slider';
+ container.setAttribute('data-label', label);
+ if (icon) container.setAttribute('data-icon', icon);
if (!items || items.length === 0) {
return container;
}
-
+
const displayContainer = document.createElement('span');
- const labelSpan = document.createElement('span');
- labelSpan.textContent = `${label} `;
- displayContainer.appendChild(labelSpan);
-
+ displayContainer.className = 'emby-tab-button emby-button-foreground';
+ if (icon) displayContainer.setAttribute('data-icon', icon);
+ if (label) {
+ const labelSpan = document.createElement('span');
+ labelSpan.className = 'spotlight-truncated-list-label';
+ labelSpan.textContent = `${label} `;
+ displayContainer.appendChild(labelSpan);
+ }
+
// Extract names and IDs from items (handle both strings and objects)
- const itemsData = items.map(item => {
- if (typeof item === 'string') {
- return { name: item, id: null };
- } else if (item && typeof item === 'object') {
- return {
- name: item.Name || item.name || null,
- id: item.Id || item.id || null
- };
- }
- return container;
- }).filter(item => item && item.name);
-
+ const itemsData = items
+ .map((item) => {
+ if (typeof item === 'string') {
+ return { name: item, id: null };
+ } else if (item && typeof item === 'object') {
+ return {
+ name: item.Name || item.name || null,
+ id: item.Id || item.id || null,
+ };
+ }
+ return container;
+ })
+ .filter((item) => item && item.name);
+
if (itemsData.length === 0) return null;
-
+
const hasMore = itemsData.length > maxItems;
-
- // Store references to hidden elements for easy show/hide
- const hiddenElements = [];
-
- // Create spans for all names (but initially hide the ones beyond maxItems)
+
+ // Resolve link type: explicit linkType, or fall back to isPeople for person links
+ const usePersonLinks = linkType === 'person' || (linkType !== 'genre' && linkType !== 'studio' && isPeople);
+ const useGenreLinks = linkType === 'genre';
+ const useStudioLinks = linkType === 'studio';
+
+ // Create spans for visible names only (no layout-shifting expand)
itemsData.forEach((itemData, index) => {
+ if (index >= maxItems) return;
let nameElement;
-
- // Create link if it's a person with an ID, otherwise just text
- if (isPeople && itemData.id) {
+
+ if (usePersonLinks && itemData.id) {
nameElement = document.createElement('a');
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();
});
+ } else if (useGenreLinks && itemData.id) {
+ nameElement = document.createElement('a');
+ 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');
nameElement.textContent = itemData.name;
}
-
+
nameElement.setAttribute('data-name-index', index);
-
- // Hide names beyond maxItems initially
- if (index >= maxItems) {
- nameElement.style.display = 'none';
- hiddenElements.push(nameElement);
- }
-
+
displayContainer.appendChild(nameElement);
-
- // Add comma separator (except for last item)
- if (index < itemsData.length - 1) {
- const comma = document.createElement('span');
- comma.textContent = ', ';
- comma.setAttribute('data-comma-after', index);
-
- // Hide comma if it's after the last visible item
- if (index >= maxItems - 1) {
- comma.style.cssText = 'display: none;';
- hiddenElements.push(comma);
- }
- displayContainer.appendChild(comma);
+
+ // Bullet separator (except for last visible item - use actual count when fewer than maxItems)
+ const lastVisibleIndex = Math.min(maxItems, itemsData.length) - 1;
+ if (index < lastVisibleIndex) {
+ const separator = document.createElement('span');
+ separator.className = 'spotlight-list-separator';
+ separator.textContent = '•';
+ displayContainer.appendChild(separator);
}
});
-
- // Add ellipsis as separate hoverable element if there are more items
- if (hasMore) {
- const ellipsisElement = document.createElement('span');
- ellipsisElement.className = 'spotlight-ellipsis';
- ellipsisElement.textContent = '...';
- displayContainer.appendChild(ellipsisElement);
-
- let isExpanded = false;
-
- // On ellipsis hover, reveal all hidden names and hide ellipsis
- ellipsisElement.addEventListener('mouseenter', () => {
- if (!isExpanded) {
- // Show all hidden elements
- hiddenElements.forEach(el => {
- el.style.display = '';
- });
- ellipsisElement.style.display = 'none';
- isExpanded = true;
+
+ // 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';
+ if (label) {
+ const labelSpan = document.createElement('span');
+ labelSpan.className = 'spotlight-truncated-list-tooltip-label';
+ labelSpan.textContent = label + ' ';
+ tooltip.appendChild(labelSpan);
+ }
+ const listWrapper = document.createElement('div');
+ listWrapper.className = 'spotlight-truncated-list-tooltip-items';
+ itemsData.forEach((itemData) => {
+ const itemRow = document.createElement('div');
+ itemRow.className = 'spotlight-truncated-list-tooltip-item';
+ let linkEl;
+ if (usePersonLinks && itemData.id) {
+ linkEl = document.createElement('a');
+ linkEl.href = `${serverAddress}/web/#/details?id=${itemData.id}&serverId=${serverId}`;
+ linkEl.className = 'spotlight-person-link';
+ linkEl.textContent = itemData.name;
+ linkEl.addEventListener('click', (e) => e.stopPropagation());
+ } else if (useGenreLinks && itemData.id) {
+ linkEl = document.createElement('a');
+ linkEl.href = `${serverAddress}/web/#/list.html?genreId=${itemData.id}&serverId=${serverId}`;
+ linkEl.className = 'spotlight-genre-link';
+ linkEl.textContent = itemData.name;
+ linkEl.addEventListener('click', (e) => e.stopPropagation());
+ } else if (useStudioLinks && itemData.id) {
+ linkEl = document.createElement('a');
+ linkEl.href = `${serverAddress}/web/#/list.html?studioId=${itemData.id}&serverId=${serverId}`;
+ linkEl.className = 'spotlight-studio-link';
+ linkEl.textContent = itemData.name;
+ linkEl.addEventListener('click', (e) => e.stopPropagation());
+ } else {
+ linkEl = document.createElement('span');
+ linkEl.textContent = itemData.name;
+ }
+ itemRow.appendChild(linkEl);
+ listWrapper.appendChild(itemRow);
+ });
+ tooltip.appendChild(listWrapper);
+ container.appendChild(tooltip);
+ container.style.position = 'relative';
+
+ const TOOLTIP_DELAY_MS = 600;
+ const HIDE_DELAY_MS = 150;
+ let tooltipTimer = null;
+ let hideTimer = null;
+
+ const showTooltip = () => {
+ tooltip.classList.add('visible');
+ tooltipTimer = null;
+ };
+ const hideTooltip = () => {
+ tooltip.classList.remove('visible');
+ if (tooltipTimer) {
+ clearTimeout(tooltipTimer);
+ tooltipTimer = null;
+ }
+ if (hideTimer) {
+ clearTimeout(hideTimer);
+ hideTimer = null;
+ }
+ };
+ const scheduleHide = () => {
+ if (hideTimer) clearTimeout(hideTimer);
+ hideTimer = setTimeout(hideTooltip, HIDE_DELAY_MS);
+ };
+
+ container.addEventListener('mouseenter', () => {
+ if (hideTimer) {
+ clearTimeout(hideTimer);
+ hideTimer = null;
+ }
+ tooltipTimer = setTimeout(showTooltip, TOOLTIP_DELAY_MS);
+ });
+ container.addEventListener('mouseleave', () => {
+ scheduleHide();
+ });
+ tooltip.addEventListener('mouseenter', () => {
+ if (hideTimer) {
+ clearTimeout(hideTimer);
+ hideTimer = null;
}
});
-
- // Keep expanded state when mouse moves within the container
- // Only collapse when mouse leaves the entire container
- container.addEventListener('mouseleave', () => {
- if (isExpanded) {
- // Hide names beyond maxItems again
- hiddenElements.forEach(el => {
- el.style.display = 'none';
- });
- ellipsisElement.style.display = '';
- isExpanded = false;
- }
+ tooltip.addEventListener('mouseleave', () => {
+ hideTooltip();
});
}
-
+
container.appendChild(displayContainer);
return container;
}
-
+
+ // Studios - show 1 visible, rest in tooltip with links (like directors/writers)
+ const studiosData =
+ Array.isArray(item.Studios) && item.Studios.length > 0
+ ? item.Studios
+ : typeof item.Studio === 'string' && item.Studio
+ ? item.Studio.split('|')
+ .map((s) => ({ Name: s.trim(), Id: null }))
+ .filter((x) => x.Name)
+ : [];
+ const studioContainer =
+ studiosData.length > 0 ? createTruncatedList('Produced by', studiosData, 1, false, 'apartment', 'studio') : null;
+
// For Series: show seasons/episodes. For Movies: show director/writer
let directorContainer = null;
let writerContainer = null;
let combinedContainer = null;
- let seriesInfoContainer = null;
-
- // Create series info container
- seriesInfoContainer = document.createElement('div');
- seriesInfoContainer.className = 'spotlight-truncated-list';
-
+
+ // Create series info container (seasons/episodes count)
+ const seriesInfoContainer = document.createElement('div');
+ seriesInfoContainer.className = 'spotlight-metadata-row spotlight-series-info';
+
if (itemType === 'Series') {
+ // Studio first (before seasons/episodes)
+ if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Series, show seasons and episodes count
const seasonsCount = item.ChildCount || 0;
-
- // Initial text with seasons count
- const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? 'season' : 'seasons'}`;
- seriesInfoContainer.textContent = seasonsText;
-
- // Fetch episode count asynchronously and update
- (async () => {
- try {
- const episodesCount = item.RecursiveItemCount || 0;
-
- // Update the series info container with episode count
- if (seriesInfoContainer) {
- const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
- seriesInfoContainer.textContent = `${seasonsText} - ${episodesText}`;
- }
- } catch (err) {
- console.error('Failed to fetch episode count:', err);
- // Keep just the seasons text if episode fetch fails
- }
- })();
+ if (seasonsCount > 1) {
+ const seasonsText = `${seasonsCount} ${seasonsCount === 1 ? "season" : "seasons"}`;
+ seriesInfoContainer.appendChild(createMetadataItem(seasonsText));
+ }
+ const episodesCount = item.RecursiveItemCount || 0;
+ if (episodesCount > 0) {
+ const episodesText = `${episodesCount} ${episodesCount === 1 ? "episode" : "episodes"}`;
+ seriesInfoContainer.appendChild(createMetadataItem(episodesText));
+ }
} else if (itemType === 'Movie') {
// For Movies, show director/writer (existing logic)
if (item.People && Array.isArray(item.People)) {
- const directors = item.People.filter(p => p.Type === 'Director');
- const writers = item.People.filter(p => p.Type === 'Writer');
-
+ const directors = item.People.filter((p) => p.Type === 'Director');
+ const writers = item.People.filter((p) => p.Type === 'Writer');
+
// Extract names for comparison
- const directorNames = directors.map(p => p.Name);
- const writerNames = writers.map(p => p.Name);
-
+ const directorNames = directors.map((p) => p.Name);
+ const writerNames = writers.map((p) => p.Name);
+
// Check if directors and writers are identical (same length and same names)
- const areIdentical = directorNames.length === writerNames.length &&
- directorNames.length > 0 &&
- directorNames.every((name, index) => name === writerNames[index]);
-
+ const areIdentical =
+ directorNames.length === writerNames.length &&
+ directorNames.length > 0 &&
+ directorNames.every((name, index) => name === writerNames[index]);
+
if (areIdentical) {
// Combine into single "Directed and Written by" row (with person objects for links)
- combinedContainer = createTruncatedList('Directed and Written by', directors, 3, true);
+ combinedContainer = createTruncatedList('Directed and Written by', directors, 1, true, 'movie');
// Add empty writer container to maintain spacing
writerContainer = document.createElement('div');
writerContainer.className = 'spotlight-empty-writer-container';
} else {
// Show separately (with person objects for links)
- directorContainer = createTruncatedList('Directed by', directors, 3, true);
- writerContainer = createTruncatedList('Written by', writers, 3, true);
+ if (directors.length > 0) {
+ directorContainer = createTruncatedList('Directed by', directors, 1, true, 'movie');
+ }
+ if (writers.length > 0) {
+ writerContainer = createTruncatedList('Written by', writers, 1, true, 'edit');
+ }
}
}
} else if (itemType === 'Season') {
+ // Studio first (before episodes)
+ if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Seasons, show episodes count
const episodesCount = item.RecursiveItemCount || 0;
- const episodesText = `${episodesCount} ${episodesCount === 1 ? 'episode' : 'episodes'}`;
- seriesInfoContainer.textContent = episodesText;
+ const episodesText = `${episodesCount} ${episodesCount === 1 ? "episode" : "episodes"}`;
+ seriesInfoContainer.appendChild(createMetadataItem(episodesText));
} else if (itemType === 'Episode') {
+ // Studio first (before season/episode)
+ if (studioContainer) seriesInfoContainer.appendChild(studioContainer);
// For Episodes, show episode number
const seasonNumber = item.ParentIndexNumber || 0;
const seasonText = `Season ${seasonNumber}`;
const episodeNumber = item.IndexNumber || 0;
const episodeText = `Episode ${episodeNumber}`;
- seriesInfoContainer.textContent = `${seasonText} - ${episodeText}`;
+ seriesInfoContainer.appendChild(createMetadataItem(`${seasonText} - ${episodeText}`));
}
-
- // Taglines for Movies, Overview for Series
- let taglineEl = null;
- taglineEl = document.createElement('p');
- taglineEl.className = 'spotlight-tagline';
- if (itemType === 'Series') {
- // For Series, show Overview instead of Tagline
- if (item.Overview) {
- taglineEl.textContent = item.Overview;
- }
- } else {
- // For Movies, show Tagline (if available)
- if (item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0) {
- taglineEl.textContent = item.Taglines[0]; // Show first tagline
- } else if (item.Overview) {
- taglineEl.textContent = item.Overview;
- }
- }
-
+
+ // Tagline (tagline only) and Overview (separate element) - show/hide via CSS based on spotlight type
+ const taglineEl = document.createElement('p');
+ taglineEl.className = 'emby-tab-button emby-tab-button-active';
+ const tagline = item.Taglines && Array.isArray(item.Taglines) && item.Taglines.length > 0 ? item.Taglines[0] : '';
+ if (tagline) taglineEl.textContent = tagline;
+
+ const overviewEl = document.createElement('p');
+ overviewEl.className = 'spotlight-overview';
+ if (item.Overview) overviewEl.textContent = item.Overview;
+
// Buttons container
const buttonsContainer = document.createElement('div');
- buttonsContainer.className = 'spotlight-buttons-container';
-
+ 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 = 'spotlight-play-button emby-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';
@@ -1068,115 +6147,104 @@
console.error('[KefinTweaks CardBuilder] Error playing item:', error);
}
});
-
- // Watchlist button (material icon span button with bookmark icon)
- const watchlistButton = document.createElement('button');
- watchlistButton.className = 'spotlight-watchlist-button emby-button';
-
- // Check watchlist status - try to get from cache or API
- let isInWatchlist = false;
- const sectionName = itemType === 'Movie' ? 'movies' :
- itemType === 'Series' ? 'series' :
- itemType === 'Season' ? 'seasons' :
- itemType === 'Episode' ? 'episodes' : null;
-
- // Check watchlist cache if available
- if (sectionName && typeof window.watchlistCache !== 'undefined' && window.watchlistCache[sectionName]?.data) {
- isInWatchlist = window.watchlistCache[sectionName].data.some(watchlistItem => watchlistItem.Id === item.Id);
- }
-
- const watchlistIcon = document.createElement('span');
- watchlistIcon.className = 'material-icons';
- watchlistIcon.textContent = 'bookmark';
- watchlistButton.appendChild(watchlistIcon);
- if (isInWatchlist) {
- watchlistButton.classList.add('watchlisted');
- }
- watchlistButton.addEventListener('click', async (e) => {
- e.stopPropagation();
- const userId = ApiClient.getCurrentUserId();
- const newStatus = !isInWatchlist;
-
- try {
- // Update watchlist status using proper API
- await ApiClient.updateUserItemRating(userId, item.Id, newStatus ? 'true' : 'false');
-
- // Update local state
- isInWatchlist = newStatus;
-
- // Update button class
- if (isInWatchlist) {
- watchlistButton.classList.add('watchlisted');
- } else {
- watchlistButton.classList.remove('watchlisted');
- }
-
- // Update watchlist cache if available
- if (sectionName && typeof window.updateWatchlistCacheOnToggle === 'function') {
- await window.updateWatchlistCacheOnToggle(item.Id, itemType, isInWatchlist);
- }
- } catch (err) {
- console.error('Failed to update watchlist status:', err);
+
+ // Watchlist button (same Likes / watchlist-button pattern as cards; clicks via watchlist.js)
+ const spotlightWatchlistSupportedTypes = ['Movie', 'Series', 'Season', 'Episode', 'BoxSet', 'Playlist', 'Video'];
+ let watchlistButton = null;
+ if (state.watchlist && spotlightWatchlistSupportedTypes.includes(itemType)) {
+ const isLiked = item.UserData?.Likes === true;
+ 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');
+ watchlistButton.title = isLiked ? 'Remove from Watchlist' : 'Add to Watchlist';
+
+ const watchlistIcon = document.createElement('span');
+ watchlistIcon.className = 'material-icons watchlist';
+ watchlistIcon.setAttribute('aria-hidden', 'true');
+ watchlistButton.appendChild(watchlistIcon);
+
+ if (typeof window.KefinWatchlistLikedIds?.bindWatchlistButton === 'function') {
+ window.KefinWatchlistLikedIds.bindWatchlistButton(watchlistButton, item.Id, itemType);
}
- });
-
+ }
+
// Info button (shows overview on hover)
const infoButton = document.createElement('button');
- infoButton.className = 'spotlight-info-button emby-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 = 'View Details';
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;
infoButton.appendChild(overviewTooltip);
}
-
+
buttonsContainer.appendChild(playButton);
- buttonsContainer.appendChild(watchlistButton);
+ if (watchlistButton) {
+ buttonsContainer.appendChild(watchlistButton);
+ }
buttonsContainer.appendChild(infoButton);
-
+
// Rating with star icon
const rating = item.CommunityRating || item.CriticRating;
if (rating && typeof rating === 'number') {
const ratingContainer = document.createElement('div');
- ratingContainer.className = 'spotlight-rating-container';
-
+ ratingContainer.className = 'emby-button button-flat starRatingContainer mediaInfoItem spotlight-rating-container';
+
const starIcon = document.createElement('span');
- starIcon.className = 'material-icons spotlight-rating-star';
- starIcon.textContent = 'star';
-
- const ratingValue = document.createElement('span');
- ratingValue.className = 'spotlight-rating-value';
+ starIcon.className = 'material-icons starIcon star spotlight-rating-star';
+
if (rating && typeof rating === 'number') {
- ratingValue.textContent = rating.toFixed(1);
- } else {
- ratingValue.textContent = 'N/A';
- ratingValue.classList.add('na');
+ ratingContainer.innerText = rating.toFixed(1);
+ ratingContainer.prepend(starIcon);
+ buttonsContainer.appendChild(ratingContainer);
}
-
- ratingContainer.appendChild(starIcon);
- ratingContainer.appendChild(ratingValue);
- buttonsContainer.appendChild(ratingContainer);
}
-
+
// Build overlay content in order: Name, Rating, Year+Time+EndsAt+Genres, Directed by, Written by, Taglines, Buttons
overlay.appendChild(titleEl);
- const metadataContainer = document.createElement('div');
- metadataContainer.className = 'metadata-container';
+ // TV: real emby-button so Jellyfin spatial nav lands on metadata (plain div is Tab-only)
+ const metadataContainer = isTvLayout()
+ ? document.createElement('button')
+ : document.createElement('div');
+ metadataContainer.className = isTvLayout()
+ ? 'metadata-container emby-button show-focus'
+ : 'metadata-container';
+ if (isTvLayout()) {
+ metadataContainer.type = 'button';
+ metadataContainer.setAttribute('tabindex', '0');
+ metadataContainer.setAttribute('aria-label', 'Item metadata');
+ metadataContainer.dataset.metadataDrilled = 'false';
+ // Collapsed click must not navigate; drill-in is Right/Enter only
+ metadataContainer.addEventListener('click', (e) => {
+ if (metadataContainer.dataset.metadataDrilled !== 'true') {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ });
+ }
+ if (isTvLayout()) metadataRow.classList.add('focuscontainer-x');
if (metadataRow.children.length > 0) {
metadataContainer.appendChild(metadataRow);
}
// For Series, show seasons/episodes. For Movies, show director/writer
if (itemType === 'Series' || itemType === 'Season' || itemType === 'Episode') {
if (seriesInfoContainer) {
+ if (isTvLayout()) seriesInfoContainer.classList.add('focuscontainer-x');
metadataContainer.appendChild(seriesInfoContainer);
}
} else {
@@ -1187,260 +6255,2238 @@
emptyWriterContainer.className = 'spotlight-empty-writer-container';
if (combinedContainer) {
- metadataContainer.appendChild(combinedContainer);
- metadataContainer.appendChild(emptyWriterContainer);
- } else {
- if (directorContainer) {
- metadataContainer.appendChild(directorContainer);
- }
-
- if (writerContainer) {
- metadataContainer.appendChild(writerContainer);
- if (!directorContainer) {
- metadataContainer.appendChild(emptyDirectorContainer);
- }
- } else {
- if (!directorContainer) {
- metadataContainer.appendChild(emptyDirectorContainer);
- }
- metadataContainer.appendChild(emptyWriterContainer);
- }
+ const creditsRow = document.createElement('div');
+ creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
+ if (isTvLayout()) creditsRow.classList.add('focuscontainer-x');
+ if (studioContainer) creditsRow.appendChild(studioContainer);
+ creditsRow.appendChild(combinedContainer);
+ metadataContainer.appendChild(creditsRow);
+ } else if (directorContainer || writerContainer) {
+ const creditsRow = document.createElement('div');
+ creditsRow.className = 'spotlight-metadata-row spotlight-credits-row';
+ if (isTvLayout()) creditsRow.classList.add('focuscontainer-x');
+ if (studioContainer) creditsRow.appendChild(studioContainer);
+ if (directorContainer) creditsRow.appendChild(directorContainer);
+ if (writerContainer) creditsRow.appendChild(writerContainer);
+ metadataContainer.appendChild(creditsRow);
+ } else if (studioContainer) {
+ const studioRow = document.createElement('div');
+ studioRow.className = 'spotlight-metadata-row spotlight-credits-row';
+ if (isTvLayout()) studioRow.classList.add('focuscontainer-x');
+ studioRow.appendChild(studioContainer);
+ metadataContainer.appendChild(studioRow);
}
}
- if (metadataContainer.children.length > 0) {
- overlay.appendChild(metadataContainer);
+ if (isTvLayout()) {
+ // Collapsed by default: links not in vertical focus path until Right/OK drill-in
+ setMetadataDrilled(metadataContainer, false);
}
- if (taglineEl) {
- overlay.appendChild(taglineEl);
- }
- overlay.appendChild(buttonsContainer);
-
+ // Fixed 4-section grid: Logo, Metadata, Overview, Buttons (always render all to prevent layout shift)
+ const logoSection = document.createElement('div');
+ logoSection.className = 'spotlight-logo-section';
+ logoSection.appendChild(titleEl);
+
+ const metadataSection = document.createElement('div');
+ metadataSection.className = 'spotlight-metadata-section';
+ metadataSection.appendChild(metadataContainer);
+
+ const overviewSection = document.createElement('div');
+ overviewSection.className = 'spotlight-overview-section';
+ if (overviewEl.textContent) overviewSection.appendChild(overviewEl);
+
+ const buttonsSection = document.createElement('div');
+ buttonsSection.className = 'spotlight-buttons-section';
+ buttonsSection.appendChild(buttonsContainer);
+
+ overlay.appendChild(logoSection);
+ overlay.appendChild(metadataSection);
+ overlay.appendChild(overviewSection);
+ overlay.appendChild(buttonsSection);
+
itemDiv.appendChild(overlay);
-
+ if (taglineEl.textContent) {
+ const taglineContainer = document.createElement('div');
+ taglineContainer.className = 'spotlight-tagline headerTabs sectionTabs';
+ taglineContainer.appendChild(taglineEl);
+ itemDiv.appendChild(taglineContainer);
+ }
+
// ClearArt image (bottom right corner, if enabled and available)
if (showClearArt && item.ImageTags?.Art) {
const clearArtUrl = `${serverAddress}/Items/${item.Id}/Images/Art?fillHeight=300&quality=96&tag=${item.ImageTags.Art}`;
const clearArtEl = document.createElement('img');
clearArtEl.className = 'spotlight-clearart';
- clearArtEl.src = clearArtUrl;
+ clearArtEl.setAttribute('data-src', clearArtUrl);
+ if (eagerLoadImages) clearArtEl.src = clearArtUrl;
clearArtEl.alt = item.Name || 'Unknown';
itemDiv.appendChild(clearArtEl);
}
-
- // Click to navigate (but not on buttons)
- infoButton.addEventListener('click', (e) => {
- window.location.href = `${serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`;
- });
-
+
itemsContainer.appendChild(itemDiv);
- });
-
- bannerContainer.appendChild(itemsContainer);
-
- // Navigation buttons (top right)
- if (showNavButtons && items.length > 1) {
- const navContainer = document.createElement('div');
- navContainer.className = 'spotlight-nav-container';
-
- const prevButton = document.createElement('button');
- prevButton.className = 'spotlight-nav-button spotlight-nav-prev emby-button';
- const prevIcon = document.createElement('span');
- prevIcon.className = 'material-icons';
- prevIcon.textContent = 'chevron_left';
- prevButton.appendChild(prevIcon);
- prevButton.addEventListener('click', (e) => {
- e.stopPropagation();
- // Reset timer when manually navigating
- goToItem((currentIndex - 1 + items.length) % items.length, true);
- });
-
- const nextButton = document.createElement('button');
- nextButton.className = 'spotlight-nav-button spotlight-nav-next emby-button';
- const nextIcon = document.createElement('span');
- nextIcon.className = 'material-icons';
- nextIcon.textContent = 'chevron_right';
- nextButton.appendChild(nextIcon);
- nextButton.addEventListener('click', (e) => {
- e.stopPropagation();
- // Reset timer when manually navigating
- goToItem((currentIndex + 1) % items.length, true);
- });
-
- navContainer.appendChild(prevButton);
- navContainer.appendChild(nextButton);
- bannerContainer.appendChild(navContainer);
- }
-
- // Pause button (bottom right)
- if (autoPlay && items.length > 1) {
- const navContainer = bannerContainer.querySelector('.spotlight-nav-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) {
- clearInterval(autoPlayTimer);
- pauseIcon.textContent = 'play_arrow';
- } else {
- startAutoPlay();
- pauseIcon.textContent = 'pause';
- }
- });
- navContainer.insertBefore(pauseButton, navContainer.firstChild);
- }
-
- // Dot indicators
- if (showDots && items.length > 1) {
- const dotsContainer = document.createElement('div');
- dotsContainer.className = 'spotlight-dots';
-
- items.forEach((_, index) => {
- const dot = document.createElement('button');
- dot.className = 'spotlight-dot' + (index === 0 ? ' active' : '');
- dot.setAttribute('data-index', index);
- dot.addEventListener('click', (e) => {
- e.stopPropagation();
- // Reset timer when manually navigating
- goToItem(index, true);
- });
- dotsContainer.appendChild(dot);
- });
-
- bannerContainer.appendChild(dotsContainer);
- }
-
- // Go to item function (fade transition)
- function goToItem(index, resetTimer = true) {
- if (index === currentIndex) return;
-
- const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
- const nextItem = bannerContainer.querySelector(`.spotlight-item[data-index="${index}"]`);
-
- if (currentItem && nextItem) {
- currentItem.style.opacity = '0';
- nextItem.style.opacity = '1';
- }
-
- currentIndex = index;
-
- // Update dots
- if (showDots) {
- const dots = bannerContainer.querySelectorAll('.spotlight-dot');
- dots.forEach((dot, i) => {
- if (i === index) {
- dot.classList.add('active');
- } else {
- dot.classList.remove('active');
- }
- });
- }
-
- // Reset auto-play timer when manually navigating (always reset delay)
- if (resetTimer && autoPlay && !isPaused) {
- clearInterval(autoPlayTimer);
- autoPlayTimer = null;
- startAutoPlay();
- }
- }
-
- // Auto-play function
- function startAutoPlay() {
- // Always clear any existing timer first
- if (autoPlayTimer) {
- clearInterval(autoPlayTimer);
- autoPlayTimer = null;
- }
-
- if (!autoPlay || items.length <= 1 || isPaused) return;
-
- autoPlayTimer = setInterval(() => {
- // Don't reset timer when auto-advancing (to maintain interval)
- goToItem((currentIndex + 1) % items.length, false);
- }, interval);
- }
-
- // Pause on hover - prevent auto-cycling when hovering
- let isHovering = false;
- bannerContainer.addEventListener('mouseenter', () => {
- isHovering = true;
- if (autoPlayTimer) {
- clearInterval(autoPlayTimer);
- autoPlayTimer = null;
- }
- });
-
- bannerContainer.addEventListener('mouseleave', () => {
- isHovering = false;
- // Only restart if auto-play is enabled, not paused, and not hovering
- if (autoPlay && !isPaused && !isHovering) {
- startAutoPlay();
- }
- });
-
- // Start auto-play
- if (autoPlay) {
- startAutoPlay();
+ return itemDiv;
}
- // Touch swipe handling
- let touchStartX = 0;
- let touchStartY = 0;
-
- bannerContainer.addEventListener('touchstart', (e) => {
- touchStartX = e.touches[0].clientX;
- touchStartY = e.touches[0].clientY;
- }, { passive: true });
-
- bannerContainer.addEventListener('touchmove', (e) => {
- if (!e.touches[0]) return;
-
- const touchX = e.touches[0].clientX;
- const touchY = e.touches[0].clientY;
-
- const deltaX = touchX - touchStartX;
- const deltaY = touchY - touchStartY;
-
- // If horizontal movement is dominant, prevent vertical scrolling and propagation
- if (Math.abs(deltaX) > Math.abs(deltaY)) {
- if (e.cancelable) {
+ // Initial render: create slides for window around currentIndex
+ currentWindowIndices.forEach((idx) => {
+ createAndAppendSlide(items[idx], idx, idx === currentIndex);
+ });
+
+ bannerContainer.appendChild(itemsContainer);
+
+ // Mark the initially visible slide as active so only it is shown
+ const firstItemActive = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
+ if (firstItemActive) {
+ firstItemActive.setAttribute('data-active', 'true');
+ }
+
+ function syncSpotlightTvFocus() {
+ if (!isTvLayout()) return;
+ const isTooltipFocusable = (el) =>
+ !!el.closest?.('.spotlight-truncated-list-tooltip, .spotlight-overview-tooltip');
+ const isMetaLink = (el) =>
+ el.classList?.contains('spotlight-genre-link') ||
+ el.classList?.contains('spotlight-person-link') ||
+ el.classList?.contains('spotlight-studio-link');
+ bannerContainer.querySelectorAll('.spotlight-item').forEach((slide) => {
+ const active = slide.getAttribute('data-active') === 'true';
+ const meta = slide.querySelector('.metadata-container');
+ const drilled = meta?.dataset?.metadataDrilled === 'true';
+
+ slide.querySelectorAll('a[href], button').forEach((el) => {
+ if (el.classList.contains('spotlight-dot') || isTooltipFocusable(el)) {
+ el.setAttribute('tabindex', '-1');
+ return;
+ }
+ if (!active) {
+ el.setAttribute('tabindex', '-1');
+ return;
+ }
+ // Active slide: metadata links gated by drill-in
+ if (isMetaLink(el)) {
+ if (drilled) {
+ if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
+ } else {
+ el.setAttribute('tabindex', '-1');
+ }
+ return;
+ }
+ if (el.getAttribute('tabindex') === '-1') el.removeAttribute('tabindex');
+ });
+
+ if (meta) {
+ if (!active) {
+ meta.setAttribute('tabindex', '-1');
+ } else if (drilled) {
+ meta.setAttribute('tabindex', '-1');
+ } else {
+ meta.setAttribute('tabindex', '0');
+ meta.classList.add('show-focus');
+ }
+ }
+ });
+ bannerContainer.querySelectorAll('.spotlight-dot').forEach((dot) => {
+ dot.setAttribute('tabindex', '-1');
+ });
+ }
+
+ function captureSpotlightFocusSlot() {
+ if (!isTvLayout()) return null;
+ const ae = document.activeElement;
+ if (!ae || !container.contains(ae)) return null;
+ if (ae.closest('.spotlight-section-title-container')) return { slot: 'title' };
+ if (ae.closest('.spotlight-nav-container')) return { slot: 'nav' };
+ const meta = ae.closest('.metadata-container');
+ if (meta && container.contains(meta)) {
+ if (meta.dataset.metadataDrilled === 'true') {
+ const links = Array.from(
+ meta.querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ return { slot: 'metadata-drilled', linkIndex: Math.max(0, links.indexOf(ae)) };
+ }
+ return { slot: 'metadata' };
+ }
+ const btn = ae.closest('.spotlight-buttons-container');
+ if (btn && container.contains(btn)) {
+ let role = 'play';
+ if (ae.classList.contains('watchlist-button') || ae.closest('.watchlist-button')) role = 'watchlist';
+ else if (ae.classList.contains('spotlight-info-button') || ae.closest('.spotlight-info-button'))
+ role = 'info';
+ else if (ae.classList.contains('btnPlay') || ae.closest('.btnPlay')) role = 'play';
+ return { slot: 'buttons', role };
+ }
+ return null;
+ }
+
+ function restoreSpotlightFocusSlot(slot) {
+ if (!slot || !isTvLayout()) return;
+ const activeSlide = bannerContainer.querySelector('.spotlight-item[data-active="true"]');
+ if (slot.slot === 'title') {
+ const headerCluster = bannerContainer.querySelector('.spotlight-header-container');
+ const first = getFirstSpotlightHeaderFocusable(headerCluster);
+ if (first) focusTvEl(first);
+ return;
+ }
+ if (slot.slot === 'nav') {
+ const nav = bannerContainer.querySelector('.spotlight-nav-buttons-container');
+ const first = getTvFocusables(nav)[0];
+ if (first) focusTvEl(first);
+ return;
+ }
+ if (!activeSlide) return;
+ if (slot.slot === 'metadata') {
+ collapseAllMetadataDrillIn(activeSlide);
+ const meta = activeSlide.querySelector('.metadata-container');
+ if (meta) {
+ setMetadataDrilled(meta, false);
+ focusTvEl(meta);
+ }
+ return;
+ }
+ if (slot.slot === 'metadata-drilled') {
+ const meta = activeSlide.querySelector('.metadata-container');
+ if (!meta) return;
+ setMetadataDrilled(meta, true);
+ const links = Array.from(
+ meta.querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ const idx = Math.min(slot.linkIndex || 0, Math.max(0, links.length - 1));
+ if (links[idx]) focusTvEl(links[idx]);
+ else if (links[0]) focusTvEl(links[0]);
+ else {
+ setMetadataDrilled(meta, false);
+ focusTvEl(meta);
+ }
+ return;
+ }
+ if (slot.slot === 'buttons') {
+ const buttons = activeSlide.querySelector('.spotlight-buttons-container');
+ if (!buttons) return;
+ let target = null;
+ if (slot.role === 'watchlist') target = buttons.querySelector('.watchlist-button');
+ else if (slot.role === 'info') target = buttons.querySelector('.spotlight-info-button');
+ else target = buttons.querySelector('.btnPlay');
+ focusTvEl(target || getTvFocusables(buttons)[0]);
+ }
+ }
+
+ if (!inert) {
+ // Delegated handler: info button click -> navigate to item details for the current slide
+ bannerContainer.addEventListener('click', (e) => {
+ const infoBtn = e.target.closest('.spotlight-info-button');
+ if (!infoBtn || !bannerContainer.contains(infoBtn)) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const slide = infoBtn.closest('.spotlight-item');
+ if (!slide) return;
+
+ const itemId = slide.getAttribute('data-id');
+ if (!itemId) return;
+
+ const detailsUrl = `#/details?id=${itemId}&serverId=${serverId}`;
+ if (typeof Dashboard !== 'undefined' && Dashboard && typeof Dashboard.navigate === 'function') {
+ Dashboard.navigate(detailsUrl);
+ } else {
+ const fullDetailsUrl = `${serverAddress}/web/#${detailsUrl}`;
+ window.location.href = fullDetailsUrl;
+ }
+ });
+
+ // Initial Ken Burns (.animate) is deferred until first visibilityObserver intersect + one rAF
+ // (see visibilityObserver below). Navigation/timer still applies sync via goToItem.
+
+ // Navigation buttons (top right)
+ if (showNavButtons && items.length > 1) {
+ const navButtonsContainer = document.createElement('div');
+ navButtonsContainer.className = 'spotlight-nav-buttons-container emby-tab-button emby-tab-button-active emby-button-foreground';
+
+ 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';
+ prevButton.appendChild(prevIcon);
+ prevButton.addEventListener('click', (e) => {
+ e.stopPropagation();
+ // Reset timer when manually navigating
+ goToItem((currentIndex - 1 + items.length) % items.length, true);
+ });
+
+ 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';
+ nextButton.appendChild(nextIcon);
+ nextButton.addEventListener('click', (e) => {
+ e.stopPropagation();
+ // Reset timer when manually navigating
+ goToItem((currentIndex + 1) % items.length, true);
+ });
+
+ navButtonsContainer.appendChild(prevButton);
+ navButtonsContainer.appendChild(nextButton);
+
+ const navContainer = document.createElement('div');
+ navContainer.className = 'spotlight-nav-container headerTabs sectionTabs';
+ navContainer.appendChild(navButtonsContainer);
+
+ const headerContainer =
+ bannerContainer.querySelector('.spotlight-header-container') || bannerContainer;
+ headerContainer.appendChild(navContainer);
+ }
+
+ // 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');
+ 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';
+ 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';
+ }
+ });
+
+ navButtonsContainer.insertBefore(pauseButton, navButtonsContainer.firstChild);
+ }
+ }
+
+ // Slide state container (dots or numeric "N / X")
+ const MAX_DOTS = 5;
+ if (showSlideState && items.length > 1) {
+ const dotsContainer = document.createElement('div');
+ dotsContainer.className = 'spotlight-dots' + (showDots ? '' : ' spotlight-dots-numeric');
+ dotsContainer.setAttribute('data-index', String(currentIndex + 1)); /* 1-based for "N / X" display */
+ dotsContainer.setAttribute('data-total-items', String(items.length));
+
+ if (showDots) {
+ const numDots = Math.min(items.length, MAX_DOTS);
+ const activeDotIndex = currentIndex % numDots;
+ for (let i = 0; i < numDots; i++) {
+ const dot = document.createElement('button');
+ dot.className = 'spotlight-dot' + (i === activeDotIndex ? ' active' : '');
+ dot.setAttribute('data-dot-index', i);
+ dot.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const windowStart = Math.floor(currentIndex / numDots) * numDots;
+ const targetIndex = Math.min(windowStart + i, items.length - 1);
+ goToItem(targetIndex, true);
+ });
+ dotsContainer.appendChild(dot);
+ }
+ }
+
+ bannerContainer.appendChild(dotsContainer);
+ }
+
+ // Load images for a slide (and optionally preload next). Only sets src/backgroundImage from data-* so browser loads on demand.
+ function ensureSlideImagesLoaded(slideIndex) {
+ if (slideIndex < 0 || slideIndex >= items.length) return;
+ const itemEl = bannerContainer.querySelector(`.spotlight-item[data-index="${slideIndex}"]`);
+ if (!itemEl) return;
+ itemEl.querySelectorAll('img[data-src]').forEach((img) => {
+ const url = img.getAttribute('data-src');
+ // Use content attribute — img.src IDL resolves '' to the document URL and would skip reload
+ if (url && img.getAttribute('src') !== url) img.setAttribute('src', url);
+ });
+ const bgUrl = itemEl.getAttribute('data-background-url');
+ if (bgUrl && !itemEl.style.backgroundImage) itemEl.style.backgroundImage = `url("${bgUrl}")`;
+ const logoEl = itemEl.querySelector('.spotlight-item-logo[data-background-url]');
+ if (logoEl) {
+ const logoUrl = logoEl.getAttribute('data-background-url');
+ if (logoUrl && !logoEl.style.backgroundImage) logoEl.style.backgroundImage = `url("${logoUrl}")`;
+ }
+ }
+
+ // Apply pan animation to a slide and track when it completes (for re-trigger on viewport re-entry)
+ const SLIDE_ANIMATION_NAMES = new Set([
+ 'kenBurnsZoomOut',
+ 'kenBurnsZoomIn',
+ 'kenBurnsZoomOutFullscreen',
+ 'kenBurnsZoomInFullscreen',
+ 'kenBurnsPanRight',
+ 'kenBurnsPanLeft',
+ 'kenBurnsPanUp',
+ 'kenBurnsDiagonal',
+ 'fadeInScale',
+ 'parallaxFloat',
+ 'depthPulse',
+ 'slowRotate',
+ 'breathe',
+ 'heatHaze',
+ 'colorWash',
+ 'vignetteIn',
+ ]);
+ const SLIDE_ANIMATION_FINAL_TRANSFORM = {
+ kenBurnsZoomIn: 'scale3d(1.12, 1.12, 1)',
+ kenBurnsZoomOut: 'scale3d(1.02, 1.02, 1)',
+ kenBurnsZoomInFullscreen: 'scale3d(1.12, 1.12, 1)',
+ kenBurnsZoomOutFullscreen: 'scale3d(1.08, 1.08, 1)',
+ kenBurnsPanRight: 'scale3d(1.1, 1.1, 1) translateX(5%)',
+ kenBurnsPanLeft: 'scale3d(1.1, 1.1, 1) translateX(-5%)',
+ kenBurnsPanUp: 'scale3d(1.1, 1.1, 1) translateY(-5%)',
+ kenBurnsDiagonal: 'scale3d(1.13, 1.13, 1) translate(5%, -5%)',
+ fadeInScale: 'scale3d(1, 1, 1)',
+ parallaxFloat: 'scale3d(1, 1, 1) translateY(0)',
+ depthPulse: 'scale3d(1, 1, 1)',
+ slowRotate: 'scale3d(1.15, 1.15, 1) rotate(3deg)',
+ breathe: 'scale3d(1, 1, 1)',
+ heatHaze: 'scale3d(1.05, 1.05, 1) skewX(0deg)',
+ };
+ /** Prefer live dataset (section configure toggle) over create-time option. */
+ function isPanAnimationEnabled() {
+ return container.dataset.panAnimation !== 'false';
+ }
+
+ function applyPanAnimationToSlide(slideElement) {
+ if (!slideElement || !isPanAnimationEnabled()) return;
+ const imgs = slideElement.querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img');
+ if (!imgs.length) return;
+ currentSlideAnimationComplete = false;
+ let finishedCount = 0;
+ const checkAllDone = () => {
+ finishedCount++;
+ if (finishedCount >= imgs.length) currentSlideAnimationComplete = true;
+ };
+ imgs.forEach((img) => {
+ img.style.transform = '';
+ img.style.animationPlayState = '';
+ img.classList.remove('animate');
+ img.classList.add('animate');
+ const slideName = img.getAttribute('data-slide-animation') || '';
+ const handler = (e) => {
+ if (e.animationName === slideName || SLIDE_ANIMATION_NAMES.has(e.animationName)) {
+ img.removeEventListener('animationend', handler);
+ checkAllDone();
+ var finalTransform = SLIDE_ANIMATION_FINAL_TRANSFORM[e.animationName];
+ if (finalTransform) {
+ requestAnimationFrame(() => {
+ img.style.transform = finalTransform;
+ });
+ }
+ }
+ };
+ img.addEventListener('animationend', handler);
+ });
+ }
+
+ function setSlideAnimationPlayState(slideElement, playState) {
+ if (!slideElement || !isPanAnimationEnabled()) return;
+ slideElement
+ .querySelectorAll('.spotlight-background-layer img, .spotlight-background-single img')
+ .forEach((img) => {
+ img.style.animationPlayState = playState;
+ });
+ }
+
+ function getCurrentSpotlightSlide() {
+ return bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
+ }
+
+ function startCycleBackdropTimer(slideElement, itemId) {
+ if (cycleBackdropTimer) {
+ clearInterval(cycleBackdropTimer);
+ cycleBackdropTimer = null;
+ }
+ if (!cycleBackdrops || tileCount !== 1 || !isVisible) return;
+ const container = slideElement.querySelector('.spotlight-background-single.cycle-backdrops');
+ const imgs = container ? container.querySelectorAll('img') : [];
+ const mapKey = sectionKey + '_' + itemId;
+ const entry = cycleBackdropMap.get(mapKey);
+ if (!entry || !entry.shuffledUrls.length) return;
+ var cycleCount = 0;
+ var maxCycles = Math.max(0, backdropsCount - 1);
+ if (imgs.length >= 2) {
+ const bottomImg = imgs[0];
+ const topImg = imgs[1];
+ const crossfadeDuration = '0.8s';
+ cycleBackdropTimer = setInterval(() => {
+ if (!isVisible) return;
+ if (cycleCount >= maxCycles) {
+ if (cycleBackdropTimer) {
+ clearInterval(cycleBackdropTimer);
+ cycleBackdropTimer = null;
+ }
+ return;
+ }
+ cycleCount++;
+ entry.currentIndex = (entry.currentIndex + 1) % entry.shuffledUrls.length;
+ const nextUrl = entry.shuffledUrls[entry.currentIndex];
+ const preload = new Image();
+ preload.onload = () => {
+ topImg.src = nextUrl;
+ topImg.setAttribute('data-src', nextUrl);
+ topImg.style.transition = 'opacity ' + crossfadeDuration + ' ease-in-out';
+ topImg.style.opacity = '0';
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ topImg.style.opacity = '1';
+ });
+ });
+ const handler = (e) => {
+ if (e.propertyName !== 'opacity') return;
+ topImg.removeEventListener('transitionend', handler);
+ bottomImg.src = nextUrl;
+ bottomImg.setAttribute('data-src', nextUrl);
+ topImg.style.opacity = '0';
+ };
+ topImg.addEventListener('transitionend', handler);
+ };
+ preload.src = nextUrl;
+ }, perBackdropMs);
+ } else {
+ const img = slideElement.querySelector('.spotlight-background-single img');
+ if (!img) return;
+ cycleBackdropTimer = setInterval(() => {
+ if (!isVisible) return;
+ if (cycleCount >= maxCycles) {
+ if (cycleBackdropTimer) {
+ clearInterval(cycleBackdropTimer);
+ cycleBackdropTimer = null;
+ }
+ return;
+ }
+ cycleCount++;
+ entry.currentIndex = (entry.currentIndex + 1) % entry.shuffledUrls.length;
+ const nextUrl = entry.shuffledUrls[entry.currentIndex];
+ const preload = new Image();
+ preload.onload = () => {
+ img.style.opacity = '0';
+ img.style.transition = 'opacity 0.8s ease-in-out';
+ requestAnimationFrame(() => {
+ img.src = nextUrl;
+ img.setAttribute('data-src', nextUrl);
+ requestAnimationFrame(() => {
+ img.style.opacity = '1';
+ });
+ });
+ };
+ preload.src = nextUrl;
+ }, perBackdropMs);
+ }
+ }
+
+ // Go to item function (crossfade transition, then only active slide is visible)
+ function goToItem(index, resetTimer = true) {
+ if (index === currentIndex) return;
+ const focusSlot = captureSpotlightFocusSlot();
+ if (cycleBackdropTimer) {
+ clearInterval(cycleBackdropTimer);
+ cycleBackdropTimer = null;
+ }
+ const newWindowIndices = getSpotlightWindowIndices(index, items.length);
+ const indexToRemove = currentWindowIndices.find((i) => !newWindowIndices.includes(i));
+ const indexToAdd = newWindowIndices.find((i) => !currentWindowIndices.includes(i));
+
+ var slideToRemove = undefined;
+ if (indexToRemove !== undefined) {
+ slideToRemove = bannerContainer.querySelector(`.spotlight-item[data-index="${indexToRemove}"]`);
+ }
+ if (indexToAdd !== undefined) {
+ createAndAppendSlide(items[indexToAdd], indexToAdd, false);
+ }
+ currentWindowIndices = newWindowIndices;
+
+ ensureSlideImagesLoaded(index);
+ ensureSlideImagesLoaded((index + 1) % items.length);
+
+ const currentItem = bannerContainer.querySelector(`.spotlight-item[data-index="${currentIndex}"]`);
+ const nextItem = bannerContainer.querySelector(`.spotlight-item[data-index="${index}"]`);
+
+ // Prune the slide that left the window so the container never exceeds 9 items.
+ // If it's not the one we're fading out, remove it now; otherwise remove it in onFadeOutEnd.
+ if (slideToRemove && slideToRemove.parentNode && slideToRemove !== currentItem) {
+ slideToRemove.remove();
+ }
+
+ if (currentItem && nextItem) {
+ // Start crossfade: outgoing stays visible but fades out; incoming becomes visible and fades in
+ currentItem.removeAttribute('data-active');
+ currentItem.setAttribute('data-fade-out', 'true');
+ nextItem.setAttribute('data-active', 'true');
+ nextItem.setAttribute('data-entering', 'true');
+ applyPanAnimationToSlide(nextItem);
+ startCycleBackdropTimer(nextItem, items[index].Id);
+
+ // After next frame, remove data-entering so incoming slide transitions from 0 to 1
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ nextItem.removeAttribute('data-entering');
+ });
+ });
+
+ // When outgoing slide opacity transition ends, clear its fade-out state and prune if it left the window
+ const onFadeOutEnd = (e) => {
+ if (e.propertyName !== 'opacity') return;
+ currentItem.removeEventListener('transitionend', onFadeOutEnd);
+ currentItem.removeAttribute('data-fade-out');
+ if (slideToRemove && slideToRemove.parentNode) {
+ slideToRemove.remove();
+ }
+ };
+ currentItem.addEventListener('transitionend', onFadeOutEnd);
+ } else if (slideToRemove && slideToRemove.parentNode) {
+ slideToRemove.remove();
+ }
+ currentIndex = index;
+
+ // Update slide state (dots or data attrs for numeric)
+ if (showSlideState) {
+ const dotsContainer = bannerContainer.querySelector('.spotlight-dots');
+ if (dotsContainer) {
+ dotsContainer.setAttribute('data-index', String(index + 1)); // 1-based for display
+ }
+ if (showDots) {
+ const dots = bannerContainer.querySelectorAll('.spotlight-dot');
+ const numDots = dots.length;
+ const activeDotIndex = numDots > 0 ? index % numDots : 0;
+ dots.forEach((dot, i) => {
+ if (i === activeDotIndex) {
+ dot.classList.add('active');
+ } else {
+ dot.classList.remove('active');
+ }
+ });
+ }
+ }
+
+ // Reset auto-play timer when manually navigating (always reset delay)
+ if (resetTimer && autoPlay && !isPaused) {
+ if (autoPlayTimer) {
+ clearTimeout(autoPlayTimer);
+ autoPlayTimer = null;
+ }
+ advanceDue = false;
+ startAutoPlay();
+ }
+
+ syncSpotlightTvFocus();
+ if (focusSlot) {
+ requestAnimationFrame(() => restoreSpotlightFocusSlot(focusSlot));
+ }
+ }
+
+ // Auto-play: single-timeout per slide, controlled only by pause/visibility (hover is ignored)
+ function startAutoPlay() {
+ if (autoPlayTimer) {
+ clearTimeout(autoPlayTimer);
+ autoPlayTimer = null;
+ }
+ if (!autoPlay || items.length <= 1 || isPaused || !isVisible) return;
+ autoPlayTimer = setTimeout(onAutoPlayTimerFired, interval);
+ }
+
+ function onAutoPlayTimerFired() {
+ autoPlayTimer = null;
+ if (isPaused) {
+ advanceDue = true;
+ return;
+ }
+ if (!autoPlay || !isVisible || items.length <= 1) return;
+ goToItem((currentIndex + 1) % items.length, false);
+ advanceDue = false;
+ // Schedule next slide timer for the new current slide
+ if (autoPlay && !isPaused && isVisible && items.length > 1) {
+ autoPlayTimer = setTimeout(onAutoPlayTimerFired, interval);
+ }
+ }
+
+ // Only cycle when spotlight is in view: pause CSS pan + stop timers when off-screen.
+ // First intersect also gates initial .animate (post one rAF so IO + animate don't stack).
+ let initialPanScheduled = false;
+ const visibilityObserver = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry) return;
+ isVisible = entry.isIntersecting;
+ const currentSlide = getCurrentSpotlightSlide();
+ if (!isVisible) {
+ if (autoPlayTimer) {
+ clearTimeout(autoPlayTimer);
+ autoPlayTimer = null;
+ }
+ if (cycleBackdropTimer) {
+ clearInterval(cycleBackdropTimer);
+ cycleBackdropTimer = null;
+ }
+ setSlideAnimationPlayState(currentSlide, 'paused');
+ } else {
+ ensureSlideImagesLoaded(currentIndex);
+ if (items.length > 1) {
+ ensureSlideImagesLoaded((currentIndex + 1) % items.length);
+ }
+
+ const needsInitialPan =
+ isPanAnimationEnabled() &&
+ currentSlide &&
+ !initialPanScheduled &&
+ !currentSlide.querySelector(
+ '.spotlight-background-layer img.animate, .spotlight-background-single img.animate',
+ );
+
+ if (needsInitialPan) {
+ initialPanScheduled = true;
+ requestAnimationFrame(() => {
+ if (!container.isConnected || !isPanAnimationEnabled() || !isVisible) return;
+ const slide = getCurrentSpotlightSlide();
+ if (!slide) return;
+ if (
+ !slide.querySelector(
+ '.spotlight-background-layer img.animate, .spotlight-background-single img.animate',
+ )
+ ) {
+ applyPanAnimationToSlide(slide);
+ }
+ if (items[currentIndex]) {
+ startCycleBackdropTimer(slide, items[currentIndex].Id);
+ }
+ });
+ } else {
+ // Re-entering viewport: resume pan, restart timers
+ setSlideAnimationPlayState(currentSlide, 'running');
+ if (currentSlide && items[currentIndex]) {
+ startCycleBackdropTimer(currentSlide, items[currentIndex].Id);
+ }
+ }
+
+ if (autoPlay && !isPaused) {
+ startAutoPlay();
+ }
+ }
+ },
+ { root: null, rootMargin: '0px', threshold: 0 },
+ );
+ visibilityObserver.observe(container);
+
+ document.addEventListener('visibilitychange', () => {
+ const currentSlide = getCurrentSpotlightSlide();
+ if (document.hidden) {
+ if (autoPlayTimer) {
+ clearTimeout(autoPlayTimer);
+ autoPlayTimer = null;
+ }
+ setSlideAnimationPlayState(currentSlide, 'paused');
+ } else {
+ if (isVisible) {
+ setSlideAnimationPlayState(currentSlide, 'running');
+ }
+ if (autoPlay && !isPaused && isVisible) {
+ startAutoPlay();
+ }
+ }
+ });
+
+ // Start auto-play (will no-op if container not yet in DOM or not visible once observer runs)
+ if (autoPlay) {
+ startAutoPlay();
+ }
+
+ // Preload next slide soon after mount so transition has no pop
+ if (items.length > 1) {
+ requestAnimationFrame(() => ensureSlideImagesLoaded(1));
+ }
+
+ // TV D-pad: metadata drill-in, row-scoped seams (header L/R is native focuscontainer-x)
+ if (isTvLayout()) {
+ const ARROW = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
+
+ function getHeaderCluster() {
+ return bannerContainer.querySelector('.spotlight-header-container');
+ }
+ function getActiveMeta() {
+ return bannerContainer.querySelector(
+ '.spotlight-item[data-active="true"] .metadata-container',
+ );
+ }
+ function getActiveButtons() {
+ return bannerContainer.querySelector(
+ '.spotlight-item[data-active="true"] .spotlight-buttons-container',
+ );
+ }
+
+ function exitMetadataTo(direction) {
+ const meta = getActiveMeta();
+ if (meta) setMetadataDrilled(meta, false);
+ if (direction === 'up') {
+ focusTvEl(getFirstSpotlightHeaderFocusable(getHeaderCluster()));
+ } else {
+ const buttons = getActiveButtons();
+ focusTvEl(getTvFocusables(buttons)[0]);
+ }
+ }
+
+ function enterMetadataDrillIn() {
+ const meta = getActiveMeta();
+ if (!meta) return false;
+ setMetadataDrilled(meta, true);
+ const rows = getMetadataRows(meta);
+ for (const row of rows) {
+ const links = Array.from(
+ row.querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ if (links[0]) {
+ focusTvEl(links[0]);
+ return true;
+ }
+ }
+ setMetadataDrilled(meta, false);
+ return false;
+ }
+
+ bannerContainer.addEventListener(
+ 'keydown',
+ (e) => {
+ if (!ARROW.has(e.key) && e.key !== 'Enter' && e.key !== ' ') return;
+ const ae = document.activeElement;
+ if (!ae || !bannerContainer.contains(ae)) return;
+
+ const meta = getActiveMeta();
+ const inMeta = !!(meta && meta.contains(ae));
+ const drilled = meta?.dataset?.metadataDrilled === 'true';
+
+ // Metadata container: Right/Enter drills in; Up/Down leave to neighbors
+ if (inMeta && ae === meta && !drilled) {
+ if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ e.stopPropagation();
+ enterMetadataDrillIn();
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ e.stopPropagation();
+ exitMetadataTo('up');
+ return;
+ }
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ e.stopPropagation();
+ exitMetadataTo('down');
+ return;
+ }
+ }
+
+ // Metadata drilled: row-scoped L/R + Up/Down exit
+ if (inMeta && drilled && meta) {
+ const rows = getMetadataRows(meta).filter((row) =>
+ row.querySelector(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ const row = ae.closest('.spotlight-metadata-row');
+ const rowIndex = rows.indexOf(row);
+ const rowLinks = row
+ ? Array.from(
+ row.querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ )
+ : [];
+ const linkIndex = rowLinks.indexOf(ae);
+
+ if (e.key === 'ArrowUp') {
+ if (rowIndex <= 0) {
+ e.preventDefault();
+ e.stopPropagation();
+ exitMetadataTo('up');
+ return;
+ }
+ // Move to previous row rightmost for predictability when using Up between rows
+ const prevLinks = Array.from(
+ rows[rowIndex - 1].querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ if (prevLinks.length) {
+ e.preventDefault();
+ e.stopPropagation();
+ focusTvEl(prevLinks[prevLinks.length - 1]);
+ }
+ return;
+ }
+ if (e.key === 'ArrowDown') {
+ if (rowIndex < 0 || rowIndex >= rows.length - 1) {
+ e.preventDefault();
+ e.stopPropagation();
+ exitMetadataTo('down');
+ return;
+ }
+ const nextLinks = Array.from(
+ rows[rowIndex + 1].querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ if (nextLinks[0]) {
+ e.preventDefault();
+ e.stopPropagation();
+ focusTvEl(nextLinks[0]);
+ }
+ return;
+ }
+ if (e.key === 'ArrowLeft' && linkIndex === 0) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (rowIndex > 0) {
+ // Bottom/mid row first link → previous row (not category rail)
+ const prevLinks = Array.from(
+ rows[rowIndex - 1].querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ focusTvEl(prevLinks[prevLinks.length - 1] || meta);
+ } else {
+ // Top row first link → title / header
+ exitMetadataTo('up');
+ }
+ return;
+ }
+ if (e.key === 'ArrowRight' && linkIndex === rowLinks.length - 1) {
+ if (rowIndex >= 0 && rowIndex < rows.length - 1) {
+ const nextLinks = Array.from(
+ rows[rowIndex + 1].querySelectorAll(
+ 'a.spotlight-genre-link, a.spotlight-person-link, a.spotlight-studio-link',
+ ),
+ );
+ if (nextLinks[0]) {
+ e.preventDefault();
+ e.stopPropagation();
+ focusTvEl(nextLinks[0]);
+ }
+ } else {
+ // Right on last bottom link → buttons row
+ e.preventDefault();
+ e.stopPropagation();
+ exitMetadataTo('down');
+ }
+ return;
+ }
+ // Left/Right mid-row: let focuscontainer-x / browser handle
+ }
+ },
+ true,
+ );
+
+ // Vertical entry into header / buttons → snap leftmost
+ bannerContainer.addEventListener(
+ 'focusin',
+ (e) => {
+ const target = e.target;
+ if (!target || !bannerContainer.contains(target)) return;
+ const related = e.relatedTarget;
+
+ const headerCluster = getHeaderCluster();
+ const buttons = getActiveButtons();
+
+ const enteredHeader =
+ headerCluster?.contains(target) &&
+ (!related || !headerCluster.contains(related));
+ const enteredButtons =
+ buttons?.contains(target) && (!related || !buttons.contains(related));
+
+ if (enteredHeader) {
+ const first = getFirstSpotlightHeaderFocusable(headerCluster);
+ if (first && target !== first) focusTvEl(first);
+ } else if (enteredButtons) {
+ const fs = getTvFocusables(buttons);
+ if (fs[0] && target !== fs[0]) focusTvEl(fs[0]);
+ }
+ },
+ true,
+ );
+ }
+
+ // Touch swipe handling
+ let touchStartX = 0;
+ let touchStartY = 0;
+
+ bannerContainer.addEventListener(
+ 'touchstart',
+ (e) => {
+ touchStartX = e.touches[0].clientX;
+ touchStartY = e.touches[0].clientY;
+ },
+ { passive: true },
+ );
+
+ bannerContainer.addEventListener(
+ 'touchmove',
+ (e) => {
+ if (!e.touches[0]) return;
+
+ const touchX = e.touches[0].clientX;
+ const touchY = e.touches[0].clientY;
+
+ const deltaX = touchX - touchStartX;
+ const deltaY = touchY - touchStartY;
+
+ // If horizontal movement is dominant, prevent vertical scrolling and propagation
+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
+ if (e.cancelable) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+ },
+ { passive: false },
+ );
+
+ bannerContainer.addEventListener(
+ 'touchend',
+ (e) => {
+ if (!e.changedTouches[0]) return;
+
+ const touchEndX = e.changedTouches[0].clientX;
+ const touchEndY = e.changedTouches[0].clientY;
+
+ const deltaX = touchEndX - touchStartX;
+ const deltaY = touchEndY - touchStartY;
+
+ // Threshold for swipe (50px)
+ if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY)) {
+ // Stop propagation to prevent parent handlers (like tab switching)
+ e.stopPropagation();
+
+ if (deltaX > 0) {
+ // Swipe right (previous)
+ goToItem((currentIndex - 1 + items.length) % items.length, true);
+ } else {
+ // Swipe left (next)
+ goToItem((currentIndex + 1) % items.length, true);
+ }
+ }
+ },
+ { passive: true },
+ );
+ } // end !inert wiring
+
+ container.appendChild(bannerContainer);
+
+ container.dataset.layout = layout;
+ container.dataset.size = size;
+ container.dataset.tileCount = String(tileCount);
+
+ // Allow in-place SWR to update the closed-over items list used by goToItem / windowing
+ container._kefinSpotlightApplyItems = (nextItems, opts = {}) => {
+ items = Array.isArray(nextItems) ? nextItems.slice() : [];
+ if (typeof opts.currentIndex === 'number' && items.length) {
+ currentIndex = Math.max(0, Math.min(items.length - 1, opts.currentIndex));
+ } else if (currentIndex >= items.length) {
+ currentIndex = Math.max(0, items.length - 1);
+ }
+ currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length);
+ };
+
+ container._kefinSpotlightPruneWindow = () => {
+ if (!items.length) return;
+ currentWindowIndices = getSpotlightWindowIndices(currentIndex, items.length);
+ const keep = new Set(currentWindowIndices.map(String));
+ itemsContainer.querySelectorAll(':scope > .spotlight-item').forEach((slide) => {
+ const idx = slide.getAttribute('data-index');
+ if (keep.has(idx)) return;
+ if (slide.getAttribute('data-active') === 'true') return;
+ slide.remove();
+ });
+ currentWindowIndices.forEach((idx) => {
+ if (itemsContainer.querySelector(`.spotlight-item[data-index="${idx}"]`)) return;
+ if (!items[idx]) return;
+ createAndAppendSlide(items[idx], idx, idx === currentIndex);
+ });
+ syncSpotlightTvFocus();
+ };
+
+ syncSpotlightTvFocus();
+
+ return container;
+ }
+
+ const ITEMS_LAYOUT_CYCLE = ['row', 'grid'];
+ const ITEMS_LAYOUT_ICONS = {
+ row: 'view_array',
+ grid: 'grid_view',
+ };
+ const ITEMS_LAYOUT_NEXT_TITLE = {
+ row: 'Grid layout',
+ grid: 'Row layout',
+ };
+
+ function resolveItemsLayout(sectionConfig) {
+ if (!sectionConfig) return 'row';
+ if (sectionConfig.itemsLayout === 'grid') return 'grid';
+ if (sectionConfig.itemsLayout === 'row') return 'row';
+ if (sectionConfig.gridExpanded === true) return 'grid';
+ return 'row';
+ }
+
+ function resolveUseGaplessCards(sectionConfig) {
+ return sectionConfig?.useGaplessCards === true;
+ }
+
+ function nextItemsLayout(current) {
+ const idx = ITEMS_LAYOUT_CYCLE.indexOf(current);
+ return ITEMS_LAYOUT_CYCLE[(idx < 0 ? 0 : idx + 1) % ITEMS_LAYOUT_CYCLE.length];
+ }
+
+ function setShowAllIcon(button, layout) {
+ if (!button) return;
+ const iconName = ITEMS_LAYOUT_ICONS[layout] || 'view_array';
+ const icon = button.querySelector('.material-icons') || button;
+ icon.classList.remove('view_array', 'grid_view', 'grid_on');
+ icon.classList.add(iconName);
+ // Keep glyph classes off the paper-icon button itself
+ button.classList.remove('view_array', 'grid_view', 'grid_on');
+ }
+
+ function isButtonCardFormat(cardFormat) {
+ return (cardFormat || '').toLowerCase() === 'button';
+ }
+
+ function getPaintedSectionItems(itemsContainer, cardFormat) {
+ if (!itemsContainer) return [];
+ if (isButtonCardFormat(cardFormat)) {
+ return Array.from(itemsContainer.querySelectorAll(':scope > .homeLibraryButton'));
+ }
+ return Array.from(itemsContainer.querySelectorAll(':scope > .card'));
+ }
+
+ function getRealLayoutCards(itemsContainer) {
+ if (!itemsContainer) return [];
+ return Array.from(itemsContainer.querySelectorAll(':scope > .card'));
+ }
+
+ /**
+ * Apply items layout (row|grid) and optional gapless cards on a scrollable section.
+ * @param {HTMLElement} verticalSection
+ * @param {string} layout
+ * @param {boolean} [gapless]
+ */
+ function applyItemsLayoutState(verticalSection, layout, gapless) {
+ if (!verticalSection) return;
+ const itemsContainer = verticalSection.querySelector('.itemsContainer');
+ if (!itemsContainer) return;
+ const resolved = layout === 'grid' ? 'grid' : 'row';
+ const showAllButton = verticalSection.querySelector('.show-all-button');
+ const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons');
+ itemsContainer.setAttribute('data-layout', resolved);
+ verticalSection.setAttribute('data-layout', resolved);
+ itemsContainer.removeAttribute('data-expanded');
+ if (gapless === true) {
+ itemsContainer.setAttribute('data-gapless', 'true');
+ } else if (gapless === false) {
+ itemsContainer.removeAttribute('data-gapless');
+ }
+ if (resolved === 'row') {
+ if (scrollButtons) scrollButtons.style.display = '';
+ } else if (scrollButtons) {
+ scrollButtons.style.display = 'none';
+ }
+ if (showAllButton) {
+ const nextLayout = nextItemsLayout(resolved);
+ showAllButton.title = ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row;
+ showAllButton.setAttribute('aria-label', showAllButton.title);
+ setShowAllIcon(showAllButton, nextLayout);
+ }
+ }
+
+ /** @deprecated Use applyItemsLayoutState */
+ function applyGridExpandedState(verticalSection, expanded) {
+ applyItemsLayoutState(verticalSection, expanded ? 'grid' : 'row');
+ }
+
+ const pendingItemsLayoutSaves = new Map();
+ let itemsLayoutSaveTimeout = null;
+
+ function scheduleItemsLayoutSave(sectionId, layout, sectionType) {
+ if (!sectionId) return;
+ const value = layout === 'grid' ? layout : 'row';
+ pendingItemsLayoutSaves.set(sectionId, { layout: value, type: sectionType });
+ window.KefinHomeScreenSectionConfigure?.updateRuntimeItemsLayout?.(sectionId, value);
+ clearTimeout(itemsLayoutSaveTimeout);
+ itemsLayoutSaveTimeout = setTimeout(async () => {
+ const batch = new Map(pendingItemsLayoutSaves);
+ pendingItemsLayoutSaves.clear();
+ const api = window.KefinUserHomeScreenConfig;
+ const save = api?.saveSectionItemsLayout || api?.saveSectionGridExpanded;
+ if (!save) return;
+ for (const [id, entry] of batch) {
+ try {
+ const itemsLayout = typeof entry === 'object' ? entry.layout : entry;
+ const type = typeof entry === 'object' ? entry.type : undefined;
+ await save.call(api, id, itemsLayout, type ? { type } : undefined);
+ } catch (e) {
+ console.warn('[KefinTweaks CardBuilder] Failed to save itemsLayout:', e);
+ }
+ }
+ }, 300);
+ }
+
+ function isCardScrollerSection(sectionElement) {
+ return !!sectionElement?.querySelector('.itemsContainer');
+ }
+
+ function getShowAllControlLabel(sectionElement) {
+ const itemsContainer = sectionElement?.querySelector('.itemsContainer');
+ const currentLayout = itemsContainer?.getAttribute('data-layout') || 'row';
+ const resolved = currentLayout === 'grid' ? currentLayout : 'row';
+ return ITEMS_LAYOUT_NEXT_TITLE[resolved] || ITEMS_LAYOUT_NEXT_TITLE.row;
+ }
+
+ function getShowAllControlIcon(sectionElement) {
+ const itemsContainer = sectionElement?.querySelector('.itemsContainer');
+ const currentLayout = itemsContainer?.getAttribute('data-layout') || 'row';
+ const resolved = currentLayout === 'grid' ? currentLayout : 'row';
+ return ITEMS_LAYOUT_ICONS[nextItemsLayout(resolved)] || 'grid_view';
+ }
+
+ function handleShowAllLayoutToggle(sectionElement) {
+ const verticalSection = sectionElement.closest('.emby-scroller-container') || sectionElement;
+ const itemsContainer = verticalSection.querySelector('.itemsContainer');
+ if (!itemsContainer) return;
+
+ const scroller = verticalSection.querySelector('.emby-scroller');
+ const currentLayout = itemsContainer.getAttribute('data-layout') || 'row';
+ const gapless = itemsContainer.getAttribute('data-gapless') === 'true';
+ const layout = nextItemsLayout(currentLayout === 'grid' ? currentLayout : 'row');
+ const sectionId = verticalSection.getAttribute('data-section-id') || verticalSection.dataset?.sectionId;
+ const sectionType = verticalSection.getAttribute('data-section-type') || verticalSection.dataset?.sectionType;
+
+ if (layout === 'row') {
+ applyItemsLayoutState(verticalSection, 'row', gapless);
+ updateScrollButtonStateForSection(verticalSection);
+ const saved = parseFloat(itemsContainer.getAttribute('data-row-scroll-x'));
+ const position = Number.isFinite(saved) ? saved : 0;
+ setScrollerPosition(scroller, position, true);
+ requestAnimationFrame(() => applyScrollButtonState(verticalSection));
+ scheduleItemsLayoutSave(sectionId, 'row', sectionType);
+ } else {
+ if (currentLayout === 'row') {
+ itemsContainer.setAttribute('data-row-scroll-x', String(getScrollerPosition(scroller)));
+ setScrollerPosition(scroller, 0, false);
+ }
+ applyItemsLayoutState(verticalSection, layout, gapless);
+ scheduleItemsLayoutSave(sectionId, layout, sectionType);
+ }
+ }
+
+ function getSectionControlDefinitions(sectionConfig, sectionElement, cachedItems) {
+ const definitions = [];
+
+ definitions.push({
+ id: 'refresh',
+ icon: 'refresh',
+ label: 'Refresh',
+ className: 'section-refresh-button',
+ isVisible: () => true,
+ onClick: async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ await executeSectionRefresh(sectionConfig, sectionElement);
+ },
+ });
+
+ if (isCardScrollerSection(sectionElement)) {
+ definitions.push({
+ id: 'showAll',
+ icon: 'grid_view',
+ getIcon: () => getShowAllControlIcon(sectionElement),
+ getLabel: () => getShowAllControlLabel(sectionElement),
+ label: 'Grid layout',
+ className: 'show-all-button',
+ isVisible: () => true,
+ onClick: (e) => {
e.preventDefault();
e.stopPropagation();
- }
+ handleShowAllLayoutToggle(sectionElement);
+ },
+ });
+ }
+
+ const configureDef = window.KefinHomeScreenSectionConfigure?.getConfigureSectionControl?.(
+ sectionConfig,
+ sectionElement,
+ cachedItems,
+ );
+ if (configureDef) {
+ definitions.push(configureDef);
+ }
+
+ return definitions;
+ }
+
+ function renderSectionControls(sectionConfig, sectionElement, titleContainer, definitions) {
+ const { inline, moreButton } = ensureSectionControlsMount(titleContainer);
+ inline.innerHTML = '';
+
+ const visibleDefinitions = definitions.filter((def) => !def.isVisible || def.isVisible());
+ let refreshButton = null;
+
+ visibleDefinitions.forEach((def) => {
+ const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label;
+ const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon;
+ const button = createPaperIconButton({
+ className: def.className || '',
+ icon: icon || '',
+ title: label,
+ showFocus: isTvLayout(),
+ });
+ button.dataset.sectionControlId = def.id;
+ button.addEventListener('click', (e) => {
+ def.onClick(e, button);
+ });
+ inline.appendChild(button);
+ if (def.id === 'refresh') {
+ refreshButton = button;
}
- }, { passive: false });
-
- bannerContainer.addEventListener('touchend', (e) => {
- if (!e.changedTouches[0]) return;
-
- const touchEndX = e.changedTouches[0].clientX;
- const touchEndY = e.changedTouches[0].clientY;
-
- const deltaX = touchEndX - touchStartX;
- const deltaY = touchEndY - touchStartY;
-
- // Threshold for swipe (50px)
- if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY)) {
- // Stop propagation to prevent parent handlers (like tab switching)
- e.stopPropagation();
-
- if (deltaX > 0) {
- // Swipe right (previous)
- goToItem((currentIndex - 1 + items.length) % items.length, true);
+ });
+
+ const newMoreButton = moreButton.cloneNode(true);
+ newMoreButton.classList.add('paper-icon-button-light');
+ if (isTvLayout()) newMoreButton.classList.add('show-focus');
+ if (!newMoreButton.querySelector('.material-icons')) {
+ const iconSpan = document.createElement('span');
+ iconSpan.className = 'material-icons more_vert';
+ iconSpan.setAttribute('aria-hidden', 'true');
+ newMoreButton.appendChild(iconSpan);
+ }
+ // Ensure legacy more buttons no longer carry icon font classes on the button itself
+ newMoreButton.classList.remove('material-icons', 'more_vert');
+ moreButton.replaceWith(newMoreButton);
+
+ const MORE_CONTROLS_MODAL_ID = 'kefin-section-controls-more';
+ let activeMorePopover = null;
+ const closeMorePopover = () => {
+ if (window.ModalSystem?.isOpen?.(MORE_CONTROLS_MODAL_ID)) {
+ window.ModalSystem.close(MORE_CONTROLS_MODAL_ID);
+ }
+ if (activeMorePopover) {
+ activeMorePopover.remove();
+ activeMorePopover = null;
+ }
+ };
+
+ const buildMorePopoverContent = (onActivate, { forModal = false } = {}) => {
+ const popover = document.createElement('div');
+ popover.className = forModal
+ ? 'kefinTweaks-popover section-controls-more-menu'
+ : 'kefinTweaks-popover section-controls-more-popover';
+ visibleDefinitions.forEach((def) => {
+ const label = typeof def.getLabel === 'function' ? def.getLabel() : def.label;
+ const icon = typeof def.getIcon === 'function' ? def.getIcon() : def.icon;
+ const item = document.createElement('div');
+ item.className = 'kefinTweaks-popover-item detailsGroupItem section-controls-more-item';
+ item.setAttribute('role', 'button');
+ item.setAttribute('tabindex', '0');
+
+ const iconSpan = document.createElement('span');
+ iconSpan.className = `material-icons section-controls-more-item-icon ${icon || ''}`.trim();
+ iconSpan.setAttribute('aria-hidden', 'true');
+
+ const labelSpan = document.createElement('span');
+ labelSpan.className = 'section-controls-more-item-label';
+ labelSpan.textContent = label;
+
+ item.appendChild(iconSpan);
+ item.appendChild(labelSpan);
+
+ const activate = (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ onActivate();
+ def.onClick(ev, newMoreButton);
+ };
+ item.addEventListener('click', activate);
+ item.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter' || ev.key === ' ') {
+ activate(ev);
+ }
+ });
+ popover.appendChild(item);
+ });
+ return popover;
+ };
+
+ const positionMoreModal = (modal, anchorBtn) => {
+ if (!modal?.dialog || !anchorBtn) return;
+ modal.dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY', 'dialog-fixedSize');
+ modal.dialog.style.position = 'fixed';
+ modal.dialog.style.margin = '0';
+ modal.dialog.style.maxHeight = 'none';
+ modal.dialog.style.height = 'auto';
+ modal.dialog.style.minHeight = 'auto';
+ modal.dialog.style.minWidth = '12rem';
+ modal.dialog.style.width = 'auto';
+ modal.dialog.style.padding = '0.25em 0';
+ if (typeof window.ModalSystem?.applyOpenAnimation === 'function') {
+ window.ModalSystem.applyOpenAnimation(modal.dialog);
+ } else {
+ modal.dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup';
+ }
+ if (modal.dialogContent) {
+ modal.dialogContent.style.padding = '0';
+ modal.dialogContent.style.overflow = 'visible';
+ modal.dialogContent.style.minHeight = 'auto';
+ modal.dialogContent.style.height = 'auto';
+ modal.dialogContent.style.flex = '0 0 auto';
+ }
+ if (modal.backdrop) {
+ modal.backdrop.style.background = 'transparent';
+ }
+ if (modal.dialogContainer) {
+ modal.dialogContainer.style.pointerEvents = 'none';
+ }
+ modal.dialog.style.pointerEvents = 'auto';
+
+ const btnRect = anchorBtn.getBoundingClientRect();
+ const margin = 8;
+ const gap = 4;
+ requestAnimationFrame(() => {
+ const menuHeight = modal.dialog.offsetHeight || 0;
+ const menuWidth = Math.max(modal.dialog.offsetWidth || 0, 180);
+ let top = btnRect.bottom + gap;
+ if (top + menuHeight > window.innerHeight - margin) {
+ top = Math.max(margin, btnRect.top - menuHeight - gap);
+ }
+ let left = btnRect.right - menuWidth;
+ left = Math.min(Math.max(left, margin), window.innerWidth - menuWidth - margin);
+ modal.dialog.style.top = `${top}px`;
+ modal.dialog.style.left = `${left}px`;
+ });
+ };
+
+ newMoreButton.addEventListener('click', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (activeMorePopover || window.ModalSystem?.isOpen?.(MORE_CONTROLS_MODAL_ID)) {
+ closeMorePopover();
+ return;
+ }
+
+ if (isMobileLayout() && window.ModalSystem?.create) {
+ const content = buildMorePopoverContent(() => closeMorePopover(), { forModal: true });
+ window.ModalSystem.create({
+ id: MORE_CONTROLS_MODAL_ID,
+ title: '',
+ content,
+ showCloseButton: false,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ fixedSize: false,
+ onOpen: (modal) => {
+ if (modal?.dialogHeader) modal.dialogHeader.style.display = 'none';
+ if (modal?.dialogFooter) modal.dialogFooter.style.display = 'none';
+ positionMoreModal(modal, newMoreButton);
+ },
+ });
+ return;
+ }
+
+ const popover = buildMorePopoverContent(() => closeMorePopover());
+ titleContainer.style.position = 'relative';
+ newMoreButton.parentNode.insertBefore(popover, newMoreButton.nextSibling);
+
+ const buttonRect = newMoreButton.getBoundingClientRect();
+ const containerRect = titleContainer.getBoundingClientRect();
+ popover.style.right = '40px';
+ popover.style.left = 'auto';
+ popover.style.top = '0px';
+ popover.style.minWidth = `${Math.max(buttonRect.width, 180)}px`;
+ popover.style.maxWidth = `${Math.max(0, containerRect.width)}px`;
+
+ activeMorePopover = popover;
+
+ const closeHandler = (ev) => {
+ if (!popover.contains(ev.target) && !newMoreButton.contains(ev.target)) {
+ closeMorePopover();
+ document.removeEventListener('click', closeHandler);
+ }
+ };
+ setTimeout(() => {
+ document.addEventListener('click', closeHandler);
+ }, 100);
+ });
+
+ return refreshButton;
+ }
+
+ function isMobileLayout() {
+ return document.documentElement.classList.contains('layout-mobile') || window.innerWidth < 900;
+ }
+
+ function getSectionSkeletonCount(sectionConfig, cardFormat = null, itemsLayout = null) {
+ const raw = sectionConfig?.itemLimit || sectionConfig?.queries?.[0]?.queryOptions?.Limit || 0;
+ const fullLimit = raw > 0 ? raw : 16;
+ const layout = itemsLayout || resolveItemsLayout(sectionConfig);
+ if (layout === 'grid') return fullLimit;
+
+ const format = String(cardFormat || '').toLowerCase();
+ // Default/empty format resolves to portrait in createSkeletonCard
+ let rowCap = 6;
+ if (format === 'banner') {
+ rowCap = 3;
+ } else if (
+ !format ||
+ format === 'portrait' ||
+ format === 'poster' ||
+ format === 'series poster' ||
+ format === 'square' ||
+ format === 'disc'
+ ) {
+ rowCap = 8;
+ }
+ return Math.min(fullLimit, rowCap);
+ }
+
+ const SKELETON_ONE_LINE_ITEM_TYPES = new Set(['Book', 'Genre', 'CollectionFolder', 'BoxSet', 'Playlist', 'Folder', 'Studio']);
+
+ const SKELETON_CUSTOM_FOOTER_SECTION_IDS = new Set(['upcoming', 'recentlyReleased.movies', 'recentlyReleased.episodes']);
+
+ function sectionUsesCustomFooterText(sectionConfig) {
+ if (!sectionConfig) return false;
+ if (SKELETON_CUSTOM_FOOTER_SECTION_IDS.has(sectionConfig.id)) return true;
+ if (Array.isArray(sectionConfig.items) && sectionConfig.items.some((item) => item?.CustomFooterText || item?.cardFooter)) {
+ return true;
+ }
+ return false;
+ }
+
+ function inferSkeletonItemType(sectionConfig) {
+ if (!sectionConfig) return null;
+ const queries = sectionConfig.queries || [];
+ const activeIndex = resolveActiveQueryIndex(sectionConfig, { initialize: false });
+ const query = queries[activeIndex] || queries[0];
+ const path = typeof query?.path === 'string' ? query.path : '';
+ if (path.includes('/Studios')) return 'Studio';
+ if (path.includes('/Genres')) return 'Genre';
+
+ let includeTypes = query?.queryOptions?.IncludeItemTypes;
+ if (typeof includeTypes === 'string') {
+ includeTypes = includeTypes
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean);
+ }
+ if (!Array.isArray(includeTypes) || includeTypes.length === 0) return null;
+ if (includeTypes.length === 1) return includeTypes[0];
+ if (includeTypes.every((t) => SKELETON_ONE_LINE_ITEM_TYPES.has(t))) {
+ return includeTypes[0];
+ }
+ return null;
+ }
+
+ function getSkeletonFooterLineCount(sectionConfig) {
+ if (sectionConfig?.hideCardFooter === true) return 0;
+ if (sectionUsesCustomFooterText(sectionConfig)) return 3;
+ const itemType = inferSkeletonItemType(sectionConfig);
+ if (itemType && SKELETON_ONE_LINE_ITEM_TYPES.has(itemType)) return 1;
+ return 2;
+ }
+
+ function readScrollerPadding(scroller) {
+ if (!scroller) return { left: 0, right: 0 };
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics.pad) return metrics.pad;
+
+ // Reuse first measured pad for sibling scrollers in the same sections container
+ const host = scroller.closest?.('.homeSectionsContainer, .sections');
+ if (host && host._kefinSharedScrollerPad) {
+ metrics.pad = host._kefinSharedScrollerPad;
+ return metrics.pad;
+ }
+
+ const style = window.getComputedStyle(scroller);
+ const pad = {
+ left: parseFloat(style.paddingLeft) || 0,
+ right: parseFloat(style.paddingRight) || 0,
+ };
+ metrics.pad = pad;
+ if (host) host._kefinSharedScrollerPad = pad;
+ return pad;
+ }
+
+ /** Frame content-box width (clientWidth minus horizontal padding) — viewport for slidee math. */
+ function getScrollerViewportWidth(scroller, pad) {
+ const p = pad || readScrollerPadding(scroller);
+ return Math.max(0, (scroller?.clientWidth || 0) - (p.left || 0) - (p.right || 0));
+ }
+
+ function readCardPadding(card) {
+ if (!card) return { left: 0, right: 0 };
+ const cached = cardPaddingByEl.get(card);
+ if (cached) return cached;
+
+ const style = window.getComputedStyle(card);
+ const pad = {
+ left: parseFloat(style.paddingLeft) || 0,
+ right: parseFloat(style.paddingRight) || 0,
+ };
+ cardPaddingByEl.set(card, pad);
+ return pad;
+ }
+
+ function readSampleCardPadding(scroller) {
+ if (!scroller) return { left: 0, right: 0 };
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics.cardPad) return metrics.cardPad;
+
+ const sampleCard = scroller.querySelector?.('.itemsContainer > .card:not(.skeleton-card)');
+ const pad = readCardPadding(sampleCard);
+ metrics.cardPad = pad;
+ return pad;
+ }
+
+ /** Inner slidee that receives translateX (Jellyfin .scrollSlider), not the outer frame. */
+ function getScrollSlidee(scroller) {
+ if (!scroller) return null;
+ return scroller.querySelector('.scrollSlider') || scroller.querySelector('.itemsContainer');
+ }
+
+ function getCurrentPosition(scroller) {
+ if (!scroller) return 0;
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics && Number.isFinite(metrics.scrollX)) return metrics.scrollX;
+ return 0;
+ }
+
+ function setStoredScrollPosition(scroller, scrollX) {
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) metrics.scrollX = scrollX;
+ }
+
+ function setMaxScrollPosition(scroller, maxScroll) {
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) metrics.maxScroll = maxScroll;
+ }
+
+ /**
+ * Apply row scroll as translateX on the slidee (itemsContainer), matching Jellyfin.
+ * Clears any leftover transform on the outer scroller frame.
+ */
+ function setSlideePosition(scroller, position, options = {}) {
+ if (!scroller) return 0;
+ const opts = options || {};
+ const slidee = getScrollSlidee(scroller);
+
+ if (opts.freshMax) {
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) {
+ metrics.maxScroll = null;
+ metrics.pad = null;
+ metrics.cardPad = null;
+ }
+ }
+
+ const max = Math.max(
+ 0,
+ getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)),
+ );
+ const scrollPosition = Math.min(Math.max(position, 0), max);
+ const clampedPosition = scrollPosition > 20 ? scrollPosition : 0;
+
+ if (scroller.style.transform) scroller.style.transform = '';
+ if (scroller.style.transition) scroller.style.transition = '';
+
+ if (slidee) {
+ if (opts.animate) {
+ slidee.style.transition = 'transform 270ms ease-out';
+ slidee.style.willChange = 'transform';
+ } else {
+ slidee.style.transition = 'none';
+ }
+ slidee.style.transform = `translateX(-${clampedPosition}px)`;
+ }
+
+ setStoredScrollPosition(scroller, clampedPosition);
+ return clampedPosition;
+ }
+
+ /**
+ * Content-space X of a card relative to the scroller frame (undoes slidee translate).
+ */
+ function getCardContentOffsetX(scroller, card) {
+ if (!scroller || !card) return 0;
+ const current = getCurrentPosition(scroller);
+ const scrollerRect = scroller.getBoundingClientRect();
+ const cardRect = card.getBoundingClientRect();
+ return cardRect.left - scrollerRect.left + current;
+ }
+
+ /**
+ * JF-style toCenter: put card center on frame center, clamped to [0, max].
+ * Early cards stay left-aligned (desired < 0 → 0); mid/late track center.
+ */
+ function toCenterCard(scroller, card, options = {}) {
+ if (!scroller || !card || !scroller.contains(card)) return;
+ if (card.classList.contains('skeleton-card')) return;
+
+ const animate = options.animate !== false;
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) metrics.maxScroll = null;
+
+ const pad = readScrollerPadding(scroller);
+ const cardPad = readSampleCardPadding(scroller);
+ const contentOffset = getCardContentOffsetX(scroller, card);
+ const cardWidth = card.offsetWidth || card.getBoundingClientRect().width || 0;
+ // Center within the frame content box (same viewport as max-scroll)
+ const frameInnerWidth = getScrollerViewportWidth(scroller, pad);
+ const cardCenterInContent = contentOffset + cardWidth / 2 - pad.left;
+ const desired = cardCenterInContent - frameInnerWidth / 2;
+ const max = getMaxPositionFromPadding(scroller, pad, cardPad);
+ setSlideePosition(scroller, Math.min(Math.max(desired, 0), max), { animate: animate });
+ }
+
+ function attachTvFocusScroll(scroller) {
+ if (!scroller || scroller.dataset.tvFocusScroll === '1') return;
+ scroller.dataset.tvFocusScroll = '1';
+
+ let rafId = 0;
+ let pendingCard = null;
+
+ scroller.addEventListener(
+ 'focusin',
+ (e) => {
+ const card = e.target?.closest?.('.card');
+ if (!card || !scroller.contains(card)) return;
+ if (card.classList.contains('skeleton-card')) return;
+ pendingCard = card;
+ if (rafId) return;
+ rafId = requestAnimationFrame(() => {
+ rafId = 0;
+ const target = pendingCard;
+ pendingCard = null;
+ if (target && scroller.contains(target)) {
+ toCenterCard(scroller, target, { animate: true });
+ }
+ });
+ },
+ true,
+ );
+ }
+
+ function getFullyVisibleCardCount(scroller, cards) {
+ const epsilon = 1;
+ const pad = readScrollerPadding(scroller);
+ const cardPad = readSampleCardPadding(scroller);
+ const scrollerRect = scroller.getBoundingClientRect();
+ // Slidee is translated; frame is fixed — visible window is the content box
+ const contentWidth = getScrollerViewportWidth(scroller, pad);
+ const visibleLeft = scrollerRect.left + pad.left;
+ const visibleRight = visibleLeft + contentWidth;
+ let count = 0;
+ cards.forEach((card) => {
+ const cardRect = card.getBoundingClientRect();
+ if (cardRect.width <= 0) return;
+ const cardContentLeft = cardRect.left + cardPad.left;
+ const cardContentRight = cardRect.right - cardPad.right;
+ if (cardContentLeft >= visibleLeft - epsilon && cardContentRight <= visibleRight + epsilon) {
+ count++;
+ }
+ });
+ return count;
+ }
+
+ /**
+ * Max translateX so slidee content flushes to the frame content-box right edge.
+ * Pairs slidee.scrollWidth with viewport = clientWidth - padL - padR (not clientWidth + endInset hacks).
+ * @param {HTMLElement} scroller
+ * @param {{left:number,right:number}} [pad]
+ * @param {{left:number,right:number}} [_cardPad] unused; kept for call-site compatibility
+ */
+ function getMaxPositionFromPadding(scroller, pad, _cardPad) {
+ if (!scroller) return 0;
+
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics && metrics.maxScroll != null && Number.isFinite(metrics.maxScroll)) {
+ return metrics.maxScroll;
+ }
+
+ const cards = scroller.querySelectorAll('.itemsContainer > .card:not(.skeleton-card)');
+ const fullyVisible = getFullyVisibleCardCount(scroller, cards);
+ if (fullyVisible === cards.length) {
+ setMaxScrollPosition(scroller, 0);
+ return 0;
+ }
+
+ const resolvedPad = pad || readScrollerPadding(scroller);
+ const slidee = getScrollSlidee(scroller);
+ const contentWidth = slidee ? slidee.scrollWidth : scroller.scrollWidth;
+ const viewport = getScrollerViewportWidth(scroller, resolvedPad);
+ const maxScroll = Math.max(0, contentWidth - viewport);
+ setMaxScrollPosition(scroller, maxScroll);
+ return maxScroll;
+ }
+
+ function createScrollerElement() {
+ const scroller = document.createElement('div');
+ const isMobile = isMobileLayout();
+
+ scroller.setAttribute('data-horizontal', 'true');
+ scroller.setAttribute('data-centerfocus', 'true');
+ scroller.className = `padded-top-focusscale padded-bottom-focusscale emby-scroller padded-left custom-scroller${isMobile ? " scrollX hiddenScrollX" : ""}`;
+ scroller.setAttribute('data-scroll-mode-x', isMobile ? 'native' : 'custom');
+ scroller.style.scrollSnapType = 'none';
+ scroller.style.touchAction = 'auto';
+ scroller.style.overscrollBehaviorX = 'contain';
+ scroller.style.overscrollBehaviorY = 'auto';
+ scroller.style.webkitOverflowScrolling = 'touch';
+ // Desktop custom path: clip frame + translateX slidee. Mobile scrollX: native overflow swipe.
+ if (!isMobile) {
+ scroller.style.overflow = 'hidden';
+ }
+ setStoredScrollPosition(scroller, 0);
+
+ return scroller;
+ }
+
+ function createItemsSliderElement(isEmbyItemscontainer = true) {
+ const isMobile = isMobileLayout();
+ const itemsContainer = document.createElement('div');
+ if (isEmbyItemscontainer) {
+ itemsContainer.setAttribute('is', 'emby-itemscontainer');
+ }
+ const tvClass = isTvLayout() ? ' itemsContainer-tv' : '';
+ itemsContainer.className = `focuscontainer-x itemsContainer scrollSlider${!isMobile ? " animatedScrollX" : ""}${tvClass}`;
+ itemsContainer.style.whiteSpace = 'nowrap';
+ return itemsContainer;
+ }
+
+ /**
+ * Attach scroll buttons and desktop drag handlers to a scrollable section scroller.
+ * @param {HTMLElement} verticalSection
+ * @param {HTMLElement} scroller
+ * @returns {HTMLElement} scrollButtons container
+ */
+ function attachScrollableSectionChrome(verticalSection, scroller) {
+ const isMobile = isMobileLayout();
+ const scrollButtons = document.createElement('div');
+ scrollButtons.className = 'emby-scrollbuttons padded-right';
+
+ const leftButton = document.createElement('button');
+ leftButton.type = 'button';
+ leftButton.setAttribute('data-ripple', 'false');
+ leftButton.setAttribute('data-direction', 'left');
+ leftButton.setAttribute('title', 'Previous');
+ leftButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
+ leftButton.disabled = true;
+ const leftSpan = document.createElement('span');
+ leftSpan.className = 'material-icons chevron_left';
+ leftSpan.setAttribute('aria-hidden', 'true');
+ leftButton.appendChild(leftSpan);
+
+ function getCurrentPosition() {
+ return getScrollerPosition(scroller);
+ }
+
+ // Gesture-local padding: captured once per drag/wheel burst, cleared on end/idle
+ let gesturePad = null;
+ let gestureCardPad = null;
+ let wheelIdleTimer = null;
+
+ function ensureGestureInsets() {
+ if (!gesturePad) gesturePad = readScrollerPadding(scroller);
+ if (!gestureCardPad) gestureCardPad = readSampleCardPadding(scroller);
+ }
+
+ function clearGestureInsets() {
+ gesturePad = null;
+ gestureCardPad = null;
+ }
+
+ function getMaxPosition() {
+ ensureGestureInsets();
+ return getMaxPositionFromPadding(scroller, gesturePad, gestureCardPad);
+ }
+
+ function getMaxPositionFresh() {
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) {
+ metrics.maxScroll = null;
+ metrics.pad = null;
+ metrics.cardPad = null;
+ }
+ return getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
+ }
+
+ function setPosition(newPosition, options) {
+ const opts = options || {};
+ const slidee = getScrollSlidee(scroller);
+ const clampedPosition = setSlideePosition(scroller, newPosition, {
+ animate: !!opts.animate,
+ freshMax: !!opts.freshMax,
+ });
+
+ const section = scroller.closest('.emby-scroller-container');
+ if (section && typeof applyScrollButtonState === 'function') {
+ if (opts.animate && slidee) {
+ const onTransitionEnd = (e) => {
+ if (e && e.target !== slidee) return;
+ slidee.removeEventListener('transitionend', onTransitionEnd);
+ applyScrollButtonState(section);
+ };
+ slidee.addEventListener('transitionend', onTransitionEnd);
} else {
- // Swipe left (next)
- goToItem((currentIndex + 1) % items.length, true);
+ requestAnimationFrame(() => applyScrollButtonState(section));
}
}
- }, { passive: true });
-
- container.appendChild(bannerContainer);
-
- return container;
+ return clampedPosition;
+ }
+
+ function getScrollCards() {
+ const itemsContainer = scroller.querySelector('.itemsContainer');
+ if (!itemsContainer) return [];
+ return Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.skeleton-card)'));
+ }
+
+ function getCardScrollLeft(card) {
+ // Align card content edge (border-box left + card paddingLeft) to the
+ // scroller's padded content gutter. Content-space (undoes slidee translate).
+ const pad = readScrollerPadding(scroller);
+ const cardPad = readSampleCardPadding(scroller);
+ return getCardContentOffsetX(scroller, card) - pad.left + cardPad.left;
+ }
+
+ /**
+ * Cards fully inside the frame content-box clip.
+ * Frame is fixed; slidee translate moves cards — use live rects vs content-box edges.
+ */
+ function getFullyVisibleCardIndices(cards) {
+ const fullyVisible = [];
+ const epsilon = 1;
+ const pad = readScrollerPadding(scroller);
+ const cardPad = readSampleCardPadding(scroller);
+ const scrollerRect = scroller.getBoundingClientRect();
+ const contentWidth = getScrollerViewportWidth(scroller, pad);
+ const visibleLeft = scrollerRect.left + pad.left;
+ const visibleRight = visibleLeft + contentWidth;
+ cards.forEach((card, index) => {
+ const cardRect = card.getBoundingClientRect();
+ if (cardRect.width <= 0) return;
+ // Use content box (inside card padding) for visibility
+ const cardContentLeft = cardRect.left + cardPad.left;
+ const cardContentRight = cardRect.right - cardPad.right;
+ if (cardContentLeft >= visibleLeft - epsilon && cardContentRight <= visibleRight + epsilon) {
+ fullyVisible.push(index);
+ }
+ });
+ return fullyVisible;
+ }
+
+ function snapScrollToCardIndex(cards, targetIndex) {
+ if (!cards.length) return;
+ const clampedIndex = Math.max(0, Math.min(targetIndex, cards.length - 1));
+ // Last card: flush to frame content-box end (getMaxPositionFresh)
+ if (clampedIndex === cards.length - 1) {
+ setPosition(getMaxPositionFresh(), { animate: true, freshMax: true });
+ return;
+ }
+ const targetLeft = getCardScrollLeft(cards[clampedIndex]);
+ setPosition(targetLeft, { animate: true, freshMax: true });
+ }
+
+ leftButton.addEventListener('click', () => {
+ const cards = getScrollCards();
+ if (!cards.length) return;
+ const fullyVisible = getFullyVisibleCardIndices(cards);
+ const firstVisible = fullyVisible.length ? fullyVisible[0] : 0;
+ const step = Math.max(1, fullyVisible.length);
+ snapScrollToCardIndex(cards, firstVisible - step);
+ });
+
+ scrollButtons.appendChild(leftButton);
+
+ const rightButton = document.createElement('button');
+ rightButton.type = 'button';
+ rightButton.setAttribute('data-ripple', 'false');
+ rightButton.setAttribute('data-direction', 'right');
+ rightButton.setAttribute('title', 'Next');
+ rightButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
+ const rightSpan = document.createElement('span');
+ rightSpan.className = 'material-icons chevron_right';
+ rightSpan.setAttribute('aria-hidden', 'true');
+ rightButton.appendChild(rightSpan);
+
+ rightButton.addEventListener('click', () => {
+ const cards = getScrollCards();
+ if (!cards.length) return;
+ const fullyVisible = getFullyVisibleCardIndices(cards);
+ const lastVisible = fullyVisible.length ? fullyVisible[fullyVisible.length - 1] : -1;
+ const nextIndex = lastVisible + 1;
+ const lastWillBeVisible = nextIndex + fullyVisible.length >= cards.length;
+ if (nextIndex >= cards.length || lastWillBeVisible) {
+ setPosition(getMaxPositionFresh(), { animate: true, freshMax: true });
+ return;
+ }
+ snapScrollToCardIndex(cards, nextIndex);
+ });
+
+ scrollButtons.appendChild(rightButton);
+
+ let dragState = {
+ isDragging: false,
+ startX: 0,
+ startY: 0,
+ startPosition: 0,
+ currentPosition: 0,
+ maxPosition: 0,
+ hasMoved: false,
+ lastX: 0,
+ lastTime: 0,
+ velocity: 0,
+ momentumId: null,
+ };
+
+ if (!isMobile) {
+ const DRAG_THRESHOLD_PX = 8;
+
+ function onPointerDown(e) {
+ if (e.type === 'mousedown' && e.button !== 0) {
+ return;
+ }
+
+ const point = e.touches ? e.touches[0] : e;
+
+ if (dragState.momentumId !== null) {
+ cancelAnimationFrame(dragState.momentumId);
+ dragState.momentumId = null;
+ }
+
+ dragState.startX = point.clientX;
+ dragState.startY = point.clientY;
+ dragState.isDragging = false;
+
+ function checkThreshold(moveEvent) {
+ const p = moveEvent.touches ? moveEvent.touches[0] : moveEvent;
+ const dx = p.clientX - dragState.startX;
+ const dy = p.clientY - dragState.startY;
+ if (Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return;
+
+ ensureGestureInsets();
+ dragState.isDragging = true;
+ dragState.startPosition = getCurrentPosition();
+ dragState.currentPosition = dragState.startPosition;
+ dragState.maxPosition = getMaxPosition();
+ dragState.hasMoved = false;
+ dragState.lastX = p.clientX;
+ dragState.lastTime = Date.now();
+ dragState.velocity = 0;
+
+ cleanupPending();
+ if (e.touches) {
+ document.addEventListener('touchmove', onDragMove, { passive: false });
+ document.addEventListener('touchend', onDragEnd, { passive: true });
+ document.addEventListener('touchcancel', onDragEnd, { passive: true });
+ } else {
+ document.addEventListener('mousemove', onDragMove);
+ document.addEventListener('mouseup', onDragEnd);
+ }
+ if (!moveEvent.touches) moveEvent.preventDefault();
+ }
+
+ function cleanupPending() {
+ if (e.touches) {
+ document.removeEventListener('touchmove', checkThreshold);
+ document.removeEventListener('touchend', onPointerUp);
+ } else {
+ document.removeEventListener('mousemove', checkThreshold);
+ document.removeEventListener('mouseup', onPointerUp);
+ }
+ }
+
+ function onPointerUp() {
+ if (!dragState.isDragging) cleanupPending();
+ }
+
+ if (e.touches) {
+ document.addEventListener('touchmove', checkThreshold, { passive: false });
+ document.addEventListener('touchend', onPointerUp, { passive: true });
+ } else {
+ document.addEventListener('mousemove', checkThreshold);
+ document.addEventListener('mouseup', onPointerUp);
+ e.preventDefault();
+ }
+ }
+
+ function onDragMove(e) {
+ if (!dragState.isDragging) return;
+
+ const point = e.touches ? e.touches[0] : e;
+ const deltaX = dragState.startX - point.clientX;
+ const deltaY = dragState.startY - point.clientY;
+
+ if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) {
+ dragState.hasMoved = true;
+
+ const rawNewPosition = dragState.startPosition + deltaX;
+ const clampedPosition = Math.min(Math.max(rawNewPosition, 0), Math.max(dragState.maxPosition, 0));
+ dragState.currentPosition = clampedPosition;
+
+ setPosition(clampedPosition, { animate: false });
+
+ const currentTime = Date.now();
+ const timeDelta = currentTime - dragState.lastTime;
+ if (timeDelta > 0) {
+ const positionDelta = point.clientX - dragState.lastX;
+ dragState.velocity = positionDelta / timeDelta;
+ }
+ dragState.lastX = point.clientX;
+ dragState.lastTime = currentTime;
+
+ if (e.touches) {
+ e.preventDefault();
+ }
+ }
+ }
+
+ function onDragEnd(e) {
+ if (!dragState.isDragging) return;
+
+ if (e.type === 'touchend' || e.type === 'touchcancel') {
+ document.removeEventListener('touchmove', onDragMove);
+ document.removeEventListener('touchend', onDragEnd);
+ document.removeEventListener('touchcancel', onDragEnd);
+ } else {
+ document.removeEventListener('mousemove', onDragMove);
+ document.removeEventListener('mouseup', onDragEnd);
+ }
+
+ if (dragState.hasMoved) {
+ scroller.style.pointerEvents = 'none';
+ requestAnimationFrame(() => {
+ scroller.style.pointerEvents = '';
+ });
+ }
+
+ dragState.isDragging = false;
+
+ const friction = 0.965;
+ const velocityThreshold = 0.01;
+ const velocityBoost = 2.0;
+
+ dragState.velocity *= velocityBoost;
+
+ if (Math.abs(dragState.velocity) > velocityThreshold) {
+ let currentPosition = dragState.currentPosition;
+ const maxPosition = dragState.maxPosition;
+ let lastTimestamp = 0;
+
+ const step = (timestamp) => {
+ const frameTime = lastTimestamp ? timestamp - lastTimestamp : 16;
+ lastTimestamp = timestamp;
+
+ if (Math.abs(dragState.velocity) > velocityThreshold) {
+ currentPosition -= dragState.velocity * frameTime;
+ currentPosition = Math.min(Math.max(currentPosition, 0), Math.max(maxPosition, 0));
+ setPosition(currentPosition, { animate: false });
+
+ if (currentPosition === 0 || currentPosition === maxPosition) {
+ dragState.velocity = 0;
+ dragState.momentumId = null;
+ clearGestureInsets();
+ return;
+ }
+
+ dragState.velocity *= friction;
+ dragState.momentumId = requestAnimationFrame(step);
+ } else {
+ dragState.velocity = 0;
+ dragState.momentumId = null;
+ clearGestureInsets();
+ }
+ };
+
+ dragState.momentumId = requestAnimationFrame(step);
+ } else {
+ dragState.velocity = 0;
+ dragState.momentumId = null;
+ clearGestureInsets();
+ }
+ }
+
+ scroller.addEventListener('mousedown', onPointerDown);
+ scroller.addEventListener('touchstart', onPointerDown, { passive: true });
+
+ // Trackpad / mouse wheel → custom transform scroll (native overflow scrollers handle this themselves).
+ // Keep passive:false so we can preventDefault on horizontal gestures, but coalesce apply work
+ // into one rAF so the event callback stays cheap under high-frequency trackpad input.
+ const WHEEL_DELTA_MIN = 1;
+ const WHEEL_IDLE_MS = 150;
+ let pendingWheelDx = 0;
+ let wheelRafId = null;
+ const flushPendingWheel = () => {
+ wheelRafId = null;
+ const dx = pendingWheelDx;
+ pendingWheelDx = 0;
+ if (!dx) return;
+ ensureGestureInsets();
+ setPosition(getCurrentPosition() + dx, { animate: false });
+ };
+ scroller.addEventListener(
+ 'wheel',
+ (e) => {
+ if (scroller.classList.contains('scrollX')) return;
+
+ let dx = e.deltaX;
+ let dy = e.deltaY;
+ // Line mode: normalize without layout. Page mode: defer clientWidth until apply frame.
+ if (e.deltaMode === 1) {
+ dx *= 16;
+ dy *= 16;
+ }
+
+ const shiftHorizontal = e.shiftKey && Math.abs(dy) >= WHEEL_DELTA_MIN && Math.abs(dx) < WHEEL_DELTA_MIN;
+ if (shiftHorizontal) {
+ dx = dy;
+ dy = 0;
+ }
+
+ if (Math.abs(dx) < WHEEL_DELTA_MIN) return;
+ if (Math.abs(dx) < Math.abs(dy)) return;
+
+ e.preventDefault();
+
+ if (e.deltaMode === 2) {
+ dx *= scroller.clientWidth || 1;
+ }
+
+ pendingWheelDx += dx;
+ if (wheelIdleTimer) clearTimeout(wheelIdleTimer);
+ wheelIdleTimer = setTimeout(() => {
+ wheelIdleTimer = null;
+ clearGestureInsets();
+ }, WHEEL_IDLE_MS);
+
+ if (wheelRafId == null) {
+ wheelRafId = requestAnimationFrame(flushPendingWheel);
+ }
+ },
+ { passive: false },
+ );
+ }
+
+ return scrollButtons;
}
/**
@@ -1452,22 +8498,47 @@
* @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square'
* @returns {HTMLElement} - The constructed scrollable container
*/
- function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) {
- // Create the main vertical section container
- const verticalSection = document.createElement('div');
- verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
+ function getFacetRowType(items) {
+ if (!Array.isArray(items) || items.length === 0) return null;
+ let genreCount = 0;
+ let studioCount = 0;
+ items.forEach((item) => {
+ if (item?.Type === 'Genre') genreCount++;
+ else if (item?.Type === 'Studio') studioCount++;
+ });
+ const threshold = Math.ceil(items.length / 2);
+ if (genreCount >= threshold) return 'Genre';
+ if (studioCount >= threshold) return 'Studio';
+ return null;
+ }
- // Create section title
- const sectionTitleContainer = document.createElement('div');
- sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
-
- if (viewMoreUrl) {
- // Create clickable title with chevron icon
+ function appendSectionTitleContent(sectionTitleContainer, { title, caption, captionUrl, viewMoreUrl, discoveryPending }) {
+ const captionText = caption && String(caption).trim();
+ const hasCaption = !!captionText;
+ const useSkeletonTitle = discoveryPending === true;
+ let parent = sectionTitleContainer;
+
+ if (hasCaption) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'sectionTitle-wrapper';
+ sectionTitleContainer.appendChild(wrapper);
+ parent = wrapper;
+ }
+
+ if (useSkeletonTitle) {
+ const titleText = document.createElement('h2');
+ titleText.className = 'sectionTitle sectionTitle-cards';
+ const skeletonTitle = document.createElement('span');
+ skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
+ skeletonTitle.setAttribute('aria-hidden', 'true');
+ titleText.appendChild(skeletonTitle);
+ parent.appendChild(titleText);
+ } else if (viewMoreUrl) {
const titleLink = document.createElement('a');
- titleLink.className = 'sectionTitle-link sectionTitleTextButton';
+ 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;';
-
- // Handle both URL and function
+
if (typeof viewMoreUrl === 'function') {
titleLink.addEventListener('click', (e) => {
e.preventDefault();
@@ -1476,106 +8547,2344 @@
} else {
titleLink.href = viewMoreUrl;
}
-
+
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
-
+
const chevronIcon = document.createElement('span');
chevronIcon.className = 'material-icons chevron_right';
chevronIcon.setAttribute('aria-hidden', 'true');
-
+
titleLink.appendChild(titleText);
titleLink.appendChild(chevronIcon);
- sectionTitleContainer.appendChild(titleLink);
+ parent.appendChild(titleLink);
} else {
- // Regular non-clickable title
const titleText = document.createElement('h2');
titleText.className = 'sectionTitle sectionTitle-cards';
titleText.textContent = title;
- sectionTitleContainer.appendChild(titleText);
+ parent.appendChild(titleText);
}
- // Create "Show All" button (will be added after items are created)
- const showAllButton = document.createElement('button');
- showAllButton.type = 'button';
- showAllButton.className = 'show-all-button';
- showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer; color: var(--main-text, #fff) !important; margin-bottom: .35em; align-self: center;';
- showAllButton.textContent = 'Expand';
- showAllButton.title = 'Show all items';
+ if (hasCaption) {
+ let captionEl;
+ if (useSkeletonTitle) {
+ captionEl = document.createElement('span');
+ captionEl.className = 'sectionTitle sectionCaption';
+ const skeletonCaption = document.createElement('span');
+ skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
+ skeletonCaption.setAttribute('aria-hidden', 'true');
+ captionEl.appendChild(skeletonCaption);
+ } 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();
+ captionUrl();
+ });
+ } else {
+ captionEl.href = captionUrl;
+ }
+ captionEl.textContent = captionText;
+ } else {
+ captionEl = document.createElement('span');
+ captionEl.className = 'sectionTitle sectionCaption';
+ captionEl.textContent = captionText;
+ }
+ parent.appendChild(captionEl);
+ }
+ }
+
+ function createScrollableContainer(
+ items,
+ title,
+ viewMoreUrl = null,
+ overflowCard = false,
+ cardFormat = null,
+ sectionConfig = null,
+ registerScrollButtons = true,
+ ) {
+ const normalizedFormat = (cardFormat || '').toLowerCase();
+
+ // Create the main vertical section container
+ const verticalSection = document.createElement('div');
+ verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
+ const facetRowType = getFacetRowType(items);
+ if (facetRowType) {
+ verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`);
+ verticalSection.dataset.facetRowType = facetRowType;
+ }
+
+ // Persist the card format if provided (ensures consistency for random/updates)
+ if (cardFormat) {
+ verticalSection.setAttribute('data-card-format', cardFormat);
+ }
+
+ // Create section title
+ const sectionTitleContainer = document.createElement('div');
+ sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
+
+ appendSectionTitleContent(sectionTitleContainer, {
+ title,
+ caption: getSectionCaption(sectionConfig),
+ captionUrl: getSectionCaptionUrl(sectionConfig),
+ viewMoreUrl,
+ });
+
+ ensureSectionControlsMount(sectionTitleContainer);
// Create scroller container
- const scroller = document.createElement('div');
- scroller.setAttribute('is', 'emby-scroller');
- scroller.setAttribute('data-horizontal', 'true');
- scroller.setAttribute('data-centerfocus', 'card');
- scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller';
- scroller.setAttribute('data-scroll-mode-x', 'custom');
- // Enable smooth native horizontal touch scrolling (no snapping, no buttons)
- scroller.style.scrollSnapType = 'none';
- // Allow both axes so vertical page scroll isn't blocked when gesture starts over the scroller
- scroller.style.touchAction = 'auto';
- // Keep horizontal scroll self-contained but allow vertical to bubble to page
- scroller.style.overscrollBehaviorX = 'contain';
- scroller.style.overscrollBehaviorY = 'auto';
- // iOS inertia scrolling
- scroller.style.webkitOverflowScrolling = 'touch';
+ const scroller = createScrollerElement();
+ const itemsContainer = createItemsSliderElement();
- // Create items container
- const itemsContainer = document.createElement('div');
- itemsContainer.setAttribute('is', 'emby-itemscontainer');
- itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX';
- itemsContainer.style.whiteSpace = 'nowrap';
+ // Add items to container (TV → button cards via createCardElement)
+ const useParentCard = !!sectionConfig?.useParentCard;
+ const borderStyle = sectionConfig?.borderStyle || null;
- // Add items to container
- items.forEach((item, index) => {
- const card = createJellyfinCardElement(item, overflowCard, cardFormat);
+ items.forEach((item, index) => {
+ let imageParams = null;
+ if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) {
+ imageParams = getDoubledAspectRatioImageParams(cardFormat);
+ }
+
+ const card = createCardElement(
+ item,
+ overflowCard,
+ cardFormat,
+ item.cardFooter,
+ useParentCard,
+ borderStyle,
+ imageParams,
+ );
card.setAttribute('data-index', index);
itemsContainer.appendChild(card);
});
scroller.appendChild(itemsContainer);
- // Add "Show All" button to title if there are more than 20 items
- if (items.length > 20) {
- sectionTitleContainer.appendChild(showAllButton);
+ let scrollButtons = null;
+ if (isTvLayout()) {
+ attachTvFocusScroll(scroller);
+ } else {
+ scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
}
- // Toggle between scroll and grid view
- let isShowingAll = false;
- const originalItemsContainerStyle = itemsContainer.style.cssText;
-
- showAllButton.addEventListener('click', () => {
- // Find the scroll buttons for this container
- const scrollerContainer = showAllButton.closest('.emby-scroller-container');
- const scrollButtons = scrollerContainer ? scrollerContainer.querySelector('.emby-scrollbuttons') : null;
-
- if (isShowingAll) {
- // Switch back to scroll view
- itemsContainer.style.cssText = originalItemsContainerStyle;
- if (scrollButtons) scrollButtons.style.display = '';
- showAllButton.textContent = 'Expand';
- showAllButton.title = 'Show all items in a grid layout';
- isShowingAll = false;
- } else {
- // Switch to grid view
- itemsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 12px; white-space: normal; transform: none !important; transition: none !important;';
- if (scrollButtons) scrollButtons.style.display = 'none';
- showAllButton.textContent = 'Collapse';
- showAllButton.title = 'Show items in scrollable layout';
- isShowingAll = true;
- }
- });
-
// Assemble the section
verticalSection.appendChild(sectionTitleContainer);
+ if (scrollButtons) {
+ verticalSection.appendChild(scrollButtons);
+ }
verticalSection.appendChild(scroller);
+ if (registerScrollButtons && scrollButtons) {
+ registerScrollSectionScrollButtons(verticalSection);
+ }
+ return verticalSection;
+ }
+
+ function createSectionScrollableContainer(
+ items,
+ title,
+ viewMoreUrl = null,
+ overflowCard = false,
+ cardFormat = null,
+ sectionConfig = null,
+ ) {
+ return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat, sectionConfig);
+ }
+
+ /**
+ * Creates a skeleton card element for loading states
+ * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', 'square', 'logo', 'clear art', 'disc', 'banner', etc.
+ * @param {boolean} overflowCard - Use overflow card classes
+ * @param {Object|null} sectionConfig - Section config used to infer footer line count
+ * @returns {HTMLElement} - Skeleton card element
+ */
+ function createSkeletonCard(cardFormat = null, overflowCard = false, sectionConfig = null) {
+ const card = document.createElement('div');
+
+ // Determine card classes based on format (must match createJellyfinCardElement)
+ let cardClass, padderClass;
+ cardFormat = cardFormat?.toLowerCase() || 'portrait';
+
+ if (
+ cardFormat === 'backdrop' ||
+ cardFormat === 'thumb' ||
+ cardFormat === 'series thumb' ||
+ cardFormat === 'logo' ||
+ cardFormat === 'clear art'
+ ) {
+ cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard';
+ padderClass = 'cardPadder-backdrop';
+ } else if (cardFormat === 'square' || cardFormat === 'disc') {
+ cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard';
+ padderClass = 'cardPadder-square';
+ } else if (cardFormat === 'banner') {
+ cardClass = overflowCard ? 'overflowBannerCard' : 'bannerCard';
+ padderClass = 'cardPadder-banner';
+ } else {
+ // portrait / poster / series poster (default)
+ cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard';
+ padderClass = 'cardPadder-portrait';
+ }
+
+ card.className = `card ${cardClass} skeleton-card`;
+ card.setAttribute('data-skeleton', 'true');
+
+ const cardBox = document.createElement('div');
+ cardBox.className = `cardBox cardBox-bottompadded`;
+
+ const cardScalable = document.createElement('div');
+ cardScalable.className = 'cardScalable';
+
+ const cardPadder = document.createElement('div');
+ cardPadder.className = `cardPadder ${padderClass} skeleton-padder`;
+ cardPadder.style.cssText = 'background: rgba(255,255,255,0.12);';
+
+ cardScalable.appendChild(cardPadder);
+ cardBox.appendChild(cardScalable);
+
+ const footerLineCount = getSkeletonFooterLineCount(sectionConfig);
+ if (footerLineCount > 0) {
+ const cardTextStack = document.createElement('div');
+ cardTextStack.className = 'cardTextStack';
+
+ const lineWidths = ['70%', '55%', '40%'];
+ for (let lineIndex = 0; lineIndex < footerLineCount; lineIndex++) {
+ const isFirst = lineIndex === 0;
+ const cardText = document.createElement('div');
+ cardText.className = isFirst ? 'cardText cardTextCentered cardText-first' : 'cardText cardTextCentered cardText-secondary';
+
+ const bdi = document.createElement('bdi');
+ const skeletonLine = document.createElement('span');
+ skeletonLine.className = 'skeleton-text-line';
+ skeletonLine.style.width = lineWidths[lineIndex] || '40%';
+ bdi.appendChild(skeletonLine);
+ cardText.appendChild(bdi);
+ cardTextStack.appendChild(cardText);
+ }
+
+ cardBox.appendChild(cardTextStack);
+ }
+
+ card.appendChild(cardBox);
+
+ return card;
+ }
+
+ /**
+ * Creates a skeleton spotlight section for progressive enhancement.
+ * Layout/size are derived from options so the skeleton matches the final spotlight (Border/Borderless, Normal/Large/Full).
+ * @param {string} title - Title for the spotlight section
+ * @param {Object} options - Options for the spotlight carousel (viewMoreUrl, spotlightLayout, spotlightSize, fullScreen)
+ * @returns {HTMLElement} - Skeleton spotlight container
+ */
+ function createSkeletonSpotlightSection(title, options = {}) {
+ const { viewMoreUrl = null, discoveryPending = false, caption = null } = options;
+ const { layout, size } = resolveSpotlightPresentation(options);
+ const captionText = caption && String(caption).trim();
+ const useSkeletonTitle = discoveryPending === true;
+
+ // Create main container (same structure as real spotlight)
+ const container = document.createElement('div');
+ container.className = 'spotlight-section padded-left';
+ container.dataset.layout = layout;
+ container.dataset.size = size;
+
+ // Create banner container
+ const bannerContainer = document.createElement('div');
+ bannerContainer.className = 'spotlight-banner-container';
+
+ // Header wraps title (+ nav when present) to match live spotlight DOM.
+ // Title container hosts section name + refresh/configure controls.
+ // Always create it so controls can attach even when the section has no name.
+ {
+ const headerContainer = document.createElement('div');
+ headerContainer.className = 'spotlight-header-container';
+
+ const sectionTitleContainer = document.createElement('div');
+ sectionTitleContainer.className = 'spotlight-section-title-container';
+ if (useSkeletonTitle || title) {
+ let sectionTitleEl;
+ if (useSkeletonTitle) {
+ sectionTitleEl = document.createElement('div');
+ sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
+ const skeletonTitle = document.createElement('span');
+ skeletonTitle.className = 'skeleton-text-line skeleton-section-title';
+ skeletonTitle.setAttribute('aria-hidden', 'true');
+ sectionTitleEl.appendChild(skeletonTitle);
+ } else if (viewMoreUrl) {
+ const titleLink = document.createElement('a');
+ titleLink.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
+ titleLink.textContent = title;
+ titleLink.title = 'See All';
+ titleLink.style.textDecoration = 'none';
+ if (typeof viewMoreUrl === 'function') {
+ titleLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ viewMoreUrl();
+ });
+ } else {
+ titleLink.href = viewMoreUrl;
+ titleLink.addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
+ }
+ sectionTitleEl = titleLink;
+ } else {
+ sectionTitleEl = document.createElement('div');
+ sectionTitleEl.className = 'emby-tab-button emby-tab-button-active emby-button-foreground';
+ sectionTitleEl.textContent = title;
+ }
+ const sectionTitleWrapper = document.createElement('div');
+ sectionTitleWrapper.className = `spotlight-section-title ${!useSkeletonTitle && viewMoreUrl ? "" : "spotlight-title-link "}headerTabs sectionTabs`;
+ sectionTitleWrapper.appendChild(sectionTitleEl);
+ sectionTitleContainer.appendChild(sectionTitleWrapper);
+
+ if (useSkeletonTitle && captionText) {
+ const captionEl = document.createElement('span');
+ captionEl.className = 'sectionTitle sectionCaption';
+ const skeletonCaption = document.createElement('span');
+ skeletonCaption.className = 'skeleton-text-line skeleton-section-caption';
+ skeletonCaption.setAttribute('aria-hidden', 'true');
+ captionEl.appendChild(skeletonCaption);
+ sectionTitleContainer.appendChild(captionEl);
+ }
+ }
+ headerContainer.appendChild(sectionTitleContainer);
+ bannerContainer.appendChild(headerContainer);
+ }
+
+ // Create skeleton item (single full-container placeholder)
+ const itemsContainer = document.createElement('div');
+ itemsContainer.className = 'spotlight-items-container';
+
+ const skeletonItem = document.createElement('div');
+ skeletonItem.className = 'spotlight-item skeleton-spotlight-item';
+ skeletonItem.setAttribute('data-index', '0');
+ skeletonItem.setAttribute('data-active', 'true');
+ skeletonItem.style.cssText = 'background: rgba(255,255,255,0.12);';
+
+ itemsContainer.appendChild(skeletonItem);
+ bannerContainer.appendChild(itemsContainer);
+ ensureSkeletonShimmerSibling(itemsContainer);
+ container.appendChild(bannerContainer);
+
+ container.classList.add('skeleton-section');
+ observeSkeletonShimmer(container);
+
+ return container;
+ }
+
+ /**
+ * Creates a scrollable container with skeleton cards for progressive enhancement
+ * @param {string} title - Title for the scrollable container
+ * @param {string|Function} viewMoreUrl - Optional URL to make title clickable
+ * @param {string} cardFormat - Card format: 'portrait', 'backdrop', 'thumb', or 'square'
+ * @param {boolean} overflowCard - Use overflow card classes
+ * @returns {HTMLElement} - Container with skeleton cards
+ */
+ function createProgressivelyEnhancedScrollableContainer(
+ title,
+ viewMoreUrl = null,
+ cardFormat = null,
+ overflowCard = false,
+ sectionConfig = null,
+ ) {
+ const tvLayout = isTvLayout();
+
+ // Create the main vertical section container (same structure as createScrollableContainer)
+ const verticalSection = document.createElement('div');
+ verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container';
+
+ // Persist the card format if provided (ensures consistency for random/updates)
+ if (cardFormat) {
+ verticalSection.setAttribute('data-card-format', cardFormat);
+ }
+
+ // If the query is using the /Genres or /Studios paths, set the row hue to a random color
+ if (
+ sectionConfig?.queries?.some(
+ (query) => typeof query.path === 'string' && (query.path.includes('/Genres') || query.path.includes('/Studios')),
+ )
+ ) {
+ verticalSection.style.setProperty('--row-hue', `${Math.floor(Math.random() * 360)}deg`);
+ }
+
+ // Create section title
+ const sectionTitleContainer = document.createElement('div');
+ sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left';
+
+ const discoveryPending = sectionConfig?.discoveryPending === true;
+ appendSectionTitleContent(sectionTitleContainer, {
+ title,
+ caption: getSectionCaption(sectionConfig),
+ captionUrl: getSectionCaptionUrl(sectionConfig),
+ viewMoreUrl: discoveryPending ? null : viewMoreUrl,
+ discoveryPending,
+ });
+
+ ensureSectionControlsMount(sectionTitleContainer);
+
+ const scroller = createScrollerElement();
+ const itemsContainer = createItemsSliderElement(true);
+
+ const itemsLayout = resolveItemsLayout(sectionConfig);
+ const skeletonCount = getSectionSkeletonCount(sectionConfig, cardFormat, itemsLayout);
+ for (let i = 0; i < skeletonCount; i++) {
+ const skeletonCard = createSkeletonCard(cardFormat, overflowCard, sectionConfig);
+ skeletonCard.setAttribute('data-index', i);
+ itemsContainer.appendChild(skeletonCard);
+ }
+
+ scroller.appendChild(itemsContainer);
+ ensureSkeletonShimmerSibling(itemsContainer);
+
+ verticalSection.appendChild(sectionTitleContainer);
+ if (tvLayout) {
+ attachTvFocusScroll(scroller);
+ } else {
+ const scrollButtons = attachScrollableSectionChrome(verticalSection, scroller);
+ if (scrollButtons) {
+ verticalSection.appendChild(scrollButtons);
+ }
+ }
+ verticalSection.appendChild(scroller);
+
+ verticalSection.classList.add('skeleton-section');
+ observeSkeletonShimmer(verticalSection);
+
return verticalSection;
}
+ /**
+ * Measures the width of an element
+ * @param {HTMLElement} el - Element to measure
+ * @returns {number} - Width of the element
+ */
+ function measure(el) {
+ if (!el) {
+ return 0;
+ }
+
+ let measureRoot = document.querySelector('#measure-root');
+ if (!measureRoot) {
+ measureRoot = document.createElement('div');
+ measureRoot.id = 'measure-root';
+ measureRoot.style.cssText =
+ 'position: absolute; visibility: hidden; contain: layout style paint; white-space: nowrap; pointer-events: none;';
+ document.body.appendChild(measureRoot);
+ }
+
+ const clonedEl = el.cloneNode(true);
+ measureRoot.appendChild(clonedEl);
+ const width = clonedEl.getBoundingClientRect().width;
+ measureRoot.removeChild(clonedEl);
+ return width;
+ }
+
+ /**
+ * Checks the state of a promise
+ * @param {Promise} p - Promise to check
+ * @returns {string} - State of the promise: 'pending', 'fulfilled', or 'rejected'
+ */
+ function promiseState(p) {
+ const t = {};
+ return Promise.race([p, t]).then(
+ (v) => (v === t ? 'pending' : 'fulfilled'),
+ () => 'rejected',
+ );
+ }
+
+ // Section-level skeleton shimmer (keyframes live in cardBuilder.css)
+ let skeletonShimmerObserver = null;
+ /** @type {WeakMap} */
+ const skeletonShimmerRafBySection = new WeakMap();
+
+ function prefersReducedSkeletonMotion() {
+ return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ }
+
+ function ensureSkeletonShimmerObserver() {
+ if (skeletonShimmerObserver || typeof IntersectionObserver === 'undefined') return skeletonShimmerObserver;
+ skeletonShimmerObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ const el = entry.target;
+ if (!el?.classList?.contains('skeleton-section')) {
+ unobserveSkeletonShimmer(el);
+ return;
+ }
+ const pendingRaf = skeletonShimmerRafBySection.get(el);
+ if (pendingRaf) {
+ cancelAnimationFrame(pendingRaf);
+ skeletonShimmerRafBySection.delete(el);
+ }
+ if (entry.isIntersecting && !prefersReducedSkeletonMotion()) {
+ const raf = requestAnimationFrame(() => {
+ skeletonShimmerRafBySection.delete(el);
+ if (el.isConnected && el.classList.contains('skeleton-section')) {
+ el.classList.add('skeleton-shimmer-active');
+ }
+ });
+ skeletonShimmerRafBySection.set(el, raf);
+ } else {
+ el.classList.remove('skeleton-shimmer-active');
+ }
+ });
+ },
+ { root: null, rootMargin: '100px 0px', threshold: 0.01 },
+ );
+ return skeletonShimmerObserver;
+ }
+
+ /**
+ * Insert a .skeleton-shimmer sibling after the items host (inside scroller / banner).
+ * @param {HTMLElement|null|undefined} itemsHost .itemsContainer or .spotlight-items-container
+ * @returns {HTMLElement|null}
+ */
+ function ensureSkeletonShimmerSibling(itemsHost) {
+ if (!itemsHost?.parentElement) return null;
+ const parent = itemsHost.parentElement;
+ let shimmer = null;
+ if (itemsHost.nextElementSibling?.classList?.contains('skeleton-shimmer')) {
+ shimmer = itemsHost.nextElementSibling;
+ } else {
+ shimmer = parent.querySelector(':scope > .skeleton-shimmer');
+ }
+ if (!shimmer) {
+ shimmer = document.createElement('div');
+ shimmer.className = 'skeleton-shimmer';
+ shimmer.setAttribute('aria-hidden', 'true');
+ itemsHost.insertAdjacentElement('afterend', shimmer);
+ } else if (shimmer.previousElementSibling !== itemsHost) {
+ itemsHost.insertAdjacentElement('afterend', shimmer);
+ }
+ return shimmer;
+ }
+
+ function removeSkeletonShimmerOverlays(root) {
+ if (!root?.querySelectorAll) return;
+ root.querySelectorAll('.skeleton-shimmer').forEach((el) => el.remove());
+ }
+
+ function observeSkeletonShimmer(sectionElement) {
+ if (!sectionElement?.classList) return;
+ sectionElement.classList.add('skeleton-section');
+ sectionElement.classList.remove('skeleton-shimmer-active');
+ const itemsHost =
+ sectionElement.querySelector('.spotlight-items-container') ||
+ sectionElement.querySelector('.itemsContainer');
+ ensureSkeletonShimmerSibling(itemsHost);
+ const observer = ensureSkeletonShimmerObserver();
+ if (observer) observer.observe(sectionElement);
+ }
+
+ function unobserveSkeletonShimmer(sectionElement) {
+ if (!sectionElement) return;
+ const pendingRaf = skeletonShimmerRafBySection.get(sectionElement);
+ if (pendingRaf) {
+ cancelAnimationFrame(pendingRaf);
+ skeletonShimmerRafBySection.delete(sectionElement);
+ }
+ sectionElement.classList?.remove('skeleton-shimmer-active', 'skeleton-section');
+ removeSkeletonShimmerOverlays(sectionElement);
+ if (skeletonShimmerObserver) {
+ try {
+ skeletonShimmerObserver.unobserve(sectionElement);
+ } catch (_) {
+ /* ignore */
+ }
+ }
+ }
+
+ function stripCopiedSkeletonSectionClasses(el) {
+ el?.classList?.remove('skeleton-section', 'skeleton-shimmer-active');
+ removeSkeletonShimmerOverlays(el);
+ }
+
+ // Add skeleton loading animation CSS if not already present
+ if (!document.getElementById('skeleton-animation-style')) {
+ const style = document.createElement('style');
+ style.id = 'skeleton-animation-style';
+ style.textContent = `
+ @keyframes skeleton-shimmer-sweep {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ // Smart Lazy Image Loading with Global Observers
+ let lazyImageObserver = null;
+ /** @type {WeakMap} */
+ const lazyObserversByScroller = new WeakMap();
+ // Run updateScrollButtonStateForSection once when a section becomes visible (so --max-scroll is computed after layout)
+ let scrollSectionVisibilityObserver = null;
+
+ function getLazyScrollerRoot(el) {
+ if (!el || typeof el.closest !== 'function') return null;
+ return el.closest('.emby-scroller, .custom-scroller, [data-horizontal="true"]');
+ }
+
+ function clearLazyImagePaint(cardImageContainer) {
+ if (!cardImageContainer) return;
+ cardImageContainer.style.backgroundImage = '';
+ cardImageContainer.style.webkitMaskImage = '';
+ cardImageContainer.style.maskImage = '';
+ cardImageContainer.style.webkitMaskRepeat = '';
+ cardImageContainer.style.maskRepeat = '';
+ cardImageContainer.style.webkitMaskPosition = '';
+ cardImageContainer.style.maskPosition = '';
+ cardImageContainer.style.webkitMaskSize = '';
+ cardImageContainer.style.maskSize = '';
+ cardImageContainer.classList.remove('lazy-masked-svg');
+ }
+
+ function ensureLazyUnloadPlaceholder(cardImageContainer) {
+ const canvas = cardImageContainer.previousElementSibling;
+ const hasBlurhashCanvas = canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas');
+ const blurhash = cardImageContainer.getAttribute('data-blurhash');
+
+ if (state.useBlurhash && hasBlurhashCanvas && blurhash) {
+ // Unload restore: always prefer blurhash when available (ignore reduced-motion / saveData)
+ if (!canvas.hasAttribute('data-blurhash-pending') && canvas.classList.contains('lazy-hidden')) {
+ // Re-show existing painted blurhash, or re-queue decode
+ const hasPixels = canvas.hasAttribute('data-blurhash-painted');
+ if (!hasPixels) {
+ canvas.removeAttribute('data-blurhash-painted');
+ canvas.setAttribute('data-blurhash-pending', blurhash);
+ observeBlurhashCanvas(canvas);
+ }
+ }
+ canvas.classList.remove('lazy-hidden');
+ cardImageContainer.classList.add('lazy-hidden');
+ return;
+ }
+
+ if (hasBlurhashCanvas) {
+ canvas.classList.add('lazy-hidden');
+ }
+ cardImageContainer.classList.remove('lazy-hidden');
+ let icon = cardImageContainer.querySelector('.cardImageIcon');
+ if (!icon) {
+ icon = document.createElement('span');
+ icon.className = 'cardImageIcon material-icons';
+ icon.setAttribute('aria-hidden', 'true');
+ icon.textContent = 'movie';
+ cardImageContainer.appendChild(icon);
+ }
+ if (!/\bdefaultCardBackground\d\b/.test(cardImageContainer.className)) {
+ cardImageContainer.classList.add('defaultCardBackground1');
+ }
+ }
+
+ function applyLazyImageToContainer(cardImageContainer, imageUrl, isSvg) {
+ if (!cardImageContainer?.isConnected) return;
+ if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
+ // Cancelled / unloaded before apply drained
+ if (!cardImageContainer.hasAttribute('data-loading')) return;
+ // Style writes first, then one class/attr batch so paint absorbs a single invalidation.
+ if (isSvg) {
+ const maskUrl = `url("${imageUrl}")`;
+ cardImageContainer.style.webkitMaskImage = maskUrl;
+ cardImageContainer.style.maskImage = maskUrl;
+ cardImageContainer.style.webkitMaskRepeat = 'no-repeat';
+ cardImageContainer.style.maskRepeat = 'no-repeat';
+ cardImageContainer.style.webkitMaskPosition = 'center';
+ cardImageContainer.style.maskPosition = 'center';
+ cardImageContainer.style.webkitMaskSize = 'contain';
+ cardImageContainer.style.maskSize = 'contain';
+ cardImageContainer.style.backgroundColor = 'white';
+ } else {
+ cardImageContainer.style.backgroundImage = `url("${imageUrl}")`;
+ }
+ cardImageContainer.classList.remove('lazy', 'lazy-hidden');
+ if (isSvg) {
+ cardImageContainer.classList.add('lazy-loaded', 'lazy-masked-svg');
+ } else {
+ cardImageContainer.classList.add('lazy-loaded');
+ }
+ cardImageContainer.removeAttribute('data-loading');
+ const icon = cardImageContainer.querySelector('.cardImageIcon');
+ if (icon) icon.remove();
+ const canvas = cardImageContainer.previousElementSibling;
+ if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
+ canvas.classList.add('lazy-hidden');
+ canvas.removeAttribute('data-blurhash-pending');
+ }
+ }
+
+ function unloadLazyImage(cardImageContainer) {
+ if (!cardImageContainer?.classList?.contains('lazy-loaded')) return;
+ clearLazyImagePaint(cardImageContainer);
+ cardImageContainer.classList.remove('lazy-loaded');
+ cardImageContainer.classList.add('lazy');
+ cardImageContainer.removeAttribute('data-loading');
+ ensureLazyUnloadPlaceholder(cardImageContainer);
+ }
+
+ function startLazyImageLoad(cardImageContainer) {
+ const imageUrl = cardImageContainer.getAttribute('data-src');
+ if (!imageUrl) return;
+ if (cardImageContainer.hasAttribute('data-loading')) return;
+ if (cardImageContainer.classList.contains('lazy-loaded')) return;
+
+ cardImageContainer.setAttribute('data-loading', 'true');
+ const img = new Image();
+ const finishError = () => {
+ if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
+ cardImageContainer.removeAttribute('data-loading');
+ cardImageContainer.classList.remove('lazy');
+ cardImageContainer.classList.add('lazy-loaded');
+ cardImageContainer.classList.remove('lazy-hidden');
+ const canvas = cardImageContainer.previousElementSibling;
+ if (canvas && canvas.tagName === 'CANVAS' && canvas.classList.contains('blurhash-canvas')) {
+ canvas.classList.add('lazy-hidden');
+ canvas.removeAttribute('data-blurhash-pending');
+ }
+ };
+ img.onload = () => {
+ if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
+ if (!cardImageContainer.isConnected) return;
+ if (!cardImageContainer.hasAttribute('data-loading')) return;
+ const isSvg = /\.svg(?:[?#]|$)/i.test(imageUrl);
+ requestAnimationFrame(() => {
+ if (cardImageContainer.getAttribute('data-src') !== imageUrl) return;
+ if (!cardImageContainer.isConnected) return;
+ if (!cardImageContainer.hasAttribute('data-loading')) return;
+ applyLazyImageToContainer(cardImageContainer, imageUrl, isSvg);
+ });
+ };
+ img.onerror = finishError;
+ img.src = imageUrl;
+ }
+
+ function handleLazyImageIntersection(entries, observer) {
+ const mobile = isMobileLayout();
+ entries.forEach((entry) => {
+ const cardImageContainer = entry.target;
+ if (entry.isIntersecting) {
+ startLazyImageLoad(cardImageContainer);
+ if (!mobile) {
+ observer.unobserve(cardImageContainer);
+ }
+ return;
+ }
+ if (mobile && cardImageContainer.classList.contains('lazy-loaded')) {
+ unloadLazyImage(cardImageContainer);
+ }
+ });
+ }
+
+ function createLazyImageObserver(root) {
+ return new IntersectionObserver(handleLazyImageIntersection, {
+ root: root || null,
+ threshold: 0,
+ rootMargin: '50%',
+ });
+ }
+
+ /**
+ * Initialize the global IntersectionObserver for lazy loading images
+ * Watches when elements enter the viewport and loads their images
+ */
+ function initLazyImageObserver() {
+ if (lazyImageObserver) return; // Already initialized
+ lazyImageObserver = createLazyImageObserver(null);
+ }
+
+ function observeLazyImage(cardImageContainer) {
+ if (!cardImageContainer || !cardImageContainer.hasAttribute('data-src')) return;
+ initLazyImageObserver();
+ if (!lazyImageObserver) return;
+
+ if (isMobileLayout()) {
+ const scroller = getLazyScrollerRoot(cardImageContainer);
+ if (scroller) {
+ let observer = lazyObserversByScroller.get(scroller);
+ if (!observer) {
+ observer = createLazyImageObserver(scroller);
+ lazyObserversByScroller.set(scroller, observer);
+ }
+ observer.observe(cardImageContainer);
+ return;
+ }
+ }
+
+ lazyImageObserver.observe(cardImageContainer);
+ }
+
+ // --- Push lazy path (parallel native sections) — independent of progressive MO/kill-switch ---
+ let pushLazyImageObserver = null;
+ /** @type {WeakMap} */
+ const pushLazyObserversByScroller = new WeakMap();
+ let pushBlurhashFillObserver = null;
+
+ function initPushLazyImageObserver() {
+ if (pushLazyImageObserver) return;
+ if (typeof IntersectionObserver === 'undefined') return;
+ pushLazyImageObserver = createLazyImageObserver(null);
+ }
+
+ function observeLazyImagePush(cardImageContainer) {
+ if (!cardImageContainer || !cardImageContainer.hasAttribute('data-src')) return;
+ initPushLazyImageObserver();
+ if (!pushLazyImageObserver) {
+ // No IO: load immediately
+ startLazyImageLoad(cardImageContainer);
+ return;
+ }
+
+ if (isMobileLayout()) {
+ const scroller = getLazyScrollerRoot(cardImageContainer);
+ if (scroller) {
+ let observer = pushLazyObserversByScroller.get(scroller);
+ if (!observer) {
+ observer = createLazyImageObserver(scroller);
+ pushLazyObserversByScroller.set(scroller, observer);
+ }
+ observer.observe(cardImageContainer);
+ return;
+ }
+ }
+
+ pushLazyImageObserver.observe(cardImageContainer);
+ }
+
+ function initPushBlurhashFillObserver() {
+ if (pushBlurhashFillObserver) return;
+ if (typeof IntersectionObserver === 'undefined') return;
+ pushBlurhashFillObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) return;
+ const canvas = entry.target;
+ pushBlurhashFillObserver.unobserve(canvas);
+ fillBlurhashCanvas(canvas);
+ });
+ },
+ {
+ threshold: 0,
+ rootMargin: getBlurhashFillRootMargin(),
+ },
+ );
+ }
+
+ function observeBlurhashCanvasPush(canvas) {
+ if (!canvas || !canvas.hasAttribute('data-blurhash-pending')) return;
+ if (shouldSkipBlurhashDecode()) {
+ canvas.removeAttribute('data-blurhash-pending');
+ canvas.classList.add('lazy-hidden');
+ return;
+ }
+ initPushBlurhashFillObserver();
+ if (pushBlurhashFillObserver) {
+ pushBlurhashFillObserver.observe(canvas);
+ } else {
+ fillBlurhashCanvas(canvas);
+ }
+ }
+
+ function observePendingBlurhashInNodePush(node) {
+ if (!node || node.nodeType !== 1) return;
+ if (node.matches && node.matches('canvas.blurhash-canvas[data-blurhash-pending]')) {
+ observeBlurhashCanvasPush(node);
+ }
+ if (node.querySelectorAll) {
+ node.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvasPush);
+ }
+ }
+
+ /**
+ * Push-register lazy images + blurhash under an attached root.
+ * Call sites should pass each section's .itemsContainer (never .homeSectionsContainer).
+ * @param {ParentNode|Element|null|undefined} root
+ */
+ function registerLazyImagesIn(root) {
+ if (!root) return;
+ if (root.nodeType === 1 && root.matches?.('.cardImageContainer[data-src]')) {
+ observeLazyImagePush(root);
+ }
+ if (root.querySelectorAll) {
+ root.querySelectorAll('.cardImageContainer[data-src]').forEach(observeLazyImagePush);
+ }
+ observePendingBlurhashInNodePush(root);
+ }
+
+ /** Register lazy images for a section via its .itemsContainer only. */
+ function registerLazyImagesInSection(sectionElement) {
+ if (!sectionElement?.querySelector) return;
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ if (itemsContainer) registerLazyImagesIn(itemsContainer);
+ }
+
+ /**
+ * Apply scroll button state for a section from in-memory scrollX / maxScroll.
+ */
+ function applyScrollButtonState(verticalSection) {
+ const scroller = verticalSection.querySelector('.emby-scroller');
+ const scrollButtons = verticalSection.querySelector('.emby-scrollbuttons');
+ const leftButton = scrollButtons && scrollButtons.querySelector('button[data-direction="left"]');
+ const rightButton = scrollButtons && scrollButtons.querySelector('button[data-direction="right"]');
+ if (!scroller || !scrollButtons || !leftButton || !rightButton) return;
+ const scrollX = getScrollerPosition(scroller);
+
+ let maxScroll = 0;
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics && metrics.maxScroll != null && Number.isFinite(metrics.maxScroll)) {
+ maxScroll = metrics.maxScroll;
+ } else {
+ maxScroll = getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
+ }
+ leftButton.disabled = scrollX <= 20;
+ rightButton.disabled = scrollX >= maxScroll - 1;
+ scrollButtons.classList.toggle('hide', leftButton.disabled && rightButton.disabled);
+ }
+
+ /**
+ * Compute max scroll from dimensions and apply button state. Called once when section is visible so layout is ready.
+ */
+ function updateScrollButtonStateForSection(verticalSection) {
+ const scroller = verticalSection.querySelector('.emby-scroller');
+ if (!scroller) return;
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) metrics.maxScroll = null;
+ getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller));
+ applyScrollButtonState(verticalSection);
+ }
+
+ /**
+ * Ensure scroll section visibility observer exists. When a section becomes visible, runs updateScrollButtonStateForSection once then unobserves.
+ */
+ function getScrollSectionVisibilityObserver() {
+ if (scrollSectionVisibilityObserver) return scrollSectionVisibilityObserver;
+ scrollSectionVisibilityObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) return;
+ const verticalSection = entry.target;
+ scrollSectionVisibilityObserver.unobserve(verticalSection);
+ const run = () => updateScrollButtonStateForSection(verticalSection);
+ if (typeof requestIdleCallback === 'function') {
+ requestIdleCallback(run, { timeout: 200 });
+ } else {
+ requestAnimationFrame(() => requestAnimationFrame(run));
+ }
+ });
+ },
+ { root: null, threshold: 0.01 },
+ );
+ return scrollSectionVisibilityObserver;
+ }
+
+ /**
+ * Register a scrollable section: when it becomes visible, set max scroll once and apply initial button state.
+ */
+ function registerScrollSectionScrollButtons(verticalSection) {
+ const itemsContainer = verticalSection.querySelector('.itemsContainer');
+ const scroller = verticalSection.querySelector('.emby-scroller');
+ const first = itemsContainer && itemsContainer.firstElementChild;
+ const last = itemsContainer && itemsContainer.lastElementChild;
+ if (!first || !last) {
+ if (scroller) {
+ setMaxScrollPosition(scroller, 0);
+ applyScrollButtonState(verticalSection);
+ }
+ return;
+ }
+ getScrollSectionVisibilityObserver().observe(verticalSection);
+ }
+
+ /**
+ * Initialize push lazy loading (IntersectionObserver only — no body MutationObserver).
+ */
+ function initSmartLazyLoading() {
+ if (typeof IntersectionObserver === 'undefined') {
+ return;
+ }
+
+ initLazyImageObserver();
+ initBlurhashFillObserver();
+ initPushLazyImageObserver();
+ initPushBlurhashFillObserver();
+
+ // One-time scan for nodes already present before first push register
+ document.querySelectorAll('.cardImageContainer[data-src]').forEach(observeLazyImagePush);
+ document.querySelectorAll('canvas.blurhash-canvas[data-blurhash-pending]').forEach(observeBlurhashCanvasPush);
+ }
+
+ // Initialize smart lazy loading on module load
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initSmartLazyLoading);
+ } else {
+ initSmartLazyLoading();
+ }
+
+ // Progressive section enhancement (deferred via IntersectionObserver when enhanceOnVisible)
+ let sectionEnhanceObserver = null;
+ let sectionEnhanceObserverRootMargin = null;
+ const sectionEnhancePending = new WeakMap();
+
+ /** FIFO: at most one section enhance apply per animation frame. */
+ const enhanceFifoQueue = [];
+ let enhanceFifoRafScheduled = false;
+
+ function enqueueSectionEnhance(task) {
+ if (typeof task !== 'function') return;
+ enhanceFifoQueue.push(task);
+ if (enhanceFifoRafScheduled) return;
+ enhanceFifoRafScheduled = true;
+ const drain = () => {
+ const next = enhanceFifoQueue.shift();
+ if (next) {
+ try {
+ next();
+ } catch (err) {
+ console.warn('[KefinTweaks CardBuilder] enhance FIFO task failed:', err);
+ }
+ }
+ if (enhanceFifoQueue.length) {
+ requestAnimationFrame(drain);
+ } else {
+ enhanceFifoRafScheduled = false;
+ }
+ };
+ requestAnimationFrame(drain);
+ }
+
+ function applyProgressiveEnhancementQueued(sectionElement, section, result, options) {
+ enqueueSectionEnhance(() => {
+ if (!sectionElement?.isConnected) return;
+ applyProgressiveEnhancement(sectionElement, section, result, options);
+ });
+ }
+
+ /**
+ * Read the currently active spotlight slide from a live section.
+ * @param {HTMLElement} sectionEl
+ * @returns {{ slide: HTMLElement, index: number, id: string } | null}
+ */
+ function getActiveSpotlightSlide(sectionEl) {
+ if (!sectionEl) return null;
+ const slide = sectionEl.querySelector('.spotlight-item[data-active="true"], .spotlight-item[data-active]');
+ if (!slide || slide.classList.contains('skeleton-spotlight-item')) return null;
+ const index = parseInt(slide.getAttribute('data-index'), 10);
+ const id = slide.getAttribute('data-id');
+ if (!id || Number.isNaN(index) || index < 0) return null;
+ return { slide, index, id };
+ }
+
+ /**
+ * Build the item list for a keep-current-slide spotlight refresh.
+ * Dedupes activeId from results when present; inserts at i or appends as N+1 when beyond.
+ * @param {Array} newItems
+ * @param {{ index: number, id: string }} activeMeta
+ * @param {Array} dataItems - prior/cached items used to resolve the active item object
+ * @returns {{ items: Array, preserveIndex: number }}
+ */
+ function buildSpotlightRefreshItems(newItems, activeMeta, dataItems) {
+ const source = Array.isArray(newItems) ? newItems.slice() : [];
+ const i = activeMeta.index;
+ const activeId = String(activeMeta.id);
+ const N = source.length;
+
+ const findById = (list) => (list || []).find((d) => d && String(d.Id) === activeId);
+ let activeItem = findById(dataItems) || findById(source);
+ if (!activeItem) {
+ return {
+ items: source,
+ preserveIndex: Math.min(i, Math.max(0, N - 1)),
+ canPreserve: false,
+ };
+ }
+
+ // Prefer cached object so the kept slide matches item data
+ activeItem = findById(dataItems) || activeItem;
+
+ const wasInResults = source.some((d) => d && String(d.Id) === activeId);
+ let working = wasInResults ? source.filter((d) => !(d && String(d.Id) === activeId)) : source;
+
+ if (wasInResults) {
+ if (i <= working.length) {
+ working.splice(i, 0, activeItem);
+ return { items: working, preserveIndex: i, canPreserve: true };
+ }
+ working.push(activeItem);
+ return { items: working, preserveIndex: working.length - 1, canPreserve: true };
+ }
+
+ if (i < N) {
+ working.splice(i, 0, activeItem);
+ return { items: working, preserveIndex: i, canPreserve: true };
+ }
+ working.push(activeItem);
+ return { items: working, preserveIndex: working.length - 1, canPreserve: true };
+ }
+
+ /**
+ * Replace the rebuilt slide at preserveIndex with the detached active slide DOM.
+ * @param {HTMLElement} newSection
+ * @param {HTMLElement} activeSlide
+ * @param {number} preserveIndex
+ */
+ function transplantActiveSpotlightSlide(newSection, activeSlide, preserveIndex) {
+ if (!newSection || !activeSlide) return;
+ const container = newSection.querySelector('.spotlight-items-container');
+ if (!container) return;
+
+ activeSlide.setAttribute('data-index', String(preserveIndex));
+ activeSlide.setAttribute('data-active', 'true');
+ activeSlide.removeAttribute('data-fade-out');
+ activeSlide.removeAttribute('data-entering');
+
+ const built = container.querySelector(`.spotlight-item[data-index="${preserveIndex}"]`);
+ if (built) {
+ built.replaceWith(activeSlide);
+ } else {
+ container.appendChild(activeSlide);
+ }
+ }
+
+ /**
+ * Rebuild a spotlight section from fresh items, optionally preserving the active slide.
+ * @param {HTMLElement} sectionElement
+ * @param {Array} items
+ * @param {Object} options
+ * @param {string} options.name
+ * @param {string} [options.viewMoreUrl]
+ * @param {Object} [options.spotlightConfig]
+ * @param {Array} [options.dataItems]
+ * @param {boolean} [options.keepCurrentSlide]
+ * @param {Object} [options.sectionConfig]
+ * @returns {{ content: HTMLElement, items: Array }}
+ */
+ /**
+ * Rebuild a spotlight section from fresh items in-place (keeps section shell).
+ * Enhance/SWR (keepCurrentSlide): leave active slide DOM alone; swap inactive only.
+ * Manual refresh (!keepCurrentSlide): overwrite active + inactive slides.
+ * Skeleton sections still get a full createSpotlightSection + replaceWith for wiring.
+ * @returns {{ content: HTMLElement, items: Array }}
+ */
+ function refreshSpotlight(sectionElement, items, options = {}) {
+ const { name, viewMoreUrl = null, spotlightConfig = {}, dataItems = [], keepCurrentSlide = false, sectionConfig = null } = options;
+
+ let itemsForBuild = Array.isArray(items) ? items.slice() : [];
+ let preserveIndex = 0;
+ let activeSlide = null;
+ let doPreserve = false;
+
+ const isSkeleton = !!sectionElement?.querySelector?.('.skeleton-spotlight-item');
+ const liveItemsContainer = sectionElement?.querySelector?.('.spotlight-items-container');
+
+ if (keepCurrentSlide && !isSkeleton) {
+ const activeMeta = getActiveSpotlightSlide(sectionElement);
+ if (activeMeta) {
+ const built = buildSpotlightRefreshItems(itemsForBuild, activeMeta, dataItems);
+ if (built.canPreserve) {
+ itemsForBuild = built.items;
+ preserveIndex = built.preserveIndex;
+ activeSlide = activeMeta.slide;
+ doPreserve = true;
+ }
+ }
+ }
+
+ // Skeleton or missing live container: full rebuild + replaceWith (needs interactive wiring)
+ if (isSkeleton || !liveItemsContainer) {
+ const content = createSpotlightSection(itemsForBuild, name, {
+ viewMoreUrl,
+ ...spotlightConfig,
+ ...(doPreserve ? { initialIndex: preserveIndex } : {}),
+ });
+
+ content.style.cssText = sectionElement.style.cssText;
+ content.className = sectionElement.className;
+ Array.from(sectionElement.attributes).forEach((attr) => {
+ content.setAttribute(attr.name, attr.value);
+ });
+
+ if (sectionConfig) {
+ attachSectionControlButtons(sectionConfig, content, itemsForBuild);
+ }
+
+ stripCopiedSkeletonSectionClasses(content);
+ unobserveSkeletonShimmer(sectionElement);
+ sectionElement.replaceWith(content);
+ ensureSectionRevealObservation(content);
+ return { content, items: itemsForBuild };
+ }
+
+ // In-place: harvest slides from an inert build; never transplant the active slide
+ const temp = createSpotlightSection(itemsForBuild, name, {
+ viewMoreUrl,
+ ...spotlightConfig,
+ initialIndex: doPreserve
+ ? preserveIndex
+ : parseInt(liveItemsContainer.querySelector('.spotlight-item[data-active]')?.getAttribute('data-index'), 10) || 0,
+ inert: true,
+ });
+ const tempItems = temp.querySelector('.spotlight-items-container');
+
+ if (doPreserve && activeSlide && tempItems) {
+ const keepId = activeSlide.getAttribute('data-id');
+ Array.from(liveItemsContainer.children).forEach((slide) => {
+ if (slide !== activeSlide) slide.remove();
+ });
+ Array.from(tempItems.children).forEach((slide) => {
+ if (keepId && slide.getAttribute('data-id') === keepId) {
+ slide.remove();
+ return;
+ }
+ liveItemsContainer.appendChild(slide);
+ });
+ activeSlide.setAttribute('data-index', String(preserveIndex));
+ activeSlide.setAttribute('data-active', 'true');
+ activeSlide.removeAttribute('data-fade-out');
+ activeSlide.removeAttribute('data-entering');
+ } else if (tempItems) {
+ liveItemsContainer.replaceChildren(...Array.from(tempItems.children));
+ }
+
+ // Update title text/link on the same root (spotlight title lives under banner)
+ const titleEl = sectionElement.querySelector('.spotlight-section-title .emby-tab-button');
+ if (titleEl && name != null) titleEl.textContent = name;
+ updateSectionViewMoreLink(sectionElement, viewMoreUrl);
+
+ if (typeof sectionElement._kefinSpotlightApplyItems === 'function') {
+ sectionElement._kefinSpotlightApplyItems(itemsForBuild, {
+ currentIndex: doPreserve ? preserveIndex : undefined,
+ });
+ }
+ if (typeof sectionElement._kefinSpotlightPruneWindow === 'function') {
+ sectionElement._kefinSpotlightPruneWindow();
+ }
+
+ if (sectionConfig) {
+ attachSectionControlButtons(sectionConfig, sectionElement, itemsForBuild);
+ }
+
+ ensureSectionRevealObservation(sectionElement);
+ return { content: sectionElement, items: itemsForBuild };
+ }
+
+ function getPaintedCardIds(sectionElement) {
+ const cards = sectionElement.querySelectorAll('.itemsContainer > .card[data-id]:not(.skeleton-card)');
+ if (cards.length) {
+ return Array.from(cards)
+ .map((card) => card.getAttribute('data-id'))
+ .filter(Boolean);
+ }
+ const buttons = sectionElement.querySelectorAll('.itemsContainer > .homeLibraryButton[data-id]');
+ return Array.from(buttons)
+ .map((button) => button.getAttribute('data-id'))
+ .filter(Boolean);
+ }
+
+ function classifyIdSequence(staleIds, freshIds) {
+ if (!staleIds.length || !freshIds.length) return { type: 'replace' };
+ if (staleIds.length === freshIds.length && staleIds.every((id, i) => id === freshIds[i])) {
+ return { type: 'perfect' };
+ }
+
+ const staleSet = new Set(staleIds);
+ const freshSet = new Set(freshIds);
+ const sharedStale = staleIds.filter((id) => freshSet.has(id));
+ const sharedFresh = freshIds.filter((id) => staleSet.has(id));
+ if (!sharedStale.length || sharedStale.length !== sharedFresh.length) return { type: 'replace' };
+ if (sharedStale.some((id, i) => id !== sharedFresh[i])) return { type: 'replace' };
+ return { type: 'reconcile' };
+ }
+
+ function runAfterTransition(element, durationMs, callback) {
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ element.removeEventListener('transitionend', onEnd);
+ callback();
+ };
+ const onEnd = (event) => {
+ if (event.target !== element) return;
+ finish();
+ };
+ element.addEventListener('transitionend', onEnd);
+ setTimeout(finish, durationMs);
+ }
+
+ function getItemProgress(item) {
+ if (item.Type === 'Program') {
+ const isOnNow = Date.now() >= new Date(item.StartDate).getTime() && Date.now() <= new Date(item.EndDate).getTime()
+ if (isOnNow) {
+ const progress = (Date.now() - new Date(item.StartDate).getTime()) / (new Date(item.EndDate).getTime() - new Date(item.StartDate).getTime());
+ const progressPercentage = Math.round(progress * 100);
+ return progressPercentage;
+ }
+ }
+ if (item.UserData?.PlayedPercentage) {
+ return item.UserData?.PlayedPercentage;
+ }
+ return 0;
+ }
+
+ function patchCardProgressBar(card, item) {
+ const imageContainer = card.querySelector('.cardImageContainer');
+ if (!imageContainer) return;
+
+ const percent = getItemProgress(item);
+ const existingBar = imageContainer.querySelector('.itemProgressBarForeground');
+ const existingFooter = existingBar?.closest('.innerCardFooter');
+
+ if (percent && percent > 0) {
+ if (existingBar) {
+ if (existingBar.style.width !== `${percent}%`) {
+ existingBar.style.width = `${percent}%`;
+ }
+ return;
+ }
+ const innerCardFooter = document.createElement('div');
+ innerCardFooter.className = 'innerCardFooter fullInnerCardFooter innerCardFooterClear';
+ const itemProgressBar = document.createElement('div');
+ itemProgressBar.className = 'itemProgressBar';
+ const itemProgressBarForeground = document.createElement('div');
+ itemProgressBarForeground.className = 'itemProgressBarForeground';
+ itemProgressBarForeground.style.width = `${percent}%`;
+ itemProgressBar.appendChild(itemProgressBarForeground);
+ innerCardFooter.appendChild(itemProgressBar);
+ imageContainer.appendChild(innerCardFooter);
+ return;
+ }
+
+ if (existingFooter && existingFooter.querySelector('.itemProgressBar')) {
+ existingFooter.remove();
+ }
+ }
+
+ function patchCardsUserData(sectionElement, items) {
+ items.forEach((item) => {
+ if (!item?.Id) return;
+ updateCardResumeAttributes(item.Id, item.UserData || {});
+ const escapedId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(item.Id) : String(item.Id).replace(/["\\]/g, '\\$&');
+ const card = sectionElement.querySelector(`.itemsContainer > .card[data-id="${escapedId}"]`);
+ if (card) patchCardProgressBar(card, item);
+ });
+ }
+
+ function getCardOuterWidth(card) {
+ if (!card) return 0;
+ const style = window.getComputedStyle(card);
+ return card.getBoundingClientRect().width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0);
+ }
+
+ function getScrollerPosition(scroller) {
+ if (!scroller) return 0;
+ if (scroller.classList.contains('scrollX')) return scroller.scrollLeft || 0;
+ return getCurrentPosition(scroller);
+ }
+
+ function setScrollerPosition(scroller, position, animate) {
+ if (!scroller) return;
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) metrics.maxScroll = null;
+
+ if (scroller.classList.contains('scrollX')) {
+ const maxPosition = getMaxPositionFromPadding(
+ scroller,
+ readScrollerPadding(scroller),
+ readSampleCardPadding(scroller),
+ );
+ const scrollPosition = Math.min(Math.max(position, 0), maxPosition);
+ const clamped = scrollPosition > 20 ? scrollPosition : 0;
+ // Clear any desktop slidee transform so it cannot fight native scrollLeft
+ const slidee = getScrollSlidee(scroller);
+ if (slidee) {
+ if (slidee.style.transform) slidee.style.transform = '';
+ if (slidee.style.transition) slidee.style.transition = '';
+ }
+ if (scroller.style.transform) scroller.style.transform = '';
+ scroller.scrollTo({ left: clamped, behavior: animate ? 'smooth' : 'auto' });
+ setStoredScrollPosition(scroller, clamped);
+ } else {
+ const slidee = getScrollSlidee(scroller);
+ setSlideePosition(scroller, position, { animate: !!animate, freshMax: true });
+
+ const section = scroller.closest('.emby-scroller-container');
+ if (section) {
+ if (animate && slidee) {
+ const onTransitionEnd = (e) => {
+ if (e && e.target !== slidee) return;
+ slidee.removeEventListener('transitionend', onTransitionEnd);
+ applyScrollButtonState(section);
+ };
+ slidee.addEventListener('transitionend', onTransitionEnd);
+ requestAnimationFrame(() => applyScrollButtonState(section));
+ } else {
+ applyScrollButtonState(section);
+ }
+ }
+ return;
+ }
+
+ const section = scroller.closest('.emby-scroller-container');
+ if (section) {
+ if (animate) {
+ requestAnimationFrame(() => applyScrollButtonState(section));
+ } else {
+ applyScrollButtonState(section);
+ }
+ }
+ }
+
+ /**
+ * Scroll a Kefin custom scroller so card at index is left-aligned (last card → max).
+ * @param {HTMLElement} scroller
+ * @param {number} index
+ * @param {{ animate?: boolean }} [options]
+ */
+ function scrollToCardIndex(scroller, index, options = {}) {
+ if (!scroller) return;
+ const animate = options.animate === true;
+ const itemsContainer = scroller.querySelector('.itemsContainer');
+ if (!itemsContainer) return;
+ const cards = Array.from(itemsContainer.querySelectorAll(':scope > .card:not(.skeleton-card)'));
+ if (!cards.length) return;
+
+ const clampedIndex = Math.max(0, Math.min(index, cards.length - 1));
+ const metrics = getScrollerMetrics(scroller);
+ if (metrics) {
+ metrics.maxScroll = null;
+ metrics.pad = null;
+ metrics.cardPad = null;
+ }
+
+ if (clampedIndex === cards.length - 1) {
+ setScrollerPosition(
+ scroller,
+ getMaxPositionFromPadding(scroller, readScrollerPadding(scroller), readSampleCardPadding(scroller)),
+ animate,
+ );
+ return;
+ }
+
+ const pad = readScrollerPadding(scroller);
+ const cardPad = readSampleCardPadding(scroller);
+ const targetLeft = getCardContentOffsetX(scroller, cards[clampedIndex]) - pad.left + cardPad.left;
+ setScrollerPosition(scroller, targetLeft, animate);
+
+ const section = scroller.closest('.emby-scroller-container');
+ if (section) applyScrollButtonState(section);
+ }
+
+ function reindexSectionCards(itemsContainer) {
+ getRealLayoutCards(itemsContainer).forEach((card, index) => {
+ card.setAttribute('data-index', String(index));
+ });
+ }
+
+ function clearCardReconcileStyles(card) {
+ card.classList.remove('cardbuilder-card-insert', 'cardbuilder-card-collapse', 'is-in');
+ card.style.width = '';
+ card.style.minWidth = '';
+ card.style.maxWidth = '';
+ card.style.marginLeft = '';
+ card.style.marginRight = '';
+ card.style.overflow = '';
+ card.style.opacity = '';
+ card.style.paddingLeft = '';
+ card.style.paddingRight = '';
+ }
+
+ function captureCardLayout(card) {
+ const style = window.getComputedStyle(card);
+ return {
+ card,
+ width: card.getBoundingClientRect().width,
+ marginLeft: style.marginLeft,
+ marginRight: style.marginRight,
+ };
+ }
+
+ function applyCardCollapsedSize(card) {
+ card.style.width = '0px';
+ card.style.minWidth = '0px';
+ card.style.maxWidth = '0px';
+ card.style.marginLeft = '0px';
+ card.style.marginRight = '0px';
+ card.style.overflow = 'hidden';
+ card.style.opacity = '0';
+ }
+
+ function applyCardExpandedSize(layout) {
+ const { card, width, marginLeft, marginRight } = layout;
+ card.style.width = `${width}px`;
+ card.style.minWidth = `${width}px`;
+ card.style.maxWidth = `${width}px`;
+ card.style.marginLeft = marginLeft;
+ card.style.marginRight = marginRight;
+ card.style.overflow = 'hidden';
+ card.style.opacity = '1';
+ }
+
+ function reconcileRowItems(sectionElement, items, sectionConfig, onComplete) {
+ console.log(`[KefinTweaks CardBuilder] Reconciling row items for section: ${sectionConfig.name} with ${items.length} items`);
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ const scroller = sectionElement.querySelector('.emby-scroller');
+ if (!itemsContainer) return false;
+
+ const overflowCard = sectionConfig.overflowCard;
+ const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
+
+ const paintedCards = getRealLayoutCards(itemsContainer).filter((card) => {
+ return card.getAttribute('data-id') && !card.classList.contains('skeleton-card');
+ });
+ const cardById = new Map();
+ paintedCards.forEach((card) => {
+ const id = card.getAttribute('data-id');
+ if (id && !cardById.has(id)) cardById.set(id, card);
+ });
+
+ const freshIds = sectionConfig?.useParentCard
+ ? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean)
+ : items.map((item) => item?.Id).filter(Boolean);
+ const freshSet = new Set(freshIds);
+ const outgoing = paintedCards.filter((card) => !freshSet.has(card.getAttribute('data-id')));
+ const inserted = [];
+ const prefixInserted = [];
+
+ let nextNode = paintedCards[0] || null;
+ let seenKept = false;
+
+ items.forEach((item, index) => {
+ if (!item?.Id) return;
+ const existing = cardById.get(item.Id);
+ if (existing) {
+ seenKept = true;
+ nextNode = existing.nextElementSibling;
+ while (nextNode && nextNode.classList.contains('skeleton-card')) {
+ nextNode = nextNode.nextElementSibling;
+ }
+ return;
+ }
+
+ let imageParams = null;
+ if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) {
+ imageParams = getDoubledAspectRatioImageParams(cardFormat);
+ }
+
+ const card = createCardElement(
+ item,
+ overflowCard,
+ cardFormat,
+ item.cardFooter,
+ !!sectionConfig?.useParentCard,
+ sectionConfig?.borderStyle || null,
+ imageParams,
+ );
+ if (nextNode) itemsContainer.insertBefore(card, nextNode);
+ else itemsContainer.appendChild(card);
+ inserted.push(card);
+ if (!seenKept) prefixInserted.push(card);
+ });
+
+ ensureCardBorders(sectionElement);
+ registerLazyImagesIn(itemsContainer);
+
+ const previousPosition = getScrollerPosition(scroller);
+ let prefixWidth = 0;
+ prefixInserted.forEach((card) => {
+ prefixWidth += getCardOuterWidth(card);
+ });
+
+ const midInserted = inserted.filter((card) => !prefixInserted.includes(card));
+ const midLayouts = midInserted.map(captureCardLayout);
+ const outgoingLayouts = outgoing.map(captureCardLayout);
+ const canSlidePrefix = !!(scroller && prefixWidth > 0);
+ const prefixLayouts = canSlidePrefix ? [] : prefixInserted.map(captureCardLayout);
+
+ midLayouts.forEach(({ card }) => {
+ card.classList.add('cardbuilder-card-insert');
+ applyCardCollapsedSize(card);
+ });
+ prefixLayouts.forEach(({ card }) => {
+ card.classList.add('cardbuilder-card-insert');
+ applyCardCollapsedSize(card);
+ });
+ outgoingLayouts.forEach((layout) => {
+ layout.card.classList.add('cardbuilder-card-collapse');
+ applyCardExpandedSize(layout);
+ });
+ if (canSlidePrefix) {
+ prefixInserted.forEach((card) => {
+ card.classList.add('cardbuilder-card-insert');
+ card.style.opacity = '0';
+ });
+ setScrollerPosition(scroller, previousPosition + prefixWidth, false);
+ }
+
+ itemsContainer.offsetHeight;
+ itemsContainer.classList.add('cardbuilder-row-animating');
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ outgoingLayouts.forEach(({ card }) => applyCardCollapsedSize(card));
+ midLayouts.forEach((layout) => {
+ layout.card.classList.add('is-in');
+ applyCardExpandedSize(layout);
+ });
+ prefixLayouts.forEach((layout) => {
+ layout.card.classList.add('is-in');
+ applyCardExpandedSize(layout);
+ });
+ if (canSlidePrefix) {
+ prefixInserted.forEach((card) => {
+ card.classList.add('is-in');
+ card.style.opacity = '1';
+ });
+ setScrollerPosition(scroller, previousPosition, true);
+ }
+
+ const animTarget = outgoing[0] || midInserted[0] || prefixInserted[0] || scroller || itemsContainer;
+ runAfterTransition(animTarget, 450, () => {
+ outgoing.forEach((card) => card.remove());
+ inserted.forEach(clearCardReconcileStyles);
+ itemsContainer.classList.remove('cardbuilder-row-animating');
+ if (scroller) {
+ scroller.style.transition = '';
+ const slidee = getScrollSlidee(scroller);
+ if (slidee) slidee.style.transition = '';
+ }
+ reindexSectionCards(itemsContainer);
+ patchCardsUserData(sectionElement, items);
+ ensureCardBorders(sectionElement);
+ if (typeof onComplete === 'function') {
+ onComplete();
+ }
+ });
+ });
+ });
+
+ return true;
+ }
+
+ function replaceItemsContainerContents(sectionElement, items, sectionConfig) {
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ if (!itemsContainer || !sectionConfig) return sectionElement;
+
+ const overflowCard = sectionConfig.overflowCard;
+ 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 gapless = itemsContainer.getAttribute('data-gapless') === 'true' || resolveUseGaplessCards(sectionConfig);
+
+ const fragment = document.createDocumentFragment();
+ if (normalizedFormat === 'button' || isButtonCardFormat(cardFormat)) {
+ items.forEach((item, index) => {
+ const button = createLibraryButtonElement(item);
+ button.setAttribute('data-index', String(index));
+ fragment.appendChild(button);
+ });
+ } else {
+ const useParentCard = !!sectionConfig?.useParentCard;
+ const borderStyle = sectionConfig?.borderStyle || null;
+
+ items.forEach((item, index) => {
+ let imageParams = null;
+ if (sectionConfig?.sectionCssClass && sectionConfig?.sectionCssClass.includes('rows-2') && index === 0) {
+ imageParams = getDoubledAspectRatioImageParams(cardFormat);
+ }
+
+ const card = createCardElement(
+ item,
+ overflowCard,
+ cardFormat,
+ item.cardFooter,
+ useParentCard,
+ borderStyle,
+ imageParams,
+ );
+ card.setAttribute('data-index', String(index));
+ fragment.appendChild(card);
+ });
+ }
+
+ replaceChildren(itemsContainer, fragment);
+
+ applyItemsLayoutState(sectionElement, layout, gapless);
+ ensureCardBorders(sectionElement);
+ attachSectionControlButtons(sectionConfig, sectionElement, items);
+ registerLazyImagesIn(itemsContainer);
+
+ const scroller = sectionElement.querySelector('.emby-scroller');
+ invalidateScrollerMetrics(scroller);
+ if (scroller && isTvLayout()) {
+ attachTvFocusScroll(scroller);
+ const focusedCard = document.activeElement?.closest?.('.card');
+ if (focusedCard && scroller.contains(focusedCard)) {
+ requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false }));
+ }
+ }
+
+ return sectionElement;
+ }
+
+ function fadeReplaceRowItems(sectionElement, items, sectionConfig, onComplete) {
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ if (!itemsContainer) return false;
+
+ const finish = () => {
+ replaceItemsContainerContents(sectionElement, items, sectionConfig);
+ requestAnimationFrame(() => itemsContainer.classList.remove('is-hidden'));
+ if (typeof onComplete === 'function') {
+ onComplete();
+ }
+ };
+
+ if (isMobileLayout()) {
+ finish();
+ return true;
+ }
+
+ itemsContainer.classList.add('cardbuilder-items-fade');
+ itemsContainer.classList.add('is-hidden');
+ runAfterTransition(itemsContainer, 320, finish);
+ return true;
+ }
+
+ function replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially) {
+ let finalCardFormat = sectionConfig.cardFormat;
+ const skeletonCardFormat = sectionElement.getAttribute('data-card-format');
+ if (skeletonCardFormat) {
+ finalCardFormat = skeletonCardFormat;
+ }
+
+ if (sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight') {
+ const refreshed = refreshSpotlight(sectionElement, items, {
+ name: getSectionTitleForMultiQuery(sectionConfig) || sectionConfig.name,
+ viewMoreUrl: getActiveViewMoreUrl(sectionConfig) || sectionConfig.viewMoreUrl,
+ spotlightConfig: sectionConfig.spotlightConfig,
+ dataItems: Array.isArray(dataItems) ? dataItems : [],
+ keepCurrentSlide: true,
+ sectionConfig,
+ });
+ ensureCardBorders(refreshed.content);
+ ensureSectionRevealObservation(refreshed.content);
+ return refreshed.content;
+ }
+
+ // Prefer in-place itemsContainer swap (sticky is=emby-itemscontainer)
+ if (sectionElement.querySelector('.itemsContainer')) {
+ rebuildSectionTitleInPlace(sectionElement, sectionConfig);
+ replaceItemsContainerContents(sectionElement, items, {
+ ...sectionConfig,
+ cardFormat: finalCardFormat,
+ });
+ ensureSectionRevealObservation(sectionElement);
+ return sectionElement;
+ }
+
+ const content = createScrollableContainer(
+ items,
+ sectionConfig.name,
+ sectionConfig.viewMoreUrl,
+ sectionConfig.overflowCard,
+ finalCardFormat,
+ sectionConfig,
+ false,
+ );
+ content.style.cssText = sectionElement.style.cssText;
+ content.className = sectionElement.className;
+ Array.from(sectionElement.attributes).forEach((attr) => {
+ content.setAttribute(attr.name, attr.value);
+ });
+
+ const oldItemsContainer = sectionElement.querySelector('.itemsContainer');
+ const layoutFromDom = oldItemsContainer?.getAttribute('data-layout');
+ const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
+ const gapless = oldItemsContainer
+ ? oldItemsContainer.getAttribute('data-gapless') === 'true'
+ : resolveUseGaplessCards(sectionConfig);
+ attachSectionControlButtons(sectionConfig, content, items);
+ invalidateScrollerMetrics(sectionElement.querySelector('.emby-scroller'));
+ stripCopiedSkeletonSectionClasses(content);
+ unobserveSkeletonShimmer(sectionElement);
+ sectionElement.replaceWith(content);
+ applyItemsLayoutState(content, layout, gapless);
+ ensureCardBorders(content);
+ ensureSectionRevealObservation(content);
+ return content;
+ }
+
+ function fadeReplaceSpotlight(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially) {
+ sectionElement.classList.add('cardbuilder-items-fade');
+ sectionElement.classList.add('is-hidden');
+ runAfterTransition(sectionElement, 320, () => {
+ const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
+ markSectionEnhanced(content, sectionConfig);
+ content.classList.add('cardbuilder-items-fade', 'is-hidden');
+ requestAnimationFrame(() => content.classList.remove('is-hidden'));
+ });
+ }
+
+ function markSectionEnhanced(sectionElement, sectionConfig, { updateScrollButtons = false } = {}) {
+ if (!sectionElement) return;
+ unobserveSkeletonShimmer(sectionElement);
+ sectionElement.dataset.enhanced = 'true';
+ if (typeof sectionConfig?._onSectionEnhanced === 'function') {
+ sectionConfig._onSectionEnhanced(sectionElement, sectionConfig);
+ }
+ if (updateScrollButtons) {
+ const scroller = sectionElement.querySelector?.('.emby-scroller');
+ if (scroller) invalidateScrollerMetrics(scroller);
+ if (isTvLayout()) {
+ if (scroller) {
+ attachTvFocusScroll(scroller);
+ const focusedCard = document.activeElement?.closest?.('.card');
+ if (focusedCard && scroller.contains(focusedCard)) {
+ requestAnimationFrame(() => toCenterCard(scroller, focusedCard, { animate: false }));
+ }
+ }
+ } else {
+ registerScrollSectionScrollButtons(sectionElement);
+ }
+ }
+ }
+
+ function getDismissEmptySectionTimerMs() {
+ const raw =
+ window.KefinTweaksConfig?.homeScreenConfig?.HOME_SETTINGS?.dismissEmptySectionTimer ??
+ window.KefinHomeConfig2?.HOME_SETTINGS?.dismissEmptySectionTimer ??
+ 0;
+ const n = Number(raw);
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
+ }
+
+ /**
+ * Show "No items found" at locked section height, then fade and remove.
+ * Used when progressive enhance resolves to an empty item list and the timer is > 0.
+ */
+ function dismissEmptyProgressiveSection(sectionElement, { holdMs = 1000 } = {}) {
+ if (!sectionElement || !sectionElement.isConnected) return;
+ if (sectionElement.dataset.emptyDismissing === 'true') return;
+
+ sectionElement.dataset.emptyDismissing = 'true';
+ const lockedHeight = Math.max(sectionElement.offsetHeight, 0);
+ if (lockedHeight > 0) {
+ sectionElement.style.minHeight = `${lockedHeight}px`;
+ }
+
+ const isSpotlight =
+ sectionElement.classList.contains('spotlight-section') ||
+ !!sectionElement.querySelector('.spotlight-items-container, .skeleton-spotlight-item');
+
+ const message = document.createElement('div');
+ message.className = 'cardbuilder-empty-message';
+ message.textContent = 'No items found';
+
+ if (isSpotlight) {
+ const itemsContainer = sectionElement.querySelector('.spotlight-items-container');
+ if (itemsContainer) {
+ itemsContainer.innerHTML = '';
+ itemsContainer.appendChild(message);
+ } else {
+ const body = sectionElement.querySelector('.spotlight-banner-container') || sectionElement;
+ Array.from(body.children).forEach((child) => {
+ if (
+ !child.classList.contains('spotlight-header-container') &&
+ !child.classList.contains('spotlight-section-title-container') &&
+ !child.classList.contains('sectionTitleContainer')
+ ) {
+ child.remove();
+ }
+ });
+ body.appendChild(message);
+ }
+ } else {
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ if (itemsContainer) {
+ itemsContainer.innerHTML = '';
+ itemsContainer.appendChild(message);
+ } else {
+ sectionElement.appendChild(message);
+ }
+ }
+
+ const hold = Math.max(0, Number(holdMs) || 0);
+ setTimeout(() => {
+ if (!sectionElement.isConnected) return;
+ sectionElement.classList.add('cardbuilder-items-fade');
+ sectionElement.classList.add('is-hidden');
+ runAfterTransition(sectionElement, 320, () => {
+ sectionElement.remove();
+ notifyDiscoveryLeadFillAfterSectionRemove();
+ });
+ }, hold);
+ }
+
+ function hasSectionDeferredData(result) {
+ if (!result) return false;
+ if (typeof result.ensureData === 'function') return true;
+ if (result.isStalePromise) return true;
+ return !!Object.getOwnPropertyDescriptor(result, 'dataPromise');
+ }
+
+ function getSectionDataPromise(result) {
+ if (!result) return null;
+ if (typeof result.ensureData === 'function') return result.ensureData();
+ const desc = Object.getOwnPropertyDescriptor(result, 'dataPromise');
+ if (desc?.get) return desc.get.call(result);
+ return result.dataPromise || null;
+ }
+
+ function applyProgressiveEnhancement(
+ sectionElement,
+ section,
+ result,
+ { revealSectionsSequentially = false, updateScrollButtons = false } = {},
+ ) {
+ if (!sectionElement?.isConnected || !section?.config) return;
+
+ setSectionRefreshing(sectionElement, false);
+ sectionElement.dataset.enhanceApplied = 'true';
+
+ const sectionConfig = section.config;
+ let items = result?.Items ?? result ?? [];
+ if (!Array.isArray(items)) items = [];
+
+ const dataItems = section.result?.data?.Items ?? section.result?.data ?? [];
+ const isSpotlight = sectionConfig.spotlight || sectionConfig.renderMode === 'Spotlight';
+ const isSkeleton = !!sectionElement.querySelector('.skeleton-card, .skeleton-spotlight-item');
+ const hasSkeletonTitle = !!sectionElement.querySelector('.skeleton-section-title');
+ const enhanceMarkOpts = { updateScrollButtons: !!updateScrollButtons };
+
+ if (items.length === 0) {
+ const holdMs = getDismissEmptySectionTimerMs();
+ if (holdMs > 0) {
+ dismissEmptyProgressiveSection(sectionElement, { holdMs });
+ } else {
+ sectionElement.remove();
+ notifyDiscoveryLeadFillAfterSectionRemove();
+ }
+ return;
+ }
+
+ // Skeleton spotlight needs full createSpotlightSection wiring via replaceWith
+ if (isSkeleton && isSpotlight) {
+ const content = replaceSectionWithFreshCards(sectionElement, items, sectionConfig, dataItems, revealSectionsSequentially);
+ markSectionEnhanced(content, sectionConfig, enhanceMarkOpts);
+ return;
+ }
+
+ // Pending→resolved title (skeleton title text) + cards: rebuild title in place, swap cards
+ if (isSkeleton && hasSkeletonTitle && !isSpotlight) {
+ rebuildSectionTitleInPlace(sectionElement, sectionConfig);
+ replaceItemsContainerContents(sectionElement, items, sectionConfig);
+ markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
+ return;
+ }
+
+ if (!isSkeleton && isSpotlight) {
+ const staleIds = (Array.isArray(dataItems) ? dataItems : []).map((item) => item?.Id).filter(Boolean);
+ const paintedIds = staleIds.length
+ ? staleIds
+ : Array.from(sectionElement.querySelectorAll('.spotlight-item[data-id]:not(.skeleton-spotlight-item)'))
+ .map((el) => el.getAttribute('data-id'))
+ .filter(Boolean);
+ const freshIds = sectionConfig?.useParentCard
+ ? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean)
+ : items.map((item) => item?.Id).filter(Boolean);
+ const match = classifyIdSequence(paintedIds, freshIds);
+ if (match.type === 'perfect') {
+ markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
+ return;
+ }
+ // In-place inactive slides only (keepCurrentSlide); no full-section replaceWith
+ const spotlightContent = replaceSectionWithFreshCards(
+ sectionElement,
+ items,
+ sectionConfig,
+ dataItems,
+ revealSectionsSequentially,
+ );
+ markSectionEnhanced(spotlightContent, sectionConfig, enhanceMarkOpts);
+ return;
+ }
+
+ if (!isSkeleton && !isSpotlight) {
+ const paintedIds = getPaintedCardIds(sectionElement);
+ const freshIds = sectionConfig?.useParentCard
+ ? items.map((item) => item?.ParentId || item?.SeriesId || item?.Id).filter(Boolean)
+ : items.map((item) => item?.Id).filter(Boolean);
+ const match = classifyIdSequence(paintedIds, freshIds);
+
+ if (match.type === 'perfect') {
+ patchCardsUserData(sectionElement, items);
+ markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
+ return;
+ }
+
+ const itemsContainer = sectionElement.querySelector('.itemsContainer');
+ const layoutFromDom = itemsContainer?.getAttribute('data-layout');
+ const layout = layoutFromDom === 'grid' || layoutFromDom === 'row' ? layoutFromDom : resolveItemsLayout(sectionConfig);
+
+ if (match.type === 'reconcile' && layout === 'row') {
+ reconcileRowItems(sectionElement, items, sectionConfig, () => {
+ markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
+ });
+ return;
+ }
+
+ const cardFormat = sectionElement.getAttribute('data-card-format') || sectionConfig.cardFormat;
+ if (isButtonCardFormat(cardFormat) && match.type === 'replace') {
+ replaceItemsContainerContents(sectionElement, items, sectionConfig);
+ markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
+ return;
+ }
+
+ fadeReplaceRowItems(sectionElement, items, sectionConfig, () => {
+ markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
+ });
+ return;
+ }
+
+ // Skeleton cards without skeleton title: swap itemsContainer contents in place
+ replaceItemsContainerContents(sectionElement, items, sectionConfig);
+ markSectionEnhanced(sectionElement, sectionConfig, enhanceMarkOpts);
+ }
+
+ function parseEnhanceRootMargin(rootMargin) {
+ const raw = String(rootMargin || '30% 0px 30% 0px')
+ .trim()
+ .split(/\s+/);
+ const top = raw[0] || '0px';
+ const right = raw[1] || top;
+ const bottom = raw[2] || top;
+ const left = raw[3] || right;
+ const toPx = (token, axisSize) => {
+ const t = String(token || '0').trim();
+ if (t.endsWith('%')) {
+ const pct = parseFloat(t) || 0;
+ return (pct / 100) * axisSize;
+ }
+ return parseFloat(t) || 0;
+ };
+ return {
+ top: toPx(top, window.innerHeight || 0),
+ right: toPx(right, window.innerWidth || 0),
+ bottom: toPx(bottom, window.innerHeight || 0),
+ left: toPx(left, window.innerWidth || 0),
+ };
+ }
+
+ function isSectionWithinEnhanceMargin(el, rootMargin) {
+ if (!el?.isConnected || typeof el.getBoundingClientRect !== 'function') return false;
+ const rect = el.getBoundingClientRect();
+ const m = parseEnhanceRootMargin(rootMargin || sectionEnhanceObserverRootMargin || '30% 0px 30% 0px');
+ const vw = window.innerWidth || 0;
+ const vh = window.innerHeight || 0;
+ return rect.bottom >= -m.top && rect.top <= vh + m.bottom && rect.right >= -m.left && rect.left <= vw + m.right;
+ }
+
+ function setupSectionProgressiveEnhancement(sectionElement, section, options = {}) {
+ const { revealSectionsSequentially = false, deferUntilVisible = false } = options;
+ if (!sectionElement || !section?.result) return;
+ if (!hasSectionDeferredData(section.result)) return;
+ if (sectionElement.dataset.enhanceApplied === 'true') return;
+ if (sectionElement.dataset.enhanceScheduled === 'true') return;
+
+ sectionElement.dataset.enhanceScheduled = 'true';
+
+ const dataPromise = getSectionDataPromise(section.result);
+ if (!dataPromise) return;
+
+ const enhanceOptions = { revealSectionsSequentially, deferUntilVisible };
+
+ dataPromise
+ .then((result) => {
+ if (!sectionElement.isConnected) return;
+
+ const refreshPromise = section.result?.refreshPromise;
+ const hasPendingRefresh = !!refreshPromise;
+ const sectionConfig = section.config;
+ const isSpotlight = !!(sectionConfig?.spotlight || sectionConfig?.renderMode === 'Spotlight');
+ // Non-spotlight Random: keep skeleton until fresh refresh — avoid stale paint → fadeReplace
+ const skipStalePaint = hasPendingRefresh && sectionHasRandomQuerySort(sectionConfig) && !isSpotlight;
+
+ let items = result?.Items ?? result ?? [];
+ if (!Array.isArray(items)) items = [];
+
+ const applyOrDefer = (payload, { updateScrollButtons = false } = {}) => {
+ const applyOpts = {
+ ...enhanceOptions,
+ updateScrollButtons: !!updateScrollButtons,
+ };
+ if (!deferUntilVisible) {
+ applyProgressiveEnhancementQueued(sectionElement, section, payload, applyOpts);
+ return;
+ }
+ const margin = sectionEnhanceObserverRootMargin || '30% 0px 30% 0px';
+ const inMargin = isSectionWithinEnhanceMargin(sectionElement, margin);
+ if (inMargin) {
+ applyProgressiveEnhancementQueued(sectionElement, section, payload, applyOpts);
+ } else {
+ // Data ready but section left the enhance margin — wait until it re-enters
+ delete sectionElement.dataset.enhanceScheduled;
+ sectionEnhancePending.set(sectionElement, {
+ section,
+ options: applyOpts,
+ pendingResult: payload,
+ });
+ observeSectionForEnhance(sectionElement);
+ }
+ };
+
+ if (!skipStalePaint) {
+ if (items.length === 0) {
+ // Empty sections: dismiss immediately even if scrolled away
+ applyProgressiveEnhancementQueued(sectionElement, section, result, {
+ ...enhanceOptions,
+ updateScrollButtons: !hasPendingRefresh,
+ });
+ return;
+ }
+ applyOrDefer(result, { updateScrollButtons: !hasPendingRefresh });
+ }
+ // skipStalePaint: do not paint stale Random cache — wait for refreshPromise below
+
+ if (!hasPendingRefresh) return;
+
+ refreshPromise
+ .then((fresh) => {
+ if (!sectionElement.isConnected) return;
+ // Prefer live DOM apply; if still pending observe, update pending snapshot
+ const pending = sectionEnhancePending.get(sectionElement);
+ if (pending && pending.pendingResult !== undefined) {
+ pending.pendingResult = fresh;
+ pending.options = {
+ ...(pending.options || enhanceOptions),
+ updateScrollButtons: true,
+ };
+ return;
+ }
+ if (skipStalePaint) {
+ applyOrDefer(fresh, { updateScrollButtons: true });
+ return;
+ }
+ // Refresh through same FIFO + margin gate as initial enhance
+ applyOrDefer(fresh, { updateScrollButtons: true });
+ })
+ .catch((err) => {
+ console.warn('[KefinTweaks CardBuilder] Background refresh failed:', section?.config?.id, err);
+ if (!sectionElement.isConnected) return;
+ // Random non-spotlight skipped stale paint — fall back so we are not stuck on skeletons
+ if (skipStalePaint) {
+ applyOrDefer(result, { updateScrollButtons: true });
+ }
+ });
+ })
+ .catch((err) => {
+ console.error('[KefinTweaks CardBuilder] Progressive enhance failed:', section?.config?.id, err);
+ if (!sectionElement.isConnected) return;
+ setSectionRefreshing(sectionElement, false);
+ delete sectionElement.dataset.enhanceScheduled;
+ applyProgressiveEnhancementQueued(sectionElement, section, { Items: [] }, enhanceOptions);
+ });
+ }
+
+ function initializeSectionEnhanceObserver(rootMargin = '30% 0px 30% 0px') {
+ if (typeof IntersectionObserver === 'undefined') {
+ return;
+ }
+
+ if (sectionEnhanceObserver && sectionEnhanceObserverRootMargin === rootMargin) {
+ return;
+ }
+
+ if (sectionEnhanceObserver) {
+ sectionEnhanceObserver.disconnect();
+ }
+
+ sectionEnhanceObserverRootMargin = rootMargin;
+ sectionEnhanceObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) return;
+
+ const target = entry.target;
+ sectionEnhanceObserver.unobserve(target);
+
+ const pending = sectionEnhancePending.get(target);
+ if (!pending) return;
+
+ sectionEnhancePending.delete(target);
+
+ if (pending.pendingResult !== undefined) {
+ applyProgressiveEnhancementQueued(target, pending.section, pending.pendingResult, pending.options || {});
+ return;
+ }
+
+ setupSectionProgressiveEnhancement(target, pending.section, pending.options);
+ });
+ },
+ {
+ threshold: 0,
+ rootMargin,
+ },
+ );
+ }
+
+ function observeSectionForEnhance(sectionElement) {
+ if (!sectionElement || !sectionEnhancePending.has(sectionElement)) return;
+
+ const sectionId = sectionElement.getAttribute('data-section-id');
+ console.log('[KefinTweaks CardBuilder] observeSectionForEnhance', {
+ sectionId,
+ hasObserver: !!sectionEnhanceObserver,
+ pendingCount: sectionEnhancePending.has(sectionElement) ? 1 : 0,
+ });
+
+ initializeSectionEnhanceObserver(sectionEnhanceObserverRootMargin || '30% 0px 30% 0px');
+
+ if (sectionEnhanceObserver) {
+ sectionEnhanceObserver.observe(sectionElement);
+ return;
+ }
+
+ // No IO (unsupported): apply pending immediately — do NOT call
+ // setupSectionProgressiveEnhancement again (that re-enters applyOrDefer → infinite loop).
+ const pending = sectionEnhancePending.get(sectionElement);
+ sectionEnhancePending.delete(sectionElement);
+ if (!pending) return;
+ console.warn('[KefinTweaks CardBuilder] enhance observer unavailable; applying pending immediately', sectionId);
+ if (pending.pendingResult !== undefined) {
+ applyProgressiveEnhancementQueued(sectionElement, pending.section, pending.pendingResult, pending.options || {});
+ } else {
+ applyProgressiveEnhancementQueued(sectionElement, pending.section, { Items: [] }, pending.options || {});
+ }
+ }
+
+ function scheduleSectionProgressiveEnhancement(sectionElement, section, options) {
+ const { enhanceOnVisible = false, enhanceRootMargin = '30% 0px 30% 0px', revealSectionsSequentially = false } = options;
+
+ if (!hasSectionDeferredData(section?.result)) {
+ return;
+ }
+
+ const enhanceOptions = {
+ revealSectionsSequentially,
+ deferUntilVisible: !!enhanceOnVisible,
+ };
+
+ if (!enhanceOnVisible) {
+ setupSectionProgressiveEnhancement(sectionElement, section, enhanceOptions);
+ return;
+ }
+
+ if (typeof IntersectionObserver === 'undefined') {
+ setupSectionProgressiveEnhancement(sectionElement, section, enhanceOptions);
+ return;
+ }
+
+ sectionEnhanceObserverRootMargin = enhanceRootMargin;
+ sectionEnhancePending.set(sectionElement, { section, options: enhanceOptions });
+ observeSectionForEnhance(sectionElement);
+ }
+
+ // Progressive Section Reveal with IntersectionObserver
+ let sectionRevealObserver = null;
+
+ /**
+ * Initialize the global IntersectionObserver for section reveal animations
+ * Watches when sections enter the viewport and triggers fade-in animations
+ */
+ function initializeSectionRevealObserver() {
+ if (sectionRevealObserver) return; // Already initialized
+
+ if (typeof IntersectionObserver === 'undefined') {
+ return; // Browser doesn't support IntersectionObserver
+ }
+
+ const observerOptions = {
+ threshold: 0.1, // Trigger when 10% of section is visible
+ rootMargin: '0px 0px -50px 0px', // Trigger slightly before fully in view
+ };
+
+ sectionRevealObserver = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ // Add in-viewport class to trigger animation
+ entry.target.classList.add('in-viewport');
+ // Unobserve after animation is triggered (one-time animation)
+ sectionRevealObserver.unobserve(entry.target);
+ }
+ });
+ }, observerOptions);
+ }
+
+ /**
+ * After replaceWith, the new node keeps cardbuilder-section-reveal but is no longer
+ * observed — re-attach so in-viewport can still be applied (or stays if already set).
+ */
+ function ensureSectionRevealObservation(sectionElement) {
+ if (!sectionElement?.classList?.contains('cardbuilder-section-reveal')) return;
+ if (sectionElement.classList.contains('in-viewport')) return;
+ observeSectionForReveal(sectionElement);
+ }
+
+ /**
+ * Observe a section element for viewport entry to trigger reveal animation
+ * @param {HTMLElement} sectionElement - The section element to observe
+ */
+ function observeSectionForReveal(sectionElement) {
+ if (!sectionElement) return;
+
+ // Initialize observer if not already done
+ if (!sectionRevealObserver) {
+ initializeSectionRevealObserver();
+ }
+
+ // Only observe if observer was successfully created
+ if (sectionRevealObserver) {
+ sectionRevealObserver.observe(sectionElement);
+ }
+ }
+
+ async function initialize() {
+ // Match loader default: watchlist enabled unless scripts.watchlist === false
+ const scripts = window.KefinTweaksConfig?.scripts || {};
+ state.watchlist = scripts.watchlist !== false;
+
+ if (window.userHelper?.waitForLogin) {
+ await window.userHelper.waitForLogin();
+ }
+ startCardUserDataListener();
+ startCardLibraryChangedListener();
+ startCardEditorCloseLiveSync();
+ try {
+ const userId = typeof ApiClient !== 'undefined' && ApiClient.getCurrentUserId ? ApiClient.getCurrentUserId() : null;
+ if (userId) {
+ const raw = localStorage.getItem(`${userId}-blurhash`);
+ // Jellyfin enableBlurhash: default on when unset; only explicit 'false' disables
+ state.useBlurhash = raw !== 'false';
+ } else {
+ state.useBlurhash = true;
+ }
+ } catch (e) {
+ state.useBlurhash = true;
+ }
+ }
+
+ initialize();
+
// Expose the cardBuilder to the global window object
window.cardBuilder = cardBuilder;
-
+
console.log('[KefinTweaks CardBuilder] Module loaded and available at window.cardBuilder');
})();
diff --git a/scripts/collections.js b/scripts/collections.js
index 550d495..79ecb6f 100644
--- a/scripts/collections.js
+++ b/scripts/collections.js
@@ -595,6 +595,44 @@
activePage.dataset.collectionSortingAdded = 'true';
}
+ /**
+ * Update collection page by adding the played item count to the page
+ * @param {Object} item - Collection item (BoxSet)
+ */
+ async function updateCollectionPage(item) {
+ const activePage = document.querySelector('.libraryPage:not(.hide)');
+ if (!activePage) {
+ return;
+ }
+
+ if (activePage.dataset.kefinCollectionPage === 'true') {
+ return;
+ }
+
+ const unplayedItemCount = item.UserData?.UnplayedItemCount || 0;
+ const totalItemCount = item.RecursiveItemCount || item.ChildCount || 0;
+ const playedItemCount = totalItemCount - unplayedItemCount;
+
+ const isVideoCollection = document.querySelector('.libraryPage:not(.hide) .collectionItems:has(.card[data-mediatype="Video"])') !== null;
+ const progressText = isVideoCollection ? 'watched' : 'complete';
+ const incompleteText = isVideoCollection ? 'unwatched' : 'incomplete';
+
+ const itemMiscInfoPrimary = document.querySelector('.libraryPage:not(.hide) .itemMiscInfo-primary');
+ if (itemMiscInfoPrimary && unplayedItemCount > 0) {
+ const mediaInfoItem = document.createElement('div');
+ mediaInfoItem.className = 'mediaInfoItem';
+ mediaInfoItem.textContent = `${playedItemCount} ${progressText}`;
+ itemMiscInfoPrimary.appendChild(mediaInfoItem);
+
+ const mediaInfoItemIncomplete = document.createElement('div');
+ mediaInfoItemIncomplete.className = 'mediaInfoItem';
+ mediaInfoItemIncomplete.textContent = `${unplayedItemCount} ${incompleteText}`;
+ itemMiscInfoPrimary.appendChild(mediaInfoItemIncomplete);
+ }
+
+ activePage.dataset.kefinCollectionPage = 'true';
+ }
+
/**
* Initialize collections hook
*/
@@ -618,8 +656,9 @@
// Small delay to ensure details DOM is ready
setTimeout(async () => {
- if (item && item.Id) {
- await addCollectionSorting(item);
+ if (item && item.Id && item.Type === 'BoxSet') {
+ addCollectionSorting(item);
+ updateCollectionPage(item);
}
}, 100);
},
diff --git a/scripts/configuration.css b/scripts/configuration.css
index e6c4235..ce13b0d 100644
--- a/scripts/configuration.css
+++ b/scripts/configuration.css
@@ -1,184 +1,368 @@
.kefin-config-button {
- -webkit-tap-highlight-color: transparent;
- background-color: transparent;
- cursor: pointer;
- user-select: none;
- vertical-align: middle;
- appearance: none;
- color: inherit;
- display: flex;
- -webkit-box-flex: 1;
- flex-grow: 1;
- -webkit-box-pack: start;
- justify-content: flex-start;
- -webkit-box-align: center;
- align-items: center;
- position: relative;
- min-width: 0px;
- box-sizing: border-box;
- text-align: left;
- padding-top: 8px;
- padding-bottom: 8px;
- padding-left: 16px;
- padding-right: 16px;
- outline: 0px;
- border-width: 0px;
- border-style: initial;
- border-color: initial;
- border-image: initial;
- margin: 0px;
- border-radius: 0px;
- text-decoration: none;
- transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ -webkit-tap-highlight-color: transparent;
+ background-color: transparent;
+ cursor: pointer;
+ user-select: none;
+ vertical-align: middle;
+ appearance: none;
+ color: inherit;
+ display: flex;
+ -webkit-box-flex: 1;
+ flex-grow: 1;
+ -webkit-box-pack: start;
+ justify-content: flex-start;
+ -webkit-box-align: center;
+ align-items: center;
+ position: relative;
+ min-width: 0px;
+ box-sizing: border-box;
+ text-align: left;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ padding-left: 16px;
+ padding-right: 16px;
+ outline: 0px;
+ border-width: 0px;
+ border-style: initial;
+ border-color: initial;
+ border-image: initial;
+ margin: 0px;
+ border-radius: 0px;
+ text-decoration: none;
+ transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.kefin-config-button:hover {
- background-color: var(--jf-palette-action-hover);
+ background-color: var(--jf-palette-action-hover);
+}
+
+[data-kefintweaks-dashboard-config-button] span.material-icons.MuiSvgIcon-root {
+ display: block;
+ color: transparent;
+ background: var(--kefinTweaksLogo);
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ width: 100%;
+ height: 36px;
+}
+
+[data-kefintweaks-user-menu-config-button] span.material-icons.MuiSvgIcon-root {
+ display: block;
+ color: transparent;
+ background: var(--kefinTweaksLogo);
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ width: 24px;
+ height: 24px;
+}
+
+[data-kefintweaks-config-button] span.material-icons.listItemIcon {
+ display: block;
+ color: transparent;
+ background: var(--kefinTweaksLogo) !important;
+ background-size: contain !important;
+ background-position: center !important;
+ background-repeat: no-repeat !important;
+ width: 100%;
+ height: 36px;
}
.customMenuOptions {
- display: flex;
- flex-direction: column;
+ display: flex;
+ flex-direction: column;
}
.customMenuOptions > * {
- order: 1;
+ order: 1;
+}
+
+/* Generic toggle card / switch (shared across config modals) */
+.kefin-toggle-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75em;
+ padding: 0.85em 1em;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.kefin-toggle-card-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.kefin-toggle-card-label {
+ font-weight: 500;
+}
+
+.kefin-toggle-card-desc {
+ font-size: 0.8125rem;
+ margin-top: 0.25em;
+ line-height: 1.45;
+}
+
+.kefin-toggle-switch {
+ position: relative;
+ width: 52px;
+ height: 28px;
+ border-radius: 14px;
+ border: none;
+ background: rgba(158, 158, 158, 0.5);
+ cursor: pointer;
+ flex-shrink: 0;
+ padding: 0;
+ transition: background-color 0.3s ease;
+}
+
+.kefin-toggle-switch[data-enabled="true"] {
+ background: rgba(0, 164, 220, 0.8);
+}
+
+.kefin-toggle-switch::after {
+ content: "close";
+ font-family: "Material Icons";
+ font-size: 14px;
+ line-height: 22px;
+ text-align: center;
+ color: rgba(0, 0, 0, 0.55);
+ position: absolute;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: white;
+ top: 3px;
+ left: 3px;
+ transition: left 0.3s ease;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+}
+
+.kefin-toggle-switch[data-enabled="true"]::after {
+ content: "check";
+ left: calc(100% - 25px);
}
.navMenuOption[data-name="configure"] {
- order: 9999;
- margin-top: 3em !important;
- position: relative;
+ order: 9999;
+ margin-top: 3em !important;
+ position: relative;
}
-.navMenuOption[data-name="configure"]>.material-icons {
- position: relative;
+.navMenuOption[data-name="configure"] > .material-icons {
+ position: relative;
}
-.navMenuOption[data-name="configure"]>.material-icons::after {
- content: 'KefinTweaks';
- position: absolute;
- top: -2.5em;
- left: -1.25em;
- font-family: 'Noto Sans';
- font-size: 14.88px;
+.navMenuOption[data-name="configure"] > .material-icons::after {
+ content: "KefinTweaks";
+ position: absolute;
+ top: -2.5em;
+ left: -1.25em;
+ font-family: "Noto Sans";
+ font-size: 14.88px;
}
.listItemContent {
- width: 100%;
+ width: 100%;
}
#kefinTweaksConfigModal .listItem {
- align-items: start;
- overflow: visible;
+ align-items: start;
+ overflow: visible;
+}
+
+[dir="ltr"]
+ .kefin-config-wrapper
+ .listItem.feature-card:not(.actionSheetMenuItem, .childrenItemsContainer .listItem, .activityLogListWidget .listItem) {
+ padding: 0.75em !important;
+}
+
+/* Only editable feature cards use the pointer; toggles/buttons keep pointer via their own rules */
+.kefin-config-wrapper .listItem.feature-card {
+ cursor: default;
+}
+
+.kefin-config-wrapper .listItem.feature-card.feature-card--editable {
+ cursor: pointer;
+}
+
+.kefin-config-wrapper .listItem.feature-card .checkboxContainer,
+.kefin-config-wrapper .listItem.feature-card .emby-checkbox-label,
+.kefin-config-wrapper .listItem.feature-card .emby-button {
+ cursor: pointer;
}
#customSections_list .listItem {
- overflow: visible;
+ overflow: visible;
}
/* Autocomplete suggestions styling */
.autocomplete-suggestions {
- background: #1a1a1a !important;
- color: rgba(255,255,255,0.87) !important;
- box-shadow: 0 4px 8px rgba(0,0,0,0.5) !important;
- opacity: 1 !important;
- z-index: 10000 !important;
+ background: #1a1a1a !important;
+ color: rgba(255, 255, 255, 0.87) !important;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5) !important;
+ opacity: 1 !important;
+ z-index: 10000 !important;
}
.autocomplete-suggestion {
- color: rgba(255,255,255,0.87) !important;
- background: transparent !important;
+ color: rgba(255, 255, 255, 0.87) !important;
+ background: transparent !important;
}
.autocomplete-suggestion:hover {
- background: rgba(255,255,255,0.1) !important;
+ background: rgba(255, 255, 255, 0.1) !important;
}
-#kefinTweaksConfigModal .checkboxContainer, #kefinTweaksConfigModal .checkboxListContainer {
- margin-bottom: 0 !important;
+#kefinTweaksConfigModal .checkboxContainer,
+#kefinTweaksConfigModal .checkboxListContainer {
+ margin-bottom: 0 !important;
}
#kefinTweaksConfigModal .info-tooltip {
- position: relative;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 18px;
- height: 18px;
- border: 1px solid rgba(255,255,255,0.4);
- border-radius: 50%;
- cursor: help;
- color: rgba(255,255,255,0.8);
- transition: border-color 0.15s ease, color 0.15s ease;
- outline: none;
- font-family: 'Material Icons';
- font-style: normal;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.4);
+ border-radius: 50%;
+ cursor: help;
+ color: rgba(255, 255, 255, 0.8);
+ transition:
+ border-color 0.15s ease,
+ color 0.15s ease;
+ outline: none;
+ font-family: "Material Icons";
+ font-style: normal;
}
#kefinTweaksConfigModal .info-tooltip:hover,
#kefinTweaksConfigModal .info-tooltip:focus-visible {
- border-color: rgba(255,255,255,0.8);
- color: #ffffff;
+ border-color: rgba(255, 255, 255, 0.8);
+ color: #ffffff;
}
#kefinTweaksConfigModal .info-tooltip::after {
- content: attr(data-tooltip);
- position: absolute;
- bottom: 180%;
- left: 50%;
- transform: translate(-50%, 0);
- background: rgba(0,0,0,0.9);
- color: #fff;
- padding: 0.4em 0.6em;
- border-radius: 4px;
- font-size: 0.9rem;
- white-space: nowrap;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.15s ease, transform 0.15s ease;
- z-index: 20;
- font-family: 'Noto Sans';
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 180%;
+ left: 50%;
+ transform: translate(-50%, 0);
+ background: rgba(0, 0, 0, 0.9);
+ color: #fff;
+ padding: 0.4em 0.6em;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transition:
+ opacity 0.15s ease,
+ transform 0.15s ease;
+ z-index: 20;
+ font-family: "Noto Sans";
}
#kefinTweaksConfigModal .info-tooltip:hover::after,
#kefinTweaksConfigModal .info-tooltip:focus-visible::after {
- opacity: 1;
- transform: translate(-50%, 6px);
+ opacity: 1;
+ transform: translate(-50%, 6px);
}
details[open] summary.listItemBodyText::after {
- content: '▲';
+ content: "▲";
}
summary.listItemBodyText::after {
- content: '▼';
- position: absolute;
- bottom: 0px;
- left: 0;
- right: 0;
- text-align: center;
- font-size: 0.8rem;
+ content: "▼";
+ position: absolute;
+ bottom: 0px;
+ left: 0;
+ right: 0;
+ text-align: center;
+ font-size: 0.8rem;
}
summary.listItemBodyText {
- position: relative;
+ position: relative;
}
.kefin-config-button:hover {
- background-color: rgba(255, 255, 255, 0.08);
+ background-color: rgba(255, 255, 255, 0.08);
}
.preview-section-container .custom-scroller-container {
- max-width: calc(100% - 4rem);
+ max-width: calc(100% - 4rem);
}
.navMenuOption[data-name="configure"]:hover:not(.navMenuOption-selected) {
- transform: none !important;
- margin-top: 3em !important;
+ transform: none !important;
+ margin-top: 3em !important;
}
-div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_movies_sortOrder),
+/* div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_movies_sortOrder),
div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyReleased_episodes_sortOrder) {
display: none;
-}
\ No newline at end of file
+} */
+.emby-scroller-container[data-section-id].hideIndicators .cardIndicators {
+ display: none;
+}
+
+.manage-config-tabs .manage-tab-btn.active {
+ background-color: rgba(82, 181, 255, 0.25);
+ outline: 1px solid rgba(82, 181, 255, 0.5);
+}
+
+[data-modal-id="kefinTweaksManageModal"] [data-name="kefin-modal-content"] {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.manage-config-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.manage-tab-panels {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 28rem;
+ height: 100%;
+}
+
+.manage-tab-panel {
+ position: absolute;
+ inset: 0;
+ overflow: auto;
+ display: none;
+}
+
+.manage-tab-panel.is-active {
+ display: block;
+}
+
+.manage-io-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ box-sizing: border-box;
+}
+
+.manage-io-textarea {
+ width: 100%;
+ flex: 1 1 auto;
+ min-height: 16rem;
+ font-family: monospace;
+ font-size: 0.9em;
+ line-height: 1.5;
+ resize: none;
+ box-sizing: border-box;
+}
diff --git a/scripts/configuration.js b/scripts/configuration.js
index 1a47b9c..7594bee 100644
--- a/scripts/configuration.js
+++ b/scripts/configuration.js
@@ -22,9 +22,9 @@
{ key: 'directedByTopDirector', label: 'Directed by [Top Director]', defaultName: 'Directed by [Director]' },
{ key: 'writtenByTopWriter', label: 'Written by [Top Writer]', defaultName: 'Written by [Writer]' },
{ key: 'becauseYouRecentlyWatched', label: 'Because you recently watched [Movie]', defaultName: 'Because you recently watched [Movie]' },
- { key: 'starringActorRecentlyWatched', label: 'Starring [Actor] because you recently watched [Movie]', defaultName: 'Starring [Actor] because you recently watched [Movie]' },
- { key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Director] because you recently watched [Movie]', defaultName: 'Directed by [Director] because you recently watched [Movie]' },
- { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Writer] because you recently watched [Movie]', defaultName: 'Written by [Writer] because you recently watched [Movie]' }
+ { key: 'starringActorRecentlyWatched', label: 'Starring [Person]', defaultName: 'Starring {Person}', extras: { caption: 'because you recently watched {Title}' } },
+ { key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Person]', defaultName: 'Directed by {Person}', extras: { caption: 'because you recently watched {Title}' } },
+ { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Person]', defaultName: 'Written by {Person}', extras: { caption: 'because you recently watched {Title}' } }
];
const SUPPORTED_CUSTOM_SECTION_PARAMS = {
@@ -145,55 +145,67 @@
});
}
+ // Migrate legacy homeScreen config to new homeScreenConfig format
+ async function migrateLegacyHomeScreenConfig() {
+ return await window.migrateHomeScreenConfig();
+ }
+
// Get KefinTweaks configuration from JS Injector or load defaults
async function getKefinTweaksConfig() {
try {
+ await migrateLegacyHomeScreenConfig();
// First, try to get config from JS Injector
- const pluginId = await findJavaScriptInjectorPlugin();
+/* const pluginId = await findJavaScriptInjectorPlugin();
const injectorConfig = await getJavaScriptInjectorConfig(pluginId);
// Find KefinTweaks-Config script
const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find(
script => script.Name === 'KefinTweaks-Config'
- );
+ ); */
- if (kefinTweaksScript && kefinTweaksScript.Script) {
+/* if (kefinTweaksScript && kefinTweaksScript.Script) {
// Extract config from script content
// The script should contain: window.KefinTweaksConfig = {...};
const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/);
if (scriptMatch && scriptMatch[1]) {
try {
- const config = JSON.parse(scriptMatch[1]);
+ let config = JSON.parse(scriptMatch[1]);
// Store enabled state globally for use in modal (defaults to true if not set)
window.KefinTweaksConfigEnabled = config.enabled !== false;
console.log('[KefinTweaks Configuration] Loaded config from JS Injector:', config);
+
+ // Run migration if needed
+ config = await migrateLegacyHomeScreenConfig();
+
return config;
} catch (parseError) {
console.error('[KefinTweaks Configuration] Error parsing config from script:', parseError);
}
}
- }
+ } */
// If no config found in JS Injector, load defaults
- console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults');
- const rawDefaultConfig = await loadDefaultConfig();
- const defaultConfig = JSON.parse(JSON.stringify(rawDefaultConfig));
+/* console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults');
+ const rawDefaultConfig = await loadDefaultConfig();
+ const defaultConfig = JSON.parse(JSON.stringify(rawDefaultConfig));
- const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {};
- const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot;
- defaultConfig.kefinTweaksRoot = currentKefinRoot;
- // Ensure enabled field is set (defaults to true if not present)
- if (defaultConfig.enabled === undefined) {
- defaultConfig.enabled = true;
- }
- // Store enabled state globally for use in modal
- window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false;
- // Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/'
+ const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {};
+ const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot;
+ defaultConfig.kefinTweaksRoot = currentKefinRoot;
+ // Ensure enabled field is set (defaults to true if not present)
+ if (defaultConfig.enabled === undefined) {
+ defaultConfig.enabled = true;
+ }
+ // Store enabled state globally for use in modal
+ window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false;
- // Save defaults to JS Injector for future use
- await KefinTweaksUtils.saveConfigToJavaScriptInjector(defaultConfig);
+ // Run migration if needed
+ const migratedConfig = await migrateLegacyHomeScreenConfig(defaultConfig);
- return defaultConfig;
+ // Save migrated config to JS Injector for future use
+ await saveConfigToJavaScriptInjector(migratedConfig); */
+
+ return window.KefinTweaksConfig;
} catch (error) {
console.error('[KefinTweaks Configuration] Error getting config, falling back to defaults:', error);
// Fallback to defaults if anything fails
@@ -206,7 +218,6 @@
kefinTweaksRoot: '',
scripts: {},
homeScreen: {},
- exclusiveElsewhere: {},
search: {},
skins: [],
defaultSkin: null,
@@ -217,6 +228,432 @@
}
}
+ // Import/Export Logic
+ async function showExportDialog(config) {
+ const customSections = config.homeScreen?.customSections || [];
+ const seasonalSeasons = config.homeScreen?.seasonal?.seasons || [];
+
+ if (customSections.length === 0 && seasonalSeasons.length === 0) {
+ alert('No custom sections or seasonal configurations found to export.');
+ return;
+ }
+
+ const content = document.createElement('div');
+ content.innerHTML = `
+
+
Select sections to export:
+
+ ${customSections.length > 0 ? `
+
+
+
Custom Sections
+
+ Select All
+ Deselect All
+
+
+
+ ${customSections.map((section, i) => `
+
+
+ ${section.name || 'Unnamed Section'}
+
+ `).join('')}
+
+
` : ''}
+
+ ${seasonalSeasons.length > 0 ? `
+
+
+
Seasonal Seasons
+
+ Select All
+ Deselect All
+
+
+
+ ${seasonalSeasons.map((season, i) => `
+
+
+ ${season.name || 'Unnamed Season'}
+
+ `).join('')}
+
+
` : ''}
+
+
+
Export JSON:
+
+
+ Copy to Clipboard
+ Download .json
+
+
+
+ `;
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.innerHTML = `
+ Generate JSON
+ Close
+ `;
+
+ window.ModalSystem.create({
+ id: 'kefinTweaksExportModal',
+ title: 'Export Custom Sections',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ onOpen: (modalInstance) => {
+ const generateBtn = modalInstance.dialogFooter.querySelector('#generateExportBtn');
+ generateBtn.addEventListener('click', () => {
+ const selectedCustomIndices = Array.from(content.querySelectorAll('.export-custom-check:checked')).map(c => parseInt(c.dataset.index));
+ const selectedSeasonalIndices = Array.from(content.querySelectorAll('.export-seasonal-check:checked')).map(c => parseInt(c.dataset.index));
+
+ if (selectedCustomIndices.length === 0 && selectedSeasonalIndices.length === 0) {
+ alert('Please select at least one item to export.');
+ return;
+ }
+
+ const exportData = {
+ meta: {
+ version: '1.0',
+ date: new Date().toISOString(),
+ source: 'KefinTweaks'
+ },
+ customSections: selectedCustomIndices.map(i => customSections[i]),
+ seasonalSeasons: selectedSeasonalIndices.map(i => seasonalSeasons[i])
+ };
+
+ const json = JSON.stringify(exportData, null, 2);
+ const outputContainer = content.querySelector('#exportOutputContainer');
+ const output = content.querySelector('#exportOutput');
+
+ output.value = json;
+ outputContainer.style.display = 'block';
+ generateBtn.style.display = 'none';
+
+ content.querySelector('#copyExportBtn').addEventListener('click', () => {
+ output.select();
+ document.execCommand('copy');
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Copied to clipboard');
+ }
+ });
+
+ content.querySelector('#downloadExportBtn').addEventListener('click', () => {
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `kefin-tweaks-export-${new Date().toISOString().slice(0,10)}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ });
+ });
+ }
+ });
+ }
+
+
+ async function showCommunityImportDialog(config, saveConfigCallback) {
+ // Ensure community config is available
+ if (!window.KefinCommunityConfig) {
+ alert('Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.');
+ return;
+ }
+
+ const communityCollections = window.KefinCommunityConfig.collections || [];
+
+ const content = document.createElement('div');
+ content.innerHTML = `
+
+
Select Home Screen Sections to import:
+
These sections are created and submitted by community members like you!
+
Submit your own Home Screen Sections
here.
+
+
+
+
+
+
+
+
+
Sections in this collection:
+
+
+
+ `;
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.innerHTML = `
+
+ Cancel
+ `;
+
+ let selectedCollection = null;
+
+ window.ModalSystem.create({
+ id: 'kefinTweaksCommunityImportModal',
+ title: 'Import Community Sections',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: false,
+ closeOnEscape: true,
+ onOpen: (modalInstance) => {
+ const select = content.querySelector('#communityCollectionSelect');
+ const descContainer = content.querySelector('#collectionDescription');
+ const selectionContainer = content.querySelector('#importSelectionContainer');
+ const selectionList = content.querySelector('#importSelectionList');
+ const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmCommunityImportBtn');
+
+ select.addEventListener('change', () => {
+ const index = select.value;
+ if (index === '') {
+ selectedCollection = null;
+ descContainer.style.display = 'none';
+ selectionContainer.style.display = 'none';
+ confirmBtn.style.display = 'none';
+ return;
+ }
+
+ selectedCollection = communityCollections[parseInt(index)];
+
+ // Show description
+ descContainer.textContent = selectedCollection.description || 'No description.';
+ descContainer.style.display = 'block';
+
+ // Render sections list
+ let html = '';
+ if (selectedCollection.sections && selectedCollection.sections.length > 0) {
+ html += `
+
+ ${selectedCollection.sections.map((s, i) => `
+
+
+ ${s.name || 'Unnamed'} (${s.type})
+
+ `).join('')}
+
`;
+ } else {
+ html = 'No sections found in this collection.
';
+ }
+
+ selectionList.innerHTML = html;
+ selectionContainer.style.display = 'block';
+ confirmBtn.style.display = 'inline-block';
+ });
+
+ confirmBtn.addEventListener('click', async () => {
+ if (!selectedCollection) return;
+
+ const selectedIndices = Array.from(content.querySelectorAll('.import-community-check:checked')).map(c => parseInt(c.dataset.index));
+
+ if (selectedIndices.length === 0) {
+ alert('Please select at least one section to import.');
+ return;
+ }
+
+ // Import logic
+ if (!config.homeScreen) config.homeScreen = {};
+ if (!config.homeScreen.customSections) config.homeScreen.customSections = [];
+
+ let importedCount = 0;
+ selectedIndices.forEach(i => {
+ const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); // Deep copy
+ // Regenerate ID to avoid collision
+ if (section.id) section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random()*1000)}`;
+ config.homeScreen.customSections.push(section);
+ importedCount++;
+ });
+
+ if (importedCount > 0) {
+ await saveConfigCallback(config);
+ alert(`Successfully imported ${importedCount} sections. The page will reload.`);
+ window.location.reload();
+ } else {
+ window.ModalSystem.close('kefinTweaksCommunityImportModal');
+ }
+ });
+ }
+ });
+ }
+
+ async function showImportDialog(config, saveConfigCallback) {
+ const content = document.createElement('div');
+ content.innerHTML = `
+
+
Paste JSON content or a URL (Raw GitHub, Gist, Pastebin):
+
+
+
+
Found items to import:
+
+
+
+ `;
+
+ const footer = document.createElement('div');
+ footer.style.display = 'flex';
+ footer.style.gap = '0.75em';
+ footer.innerHTML = `
+ Load
+ Import Selected
+ Cancel
+ `;
+
+ let parsedData = null;
+
+ window.ModalSystem.create({
+ id: 'kefinTweaksImportModal',
+ title: 'Import Custom Sections',
+ content: content,
+ footer: footer,
+ closeOnBackdrop: false,
+ closeOnEscape: true,
+ onOpen: (modalInstance) => {
+ const loadBtn = modalInstance.dialogFooter.querySelector('#loadImportBtn');
+ const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmImportBtn');
+ const input = content.querySelector('#importInput');
+ const selectionContainer = content.querySelector('#importSelectionContainer');
+ const selectionList = content.querySelector('#importSelectionList');
+
+ loadBtn.addEventListener('click', async () => {
+ const rawInput = input.value.trim();
+ if (!rawInput) return;
+
+ loadBtn.disabled = true;
+ loadBtn.textContent = 'Loading...';
+
+ try {
+ let jsonStr = rawInput;
+ // Check if URL
+ if (rawInput.startsWith('http://') || rawInput.startsWith('https://')) {
+ const res = await fetch(rawInput);
+ if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`);
+ jsonStr = await res.text();
+ }
+
+ parsedData = JSON.parse(jsonStr);
+ if (!parsedData.customSections && !parsedData.seasonalSeasons && !Array.isArray(parsedData)) {
+ throw new Error('Invalid format: No sections found.');
+ }
+
+ // Handle legacy/array-only format if necessary (assume customSections)
+ if (Array.isArray(parsedData)) {
+ parsedData = { customSections: parsedData };
+ }
+
+ // Render selection
+ let html = '';
+ if (parsedData.customSections && parsedData.customSections.length > 0) {
+ html += `
+
+
Custom Sections
+
+ ${parsedData.customSections.map((s, i) => `
+
+
+ ${s.name || 'Unnamed'}
+
+ `).join('')}
+
+
`;
+ }
+ if (parsedData.seasonalSeasons && parsedData.seasonalSeasons.length > 0) {
+ html += `
+
+
Seasonal Seasons
+
+ ${parsedData.seasonalSeasons.map((s, i) => `
+
+
+ ${s.name || 'Unnamed'}
+
+ `).join('')}
+
+
`;
+ }
+
+ if (!html) throw new Error('No valid sections found in data.');
+
+ selectionList.innerHTML = html;
+ selectionContainer.style.display = 'block';
+ loadBtn.style.display = 'none';
+ confirmBtn.style.display = 'inline-block';
+ input.disabled = true;
+
+ } catch (err) {
+ alert('Error loading data: ' + err.message);
+ loadBtn.disabled = false;
+ loadBtn.textContent = 'Load';
+ }
+ });
+
+ confirmBtn.addEventListener('click', async () => {
+ if (!parsedData) return;
+
+ const selectedCustomIndices = Array.from(content.querySelectorAll('.import-custom-check:checked')).map(c => parseInt(c.dataset.index));
+ const selectedSeasonalIndices = Array.from(content.querySelectorAll('.import-seasonal-check:checked')).map(c => parseInt(c.dataset.index));
+
+ // Import logic
+ if (!config.homeScreen) config.homeScreen = {};
+
+ let importedCount = 0;
+
+ // Import Custom Sections
+ if (selectedCustomIndices.length > 0 && parsedData.customSections) {
+ if (!config.homeScreen.customSections) config.homeScreen.customSections = [];
+
+ selectedCustomIndices.forEach(i => {
+ const section = parsedData.customSections[i];
+ // Regenerate ID to avoid collision
+ if (section.id) section.id = `${section.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`;
+ config.homeScreen.customSections.push(section);
+ importedCount++;
+ });
+ }
+
+ // Import Seasonal Seasons
+ if (selectedSeasonalIndices.length > 0 && parsedData.seasonalSeasons) {
+ if (!config.homeScreen.seasonal) config.homeScreen.seasonal = {};
+ if (!config.homeScreen.seasonal.seasons) config.homeScreen.seasonal.seasons = [];
+
+ selectedSeasonalIndices.forEach(i => {
+ const season = parsedData.seasonalSeasons[i];
+ // Regenerate IDs for internal sections if needed?
+ if (season.sections) {
+ season.sections.forEach(s => {
+ if (s.id) s.id = `${s.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`;
+ });
+ }
+ config.homeScreen.seasonal.seasons.push(season);
+ importedCount++;
+ });
+ }
+
+ if (importedCount > 0) {
+ await saveConfigCallback(config);
+ alert(`Successfully imported ${importedCount} items. The page will reload.`);
+ window.location.reload();
+ } else {
+ window.ModalSystem.close('kefinTweaksImportModal');
+ }
+ });
+ }
+ });
+ }
+
// Inject responsive CSS for the configuration modal
function injectConfigModalCSS() {
const styleId = 'kefintweaks-config-modal-css';
@@ -225,7 +662,7 @@
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
- #${MODAL_ID} .modal-content-wrapper {
+ :not(.layout-mobile) #${MODAL_ID} .modal-content-wrapper {
max-width: 90vw;
width: 1400px;
max-height: 90vh;
@@ -434,48 +871,11 @@
// Build the configuration page content
function buildConfigPageContent(config, libraries = []) {
const scripts = config.scripts || {};
- const homeScreen = config.homeScreen || {};
- const exclusiveElsewhere = config.exclusiveElsewhere || {};
- const search = config.search || {};
- const flattenSingleSeasonShows = config.flattenSingleSeasonShows || {};
- const customMenuLinks = config.customMenuLinks || [];
- // Reconstruct full skin list (enabled + disabled) for configuration
- // We can't use window.KefinTweaksSkinConfig directly because it filters out disabled skins
- const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || [];
- const adminSkins = config.skins || [];
-
- const mergedSkins = [];
-
- // 1. Add all default skins first
- defaultSkins.forEach(skin => {
- mergedSkins.push({ ...skin, enabled: true });
- });
-
- // 2. Override/Add with admin skins
- adminSkins.forEach(adminSkin => {
- const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name);
- if (existingIndex >= 0) {
- // Override existing default skin
- mergedSkins[existingIndex] = { ...mergedSkins[existingIndex], ...adminSkin };
- } else {
- // Add new admin skin
- mergedSkins.push({ ...adminSkin });
- }
- });
-
- const skins = mergedSkins;
- const themes = config.themes || [];
- // Get all skin names for autocomplete
- const allSkinNames = skins.map(skin => skin.name).filter(Boolean);
-
- // Get skin source info for visual distinctions
- const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {};
-
- const isEnabled = window.KefinTweaksConfigEnabled !== false;
+ const isEnabled = window.KefinTweaksConfig?.enabled !== false;
return `
-
+
@@ -485,11 +885,14 @@
Plugin Settings
+
+ Manage
+
-
+
Feature Configuration
@@ -501,211 +904,6 @@
-
-
-
-
Home Screen Configuration
-
Configure custom home screen sections and discovery features
-
-
-
- ${buildHomeScreenConfig(homeScreen, libraries)}
-
-
-
-
-
-
-
Exclusive Elsewhere Configuration
-
Configure exclusive elsewhere branding behavior
-
-
- ${buildExclusiveElsewhereConfig(exclusiveElsewhere)}
-
-
-
-
-
-
Search Configuration
-
Configure search functionality
-
-
- ${buildSearchConfig(search)}
-
-
-
-
-
-
Episodes On Series Page Configuration
-
Configure episodes display on series pages
-
-
- ${buildFlattenShowsConfig(flattenSingleSeasonShows)}
-
-
-
-
-
-
Skin Configuration
-
Configure default skin and available skins for all users
-
-
-
-
-
Default Skin
-
- ${allSkinNames.map(name => `${name} `).join('')}
- ${allSkinNames.length === 0 ? 'No skins available ' : ''}
-
-
Select a default skin for all users
-
-
-
-
-
Enable/Disable Skins
-
Disabled skins will not appear in the appearance dropdowns for users
-
- All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations.
- KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility.
-
-
- ${buildSkinToggles(skins, skinSources)}
-
-
-
- ${(() => {
- // Check if there are any overridden default skins
- const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden');
- if (hasOverriddenSkins) {
- return `
-
-
-
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
-
- Remove Custom Skins Overriding Defaults
-
-
-
- `;
- }
- return '';
- })()}
-
-
-
Optional CSS Modules
-
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
-
Select Skin
-
- Global (applies to all skins)
- ${buildOptionalIncludesSkinOptions(skins)}
-
-
- ${buildOptionalIncludesEditor('global', config, skins)}
-
-
-
-
-
- chevron_right
- Skins JSON
-
-
-
-
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
-
-
- View Example Format
- [
- {
- "name": "Custom Skin",
- "author": "username",
- "url": [
- {
- "majorServerVersions": [10, 11],
- "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme.css"]
- }
- ],
- "colorSchemes": []
- },
- {
- "name": "Custom Skin 2",
- "author": "username 2",
- "url": [
- {
- "majorServerVersions": [10],
- "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme2.css"]
- }
- ],
- "colorSchemes": []
- }
-]
-
-
-
-
-
-
-
-
-
-
Theme Configuration
-
Configure additional themes available to all users
-
-
-
-
-
Themes JSON
-
Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.
-
-
- View Example Format
- [
- {
- "name": "Custom Dark Theme",
- "url": "https://cdn.jsdelivr.net/gh/username/custom-dark-theme.css"
- },
- {
- "name": "Custom Light Theme",
- "url": "https://cdn.jsdelivr.net/gh/username/custom-light-theme.css"
- }
-]
-
-
-
-
-
-
`;
}
@@ -975,6 +1173,9 @@
// Build script toggle switches
function buildScriptToggles(scripts) {
+ // Features that have configuration options (not just toggles)
+ const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', 'watchlist', 'userManager']);
+
const scriptNames = [
{ key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' },
{ key: 'homeScreen', label: 'Enhanced Home Screen', desc: 'Add custom home screen sections and a "discovery engine" to your Home Page. Create sections based on Genre, Tags, Playlists, Collections or Search Terms for specific Item Types. Use the "Spotlight" feature to highlight specific sections with an image carousel/slideshow.' },
@@ -984,26 +1185,56 @@
{ key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' },
{ key: 'headerTabs', label: 'Header Tabs', desc: 'Allows direct navigation to specific Tab sections of a given library page. Full support for default Jellyfin landing pages.' },
{ key: 'customMenuLinks', label: 'Custom Menu Links', desc: 'Add custom menu links to the left side navigation menu' },
+ { key: 'hamburgerMenu', label: 'Desktop Hamburger Menu', desc: 'Shows an Open Menu button on desktop (900px+) that opens a left navigation drawer like the mobile layout, including libraries and custom menu links.' },
+ { key: 'games', label: 'Games', desc: 'Add a Games link to the side menu for Movie LUT and other library games' },
{ key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' },
{ key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' },
{ key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' },
{ key: 'flattenSingleSeasonShows', label: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' },
{ key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' },
+ { key: 'versionPreferences', label: 'Item Version Preferences', desc: 'Manage how different versions and editions of your items are displayed. Reorders and sanitizes the media source dropdown on item details pages by prioritized version and edition terms.' },
{ key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' },
{ key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' },
- { key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' },
{ key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' },
- { key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' }
+ { key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' },
+ { key: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' },
+ { key: 'watchTogether', label: 'Watch Together', desc: 'Link additional Jellyfin accounts to a Watch Together group, then sync watched / in-progress playstate to selected accounts on this device.' },
+ { key: 'userManager', label: 'User Manager', desc: 'Create reusable user policy templates and apply them when adding users or bulk-editing existing accounts. Import/export comes later.' }
];
- return scriptNames.map(script => {
- const isEnabled = scripts[script.key] !== false; // Default to true if not set
+ const defsByName = new Map(
+ (window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def])
+ );
+ // Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal
+ const major = window.KefinTweaks?._jellyfinMajorVersion ?? null;
+
+ const visibleScripts = scriptNames.filter((script) => {
+ const def = defsByName.get(script.key);
+ if (!def?.versions?.length) return true;
+ if (major == null) return false;
+ return def.versions.includes(major);
+ });
+
+ return visibleScripts.map(script => {
+ // Match injector defaults: most scripts on unless set false; opt-in scripts require true
+ const defaultDisabled = script.key === 'thumbnailScrubber' || script.key === 'watchTogether' || script.key === 'userManager';
+ const isEnabled = defaultDisabled
+ ? scripts[script.key] === true
+ : scripts[script.key] !== false;
+ const hasConfig = featuresWithConfig.has(script.key);
+ const configKey = script.key === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : script.key;
+
return `
-
+
${script.label}
- ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })}
+
+ ${hasConfig ? `
+ edit
+ ` : ''}
+ ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })}
+
${script.desc}
@@ -1012,6 +1243,54 @@
}).join('');
}
+ function openFeatureConfigurationModal(event, featureKey) {
+ // Check if the click target is the toggle switch or edit button
+ const target = event.target;
+
+ // Check if clicking on the toggle switch (checkbox, label, or container)
+ const isCheckbox = target.closest('.checkboxContainer') ||
+ target.closest('.emby-checkbox-label') ||
+ target.closest('.emby-checkbox') ||
+ target.getAttribute('is') === 'emby-checkbox' ||
+ target.hasAttribute('data-embycheckbox');
+
+ // Don't open modal if clicking on toggle switch or edit button
+ if (isCheckbox) {
+ return;
+ }
+
+ // Get the feature key from the card if not provided
+ if (!featureKey) {
+ const featureCard = event.target.closest('.feature-card');
+ if (featureCard) {
+ featureKey = featureCard.getAttribute('data-feature-key');
+ }
+ }
+
+ if (!featureKey) {
+ console.warn('[KefinTweaks Configuration] Could not determine feature key');
+ return;
+ }
+
+ // Map feature key to config key (for flattenSingleSeasonShows -> seriesEpisodes)
+ const configKey = featureKey === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : featureKey;
+
+ // Open the appropriate configuration modal
+ if (window.KefinTweaksFeatureConfigs && window.KefinTweaksFeatureConfigs[configKey]) {
+ const configModule = window.KefinTweaksFeatureConfigs[configKey];
+ if (configModule.openConfigModal) {
+ configModule.openConfigModal();
+ } else {
+ console.warn(`[KefinTweaks Configuration] No openConfigModal function found for ${configKey}`);
+ }
+ } else {
+ console.warn(`[KefinTweaks Configuration] No configuration module found for ${configKey}`);
+ }
+ }
+
+ // Expose function globally so it can be called from inline onclick handlers
+ window.openFeatureConfigurationModal = openFeatureConfigurationModal;
+
// Helper function to build Include Item Types badge field
function buildIncludeItemTypesField(prefix, sectionIndex, sanitizedPrefix, currentTypes, sourceType) {
// All available item types
@@ -1427,6 +1706,9 @@
if (definition.extras?.minimumItems !== undefined) {
defaults.minimumItems = definition.extras.minimumItems;
}
+ if (definition.extras?.caption !== undefined) {
+ defaults.caption = definition.extras.caption;
+ }
let normalized = { ...defaults };
@@ -1714,7 +1996,7 @@
recentlyAddedInLibraryHtml = libraries.map(library => {
const libConfig = recentlyAddedInLibrary[library.Id] || {};
const prefix = `homeScreen_recentlyAddedInLibrary_${library.Id}`;
- const defaultName = `Recently Added in ${library.Name}`;
+ const defaultName = `Recently Added ${library.Name}`;
return `
@@ -1986,7 +2268,7 @@
-
Infinite Scroll / Load More Button
+
Infinite Scroll / Discover More
${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
@@ -2085,6 +2367,28 @@
+
+
+
+
+ import_export
+ Import / Export Custom Sections
+
+
+
Share your custom home screen sections or import configurations from others.
+
+
+ Export Sections
+
+
+ Import Sections
+
+
+
+
+
`;
}
@@ -2106,8 +2410,8 @@
return `
-
Enable Jellyseerr
-
Enable Jellyseerr integration for request functionality
+
Enable Seerr results from JellyfinEnhanced
+
If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests
${buildJellyfinCheckbox('search_enableJellyseerr', search.enableJellyseerr === true, 'Enabled')}
@@ -2171,8 +2475,14 @@
currentLoadedConfig = config;
window.KefinTweaksCurrentConfig = config;
- // Fetch libraries for configuration
- const libraries = await fetchLibraries();
+ // Ensure Jellyfin major is cached so version-gated feature cards filter correctly
+ try {
+ if (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function') {
+ await window.KefinTweaks.getJellyfinMajorVersion();
+ }
+ } catch (e) {
+ console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e);
+ }
// Build the content (without title, as ModalSystem adds it)
const content = document.createElement('div');
@@ -2180,31 +2490,19 @@
content.id = MODAL_ID;
content.innerHTML = `
- ${buildConfigPageContent(config, libraries)}
+ ${buildConfigPageContent(config)}
`;
- // Create footer with save, export, and import buttons
+ // Create footer with save (reset actions live in Manage)
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.alignItems = 'center';
footer.innerHTML = `
-
+
Save
-
- Defaults
-
-
- Export
-
-
- Import
-
-
- Reset All Users Local Settings
-
`;
// Create modal using ModalSystem
@@ -2221,14 +2519,10 @@
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
+ dialogStyle: window.innerWidth >= 900
+ ? { maxWidth: '90vw', width: '1400px', maxHeight: '90vh' }
+ : undefined,
onOpen: (modalInstance) => {
- // Update modal dialog styling for large size
- if (modalInstance && modalInstance.dialog) {
- modalInstance.dialog.style.maxWidth = '90vw';
- modalInstance.dialog.style.width = '1400px';
- modalInstance.dialog.style.maxHeight = '90vh';
- }
-
// Add event handlers
setupConfigModalHandlers(modalInstance, config);
}
@@ -2383,6 +2677,34 @@
addCustomSection();
}
});
+
+ // Export custom sections
+ modalInstance.dialogContent.addEventListener('click', (e) => {
+ const exportBtn = e.target.closest('.export-custom-sections-btn');
+ if (exportBtn) {
+ showExportDialog(config);
+ }
+ });
+
+ // Import custom sections
+ modalInstance.dialogContent.addEventListener('click', (e) => {
+ const importBtn = e.target.closest('.import-custom-sections-btn');
+ if (importBtn) {
+ showImportDialog(config, async (newConfig) => {
+ await KefinTweaksUtils.saveConfigToJavaScriptInjector(newConfig);
+ });
+ }
+ });
+
+ // Import community sections
+ modalInstance.dialogContent.addEventListener('click', (e) => {
+ const importBtn = e.target.closest('.import-community-sections-btn');
+ if (importBtn) {
+ showCommunityImportDialog(config, async (newConfig) => {
+ await KefinTweaksUtils.saveConfigToJavaScriptInjector(newConfig);
+ });
+ }
+ });
// Delete section
modalInstance.dialogContent.addEventListener('click', (e) => {
@@ -3490,12 +3812,10 @@
}
});
}
-
- // Hide suggestions when clicking outside
- document.addEventListener('click', (e) => {
- if (!badgeContainer.contains(e.target) && !suggestionsDiv.contains(e.target)) {
- suggestionsDiv.style.display = 'none';
- }
+
+ // Hide suggestions when focus leaves the input
+ input.addEventListener('blur', () => {
+ suggestionsDiv.style.display = 'none';
});
}
@@ -3782,7 +4102,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, {
- headers: { 'Authorization': apiHelper.getAuthHeader() }
+ headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -3902,7 +4222,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, {
- headers: { 'Authorization': apiHelper.getAuthHeader() }
+ headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -4022,7 +4342,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, {
- headers: { 'Authorization': apiHelper.getAuthHeader() }
+ headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -4160,7 +4480,7 @@
const token = window.ApiClient.accessToken();
const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, {
- headers: { 'Authorization': apiHelper.getAuthHeader() }
+ headers: { 'Authorization': window.apiHelper.getAuthHeader() }
});
if (!response.ok) {
@@ -4435,26 +4755,10 @@
});
}
- const resetBtn = modalInstance.dialogFooter?.querySelector('#resetConfigBtn');
- if (resetBtn) {
- resetBtn.addEventListener('click', () => handleResetConfig(modalInstance));
- }
-
- const resetAllUsersBtn = modalInstance.dialogFooter?.querySelector('#resetAllUsersBtn');
- if (resetAllUsersBtn) {
- resetAllUsersBtn.addEventListener('click', () => handleResetAllUsersConfig(modalInstance));
- }
-
- // Export button handler
- const exportBtn = modalInstance.dialogFooter?.querySelector('#exportConfigBtn');
- if (exportBtn) {
- exportBtn.addEventListener('click', () => handleExportConfig(config));
- }
-
- // Import button handler
- const importBtn = modalInstance.dialogFooter?.querySelector('#importConfigBtn');
- if (importBtn) {
- importBtn.addEventListener('click', () => handleImportConfig(modalInstance));
+ // Manage button handler (Backups / Export / Import / Resets)
+ const manageBtn = modalInstance.dialog?.querySelector('#manageConfigBtn');
+ if (manageBtn) {
+ manageBtn.addEventListener('click', () => openManageConfigModal(config, modalInstance));
}
// Remove duplicate defaults button handler
@@ -4505,24 +4809,760 @@
}
}
- // Export configuration to clipboard
+ function toastMsg(message, timeout) {
+ if (window.KefinTweaksToaster?.toast) {
+ window.KefinTweaksToaster.toast(message, timeout);
+ } else {
+ alert(message);
+ }
+ }
+
+ function getExportClipboardFlags() {
+ const isHttps =
+ (typeof ApiClient !== 'undefined' &&
+ ApiClient._serverAddress &&
+ ApiClient._serverAddress.startsWith('https://')) ||
+ (typeof location !== 'undefined' && location.protocol === 'https:');
+ const supportsClipboard = !!(navigator.clipboard && navigator.clipboard.writeText);
+ return { canUseClipboard: isHttps && supportsClipboard };
+ }
+
+ function buildManageExportPanelHtml(canUseClipboard) {
+ return `
+
+
+ Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration.
+ ${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'}
+
+
+ ${canUseClipboard ? `
+
+
+ Copy to Clipboard
+
+
` : ''}
+
+ `;
+ }
+
+ function buildManageImportPanelHtml() {
+ return `
+
+
+ Paste your configuration JSON below. This will completely replace your current configuration.
+
+
+
+
+ Import
+
+
+
+ `;
+ }
+
+ function backupsSettingsDescription(scheduledEnabled, keepCount) {
+ const x = Math.max(1, Math.floor(Number(keepCount) || 5));
+ if (scheduledEnabled) {
+ return `KefinTweaks will backup your configuration once per day and keep up to ${x} previous scheduled backups. Backups are only created from an admin account that is logged in.`;
+ }
+ return 'KefinTweaks will never automatically backup your configuration unless necessary during a migration task or other operations.';
+ }
+
+ function buildManageBackupsPanelHtml(settings) {
+ const scheduledEnabled = settings.scheduledEnabled === true;
+ const keepCount = Math.max(1, Math.floor(Number(settings.keepCount) || 5));
+ const toggleHtml = window.KefinTweaksUI?.buildToggleCard
+ ? window.KefinTweaksUI.buildToggleCard(
+ 'manageScheduledBackups',
+ scheduledEnabled,
+ 'Scheduled Backups',
+ backupsSettingsDescription(scheduledEnabled, keepCount)
+ )
+ : `
+
+
+ Scheduled Backups
+
+ ${backupsSettingsDescription(scheduledEnabled, keepCount)}
+ `;
+
+ return `
+
+
${toggleHtml}
+
+
Number of Backups to Keep
+
+
KefinTweaks will overwrite the oldest scheduled Backup once you get to this limit.
+
+
+
+ `;
+ }
+
+ function renderBackupsTableRows(backups) {
+ if (!backups.length) {
+ return `No backups found.
`;
+ }
+ const rows = backups.map((b) => `
+
+
+
+
+
+ ${escapeHtmlAttr(b.name)}
+
+ ${escapeHtmlAttr(b.kindLabel || 'Manual')} · ${escapeHtmlAttr(b.dateLabel || 'Unknown date')}
+
+
+
+
+ settings_backup_restore
+
+
+ delete
+
+
+
+ `).join('');
+ return `
+
+ Create
+ Select all
+ Deselect all
+ Delete selected
+
+
+
+
+
+
+
+
+ Backup
+ Actions
+
+
+ ${rows}
+
+
+ `;
+ }
+
+ function escapeHtmlAttr(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ async function refreshManageBackupsTable(host) {
+ if (!host) return;
+ host.innerHTML = `Loading backups…
`;
+ const listFn = window.KefinTweaksUtils?.listKefinConfigBackups;
+ if (!listFn) {
+ host.innerHTML = `Backup helpers unavailable.
`;
+ return;
+ }
+ const backups = await listFn();
+ host.innerHTML = renderBackupsTableRows(backups);
+ }
+
+ function confirmDeleteBackup(name) {
+ return confirmDeleteBackups([name]);
+ }
+
+ function confirmCreateBackup() {
+ return new Promise((resolve) => {
+ if (!window.ModalSystem) {
+ const label = window.prompt('Optional backup name (leave blank for default):', '') || '';
+ resolve(window.confirm('Create a configuration backup now?')
+ ? { create: true, label: String(label).trim() }
+ : { create: false });
+ return;
+ }
+ window.ModalSystem.create({
+ id: 'manageCreateBackupConfirm',
+ title: 'Create Backup',
+ content: `
+
+ Create a backup of your current KefinTweaks configuration?
+
+
+ Optional name
+
+
+
+ When set, the backup is named KefinTweaks-Config-Backup-[Name]-Timestamp. Leave blank for the default name.
+
+ `,
+ footer: `
+ Create
+ Cancel
+ `,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ onOpen: (mi) => {
+ const input = mi.dialogContent?.querySelector('#manageCreateBackupName');
+ setTimeout(() => input?.focus(), 100);
+ mi.dialogFooter?.querySelector('#manageCreateBackupConfirmBtn')?.addEventListener('click', () => {
+ const label = (input?.value || '').trim();
+ mi.close();
+ resolve({ create: true, label });
+ });
+ mi.dialogFooter?.querySelector('#manageCreateBackupCancelBtn')?.addEventListener('click', () => {
+ mi.close();
+ resolve({ create: false });
+ });
+ }
+ });
+ });
+ }
+
+ function confirmDeleteBackups(names) {
+ const list = (Array.isArray(names) ? names : []).filter(Boolean);
+ return new Promise((resolve) => {
+ if (!list.length) {
+ resolve(false);
+ return;
+ }
+ const title = list.length === 1 ? 'Delete Backup' : `Delete ${list.length} Backups`;
+ const body = list.length === 1
+ ? `Remove backup ${escapeHtmlAttr(list[0])} ? This cannot be undone.
`
+ : `Remove ${list.length} selected backups? This cannot be undone.
+
+ ${list.map((n) => `${escapeHtmlAttr(n)} `).join('')}
+ `;
+
+ if (!window.ModalSystem) {
+ resolve(window.confirm(list.length === 1
+ ? `Delete backup "${list[0]}"?`
+ : `Delete ${list.length} selected backups?`));
+ return;
+ }
+ window.ModalSystem.create({
+ id: 'manageDeleteBackupConfirm',
+ title,
+ content: body,
+ footer: `
+ Delete
+ Cancel
+ `,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ onOpen: (mi) => {
+ mi.dialogFooter?.querySelector('#manageDeleteBackupConfirmBtn')?.addEventListener('click', () => {
+ mi.close();
+ resolve(true);
+ });
+ mi.dialogFooter?.querySelector('#manageDeleteBackupCancelBtn')?.addEventListener('click', () => {
+ mi.close();
+ resolve(false);
+ });
+ }
+ });
+ });
+ }
+
+ function confirmRestoreBackup(name) {
+ return new Promise((resolve) => {
+ if (!window.ModalSystem) {
+ const backupFirst = window.confirm(`Backup current config before restoring "${name}"?`);
+ resolve(window.confirm(`Restore "${name}"?`) ? { restore: true, backupCurrentFirst: backupFirst } : { restore: false });
+ return;
+ }
+ window.ModalSystem.create({
+ id: 'manageRestoreBackupConfirm',
+ title: 'Restore Backup',
+ content: `
+
+ Restore configuration from ${escapeHtmlAttr(name)} ? This will overwrite your current active configuration.
+
+ `,
+ footer: `
+ Backup before Restore
+ Restore
+ Cancel
+ `,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ onOpen: (mi) => {
+ mi.dialogFooter?.querySelector('#manageRestoreWithBackupBtn')?.addEventListener('click', () => {
+ mi.close();
+ resolve({ restore: true, backupCurrentFirst: true });
+ });
+ mi.dialogFooter?.querySelector('#manageRestoreOnlyBtn')?.addEventListener('click', () => {
+ mi.close();
+ resolve({ restore: true, backupCurrentFirst: false });
+ });
+ mi.dialogFooter?.querySelector('#manageRestoreCancelBtn')?.addEventListener('click', () => {
+ mi.close();
+ resolve({ restore: false });
+ });
+ }
+ });
+ });
+ }
+
+ async function persistBackupsSettings(config, settings) {
+ config.backups = {
+ scheduledEnabled: settings.scheduledEnabled === true,
+ keepCount: Math.max(1, Math.floor(Number(settings.keepCount) || 5)),
+ lastScheduledAt: settings.lastScheduledAt || config.backups?.lastScheduledAt || null
+ };
+ if (window.KefinTweaksConfig) {
+ window.KefinTweaksConfig.backups = { ...config.backups };
+ }
+ await KefinTweaksUtils.saveConfigToJavaScriptInjector(config);
+ if (config.backups.scheduledEnabled) {
+ await window.KefinTweaksUtils.maybeRunScheduledBackup?.(config);
+ }
+ }
+
+ function bindManageBackupsPanel(root, config) {
+ const settings = window.KefinTweaksUtils?.normalizeBackupsSettings
+ ? window.KefinTweaksUtils.normalizeBackupsSettings(config)
+ : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null };
+
+ const scheduledCb = root.querySelector('#manageScheduledBackups');
+ const keepInput = root.querySelector('#manageBackupKeepCount');
+ const descEl = root.querySelector('#manageScheduledBackupsDesc')
+ || root.querySelector('.kefin-toggle-card-desc');
+ const tableHost = root.querySelector('#manageBackupsTableHost');
+
+ if (window.KefinTweaksUI?.bindToggleCards) {
+ window.KefinTweaksUI.bindToggleCards(root);
+ }
+
+ const syncDesc = () => {
+ if (!descEl) return;
+ const enabled = scheduledCb?.checked === true;
+ const keep = Math.max(1, Math.floor(Number(keepInput?.value) || 5));
+ descEl.textContent = backupsSettingsDescription(enabled, keep);
+ };
+
+ scheduledCb?.addEventListener('change', async () => {
+ syncDesc();
+ try {
+ await persistBackupsSettings(config, {
+ ...settings,
+ scheduledEnabled: scheduledCb.checked === true,
+ keepCount: Math.max(1, Math.floor(Number(keepInput?.value) || 5))
+ });
+ toastMsg(`Scheduled backups ${scheduledCb.checked ? 'enabled' : 'disabled'}`);
+ await refreshManageBackupsTable(tableHost);
+ } catch (e) {
+ console.error('[KefinTweaks Configuration] Error saving backup settings:', e);
+ toastMsg('Failed to save backup settings', '5');
+ }
+ });
+
+ keepInput?.addEventListener('change', async () => {
+ const keep = Math.max(1, Math.floor(Number(keepInput.value) || 5));
+ keepInput.value = String(keep);
+ syncDesc();
+ try {
+ await persistBackupsSettings(config, {
+ ...settings,
+ scheduledEnabled: scheduledCb?.checked === true,
+ keepCount: keep
+ });
+ await window.KefinTweaksUtils.pruneKefinConfigBackups?.(keep);
+ await refreshManageBackupsTable(tableHost);
+ toastMsg(`Keeping up to ${keep} backups`);
+ } catch (e) {
+ console.error('[KefinTweaks Configuration] Error saving keep count:', e);
+ toastMsg('Failed to save backup keep count', '5');
+ }
+ });
+
+ tableHost?.addEventListener('click', async (e) => {
+ const createBtn = e.target.closest?.('#manageBackupsCreateBtn');
+ const selectAllBtn = e.target.closest?.('#manageBackupsSelectAllBtn');
+ const deselectAllBtn = e.target.closest?.('#manageBackupsDeselectAllBtn');
+ const deleteSelectedBtn = e.target.closest?.('#manageBackupsDeleteSelectedBtn');
+
+ const syncSelectionUi = () => {
+ const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked');
+ const all = tableHost.querySelectorAll('.manage-backup-row-check');
+ const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn');
+ if (btn) btn.disabled = selected.length === 0;
+ const header = tableHost.querySelector('#manageBackupsSelectAllHeader');
+ if (header && all.length) {
+ header.checked = selected.length === all.length;
+ header.indeterminate = selected.length > 0 && selected.length < all.length;
+ }
+ };
+
+ const setAllChecked = (checked) => {
+ tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => {
+ cb.checked = checked;
+ });
+ const header = tableHost.querySelector('#manageBackupsSelectAllHeader');
+ if (header) {
+ header.checked = checked;
+ header.indeterminate = false;
+ }
+ syncSelectionUi();
+ };
+
+ if (createBtn) {
+ const choice = await confirmCreateBackup();
+ if (!choice?.create) return;
+ const result = await window.KefinTweaksUtils?.createKefinConfigBackup?.(config, {
+ reason: 'manual',
+ kind: 'manual',
+ label: choice.label || ''
+ });
+ if (result?.ok) {
+ toastMsg(`Backup created: ${result.name}`);
+ await refreshManageBackupsTable(tableHost);
+ } else {
+ toastMsg('Failed to create backup', '5');
+ }
+ return;
+ }
+ if (selectAllBtn) {
+ setAllChecked(true);
+ return;
+ }
+ if (deselectAllBtn) {
+ setAllChecked(false);
+ return;
+ }
+ if (deleteSelectedBtn) {
+ const names = Array.from(tableHost.querySelectorAll('.manage-backup-row-check:checked'))
+ .map((cb) => cb.getAttribute('data-backup-name'))
+ .filter(Boolean);
+ if (!names.length) return;
+ const confirmed = await confirmDeleteBackups(names);
+ if (!confirmed) return;
+ const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.(names);
+ if (result?.ok) {
+ toastMsg(`Deleted ${result.removed} backup${result.removed === 1 ? '' : 's'}`);
+ await refreshManageBackupsTable(tableHost);
+ } else {
+ toastMsg('Failed to delete selected backups', '5');
+ }
+ return;
+ }
+
+ // Ignore row-checkbox clicks here (handled by change); don't treat as row actions
+ if (e.target?.classList?.contains('manage-backup-row-check')
+ || e.target?.id === 'manageBackupsSelectAllHeader') {
+ return;
+ }
+
+ const restoreBtn = e.target.closest?.('.manage-backup-restore-btn');
+ const deleteBtn = e.target.closest?.('.manage-backup-delete-btn');
+ const row = e.target.closest?.('tr[data-backup-name]');
+ const name = row?.getAttribute('data-backup-name');
+ if (!name) return;
+
+ if (restoreBtn) {
+ const choice = await confirmRestoreBackup(name);
+ if (!choice.restore) return;
+ const ok = await window.KefinTweaksUtils.restoreKefinConfigBackup?.(name, {
+ backupCurrentFirst: choice.backupCurrentFirst === true
+ });
+ if (ok) {
+ toastMsg('Configuration restored. Reloading…');
+ setTimeout(() => window.location.reload(), 600);
+ } else {
+ toastMsg('Failed to restore backup', '5');
+ }
+ return;
+ }
+
+ if (deleteBtn) {
+ const confirmed = await confirmDeleteBackup(name);
+ if (!confirmed) return;
+ const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.([name]);
+ if (result?.ok) {
+ toastMsg('Backup deleted');
+ await refreshManageBackupsTable(tableHost);
+ } else {
+ toastMsg('Failed to delete backup', '5');
+ }
+ }
+ });
+
+ tableHost?.addEventListener('change', (e) => {
+ const target = e.target;
+ if (!target) return;
+
+ if (target.id === 'manageBackupsSelectAllHeader') {
+ const checked = target.checked === true;
+ tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => {
+ cb.checked = checked;
+ });
+ target.indeterminate = false;
+ } else if (!target.classList?.contains('manage-backup-row-check')) {
+ return;
+ }
+
+ const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked');
+ const all = tableHost.querySelectorAll('.manage-backup-row-check');
+ const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn');
+ if (btn) btn.disabled = selected.length === 0;
+ const header = tableHost.querySelector('#manageBackupsSelectAllHeader');
+ if (header && all.length && target.id !== 'manageBackupsSelectAllHeader') {
+ header.checked = selected.length === all.length;
+ header.indeterminate = selected.length > 0 && selected.length < all.length;
+ }
+ });
+
+ refreshManageBackupsTable(tableHost);
+ }
+
+ function openManageConfigModal(config, parentModalInstance) {
+ if (!window.ModalSystem) {
+ alert('Modal system not available');
+ return;
+ }
+
+ const { canUseClipboard } = getExportClipboardFlags();
+ const settings = window.KefinTweaksUtils?.normalizeBackupsSettings
+ ? window.KefinTweaksUtils.normalizeBackupsSettings(config)
+ : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null };
+ const configJson = JSON.stringify(config, null, 2);
+
+ window.ModalSystem.create({
+ id: 'kefinTweaksManageModal',
+ title: 'Manage',
+ dialogStyle: {
+ width: 'min(720px, 95vw)',
+ height: 'min(780px, 90vh)',
+ maxHeight: '90vh'
+ },
+ content: `
+
+
+ Backups
+ Export
+ Import
+ Restore Defaults
+ Reset All Users
+
+
+
+ ${buildManageBackupsPanelHtml(settings)}
+
+
+ ${buildManageExportPanelHtml(canUseClipboard)}
+
+
+ ${buildManageImportPanelHtml()}
+
+
+
+ `,
+ footer: `
+
+ Close
+
+ `,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ onOpen: (manageModal) => {
+ const content = manageModal.dialogContent;
+ manageModal.dialogFooter?.querySelector('#closeManageConfigBtn')?.addEventListener('click', () => manageModal.close());
+
+ const setTab = (tab) => {
+ content.querySelectorAll('.manage-tab-btn').forEach((btn) => {
+ btn.classList.toggle('active', btn.dataset.manageTab === tab);
+ });
+ content.querySelectorAll('.manage-tab-panel').forEach((panel) => {
+ panel.classList.toggle('is-active', panel.dataset.managePanel === tab);
+ });
+ };
+ content.querySelectorAll('.manage-tab-btn').forEach((btn) => {
+ btn.addEventListener('click', () => setTab(btn.dataset.manageTab));
+ });
+
+ content.querySelector('#manageRestoreDefaultsBtn')?.addEventListener('click', async () => {
+ const didReset = await handleResetConfig(parentModalInstance || manageModal);
+ if (didReset) {
+ manageModal.close();
+ }
+ });
+ content.querySelector('#manageResetAllUsersBtn')?.addEventListener('click', () => {
+ handleResetAllUsersConfig();
+ });
+
+ // Export panel
+ const exportTa = content.querySelector('#manageExportConfigTextarea');
+ if (exportTa) {
+ exportTa.value = configJson;
+ setTimeout(() => {
+ exportTa.focus();
+ exportTa.select();
+ }, 100);
+ }
+ content.querySelector('#manageCopyExportConfigBtn')?.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(configJson);
+ toastMsg('Configuration copied to clipboard!');
+ } catch (copyError) {
+ console.error('[KefinTweaks Configuration] Error copying config:', copyError);
+ toastMsg('Error copying configuration to clipboard', '5');
+ }
+ });
+
+ // Import panel
+ content.querySelector('#manageConfirmImportBtn')?.addEventListener('click', async () => {
+ const jsonText = content.querySelector('#manageImportConfigTextarea')?.value.trim() || '';
+ if (!jsonText) {
+ toastMsg('Please paste configuration JSON', '3');
+ return;
+ }
+ let importedConfig;
+ try {
+ importedConfig = JSON.parse(jsonText);
+ } catch (parseError) {
+ toastMsg('Invalid JSON format', '5');
+ return;
+ }
+ if (!importedConfig || typeof importedConfig !== 'object' || Array.isArray(importedConfig)) {
+ toastMsg('Configuration must be a JSON object', '5');
+ return;
+ }
+ const confirmed = await showImportConfirmation();
+ if (!confirmed) return;
+ manageModal.close();
+ await applyImportedConfig(importedConfig, parentModalInstance || manageModal);
+ });
+
+ bindManageBackupsPanel(content.querySelector('[data-manage-panel="backups"]') || content, config);
+ }
+ });
+ }
+
+ // Export configuration via modal (with optional clipboard copy on HTTPS)
async function handleExportConfig(config) {
try {
const configJson = JSON.stringify(config, null, 2);
- await navigator.clipboard.writeText(configJson);
-
- if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
- window.KefinTweaksToaster.toast('Configuration exported to clipboard!');
+
+ // Prefer modal-based export so it works even on unsecured servers
+ if (window.ModalSystem) {
+ const { canUseClipboard } = getExportClipboardFlags();
+
+ window.ModalSystem.create({
+ id: 'exportConfigModal',
+ title: 'Export Configuration',
+ content: `
+
+ Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration.
+ ${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'}
+
+
+ `,
+ footer: `
+ ${
+ canUseClipboard
+ ? `
+
+ Copy to Clipboard
+
+ `
+ : ''
+ }
+
+ Close
+
+ `,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ onOpen: (modalInstance) => {
+ const textarea = modalInstance.dialogContent.querySelector('#exportConfigTextarea');
+ if (textarea) {
+ textarea.value = configJson;
+ // Select text to make manual copying easier
+ setTimeout(() => {
+ textarea.focus();
+ textarea.select();
+ }, 100);
+ }
+
+ const closeBtn = modalInstance.dialogFooter?.querySelector('#closeExportConfigBtn');
+ if (closeBtn) {
+ closeBtn.addEventListener('click', () => modalInstance.close());
+ }
+
+ if (canUseClipboard) {
+ const copyBtn = modalInstance.dialogFooter?.querySelector('#copyExportConfigBtn');
+ if (copyBtn) {
+ copyBtn.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(configJson);
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Configuration copied to clipboard!');
+ } else {
+ alert('Configuration copied to clipboard!');
+ }
+ } catch (copyError) {
+ console.error(
+ '[KefinTweaks Configuration] Error copying config to clipboard:',
+ copyError
+ );
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast(
+ 'Error copying configuration to clipboard',
+ '5'
+ );
+ } else {
+ alert('Error copying configuration to clipboard');
+ }
+ }
+ });
+ }
+ }
+ }
+ });
+ } else if (navigator.clipboard && navigator.clipboard.writeText) {
+ // Fallback: no modal system, but clipboard is available
+ await navigator.clipboard.writeText(configJson);
+
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast('Configuration exported to clipboard!');
+ } else {
+ alert('Configuration exported to clipboard!');
+ }
} else {
- alert('Configuration exported to clipboard!');
+ // Last-resort fallback: log to console
+ console.log('[KefinTweaks Configuration] Exported configuration:', configJson);
+ if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
+ window.KefinTweaksToaster.toast(
+ 'Unable to open export modal or access clipboard; configuration logged to console',
+ '3'
+ );
+ } else {
+ alert(
+ 'Unable to open export modal or access clipboard. Check the browser console for the exported configuration.'
+ );
+ }
}
} catch (error) {
console.error('[KefinTweaks Configuration] Error exporting config:', error);
-
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
- window.KefinTweaksToaster.toast('Error exporting configuration to clipboard', '5');
+ window.KefinTweaksToaster.toast('Error exporting configuration', '5');
} else {
- alert('Error exporting configuration to clipboard');
+ alert('Error exporting configuration');
}
}
}
@@ -4683,73 +5723,55 @@
// Apply imported configuration
async function applyImportedConfig(importedConfig, modalInstance) {
- // Save to JavaScript Injector
+ // Update in-memory config before reopen (getKefinTweaksConfig reads window.KefinTweaksConfig)
+ window.KefinTweaksConfig = importedConfig;
+ window.KefinTweaksCurrentConfig = importedConfig;
+ currentLoadedConfig = importedConfig;
+ window.KefinTweaksConfigEnabled = importedConfig.enabled !== false;
+
await KefinTweaksUtils.saveConfigToJavaScriptInjector(importedConfig);
-
- // Reload the modal with new config
+
modalInstance.close();
-
- // Show success message
+
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration imported successfully! Reloading...');
}
-
- // Reopen modal with new config after a short delay
+
setTimeout(() => {
openConfigurationModal();
}, 500);
}
- // Find JavaScript Injector plugin
+ const JS_INJECTOR_ALIASES = ['JavaScript Injector', 'JS Injector'];
+
+ // Find JavaScript Injector plugin (delegates to shared utils cache)
async function findJavaScriptInjectorPlugin() {
try {
- if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
- throw new Error('ApiClient not available');
+ if (!window.KefinTweaksUtils?.resolvePluginId) {
+ throw new Error('KefinTweaksUtils.resolvePluginId is not available');
}
- const plugins = await apiHelper.getPlugins();
- console.log('[KefinTweaks Configuration] Active Plugins:', plugins);
-
- // Handle both array and object with Items property
- const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []);
-
- // Find JavaScript Injector plugin
- const injectorPlugin = pluginsList.find(plugin =>
- plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector'
- );
-
- if (!injectorPlugin) {
+ const pluginId = await window.KefinTweaksUtils.resolvePluginId(JS_INJECTOR_ALIASES);
+ if (!pluginId) {
throw new Error('JavaScript Injector plugin not found. Please ensure it is installed.');
}
- console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', injectorPlugin);
- return injectorPlugin.Id;
+ console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', pluginId);
+ return pluginId;
} catch (error) {
console.error('[KefinTweaks Configuration] Error finding JavaScript Injector plugin:', error);
throw error;
}
}
- // Get current JavaScript Injector configuration
+ // Get current JavaScript Injector configuration (delegates to shared utils)
async function getJavaScriptInjectorConfig(pluginId) {
try {
- if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
- throw new Error('ApiClient not available');
+ if (!window.KefinTweaksUtils?.getPluginConfiguration) {
+ throw new Error('KefinTweaksUtils.getPluginConfiguration is not available');
}
- const server = ApiClient._serverAddress;
-
- const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
- headers: {
- 'Authorization': apiHelper.getAuthHeader()
- }
- });
-
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
- }
-
- const config = await response.json();
+ const config = await window.KefinTweaksUtils.getPluginConfiguration(pluginId || JS_INJECTOR_ALIASES);
console.log('[KefinTweaks Configuration] Current JS Injector config:', config);
return config;
} catch (error) {
@@ -4758,6 +5780,34 @@
}
}
+ // Save configuration to JavaScript Injector plugin (delegates to shared utils)
+ async function saveConfigToJavaScriptInjector(config) {
+ try {
+ if (!window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
+ throw new Error('KefinTweaksUtils.saveConfigToJavaScriptInjector is not available');
+ }
+
+ const success = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(config);
+ if (!success) {
+ throw new Error('Failed to save configuration to JavaScript Injector');
+ }
+
+ console.log('[KefinTweaks Configuration] Configuration saved successfully');
+ return true;
+ } catch (error) {
+ console.error('[KefinTweaks Configuration] Error saving configuration:', error);
+ throw error;
+ }
+ }
+
+ // Expose injector helpers for feature-config fallbacks
+ window.KefinTweaksConfiguration = Object.assign(window.KefinTweaksConfiguration || {}, {
+ findJavaScriptInjectorPlugin,
+ getJavaScriptInjectorConfig,
+ saveConfigToJavaScriptInjector,
+ openConfigurationModal
+ });
+
function getSectionConfigFromUI(rootElement, prefix, sectionIndex) {
const scope = rootElement || document;
const sectionItem = scope.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`);
@@ -4988,18 +6038,8 @@
// Collect all form values
// Preserve existing kefinTweaksRoot from current config (no longer editable in UI)
- const currentConfig = window.KefinTweaksCurrentConfig || {};
- const config = {
- kefinTweaksRoot: currentConfig.kefinTweaksRoot || '',
- scripts: {},
- homeScreen: {},
- exclusiveElsewhere: {},
- search: {},
- defaultSkin: document.getElementById('defaultSkin')?.value || null,
- skins: [],
- themes: [],
- customMenuLinks: []
- };
+ const currentConfig = window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {};
+ const config = currentConfig;
config.enabled = modalInstance.dialogContent.querySelector('#kefinTweaksEnabled')?.checked !== false;
// Remove scriptRoot if it exists (legacy field, no longer used)
@@ -5013,312 +6053,6 @@
config.scripts[key] = checkbox.checked;
});
- // Helper function to get section config
- function getSectionConfig(prefix, includeSortOrderOrOptions = true, includeOrderFlag = true) {
- let includeSortOrder = true;
- let includeOrder = true;
- let includeName = false;
- let defaultName = '';
-
- if (typeof includeSortOrderOrOptions === 'object') {
- const opts = includeSortOrderOrOptions || {};
- includeSortOrder = opts.includeSortOrder !== undefined ? opts.includeSortOrder : true;
- includeOrder = opts.includeOrder !== undefined ? opts.includeOrder : true;
- includeName = opts.includeName === true;
- defaultName = opts.defaultName || '';
- } else {
- includeSortOrder = includeSortOrderOrOptions !== false;
- includeOrder = includeOrderFlag !== false;
- }
-
- const config = {
- itemLimit: parseInt(document.getElementById(`${prefix}_itemLimit`)?.value || '16', 10)
- };
-
- if (includeName) {
- config.name = document.getElementById(`${prefix}_name`)?.value || defaultName || '';
- }
-
- if (includeSortOrder) {
- config.sortOrder = document.getElementById(`${prefix}_sortOrder`)?.value || 'Random';
- config.sortOrderDirection = document.getElementById(`${prefix}_sortOrderDirection`)?.value || 'Ascending';
- }
-
- config.cardFormat = document.getElementById(`${prefix}_cardFormat`)?.value || 'Poster';
-
- if (includeOrder) {
- config.order = parseInt(document.getElementById(`${prefix}_order`)?.value || '100', 10);
- }
-
- // Check for IsPlayed configuration
- const isPlayedEnabledCheckbox = document.getElementById(`${prefix}_isPlayed_enabled`);
- if (isPlayedEnabledCheckbox) {
- if (isPlayedEnabledCheckbox.checked) {
- const isPlayedValueSelect = document.getElementById(`${prefix}_isPlayed_value`);
- config.isPlayed = isPlayedValueSelect?.value === 'true';
- } else {
- config.isPlayed = null;
- }
- }
-
- // Handle Min/Max Age In Days for Recently Released
- if (prefix === 'homeScreen_recentlyReleased_movies') {
- const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value;
- const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value;
- config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null;
- config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null;
- }
- if (prefix === 'homeScreen_recentlyReleased_episodes') {
- const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value;
- const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value;
- config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null;
- config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null;
- }
-
- return config;
- }
-
- // Collect seasonal seasons
- function getSeasonalSeasons() {
- const seasons = [];
- const seasonItems = modalInstance.dialogContent.querySelectorAll('.seasonal-season-item');
- seasonItems.forEach((item, seasonIndex) => {
- // Try to find checkbox by ID first, then fall back to class selector
- const enabledCheckbox = item.querySelector(`#seasonal-season-enabled-${seasonIndex}`) || item.querySelector('.seasonal-season-enabled[data-index="${seasonIndex}"]');
- const enabled = enabledCheckbox?.checked !== false;
- const name = item.querySelector('.seasonal-season-name')?.value || '';
- const startDate = item.querySelector('.seasonal-season-startDate')?.value || '';
- const endDate = item.querySelector('.seasonal-season-endDate')?.value || '';
- const orderInput = item.querySelector(`.seasonal-season-order-input[data-index="${seasonIndex}"]`);
- const order = orderInput ? parseInt(orderInput.value || '100', 10) : 100;
-
- // Collect nested sections
- const sectionsList = item.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`);
- const sections = [];
- if (sectionsList) {
- const sectionItems = sectionsList.querySelectorAll('.seasonal_season_section_item');
- sectionItems.forEach((sectionItem, sectionItemIndex) => {
- const fullIndex = `${seasonIndex}_${sectionItemIndex}`;
- const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'seasonal_season', fullIndex);
- if (sectionConfig) {
- sections.push(sectionConfig);
- }
- });
- }
-
- const seasonConfig = {
- id: name.toLowerCase().replace(/\s+/g, '-') || `season-${seasonIndex}`,
- name: name,
- enabled: enabled,
- startDate: startDate,
- endDate: endDate,
- order: order,
- sections: sections
- };
- seasons.push(seasonConfig);
- });
- return seasons;
- }
-
- // Collect custom sections
- function getCustomSections() {
- const sectionsList = modalInstance.dialogContent.querySelector('#customSections_list');
- const sections = [];
-
- if (!sectionsList) {
- return sections;
- }
-
- const sectionItems = sectionsList.querySelectorAll('.customSection_section_item');
- sectionItems.forEach((sectionItem, index) => {
- const sectionIndex = sectionItem.getAttribute('data-section-index') ?? index;
- const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'customSection', sectionIndex);
- if (sectionConfig) {
- sections.push(sectionConfig);
- }
- });
-
- return sections;
- }
-
- // Collect home screen config
- config.homeScreen = {
- defaultItemLimit: 16,
- defaultSortOrder: 'Random',
- defaultCardFormat: 'Poster',
- recentlyReleased: {
- enabled: document.getElementById('homeScreen_recentlyReleased_enabled')?.checked !== false,
- movies: {
- enabled: document.getElementById('homeScreen_recentlyReleased_movies_enabled')?.checked !== false,
- ...getSectionConfig('homeScreen_recentlyReleased_movies', { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true })
- },
- episodes: {
- enabled: document.getElementById('homeScreen_recentlyReleased_episodes_enabled')?.checked !== false,
- ...getSectionConfig('homeScreen_recentlyReleased_episodes', { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true })
- }
- },
- recentlyAddedInLibrary: (() => {
- const libraryConfigs = {};
- const libraryCheckboxes = modalInstance.dialogContent.querySelectorAll('.recently-added-library-enabled');
- libraryCheckboxes.forEach(checkbox => {
- const libraryId = checkbox.getAttribute('data-library-id');
- if (libraryId) {
- const prefix = `homeScreen_recentlyAddedInLibrary_${libraryId}`;
- libraryConfigs[libraryId] = {
- enabled: checkbox.checked !== false,
- ...getSectionConfig(prefix, {
- includeName: true,
- defaultName: '',
- includeSortOrder: false,
- includeOrder: true
- })
- };
- }
- });
- return libraryConfigs;
- })(),
- trending: {
- enabled: document.getElementById('homeScreen_trending_enabled')?.checked === true,
- ...getSectionConfig('homeScreen_trending', { includeName: true, defaultName: 'Trending' })
- },
- popularTVNetworks: {
- enabled: document.getElementById('homeScreen_popularTVNetworks_enabled')?.checked === true,
- minimumShowsForNetwork: parseInt(document.getElementById('homeScreen_popularTVNetworks_minimumShowsForNetwork')?.value || '5'),
- ...getSectionConfig('homeScreen_popularTVNetworks', { includeName: true, defaultName: 'Popular TV Networks' })
- },
- watchlist: {
- enabled: document.getElementById('homeScreen_watchlist_enabled')?.checked === true,
- ...getSectionConfig('homeScreen_watchlist', { includeName: true, defaultName: 'Watchlist' })
- },
- watchAgain: {
- enabled: document.getElementById('homeScreen_watchAgain_enabled')?.checked === true,
- ...getSectionConfig('homeScreen_watchAgain', { includeName: true, defaultName: 'Watch Again' })
- },
- upcoming: {
- enabled: document.getElementById('homeScreen_upcoming_enabled')?.checked !== false,
- ...getSectionConfig('homeScreen_upcoming', { includeSortOrder: false, includeName: true, defaultName: 'Upcoming' })
- },
- imdbTop250: {
- enabled: document.getElementById('homeScreen_imdbTop250_enabled')?.checked !== false,
- ...getSectionConfig('homeScreen_imdbTop250', { includeName: true, defaultName: 'IMDb Top 250' })
- },
- seasonal: {
- enabled: document.getElementById('homeScreen_seasonal_enabled')?.checked !== false,
- enableSeasonalAnimations: document.getElementById('homeScreen_seasonal_enableSeasonalAnimations')?.checked !== false,
- enableSeasonalBackground: document.getElementById('homeScreen_seasonal_enableSeasonalBackground')?.checked !== false,
- defaultItemLimit: 16,
- defaultSortOrder: 'Random',
- defaultCardFormat: 'Poster',
- seasons: getSeasonalSeasons()
- },
- discovery: {
- enabled: document.getElementById('homeScreen_discovery_enabled')?.checked !== false,
- infiniteScroll: document.getElementById('homeScreen_discovery_infiniteScroll')?.checked !== false,
- randomizeOrder: document.getElementById('homeScreen_discovery_randomizeOrder')?.checked === true,
- minPeopleAppearances: parseInt(document.getElementById('homeScreen_discovery_minPeopleAppearances')?.value || '10'),
- minGenreMovieCount: parseInt(document.getElementById('homeScreen_discovery_minGenreMovieCount')?.value || '50'),
- spotlightDiscoveryChance: parseFloat(document.getElementById('homeScreen_discovery_spotlightDiscoveryChance')?.value || '0.5'),
- renderSpotlightAboveMatching: document.getElementById('homeScreen_discovery_renderSpotlightAboveMatching')?.checked === true,
- defaultItemLimit: parseInt(document.getElementById('homeScreen_discovery_itemLimit')?.value || '16'),
- defaultSortOrder: document.getElementById('homeScreen_discovery_sortOrder')?.value || 'Random',
- defaultCardFormat: document.getElementById('homeScreen_discovery_cardFormat')?.value || 'Poster',
- sectionTypes: (() => {
- const sectionConfigs = {};
- DISCOVERY_SECTION_DEFINITIONS.forEach(section => {
- const prefix = `homeScreen_discovery_sectionTypes_${section.key}`;
- const collectedConfig = getSectionConfig(prefix, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName });
- collectedConfig.enabled = document.getElementById(`${prefix}_enabled`)?.checked !== false;
- if (section.extras?.minimumItems !== undefined) {
- const minimumValue = parseInt(document.getElementById(`${prefix}_minimumItems`)?.value || `${section.extras.minimumItems}`, 10);
- collectedConfig.minimumItems = Number.isFinite(minimumValue) ? minimumValue : section.extras.minimumItems;
- }
- sectionConfigs[section.key] = collectedConfig;
- });
- return sectionConfigs;
- })()
- },
- customSections: getCustomSections()
- };
-
- // Collect exclusive elsewhere config
- config.exclusiveElsewhere = {
- hideServerName: document.getElementById('exclusiveElsewhere_hideServerName')?.checked === true
- };
-
- // Collect search config
- config.search = {
- enableJellyseerr: document.getElementById('search_enableJellyseerr')?.checked === true
- };
-
- // Collect flattenSingleSeasonShows config
- config.flattenSingleSeasonShows = {
- hideSingleSeasonContainer: document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true
- };
-
- // Parse JSON fields
- const skinsFromJson = parseJSONField('skinsJson', []);
- config.themes = parseJSONField('themesJson', []);
- config.customMenuLinks = parseJSONField('customMenuLinksJson', []);
-
- // Collect optional includes configuration
- if (overrideOptionalIncludes) {
- config.optionalIncludes = overrideOptionalIncludes;
- } else {
- // Fallback: Use existing config.optionalIncludes to prevent data loss
- // DOM scraping caused partial overwrites because it only found visible checkboxes for the current category
- console.warn('[KefinTweaks Configuration] handleSaveConfig called without overrideOptionalIncludes - preserving existing optionalIncludes');
- if (!config.optionalIncludes) {
- config.optionalIncludes = [];
- }
- }
-
- // Collect skin enabled toggles and merge with JSON skins
- // Only save admin-configured skins (not defaults)
- // Get default skin names to filter them out
- const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name));
-
- // Collect enabled states from toggles (for all skins, including defaults)
- const skinEnabledStates = {};
- modalInstance.dialogContent.querySelectorAll('[data-skin-name]').forEach(checkbox => {
- const skinName = checkbox.getAttribute('data-skin-name');
- skinEnabledStates[skinName] = checkbox.checked;
- });
-
- // Only save admin skins (from JSON textarea) - filter out any that match default names
- // This ensures we don't accidentally save default skins
- const customSkins = skinsFromJson
- .filter(skin => !defaultSkinNames.has(skin.name)) // Exclude defaults
- .map(skin => ({
- ...skin,
- enabled: skinEnabledStates[skin.name] !== false, // Default to true if not set
- hidden: false // Explicitly set for admin skins
- }));
-
- // Handle disabled default skins
- // We need to explicitly save them in config.skins with enabled: false to override the default
- const disabledDefaultSkins = [];
- defaultSkinNames.forEach(name => {
- // If explicitly disabled in UI
- if (skinEnabledStates[name] === false) {
- disabledDefaultSkins.push({
- name: name,
- enabled: false
- });
- }
- });
-
- // Combine custom skins and disabled default skins
- config.skins = [...customSkins, ...disabledDefaultSkins];
-
- // Note: Enabled states for default skins are collected but not saved in config.skins
- // Default skins will use their default enabled state from skinConfig.js
- // If we need to save enabled states for defaults, we'd need a separate field (future enhancement)
-
- // Handle defaultSkin
- if (config.defaultSkin === '') {
- config.defaultSkin = null;
- }
-
console.log('[KefinTweaks Configuration] Configuration collected:', config);
// Save to JavaScript Injector plugin
@@ -5347,16 +6081,19 @@
}
}
+ /**
+ * @returns {Promise} true if defaults were restored
+ */
async function handleResetConfig(modalInstance) {
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
alert('You must be an administrator to reset configuration.');
- return;
+ return false;
}
const confirmed = confirm('This will restore the KefinTweaks configuration to its defaults. It cannot be undone. Are you sure you want to continue?');
if (!confirmed) {
- return;
+ return false;
}
try {
@@ -5367,6 +6104,11 @@
defaultConfig.kefinTweaksRoot = existingConfig.kefinTweaksRoot;
// Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/'
+ window.KefinTweaksConfig = defaultConfig;
+ window.KefinTweaksCurrentConfig = defaultConfig;
+ currentLoadedConfig = defaultConfig;
+ window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false;
+
await KefinTweaksUtils.saveConfigToJavaScriptInjector(defaultConfig);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
@@ -5379,6 +6121,7 @@
modalInstance.close();
}
openConfigurationModal();
+ return true;
} catch (error) {
console.error('[KefinTweaks Configuration] Error resetting config:', error);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
@@ -5386,25 +6129,27 @@
} else {
alert(`Error resetting configuration: ${error.message}`);
}
+ return false;
}
}
- async function handleResetAllUsersConfig(modalInstance) {
+ /**
+ * Bump configVersion so clients clear kefinTweaks* localStorage on next load (see injector.js).
+ */
+ async function handleResetAllUsersConfig() {
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
alert('You must be an administrator to reset configuration for all users.');
return;
}
- const confirmed = confirm('WARNING: This will force ALL users to reset their KefinTweaks settings to the default configuration on their next visit. This action cannot be undone. Are you sure you want to continue?');
+ const confirmed = confirm('WARNING: This will force ALL users to reset their KefinTweaks local settings on their next visit. This action cannot be undone. Are you sure you want to continue?');
if (!confirmed) {
return;
}
try {
const config = await getKefinTweaksConfig();
- // Update the configVersion to current timestamp
- // This will trigger the reset logic in injector.js for all clients
config.configVersion = Date.now();
await KefinTweaksUtils.saveConfigToJavaScriptInjector(config);
@@ -5439,8 +6184,174 @@
}
}
+ function getKefinTweaksRoot() {
+ const raw = window.KefinTweaksConfig?.kefinTweaksRoot || '';
+ return raw.endsWith('/') ? raw : `${raw}/`;
+ }
+
+ function getKefinTweaksLogoCssValue() {
+ const root = getKefinTweaksRoot();
+ if (!root) return 'none';
+ return `url('${root}logo.png')`;
+ }
+
+ function ensureKefinTweaksLogoVar() {
+ document.documentElement.style.setProperty(
+ '--kefinTweaksLogo',
+ getKefinTweaksLogoCssValue()
+ );
+ }
+
+ function closeAppUserMenu() {
+ const menu = document.getElementById('app-user-menu');
+ if (!menu) return;
+ const backdrop = menu.querySelector('.MuiBackdrop-root, .MuiModal-backdrop');
+ if (backdrop) {
+ backdrop.click();
+ return;
+ }
+ document.dispatchEvent(new KeyboardEvent('keydown', {
+ key: 'Escape',
+ code: 'Escape',
+ keyCode: 27,
+ which: 27,
+ bubbles: true
+ }));
+ }
+
+ /**
+ * Create KefinTweaks menu item for Jellyfin v12 #app-user-menu
+ * @returns {HTMLAnchorElement}
+ */
+ function createAppUserMenuConfigButton() {
+ ensureKefinTweaksLogoVar();
+
+ const link = document.createElement('a');
+ link.className = 'MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root MuiMenuItem-gutters css-mbeig7';
+ link.setAttribute('tabindex', '-1');
+ link.setAttribute('role', 'menuitem');
+ link.href = '#';
+ link.setAttribute('data-kefintweaks-user-menu-config-button', 'true');
+
+ const icon = document.createElement('div');
+ icon.className = 'MuiListItemIcon-root';
+ const iconSpan = document.createElement('span');
+ iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
+ iconSpan.setAttribute('aria-hidden', 'true');
+ icon.appendChild(iconSpan);
+
+ const text = document.createElement('div');
+ text.className = 'MuiListItemText-root';
+ const span = document.createElement('span');
+ span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary';
+ span.textContent = 'KefinTweaks';
+ text.appendChild(span);
+
+ const ripple = document.createElement('span');
+ ripple.className = 'MuiTouchRipple-root';
+
+ link.appendChild(icon);
+ link.appendChild(text);
+ link.appendChild(ripple);
+
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ closeAppUserMenu();
+ openConfigurationModal();
+ });
+
+ return link;
+ }
+
+ /**
+ * Insert KefinTweaks into #app-user-menu before the first HR after Dashboard.
+ * @returns {boolean} true if button is present after this call
+ */
+ function addConfigButtonToAppUserMenu() {
+ if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) {
+ return true;
+ }
+
+ const appUserMenu = document.getElementById('app-user-menu');
+ if (!appUserMenu) {
+ return false;
+ }
+
+ const menuList = appUserMenu.querySelector('ul[role="menu"]');
+ if (!menuList) {
+ return false;
+ }
+
+ const dashboardLink = menuList.querySelector('a[href="#/dashboard"]')
+ || menuList.querySelector('a[href*="#/dashboard"]');
+ if (!dashboardLink) {
+ console.log('[KefinTweaks Configuration] #app-user-menu Dashboard link not found');
+ return false;
+ }
+
+ let divider = dashboardLink.nextElementSibling;
+ while (divider && !(divider.matches('hr') || divider.classList.contains('MuiDivider-root'))) {
+ divider = divider.nextElementSibling;
+ }
+ if (!divider) {
+ console.log('[KefinTweaks Configuration] #app-user-menu divider after Dashboard not found');
+ return false;
+ }
+
+ const button = createAppUserMenuConfigButton();
+ divider.parentNode.insertBefore(button, divider);
+ console.log('[KefinTweaks Configuration] Configuration button added to #app-user-menu');
+ return true;
+ }
+
+ let appUserMenuConfigObserver = null;
+
+ /**
+ * Permanent observer: keep re-injecting if #app-user-menu remounts and our button is lost.
+ */
+ function setupAppUserMenuConfigButtonObserver() {
+ addConfigButtonToAppUserMenu();
+
+ if (appUserMenuConfigObserver) {
+ return;
+ }
+
+ const utils = window.KefinTweaksUtils;
+ let scheduled = false;
+ const onMutations = () => {
+ if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) {
+ return;
+ }
+ if (scheduled) return;
+ scheduled = true;
+ requestAnimationFrame(() => {
+ scheduled = false;
+ addConfigButtonToAppUserMenu();
+ });
+ };
+
+ if (utils?.onBodyMutation) {
+ appUserMenuConfigObserver = utils.onBodyMutation('config-appUserMenu', onMutations);
+ return;
+ }
+
+ if (typeof MutationObserver === 'undefined' || !document.body) {
+ console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)');
+ return;
+ }
+
+ appUserMenuConfigObserver = new MutationObserver(onMutations);
+ appUserMenuConfigObserver.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+ }
+
// Create configuration button for Administration section
function createConfigButton() {
+ ensureKefinTweaksLogoVar();
+
const button = document.createElement('div');
button.style.display = 'block';
button.style.padding = '0';
@@ -5454,9 +6365,9 @@
button.innerHTML = `
-
+
-
Configure KefinTweaks
+
KefinTweaks
`;
@@ -5466,13 +6377,18 @@
// Create dashboard side menu button
function createDashboardConfigButton() {
+ ensureKefinTweaksLogoVar();
+
// Check if button already exists
if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) {
return null;
}
+ // Get an existing native button
+ const nativeButton = document.querySelector('.MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters.MuiListItemButton-root.MuiListItemButton-gutters');
+
const div = document.createElement('div');
- div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters kefin-config-button';
+ div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp';
div.setAttribute('tabindex', '0');
div.setAttribute('data-kefintweaks-dashboard-config-button', 'true');
div.onclick = () => {
@@ -5481,24 +6397,24 @@
// Create icon with Material icon
const icon = document.createElement('div');
- icon.className = 'MuiListItemIcon-root css-37qkju';
+ icon.className = 'MuiListItemIcon-root css-37qkju css-5pks8q';
const iconSpan = document.createElement('span');
- iconSpan.className = 'material-icons build MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
+ iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
iconSpan.style.marginRight = '12px';
iconSpan.setAttribute('aria-hidden', 'true');
icon.appendChild(iconSpan);
// Create text
const text = document.createElement('div');
- text.className = 'MuiListItemText-root css-f696uz';
+ text.className = 'MuiListItemText-root css-f696uz css-t3p1pa';
const span = document.createElement('span');
- span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz';
+ span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz css-pl8nxc';
span.textContent = 'KefinTweaks';
text.appendChild(span);
// Create ripple effect span
const ripple = document.createElement('span');
- ripple.className = 'MuiTouchRipple-root css-w0pj6f';
+ ripple.className = 'MuiTouchRipple-root css-w0pj6f css-4mb1j7';
div.appendChild(icon);
div.appendChild(text);
@@ -5518,7 +6434,7 @@
const pluginsSection = document.querySelector('ul[aria-labelledby="plugins-subheader"]');
if (!pluginsSection) {
console.log('[KefinTweaks Configuration] Plugins section not found, retrying...');
- setTimeout(addConfigButtonToDashboard, 500);
+ //setTimeout(addConfigButtonToDashboard, 500);
return;
}
@@ -5536,6 +6452,7 @@
// Add configuration button to Administration section
function addConfigButtonToAdminSection() {
+ return;
// Check if button already exists (check for admin section button specifically)
if (document.querySelector('.adminSection [data-kefintweaks-config-button]')) {
return;
@@ -5545,7 +6462,7 @@
const adminSection = document.querySelector('.adminSection.verticalSection');
if (!adminSection) {
console.log('[KefinTweaks Configuration] Administration section not found, retrying...');
- setTimeout(addConfigButtonToAdminSection, 500);
+ //setTimeout(addConfigButtonToAdminSection, 500);
return;
}
@@ -5611,43 +6528,24 @@
return;
}
- // Use a special URL that we can intercept
- const configUrl = '#';
-
- // Add the menu link
+ // Admin-only: user menu admin section + default side menu; open via JS action
const success = await window.KefinTweaksUtils.addCustomMenuLink(
- 'Configure',
+ 'KefinTweaks',
'build',
- configUrl,
+ '',
false,
- '.adminMenuOptions'
+ {
+ userMenu: true,
+ isAdminLink: true,
+ action: 'KefinTweaksConfiguration.openConfigurationModal'
+ }
);
if (success) {
console.log('[KefinTweaks Configuration] Configuration link added to custom menu');
-
- // Set up click handler to intercept navigation and open modal instead
- setupConfigLinkClickHandler();
}
}
- // Set up click handler for configuration menu link
- function setupConfigLinkClickHandler() {
- // Find the config button
- const configButton = document.querySelector('.navMenuOption[data-name="configure"]');
- if (!configButton) {
- console.log('[KefinTweaks Configuration] Configuration button not found, retrying...');
- setTimeout(setupConfigLinkClickHandler, 1000);
- return;
- }
-
- // Add click handler to the config button
- configButton.addEventListener('click', (e) => {
- e.preventDefault();
- openConfigurationModal();
- });
- }
-
// Handle direct navigation to config URL
function handleDirectConfigNavigation() {
if (window.location.hash === '#kefintweaks-config') {
@@ -5679,6 +6577,14 @@
return;
}
+ // Run migration check during initialization
+ try {
+ const config = await getKefinTweaksConfig();
+ // Migration is handled inside getKefinTweaksConfig
+ } catch (error) {
+ console.warn('[KefinTweaks Configuration] Error during initialization migration check:', error);
+ }
+
// Register view page handler
registerViewPageHandler();
@@ -5696,6 +6602,8 @@
const userIsAdmin = await isAdmin();
if (userIsAdmin) {
+ // User-menu KefinTweaks entry is owned by addCustomMenuLink(userMenu + isAdminLink + action)
+
if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) {
setTimeout(() => {
addConfigButtonToAdminSection();
diff --git a/scripts/customMenuLinks.js b/scripts/customMenuLinks.js
index f3deed2..85e464a 100644
--- a/scripts/customMenuLinks.js
+++ b/scripts/customMenuLinks.js
@@ -1,25 +1,36 @@
// Custom Menu Links Script
-// Loads custom menu links from configuration and adds them to the custom menu
-// Uses utils.addCustomMenuLink to add each configured link
+// Loads custom menu links from configuration and adds them via utils.addCustomMenuLink
// Requires: utils.js module to be loaded before this script
(function() {
'use strict';
- // Common logging function
const LOG = (...args) => console.log('[KefinTweaks CustomMenuLinks]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks CustomMenuLinks]', ...args);
const ERR = (...args) => console.error('[KefinTweaks CustomMenuLinks]', ...args);
LOG('Script loaded - JS Injector mode');
- // Get custom menu links configuration
function getCustomMenuLinksConfig() {
return window.KefinTweaksConfig?.customMenuLinks || [];
}
+
+ function normalizeTopNavigation(value) {
+ const v = String(value || 'none').toLowerCase().trim();
+ if (v === 'main' || v === 'right') return v;
+ return 'none';
+ }
- // Initialize custom menu links
async function initializeCustomMenuLinks() {
+ if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
+ ERR('KefinTweaksUtils.addCustomMenuLink not available');
+ return;
+ }
+
+ if (typeof window.KefinTweaksUtils.ensureCustomMenuLinkStyles === 'function') {
+ window.KefinTweaksUtils.ensureCustomMenuLinkStyles();
+ }
+
const customMenuLinks = getCustomMenuLinksConfig();
if (!Array.isArray(customMenuLinks) || customMenuLinks.length === 0) {
@@ -29,27 +40,32 @@
LOG(`Loading ${customMenuLinks.length} custom menu links`);
- // Check if utils is available
- if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
- ERR('KefinTweaksUtils.addCustomMenuLink not available');
- return;
- }
-
- // Add each custom menu link
- const addPromises = customMenuLinks.map(async (linkConfig, index) => {
+ // Sequential so drawer insert order matches config order
+ let successCount = 0;
+ for (let index = 0; index < customMenuLinks.length; index++) {
+ const linkConfig = customMenuLinks[index];
try {
- // Validate link configuration
- if (!linkConfig.name || !linkConfig.url) {
- WARN(`Custom menu link at index ${index} is missing required properties (name, url)`);
- return false;
+ const name = linkConfig?.name;
+ const url = linkConfig?.url != null ? String(linkConfig.url).trim() : '';
+ const action = linkConfig?.action != null ? String(linkConfig.action).trim() : '';
+
+ if (!name || (!url && !action)) {
+ WARN(`Custom menu link at index ${index} is missing required properties (name, and url or action)`);
+ continue;
}
const {
- name,
- icon = 'link', // Default icon
- url,
- openInNewTab = false
+ icon = 'link',
+ openInNewTab = false,
+ collapsed = false,
+ sideMenu = true,
+ userMenu = false,
+ isAdminLink = false
} = linkConfig;
+
+ const topNavigation = normalizeTopNavigation(linkConfig.topNavigation);
+ const orderNum = Number(linkConfig.order);
+ const order = Number.isFinite(orderNum) ? orderNum : undefined;
LOG(`Adding custom menu link: ${name}`);
@@ -57,30 +73,32 @@
name,
icon,
url,
- openInNewTab
+ openInNewTab,
+ {
+ collapsed: !!collapsed,
+ sideMenu: sideMenu !== false,
+ topNavigation,
+ userMenu: !!userMenu,
+ isAdminLink: !!isAdminLink,
+ action,
+ order
+ }
);
if (success) {
+ successCount += 1;
LOG(`Successfully added custom menu link: ${name}`);
} else {
WARN(`Failed to add custom menu link: ${name}`);
}
-
- return success;
} catch (error) {
ERR(`Error adding custom menu link at index ${index}:`, error);
- return false;
}
- });
-
- // Wait for all links to be processed
- const results = await Promise.all(addPromises);
- const successCount = results.filter(Boolean).length;
+ }
LOG(`Custom menu links initialization complete: ${successCount}/${customMenuLinks.length} links added successfully`);
}
- // Wait for utils to be available and then initialize
function waitForUtilsAndInitialize() {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
LOG('Utils available, initializing custom menu links');
@@ -90,7 +108,6 @@
LOG('Waiting for KefinTweaksUtils to be available');
- // Poll for utils availability
const checkInterval = setInterval(() => {
if (window.KefinTweaksUtils && window.KefinTweaksUtils.addCustomMenuLink) {
clearInterval(checkInterval);
@@ -99,15 +116,13 @@
}
}, 100);
- // Fallback timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
WARN('KefinTweaksUtils not available after 10 seconds');
}, 10000);
}
- // Start initialization
waitForUtilsAndInitialize();
LOG('Custom menu links functionality initialized');
-})();
\ No newline at end of file
+})();
diff --git a/scripts/dashboardButtonFix.js b/scripts/dashboardButtonFix.js
index e0e8235..3c00381 100644
--- a/scripts/dashboardButtonFix.js
+++ b/scripts/dashboardButtonFix.js
@@ -4,34 +4,64 @@
// This script sends you to home.html in these scenarios instead of leaving the app
(function () {
- function attachHandler() {
- const btn = document.querySelector('button.MuiButtonBase-root');
- if (!btn) return;
+ 'use strict';
- if (btn._backCheckAttached) return;
+ function attachHandler(btn) {
+ if (!btn || btn._backCheckAttached) return true;
btn._backCheckAttached = true;
btn.addEventListener('click', function (e) {
if (window.history.state?.idx === 0) {
- // Stop normal handlers
e.stopImmediatePropagation();
e.preventDefault();
- console.log("[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page.");
+ console.log('[KefinTweaks DashboardButtonFix]: No history to go back. Redirecting to home page.');
- // Changes this to the page you'd like to return to by default
const homeUrl = ApiClient.serverVersion().split('.')[1] > 10 ? 'home' : 'home.html';
Dashboard.navigate(`/${homeUrl}`);
}
}, true);
-
- observer.disconnect();
+ return true;
}
- // Observe DOM in case button is recreated
- const observer = new MutationObserver(attachHandler);
- observer.observe(document.body, { childList: true, subtree: true });
+ function tryAttach() {
+ const btn = document.querySelector('button.MuiButtonBase-root');
+ return attachHandler(btn);
+ }
- // Initial run
- attachHandler();
+ function armForDashboard() {
+ if (tryAttach()) return;
+
+ const utils = window.KefinTweaksUtils;
+ if (utils?.waitForElement) {
+ utils.waitForElement('button.MuiButtonBase-root', {
+ timeout: 15000,
+ quiet: true,
+ predicate: (el) => !el._backCheckAttached
+ }).then((btn) => {
+ if (btn) attachHandler(btn);
+ });
+ return;
+ }
+ if (utils?.pollUntil) {
+ utils.pollUntil(() => (tryAttach() ? true : null), { intervalMs: 200, timeoutMs: 15000 });
+ return;
+ }
+ setTimeout(armForDashboard, 500);
+ }
+
+ function start() {
+ const utils = window.KefinTweaksUtils;
+ if (!utils?.onViewPage) {
+ setTimeout(start, 500);
+ return;
+ }
+ utils.onViewPage(() => {
+ armForDashboard();
+ }, { pages: ['dashboard', 'configurationpage'] });
+ // Also try immediately (direct load)
+ armForDashboard();
+ }
+
+ start();
})();
diff --git a/scripts/defaultSkin.css b/scripts/defaultSkin.css
index e8873e5..f94e7e4 100644
--- a/scripts/defaultSkin.css
+++ b/scripts/defaultSkin.css
@@ -1,38 +1,30 @@
.docspinner {
- contain: layout style size;
- top: 6em;
- right: calc(1em + 7px);
- left: unset;
- position: fixed;
- width: 3em;
- height: 3em;
- z-index: 9999999;
- margin: 0;
- pointer-events: none;
+ contain: layout style size;
+ top: 6em;
+ right: calc(1em + 7px);
+ left: unset;
+ position: fixed;
+ width: 3em;
+ height: 3em;
+ z-index: 9999999;
+ margin: 0;
+ pointer-events: none;
}
/* Item Details Page */
div#seriesScheduleSection {
- order: 2;
+ order: 2;
}
-.detailPageContent>* {
- order: 1;
+.detailPageContent > * {
+ order: 1;
}
.commentsSection {
- order: 3;
-}
-
-@media (max-width: 768px) {
- .headerRight .btnRecommendations,
- .headerRight .btnAdmin,
- .headerRight .headerAppearanceButton {
- display: none;
- }
+ order: 3;
}
.itemBackdrop {
- pointer-events: none;
-}
\ No newline at end of file
+ pointer-events: none;
+}
diff --git a/scripts/deviceManager.js b/scripts/deviceManager.js
index 95cf017..6b0e4a8 100644
--- a/scripts/deviceManager.js
+++ b/scripts/deviceManager.js
@@ -132,7 +132,7 @@
method: 'DELETE',
headers: {
'Accept': 'application/json',
- 'Authorization': apiHelper.getAuthHeader()
+ 'Authorization': window.apiHelper.getAuthHeader()
}
});
@@ -266,7 +266,7 @@
return;
}
- const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession');
+ const activeSessions = activeDevicesContainer.querySelectorAll('.activeSession, .MuiStack-root > .MuiPaper-root.MuiPaper-elevation.MuiPaper-rounded.MuiPaper-elevation1.MuiCard-root');
LOG(`Found ${activeSessions.length} active session containers`);
if (activeSessions.length === 0) {
diff --git a/scripts/exclusiveElsewhere.js b/scripts/exclusiveElsewhere.js
index 384abfe..df563b4 100644
--- a/scripts/exclusiveElsewhere.js
+++ b/scripts/exclusiveElsewhere.js
@@ -30,29 +30,70 @@
const config = window.KefinTweaksConfig?.exclusiveElsewhere || {
hideServerName: false // Default fallback
};
-
- const observer = new MutationObserver(() => {
+
+ let pollTimer = null;
+
+ function stopPoll() {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ }
+
+ function tryApplyExclusive() {
const elsewhereContainer = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div');
if (elsewhereContainer && elsewhereContainer.children && elsewhereContainer.children.length > 2) {
- return;
+ return true;
}
- const serverName = config.hideServerName ? '' : ApiClient.serverName();
-
+ const serverName = config.hideServerName ? '' : (typeof ApiClient !== 'undefined' ? ApiClient.serverName() : '');
const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a');
if (link && !link.classList.contains('exclusive')) {
LOG('Modifying elsewhere link to show exclusive branding');
link.innerHTML = `Only available on ${serverName}`;
- link.classList.add("exclusive");
- link.title = "Exclusive";
+ link.classList.add('exclusive');
+ link.title = 'Exclusive';
link.disable = true;
+ return true;
}
- });
+ return false;
+ }
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
-
- LOG('Exclusive elsewhere functionality initialized');
-})();
\ No newline at end of file
+ function armItemDetailPoll() {
+ stopPoll();
+ if (tryApplyExclusive()) return;
+
+ const started = Date.now();
+ const TTL_MS = 15000;
+ pollTimer = setInterval(() => {
+ if (tryApplyExclusive() || Date.now() - started >= TTL_MS) {
+ stopPoll();
+ }
+ }, 300);
+ }
+
+ function start() {
+ const utils = window.KefinTweaksUtils;
+ if (!utils?.onViewPage) {
+ setTimeout(start, 500);
+ return;
+ }
+
+ utils.onViewPage((view) => {
+ stopPoll();
+ // item / details views
+ if (view === 'item' || view === 'details' || /item\.html|details/i.test(window.location.hash || '')) {
+ armItemDetailPoll();
+ }
+ });
+
+ // Direct load on item page
+ if (document.querySelector('.itemDetailPage:not(.hide)')) {
+ armItemDetailPoll();
+ }
+
+ LOG('Exclusive elsewhere functionality initialized (onViewPage + TTL poll)');
+ }
+
+ start();
+})();
diff --git a/scripts/externalList.js b/scripts/externalList.js
new file mode 100644
index 0000000..7aab7d4
--- /dev/null
+++ b/scripts/externalList.js
@@ -0,0 +1,114 @@
+// KefinTweaks External List (MDBList) — match provider IDs against library caches
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks ExternalList]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks ExternalList]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks ExternalList]', ...args);
+
+ function getMdblistApiKey() {
+ const settings = window.LibraryCacheUtils?.getLibraryCacheSettings?.() || {};
+ return (settings.mdblistApiKey || '').trim();
+ }
+
+ function appendApiKey(url, apiKey) {
+ if (!apiKey) return url;
+ try {
+ const u = new URL(url);
+ if (!u.searchParams.has('apikey') && !u.searchParams.has('api_key')) {
+ u.searchParams.set('apikey', apiKey);
+ }
+ return u.toString();
+ } catch (_) {
+ const join = url.includes('?') ? '&' : '?';
+ return `${url}${join}apikey=${encodeURIComponent(apiKey)}`;
+ }
+ }
+
+ /**
+ * Normalize mdblist (or similar) JSON into { mediaType, imdb?, tmdb? }[].
+ */
+ function normalizeExternalListPayload(data) {
+ const rows = [];
+ const list = Array.isArray(data) ? data
+ : Array.isArray(data?.movies) || Array.isArray(data?.shows) || Array.isArray(data?.items)
+ ? [...(data.movies || []), ...(data.shows || []), ...(data.items || [])]
+ : [];
+
+ list.forEach((entry) => {
+ if (!entry || typeof entry !== 'object') return;
+ const mediaType = /show|series|tv/i.test(String(entry.mediatype || entry.media_type || entry.type || ''))
+ ? 'Series'
+ : 'Movie';
+ const imdb = entry.imdb_id || entry.imdbid || entry.ids?.imdb || entry.Imdb || null;
+ const tmdb = entry.tmdb_id || entry.tmdbid || entry.ids?.tmdb || entry.Tmdb || null;
+ if (imdb || tmdb) {
+ rows.push({ mediaType, imdb: imdb ? String(imdb) : undefined, tmdb: tmdb ? String(tmdb) : undefined });
+ }
+ });
+ return rows;
+ }
+
+ async function fetchExternalListUrl(url) {
+ const apiKey = getMdblistApiKey();
+ const fetchUrl = appendApiKey(url, apiKey);
+ const response = await fetch(fetchUrl, { headers: { Accept: 'application/json' } });
+ if (!response.ok) {
+ throw new Error(`External list HTTP ${response.status}`);
+ }
+ return response.json();
+ }
+
+ async function resolveExternalListItems(sectionConfig) {
+ const urls = sectionConfig?.externalListUrls || [];
+ if (!urls.length) return { Items: [] };
+
+ if (!getMdblistApiKey()) {
+ WARN('MDBList API key not configured');
+ }
+
+ const allRows = [];
+ for (const url of urls) {
+ if (!url) continue;
+ try {
+ const data = await fetchExternalListUrl(url);
+ allRows.push(...normalizeExternalListPayload(data));
+ } catch (e) {
+ ERR('Failed to fetch external list:', url, e);
+ }
+ }
+
+ const movieIds = [];
+ const seriesIds = [];
+ allRows.forEach((row) => {
+ const keys = [];
+ if (row.imdb) keys.push(row.imdb);
+ if (row.tmdb) keys.push(row.tmdb);
+ if (row.mediaType === 'Series') seriesIds.push(...keys);
+ else movieIds.push(...keys);
+ });
+
+ const movies = movieIds.length
+ ? await (window.LibraryCache?.getByProviderIds('Movie', movieIds)
+ || window.MoviesCache?.getMoviesByProviderIds?.(movieIds)
+ || [])
+ : [];
+ const series = seriesIds.length
+ ? await (window.LibraryCache?.getByProviderIds('Series', seriesIds)
+ || window.SeriesCache?.getSeriesByProviderIds?.(seriesIds)
+ || [])
+ : [];
+
+ const items = [...movies, ...series];
+ LOG(`External list matched ${items.length} library items from ${allRows.length} rows`);
+ return { Items: items };
+ }
+
+ window.KefinExternalList = {
+ getMdblistApiKey,
+ resolveExternalListItems,
+ normalizeExternalListPayload
+ };
+
+ LOG('Module loaded');
+})();
diff --git a/scripts/games.js b/scripts/games.js
new file mode 100644
index 0000000..ecef93f
--- /dev/null
+++ b/scripts/games.js
@@ -0,0 +1,126 @@
+// KefinTweaks Games
+// Hosts Games hub + Movie LUT + 3x3 via addCustomPage
+// Requires: utils.js
+
+(function () {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks Games]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks Games]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks Games]', ...args);
+
+ LOG('Script loaded');
+
+ function getRoot() {
+ const raw = window.KefinTweaksConfig?.kefinTweaksRootResolved;
+ if (!raw) return null;
+ return raw.endsWith('/') ? raw : `${raw}/`;
+ }
+
+ /**
+ * Build custom-page HTML from a full document: styles + head scripts + body
+ * (scripts kept). Rewrites `body {` to a scope class for non-full-page host.
+ */
+ function buildContentHtmlFromDocument(htmlString, scopeClass) {
+ const doc = new DOMParser().parseFromString(htmlString, 'text/html');
+ const chunks = [];
+
+ doc.querySelectorAll('style').forEach((el) => {
+ let css = el.textContent || '';
+ if (scopeClass) {
+ css = css.replace(/(^|})\s*body\s*\{/g, `$1.${scopeClass}{`);
+ }
+ chunks.push(``);
+ });
+
+ doc.head.querySelectorAll('script').forEach((el) => {
+ chunks.push(el.outerHTML);
+ });
+
+ const wrap = document.createElement('div');
+ const bodyClass = (doc.body && doc.body.className) || '';
+ wrap.className = [scopeClass, bodyClass].filter(Boolean).join(' ').trim();
+
+ Array.from(doc.body.childNodes).forEach((node) => {
+ if (node.nodeName === 'STYLE') return;
+ wrap.appendChild(node.cloneNode(true));
+ });
+ chunks.push(wrap.outerHTML);
+
+ return chunks.join('\n');
+ }
+
+ async function fetchPageHtml(root, relativePath) {
+ const url = `${root}${relativePath.replace(/^\//, '')}`;
+ const res = await fetch(url, { cache: 'no-cache' });
+ if (!res.ok) {
+ throw new Error(`Failed to fetch ${url}: ${res.status}`);
+ }
+ return res.text();
+ }
+
+ async function registerGamesPages() {
+ const utils = window.KefinTweaksUtils;
+ if (!utils?.addCustomMenuLink || !utils?.addCustomPage) {
+ ERR('addCustomMenuLink / addCustomPage not available');
+ return false;
+ }
+
+ const root = getRoot();
+ if (!root) {
+ WARN('kefinTweaksRoot not configured; cannot register Games pages');
+ return false;
+ }
+
+ try {
+ const [gamesRaw, movieLutRaw, threeRaw] = await Promise.all([
+ fetchPageHtml(root, 'pages/games.html'),
+ fetchPageHtml(root, 'pages/movieLut.html'),
+ fetchPageHtml(root, 'pages/3x3.html'),
+ ]);
+
+ const gamesHtml = buildContentHtmlFromDocument(gamesRaw, 'kefin-custom-page-games');
+ const movieLutHtml = buildContentHtmlFromDocument(movieLutRaw, 'kefin-custom-page-movielut');
+ const threeByThreeHtml = buildContentHtmlFromDocument(threeRaw, 'kefin-custom-page-3x3');
+
+ utils.addCustomPage('#/games', 'Games', gamesHtml);
+ utils.addCustomPage('#/movieLut', 'Movie LUT', movieLutHtml);
+ utils.addCustomPage('#/3x3', '3x3', threeByThreeHtml);
+
+ const ok = await utils.addCustomMenuLink('Games', 'sports_esports', '#/games', false);
+ if (ok) LOG('Games menu link + custom pages registered');
+ else WARN('Custom pages registered but menu link failed');
+ return !!ok;
+ } catch (e) {
+ ERR('Error registering Games pages:', e);
+ return false;
+ }
+ }
+
+ function waitForUtilsAndInitialize() {
+ const ready = () =>
+ window.KefinTweaksUtils?.addCustomMenuLink && window.KefinTweaksUtils?.addCustomPage;
+
+ if (ready()) {
+ registerGamesPages();
+ return;
+ }
+
+ LOG('Waiting for KefinTweaksUtils…');
+ const checkInterval = setInterval(() => {
+ if (ready()) {
+ clearInterval(checkInterval);
+ registerGamesPages();
+ }
+ }, 100);
+
+ setTimeout(() => {
+ clearInterval(checkInterval);
+ if (!ready()) {
+ WARN('KefinTweaksUtils not available after 10 seconds');
+ }
+ }, 10000);
+ }
+
+ waitForUtilsAndInitialize();
+})();
diff --git a/scripts/hamburgerMenu.js b/scripts/hamburgerMenu.js
new file mode 100644
index 0000000..63a359e
--- /dev/null
+++ b/scripts/hamburgerMenu.js
@@ -0,0 +1,758 @@
+// KefinTweaks Desktop Hamburger Menu (Jellyfin v12)
+// Exposes a desktop Open Menu button + left drawer matching the native mobile nav.
+// Requires: utils.js
+
+(function () {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks HamburgerMenu]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks HamburgerMenu]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks HamburgerMenu]', ...args);
+
+ const FAVORITES_HREF = '#/home?tab=1';
+ const STYLE_ID = 'kefin-hamburger-menu-styles';
+ const BTN_ATTR = 'data-kefin-desktop-hamburger';
+ const DRAWER_ATTR = 'data-kefin-desktop-drawer';
+
+ const FALLBACK_BTN_CLASS =
+ 'MuiButtonBase-root MuiIconButton-root MuiIconButton-colorInherit MuiIconButton-sizeLarge css-i2hxb6';
+ const FALLBACK_SVG_CLASS = 'MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-iguwhy';
+ const FALLBACK_LI_CLASS = 'MuiListItem-root MuiListItem-gutters css-1ohqk82';
+ const FALLBACK_A_CLASS =
+ 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp';
+ const FALLBACK_ICON_WRAP = 'MuiListItemIcon-root css-5pks8q';
+ const FALLBACK_TEXT_WRAP = 'MuiListItemText-root css-t3p1a1';
+ const FALLBACK_TEXT_PRIMARY =
+ 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-pl8nxc';
+ const FALLBACK_BRAND_ICON_WRAP = 'MuiListItemIcon-root css-c9a5tz';
+ const FALLBACK_BRAND_TEXT_WRAP =
+ 'MuiListItemText-root MuiListItemText-multiline css-9ac84v';
+ const FALLBACK_BRAND_PRIMARY =
+ 'MuiTypography-root MuiTypography-h6 MuiListItemText-primary css-1y7q0ly';
+ const FALLBACK_BRAND_SECONDARY =
+ 'MuiTypography-root MuiTypography-body2 MuiListItemText-secondary css-xxlzys';
+
+ let toolbarObserver = null;
+ let openDrawerEl = null;
+ let escapeHandler = null;
+ let viewsCache = null;
+ let viewsCacheAt = 0;
+
+ LOG('Script loaded');
+
+ function ensureStyles() {
+ let style = document.getElementById(STYLE_ID);
+ if (!style) {
+ style = document.createElement('style');
+ style.id = STYLE_ID;
+ (document.head || document.documentElement).appendChild(style);
+ }
+ style.textContent = `
+[${BTN_ATTR}] {
+ position: fixed !important;
+ left: 8px;
+ top: 1px;
+ z-index: 1200;
+}
+@media (max-width: 899px) {
+ [${BTN_ATTR}] {
+ display: none !important;
+ }
+}
+.MuiDrawer-root[${DRAWER_ATTR}] {
+ z-index: 1300;
+ position: fixed;
+ inset: 0px;
+ z-index: var(--jf-zIndex-drawer);
+}
+.MuiDrawer-root[${DRAWER_ATTR}] .MuiBackdrop-root {
+ opacity: 0;
+ transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1);
+}
+.MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiBackdrop-root {
+ opacity: 1;
+ transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1);
+ position: fixed;
+ display: flex;
+ -moz-box-align: center;
+ align-items: center;
+ -moz-box-pack: center;
+ justify-content: center;
+ inset: 0px;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: -1;
+}
+:where(.MuiDrawer-root[${DRAWER_ATTR}]) .MuiDrawer-paper {
+ transform: translateX(-100%);
+ transition: transform 225ms cubic-bezier(0, 0, 0.2, 1);
+ outline: 0px;
+ background-color: var(--jf-palette-background-paper);
+ color: var(--jf-palette-text-primary);
+ box-shadow: var(--Paper-shadow);
+ background-image: var(--Paper-overlay);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 280px;
+ max-width: 100%;
+ flex: 1 0 auto;
+ z-index: var(--jf-zIndex-drawer);
+ position: fixed;
+ top: 0px;
+ left: 0px;
+}
+.MuiDrawer-root[${DRAWER_ATTR}][data-kefin-drawer-open="true"] .MuiDrawer-paper {
+ transform: none;
+}
+[${DRAWER_ATTR}] .MuiListItem-root.MuiListItem-gutters.css-1ohqk82 {
+ display: flex;
+ -moz-box-pack: start;
+ justify-content: flex-start;
+ -moz-box-align: center;
+ align-items: center;
+ position: relative;
+ text-decoration: none;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+}
+[${DRAWER_ATTR}] .MuiList-root {
+ list-style: none;
+ margin: 0px;
+ padding-right: 0px;
+ padding-left: 0px;
+ position: relative;
+ padding-bottom: 8px;
+ padding-top: calc(0 * var(--jf-spacing));
+}
+[${DRAWER_ATTR}] .MuiTypography-root {
+ margin: 0px;
+ font-family: "Noto Sans", sans-serif;
+ font-weight: 400;
+ font-size: 1rem;
+ line-height: 1.5;
+ color: var(--jf-palette-text-primary);
+}
+[${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters {
+ background-color: transparent;
+ outline: 0px;
+ border: 0px;
+ margin: 0px;
+ border-radius: 0px;
+ cursor: pointer;
+ user-select: none;
+ vertical-align: middle;
+ appearance: none;
+ color: inherit;
+ display: flex;
+ -moz-box-flex: 1;
+ flex-grow: 1;
+ -moz-box-pack: start;
+ justify-content: flex-start;
+ -moz-box-align: center;
+ align-items: center;
+ position: relative;
+ text-decoration: none;
+ min-width: 0px;
+ box-sizing: border-box;
+ text-align: left;
+ padding: 8px 16px;
+ transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
+}
+[${DRAWER_ATTR}] .MuiButtonBase-root.MuiListItemButton-root.Mui-selected {
+ background-color: var(--jf-palette-action-selected, rgba(255, 255, 255, 0.08));
+}
+[${DRAWER_ATTR}] .MuiButtonBase-root:first-child {
+ color: var(--jf-palette-action-active);
+ flex-shrink: 0;
+ display: inline-flex;
+ min-width: 36px;
+}
+[${DRAWER_ATTR}] .MuiBox-root {
+ height: 2.5rem;
+}
+[${DRAWER_ATTR}] .MuiListItemIcon-root:last-child,
+[${DRAWER_ATTR}] .MuiListItemIcon-root.css-c9a5tz {
+ color: var(--jf-palette-action-active);
+ flex-shrink: 0;
+ display: inline-flex;
+ min-width: 56px;
+}
+[${DRAWER_ATTR}] a:hover {
+ background: #4e4e4e !important;
+}
+`;
+ }
+
+ function findDesktopToolbar() {
+ const toolbars = document.querySelectorAll('.MuiToolbar-root.MuiToolbar-dense');
+ for (const toolbar of toolbars) {
+ if (toolbar.querySelector(`:scope > button[aria-label="Open Menu"]`)) continue;
+ if (toolbar.querySelector(`:scope > [${BTN_ATTR}]`)) return toolbar;
+ const hasFavorites = toolbar.querySelector(`.MuiStack-root a[href="${FAVORITES_HREF}"]`);
+ if (hasFavorites) return toolbar;
+ }
+ return null;
+ }
+
+ function findNativeOpenMenuButton() {
+ return document.querySelector('button[aria-label="Open Menu"]:not([data-kefin-desktop-hamburger])');
+ }
+
+ function menuIconSvg(className) {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', className || FALLBACK_SVG_CLASS);
+ svg.setAttribute('focusable', 'false');
+ svg.setAttribute('aria-hidden', 'true');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('data-testid', 'MenuIcon');
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.setAttribute('d', 'M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z');
+ svg.appendChild(path);
+ return svg;
+ }
+
+ function materialSvg(testId, pathD, className) {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', className || FALLBACK_SVG_CLASS);
+ svg.setAttribute('focusable', 'false');
+ svg.setAttribute('aria-hidden', 'true');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ if (testId) svg.setAttribute('data-testid', testId);
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.setAttribute('d', pathD);
+ svg.appendChild(path);
+ return svg;
+ }
+
+ const ICONS = {
+ home: {
+ testId: 'HomeIcon',
+ d: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z'
+ },
+ favorite: {
+ testId: 'FavoriteIcon',
+ d: 'm12 21.35-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54z'
+ },
+ tv: {
+ testId: 'TvIcon',
+ d: 'M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2m0 14H3V5h18z'
+ },
+ folder: {
+ testId: 'FolderIcon',
+ d: 'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8z'
+ },
+ queue: {
+ testId: 'QueueIcon',
+ d: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-1 9h-4v4h-2v-4H9V9h4V5h2v4h4z'
+ },
+ movie: {
+ testId: 'MovieIcon',
+ d: 'm18 4 2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4zm-6.75 11.25L10 18l-1.25-2.75L6 14l2.75-1.25L10 10l1.25 2.75L14 14z'
+ },
+ music: {
+ testId: 'LibraryMusicIcon',
+ d: 'M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-2 5h-3v5.5c0 1.38-1.12 2.5-2.5 2.5S10 13.88 10 12.5s1.12-2.5 2.5-2.5c.57 0 1.08.19 1.5.5V5h4zM2 6v14c0 1.1.9 2 2 2h14v-2H4V6z'
+ },
+ livetv: {
+ testId: 'LiveTvIcon',
+ d: 'M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3c-1.1 0-2 .89-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.11-.9-2-2-2m0 14H3V8h18zM9 10v8l7-4z'
+ }
+ };
+
+ function createHamburgerButton() {
+ const native = findNativeOpenMenuButton();
+ let btn;
+ if (native) {
+ btn = native.cloneNode(true);
+ btn.querySelectorAll('.MuiTouchRipple-root').forEach((el) => el.remove());
+ } else {
+ btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = FALLBACK_BTN_CLASS;
+ btn.tabIndex = 0;
+ btn.setAttribute('aria-label', 'Open Menu');
+ btn.appendChild(menuIconSvg());
+ }
+ btn.setAttribute(BTN_ATTR, 'true');
+ btn.type = 'button';
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ openDrawer();
+ });
+ return btn;
+ }
+
+ function ensureHamburgerButton() {
+ ensureStyles();
+ const toolbar = findDesktopToolbar();
+ if (!toolbar) return false;
+
+ let btn = toolbar.querySelector(`[${BTN_ATTR}]`);
+ if (btn) return true;
+
+ btn = createHamburgerButton();
+ toolbar.insertBefore(btn, toolbar.firstChild);
+ return true;
+ }
+
+ function getServerDisplayName() {
+ try {
+ const brand = document.querySelector(
+ `.MuiToolbar-root .MuiStack-root > a[href="#/"], .MuiToolbar-root .MuiStack-root > a[href="#/home"]`
+ );
+ if (brand) {
+ const text = Array.from(brand.childNodes)
+ .filter((n) => n.nodeType === Node.TEXT_NODE)
+ .map((n) => n.textContent.trim())
+ .filter(Boolean)
+ .join(' ');
+ if (text) return text;
+ }
+ } catch (_) { /* ignore */ }
+ try {
+ return window.ApiClient?.serverName?.() || window.ApiClient?._serverInfo?.Name || 'Jellyfin';
+ } catch (_) {
+ return 'Jellyfin';
+ }
+ }
+
+ function getServerVersionLabel() {
+ try {
+ const v = window.ApiClient?._serverVersion || window.ApiClient?.serverVersion?.();
+ if (v) {
+ const m = String(v).match(/^(\d+\.\d+)/);
+ return m ? m[1] : String(v);
+ }
+ } catch (_) { /* ignore */ }
+ return '';
+ }
+
+ function getBrandIconSrc() {
+ const img = document.querySelector(
+ `.MuiToolbar-root .MuiStack-root a[href="#/"] img, .MuiToolbar-root .MuiStack-root a[href="#/home"] img`
+ );
+ return img?.getAttribute('src') || null;
+ }
+
+ function libraryHref(item) {
+ const serverId = window.ApiClient?.serverId?.() || '';
+ const id = item.Id;
+ const type = String(item.CollectionType || '').toLowerCase();
+ switch (type) {
+ case 'movies':
+ return `#/movies?topParentId=${id}&collectionType=movies`;
+ case 'tvshows':
+ return `#/tv?topParentId=${id}&collectionType=tvshows`;
+ case 'music':
+ return `#/music?topParentId=${id}&collectionType=music`;
+ case 'playlists':
+ return `#/playlists?topParentId=${id}&collectionType=playlists`;
+ case 'livetv':
+ return `#/livetv?collectionType=livetv`;
+ case 'boxsets':
+ return `#/list?parentId=${id}&serverId=${serverId}`;
+ default:
+ return `#/list?parentId=${id}&serverId=${serverId}`;
+ }
+ }
+
+ function libraryIconKey(item) {
+ switch (String(item.CollectionType || '').toLowerCase()) {
+ case 'movies':
+ return 'movie';
+ case 'tvshows':
+ return 'tv';
+ case 'music':
+ return 'music';
+ case 'playlists':
+ return 'queue';
+ case 'livetv':
+ return 'livetv';
+ default:
+ return 'folder';
+ }
+ }
+
+ async function fetchUserViews() {
+ const now = Date.now();
+ if (viewsCache && now - viewsCacheAt < 60000) return viewsCache;
+ if (!window.ApiClient?.getUserViews) {
+ WARN('ApiClient.getUserViews not available');
+ return [];
+ }
+ try {
+ const userId = window.ApiClient.getCurrentUserId();
+ const result = await window.ApiClient.getUserViews({}, userId);
+ viewsCache = Array.isArray(result?.Items) ? result.Items : [];
+ viewsCacheAt = now;
+ return viewsCache;
+ } catch (e) {
+ ERR('Failed to fetch UserViews', e);
+ return [];
+ }
+ }
+
+ function getCurrentNavHash() {
+ const hash = window.location.hash || '#/';
+ return hash.startsWith('#') ? hash : `#${hash}`;
+ }
+
+ function parseHashParts(hash) {
+ const raw = String(hash || '#/').replace(/^#/, '') || '/';
+ const qIndex = raw.indexOf('?');
+ const path = (qIndex >= 0 ? raw.slice(0, qIndex) : raw) || '/';
+ const query = qIndex >= 0 ? raw.slice(qIndex + 1) : '';
+ const params = new URLSearchParams(query);
+ return { path: path.startsWith('/') ? path : `/${path}`, params };
+ }
+
+ function hrefMatchScore(href, currentHash) {
+ if (!href || /^https?:/i.test(href)) return -1;
+ const targetHash = href.startsWith('#') ? href : `#${href}`;
+ const current = parseHashParts(currentHash);
+ const target = parseHashParts(targetHash);
+
+ if (
+ targetHash === currentHash
+ || decodeURIComponent(targetHash) === decodeURIComponent(currentHash)
+ ) {
+ return 1000 + targetHash.length;
+ }
+
+ if (target.path !== current.path) return -1;
+
+ for (const [key, value] of target.params.entries()) {
+ if (current.params.get(key) !== value) return -1;
+ }
+
+ // Prefer more specific links (more query params / longer href)
+ let score = 100 + targetHash.length;
+ score += [...target.params.keys()].length * 10;
+
+ // Home without tab should not win over Favorites (?tab=1)
+ if (target.path === '/home' && !target.params.has('tab') && current.params.has('tab')) {
+ return -1;
+ }
+
+ return score;
+ }
+
+ function applySelectedState(drawerRoot) {
+ if (!drawerRoot) return;
+ const currentHash = getCurrentNavHash();
+ const links = Array.from(drawerRoot.querySelectorAll('.MuiDrawer-paper a[href]'));
+ let best = null;
+ let bestScore = -1;
+ links.forEach((a) => {
+ a.classList.remove('Mui-selected');
+ a.removeAttribute('aria-current');
+ const score = hrefMatchScore(a.getAttribute('href') || '', currentHash);
+ if (score > bestScore) {
+ bestScore = score;
+ best = a;
+ }
+ });
+ if (best && bestScore >= 0) {
+ best.classList.add('Mui-selected');
+ best.setAttribute('aria-current', 'page');
+ }
+ }
+
+ function createListItem({ href, label, iconKey, brand, secondary, imgSrc }) {
+ const li = document.createElement('li');
+ li.className = FALLBACK_LI_CLASS;
+
+ const a = document.createElement('a');
+ a.className = FALLBACK_A_CLASS;
+ a.tabIndex = 0;
+ a.href = href;
+
+ const iconWrap = document.createElement('div');
+ iconWrap.className = brand ? FALLBACK_BRAND_ICON_WRAP : FALLBACK_ICON_WRAP;
+
+ if (imgSrc) {
+ const img = document.createElement('img');
+ img.className = 'MuiBox-root css-1j3tjfb';
+ img.src = imgSrc;
+ img.alt = '';
+ iconWrap.appendChild(img);
+ } else if (iconKey && ICONS[iconKey]) {
+ iconWrap.appendChild(materialSvg(ICONS[iconKey].testId, ICONS[iconKey].d));
+ }
+
+ const textWrap = document.createElement('div');
+ if (brand) {
+ textWrap.className = FALLBACK_BRAND_TEXT_WRAP;
+ const h6 = document.createElement('h6');
+ h6.className = FALLBACK_BRAND_PRIMARY;
+ h6.textContent = label;
+ textWrap.appendChild(h6);
+ if (secondary) {
+ const p = document.createElement('p');
+ p.className = FALLBACK_BRAND_SECONDARY;
+ p.textContent = secondary;
+ textWrap.appendChild(p);
+ }
+ } else {
+ textWrap.className = FALLBACK_TEXT_WRAP;
+ const span = document.createElement('span');
+ span.className = FALLBACK_TEXT_PRIMARY;
+ span.textContent = label;
+ textWrap.appendChild(span);
+ }
+
+ a.appendChild(iconWrap);
+ a.appendChild(textWrap);
+ a.addEventListener('click', () => {
+ // Allow navigation; close drawer shortly after
+ setTimeout(closeDrawer, 0);
+ });
+ li.appendChild(a);
+ return li;
+ }
+
+ function buildDrawerShell(mainList, librariesList) {
+ const root = document.createElement('div');
+ root.setAttribute('role', 'presentation');
+ root.className = 'MuiDrawer-root MuiDrawer-anchorLeft MuiDrawer-modal MuiModal-root css-k3j2mn';
+ root.setAttribute(DRAWER_ATTR, 'true');
+
+ const backdrop = document.createElement('div');
+ backdrop.setAttribute('aria-hidden', 'true');
+ backdrop.className = 'MuiBackdrop-root MuiModal-backdrop css-14dl35y';
+ backdrop.addEventListener('click', closeDrawer);
+
+ const sentinelStart = document.createElement('div');
+ sentinelStart.tabIndex = 0;
+ sentinelStart.setAttribute('data-testid', 'sentinelStart');
+
+ const paper = document.createElement('div');
+ paper.className =
+ 'MuiPaper-root MuiPaper-elevation MuiPaper-elevation16 MuiDrawer-paper MuiDrawer-paperAnchorLeft css-epnusv';
+ paper.tabIndex = -1;
+ paper.style.setProperty('--Paper-shadow', 'var(--jf-shadows-16)');
+ paper.style.setProperty('--Paper-overlay', 'var(--jf-overlays-16)');
+
+ const box = document.createElement('div');
+ box.className = 'MuiBox-root css-0';
+ box.setAttribute('role', 'presentation');
+
+ box.appendChild(mainList);
+
+ const hr = document.createElement('hr');
+ hr.className = 'MuiDivider-root MuiDivider-fullWidth css-14093q0';
+ box.appendChild(hr);
+ box.appendChild(librariesList);
+
+ paper.appendChild(box);
+
+ const sentinelEnd = document.createElement('div');
+ sentinelEnd.tabIndex = 0;
+ sentinelEnd.setAttribute('data-testid', 'sentinelEnd');
+
+ root.appendChild(backdrop);
+ root.appendChild(sentinelStart);
+ root.appendChild(paper);
+ root.appendChild(sentinelEnd);
+ return root;
+ }
+
+ async function buildDrawer() {
+ const mainList = document.createElement('ul');
+ mainList.className = 'MuiList-root MuiList-padding css-hbss32';
+
+ const serverName = getServerDisplayName();
+ const version = getServerVersionLabel();
+ const brandSrc = getBrandIconSrc();
+
+ mainList.appendChild(
+ createListItem({
+ href: '#/',
+ label: serverName,
+ brand: true,
+ secondary: version || undefined,
+ imgSrc: brandSrc
+ })
+ );
+ mainList.appendChild(
+ createListItem({ href: '#/home', label: 'Home', iconKey: 'home' })
+ );
+ mainList.appendChild(
+ createListItem({ href: FAVORITES_HREF, label: 'Favorites', iconKey: 'favorite' })
+ );
+
+ const librariesList = document.createElement('ul');
+ librariesList.className = 'MuiList-root MuiList-padding MuiList-subheader css-q9gyaw';
+ librariesList.setAttribute('aria-labelledby', 'libraries-subheader');
+
+ const subheader = document.createElement('div');
+ subheader.className =
+ 'MuiListSubheader-root MuiListSubheader-gutters MuiListSubheader-sticky css-18qy2xf';
+ subheader.id = 'libraries-subheader';
+ subheader.textContent = 'Libraries';
+ librariesList.appendChild(subheader);
+
+ const views = await fetchUserViews();
+ views.forEach((item) => {
+ if (!item?.Id || !item?.Name) return;
+ librariesList.appendChild(
+ createListItem({
+ href: libraryHref(item),
+ label: item.Name,
+ iconKey: libraryIconKey(item)
+ })
+ );
+ });
+
+ return buildDrawerShell(mainList, librariesList);
+ }
+
+ function closeDrawer() {
+ if (escapeHandler) {
+ document.removeEventListener('keydown', escapeHandler);
+ escapeHandler = null;
+ }
+ const drawer = openDrawerEl;
+ openDrawerEl = null;
+ document.body.style.removeProperty('overflow');
+ if (!drawer) return;
+
+ drawer.removeAttribute('data-kefin-drawer-open');
+ const paper = drawer.querySelector('.MuiDrawer-paper');
+ let finished = false;
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+ drawer.remove();
+ };
+ if (paper) {
+ paper.addEventListener('transitionend', (e) => {
+ if (e.target === paper && e.propertyName === 'transform') finish();
+ }, { once: true });
+ }
+ setTimeout(finish, 280);
+ }
+
+ async function openDrawer() {
+ if (openDrawerEl) {
+ closeDrawer();
+ return;
+ }
+ try {
+ const drawer = await buildDrawer();
+ drawer.removeAttribute('data-kefin-drawer-open');
+ document.body.appendChild(drawer);
+ openDrawerEl = drawer;
+
+ escapeHandler = (e) => {
+ if (e.key === 'Escape') closeDrawer();
+ };
+ document.addEventListener('keydown', escapeHandler);
+
+ applySelectedState(drawer);
+ // Force closed paint, then open so translateX animates
+ drawer.offsetWidth;
+ requestAnimationFrame(() => {
+ drawer.setAttribute('data-kefin-drawer-open', 'true');
+ applySelectedState(drawer);
+ setTimeout(() => applySelectedState(drawer), 100);
+ });
+ } catch (e) {
+ ERR('Failed to open drawer', e);
+ closeDrawer();
+ }
+ }
+
+ function parseVersionParts(version) {
+ return String(version || '')
+ .split(/[.+-]/)
+ .filter(Boolean)
+ .map((p) => {
+ const n = parseInt(p, 10);
+ return Number.isFinite(n) ? n : 0;
+ });
+ }
+
+ /** @returns {boolean|null} true if >= 12.0.0, false if older, null if unknown */
+ function isAppVersionAtLeast12() {
+ const version = window.ApiClient?._appVersion;
+ if (!version) return null;
+ const parts = parseVersionParts(version);
+ const major = parts[0] ?? 0;
+ const minor = parts[1] ?? 0;
+ const patch = parts[2] ?? 0;
+ if (major > 12) return true;
+ if (major < 12) return false;
+ if (minor > 0) return true;
+ if (minor < 0) return false;
+ return patch >= 0;
+ }
+
+ async function waitForAppVersion(timeoutMs = 10000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ const ready = isAppVersionAtLeast12();
+ if (ready !== null) return ready;
+ await new Promise((r) => setTimeout(r, 250));
+ }
+ return isAppVersionAtLeast12();
+ }
+
+ function armToolbarPoll() {
+ ensureHamburgerButton();
+ let tries = 0;
+ const poll = setInterval(() => {
+ tries += 1;
+ if (ensureHamburgerButton() || tries >= 40) clearInterval(poll);
+ }, 250);
+ }
+
+ function start() {
+ ensureStyles();
+ armToolbarPoll();
+
+ const utils = window.KefinTweaksUtils;
+ if (utils?.onViewPage) {
+ // Toolbar remounts across navigations — short poll per view, no permanent body MO
+ utils.onViewPage(() => {
+ armToolbarPoll();
+ });
+ } else if (utils?.onBodyMutation) {
+ let scheduled = false;
+ toolbarObserver = utils.onBodyMutation('hamburger-toolbar', () => {
+ if (scheduled) return;
+ scheduled = true;
+ requestAnimationFrame(() => {
+ scheduled = false;
+ ensureHamburgerButton();
+ });
+ });
+ }
+
+ LOG('Desktop hamburger menu initialized');
+ }
+
+ async function boot() {
+ const ok = await waitForAppVersion();
+ // v12+ modern desktop UI only
+ if (ok !== true) {
+ LOG(
+ 'Skipping desktop hamburger menu; requires ApiClient._appVersion >= 12.0.0',
+ window.ApiClient?._appVersion || '(unavailable)'
+ );
+ return;
+ }
+ start();
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ boot();
+ });
+ } else {
+ boot();
+ }
+})();
diff --git a/scripts/headerTabs.js b/scripts/headerTabs.js
index 4bef6d2..b4312f2 100644
--- a/scripts/headerTabs.js
+++ b/scripts/headerTabs.js
@@ -93,7 +93,7 @@
`${serverAddress}/DisplayPreferences/usersettings?userId=${userId}&client=emby`,
{
headers: {
- 'Authorization': apiHelper.getAuthHeader()
+ 'Authorization': window.apiHelper.getAuthHeader()
}
}
);
@@ -233,9 +233,67 @@
LOG('Tab clicked:', currentPage, tabIndex);
}
}
-
+
+
+ async function pollForElement(selectorFn, maxAttempts = 50, pollInterval = 100) {
+ let attempts = 0;
+ while (attempts < maxAttempts) {
+ const element = selectorFn();
+ if (element) {
+ return element;
+ }
+ attempts++;
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
+ }
+ WARN('Element not found after', attempts, 'attempts');
+ return null;
+ }
+
// Synchronize active tab state with URL parameter
- async function syncActiveTabState() {
+ async function syncActiveTabState(currentTabIndex) {
+ const headerTabButtons = document.querySelectorAll('.headerTabs button');
+ let targetTabButton = document.querySelector(`.headerTabs button[data-index="${currentTabIndex}"]`);
+
+ // If the target tab is a custom home tab, we may need to wait for it to be rendered
+ if (!targetTabButton && currentTabIndex > 1) {
+ // Wait for the target tab button to be rendered
+ targetTabButton = await pollForElement(() => document.querySelector(`.headerTabs button[data-index="${currentTabIndex}"]`), 50, 100);
+ }
+
+ if (targetTabButton && targetTabButton.classList.contains('emby-tab-button-active')) {
+ LOG('Target tab is already active');
+ return;
+ }
+
+ const pageTabContents = document.querySelectorAll('.libraryPage:not(.hide) .pageTabContent');
+ const targetPageTabContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabIndex}"]`);
+
+ const sectionsContainer = targetPageTabContent?.querySelector('.sections');
+ if (sectionsContainer && sectionsContainer.children.length === 0) {
+ LOG('Sections container is empty, waiting for sections to be rendered');
+ return;
+ }
+
+ if (headerTabButtons && targetTabButton && pageTabContents && targetPageTabContent) {
+ // Remove is-active class from all tab buttons
+ headerTabButtons.forEach(button => {
+ button.classList.remove('emby-tab-button-active');
+ });
+ // Add is-active class to the active tab button
+ targetTabButton.classList.add('emby-tab-button-active');
+ LOG('Set active tab button:', currentTabIndex);
+
+ // Remove is-active class from all page tab contents
+ pageTabContents.forEach(content => {
+ content.classList.remove('is-active');
+ });
+ // Add is-active class to the target page tab content
+ targetPageTabContent.classList.add('is-active');
+ LOG('Set active page tab content:', currentTabIndex);
+ }
+
+
+ return;
const headerTabs = document.querySelector('.headerTabs');
if (!headerTabs) return;
@@ -339,17 +397,27 @@
const view = window.KefinTweaksUtils.getCurrentView();
if (view === 'home' || view === 'home.html') {
- // Check for custom tabs
-
- const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, {
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- "Authorization": apiHelper.getAuthHeader(),
- },
- });
- const customTabs = await response.json();
- const customTabsCount = customTabs?.length || 0;
+ // Check for custom tabs (plugin may be missing / return empty body)
+ let customTabs = [];
+ try {
+ const response = await fetch(`${ApiClient._serverAddress}/CustomTabs/Config`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": window.apiHelper.getAuthHeader(),
+ },
+ });
+ if (response.ok) {
+ const text = await response.text();
+ if (text && text.trim()) {
+ const parsed = JSON.parse(text);
+ customTabs = Array.isArray(parsed) ? parsed : [];
+ }
+ }
+ } catch (e) {
+ WARN('CustomTabs/Config unavailable, skipping custom tab listeners:', e?.message || e);
+ }
+ const customTabsCount = customTabs.length;
totalButtons += customTabsCount;
if (totalButtons > buttons.length && customTabsCount > 0) {
@@ -385,7 +453,7 @@
// All custom tabs are now present
obs.disconnect();
LOG('All custom tabs rendered, adding click listeners');
- syncActiveTabState();
+ //syncActiveTabState();
// Add click listeners to all buttons including the new custom tabs
const allButtons = headerTabs.querySelectorAll('.emby-tab-button');
@@ -435,13 +503,22 @@
}
// Register onViewPage handler to sync active tab state
- window.KefinTweaksUtils.onViewPage((view, element) => {
+ window.KefinTweaksUtils.onViewPage((view, element, hash) => {
LOG('onViewPage handler triggered for view:', view);
addClickListeners();
+ // Get selected tab from hash
+ const hashParams = hash.includes('?') ? hash.split('?')[1] : '';
+ const urlParams = new URLSearchParams(hashParams);
+ const currentTab = urlParams.get('tab');
+ const currentTabIndex = currentTab ? parseInt(currentTab, 10) : 0;
+
// Sync active tab state when page view changes
- syncActiveTabState().catch(err => {
- ERR('Error in syncActiveTabState:', err);
- });
+ // This is only necessary for the home page with custom tabs
+ if ((view === 'home' || view === 'home.html')) {
+ syncActiveTabState(currentTabIndex).catch(err => {
+ ERR('Error in syncActiveTabState:', err);
+ });
+ }
}, {
pages: SUPPORTED_PAGES // Only trigger for supported pages
});
diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css
index 3ffb133..c6738d7 100644
--- a/scripts/homeScreen.css
+++ b/scripts/homeScreen.css
@@ -1,102 +1,20 @@
-/* Please use the CSS commented below to customize the order of KefinTweaks custom home sections */
-
-/*
-[data-custom-section-id="watchlist"] {
- order: 60 !important;
-}
-
-[data-custom-section-id="popular-tv-networks"] {
- order: 61 !important;
-}
-
-[data-custom-section-id="new-movies"] {
- order: 30 !important;
-}
-
-[data-custom-section-id="new-episodes"] {
- order: 31 !important;
-}
-*/
+@import url("https://fonts.googleapis.com/css2?family=Alfa+Slab+One&family=Anton&family=Changa+One:ital@0;1&family=Exo:ital,wght@0,100..900;1,100..900&family=Orbitron:wght@400..900&family=Oswald:wght@200..700&family=Prata&family=Roboto:ital,wght@0,100..900;1,100..900&family=Unbounded:wght@200..900&family=VT323&display=swap");
.sections.homeSectionsContainer {
- display: flex;
- flex-direction: column;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
}
-.homeSectionsContainer > .section0 {
- order: 1;
-}
-.homeSectionsContainer > .section1 {
- order: 2;
-}
-.homeSectionsContainer > .section2 {
- order: 3;
-}
-.homeSectionsContainer > .section3 {
- order: 4;
-}
-.homeSectionsContainer > .section4 {
- order: 5;
-}
-.homeSectionsContainer > .section5 {
- order: 6;
-}
-.homeSectionsContainer > .section6 {
- order: 7;
-}
-.homeSectionsContainer > .section7 {
- order: 8;
-}
-.homeSectionsContainer > .section8 {
- order: 9;
-}
-.homeSectionsContainer > .section9 {
- order: 10;
-}
-.homeSectionsContainer > .section10 {
- order: 11;
+.homePage [data-section-id] h2.sectionTitle,
+.homePage [data-section-id] .sectionTitleTextButton > .material-icons {
+ margin-bottom: 0 !important;
+ margin-top: 0 !important;
+ padding-bottom: 0 !important;
+ padding-top: 0 !important;
}
-/* Halloween theme */
-.home-screen.halloween-theme.with-background #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: -25%;
- background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(https://ranaldsgift.github.io/KefinTweaks/pages/images/halloween.jpg);
- background-position: 0px -250px;
- background-size: auto 102%;
-}
-
-/* Christmas theme */
-.home-screen.christmas-theme.with-background #reactRoot:has(.libraryPage:not(.hide) .pageTabContent.is-active[data-index="0"])>.backgroundContainer::after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0%;
- background: linear-gradient(rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.85)), url('../pages/images/christmas.jpg');
- background-position: center;
- background-size: cover;
-}
-
-.snowverlay {
- display: none;
-}
-
-.home-screen.christmas-theme .snowverlay {
- display: block;
-}
-
-.home-screen h2.sectionTitle {
- margin-bottom: .35em;
- margin-top: 0;
-}
-
-div#discovery-loading-indicator:not(.show)::before {
+/* div#discovery-loading-indicator:not(.show)::before {
content: 'Loading...';
position: absolute;
top: 50%;
@@ -107,41 +25,678 @@ div#discovery-loading-indicator:not(.show)::before {
visibility: visible;
text-align: center;
opacity: 1;
+} */
+
+#discovery-loading-indicator {
+ order: 999999999 !important;
+ width: fit-content;
+ margin: 0 auto;
}
-#indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before {
- content: 'Discover More';
+#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show)::before {
+ content: "Discover More";
}
-div#discovery-loading-indicator:not(.show)::after {
- content: '\E313';
- font-family: 'Material Icons';
- position: absolute;
- top: calc(50% + 0.4em);
- left: 50%;
- transform: translateX(-50%) translateY(-50%);
- font-size: 1.5em;
- color: #ffffff;
- visibility: visible;
- opacity: 1;
+#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::before {
+ content: "We've run out of suggestions!";
}
-.libraryPage:has(.load-more-discovery-btn) div#discovery-loading-indicator {
- opacity: 0;
+#discovery-loading-indicator:not(.show)::after {
+ content: "\E313";
+ font-family: "Material Icons";
+ font-size: 1.5em;
+ color: #ffffff;
+ visibility: visible;
+ opacity: 1;
}
-#indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
+#indexPage[data-discovery-exhausted] #discovery-loading-indicator:not(.show)::after {
+ content: "";
+}
+
+[data-loading-discovery="true"] #discovery-loading-indicator,
+.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator,
+.sections[data-loading-discovery="true"] #discovery-loading-indicator {
+ display: flex;
+}
+
+/* Idle Discover More / exhausted prompt (chevron mode) */
+#indexPage[data-discovery-ready] #discovery-loading-indicator:not(.show) {
+ display: flex !important;
+ visibility: visible !important;
+ opacity: 1 !important;
+ gap: 0;
+}
+
+/* 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;
+ display: none;
+}
+
+/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator {
display: none;
-}
+} */
-#indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after {
- content: '';
+#indexPage:not([data-discovery-ready]) #discovery-loading-indicator:not(.show)::after {
+ content: "";
}
/* Ensure Editor's Choice Banner appears at top of home screen if enabled */
.editorsChoiceContainer {
- order: 1 !important;
+ order: 1 !important;
}
.spotlight-section {
- width: calc(100% - (2 * max(env(safe-area-inset-left),3.3%)));
+ width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%)));
+}
+
+.load-more-discovery-btn {
+ display: block;
+ visibility: visible;
+ order: 99998 !important;
+ margin: 2em auto;
+}
+
+.sections.loading-discovery .load-more-discovery-btn {
+ visibility: hidden !important;
+}
+
+/* Discovery section loading indicator */
+.sections.loading-discovery .discovery-loading-indicator {
+ visibility: visible !important;
+ opacity: 1 !important;
+}
+
+.discovery-loading-indicator {
+ display: none;
+ appearance: none;
+ -webkit-appearance: none;
+ background: transparent;
+ border: none;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ font: inherit;
+ text-align: center;
+ color: inherit;
+ cursor: default;
+ opacity: 1;
+ flex-direction: column;
+ gap: 10px;
+ justify-content: center;
+ align-items: center;
+ border-radius: 8px;
+ font-size: 14px;
+ color: #666;
+ transition: opacity 0.3s ease;
+ position: relative;
+ justify-content: start;
+}
+
+/* Chevron mode: clickable Discover More (desktop optional; TV focus stop) */
+#indexPage[data-discovery-ready]:not([data-discovery-exhausted]) #discovery-loading-indicator:not(.show) {
+ cursor: pointer;
+}
+
+/* TV: real button + emby-button for spatial nav; keep Discover More chrome unstyled */
+.layout-tv button#discovery-loading-indicator.emby-button,
+.layout-tv #discovery-loading-indicator.discovery-load-more-focusable {
+ outline: none;
+ min-height: 3em;
+ padding: 0.5em 1em;
+ box-sizing: border-box;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ transform: none;
+ -webkit-appearance: none;
+ appearance: none;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+
+.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus,
+.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus {
+ background-color: var(--theme-primary-color, #00a4dc) !important;
+ background: var(--theme-primary-color, #00a4dc) !important;
+ color: #fff !important;
+ outline: none;
+ box-shadow: none;
+ border-radius: 8px;
+}
+
+.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::before,
+.layout-tv #discovery-loading-indicator.discovery-load-more-focusable.show-focus:focus::after,
+.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::before,
+.layout-tv button#discovery-loading-indicator.emby-button.show-focus:focus::after {
+ color: #fff !important;
+}
+
+.discovery-loading-indicator .spinner {
+ width: 30px;
+ height: 30px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: var(--theme-primary-color, #3498db);
+ border-radius: 50%;
+ animation: discovery-spin 1s linear infinite;
+}
+
+@keyframes discovery-spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+/* Hide scroll buttons on mobile using Jellyfin's layout classes */
+.layout-mobile .emby-scrollbuttons,
+.layout-tv .emby-scrollbuttons {
+ display: none !important;
+}
+
+.load-more-discovery-btn {
+ display: block;
+}
+
+.sections[data-loading-discovery="true"] .load-more-discovery-btn {
+ display: none !important;
+}
+
+.sections[data-loading-discovery="true"] .discovery-loading-indicator,
+.sections[data-loading-discovery="true"] #discovery-loading-indicator,
+.homeSectionsContainer[data-loading-discovery="true"] #discovery-loading-indicator {
+ display: flex !important;
+ opacity: 1 !important;
+}
+
+.discovery-loading-indicator {
+ display: none;
+ opacity: 0;
+ flex-direction: column;
+ gap: 10px;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+ border-radius: 8px;
+ font-size: 14px;
+ color: #666;
+ transition: opacity 0.3s ease;
+ position: relative;
+ justify-content: start;
+ min-height: 200px;
+}
+
+.discovery-loading-indicator .spinner {
+ width: 30px;
+ height: 30px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: var(--theme-primary-color, #3498db);
+ border-radius: 50%;
+ animation: discovery-spin 1s linear infinite;
+}
+
+@keyframes discovery-spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.homeSectionsContainer [data-section-id="popularTVNetworks"] .card .cardText {
+ visibility: hidden;
+}
+
+/* .homeSectionsContainer:not(.kefinHomeSectionsContainer) {
+ display: none !important;
+} */
+
+.homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] .cardScalable > a.cardImageContainer::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ background: linear-gradient(#000000, rgb(0 0 0 / 0%) 50%);
+ box-shadow: inset 0px 0px 20px 5px black;
+}
+
+.layout-mobile .homeSectionsContainer [data-section-id*="thematic-"] .card[data-type="Folder"] a.cardImageContainer::after {
+ top: 0.5rem;
+ left: 0.5rem;
+ font-size: 1.5rem !important;
+ line-height: 1.8rem !important;
+}
+
+.homeSectionsContainer .card[data-type="TvChannel"] a.cardImageContainer:not([style])::after {
+ content: attr(aria-label);
+ font-size: 2rem;
+ text-wrap: auto;
+ text-align: center;
+}
+
+.emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer {
+ gap: 4rem;
+}
+.layout-mobile .emby-scroller-container[data-section-id="liveTv-channels"] .itemsContainer {
+ gap: 2rem;
+}
+
+[data-section-id="upcoming"] .missingIndicator {
+ display: none;
+}
+
+.homePage[data-discovery-ready] #discovery-loading-indicator:not(.show) {
+ min-height: 50px;
+}
+
+@keyframes fadeInDelayed {
+ 0%,
+ 99% {
+ opacity: 0;
+ visibility: hidden;
+ }
+ 100% {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
+[data-section-id="browse-by-studio"] .card {
+ width: 8vw !important;
+}
+
+[data-section-id="browse-by-studio"] .card,
+[data-section-id="browse-by-studio"] .card .cardScalable,
+[data-section-id="browse-by-studio"] .card .cardImageContainer,
+[data-section-id="browse-by-studio"] .card .cardPadder {
+ border-radius: 0px !important;
+}
+
+/* Hide scroll buttons on mobile and tablet layouts */
+@media (max-width: 768px) {
+ .emby-scrollbuttons {
+ display: none !important;
+ }
+
+ [data-section-id="browse-by-studio"] .card {
+ 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;
+}
+@media (hover: hover) and (pointer: fine) {
+ .kefin-home-chrome:hover,
+ .kefin-home-chrome:focus-within {
+ opacity: 1;
+ }
+}
+/* TV: rail must be fully visible while focused (no hover) */
+.layout-tv #kefin-home-category-rail:focus-within,
+.layout-tv #kefin-home-category-rail:focus-within .kefin-home-category-rail {
+ opacity: 1;
+}
+.kefin-home-chrome.hide,
+#kefin-home-category-rail.hide,
+#kefin-home-category-rail.hide .kefin-home-category-rail {
+ display: none !important;
+}
+
+/* Hide home chrome off-home via root page key (no :has; JMP-safe) */
+html:not([data-active-page="home"]) #kefin-home-category-rail,
+html:not([data-active-page="home"]) #kefin-home-create-section-btn {
+ display: none !important;
+}
+
+.kefin-home-chrome button,
+#kefin-home-category-rail button {
+ pointer-events: auto;
+}
+.kefin-home-create-section {
+ left: 0;
+ bottom: 0.25em;
+}
+.kefin-home-create-section-btn {
+ padding: 0.25em;
+}
+.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 */
+:where(.kefin-home-category-rail) {
+ --AppBar-background: var(--jf-palette-AppBar-defaultBg);
+ --AppBar-color: var(--jf-palette-text-primary);
+ background-color: var(--jf-palette-AppBar-darkBg, var(--AppBar-background));
+ color: var(--jf-palette-AppBar-darkColor, var(--AppBar-color));
+ z-index: 10;
+ gap: 0.25rem;
+ pointer-events: auto;
+ display: flex;
+}
+@media (hover: hover) and (pointer: fine) {
+ #kefin-home-category-rail .kefin-home-category-rail:hover,
+ #kefin-home-category-rail .kefin-home-category-rail:focus-within {
+ opacity: 1;
+ background: #0f0f0f !important;
+ border-radius: 0 25px 25px 0;
+ box-shadow: 1px 0px 5px -1px black;
+ }
+}
+.kefin-home-category-item {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.15rem;
+ min-width: 0;
+}
+.kefin-home-category-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0.75;
+ -webkit-tap-highlight-color: transparent;
+}
+.kefin-home-category-btn.is-active,
+.kefin-home-category-item.is-active .kefin-home-category-btn {
+ 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;
+ top: unset !important;
+}
+.kefin-home-category-btn-label {
+ display: none;
+ font-size: 0.65rem;
+ line-height: 1.1;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ max-width: 4.5rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: none;
+ text-align: center;
+}
+.kefin-home-category-item.is-active .kefin-home-category-btn-label {
+ color: var(--theme-primary-color, #00a4dc);
+}
+
+@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;
+ box-shadow: 1px 1px 1px 1px #fff !important;
+ border-radius: 0;
+ opacity: 1;
+ pointer-events: auto;
+ margin: 0 !important;
+ border: none !important;
+ transition:
+ transform 0.25s ease,
+ opacity 0.25s ease;
+ }
+ :where(#kefin-home-category-rail) .kefin-home-category-rail:hover,
+ :where(#kefin-home-category-rail) .kefin-home-category-rail:focus-within {
+ opacity: 1;
+ }
+ .kefin-home-category-btn-label {
+ display: block;
+ }
+ .kefin-home-category-btn:hover,
+ .kefin-home-category-btn:focus,
+ .kefin-home-category-btn:focus-visible,
+ .kefin-home-category-btn:active {
+ opacity: 0.75;
+ background: transparent;
+ color: inherit;
+ }
+ .kefin-home-category-btn.is-active,
+ .kefin-home-category-btn.is-active:hover,
+ .kefin-home-category-btn.is-active:focus,
+ .kefin-home-category-btn.is-active:focus-visible,
+ .kefin-home-category-btn.is-active:active,
+ .kefin-home-category-item.is-active .kefin-home-category-btn,
+ .kefin-home-category-item.is-active .kefin-home-category-btn:hover,
+ .kefin-home-category-item.is-active .kefin-home-category-btn:focus,
+ .kefin-home-category-item.is-active .kefin-home-category-btn:focus-visible,
+ .kefin-home-category-item.is-active .kefin-home-category-btn:active {
+ opacity: 1;
+ color: var(--theme-primary-color, #00a4dc);
+ background: rgba(0, 164, 220, 0.18);
+ }
+ #kefin-home-category-rail.is-scroll-hidden .kefin-home-category-rail {
+ transform: translateY(100%);
+ pointer-events: none;
+ opacity: 0;
+ }
+ .kefin-home-create-section,
+ #kefin-home-create-section-btn {
+ display: none !important;
+ }
+ .homePage:not(.hide) {
+ padding-bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px));
+ }
+}
+
+.kefin-section-more-toggle {
+ margin-left: auto !important;
+}
+
+@media (min-width: 900px) {
+ #kefin-home-category-rail .kefin-home-category-rail,
+ #kefin-home-category-rail .kefin-home-category-rail::before {
+ background: none !important;
+ background-color: transparent !important;
+ }
+ #kefin-home-category-rail .kefin-home-category-rail {
+ position: fixed !important;
+ z-index: 10;
+ top: 50vh !important;
+ left: 0 !important;
+ right: auto !important;
+ bottom: auto !important;
+ width: auto !important;
+ height: auto !important;
+ max-width: none !important;
+ flex-direction: column !important;
+ transform: translateY(-50%);
+ overflow: visible !important;
+ padding: 5px 0 !important;
+ margin: 0 !important;
+ backdrop-filter: none;
+ border: none !important;
+ display: grid !important;
+ grid-auto-rows: 1fr;
+ }
+}
+
+#kefin-home-category-rail .kefin-home-category-rail {
+ height: auto !important;
+}
+
+.sections.homeSectionsContainer[data-category="discovery"] {
+ position: relative;
+}
+
+.sections.homeSectionsContainer[data-category="discovery"]::before {
+ content: "\E87A";
+ font-family: "Material Icons";
+ padding-left: max(env(safe-area-inset-left), 3.3%);
+ font-size: 1.5rem;
+ margin: 0;
+ padding-bottom: .2em;
+ padding-top: 1em;
+ display: block;
+ line-height: 1.2;
+}
+
+.sections.homeSectionsContainer[data-category="discovery"]::after {
+ content: "Discover Something To Watch";
+ font-family: inherit;
+ font-size: 1.5rem;
+ position: absolute;
+ left: calc(max(env(safe-area-inset-left), 3.3%) + 1.7em);
+ top: 1em;
+ line-height: 1.2;
+ pointer-events: none;
+}
+
+html:not([data-active-page="home"]) .snowverlay {
+ display: none;
+}
+
+// Hide the seasonal animations on mobile by default
+@media (max-width: 899px) {
+ .snowverlay {
+ display: none !important;
+ }
+}
+
+html[data-active-page="home"] body[data-seasonal-background="true"] #reactRoot > .backgroundContainer::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0%;
+ background: linear-gradient(rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.85)), var(--seasonal-background-image);
+ background-position: center;
+ background-size: cover;
+}
+
+/* Figure out a better way to do this without requiring :has() */
+/* .homeSectionsContainer:has(> .verticalSection.section0:empty):has(> .verticalSection.section1:empty):has(
+ > .verticalSection.section2:empty
+ ):has(> .verticalSection.section3:empty):has(> .verticalSection.section4:empty):has(> .verticalSection.section5:empty):has(
+ > .verticalSection.section6:empty
+ ):has(> .verticalSection.section7:empty):has(> .verticalSection.section8:empty):has(> .verticalSection.section9:empty):not(
+ :has(> .section9 ~ :not(.discovery-loading-indicator))
+ )::after {
+ content: "Customize your Home Screen from your User Settings > Home in the top right corner.";
+ text-align: center;
+ margin: 3em auto;
+ display: block;
+ animation: fadeInDelayed 0.1s linear 1s forwards;
+ opacity: 0;
+ visibility: hidden;
+}
+ */
+
+.kefin-home-category-btn span:is(.emby-button-foreground) {
+ top: 0px !important;
+}
+
+#kefin-home-category-rail[data-category]:not([data-category="none"]) ~ #slides-container {
+ display: none !important;
+}
+
+.sections.homeSectionsContainer:not([data-category="none"]),
+html.layout-mobile .homePage .sections.homeSectionsContainer:not([data-category="none"]) {
+ margin-top: 0 !important;
+ top: 0 !important;
+}
+
+html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root,
+html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop {
+ box-shadow: 0 3px 12px rgba(180, 0, 0, 0.7) !important;
+ border-bottom: 2px solid #8b0000;
+}
+html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop {
+ position: relative;
+}
+
+html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root::after,
+html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 0;
+ width: 100%;
+ height: 14px;
+ pointer-events: none;
+ z-index: 9998;
+ background-color: #8b0000;
+ -webkit-mask-image: url('data:image/svg+xml;utf8, ');
+ mask-image: url('data:image/svg+xml;utf8, ');
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+}
+
+/* Procedural blood edge (SVG via --halloween-blood-splatter from homeScreen3.js) */
+html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] header.MuiAppBar-root::before,
+html[data-active-page="home"] [data-seasonal-theme="seasonal-halloween"][data-seasonal-background="true"] .skinHeader:not(.osdHeader) .headerTop::before {
+ content: "";
+ position: absolute;
+ top: auto;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: var(--halloween-blood-band, 10px);
+ pointer-events: none;
+ opacity: var(--halloween-blood-opacity, 0.95);
+ z-index: 1;
+ background-image: var(--halloween-blood-splatter, none);
+ background-repeat: no-repeat !important;
+ background-size: 100% 100%;
+ background-position: bottom center;
+}
+
+html[data-active-page="home"] body[data-seasonal-background="true"] .backdropContainer .backdropImage {
+ display: none !important;
+}
+
+@media (hover: hover) and (pointer: fine) and (min-width: 900px) {
+ #kefin-home-category-rail .kefin-home-category-rail.kefin-home-chrome {
+ pointer-events: auto;
+ }
}
\ No newline at end of file
diff --git a/scripts/homeScreen.js b/scripts/homeScreen.js
index 0f7e147..0059926 100644
--- a/scripts/homeScreen.js
+++ b/scripts/homeScreen.js
@@ -1112,7 +1112,7 @@
try {
const response = await fetch(url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -1552,7 +1552,7 @@
try {
const response = await fetch(url, {
headers: {
- 'Authorization': apiHelper.getAuthHeader()
+ 'Authorization': `MediaBrowser Token="${ApiClient.accessToken()}"`
}
});
@@ -1649,7 +1649,7 @@
try {
const response = await fetch(`${serverUrl}/Genres?IncludeItemTypes=Movie`, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -1745,7 +1745,7 @@
queries.map(async (query) => {
const response = await fetch(query.url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -1854,7 +1854,7 @@
const response = await fetch(url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2214,7 +2214,7 @@
try {
const response = await fetch(url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2251,7 +2251,7 @@
try {
const response = await fetch(url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2565,7 +2565,7 @@
const studiosUrl = `${serverUrl}/Studios?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series&Recursive=true&Fields=DateCreated%2CPrimaryImageAspectRatio&StartIndex=0&ParentId=${library.Id}&userId=${userId}${filterParam}`;
const response = await fetch(studiosUrl, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2646,7 +2646,7 @@
const response = await fetch(url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -2680,7 +2680,7 @@
const response = await fetch(url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -3828,7 +3828,7 @@
const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`;
const movieResponse = await fetch(movieUrl, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -3890,7 +3890,7 @@
const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`;
const movieResponse = await fetch(movieUrl, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -3958,7 +3958,7 @@
const movieUrl = `${serverUrl}/Users/${userId}/Items/${recentMovie.Id}?Fields=People`;
const movieResponse = await fetch(movieUrl, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -4708,7 +4708,7 @@
const response = await fetch(url, {
headers: {
- "Authorization": apiHelper.getAuthHeader()
+ "Authorization": `MediaBrowser Token="${token}"`
}
});
@@ -5391,7 +5391,7 @@
const serverAddress = ApiClient.serverAddress();
const token = ApiClient.accessToken();
const genresResponse = await fetch(`${serverAddress}/Genres?IncludeItemTypes=Movie`, {
- headers: { "Authorization": apiHelper.getAuthHeader() }
+ headers: { "Authorization": `MediaBrowser Token="${token}"` }
});
if (!genresResponse.ok) {
diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js
new file mode 100644
index 0000000..352fa4f
--- /dev/null
+++ b/scripts/homeScreen3.js
@@ -0,0 +1,3091 @@
+// KefinTweaks Home Screen v3 (Config-Driven & Streamlined)
+(function() {
+ 'use strict';
+
+ const LOG = (...args) => console.log('[KefinTweaks HomeScreen3]', ...args);
+ const WARN = (...args) => console.warn('[KefinTweaks HomeScreen3]', ...args);
+ const ERR = (...args) => console.error('[KefinTweaks HomeScreen3]', ...args);
+
+ // Configuration
+ const PRE_FETCH_DISCOVERY_DATA = false; // If true, fetches first discovery group immediately. If false, waits for scroll/click.
+ /** Max sections resolved/rendered per discovery group (infinite/chevron pages). */
+ const DISCOVERY_SECTIONS_PER_GROUP = 100;
+ /** IntersectionObserver rootMargin for discovery section progressive enhance (batches + any discovery enhance). */
+ const DISCOVERY_ENHANCE_ROOT_MARGIN = '30% 0px 30% 0px';
+ /** How many viewports of content to keep below the fold before idling infinite discovery. */
+ const DISCOVERY_LEAD_VIEWPORTS = 1.5;
+ /** Max consecutive auto-fill groups in one wave (empty/tiny sections). */
+ const DISCOVERY_LEAD_FILL_MAX_GROUPS = 8;
+ /** Consecutive all-empty discovery groups before marking exhausted. */
+ const DISCOVERY_EMPTY_GROUP_STREAK_MAX = 6;
+ /** Settle delay after paint before counting surviving sections (enhance may remove empties). */
+ const DISCOVERY_EMPTY_GROUP_SETTLE_MS = 400;
+
+ // Dependencies
+ const ApiHelper = window.apiHelper;
+ const Config = window.KefinHomeConfig2; // Updated to use Config2
+ const PeopleCache = window.PeopleCache;
+ const StudiosCache = window.StudiosCache;
+
+ if (!Config) {
+ ERR('Configuration not found! Ensure homeScreenConfig2.js is loaded.');
+ return;
+ }
+
+ /**
+ * Hide native Jellyfin .sectionN rows only once homeScreen3 has successfully
+ * started (so clients that cannot parse this file keep the native home).
+ * Persist for the session via a single injected stylesheet.
+ */
+ function ensureNativeHomeSectionsHidden() {
+ const styleId = 'kefin-hide-native-homesections';
+ if (document.getElementById(styleId)) return;
+ const style = document.createElement('style');
+ style.id = styleId;
+ style.textContent = [
+ '.section0:not([data-page]),',
+ '.section1:not([data-page]),',
+ '.section2:not([data-page]),',
+ '.section3:not([data-page]),',
+ '.section4:not([data-page]),',
+ '.section5:not([data-page]),',
+ '.section6:not([data-page]),',
+ '.section7:not([data-page]),',
+ '.section8:not([data-page]),',
+ '.section9:not([data-page]) {',
+ ' display: none !important;',
+ '}'
+ ].join('\n');
+ (document.head || document.documentElement).appendChild(style);
+ }
+ ensureNativeHomeSectionsHidden();
+
+ /**
+ * Flatten section groups into a flat array of sections
+ * @param {Array} groups - Array of HomeScreenSectionGroup objects
+ * @returns {Array} Flat array of all sections
+ */
+ function flattenSectionGroups(groups) {
+ if (!Array.isArray(groups)) return [];
+ const flattened = [];
+ groups.forEach(group => {
+ if (group && Array.isArray(group.sections)) {
+ flattened.push(...group.sections);
+ }
+ });
+ return flattened;
+ }
+
+ // State
+ const state = {
+ renderedSections: new Set(),
+ renderedDiscoveryIds: {
+ genres: new Set(),
+ genresSeries: new Set(),
+ seriesStudios: new Set(),
+ seriesStudiosTopRated: new Set(),
+ collections: new Set(),
+ people: new Set(),
+ similar: new Set(),
+ watchlist: new Set(),
+ customDiscoverySections: new Set()
+ },
+ currentDiscoveryGenre: null,
+ currentDiscoveryStudio: null,
+ isPeopleCacheComplete: false,
+ discoveryGroupIndex: 0,
+ isRenderingDiscovery: false,
+ discoveryEnabled: true,
+ discoveryBufferPromise: null,
+ discoveryBuffer: [],
+ /** Unresolved section configs left after capping a discovery group render. */
+ discoveryGroupRemainder: [],
+ discoverySectionOrder: 100000000,
+ discoverySectionsRemain: true,
+ ensuringDiscoveryBuffer: false,
+ discoveryNeedsMoreScroll: false,
+ /** Consecutive auto-fill loads while lead margin is short (reset when lead OK or user scroll loads). */
+ discoveryLeadFillCount: 0,
+ /** Consecutive discovery groups where every section enhanced to empty / was removed. */
+ discoveryEmptyGroupStreak: 0,
+ discoveryMode: null, // 'infinite' | 'chevron'
+ infiniteScrollHandler: null,
+ discoveryWheelHandler: null,
+ discoveryTouchStartHandler: null,
+ discoveryTouchMoveHandler: null,
+ discoveryScrollDebounceTimer: null,
+ userDisplayPreferences: null,
+ firstDiscoveryBuffer: null,
+ getDisplayPrefernces: null,
+ kefinNextUp: false,
+ kefinContinueWatching: false,
+ kefinLatestMedia: false,
+ jellyfinOrders: {},
+ homePaintedEventFired: false,
+ isRenderingHome: false
+ };
+
+ async function fetchDisplayPreferences() {
+ if (!window.userHelper?.getUserDisplayPreferences) {
+ WARN('userHelper.getUserDisplayPreferences unavailable');
+ return null;
+ }
+ const { promise, cached } = await window.userHelper.getUserDisplayPreferences({ cacheOnly: true });
+ const prefs = cached || await promise;
+ state.userDisplayPreferences = prefs;
+ return prefs;
+ }
+ state.getDisplayPrefernces = fetchDisplayPreferences;
+
+ // Cache used to support re-rendering our sections after Jellyfin overwrites the container
+ const sectionCache = {
+ renderedSections: [], // Array of section configs + results
+ fragmentCache: null, // Reserved for future use if we cache DOM fragments
+ isInitialRender: true, // Track if this is first render for the current view
+ jellyfinHasRendered: false, // Track if Jellyfin has rendered native home sections
+ pendingJellyfinSectionObservers: []
+ };
+
+ // Create loading indicator as last child of the sections container (keeps height during load; TV Down focus)
+ function createDiscoveryLoadingIndicator(container) {
+ // Prefer the provided container, but fall back to the standard sections container
+ if (!container) {
+ container = document.querySelector('.homePage:not(.hide) #homeTab .sections')
+ || document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
+ }
+
+ let loadingEl = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator')
+ || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
+
+ // Migrate legacy div indicator to a real button (Jellyfin TV spatial nav targets buttons)
+ if (loadingEl && loadingEl.tagName !== 'BUTTON') {
+ const replacement = document.createElement('button');
+ replacement.type = 'button';
+ replacement.id = loadingEl.id;
+ replacement.className = loadingEl.className;
+ replacement.innerHTML = loadingEl.innerHTML || '
';
+ loadingEl.replaceWith(replacement);
+ loadingEl = replacement;
+ }
+
+ if (loadingEl) {
+ if (container) {
+ container.appendChild(loadingEl);
+ }
+ bindDiscoveryLoadMoreControl(loadingEl);
+ syncDiscoveryLoadMoreFocus(loadingEl);
+ return loadingEl;
+ }
+
+ if (!container) {
+ WARN('Discovery loading indicator: sections container not found');
+ return null;
+ }
+
+ loadingEl = document.createElement('button');
+ loadingEl.type = 'button';
+ loadingEl.className = 'discovery-loading-indicator';
+ loadingEl.id = 'discovery-loading-indicator';
+ loadingEl.innerHTML = `
+
+ `;
+
+ container.appendChild(loadingEl);
+ LOG('Created discovery loading indicator as last child of sections container');
+ bindDiscoveryLoadMoreControl(loadingEl);
+ syncDiscoveryLoadMoreFocus(loadingEl);
+ return loadingEl;
+ }
+
+ function removeDiscoveryLoadingIndicator() {
+ document.querySelectorAll('#discovery-loading-indicator').forEach((el) => el.remove());
+ }
+
+ function isDiscoveryLoadMoreInteractive() {
+ if (state.discoveryMode !== 'chevron') return false;
+ if (state.discoverySectionsRemain === false) return false;
+ if (state.isRenderingDiscovery) return false;
+ const page = getDiscoveryHomePageEl();
+ if (page?.dataset?.discoveryExhausted === 'true') return false;
+ const indicator = document.querySelector('#discovery-loading-indicator');
+ if (indicator?.classList.contains('show')) return false;
+ return true;
+ }
+
+ function syncDiscoveryLoadMoreFocus(indicator) {
+ const el = indicator
+ || document.querySelector('.homePage:not(.hide) #discovery-loading-indicator')
+ || document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator')
+ || document.querySelector('#discovery-loading-indicator');
+ if (!el) return;
+
+ const tv = document.documentElement.classList.contains('layout-tv');
+ const interactive = isDiscoveryLoadMoreInteractive();
+
+ if (tv && interactive) {
+ el.disabled = false;
+ el.removeAttribute('disabled');
+ el.setAttribute('tabindex', '0');
+ el.setAttribute('aria-label', 'Discover More');
+ // emby-button: Jellyfin TV spatial nav treats plain controls as Tab-only otherwise
+ el.classList.add('emby-button');
+ el.classList.add('show-focus');
+ el.classList.add('discovery-load-more-focusable');
+ } else {
+ el.setAttribute('tabindex', '-1');
+ el.classList.remove('emby-button');
+ el.classList.remove('show-focus');
+ el.classList.remove('discovery-load-more-focusable');
+ if (state.discoverySectionsRemain === false || getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') {
+ el.setAttribute('aria-label', "We've run out of suggestions!");
+ } else {
+ el.removeAttribute('aria-label');
+ }
+ }
+ }
+
+ function activateDiscoveryLoadMore() {
+ if (!isDiscoveryLoadMoreInteractive()) return false;
+ if (!isDiscoveryHomeContext()) return false;
+ LOG('Discover More activated; loading next discovery group...');
+ renderNextDiscoveryGroup();
+ return true;
+ }
+
+ function bindDiscoveryLoadMoreControl(indicator) {
+ if (!indicator || indicator.dataset.loadMoreBound === '1') return;
+ indicator.dataset.loadMoreBound = '1';
+
+ indicator.addEventListener('click', (e) => {
+ if (!isDiscoveryLoadMoreInteractive()) return;
+ e.preventDefault();
+ e.stopPropagation();
+ activateDiscoveryLoadMore();
+ });
+
+ indicator.addEventListener('keydown', (e) => {
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ if (!isDiscoveryLoadMoreInteractive()) return;
+ e.preventDefault();
+ e.stopPropagation();
+ activateDiscoveryLoadMore();
+ });
+ }
+
+ function getDiscoveryHomePageEl() {
+ return document.querySelector('#indexPage')
+ || document.querySelector('.homePage:not(.hide)')
+ || document.querySelector('.libraryPage:not(.hide)');
+ }
+
+ function setDiscoveryPageAttrs({ ready = false, infinite = false, exhausted = false } = {}) {
+ const page = getDiscoveryHomePageEl();
+ if (!page) return;
+ if (ready) page.dataset.discoveryReady = 'true';
+ else delete page.dataset.discoveryReady;
+ if (infinite) page.dataset.infiniteScroll = 'true';
+ else delete page.dataset.infiniteScroll;
+ if (exhausted) page.dataset.discoveryExhausted = 'true';
+ else delete page.dataset.discoveryExhausted;
+ syncDiscoveryLoadMoreFocus();
+ }
+
+ function showDiscoveryLoadingIndicator() {
+ const loadingIndicator = createDiscoveryLoadingIndicator(
+ document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer')
+ );
+ if (!loadingIndicator) return;
+ loadingIndicator.classList.add('show');
+ loadingIndicator.style.visibility = 'visible';
+ loadingIndicator.style.display = 'flex';
+ syncDiscoveryLoadMoreFocus(loadingIndicator);
+ }
+
+ function hideDiscoveryLoadingIndicator() {
+ const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator');
+ if (!loadingIndicator) return;
+ loadingIndicator.classList.remove('show');
+ // Chevron / exhausted idle UI is shown via data-discovery-ready CSS
+ if (state.discoveryMode === 'chevron' || state.discoverySectionsRemain === false) {
+ loadingIndicator.style.visibility = '';
+ loadingIndicator.style.display = '';
+ } else {
+ loadingIndicator.style.visibility = 'hidden';
+ loadingIndicator.style.display = 'none';
+ }
+ syncDiscoveryLoadMoreFocus(loadingIndicator);
+ }
+
+ function isDiscoveryHomeContext() {
+ const currentView = window.KefinTweaksUtils?.getCurrentView();
+ const isHomePage = currentView === 'home' || currentView === 'home.html';
+ if (!isHomePage) return false;
+ const activeTab = document.querySelector('.headerTabs .emby-tab-button-active');
+ if (!activeTab || activeTab.getAttribute('data-index') !== '0') return false;
+ return true;
+ }
+
+ function removeDiscoveryScrollListeners() {
+ if (state.discoveryScrollDebounceTimer) {
+ clearTimeout(state.discoveryScrollDebounceTimer);
+ state.discoveryScrollDebounceTimer = null;
+ }
+ if (state.infiniteScrollHandler) {
+ window.removeEventListener('scroll', state.infiniteScrollHandler);
+ state.infiniteScrollHandler = null;
+ }
+ if (state.discoveryWheelHandler) {
+ window.removeEventListener('wheel', state.discoveryWheelHandler);
+ state.discoveryWheelHandler = null;
+ }
+ if (state.discoveryTouchStartHandler) {
+ window.removeEventListener('touchstart', state.discoveryTouchStartHandler);
+ state.discoveryTouchStartHandler = null;
+ }
+ if (state.discoveryTouchMoveHandler) {
+ window.removeEventListener('touchmove', state.discoveryTouchMoveHandler);
+ state.discoveryTouchMoveHandler = null;
+ }
+ const container = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer');
+ if (container) delete container.dataset.discoveryHandler;
+ }
+
+ function markDiscoveryExhausted() {
+ if (state.discoverySectionsRemain === false
+ && getDiscoveryHomePageEl()?.dataset?.discoveryExhausted === 'true') {
+ return;
+ }
+ state.discoverySectionsRemain = false;
+ state.discoveryBuffer = null;
+ state.discoveryGroupRemainder = [];
+ state.discoveryEmptyGroupStreak = 0;
+ state.discoveryLeadFillCount = 0;
+ removeDiscoveryScrollListeners();
+ setDiscoveryPageAttrs({ ready: true, infinite: false, exhausted: true });
+ const loadingIndicator = createDiscoveryLoadingIndicator(
+ document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer')
+ );
+ if (loadingIndicator) {
+ loadingIndicator.classList.remove('show');
+ loadingIndicator.style.visibility = '';
+ loadingIndicator.style.display = '';
+ syncDiscoveryLoadMoreFocus(loadingIndicator);
+ }
+ LOG("Discovery exhausted — We've run out of suggestions!");
+ }
+
+ /**
+ * After a group paints, wait for enhance/empty dismiss, then update empty-group streak.
+ * @param {Element} container
+ * @param {Array<{ config?: { id?: string } }>} bufferedSections
+ */
+ function scheduleDiscoveryEmptyGroupStreakCheck(container, bufferedSections) {
+ const ids = (bufferedSections || [])
+ .map((s) => s?.config?.id)
+ .filter((id) => id != null)
+ .map((id) => String(id));
+ if (!ids.length || !container) return;
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setTimeout(() => {
+ if (state.discoverySectionsRemain === false) return;
+ if (!isDiscoveryHomeContext()) return;
+
+ const survivors = ids.filter((id) => {
+ const escaped =
+ typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
+ ? CSS.escape(id)
+ : id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ const el = container.querySelector(`[data-section-id="${escaped}"]`);
+ return !!(el && el.isConnected);
+ });
+
+ if (survivors.length === 0) {
+ state.discoveryEmptyGroupStreak += 1;
+ LOG('Discovery group left no surviving sections', {
+ streak: state.discoveryEmptyGroupStreak,
+ groupSize: ids.length,
+ });
+ if (state.discoveryEmptyGroupStreak >= DISCOVERY_EMPTY_GROUP_STREAK_MAX) {
+ markDiscoveryExhausted();
+ }
+ } else {
+ state.discoveryEmptyGroupStreak = 0;
+ }
+ }, DISCOVERY_EMPTY_GROUP_SETTLE_MS);
+ });
+ });
+ }
+
+ // Legacy helper kept for compatibility – no longer used in the main flow
+ async function waitForHomeSectionsContainer() {
+
+ const maxAttempts = 50;
+ let attempts = 0;
+ let homeSectionsContainer = null;
+ while (attempts < maxAttempts && !homeSectionsContainer) {
+ homeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer');
+ if (homeSectionsContainer) {
+ break;
+ }
+ attempts++;
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+
+ if (!homeSectionsContainer) {
+ LOG('Home sections container not found');
+ return false;
+ }
+
+ return homeSectionsContainer;
+ }
+
+ const HOME_SECTIONS_SELECTOR = '.homePage:not(.hide) #homeTab.is-active .sections';
+ let homeSectionsReadyWaitGeneration = 0;
+
+ function disconnectHomeSectionsReadyObserver() {
+ // Cancels in-flight waitForElement/poll by bumping generation
+ homeSectionsReadyWaitGeneration += 1;
+ LOG('Cancelled home sections ready wait');
+ }
+
+ function connectHomeSectionsReadyObserver() {
+ const generation = ++homeSectionsReadyWaitGeneration;
+
+ const existing = document.querySelector(HOME_SECTIONS_SELECTOR);
+ if (existing) {
+ LOG('Home sections container already present; enhancing');
+ enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' });
+ return;
+ }
+
+ const onFound = () => {
+ if (generation !== homeSectionsReadyWaitGeneration) return;
+ if (!document.querySelector(HOME_SECTIONS_SELECTOR)) return;
+ LOG('Home sections container appeared; enhancing');
+ enqueueHomeViewJob({ type: 'home', hash: window.location.hash || '' });
+ };
+
+ const utils = window.KefinTweaksUtils;
+ if (utils?.waitForElement) {
+ LOG('Waiting for home sections via waitForElement');
+ utils.waitForElement(HOME_SECTIONS_SELECTOR, { timeout: 20000, quiet: true }).then((el) => {
+ if (el) onFound();
+ });
+ return;
+ }
+ if (utils?.pollUntil) {
+ LOG('Waiting for home sections via pollUntil');
+ utils.pollUntil(
+ () => (generation === homeSectionsReadyWaitGeneration
+ ? document.querySelector(HOME_SECTIONS_SELECTOR)
+ : true),
+ { intervalMs: 200, timeoutMs: 20000 }
+ ).then((el) => {
+ if (el && el !== true) onFound();
+ });
+ return;
+ }
+ WARN('KefinTweaksUtils wait helpers unavailable; cannot wait for home sections container');
+ }
+
+ function mulberry32(seed) {
+ let t = seed >>> 0;
+ return () => {
+ t += 0x6D2B79F5;
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
+ r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+
+ /**
+ * Dense procedural blood edge for Halloween header (mostly filled, tiny gaps,
+ * uneven height up to 10px). Returns a CSS url(...) data SVG.
+ */
+ function generateHalloweenBloodSplatter({
+ width = 1400,
+ height = 10,
+ dropletCount = 420,
+ clusterCount = 5,
+ gapCount = 4,
+ seed = (Date.now() ^ (Math.random() * 1e9)) >>> 0,
+ } = {}) {
+ const rand = mulberry32(seed);
+
+ const clusters = Array.from({ length: clusterCount }, () => ({
+ x: rand() * width,
+ r: 60 + rand() * 120,
+ weight: 1.4 + rand() * 1.8,
+ tall: rand() < 0.55,
+ }));
+
+ const gaps = Array.from({ length: gapCount }, () => ({
+ x: rand() * width,
+ r: 14 + rand() * 28,
+ }));
+
+ const inGap = (x) => gaps.some((g) => Math.abs(x - g.x) < g.r);
+
+ const sampleCluster = (x) => {
+ let boost = 0;
+ let tall = false;
+ for (const c of clusters) {
+ const d = Math.abs(x - c.x) / c.r;
+ if (d < 1) {
+ const w = c.weight * (1 - d) * (1 - d);
+ boost += w;
+ if (c.tall && w > 0.35) tall = true;
+ }
+ }
+ return { boost, tall };
+ };
+
+ const drops = [];
+ let attempts = 0;
+ while (drops.length < dropletCount && attempts < dropletCount * 16) {
+ attempts += 1;
+ const x = rand() * width;
+ if (inGap(x)) continue;
+
+ const { boost, tall } = sampleCluster(x);
+ const accept = 0.82 + Math.min(0.18, boost * 0.08);
+ if (rand() > accept) continue;
+
+ let maxRise = 3.5 + rand() * 2.5;
+ if (tall) maxRise = height * (0.75 + rand() * 0.25);
+ else if (rand() < 0.06) maxRise = height * (0.7 + rand() * 0.3);
+
+ const y = height - rand() * maxRise;
+ const maxR = tall ? (0.9 + rand() * 1.6) : (0.45 + rand() * 1.1);
+ const r = 0.3 + rand() * maxR;
+ const fill = rand() < 0.3 ? '#990000' : (rand() < 0.55 ? '#8b0000' : '#7a0000');
+
+ if (rand() < 0.16) {
+ drops.push(
+ ` `,
+ );
+ } else {
+ drops.push(` `);
+ }
+ }
+
+ const svg =
+ `` +
+ drops.join('') +
+ ` `;
+
+ return {
+ seed,
+ cssUrl: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
+ };
+ }
+
+ const HALLOWEEN_BLOOD_STYLE_ID = 'kefin-halloween-blood-styles';
+
+ function clearHalloweenBloodInlinePollution(body = document.body) {
+ if (!body) return;
+ body.style.removeProperty('--halloween-blood-band');
+ body.style.removeProperty('--halloween-blood-opacity');
+ body.style.removeProperty('--halloween-blood-splatter');
+ delete body.dataset.halloweenBloodSeed;
+ try {
+ sessionStorage.removeItem('kefin-halloween-blood-seed');
+ } catch (_) { /* private mode */ }
+ }
+
+ /** Inject procedural blood SVG via a