// KefinTweaks Home Screen Section Editor
// Creation flow (mode select + wizard) and routing to the legacy advanced editor
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks Section Editor]', ...args);
const CREATE_FLOW_MODAL_ID = 'kefin-homescreen-section-create-flow';
const DEFAULT_SECTION_QUERY_LIMIT = 16;
const QUERY_WIZARD_STEPS = ['type', 'parent', 'query', 'settings', 'appearance', 'confirm'];
const CUSTOM_WIZARD_STEPS = ['type', 'customItems', 'settings', 'appearance', 'confirm'];
const EXTERNAL_WIZARD_STEPS = ['type', 'externalList', 'settings', 'appearance', 'confirm'];
const WIZARD_STEP_LABELS = {
type: 'Type',
parent: 'Selection',
query: 'Query',
customItems: 'Create Items',
externalList: 'External List',
appearance: 'Appearance',
settings: 'Settings',
confirm: 'Confirm'
};
const CUSTOM_TYPE = {
type: 'Custom',
label: 'Custom Item Section',
description: 'Show items with custom images, links and names. These items can link to existing Jellyfin pages or to external websites. Use this to link your other services, share client download locations, provide help information, etc.',
icon: 'widgets'
};
const EXTERNAL_TYPE = {
type: 'External',
label: 'External List Section',
description: 'Match an MDBList (or compatible) URL against your Jellyfin library by IMDb/TMDB provider IDs. Requires an MDBList API key in Home Screen General settings.',
icon: 'link'
};
const CUSTOM_IMAGE_FIELDS = [
{ key: 'posterUrl', label: 'Poster URL', required: true },
{ key: 'thumbUrl', label: 'Thumb URL' },
{ key: 'backdropUrl', label: 'Backdrop URL' },
{ key: 'squareUrl', label: 'Square URL' },
{ key: 'bannerUrl', label: 'Banner URL' },
{ key: 'logoUrl', label: 'Logo URL' }
];
const CUSTOM_POSTER_FIELD = CUSTOM_IMAGE_FIELDS[0];
const CUSTOM_ADDITIONAL_IMAGE_FIELDS = CUSTOM_IMAGE_FIELDS.slice(1);
const SECTION_VISIBILITY_OPTIONS = [
{ value: 'normal', title: 'Normal', desc: 'These sections are loaded every time the home page loads.' },
{ value: 'seasonal', title: 'Seasonal', desc: 'These sections will only appear between the specified start and end date every time the home page loads.' },
{ value: 'discovery', title: 'Discovery', desc: 'These sections will appear randomly after scrolling to the bottom of the home page when loading new sections.' }
];
const WIZARD_PARENT_DATA = {
PERSON_RESULT_LIMIT: 100,
STUDIO_RESULT_LIMIT: 100,
PARENT_LIST_DISPLAY_LIMIT: 100,
SEARCH_DEBOUNCE_MS: 300,
APPEARANCE_PREVIEW_DEBOUNCE_MS: 400
};
const LOCAL_SEARCH_PARENT_TYPES = new Set(['Genre', 'Tag', 'Collection', 'Playlist', 'Library']);
const SERVER_SEARCH_PARENT_TYPES = new Set(['Person', 'Studio']);
const PARENT_FILTER_CHIP_TYPES = new Set(['Genre', 'Tag', 'Studio']);
function getParentFilterItemTypes(wizardState, parentType = wizardState?.parentType) {
if (!parentType || !wizardState?.parentFilterItemTypesByType) return [];
const list = wizardState.parentFilterItemTypesByType[parentType];
return Array.isArray(list) ? list : [];
}
function ensureParentFilterItemTypes(wizardState, parentType = wizardState?.parentType) {
if (!wizardState.parentFilterItemTypesByType) {
wizardState.parentFilterItemTypesByType = {};
}
if (!parentType) return [];
if (!Array.isArray(wizardState.parentFilterItemTypesByType[parentType])) {
wizardState.parentFilterItemTypesByType[parentType] = [];
}
return wizardState.parentFilterItemTypesByType[parentType];
}
function getParentSearch(wizardState, parentType = wizardState?.parentType) {
if (!parentType || !wizardState?.parentSearchByType) return '';
return wizardState.parentSearchByType[parentType] || '';
}
function setParentSearch(wizardState, value, parentType = wizardState?.parentType) {
if (!wizardState.parentSearchByType) {
wizardState.parentSearchByType = {};
}
if (!parentType) return;
wizardState.parentSearchByType[parentType] = value || '';
// Keep legacy mirror in sync for the active type
if (parentType === wizardState.parentType) {
wizardState.search = wizardState.parentSearchByType[parentType];
}
}
function getParentFetchKey(wizardState, parentType = wizardState?.parentType) {
if (!parentType) return '';
const search = getParentSearch(wizardState, parentType);
const types = getParentFilterItemTypes(wizardState, parentType).slice().sort().join(',');
return `${search}\0${types}`;
}
function markParentFetchKey(wizardState, parentType = wizardState?.parentType) {
if (!wizardState.parentFetchKeyByType) {
wizardState.parentFetchKeyByType = {};
}
if (!parentType) return;
wizardState.parentFetchKeyByType[parentType] = getParentFetchKey(wizardState, parentType);
}
function parentFetchKeyMatches(wizardState, parentType = wizardState?.parentType) {
if (!parentType) return false;
return wizardState.parentFetchKeyByType?.[parentType] === getParentFetchKey(wizardState, parentType);
}
const PARENT_TYPES = [
{ type: 'Genre', label: 'Genre', plural: 'genres', description: 'Show items that share a Genre, like Action or Comedy.', icon: 'theater_comedy', queryField: 'GenreIds' },
{ type: 'Collection', label: 'Collection', plural: 'collections', description: 'Show items from a specific Collection in your library.', icon: 'collections_bookmark', queryField: 'ParentId' },
{ type: 'Playlist', label: 'Playlist', plural: 'playlists', description: 'Show items from a specific Playlist in your library.', icon: 'playlist_play', queryField: 'ParentId' },
{ type: 'Studio', label: 'Studio', plural: 'studios', description: 'Show items from a specific Film Studio or TV Network.', icon: 'apartment', queryField: 'StudioIds' },
{ type: 'Person', label: 'Person', plural: 'people', description: 'Show items that a specific Person has acted in or has directed, written, produced, etc.', icon: 'person', queryField: 'PersonIds' },
{ type: 'Tag', label: 'Tag', plural: 'tags', description: 'Show items that share a Tag, like Time Travel or Revenge.', icon: 'sell', queryField: 'Tags' }
];
const MULTI_SELECT_TYPES = new Set(['Genre', 'Tag', 'Person', 'Studio']);
function isMultiSelectType(type) {
return MULTI_SELECT_TYPES.has(type);
}
function getPrimaryParent(state) {
return state.parents?.[0] ?? null;
}
function pickRandom(items) {
return items[Math.floor(Math.random() * items.length)];
}
// --- Wizard parent data (live Jellyfin API) ---
function getWizardSteps(state) {
if (state?.parentType === 'Custom') return CUSTOM_WIZARD_STEPS;
if (state?.parentType === 'External') return EXTERNAL_WIZARD_STEPS;
return QUERY_WIZARD_STEPS;
}
function isCustomType(state) {
return state?.parentType === 'Custom';
}
function isExternalType(state) {
return state?.parentType === 'External';
}
function getStepIndex(step, state) {
return getWizardSteps(state).indexOf(step);
}
function createBlankCustomItem() {
if (window.KefinHomeScreenCustomItemsEditor?.createBlankCustomItem) {
return window.KefinHomeScreenCustomItemsEditor.createBlankCustomItem();
}
return {
name: '',
cardFooter: '',
cardUrl: '',
posterUrl: '',
thumbUrl: '',
backdropUrl: '',
squareUrl: '',
bannerUrl: '',
logoUrl: '',
hidden: false
};
}
function getWizardCustomItemsOptions() {
return window.KefinHomeScreenCustomItemsEditor?.getCustomItemsOptions({
idPrefix: 'hsse-custom',
actionAttr: 'data-hsse-action',
actions: {
openEditor: 'wizard-open-custom-item-editor',
closeEditor: 'wizard-close-custom-item-editor',
addItem: 'wizard-add-custom-item',
removeItem: 'wizard-remove-custom-item',
toggleImages: 'wizard-toggle-custom-images',
toggleHide: 'wizard-toggle-custom-item-hide',
moveLeft: 'wizard-move-custom-item-left',
moveRight: 'wizard-move-custom-item-right'
}
});
}
function ensureCustomItemsInitialized(state) {
if (!state.customItems?.length) {
state.customItems = [createBlankCustomItem()];
}
}
function hasAdditionalCustomImages(item) {
return CUSTOM_ADDITIONAL_IMAGE_FIELDS.some(field => item[field.key]?.trim());
}
function isCustomItemAdditionalExpanded(wizardState, index, item) {
const explicit = wizardState.customItemAdditionalExpanded?.[index];
if (explicit === true) return true;
if (explicit === false) return false;
return hasAdditionalCustomImages(item);
}
function reindexCustomItemAdditionalExpanded(wizardState, removedIndex) {
if (!wizardState.customItemAdditionalExpanded) return;
const next = {};
Object.entries(wizardState.customItemAdditionalExpanded).forEach(([key, value]) => {
const i = parseInt(key, 10);
if (Number.isNaN(i) || value === undefined) return;
if (i < removedIndex) next[i] = value;
else if (i > removedIndex) next[i - 1] = value;
});
wizardState.customItemAdditionalExpanded = next;
}
function hasValidCustomItems(items) {
return (items || []).some(item => item.name?.trim() && item.posterUrl?.trim());
}
function hasCustomItemContent(item) {
if (!item) return false;
return [
item.name,
item.cardFooter,
item.cardUrl,
item.posterUrl,
item.thumbUrl,
item.backdropUrl,
item.squareUrl,
item.bannerUrl,
item.logoUrl
].some(value => value?.trim());
}
function buildStaticItemFromDraft(draft) {
if (window.KefinHomeScreenCustomItemsEditor?.buildStaticItemFromDraft) {
return window.KefinHomeScreenCustomItemsEditor.buildStaticItemFromDraft(draft);
}
const name = draft.name?.trim();
const posterUrl = draft.posterUrl?.trim();
if (!name || !posterUrl) return null;
const imageKeys = ['posterUrl', 'thumbUrl', 'backdropUrl', 'squareUrl', 'bannerUrl', 'logoUrl'];
const provided = {};
imageKeys.forEach(key => {
const value = draft[key]?.trim();
if (value) provided[key] = value;
});
const item = { Name: name, Type: draft.type || 'Folder' };
if (draft.id) item.Id = draft.id;
const cardUrl = draft.cardUrl?.trim();
if (cardUrl) item.cardUrl = cardUrl;
const cardFooter = draft.cardFooter?.trim();
if (cardFooter) item.cardFooter = cardFooter;
if (draft.hidden === true) item.hidden = true;
const keys = Object.keys(provided);
if (keys.length === 1 && provided.posterUrl) {
item.imageUrl = provided.posterUrl;
} else {
Object.assign(item, provided);
}
return item;
}
function collectCustomItemFromForm(root, wizardState, index) {
if (!root || !wizardState.customItems?.[index]) return;
const item = wizardState.customItems[index];
const nameEl = root.querySelector(`#hsse-custom-${index}-name`);
const cardFooterEl = root.querySelector(`#hsse-custom-${index}-cardFooter`);
const cardUrlEl = root.querySelector(`#hsse-custom-${index}-cardUrl`);
if (nameEl) item.name = nameEl.value;
if (cardFooterEl) item.cardFooter = cardFooterEl.value;
if (cardUrlEl) item.cardUrl = cardUrlEl.value;
CUSTOM_IMAGE_FIELDS.forEach(field => {
const el = root.querySelector(`#hsse-custom-${index}-${field.key}`);
if (el) item[field.key] = el.value;
});
}
function collectCustomItemsFromForm(root, wizardState) {
if (!root || !wizardState.customItems) return;
const editorIndex = wizardState.customItemEditorIndex;
if (editorIndex != null && !Number.isNaN(editorIndex)) {
collectCustomItemFromForm(root, wizardState, editorIndex);
}
}
function updateCustomImagePreview(imgEl, url) {
if (!imgEl) return;
const trimmed = (url || '').trim();
if (!trimmed) {
imgEl.removeAttribute('src');
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'empty';
return;
}
imgEl.style.display = '';
imgEl.dataset.previewState = 'loading';
imgEl.onerror = () => {
imgEl.style.display = 'none';
imgEl.dataset.previewState = 'error';
};
imgEl.onload = () => {
imgEl.style.display = '';
imgEl.dataset.previewState = 'loaded';
};
imgEl.src = trimmed;
}
function markStepReached(wizardState, stepIndex) {
if (stepIndex > wizardState.maxReachedStepIndex) {
wizardState.maxReachedStepIndex = stepIndex;
}
}
function syncCurrentStepFromForm(root, wizardState) {
if (!root) return;
switch (wizardState.step) {
case 'query':
collectQueryStepFromForm(root, wizardState);
break;
case 'customItems':
collectCustomItemsFromForm(root, wizardState);
break;
case 'externalList':
collectExternalListFromForm(root, wizardState);
break;
case 'appearance':
collectAppearanceStepFromForm(root, wizardState);
break;
case 'settings':
collectSettingsFromForm(root, wizardState);
break;
default:
break;
}
}
function filterParentItemsBySearch(items, search) {
const q = (search || '').trim().toLowerCase();
if (!q) return items;
return items.filter(i => i.name.toLowerCase().includes(q));
}
function getDisplayParentItems(fullList, search, limit = WIZARD_PARENT_DATA.PARENT_LIST_DISPLAY_LIMIT) {
return filterParentItemsBySearch(fullList, search).slice(0, limit);
}
function renderParentItemTypeChipsHTML(activeTypes, actionAttr, toggleAction) {
return `
Included Item Types
${escapeHtml(getIncludedItemTypesHintText(activeTypes))}
${ITEM_TYPE_OPTIONS.map(type => {
const active = activeTypes.includes(type);
return `${escapeHtml(humanizeLabel(type))} `;
}).join('')}
`;
}
function formatItemCountMeta(count) {
if (count == null || Number.isNaN(count)) return '';
const n = parseInt(count, 10);
return n === 1 ? '1 item' : `${n} items`;
}
/**
* Unwrap progressive apiHelper.getQuery results ({ data, dataPromise/ensureData }).
* On cache miss, data is null until ensureData()/dataPromise resolves — never treat
* the wrapper object itself as the payload.
*/
async function resolveQueryPayload(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 asItemArray(payload) {
if (Array.isArray(payload?.Items)) return payload.Items;
if (Array.isArray(payload)) return payload;
return [];
}
async function fetchGenres(includeItemTypes) {
if (!window.dataHelper?.getFilterGenreItems) return [];
const types = (includeItemTypes && includeItemTypes.length > 0) ? includeItemTypes : null;
return window.dataHelper.getFilterGenreItems(types, { useCache: true });
}
async function fetchTags(includeItemTypes) {
if (!window.dataHelper?.getFilterTagItems) return [];
const types = (includeItemTypes && includeItemTypes.length > 0) ? includeItemTypes : null;
return window.dataHelper.getFilterTagItems(types, { useCache: true });
}
async function fetchCollectionsOrPlaylists(parentType) {
if (!window.apiHelper?.getItems) return [];
const itemType = parentType === 'Playlist' ? 'Playlist' : 'BoxSet,CollectionFolder';
const data = await window.apiHelper.getItems(
{ IncludeItemTypes: itemType, Recursive: true, Fields: 'ItemCounts' },
true,
24 * 60 * 60 * 1000
);
return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: formatItemCountMeta(item.ChildCount ?? item.UserData?.UnplayedItemCount ?? item.RecursiveItemCount)
}));
}
async function fetchStudios(search, includeItemTypes) {
const userId = window.ApiClient?.getCurrentUserId();
const serverAddress = window.ApiClient?.serverAddress();
if (!userId || !serverAddress || !window.apiHelper?.getQuery) return [];
const params = new URLSearchParams({
UserId: userId,
Limit: String(WIZARD_PARENT_DATA.STUDIO_RESULT_LIMIT),
Fields: 'PrimaryImageAspectRatio'
});
const trimmed = (search || '').trim();
if (trimmed.length === 1) {
params.set('NameStartsWith', trimmed);
} else if (trimmed.length > 1) {
params.set('SearchTerm', trimmed);
}
const types = (includeItemTypes && includeItemTypes.length > 0)
? (Array.isArray(includeItemTypes) ? includeItemTypes.join(',') : String(includeItemTypes))
: '';
if (types) {
params.set('IncludeItemTypes', types);
}
const url = `${serverAddress}/Studios?${params.toString()}`;
const useCache = !trimmed && !types;
const result = await window.apiHelper.getQuery(url, {
useCache,
ttl: 24 * 60 * 60 * 1000
});
const data = await resolveQueryPayload(result);
return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: formatItemCountMeta(item.ChildCount ?? item.SeriesCount)
})).filter(item => item.id);
}
async function fetchPersons(search) {
const userId = window.ApiClient?.getCurrentUserId();
const serverAddress = window.ApiClient?.serverAddress();
if (!userId || !serverAddress) return [];
const params = new URLSearchParams({
UserId: userId,
Limit: String(WIZARD_PARENT_DATA.PERSON_RESULT_LIMIT),
Fields: 'PrimaryImageAspectRatio,ProductionYear',
EnableTotalRecordCount: 'false'
});
const trimmed = (search || '').trim();
if (trimmed.length === 1) {
params.set('NameStartsWith', trimmed);
} else if (trimmed.length > 1) {
params.set('SearchTerm', trimmed);
}
const url = `${serverAddress}/Persons?${params.toString()}`;
let data;
if (window.apiHelper?.getQuery) {
const result = await window.apiHelper.getQuery(url, { useCache: false });
data = await resolveQueryPayload(result);
} else if (window.ApiClient?.fetch) {
const resp = await window.ApiClient.fetch({ url, method: 'GET' });
data = await resp.json();
} else {
return [];
}
return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: item.ProductionYear ? String(item.ProductionYear) : ''
}));
}
async function fetchLibraries() {
const userId = window.ApiClient?.getCurrentUserId();
if (!userId) return [];
try {
if (window.ApiClient?.getUserViews) {
const result = await window.ApiClient.getUserViews({}, userId);
return asItemArray(result).map(item => ({
id: item.Id,
name: item.Name,
meta: item.CollectionType ? humanizeLabel(item.CollectionType) : ''
})).filter(item => item.id);
}
const serverAddress = window.ApiClient?.serverAddress();
if (!serverAddress || !window.apiHelper?.getQuery) return [];
const url = `${serverAddress}/UserViews?UserId=${userId}`;
const result = await window.apiHelper.getQuery(url, { useCache: true, ttl: 24 * 60 * 60 * 1000 });
const data = await resolveQueryPayload(result);
return asItemArray(data).map(item => ({
id: item.Id,
name: item.Name,
meta: item.CollectionType ? humanizeLabel(item.CollectionType) : ''
})).filter(item => item.id);
} catch (err) {
console.warn('[KefinTweaks Section Editor] Failed to fetch libraries:', err);
return [];
}
}
async function fetchWizardParentItems(parentType, options = {}) {
const { search = '', includeItemTypes = [] } = options;
if (!window.ApiClient?.getCurrentUserId?.()) {
throw new Error('Server connection not available');
}
switch (parentType) {
case 'Genre':
return fetchGenres(includeItemTypes);
case 'Tag':
return fetchTags(includeItemTypes);
case 'Collection':
case 'Playlist':
return fetchCollectionsOrPlaylists(parentType);
case 'Studio':
return fetchStudios(search, includeItemTypes);
case 'Person':
return fetchPersons(search);
case 'Library':
return fetchLibraries();
default:
return [];
}
}
const ALL_ITEM_TYPES = [
'Movie', 'Series', 'Episode', 'Season',
'AggregateFolder', 'Audio', 'AudioBook', 'BasePluginFolder', 'Book', 'BoxSet', 'Channel',
'ChannelFolderItem', 'CollectionFolder', 'Folder', 'Genre', 'ManualPlaylistsFolder',
'LiveTvChannel', 'LiveTvProgram', 'MusicAlbum', 'MusicArtist', 'MusicGenre', 'MusicVideo',
'Person', 'Photo', 'PhotoAlbum', 'Playlist', 'PlaylistsFolder', 'Program', 'Recording',
'Studio', 'Trailer', 'TvChannel', 'TvProgram', 'UserRootFolder', 'UserView', 'Video', 'Year'
];
const LIBRARY_TYPE_META = {
type: 'Library',
label: 'Library',
plural: 'libraries',
description: 'Show items from a specific Library in your collection.',
icon: 'video_library',
queryField: 'ParentId'
};
const CUSTOM_PARENT_META = {
type: 'Custom',
label: 'Custom',
plural: 'custom parents',
description: 'Show items from a custom parent item from your collection including specific folders within your libraries.',
icon: 'folder_open',
queryField: 'ParentId'
};
const LIBRARY_ITEM_META = {
type: 'LibraryItem',
label: 'Item',
plural: 'items',
description: 'Search your library for specific items.',
icon: 'movie',
queryField: 'Ids'
};
const LIBRARY_ITEM_TYPE_OPTIONS = ['Movie', 'Series', 'Episode', 'Season', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book'];
const PARENT_SELECTION_TYPES = ['Collection', 'Playlist', 'Library', 'Custom'];
const FACET_SELECTION_TYPES = ['Genre', 'Tag', 'Person', 'Studio'];
function getSelectionTypeMeta(type) {
if (type === 'Library') return LIBRARY_TYPE_META;
if (type === 'Custom') return CUSTOM_PARENT_META;
if (type === 'LibraryItem') return LIBRARY_ITEM_META;
return PARENT_TYPES.find(t => t.type === type) || null;
}
async function fetchLibrarySearchItems(options = {}) {
const { search = '', includeItemTypes = [], limit = 100 } = options;
if (!window.apiHelper?.getItems || !window.ApiClient?.getCurrentUserId?.()) {
return [];
}
const params = {
Recursive: true,
SortBy: 'SortName',
SortOrder: 'Ascending',
Fields: 'PrimaryImageAspectRatio,ProductionYear,Type',
Limit: limit
};
const trimmed = (search || '').trim();
if (trimmed) params.SearchTerm = trimmed;
if (includeItemTypes.length) params.IncludeItemTypes = includeItemTypes.join(',');
const data = await window.apiHelper.getItems(params, false, 60000);
return (data?.Items || []).map(item => ({
id: item.Id,
name: item.Name,
type: item.Type,
meta: item.ProductionYear
? String(item.ProductionYear)
: (item.Type ? humanizeLabel(item.Type) : '')
})).filter(item => item.id);
}
function fetchSelectionItems(type, options = {}) {
return fetchWizardParentItems(type, options);
}
function selectionItemNeedsLabel(item) {
return !!(item?.id && (!item.name || item.name === item.id));
}
async function resolveItemsByGetItems(ids, includeItemTypes, idToName) {
if (!ids.length || !window.apiHelper?.getItems || !window.ApiClient?.getCurrentUserId?.()) return;
const options = {
Ids: ids.join(','),
Recursive: true,
Fields: 'PrimaryImageAspectRatio'
};
if (includeItemTypes) {
options.IncludeItemTypes = includeItemTypes;
}
const data = await window.apiHelper.getItems(options, true, 300000);
(data?.Items || []).forEach(item => {
if (item?.Id && item?.Name) {
idToName.set(String(item.Id), item.Name);
}
});
}
/**
* Resolve human-readable labels for selection chips saved with IDs only.
* @param {string} type - Collection|Playlist|Library|Custom|Genre|Tag|Person|Studio
* @param {Array<{id:string,name?:string}>} items
* @param {Object} [options]
* @param {string[]} [options.includeItemTypes]
* @returns {Promise>}
*/
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 = `
`;
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 `
`;
}
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 `
${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 Thumb', 'Series Poster'];
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 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));
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,
onClose: () => {
window.KefinHomeScreenAdvancedEditor?.destroyAppearancePickrs?.(modalInstance);
activeEditCtx = null;
},
onOpen: (modalInstance) => {
if (modalInstance.dialogContent?.querySelector('.hsae-root') && window.KefinHomeScreenAdvancedEditor) {
window.KefinHomeScreenAdvancedEditor.setupModalHeader(modalInstance);
let currentGroupName = activeEditCtx?.groupContext?.currentGroupName || '';
if (!currentGroupName && section.id) {
const config = getActiveConfig();
let found = null;
if (config) {
const groupTypes = ['HOME_SECTION_GROUPS', 'SEASONAL_SECTION_GROUPS', 'DISCOVERY_SECTION_GROUPS', 'CUSTOM_SECTION_GROUPS'];
for (const gt of groupTypes) {
found = findSectionInGroupsForEditor(config[gt] || [], section.id);
if (found) break;
}
}
if (found) currentGroupName = found.group.name || '';
}
if (!currentGroupName && section._targetGroupName) currentGroupName = section._targetGroupName;
const allGroups = getActiveConfig()?.CUSTOM_SECTION_GROUPS || [];
const existingGroupNames = [...new Set(allGroups.map(g => g.name).filter(Boolean))];
window.KefinHomeScreenAdvancedEditor.attachListeners(modalInstance, { section, ...(activeEditCtx?.groupContext || {}) });
}
// Attach event listeners
const saveSectionFromEditor = async () => {
const sectionData = collectSectionEditorData(originalSection);
if (!sectionData) {
sectionEditorShowToast('Error collecting section data');
return null;
}
sectionData.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 sectionData = collectSectionEditorData(originalSection);
if (!sectionData) {
sectionEditorShowToast('Error collecting section data for preview');
return;
}
sectionData.id = sectionData.id || originalSection?.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,
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');
})();