// KefinTweaks Home Screen Configuration UI // Modular configuration modal for managing home screen sections // Uses the new HomeScreenSection format with queries array (function() { 'use strict'; const LOG = (...args) => console.log('[KefinTweaks HomeScreen Config]', ...args); const WARN = (...args) => console.warn('[KefinTweaks HomeScreen Config]', ...args); const ERR = (...args) => console.error('[KefinTweaks HomeScreen Config]', ...args); // Modal IDs const MAIN_MODAL_ID = 'kefin-homescreen-config-main'; 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' }, 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: { label: 'Is Unplayed', type: 'boolean' }, 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' }, EnableUserData: { label: 'Enable User Data', type: 'boolean' } }; const CARD_FORMATS = ['Poster', 'Thumb', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc']; 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' } ]; // Current config state let currentConfig = null; let mainModalInstance = null; let currentActiveTab = 'settings'; // Track the currently active tab let currentGlobalSettingsSubTab = 'general'; // Track active sub-tab in Global Settings (general, spotlight, discovery, cache) /** * Show toast notification */ function showToast(message, duration = '3') { if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(message, duration); } else { // Fallback to alert if toaster not available alert(message); } } /** * Flatten section groups into a flat array of sections * @param {Array} groups - Array of HomeScreenSectionGroup objects * @returns {Array} Flat array of all sections */ function flattenSectionGroups(groups) { if (!Array.isArray(groups)) return []; const flattened = []; groups.forEach(group => { if (group && Array.isArray(group.sections)) { flattened.push(...group.sections); } }); return flattened; } /** * Get all sections from groups with metadata about which group they belong to * @param {Array} groups - Array of HomeScreenSectionGroup objects * @returns {Array} Array of objects with section and groupName */ function getAllSectionsFromGroups(groups) { if (!Array.isArray(groups)) return []; const result = []; groups.forEach(group => { if (group && Array.isArray(group.sections)) { group.sections.forEach(section => { result.push({ section: section, groupName: group.name || 'Unnamed Group', groupAuthor: group.author, groupDescription: group.description }); }); } }); return result; } /** * Render section groups with group-level and section-level checkboxes * Used by import and export modals to display sections within their groups * @param {HTMLElement} container - Container element to render into * @param {Array} sectionGroups - Array of section group objects { name, author?, description?, sections: [] } * @param {Object} options - Options object * @param {string} options.checkboxClass - CSS class for section checkboxes (e.g., 'export-custom-check', 'import-custom-check') * @param {string} options.groupCheckboxClass - CSS class for group checkboxes (optional, defaults to checkboxClass + '-group') */ function renderSectionGroupsCheckboxList(container, sectionGroups, options = {}) { if (!Array.isArray(sectionGroups) || sectionGroups.length === 0) { container.innerHTML = '
No section groups found.
'; return; } const checkboxClass = options.checkboxClass || 'section-check'; const groupCheckboxClass = options.groupCheckboxClass || checkboxClass + '-group'; let html = '
'; sectionGroups.forEach((group, groupIndex) => { if (!group || !Array.isArray(group.sections) || group.sections.length === 0) { return; } const groupName = group.name || 'Unnamed Group'; // Group checkbox row html += `
`; // Section checkboxes (indented) group.sections.forEach((section, sectionIndex) => { html += ` `; }); html += `
`; }); html += '
'; container.innerHTML = html; // Wire up group checkboxes to toggle all section checkboxes in that group container.querySelectorAll(`.${groupCheckboxClass}`).forEach(groupCheckbox => { groupCheckbox.addEventListener('change', (e) => { const groupIndex = parseInt(e.target.dataset.groupIndex); const isChecked = e.target.checked; container.querySelectorAll(`.${checkboxClass}[data-group-index="${groupIndex}"]`).forEach(sectionCheckbox => { sectionCheckbox.checked = isChecked; }); }); }); } /** * Find a section by ID in groups * @param {Array} groups - Array of HomeScreenSectionGroup objects * @param {string} sectionId - Section ID to find * @returns {Object|null} Object with section, group, and groupIndex, or null if not found */ function findSectionInGroups(groups, sectionId) { if (!Array.isArray(groups)) return null; for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { const group = groups[groupIndex]; if (group && Array.isArray(group.sections)) { const sectionIndex = group.sections.findIndex(s => s.id === sectionId); if (sectionIndex !== -1) { return { section: group.sections[sectionIndex], group: group, groupIndex: groupIndex, sectionIndex: sectionIndex }; } } } return null; } /** * Find a section by ID across all section groups (HOME, SEASONAL, DISCOVERY, CUSTOM) * @param {Object} config - Configuration object * @param {string} sectionId - Section ID to find * @returns {Object|null} Object with section, group, groupIndex, sectionIndex, and groupType, or null if not found */ function findSectionInAllGroups(config, sectionId) { if (!config) return null; // Check HOME_SECTION_GROUPS let found = findSectionInGroups(config.HOME_SECTION_GROUPS || [], sectionId); if (found) { found.groupType = 'HOME_SECTION_GROUPS'; return found; } // Check SEASONAL_SECTION_GROUPS found = findSectionInGroups(config.SEASONAL_SECTION_GROUPS || [], sectionId); if (found) { found.groupType = 'SEASONAL_SECTION_GROUPS'; return found; } // Check DISCOVERY_SECTION_GROUPS found = findSectionInGroups(config.DISCOVERY_SECTION_GROUPS || [], sectionId); if (found) { found.groupType = 'DISCOVERY_SECTION_GROUPS'; return found; } // Check CUSTOM_SECTION_GROUPS found = findSectionInGroups(config.CUSTOM_SECTION_GROUPS || [], sectionId); if (found) { found.groupType = 'CUSTOM_SECTION_GROUPS'; return found; } return null; } /** * Update a section in groups * @param {Array} groups - Array of HomeScreenSectionGroup objects * @param {string} sectionId - Section ID to update * @param {Object} updatedSection - Updated section data * @returns {boolean} True if section was found and updated */ function updateSectionInGroups(groups, sectionId, updatedSection) { const found = findSectionInGroups(groups, sectionId); if (found) { found.group.sections[found.sectionIndex] = { ...found.section, ...updatedSection }; return true; } return false; } /** * Add a section to a group * @param {Array} groups - Array of HomeScreenSectionGroup objects * @param {string} groupName - Name of the group to add to * @param {Object} section - Section to add * @param {string} author - Optional author for custom groups */ function addSectionToGroup(groups, groupName, section, author) { if (!Array.isArray(groups)) return; // Find existing group let targetGroup = groups.find(g => { if (author) { return g.name === groupName && g.author === author; } return g.name === groupName && !g.author; }); // Create group if it doesn't exist if (!targetGroup) { targetGroup = { name: groupName, sections: [] }; if (author) { targetGroup.author = author; } groups.push(targetGroup); } // Add section if (!targetGroup.sections) { targetGroup.sections = []; } targetGroup.sections.push(section); // Sort sections by order targetGroup.sections.sort((a, b) => (a.order || 0) - (b.order || 0)); } /** * Remove a section from groups * @param {Array} groups - Array of HomeScreenSectionGroup objects * @param {string} sectionId - Section ID to remove * @returns {boolean} True if section was found and removed */ function removeSectionFromGroups(groups, sectionId) { if (!Array.isArray(groups)) return false; for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { const group = groups[groupIndex]; if (group && Array.isArray(group.sections)) { const sectionIndex = group.sections.findIndex(s => s.id === sectionId); if (sectionIndex !== -1) { group.sections.splice(sectionIndex, 1); // Remove empty groups (except default groups) if (group.sections.length === 0 && !group.author) { groups.splice(groupIndex, 1); } return true; } } } return false; } /** * Verify and sync library-based section groups (Recently Added, Popular Genres, etc.) with actual Jellyfin libraries * @param {Object} config - Current configuration object * @returns {Object} Updated configuration with synced library-based sections */ async function verifyLibrarySectionsConfig(config) { try { if (!window.ApiClient || !window.ApiClient._loggedIn) { WARN('User not logged in, skipping library sections verification'); return config; } if (!window.dataHelper || !window.dataHelper.getLibraries) { WARN('dataHelper.getLibraries not available, skipping library sections verification'); return config; } const libraries = await window.dataHelper.getLibraries(); if (!libraries || libraries.length === 0) { LOG('No libraries found, skipping library sections verification'); return config; } // Filter out boxsets and playlists; all templates apply to this list const filteredLibraries = libraries.filter(l => l.CollectionType && l.CollectionType !== 'boxsets' && l.CollectionType !== 'playlists'); const currentLibraryIds = new Set(filteredLibraries.map(l => l.Id)); const libraryTemplates = [ { idPrefix: 'recently-added-', groupId: 'home-recently-added', groupName: 'Recently Added', sectionTemplate: { enabled: false, order: 61, cardFormat: 'Poster', buildSection: function (library) { let viewMoreUrl = null; if (library.CollectionType === 'movies') { viewMoreUrl = `#/movies.html?topParentId=${library.Id}&collectionType=movies&tab=1`; } else if (library.CollectionType === 'tvshows') { viewMoreUrl = `#/tv.html?topParentId=${library.Id}&collectionType=tvshows&tab=1`; } return { id: `recently-added-${library.Id}`, name: `Recently Added ${library.Name}`, viewMoreUrl: viewMoreUrl, queries: [{ path: '/Items/Latest', queryOptions: { ParentId: library.Id, SortBy: 'DateCreated', SortOrder: 'Descending', Limit: 16 } }] }; } } }, { idPrefix: 'popular-genres-', groupId: 'home-popular-genres', groupName: 'Popular Genres', sectionTemplate: { enabled: false, order: 81, cardFormat: 'Thumb', ttl: 604800000, buildSection: function (library) { const ct = (library.CollectionType || '').toLowerCase(); let includeItemTypes = ['Movie', 'Series', 'Video', 'MusicArtist', 'Book', 'AudioBook']; if (ct === 'music') includeItemTypes = ['MusicArtist']; else if (ct === 'tvshows') includeItemTypes = ['Series']; else if (ct === 'movies') includeItemTypes = ['Movie']; else if (ct === 'books') includeItemTypes = ['Book', 'AudioBook']; else if (ct === 'homevideos') includeItemTypes = ['Video']; return { id: `popular-genres-${library.Id}`, name: `Popular ${library.Name} Genres`, queries: [{ path: '/Genres', ParentId: library.Id, queryOptions: { ParentId: library.Id, IncludeItemTypes: includeItemTypes, SortBy: 'ChildCount', SortOrder: 'Descending', Limit: 20 } }] }; } } } ]; const groups = config.HOME_SECTION_GROUPS || []; let updatedGroups = groups.map(g => ({ ...g, sections: Array.isArray(g.sections) ? [...g.sections] : [] })); let hasChanges = false; for (const template of libraryTemplates) { let group = updatedGroups.find(g => g.id === template.groupId); if (!group) { group = { id: template.groupId, name: template.groupName, sections: [] }; updatedGroups.push(group); hasChanges = true; } const groupSections = group.sections || []; const templateSectionIds = groupSections.filter(s => s.id && s.id.startsWith(template.idPrefix)).map(s => s.id); const existingLibraryIdsForTemplate = new Set(templateSectionIds.map(id => id.replace(template.idPrefix, ''))); const sectionsToRemove = groupSections.filter(s => { if (!s.id || !s.id.startsWith(template.idPrefix)) return false; const libId = s.id.replace(template.idPrefix, ''); return !currentLibraryIds.has(libId); }); const sectionsToRemoveIds = new Set(sectionsToRemove.map(s => s.id)); const librariesToAdd = filteredLibraries.filter(l => !existingLibraryIdsForTemplate.has(l.Id)); if (sectionsToRemoveIds.size > 0 || librariesToAdd.length > 0) hasChanges = true; group.sections = groupSections.filter(s => !sectionsToRemoveIds.has(s.id)); librariesToAdd.forEach(library => { const base = template.sectionTemplate.buildSection(library); const newSection = { ...template.sectionTemplate, ...base, id: base.id, name: base.name, queries: base.queries }; delete newSection.buildSection; group.sections.push(newSection); }); } if (!hasChanges) { LOG('Library-based sections are in sync with libraries'); return config; } config.HOME_SECTION_GROUPS = updatedGroups; LOG('Library-based sections synced successfully'); return config; } catch (error) { ERR('Error verifying library sections config:', error); return config; } } /** * Load configuration from JS Injector and merge with defaults */ function loadConfig() { try { // Get existing config from window.KefinTweaksConfig const existingConfig = (window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig) || {}; // Get defaults from homeScreenConfig2.js const defaults = window.KefinHomeConfig2; if (!defaults) { throw new Error('KefinHomeConfig2 not found. Ensure homeScreenConfig2.js is loaded.'); } // Merge configs - now using 4 groups const mergedConfig = { HOME_SECTION_GROUPS: mergeGroupDefaults( defaults.HOME_SECTION_GROUPS || [], existingConfig.HOME_SECTION_GROUPS || [], 'home' ), SEASONAL_SECTION_GROUPS: mergeGroupDefaults( defaults.SEASONAL_SECTION_GROUPS || [], existingConfig.SEASONAL_SECTION_GROUPS || [], 'seasonal' ), DISCOVERY_SECTION_GROUPS: mergeGroupDefaults( defaults.DISCOVERY_SECTION_GROUPS || [], existingConfig.DISCOVERY_SECTION_GROUPS || [], 'discovery' ), CUSTOM_SECTION_GROUPS: mergeGroupDefaults( defaults.CUSTOM_SECTION_GROUPS || [], existingConfig.CUSTOM_SECTION_GROUPS || [], 'custom' ), DISCOVERY_SETTINGS: { ...defaults.DISCOVERY_SETTINGS, ...(existingConfig.DISCOVERY_SETTINGS || {}) }, SEASONAL_THEME_SETTINGS: { ...defaults.SEASONAL_THEME_SETTINGS, ...(existingConfig.SEASONAL_THEME_SETTINGS || {}) }, CACHE: { ...defaults.CACHE, ...(existingConfig.CACHE || {}) }, SPOTLIGHT_SETTINGS: { ...defaults.SPOTLIGHT_SETTINGS, ...(existingConfig.SPOTLIGHT_SETTINGS || {}) }, HOME_SETTINGS: { ...defaults.HOME_SETTINGS, ...(existingConfig.HOME_SETTINGS || {}) }, MERGE_NEXT_UP: existingConfig.MERGE_NEXT_UP ?? defaults.MERGE_NEXT_UP ?? false }; // Add ENABLED_NORMAL_SECTIONS and ENABLED_DISCOVERY_SECTIONS // ENABLED_NORMAL_SECTIONS is all enabled sections from HOME_SECTION_GROUPS, SEASONAL_SECTION_GROUPS, and CUSTOM_SECTION_GROUPS that aren't discovery sections // If they have a start/end date the current date must fall within that date range // ENABLED_DISCOVERY_SECTIONS is all enabled sections from DISCOVERY_SECTION_GROUPS and CUSTOM_SECTION_GROUPS that are discovery sections // If they have a start/end date the current date must fall within that date range const isInSeasonalPeriod = (startDate, endDate) => { const currentDate = new Date(); const start = new Date(startDate); const end = new Date(endDate); // Ensure the years are the same as the current date year start.setFullYear(currentDate.getFullYear()); end.setFullYear(currentDate.getFullYear()); return currentDate >= start && currentDate <= end; }; const enabledHomeSections = flattenSectionGroups(mergedConfig.HOME_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const enabledSeasonalSections = flattenSectionGroups(mergedConfig.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && isInSeasonalPeriod(s.startDate, s.endDate)); const enabledCustomSections = flattenSectionGroups(mergedConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const enabledDiscoverySections = flattenSectionGroups(mergedConfig.DISCOVERY_SECTION_GROUPS).filter(s => s.enabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const enabledCustomDiscoverySections = flattenSectionGroups(mergedConfig.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); mergedConfig.ENABLED_NORMAL_SECTIONS = [...enabledHomeSections, ...enabledSeasonalSections, ...enabledCustomSections]; mergedConfig.ENABLED_DISCOVERY_SECTIONS = [...enabledDiscoverySections, ...enabledCustomDiscoverySections]; // Verify and sync recently-added library sections //await verifyLibrarySectionsConfig(mergedConfig); currentConfig = mergedConfig; return mergedConfig; } catch (error) { ERR('Error loading config:', error); throw error; } } /** * Get merged home screen config (exported for use by other scripts) * @returns {Promise} Merged configuration object */ function getConfig(forceReload = false) { if (currentConfig && !forceReload) { return currentConfig; } let config = loadConfig(); return config; } /** * Get enabled home sections and discovery sections * @returns {Object} Object with enabledHomeSections and enabledDiscoverySections */ function getSections() { const config = getConfig(); const isInSeasonalPeriod = (startDate, endDate) => { const currentDate = new Date(); const start = new Date(startDate); const end = new Date(endDate); // Ensure the years are the same as the current date year start.setFullYear(currentDate.getFullYear()); end.setFullYear(currentDate.getFullYear()); return currentDate >= start && currentDate <= end; }; const enabledHomeSections = flattenSectionGroups(config.HOME_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const enabledSeasonalSections = flattenSectionGroups(config.SEASONAL_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && isInSeasonalPeriod(s.startDate, s.endDate)); const enabledCustomSections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled !== true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const enabledDiscoverySections = flattenSectionGroups(config.DISCOVERY_SECTION_GROUPS).filter(s => s.enabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); const enabledCustomDiscoverySections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS).filter(s => s.enabled === true && s.discoveryEnabled === true && (s.startDate && s.endDate ? isInSeasonalPeriod(s.startDate, s.endDate) : true)); return { enabledHomeSections: [...enabledHomeSections, ...enabledSeasonalSections, ...enabledCustomSections], enabledDiscoverySections: [...enabledDiscoverySections, ...enabledCustomDiscoverySections] } } /** * Merge default groups with admin overrides * @param {Array} defaultGroups - Array of default HomeScreenSectionGroup objects * @param {Array} overrideGroups - Array of override HomeScreenSectionGroup objects * @param {string} sectionType - Type of section to merge (home, seasonal, discovery, custom) * @returns {Array} Merged groups */ function mergeGroupDefaults(defaultGroups, overrideGroups, sectionType = 'home') { const merged = []; const overrideGroupMapById = new Map(); const overrideGroupMapByName = new Map(); const overrideGroupByOriginalName = new Map(); // Create maps of override groups by id, name/author, and _originalName overrideGroups.forEach(overrideGroup => { // Map by id (preferred method) if (overrideGroup.id) { overrideGroupMapById.set(overrideGroup.id, overrideGroup); } // Map by name/author (fallback for backward compatibility) const nameKey = overrideGroup.author ? `${overrideGroup.name}::${overrideGroup.author}` : overrideGroup.name || 'Unnamed'; overrideGroupMapByName.set(nameKey, overrideGroup); // Also create a map by _originalName if it exists (for renamed default groups) if (overrideGroup._originalName) { const originalKey = overrideGroup.author ? `${overrideGroup._originalName}::${overrideGroup.author}` : overrideGroup._originalName || 'Unnamed'; overrideGroupByOriginalName.set(originalKey, overrideGroup); } }); // Merge default groups with overrides defaultGroups.forEach(defaultGroup => { let overrideGroup = null; let matchedBy = null; // Try to match by id first (preferred) if (defaultGroup.id) { overrideGroup = overrideGroupMapById.get(defaultGroup.id); if (overrideGroup) { matchedBy = 'id'; } } // If no match by id, try matching by name/author (backward compatibility) if (!overrideGroup) { const nameKey = defaultGroup.author ? `${defaultGroup.name}::${defaultGroup.author}` : defaultGroup.name || 'Unnamed'; overrideGroup = overrideGroupMapByName.get(nameKey); if (overrideGroup) { matchedBy = 'name'; } } // If still no match, try matching by _originalName if (!overrideGroup) { const nameKey = defaultGroup.author ? `${defaultGroup.name}::${defaultGroup.author}` : defaultGroup.name || 'Unnamed'; overrideGroup = overrideGroupByOriginalName.get(nameKey); if (overrideGroup) { matchedBy = 'originalName'; } } if (overrideGroup) { // Merge sections within the group const mergedSections = mergeSectionsInGroup( defaultGroup.sections || [], overrideGroup.sections || [] ); merged.push({ ...defaultGroup, ...overrideGroup, sections: mergedSections }); // Remove from maps using the keys that were used to store them if (overrideGroup.id && matchedBy === 'id') { overrideGroupMapById.delete(overrideGroup.id); } const currentNameKey = overrideGroup.author ? `${overrideGroup.name}::${overrideGroup.author}` : overrideGroup.name || 'Unnamed'; overrideGroupMapByName.delete(currentNameKey); if (overrideGroup._originalName) { const originalNameKey = overrideGroup.author ? `${overrideGroup._originalName}::${overrideGroup.author}` : overrideGroup._originalName || 'Unnamed'; overrideGroupByOriginalName.delete(originalNameKey); } } else { merged.push(defaultGroup); } }); // Add any custom groups (not in defaults) overrideGroupMapById.forEach(overrideGroup => { merged.push(overrideGroup); }); overrideGroupMapByName.forEach(overrideGroup => { // Only add if not already added via id map if (!overrideGroup.id || !overrideGroupMapById.has(overrideGroup.id)) { merged.push(overrideGroup); } }); // Add the type to each section in the merged sections merged.forEach(group => { group.sections.forEach(section => { section.type = sectionType; }); }); return merged; } /** * Merge sections within a group * @param {Array} defaultSections - Default sections * @param {Array} overrideSections - Override sections * @returns {Array} Merged sections */ function mergeSectionsInGroup(defaultSections, overrideSections) { const merged = []; const overrideMap = new Map(); // Create map of overrides by id overrideSections.forEach(override => { overrideMap.set(override.id, override); }); // Merge defaults with overrides defaultSections.forEach(defaultSection => { const override = overrideMap.get(defaultSection.id); if (override) { // Deep merge: override takes precedence but preserve queries structure merged.push({ ...defaultSection, ...override, queries: override.queries || defaultSection.queries || [] }); overrideMap.delete(defaultSection.id); } else { merged.push(defaultSection); } }); // Add any custom sections (not in defaults) overrideMap.forEach(override => { merged.push(override); }); // Sort by order return merged.sort((a, b) => (a.order || 0) - (b.order || 0)); } /** * Get saved config from JS Injector * @returns {Promise} Saved full KefinTweaksConfig object */ async function getSavedConfig() { try { if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { WARN('ApiClient not available, using window.KefinTweaksConfig as fallback'); return window.KefinTweaksConfig || {}; } const server = window.ApiClient._serverAddress; const token = window.ApiClient.accessToken(); // Get plugins list const pluginsResponse = await fetch(`${server}/Plugins`, { headers: { 'X-Emby-Token': token } }); if (!pluginsResponse.ok) { throw new Error(`Failed to get plugins: ${pluginsResponse.status} ${pluginsResponse.statusText}`); } const pluginsData = await pluginsResponse.json(); const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); // Find JavaScript Injector plugin const injectorPlugin = pluginsList.find(plugin => plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector' ); if (!injectorPlugin) { WARN('JavaScript Injector plugin not found, using window.KefinTweaksConfig as fallback'); return window.KefinTweaksConfig || {}; } // Get injector config const configResponse = await fetch(`${server}/Plugins/${injectorPlugin.Id}/Configuration`, { headers: { 'X-Emby-Token': token } }); if (!configResponse.ok) { throw new Error(`Failed to get plugin config: ${configResponse.status} ${configResponse.statusText}`); } const injectorConfig = await configResponse.json(); // Find KefinTweaks-Config script const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( script => script.Name === 'KefinTweaks-Config' ); if (kefinTweaksScript && kefinTweaksScript.Script) { // Extract config from script content const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/); if (scriptMatch && scriptMatch[1]) { try { const config = JSON.parse(scriptMatch[1]); return config || {}; } catch (parseError) { ERR('Error parsing config from script:', parseError); } } } // Fallback: use window.KefinTweaksConfig if available return window.KefinTweaksConfig || {}; } catch (error) { ERR('Error getting saved config:', error); // Fallback: use window.KefinTweaksConfig if available return window.KefinTweaksConfig || {}; } } /** * Deep merge queryOptions objects, preserving properties from savedQuery * @param {Object} savedQuery - Query from saved config * @param {Object} currentQuery - Query from current config * @returns {Object} Merged query object */ function mergeQueryOptions(savedQuery, currentQuery) { if (!savedQuery && !currentQuery) return {}; if (!savedQuery) return JSON.parse(JSON.stringify(currentQuery)); if (!currentQuery) return JSON.parse(JSON.stringify(savedQuery)); const merged = JSON.parse(JSON.stringify(savedQuery)); // Deep merge queryOptions if (currentQuery.queryOptions) { merged.queryOptions = { ...(savedQuery.queryOptions || {}), ...currentQuery.queryOptions }; } else if (savedQuery.queryOptions) { merged.queryOptions = JSON.parse(JSON.stringify(savedQuery.queryOptions)); } // Merge other query-level properties (current takes precedence) Object.keys(currentQuery).forEach(key => { if (key !== 'queryOptions') { merged[key] = currentQuery[key]; } }); return merged; } /** * Merge queries arrays by index, preserving queryOptions from saved queries * @param {Array} savedQueries - Queries from saved config * @param {Array} currentQueries - Queries from current config * @returns {Array} Merged queries array */ function mergeQueries(savedQueries, currentQueries) { if (!savedQueries || savedQueries.length === 0) { return currentQueries ? JSON.parse(JSON.stringify(currentQueries)) : []; } if (!currentQueries || currentQueries.length === 0) { return JSON.parse(JSON.stringify(savedQueries)); } const merged = []; const maxLength = Math.max(savedQueries.length, currentQueries.length); for (let i = 0; i < maxLength; i++) { const savedQuery = savedQueries[i]; const currentQuery = currentQueries[i]; if (savedQuery && currentQuery) { // Both exist - merge them merged.push(mergeQueryOptions(savedQuery, currentQuery)); } else if (currentQuery) { // Only current exists - use it merged.push(JSON.parse(JSON.stringify(currentQuery))); } else if (savedQuery) { // Only saved exists - preserve it merged.push(JSON.parse(JSON.stringify(savedQuery))); } } return merged; } /** * Merge section objects, preserving properties from savedSection * @param {Object} savedSection - Section from saved config * @param {Object} currentSection - Section from current config * @returns {Object} Merged section object */ function mergeSection(savedSection, currentSection) { if (!savedSection && !currentSection) return null; if (!savedSection) return JSON.parse(JSON.stringify(currentSection)); if (!currentSection) return JSON.parse(JSON.stringify(savedSection)); const merged = JSON.parse(JSON.stringify(savedSection)); // Merge section properties (current takes precedence for editable fields) Object.keys(currentSection).forEach(key => { if (key === 'queries') { // Special handling for queries array merged.queries = mergeQueries(savedSection.queries, currentSection.queries); } else { // For other properties, current config takes precedence merged[key] = currentSection[key]; } }); // Ensure id is preserved from currentSection if (currentSection.id) { merged.id = currentSection.id; } return merged; } /** * Merge group objects, matching sections by id * @param {Object} savedGroup - Group from saved config * @param {Object} currentGroup - Group from current config * @returns {Object} Merged group object */ function mergeGroup(savedGroup, currentGroup) { if (!savedGroup && !currentGroup) return null; if (!savedGroup) return JSON.parse(JSON.stringify(currentGroup)); if (!currentGroup) return JSON.parse(JSON.stringify(savedGroup)); const merged = JSON.parse(JSON.stringify(savedGroup)); // Update group properties from current (name, author, etc.) Object.keys(currentGroup).forEach(key => { if (key !== 'sections') { merged[key] = currentGroup[key]; } }); // Merge sections by id const mergedSections = []; const savedSections = savedGroup.sections || []; const currentSections = currentGroup.sections || []; const savedSectionMap = new Map(); // Create map of saved sections by id savedSections.forEach(section => { if (section.id && !section.deleted) { savedSectionMap.set(section.id, section); } }); // Process current sections (merge or add) currentSections.forEach(currentSection => { if (currentSection.deleted) { // Skip deleted sections from current config return; } const savedSection = savedSectionMap.get(currentSection.id); if (savedSection) { // Section exists in both - merge them mergedSections.push(mergeSection(savedSection, currentSection)); savedSectionMap.delete(currentSection.id); } else { // New section - add it mergedSections.push(JSON.parse(JSON.stringify(currentSection))); } }); // Add any remaining saved sections that weren't in current (preserve them) savedSectionMap.forEach(savedSection => { if (!savedSection.deleted) { mergedSections.push(JSON.parse(JSON.stringify(savedSection))); } }); merged.sections = mergedSections; return merged; } /** * Merge home screen group configs, matching groups by id * @param {Array} currentGroups - Groups from current config * @param {Array} savedGroups - Groups from saved config * @returns {Array} Merged groups array */ function mergeHomeScreenGroupConfig(currentGroups, savedGroups) { if (!currentGroups || currentGroups.length === 0) { // Filter out deleted groups from saved config return (savedGroups || []).filter(g => !g.deleted).map(g => JSON.parse(JSON.stringify(g))); } if (!savedGroups || savedGroups.length === 0) { return JSON.parse(JSON.stringify(currentGroups)); } const merged = []; const savedGroupMap = new Map(); // Create map of saved groups by id (with fallback to name for backward compatibility) savedGroups.forEach(group => { if (group.deleted) return; const key = group.id || group.name; if (key) { savedGroupMap.set(key, group); } }); // Process current groups (merge or add) currentGroups.forEach(currentGroup => { if (currentGroup.deleted) { // Skip deleted groups from current config return; } const key = currentGroup.id || currentGroup.name; const savedGroup = key ? savedGroupMap.get(key) : null; if (savedGroup) { // Group exists in both - merge them merged.push(mergeGroup(savedGroup, currentGroup)); savedGroupMap.delete(key); } else { // New group - add it merged.push(JSON.parse(JSON.stringify(currentGroup))); } }); // Add any remaining saved groups that weren't in current (preserve them) savedGroupMap.forEach(savedGroup => { if (!savedGroup.deleted) { merged.push(JSON.parse(JSON.stringify(savedGroup))); } }); return merged; } /** * Save configuration to JS Injector * Merges partial configs with saved config to preserve existing data */ async function saveConfig(config) { try { // Load saved config from JS Injector const savedConfig = await getSavedConfig(); const savedHomeScreenConfig = savedConfig.homeScreenConfig || {}; // Create merged config const mergedHomeScreenConfig = { // Merge default groups (HOME, SEASONAL, DISCOVERY) using deep merge HOME_SECTION_GROUPS: mergeHomeScreenGroupConfig( config.HOME_SECTION_GROUPS || [], savedHomeScreenConfig.HOME_SECTION_GROUPS || [] ), SEASONAL_SECTION_GROUPS: mergeHomeScreenGroupConfig( config.SEASONAL_SECTION_GROUPS || [], savedHomeScreenConfig.SEASONAL_SECTION_GROUPS || [] ), DISCOVERY_SECTION_GROUPS: mergeHomeScreenGroupConfig( config.DISCOVERY_SECTION_GROUPS || [], savedHomeScreenConfig.DISCOVERY_SECTION_GROUPS || [] ), // Custom groups are replaced entirely (UI is source of truth) CUSTOM_SECTION_GROUPS: config.CUSTOM_SECTION_GROUPS || [], // Merge other top-level properties (shallow merge) DISCOVERY_SETTINGS: { ...(savedHomeScreenConfig.DISCOVERY_SETTINGS || {}), ...(config.DISCOVERY_SETTINGS || {}) }, SEASONAL_THEME_SETTINGS: { ...(savedHomeScreenConfig.SEASONAL_THEME_SETTINGS || {}), ...(config.SEASONAL_THEME_SETTINGS || {}) }, CACHE: { ...(savedHomeScreenConfig.CACHE || {}), ...(config.CACHE || {}) }, SPOTLIGHT_SETTINGS: { ...(savedHomeScreenConfig.SPOTLIGHT_SETTINGS || {}), ...(config.SPOTLIGHT_SETTINGS || {}) }, HOME_SETTINGS: { ...(savedHomeScreenConfig.HOME_SETTINGS || {}), ...(config.HOME_SETTINGS || {}) }, MERGE_NEXT_UP: config.MERGE_NEXT_UP !== undefined ? config.MERGE_NEXT_UP : (savedHomeScreenConfig.MERGE_NEXT_UP !== undefined ? savedHomeScreenConfig.MERGE_NEXT_UP : false) }; // Ensure window.KefinTweaksConfig exists if (!window.KefinTweaksConfig) { window.KefinTweaksConfig = {}; } // Update homeScreenConfig with merged result window.KefinTweaksConfig.homeScreenConfig = mergedHomeScreenConfig; currentConfig = mergedHomeScreenConfig; // Merge other top-level config properties (preserve existing) Object.keys(savedConfig).forEach(key => { if (key !== 'homeScreenConfig' && !window.KefinTweaksConfig.hasOwnProperty(key)) { window.KefinTweaksConfig[key] = savedConfig[key]; } }); // Save to JS Injector using utils if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); LOG('Configuration saved to JS Injector'); return true; } else { // Fallback: try to use configuration.js functions if (typeof window.KefinTweaksConfiguration !== 'undefined' && window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector) { await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); LOG('Configuration saved to JS Injector (fallback)'); return true; } WARN('saveConfigToJavaScriptInjector not available'); return false; } } catch (error) { ERR('Error saving config:', error); return false; } } /** * Build Jellyfin checkbox HTML */ function buildCheckbox(id, checked, label) { return ` `; } /** * Build Jellyfin select/dropdown HTML */ function buildSelect(id, options, selectedValue, label) { const optionsHTML = options.map(opt => { const value = typeof opt === 'string' ? opt : opt.value; const label = typeof opt === 'string' ? opt : opt.label; const selected = value === selectedValue ? 'selected' : ''; return ``; }).join(''); return `
`; } /** * Build Jellyfin text input HTML */ function buildTextInput(id, value, label, type = 'text', placeholder = '') { return `
`; } /** * Build enabled toggle buttons (ENABLED/DISABLED) */ function buildEnabledToggleButtons(id, enabled) { const isEnabled = enabled !== false; return `
`; } /** * Build toggle slider (ON/OFF switch) - generic function for both settings and sections * @param {string} id - ID for the toggle (used for hidden checkbox if includeHiddenCheckbox is true) * @param {boolean} checked - Initial checked/enabled state * @param {string} label - Label text (only used if wrapInLabel is true) * @param {object} options - Configuration options * @param {boolean} options.includeHiddenCheckbox - Whether to include hidden checkbox (for settings) * @param {object} options.dataAttributes - Data attributes object (e.g., {sectionId: '...', sectionType: '...'}) * @param {string} options.cssClass - CSS class for toggle button (default: 'toggle-slider') * @param {boolean} options.wrapInLabel - Whether to wrap in label container (default: true) */ function buildToggleSlider(id, checked, label, options = {}) { const { includeHiddenCheckbox = false, dataAttributes = {}, cssClass = 'toggle-slider', wrapInLabel = true } = options; const isEnabled = checked !== false; const dataAttrsString = Object.entries(dataAttributes) .map(([key, value]) => `data-${key}="${value}"`) .join(' '); const toggleButton = ` `; const hiddenCheckbox = includeHiddenCheckbox ? ` ` : ''; if (wrapInLabel) { return ` `; } else { return hiddenCheckbox + toggleButton; } } /** * Update toggle slider UI programmatically * @param {HTMLElement} toggleButton - The toggle button element * @param {boolean} isEnabled - New enabled state */ function updateToggleSliderUI(toggleButton, isEnabled) { if (!toggleButton) return; toggleButton.dataset.enabled = isEnabled; toggleButton.style.background = isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'; // Update ON text const onText = toggleButton.querySelector('span:first-of-type'); if (onText) { onText.style.opacity = isEnabled ? '1' : '0'; onText.style.left = isEnabled ? '8px' : '6px'; } // Update OFF text const offText = toggleButton.querySelector('span:nth-of-type(2)'); if (offText) { offText.style.opacity = isEnabled ? '0' : '1'; offText.style.right = isEnabled ? '6px' : '8px'; } // Update knob position const knob = toggleButton.querySelector('span:last-of-type'); if (knob) { knob.style.left = isEnabled ? 'calc(100% - 26px)' : '3px'; } // Update hidden checkbox if present const checkboxId = toggleButton.dataset.checkboxId; if (checkboxId) { const checkbox = document.getElementById(checkboxId); if (checkbox) { checkbox.checked = isEnabled; } } } /** * Build filter by played status toggle button */ function buildFilterByPlayedStatusToggle(id, checked) { const isChecked = checked === true; return `
`; } /** * Build compact section row HTML */ function buildSectionRowHTML(section, type = 'home', groupName = null) { const isEnabled = section.enabled !== false; const isSeasonal = section.startDate && section.endDate; const isSpotlight = section.renderMode === 'Spotlight'; const isDiscovery = section.discoveryEnabled === true; const isCustom = type.toLowerCase() === 'custom' || section.id.startsWith('custom'); return `
${buildToggleSlider('', isEnabled, '', { dataAttributes: { 'section-id': section.id, 'section-type': type }, cssClass: 'toggle-slider section-toggle-switch', wrapInLabel: false })}
${groupName ? `${groupName}` : ''} Order: ${section.order || 0} ${section.cardFormat && !isSpotlight ? `${section.cardFormat}` : ''} ${isSpotlight ? `Spotlight` : ''} ${isSeasonal ? `Active from: ${section.startDate} - ${section.endDate}` : ''} ${isDiscovery ? `Discovery` : ''}
${isCustom ? ` ` : ''}
`; } /** * Check if device is mobile */ function isMobile() { return window.innerWidth <= 768; } function toggleSectionType(e) { const sectionType = e.target.dataset.sectionType; const groupContainer = e.target.closest('.section-group'); if (!groupContainer) return; // Get data-section-type-sectionType attribute from the group container const sectionTypeAttribute = groupContainer.getAttribute(`data-section-type-${sectionType}`); const newState = sectionTypeAttribute === 'false' ? 'true' : 'false'; if (newState === 'true') { e.target.classList.add('button-submit'); } else { e.target.classList.remove('button-submit'); } groupContainer.setAttribute(`data-section-type-${sectionType}`, newState); } function renderSectionTypeToggles(sectionTypes) { const sectionTypeTogglesContainer = document.createElement('div'); sectionTypeTogglesContainer.style.display = 'flex'; sectionTypeTogglesContainer.style.gap = '0.5em'; for (const type of sectionTypes) { const button = document.createElement('button'); button.type = 'button'; button.textContent = type; button.classList.add('emby-button'); button.classList.add('section-type-toggle-btn'); button.classList.add('raised'); button.classList.add('block'); button.classList.add('button-submit'); button.dataset.sectionType = type; button.style.padding = '0.5em 1em'; button.style.fontSize = '0.9em'; // Event handler is attached via event delegation in attachMainModalListeners sectionTypeTogglesContainer.appendChild(button); } return sectionTypeTogglesContainer.outerHTML; } /** * Build section navigation HTML (left column) */ function buildSectionNavigationHTML(activeType = 'home') { const types = [ { id: 'home', label: 'Home Sections' }, { id: 'seasonal', label: 'Seasonal Sections' }, { id: 'discovery', label: 'Discovery Sections' }, { id: 'custom', label: 'Custom Sections' }, { id: 'create', label: 'Create New Section' } ]; return `
${types.map(type => ` `).join('')}
`; } /** * Build global settings sub-navigation HTML (left column, same style as Edit Sections nav) */ function buildGlobalSettingsNavigationHTML(activeSubTab = 'general') { const tabs = [ { id: 'general', label: 'General Settings' }, { id: 'spotlight', label: 'Spotlight Settings' }, { id: 'discovery', label: 'Discovery Settings' }, { id: 'cache', label: 'Cache Settings' } ]; return `
${tabs.map(tab => ` `).join('')}
`; } /** * Build global settings panel content for the given sub-tab */ function buildGlobalSettingsPanelContent(subTab) { switch (subTab) { case 'general': return buildGeneralSettingsHTML(); case 'spotlight': return buildSpotlightSettingsHTML(); case 'discovery': return buildDiscoverySettingsHTML(); case 'cache': return buildCacheSettingsHTML(); default: return buildGeneralSettingsHTML(); } } /** * Build group HTML with sections and toggle-all button */ function buildGroupHTML(group, sectionType) { let sections = group.sections || []; if (sections.length === 0) return ''; sections = sections.filter(s => s.hidden !== true); const allEnabled = sections.every(s => s.enabled !== false); const groupId = `group-${group.name || 'unnamed'}-${group.author || ''}`.replace(/[^a-zA-Z0-9-]/g, '-'); // Get unique section types const sectionTypes = [...new Set(sections.map(s => s.sectionType))]; return `
${group.description ? `
${group.description}
` : ''} ${group.author ? `
by ${group.author}
` : ''}
${sectionTypes.length > 1 ? `
${renderSectionTypeToggles(sectionTypes)}
` : ''}
${sections.map(s => buildSectionRowHTML(s, s.sectionType ?? sectionType, s.sectionType)).join('')}
`; } /** * Build section content HTML (right column) */ function buildSectionContentHTML(sectionType) { // Always use currentConfig as source of truth if (!currentConfig) return '
No configuration loaded.
'; const mergeNextUp = currentConfig.MERGE_NEXT_UP || false; // Filter groups based on mergeNextUp setting const getFilteredGroups = (groups) => { if (mergeNextUp) { // Filter out groups containing nextUp or continueWatching sections when merged return groups.map(group => { const filteredSections = (group.sections || []).filter(s => s.id !== 'nextUp' && s.id !== 'continueWatching' ); return { ...group, sections: filteredSections }; }).filter(group => group.sections.length > 0); } else { // Filter out continueWatchingAndNextUp section return groups.map(group => { const filteredSections = (group.sections || []).filter(s => s.id !== 'continueWatchingAndNextUp' ); return { ...group, sections: filteredSections }; }).filter(group => group.sections.length > 0); } }; switch (sectionType) { case 'home': { //const homeGroups = getFilteredGroups(currentConfig.HOME_SECTION_GROUPS || []); const homeGroups = currentConfig.HOME_SECTION_GROUPS || []; return `
Home Sections
Customize the default Home Sections provided by KefinTweaks.
${homeGroups.map(g => buildGroupHTML(g, 'home')).join('')}
`; } case 'seasonal': { const seasonalGroups = currentConfig.SEASONAL_SECTION_GROUPS || []; return `
Seasonal Sections
Customize the default Seasonal Home Sections provided by KefinTweaks. These sections only appear between the specified start and end dates.
${seasonalGroups.map(g => buildGroupHTML(g, 'home')).join('')}
`; } case 'discovery': { const discoveryGroups = currentConfig.DISCOVERY_SECTION_GROUPS || []; return `
Discovery Sections
Discovery sections will appear dynamically on the home screen as the user scrolls down. They are pre-fetched and cached in the background to ensure a smooth experience. You can customize their settings here.
${discoveryGroups.map(g => buildGroupHTML(g, 'discovery')).join('')}
`; } case 'custom': { const customGroups = currentConfig.CUSTOM_SECTION_GROUPS || []; return `
Custom Sections
Customize your Custom Home Sections here. You can add, edit, and delete your own sections. You can also import sections from the community from the "Browse Community Sections" tab or import from a file you exported from the "Import / Export Sections" tab.
${customGroups.map(g => buildGroupHTML(g, 'custom')).join('')}
`; } default: return '
Unknown section type
'; } } /** * Build main configuration modal content */ function buildMainConfigHTML() { // Always use currentConfig as source of truth if (!currentConfig) return '
No configuration loaded.
'; // Use currentActiveTab to determine which tab/content should be displayed const activeTab = currentActiveTab || 'settings'; return `
${buildGlobalSettingsNavigationHTML(currentGlobalSettingsSubTab)}
${buildGlobalSettingsPanelContent(currentGlobalSettingsSubTab)}
${buildSectionNavigationHTML('home')}
${buildSectionContentHTML('home')}
Browse and import curated home screen sections from the community.
Submit your own Home Screen Sections here.
Collections
Sections
Select a collection to view its sections
Share your custom home screen sections or import configurations from others.
`; } /** * Build general settings HTML */ function buildGeneralSettingsHTML() { if (!currentConfig) return ''; const seasonal = currentConfig.SEASONAL_THEME_SETTINGS || {}; const homeSettings = currentConfig.HOME_SETTINGS || {}; return `
${buildToggleSlider('merge-next-up', currentConfig.MERGE_NEXT_UP === true, 'Merge Next Up with Continue Watching', { includeHiddenCheckbox: true })} ${buildToggleSlider('seasonal-enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Enable Seasonal Animations', { includeHiddenCheckbox: true })} ${buildToggleSlider('seasonal-enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Enable Seasonal Backgrounds', { includeHiddenCheckbox: true })} ${buildToggleSlider('home-ensureThumbsForPopularTVNetworks', homeSettings.ensureThumbsForPopularTVNetworks === true, 'Ensure Thumbs for Popular TV Networks', { includeHiddenCheckbox: true })} ${buildToggleSlider('home-fadeInSections', homeSettings.fadeInSections === true, 'Fade in sections sequentially', { includeHiddenCheckbox: true })}
`; } /** * Build discovery settings HTML */ function buildDiscoverySettingsHTML() { if (!currentConfig) return ''; const discovery = currentConfig.DISCOVERY_SETTINGS || {}; return `
${buildToggleSlider('discovery-enabled', discovery.enabled !== false, 'Enabled', { includeHiddenCheckbox: true })} ${buildToggleSlider('discovery-infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll', { includeHiddenCheckbox: true })} ${buildToggleSlider('discovery-renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Group Top Rated and Normal Sections', { includeHiddenCheckbox: true })} ${buildToggleSlider('discovery-randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order', { includeHiddenCheckbox: true })} ${buildToggleSlider('discovery-fadeInSections', discovery.fadeInSections === true, 'Fade in sections sequentially', { includeHiddenCheckbox: true })} ${buildTextInput('discovery-minPeopleAppearances', discovery.minPeopleAppearances || 10, 'Min People Appearances', 'number')} ${buildTextInput('discovery-minGenreMovieCount', discovery.minGenreMovieCount || 50, 'Min Genre Movie Count', 'number')} ${buildTextInput('discovery-defaultItemLimit', discovery.defaultItemLimit || 16, 'Default Item Limit', 'number')} ${buildSelect('discovery-defaultSortOrder', SORT_ORDERS, discovery.defaultSortOrder || 'Random', 'Default Sort Order')} ${buildSelect('discovery-defaultCardFormat', CARD_FORMATS, discovery.defaultCardFormat || 'Poster', 'Default Card Format')} ${buildTextInput('discovery-spotlightDiscoveryChance', discovery.spotlightDiscoveryChance || 0.5, 'Spotlight Discovery Chance (0-1)', 'number')}
`; } /** * Build seasonal settings HTML (deprecated - moved to General Settings) */ function buildSeasonalSettingsHTML() { return ''; } /** * Convert milliseconds to minutes */ function msToMinutes(ms) { return Math.round(ms / 60000); } /** * Convert minutes to milliseconds */ function minutesToMs(minutes) { return minutes * 60000; } /** * Build cache settings HTML */ function buildCacheSettingsHTML() { if (!currentConfig) return ''; const cache = currentConfig.CACHE || {}; return `
TTL values in minutes
${buildTextInput('cache-DEFAULT_TTL', msToMinutes(cache.DEFAULT_TTL || 1800000), 'Default TTL (minutes)', 'number')} ${buildTextInput('cache-VERY_SHORT_TTL', msToMinutes(cache.VERY_SHORT_TTL || 60000), 'Very Short TTL (minutes)', 'number')} ${buildTextInput('cache-SHORT_TTL', msToMinutes(cache.SHORT_TTL || 300000), 'Short TTL (minutes)', 'number')} ${buildTextInput('cache-LONG_TTL', msToMinutes(cache.LONG_TTL || 86400000), 'Long TTL (minutes)', 'number')} ${buildTextInput('cache-STATIC_TTL', msToMinutes(cache.STATIC_TTL || 604800000), 'Static TTL (minutes)', 'number')} ${buildTextInput('cache-DISCOVERY_TTL', msToMinutes(cache.DISCOVERY_TTL || 3600000), 'Discovery TTL (minutes)', 'number')}
`; } /** * Build spotlight settings HTML (default behavior for spotlight sections) */ function buildSpotlightSettingsHTML() { if (!currentConfig) return ''; const spotlight = currentConfig.SPOTLIGHT_SETTINGS || {}; const defaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {}; const spotlightLayout = spotlight.spotlightLayout ?? (spotlight.fullScreen === true ? 'Borderless' : 'Border'); const spotlightSize = spotlight.spotlightSize ?? (spotlight.fullScreen === true ? 'full' : 'normal'); const tileCount = spotlight.tileCount ?? (spotlightSize === 'full' ? 1 : spotlightSize === 'large' ? 2 : 3); const tileCountStr = String(Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1))); const tc = Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)); const cycleRowDisplay = tc > 1 ? 'none' : 'block'; function animRow(n) { const ord = n === 1 ? 'First' : n === 2 ? 'Second' : 'Third'; return buildSelect('spotlight-entranceAnimation' + ord, SPOTLIGHT_ENTRANCE_OPTIONS, spotlight['entranceAnimation' + ord] ?? defaults['entranceAnimation' + ord] ?? 'fadeIn', 'Entrance animation ' + n) + buildSelect('spotlight-slideAnimation' + ord, SPOTLIGHT_SLIDE_OPTIONS, spotlight['slideAnimation' + ord] ?? defaults['slideAnimation' + ord] ?? 'kenBurnsZoomIn', 'Slide animation ' + n); } return `
Default behavior for spotlight sections on the home screen. Per-section overrides can be set when editing a section.
${buildSelect('spotlight-spotlightLayout', SPOTLIGHT_LAYOUT_OPTIONS, spotlightLayout, 'Layout')} ${buildSelect('spotlight-spotlightSize', SPOTLIGHT_SIZE_OPTIONS, spotlightSize, 'Size')} ${buildSelect('spotlight-tileCount', SPOTLIGHT_TILE_COUNT_OPTIONS, tileCountStr, 'Tiled backdrop count')} ${buildToggleSlider('spotlight-autoPlay', spotlight.autoPlay !== false, 'Auto-play', { includeHiddenCheckbox: true })} ${buildSelect('spotlight-slideState', SLIDE_STATE_OPTIONS, (spotlight.showSlideState === false ? 'none' : (spotlight.showDots === false ? 'numeric' : 'dots')), 'Slide state')} ${buildToggleSlider('spotlight-showNavButtons', spotlight.showNavButtons !== false, 'Show prev/next buttons', { includeHiddenCheckbox: true })} ${buildToggleSlider('spotlight-showClearArt', spotlight.showClearArt === true, 'Show clear art', { includeHiddenCheckbox: true })} ${buildToggleSlider('spotlight-panAnimation', spotlight.panAnimation !== false, 'Pan animation', { includeHiddenCheckbox: true })}
${animRow(1)}
${animRow(2)}
${animRow(3)}
${buildToggleSlider('spotlight-cycleBackdrops', spotlight.cycleBackdrops === true, 'Cycle backdrops', { includeHiddenCheckbox: true })} ${buildTextInput('spotlight-cycleBackdropsTime', spotlight.cycleBackdropsTime ?? defaults.cycleBackdropsTime ?? 10000, 'Cycle backdrops time (ms)', 'number')}
${buildTextInput('spotlight-interval', spotlight.interval ?? defaults.interval ?? 10000, 'Auto-play interval (ms)', '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 = currentConfig?.SPOTLIGHT_SETTINGS || {}; const spotlightDefaults = window.KefinHomeConfig2?.SPOTLIGHT_SETTINGS || {}; const sectionSpotlight = section.spotlightConfig || {}; const spotlightOpts = { ...spotlightDefaults, ...globalSpotlight, ...sectionSpotlight }; const spotlightLayout = spotlightOpts.spotlightLayout ?? (spotlightOpts.fullScreen === true ? 'Borderless' : 'Border'); const spotlightSize = spotlightOpts.spotlightSize ?? (spotlightOpts.fullScreen === true ? 'full' : 'normal'); const tileCount = spotlightOpts.tileCount ?? (spotlightSize === 'full' ? 1 : spotlightSize === 'large' ? 2 : 3); const tc = Math.max(1, Math.min(3, parseInt(tileCount, 10) || 1)); const tileCountStr = String(tc); const cycleRowDisplay = tc > 1 ? 'none' : 'block'; function animRow(n) { const ord = n === 1 ? 'First' : n === 2 ? 'Second' : 'Third'; return buildSelect(idPrefix + 'spotlight-entranceAnimation' + ord, SPOTLIGHT_ENTRANCE_OPTIONS, spotlightOpts['entranceAnimation' + ord] ?? 'fadeIn', 'Entrance animation ' + n) + buildSelect(idPrefix + 'spotlight-slideAnimation' + ord, SPOTLIGHT_SLIDE_OPTIONS, spotlightOpts['slideAnimation' + ord] ?? 'kenBurnsZoomIn', 'Slide animation ' + n); } const containerId = idPrefix + 'spotlight-options-container'; return `
Spotlight Options
${buildSelect(idPrefix + 'spotlight-spotlightLayout', SPOTLIGHT_LAYOUT_OPTIONS, spotlightLayout, 'Layout')} ${buildSelect(idPrefix + 'spotlight-spotlightSize', SPOTLIGHT_SIZE_OPTIONS, spotlightSize, 'Size')} ${buildSelect(idPrefix + 'spotlight-tileCount', SPOTLIGHT_TILE_COUNT_OPTIONS, tileCountStr, 'Tiled backdrop count')} ${buildToggleSlider(idPrefix + 'spotlight-autoPlay', spotlightOpts.autoPlay !== false, 'Auto-play', { includeHiddenCheckbox: true })} ${buildSelect(idPrefix + 'spotlight-slideState', SLIDE_STATE_OPTIONS, (spotlightOpts.showSlideState === false ? 'none' : (spotlightOpts.showDots === false ? 'numeric' : 'dots')), 'Slide state')} ${buildToggleSlider(idPrefix + 'spotlight-showNavButtons', spotlightOpts.showNavButtons !== false, 'Show prev/next buttons', { includeHiddenCheckbox: true })} ${buildToggleSlider(idPrefix + 'spotlight-showClearArt', spotlightOpts.showClearArt === true, 'Show clear art', { includeHiddenCheckbox: true })} ${buildToggleSlider(idPrefix + 'spotlight-panAnimation', spotlightOpts.panAnimation !== false, 'Pan animation', { includeHiddenCheckbox: true })}
${animRow(1)}
${animRow(2)}
${animRow(3)}
${buildToggleSlider(idPrefix + 'spotlight-cycleBackdrops', spotlightOpts.cycleBackdrops === true, 'Cycle backdrops', { includeHiddenCheckbox: true })} ${buildTextInput(idPrefix + 'spotlight-cycleBackdropsTime', spotlightOpts.cycleBackdropsTime ?? 10000, 'Cycle backdrops time (ms)', 'number')}
${buildTextInput(idPrefix + 'spotlight-interval', spotlightOpts.interval ?? 10000, 'Auto-play interval (ms)', 'number')}
`; } /** * 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; const sizeSelect = container.querySelector('#' + idPrefix + 'spotlightSize'); const tileCountSelect = container.querySelector('#' + idPrefix + 'tileCount'); const cycleRow = container.querySelector('#' + idPrefix + 'cycle-backdrops-row'); const cycleCheckbox = container.querySelector('#' + idPrefix + 'cycleBackdrops'); const animSecond = container.querySelector('#' + idPrefix + 'anim-second'); const animThird = container.querySelector('#' + idPrefix + 'anim-third'); function updateFromTileCount(tc) { const t = parseInt(tc, 10) || 1; if (cycleRow) cycleRow.style.display = t > 1 ? 'none' : 'block'; if (cycleCheckbox && t > 1) cycleCheckbox.checked = false; if (animSecond) animSecond.style.display = t >= 2 ? 'block' : 'none'; if (animThird) animThird.style.display = t >= 3 ? 'block' : 'none'; } if (sizeSelect) { sizeSelect.addEventListener('change', () => { if (!tileCountSelect) return; const v = sizeSelect.value; tileCountSelect.value = v === 'full' ? '1' : v === 'large' ? '2' : '3'; updateFromTileCount(tileCountSelect.value); }); } if (tileCountSelect) { tileCountSelect.addEventListener('change', () => updateFromTileCount(tileCountSelect.value)); updateFromTileCount(tileCountSelect.value); } } /** * Get field configuration for default sections */ function getDefaultSectionFields(sectionId) { // Check all default groups (HOME, SEASONAL, DISCOVERY) const defaultHome = flattenSectionGroups(window.KefinHomeConfig2?.HOME_SECTION_GROUPS || []); const defaultSeasonal = flattenSectionGroups(window.KefinHomeConfig2?.SEASONAL_SECTION_GROUPS || []); const defaultDiscovery = flattenSectionGroups(window.KefinHomeConfig2?.DISCOVERY_SECTION_GROUPS || []); const allDefaultSections = [...defaultHome, ...defaultSeasonal, ...defaultDiscovery]; const defaultIds = new Set(allDefaultSections.map(s => s.id)); if (!defaultIds.has(sectionId) && !sectionId.startsWith('recently-added-')) { return null; // Not a default section } if (sectionId.startsWith('recently-added-')) { return { includeName: true, includeCardFormat: true, includeOrder: true, includeItemLimit: true, includeSortOrder: false, includeFilterByPlayedStatus: true, }; } // Simplified sections (Next Up, Continue Watching, Upcoming) const simplifiedSections = ['nextUp', 'continueWatching', 'upcoming', 'continueWatchingAndNextUp']; if (simplifiedSections.includes(sectionId)) { return { includeName: true, includeCardFormat: true, includeOrder: true, includeItemLimit: true, includeSortOrder: false, includeFilterByPlayedStatus: sectionId === 'upcoming', includePremiereDays: false, includeMinimumShows: false }; } // Recently Released sections if (sectionId.startsWith('recentlyReleased.')) { return { includeName: true, includeCardFormat: true, includeOrder: true, includeItemLimit: true, includeSortOrder: true, includeFilterByPlayedStatus: true, includePremiereDays: true, includeMinimumShows: false }; } // Popular TV Networks if (sectionId === 'popularTVNetworks') { return { includeName: true, includeCardFormat: true, includeOrder: true, includeItemLimit: true, includeSortOrder: true, includeFilterByPlayedStatus: false, includePremiereDays: false, includeMinimumShows: true }; } // Other default sections return { includeName: true, includeCardFormat: true, includeOrder: true, includeItemLimit: true, includeSortOrder: true, includeFilterByPlayedStatus: false, includePremiereDays: false, includeMinimumShows: false }; } /** * Build section editor modal content */ function buildSectionEditorHTML(section, isDiscovery = false) { if (isDiscovery) { return buildDiscoveryEditorHTML(section); } const queries = section.queries || [{}]; const hasMultipleQueries = queries.length > 1; const defaultFields = getDefaultSectionFields(section.id); const isDefaultSection = defaultFields !== null; // Get current group name for this section (if editing existing) let currentGroupName = ''; if (section.id) { // Check all groups to find where this section exists let found = findSectionInGroups(currentConfig.HOME_SECTION_GROUPS || [], section.id); if (!found) found = findSectionInGroups(currentConfig.SEASONAL_SECTION_GROUPS || [], section.id); if (!found) found = findSectionInGroups(currentConfig.DISCOVERY_SECTION_GROUPS || [], section.id); if (!found) found = findSectionInGroups(currentConfig.CUSTOM_SECTION_GROUPS || [], section.id); if (found) { currentGroupName = found.group.name || ''; } } // Get all existing group names for dropdown (only for custom sections) let groupSelectHTML = ''; if (!isDefaultSection) { // For custom sections, only show groups from CUSTOM_SECTION_GROUPS const allGroups = currentConfig.CUSTOM_SECTION_GROUPS || []; const existingGroupNames = [...new Set(allGroups.map(g => g.name).filter(Boolean))]; const groupOptions = ['New...', ...existingGroupNames]; const selectedGroupValue = currentGroupName || (existingGroupNames.length > 0 ? existingGroupNames[0] : 'New...'); groupSelectHTML = ` ${buildSelect('section-group', groupOptions, selectedGroupValue, 'Section Group')}
${buildTextInput('section-group-new', '', 'New Group Name')}
`; } // Build Basic Properties based on section type let basicPropertiesHTML = `
Basic Properties
${groupSelectHTML} ${buildTextInput('section-name', section.name || '', 'Section Name')} `; // For default sections, show simplified fields if (isDefaultSection) { if (defaultFields.includeCardFormat) { basicPropertiesHTML += buildSelect('section-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format'); } if (defaultFields.includeOrder) { basicPropertiesHTML += buildTextInput('section-order', section.order || 0, 'Order', 'number'); } if (defaultFields.includeItemLimit) { const itemLimit = section.queries?.[0]?.queryOptions?.Limit || 20; basicPropertiesHTML += buildTextInput('section-itemLimit', itemLimit, 'Item Limit', 'number'); } if (defaultFields.includeFilterByPlayedStatus) { basicPropertiesHTML += buildFilterByPlayedStatusToggle('section-filterByPlayedStatus', section.queries?.[0]?.queryOptions?.IsUnplayed === true); } basicPropertiesHTML += buildSelect('section-renderMode', [ { value: 'Normal', label: 'Normal' }, { value: 'Spotlight', label: 'Spotlight' }, { value: 'Random', label: 'Random' } ], section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal'), 'Render Mode'); } else { // Custom sections - show full editor in flexbox layout basicPropertiesHTML += `
${buildSelect('section-renderMode', [ { value: 'Normal', label: 'Normal' }, { value: 'Spotlight', label: 'Spotlight' }, { value: 'Random', label: 'Random' } ], section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal'), 'Render Mode')}
${buildTextInput('section-order', section.order || 0, 'Order', 'number')}
${buildSelect('section-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format')}
Discovery Enabled
${buildToggleSlider('section-discoveryEnabled', section.discoveryEnabled === true, '', { dataAttributes: { 'section-id': section.id, 'section-type': 'custom' }, cssClass: 'toggle-slider section-toggle-switch section-discoveryEnabled-toggle', wrapInLabel: false })}
${buildFilterByPlayedStatusToggle('section-filterByPlayedStatus', section.queries?.[0]?.queryOptions?.IsUnplayed === true)}
`; } // Additional fields for default sections let additionalFieldsHTML = ''; if (isDefaultSection) { // Check if this is a seasonal section based on the section id and add the Start Date and End Date fields if (section.id.startsWith('seasonal.')) { additionalFieldsHTML += `
Seasonal
${buildTextInput('section-startDate', section.startDate || '', 'Start Date (MM-DD)', 'text')} ${buildTextInput('section-endDate', section.endDate || '', 'End Date (MM-DD)', 'text')}
`; } if (defaultFields.includeSortOrder) { const sortBy = section.queries?.[0]?.queryOptions?.SortBy || 'Random'; const sortOrder = section.queries?.[0]?.queryOptions?.SortOrder || 'Ascending'; additionalFieldsHTML += `
Sorting
${buildSelect('section-sortBy', SORT_ORDERS, sortBy, 'Sort By')} ${buildSelect('section-sortOrder', SORT_ORDER_DIRECTIONS, sortOrder, 'Sort Order')}
`; } if (defaultFields.includePremiereDays) { // Extract min/max age from query object (not queryOptions) const query = section.queries?.[0]; let minAgeInDays = query?.minAge !== undefined ? query.minAge : 0; let maxAgeInDays = query?.maxAge !== undefined ? query.maxAge : 30; // Fallback: try to calculate from MinPremiereDate/MaxPremiereDate if minAge/maxAge not set if (query?.minAge === undefined && query?.maxAge === undefined) { const minPremiereDate = query?.queryOptions?.MinPremiereDate; const maxPremiereDate = query?.queryOptions?.MaxPremiereDate; if (minPremiereDate || maxPremiereDate) { const now = Date.now(); if (minPremiereDate) { const minDate = new Date(minPremiereDate); maxAgeInDays = Math.floor((now - minDate.getTime()) / (1000 * 60 * 60 * 24)); } if (maxPremiereDate) { const maxDate = new Date(maxPremiereDate); minAgeInDays = Math.floor((now - maxDate.getTime()) / (1000 * 60 * 60 * 24)); } } } additionalFieldsHTML += `
Premiere Date Range
${buildTextInput('section-minAgeInDays', minAgeInDays, 'Minimum Age (Days)', 'number')} ${buildTextInput('section-maxAgeInDays', maxAgeInDays, 'Maximum Age (Days)', 'number')}
`; } if (defaultFields.includeMinimumShows) { // For popularTVNetworks, we need to get this from somewhere - it's not in the section config directly // We'll store it in a custom property or query option const minimumShows = section.minimumShowsForNetwork || section.queries?.[0]?.queryOptions?.minimumShowsForNetwork || 5; additionalFieldsHTML += `
Network Settings
${buildTextInput('section-minimumShowsForNetwork', minimumShows, 'Minimum Shows to Appear', 'number')}
`; } } // Spotlight options: only include when this editor has Render Mode (custom sections) const isSpotlightMode = section.renderMode === 'Spotlight' || section.spotlight; const spotlightOptionsHTML = buildSectionEditorSpotlightHTML(section, 'section-', isSpotlightMode); return `
${buildEnabledToggleButtons('section-enabled', section.enabled)}
${basicPropertiesHTML}
${additionalFieldsHTML} ${spotlightOptionsHTML} ${!isDefaultSection ? `
Queries
${queries.map((query, index) => buildQueryEditor(query, index, queries.length > 1)).join('')}
Merge Sorting (for multiple queries)
${buildSelect('section-sortBy', SORT_ORDERS, section.sortBy, 'Sort By')} ${buildSelect('section-sortOrder', SORT_ORDER_DIRECTIONS, section.sortOrder || 'Descending', 'Sort Order')}
Advanced Options
${buildTextInput('section-ttl', section.ttl !== undefined ? section.ttl : '', 'TTL (ms, leave empty for default)', 'number')} ${buildCheckbox('section-flattenSeries', section.flattenSeries === true, 'Flatten Series')} ${buildTextInput('section-minimumItems', section.minimumItems || '', 'Minimum Items', 'number')} ${buildTextInput('section-startDate', section.startDate || '', 'Start Date (MM-DD)', 'text')} ${buildTextInput('section-endDate', section.endDate || '', 'End Date (MM-DD)', 'text')}
` : ''}
`; } /** * Build query editor HTML */ 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 ? ` ` : ''}
${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; const itemTypesForFilters = (type === 'Genres' || type === 'Tags') ? (Array.isArray(rawItemTypes) ? rawItemTypes : [rawItemTypes]) : null; // 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)) : []; // Fetch available genres from dataHelper.getFilters() to validate/match selected values if (window.dataHelper && window.dataHelper.getFilters && itemTypesForFilters) { try { const filters = await window.dataHelper.getFilters(itemTypesForFilters, true, false); const availableGenres = filters?.Genres || []; // Match selected genre names against available genres (case-insensitive) const genreMap = new Map(availableGenres.map(g => [g.toLowerCase(), g])); currentValues = selectedGenreNames .map(name => { // Try exact match first, then case-insensitive const exactMatch = availableGenres.find(g => g === name); if (exactMatch) return exactMatch; const caseInsensitiveMatch = genreMap.get(name.toLowerCase()); return caseInsensitiveMatch || name; // Fallback to original name if not found }) .filter(Boolean); } catch (err) { WARN('Error fetching genres from dataHelper:', err); // Fallback to using selected values as-is currentValues = selectedGenreNames; } } else { // Fallback to using selected values as-is 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)) : []; // Fetch available tags from dataHelper.getFilters() to validate/match selected values if (window.dataHelper && window.dataHelper.getFilters && itemTypesForFilters) { try { const filters = await window.dataHelper.getFilters(itemTypesForFilters, true, false); const availableTags = filters?.Tags || []; // Match selected tag names against available tags (case-insensitive) const tagMap = new Map(availableTags.map(t => [t.toLowerCase(), t])); currentValues = selectedTagNames .map(name => { // Try exact match first, then case-insensitive const exactMatch = availableTags.find(t => t === name); if (exactMatch) return exactMatch; const caseInsensitiveMatch = tagMap.get(name.toLowerCase()); return caseInsensitiveMatch || name; // Fallback to original name if not found }) .filter(Boolean); } catch (err) { WARN('Error fetching tags from dataHelper:', err); // Fallback to using selected values as-is currentValues = selectedTagNames; } } else { // Fallback to using selected values as-is 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)), currentValuesArray: currentValues, // Fixed: use currentValuesArray parameter name availableTypes: type === 'IncludeItemTypes' ? allAvailableItemTypes : null, includeItemTypes: itemTypesForFilters, // Pass for filter fetching (Genres/Tags only) 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]) => ``) .join(''); return `
Additional Options
`; } /** * 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 = `
`; } else { const displayValue = Array.isArray(option.value) ? option.value.join(',') : (option.value || ''); inputField = `
${meta.label}
`; } return `
${inputField}
`; }).join(''); return `
${optionsHtml}
`; } /** * 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 {boolean} isDiscovery - Whether this is a discovery section * @param {Object} originalSection - Original section data (for preserving hidden fields in default sections) */ function collectSectionEditorData(isDiscovery = false, 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 (isDiscovery) { const renderMode = dialog.querySelector('#discovery-renderMode')?.value || 'Normal'; const filterByPlayedStatus = dialog.querySelector('#discovery-filterByPlayedStatus')?.checked === true; const isCollections = dialog.querySelector('#discovery-minimumItems') !== null; const discovery = { name: dialog.querySelector('#discovery-name')?.value || '', enabled: dialog.querySelector('#discovery-enabled')?.checked !== false, renderMode: renderMode, order: parseInt(dialog.querySelector('#discovery-order')?.value || '0', 10), itemLimit: parseInt(dialog.querySelector('#discovery-itemLimit')?.value || '20', 10), sortOrder: dialog.querySelector('#discovery-sortOrder')?.value || 'Random', sortOrderDirection: dialog.querySelector('#discovery-sortOrderDirection')?.value || undefined, ttl: dialog.querySelector('#discovery-ttl')?.value ? parseInt(dialog.querySelector('#discovery-ttl').value, 10) : undefined }; // Only set cardFormat if renderMode is not Spotlight if (renderMode !== 'Spotlight') { discovery.cardFormat = dialog.querySelector('#discovery-cardFormat')?.value || 'Poster'; } // Add IsUnplayed filter if enabled if (filterByPlayedStatus) { discovery.queryOptions = { IsUnplayed: true }; } // Add minimumItems for Collections if (isCollections) { const minimumItems = dialog.querySelector('#discovery-minimumItems')?.value; if (minimumItems) { discovery.minimumItems = parseInt(minimumItems, 10); } } // Spotlight options (saved when Render Mode = Spotlight) if (renderMode === 'Spotlight') { const p = 'discovery-spotlight-'; const slideStateVal = dialog.querySelector('#' + p + 'slideState')?.value || 'dots'; const tileCountVal = Math.max(1, Math.min(3, parseInt(dialog.querySelector('#' + p + 'tileCount')?.value || '1', 10))); discovery.spotlightConfig = { spotlightLayout: dialog.querySelector('#' + p + 'spotlightLayout')?.value || 'Border', spotlightSize: dialog.querySelector('#' + p + 'spotlightSize')?.value || 'normal', tileCount: tileCountVal, autoPlay: dialog.querySelector('#' + p + 'autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#' + p + 'interval')?.value || '10000', 10), showSlideState: slideStateVal !== 'none', showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#' + p + 'showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#' + p + 'showClearArt')?.checked === true, panAnimation: dialog.querySelector('#' + p + 'panAnimation')?.checked !== false, entranceAnimationFirst: dialog.querySelector('#' + p + 'entranceAnimationFirst')?.value || 'fadeIn', entranceAnimationSecond: dialog.querySelector('#' + p + 'entranceAnimationSecond')?.value || 'fadeIn', entranceAnimationThird: dialog.querySelector('#' + p + 'entranceAnimationThird')?.value || 'fadeIn', slideAnimationFirst: dialog.querySelector('#' + p + 'slideAnimationFirst')?.value || 'kenBurnsZoomIn', slideAnimationSecond: dialog.querySelector('#' + p + 'slideAnimationSecond')?.value || 'kenBurnsZoomIn', slideAnimationThird: dialog.querySelector('#' + p + 'slideAnimationThird')?.value || 'kenBurnsZoomIn', cycleBackdrops: dialog.querySelector('#' + p + 'cycleBackdrops')?.checked === true, cycleBackdropsTime: parseInt(dialog.querySelector('#' + p + 'cycleBackdropsTime')?.value || '10000', 10) }; } else { delete discovery.spotlightConfig; } return discovery; } // Check if this is a default section const sectionId = dialog.querySelector('.section-save-btn')?.dataset.sectionId; const defaultFields = sectionId ? getDefaultSectionFields(sectionId) : null; const isDefaultSection = defaultFields !== null; // For default sections, start with original data to preserve hidden fields let section; if (isDefaultSection && originalSection) { // Start with a deep copy of the original section section = JSON.parse(JSON.stringify(originalSection)); // Now update only the editable fields from the UI section.name = dialog.querySelector('#section-name')?.value || section.name || ''; // Get enabled state from toggle buttons const enabledBtn = dialog.querySelector('.enabled-toggle-btn.active[data-enabled="true"]'); section.enabled = enabledBtn !== null; section.order = parseInt(dialog.querySelector('#section-order')?.value || '0', 10); if (defaultFields.includeCardFormat) { section.cardFormat = dialog.querySelector('#section-cardFormat')?.value || 'Poster'; } // Ensure queries array exists if (!section.queries || section.queries.length === 0) { section.queries = [{ queryOptions: {} }]; } // Update only the editable queryOptions fields if (defaultFields.includeItemLimit) { const itemLimit = parseInt(dialog.querySelector('#section-itemLimit')?.value || '20', 10); if (!section.queries[0].queryOptions) section.queries[0].queryOptions = {}; section.queries[0].queryOptions.Limit = itemLimit; } if (defaultFields.includeSortOrder) { const sortBy = dialog.querySelector('#section-sortBy')?.value; const sortOrder = dialog.querySelector('#section-sortOrder')?.value; if (sortBy || sortOrder) { if (!section.queries[0].queryOptions) section.queries[0].queryOptions = {}; if (sortBy) section.queries[0].queryOptions.SortBy = sortBy; if (sortOrder) section.queries[0].queryOptions.SortOrder = sortOrder; } } if (defaultFields.includePremiereDays) { const minAgeInDays = parseInt(dialog.querySelector('#section-minAgeInDays')?.value || '0', 10); const maxAgeInDays = parseInt(dialog.querySelector('#section-maxAgeInDays')?.value || '30', 10); // Store minAge/maxAge at query level (not in queryOptions) section.queries[0].minAge = minAgeInDays; section.queries[0].maxAge = maxAgeInDays; } if (defaultFields.includeMinimumShows) { const minimumShows = parseInt(dialog.querySelector('#section-minimumShowsForNetwork')?.value || '5', 10); section.minimumShowsForNetwork = minimumShows; } // Handle IsUnplayed filter (from toggle button) // Apply to all queries to ensure toggle is source of truth const filterToggle = dialog.querySelector('.filter-played-toggle'); const filterByPlayedStatus = filterToggle?.dataset.checked === 'true'; if (defaultFields.includeFilterByPlayedStatus) { section.queries.forEach(query => { if (!query.queryOptions) query.queryOptions = {}; if (filterByPlayedStatus) { query.queryOptions.IsUnplayed = true; } else { // Remove IsUnplayed if unchecked (toggle is source of truth) delete query.queryOptions.IsUnplayed; } }); } const discoveryEnabledToggle = dialog.querySelector('.section-discoveryEnabled-toggle'); if (discoveryEnabledToggle) { section.discoveryEnabled = discoveryEnabledToggle.dataset.enabled === 'true'; } // Check if this is a seasonal section based on the section id and add the Start Date and End Date fields if (section.id.startsWith('seasonal.')) { section.startDate = dialog.querySelector('#section-startDate')?.value || section.startDate; section.endDate = dialog.querySelector('#section-endDate')?.value || section.endDate; } const renderMode = dialog.querySelector('#section-renderMode')?.value || 'Normal'; if (renderMode) { section.renderMode = renderMode; if (renderMode === 'Spotlight') { const p = 'section-spotlight-'; const slideStateVal = dialog.querySelector('#' + p + 'slideState')?.value || 'dots'; const tileCountVal = Math.max(1, Math.min(3, parseInt(dialog.querySelector('#' + p + 'tileCount')?.value || '1', 10))); section.spotlightConfig = { spotlightLayout: dialog.querySelector('#' + p + 'spotlightLayout')?.value || 'Border', spotlightSize: dialog.querySelector('#' + p + 'spotlightSize')?.value || 'normal', tileCount: tileCountVal, autoPlay: dialog.querySelector('#' + p + 'autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#' + p + 'interval')?.value || '10000', 10), showSlideState: slideStateVal !== 'none', showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#' + p + 'showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#' + p + 'showClearArt')?.checked === true, panAnimation: dialog.querySelector('#' + p + 'panAnimation')?.checked !== false, entranceAnimationFirst: dialog.querySelector('#' + p + 'entranceAnimationFirst')?.value || 'fadeIn', entranceAnimationSecond: dialog.querySelector('#' + p + 'entranceAnimationSecond')?.value || 'fadeIn', entranceAnimationThird: dialog.querySelector('#' + p + 'entranceAnimationThird')?.value || 'fadeIn', slideAnimationFirst: dialog.querySelector('#' + p + 'slideAnimationFirst')?.value || 'kenBurnsZoomIn', slideAnimationSecond: dialog.querySelector('#' + p + 'slideAnimationSecond')?.value || 'kenBurnsZoomIn', slideAnimationThird: dialog.querySelector('#' + p + 'slideAnimationThird')?.value || 'kenBurnsZoomIn', cycleBackdrops: dialog.querySelector('#' + p + 'cycleBackdrops')?.checked === true, cycleBackdropsTime: parseInt(dialog.querySelector('#' + p + 'cycleBackdropsTime')?.value || '10000', 10) }; } } return section; } // For custom sections, build from scratch (existing logic) // Get enabled state from toggle buttons const enabledBtn = dialog.querySelector('.enabled-toggle-btn.active[data-enabled="true"]'); section = { name: dialog.querySelector('#section-name')?.value || '', enabled: enabledBtn !== null, order: parseInt(dialog.querySelector('#section-order')?.value || '0', 10), }; // Custom sections - collect full editor data const renderMode = dialog.querySelector('#section-renderMode')?.value || 'Normal'; // Get filter by played status from toggle button const filterToggle = dialog.querySelector('.filter-played-toggle'); const filterByPlayedStatus = filterToggle?.dataset.checked === 'true'; const discoveryEnabledToggle = dialog.querySelector('.section-discoveryEnabled-toggle'); section.discoveryEnabled = discoveryEnabledToggle?.dataset.enabled === 'true'; section.renderMode = renderMode; // Only set cardFormat if renderMode is not Spotlight if (renderMode !== 'Spotlight') { section.cardFormat = dialog.querySelector('#section-cardFormat')?.value || 'Poster'; } // Collect queries const queryEditors = dialog.querySelectorAll('.query-editor'); section.queries = Array.from(queryEditors).map((editor) => { const queryIndex = parseInt(editor.dataset.queryIndex, 10); const queryType = editor.querySelector(`#query-${queryIndex}-type`)?.value || 'standard'; const query = {}; if (queryType === 'custom') { const path = editor.querySelector(`#query-${queryIndex}-path`)?.value; if (path) query.path = path; } else if (queryType === 'datasource') { const dataSource = editor.querySelector(`#query-${queryIndex}-dataSource`)?.value; if (dataSource) query.dataSource = dataSource; } // Collect query options query.queryOptions = {}; // Add IsUnplayed filter if Filter by Played Status is enabled if (filterByPlayedStatus) { query.queryOptions.IsUnplayed = true; } // Collect badge autocomplete values from hidden inputs const includeItemTypesHidden = editor.querySelector(`.query_includeItemTypes_hidden[data-section-index="${queryIndex}"]`) || editor.closest('.query-editor')?.querySelector(`.query_includeItemTypes_hidden[data-section-index="${queryIndex}"]`); if (includeItemTypesHidden && includeItemTypesHidden.value) { query.queryOptions.IncludeItemTypes = includeItemTypesHidden.value.split(',').map(s => s.trim()).filter(s => s); } const genresHidden = editor.querySelector(`.query_genres_hidden[data-section-index="${queryIndex}"]`) || editor.closest('.query-editor')?.querySelector(`.query_genres_hidden[data-section-index="${queryIndex}"]`); if (genresHidden && genresHidden.value) { query.queryOptions.Genres = genresHidden.value.split(',').map(s => s.trim()).filter(s => s).join(','); } const tagsHidden = editor.querySelector(`.query_tags_hidden[data-section-index="${queryIndex}"]`) || editor.closest('.query-editor')?.querySelector(`.query_tags_hidden[data-section-index="${queryIndex}"]`); if (tagsHidden && tagsHidden.value) { query.queryOptions.Tags = tagsHidden.value.split(',').map(s => s.trim()).filter(s => s).join(','); } // Handle parent item type - only one can be set per query const parentItemTypeSelect = editor.querySelector(`#query-${queryIndex}-parentItemType`); const parentItemType = parentItemTypeSelect?.value || ''; if (parentItemType === 'Collection') { const collectionsHidden = editor.querySelector(`.query_collection_hidden[data-section-index="${queryIndex}"]`) || editor.closest('.query-editor')?.querySelector(`.query_collection_hidden[data-section-index="${queryIndex}"]`); if (collectionsHidden && collectionsHidden.value) { // Extract collection IDs and use the first one for ParentId const collectionIds = collectionsHidden.value.split(',').map(s => s.trim()).filter(s => s); if (collectionIds.length > 0) { query.queryOptions.ParentId = collectionIds[0]; // Use first collection ID as ParentId // Store at query level (not in queryOptions) query._parentItemType = 'Collection'; } } // Clear other parent types delete query.queryOptions.PlaylistIds; delete query.queryOptions.CollectionIds; delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility) } else if (parentItemType === 'Playlist') { const playlistsHidden = editor.querySelector(`.query_playlist_hidden[data-section-index="${queryIndex}"]`) || editor.closest('.query-editor')?.querySelector(`.query_playlist_hidden[data-section-index="${queryIndex}"]`); if (playlistsHidden && playlistsHidden.value) { // Extract playlist IDs and use the first one for ParentId const playlistIds = playlistsHidden.value.split(',').map(s => s.trim()).filter(s => s); if (playlistIds.length > 0) { query.queryOptions.ParentId = playlistIds[0]; // Use first playlist ID as ParentId // Store at query level (not in queryOptions) query._parentItemType = 'Playlist'; } } // Clear other parent types delete query.queryOptions.CollectionIds; delete query.queryOptions.PlaylistIds; delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility) } else if (parentItemType === 'Generic Parent') { const parentIdInput = editor.querySelector(`#query-${queryIndex}-ParentId`); if (parentIdInput && parentIdInput.value) { query.queryOptions.ParentId = parentIdInput.value.trim(); } // Clear other parent types and metadata delete query.queryOptions.CollectionIds; delete query.queryOptions.PlaylistIds; delete query._parentItemType; delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility) } else { // No parent item selected - clear all parent types and metadata delete query.queryOptions.CollectionIds; delete query.queryOptions.PlaylistIds; delete query.queryOptions.ParentId; delete query._parentItemType; delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility) } // Fallback: try to get from badge containers directly if (!includeItemTypesHidden) { const badgeContainer = editor.querySelector(`#query-${queryIndex}-IncludeItemTypes-container`); if (badgeContainer) { const badges = badgeContainer.querySelectorAll('.tag-badge'); if (badges.length > 0) { query.queryOptions.IncludeItemTypes = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean); } } } if (!genresHidden) { const badgeContainer = editor.querySelector(`#query-${queryIndex}-Genres-container`); if (badgeContainer) { const badges = badgeContainer.querySelectorAll('.tag-badge'); if (badges.length > 0) { query.queryOptions.Genres = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean); } } } if (!tagsHidden) { const badgeContainer = editor.querySelector(`#query-${queryIndex}-Tags-container`); if (badgeContainer) { const badges = badgeContainer.querySelectorAll('.tag-badge'); if (badges.length > 0) { query.queryOptions.Tags = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean); } } } /* if (!collectionsHidden) { const badgeContainer = editor.querySelector(`#query-${queryIndex}-Collections-container`); if (badgeContainer) { const badges = badgeContainer.querySelectorAll('.tag-badge'); if (badges.length > 0) { query.queryOptions.CollectionIds = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean); } } } if (!playlistsHidden) { const badgeContainer = editor.querySelector(`#query-${queryIndex}-Playlists-container`); if (badgeContainer) { const badges = badgeContainer.querySelectorAll('.tag-badge'); if (badges.length > 0) { query.queryOptions.PlaylistIds = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean); } } } */ ['SortBy', 'SortOrder', 'Filters', 'SearchTerm'].forEach(key => { const value = editor.querySelector(`#query-${queryIndex}-${key}`)?.value; if (value) query.queryOptions[key] = value; }); const limit = editor.querySelector(`#query-${queryIndex}-Limit`)?.value; if (limit) query.queryOptions.Limit = parseInt(limit, 10); // Collect additional options from list const additionalOptionsList = editor.querySelector(`.additional-options-list[data-query-index="${queryIndex}"]`); if (additionalOptionsList) { const optionRows = additionalOptionsList.querySelectorAll('.additional-option-row'); optionRows.forEach(row => { const keyInput = row.querySelector('.additional-option-key'); const typeInput = row.querySelector('.additional-option-type'); const valueInput = row.querySelector('.additional-option-value'); if (valueInput) { const key = valueInput.dataset.key; const type = valueInput.dataset.type || 'string'; if (key) { let value; if (type === 'boolean') { value = valueInput.checked === true; } else if (type === 'number') { value = valueInput.value ? parseFloat(valueInput.value) : undefined; } else if (type === 'array') { value = valueInput.value ? valueInput.value.split(',').map(s => s.trim()).filter(s => s) : []; } else { value = valueInput.value || undefined; } if (value !== undefined && value !== null && value !== '') { query.queryOptions[key] = value; } } } }); } // Ensure IsUnplayed is removed if toggle is OFF (toggle is source of truth) // This must happen after collecting all options to override any IsUnplayed from additional options if (!filterByPlayedStatus) { delete query.queryOptions.IsUnplayed; } // Ensure queryOptions exists even if empty if (Object.keys(query.queryOptions).length === 0 && !query.path && !query.dataSource) { query.queryOptions = {}; } return query; }); // Collect advanced options const ttl = dialog.querySelector('#section-ttl')?.value; if (ttl) section.ttl = parseInt(ttl, 10); section.flattenSeries = dialog.querySelector('#section-flattenSeries')?.checked === true; const minimumItems = dialog.querySelector('#section-minimumItems')?.value; if (minimumItems) section.minimumItems = parseInt(minimumItems, 10); const startDate = dialog.querySelector('#section-startDate')?.value; const endDate = dialog.querySelector('#section-endDate')?.value; if (startDate) section.startDate = startDate; if (endDate) section.endDate = endDate; // Spotlight options (saved to spotlightConfig when Render Mode = Spotlight) if (renderMode === 'Spotlight') { const p = 'section-spotlight-'; const slideStateVal = dialog.querySelector('#' + p + 'slideState')?.value || 'dots'; const tileCountVal = Math.max(1, Math.min(3, parseInt(dialog.querySelector('#' + p + 'tileCount')?.value || '1', 10))); section.spotlightConfig = { spotlightLayout: dialog.querySelector('#' + p + 'spotlightLayout')?.value || 'Border', spotlightSize: dialog.querySelector('#' + p + 'spotlightSize')?.value || 'normal', tileCount: tileCountVal, autoPlay: dialog.querySelector('#' + p + 'autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#' + p + 'interval')?.value || '10000', 10), showSlideState: slideStateVal !== 'none', showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#' + p + 'showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#' + p + 'showClearArt')?.checked === true, panAnimation: dialog.querySelector('#' + p + 'panAnimation')?.checked !== false, entranceAnimationFirst: dialog.querySelector('#' + p + 'entranceAnimationFirst')?.value || 'fadeIn', entranceAnimationSecond: dialog.querySelector('#' + p + 'entranceAnimationSecond')?.value || 'fadeIn', entranceAnimationThird: dialog.querySelector('#' + p + 'entranceAnimationThird')?.value || 'fadeIn', slideAnimationFirst: dialog.querySelector('#' + p + 'slideAnimationFirst')?.value || 'kenBurnsZoomIn', slideAnimationSecond: dialog.querySelector('#' + p + 'slideAnimationSecond')?.value || 'kenBurnsZoomIn', slideAnimationThird: dialog.querySelector('#' + p + 'slideAnimationThird')?.value || 'kenBurnsZoomIn', cycleBackdrops: dialog.querySelector('#' + p + 'cycleBackdrops')?.checked === true, cycleBackdropsTime: parseInt(dialog.querySelector('#' + p + 'cycleBackdropsTime')?.value || '10000', 10) }; } else { delete section.spotlightConfig; } // Multi-query sorting if (section.queries.length > 1) { const sortBy = dialog.querySelector('#section-sortBy')?.value; const sortOrder = dialog.querySelector('#section-sortOrder')?.value; if (sortBy) section.sortBy = sortBy; if (sortOrder) section.sortOrder = sortOrder; } // Populate the _targetGroupName with the group name specified in the section editor const targetGroupName = dialog.querySelector('select#section-group')?.value; if (targetGroupName) section._targetGroupName = targetGroupName; return section; } /** * Collect all settings from form */ function collectGlobalSettings() { const dialog = document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"]`); if (!dialog) return {}; const discovery = { enabled: dialog.querySelector('#discovery-enabled')?.checked !== false, infiniteScroll: dialog.querySelector('#discovery-infiniteScroll')?.checked !== false, minPeopleAppearances: parseInt(dialog.querySelector('#discovery-minPeopleAppearances')?.value || '10', 10), minGenreMovieCount: parseInt(dialog.querySelector('#discovery-minGenreMovieCount')?.value || '50', 10), defaultItemLimit: parseInt(dialog.querySelector('#discovery-defaultItemLimit')?.value || '16', 10), defaultSortOrder: dialog.querySelector('#discovery-defaultSortOrder')?.value || 'Random', defaultCardFormat: dialog.querySelector('#discovery-defaultCardFormat')?.value || 'Poster', spotlightDiscoveryChance: parseFloat(dialog.querySelector('#discovery-spotlightDiscoveryChance')?.value || '0.5'), renderSpotlightAboveMatching: dialog.querySelector('#discovery-renderSpotlightAboveMatching')?.checked === true, randomizeOrder: dialog.querySelector('#discovery-randomizeOrder')?.checked === true, fadeInSections: dialog.querySelector('#discovery-fadeInSections')?.checked === true }; const seasonal = { enabled: dialog.querySelector('#seasonal-enabled')?.checked !== false, enableSeasonalAnimations: dialog.querySelector('#seasonal-enableSeasonalAnimations')?.checked !== false, enableSeasonalBackground: dialog.querySelector('#seasonal-enableSeasonalBackground')?.checked !== false, seasonToggles: (window.KefinHomeConfig2?.SEASONAL_THEME_SETTINGS?.seasonToggles || []).map(season => ({ ...season, enabled: dialog.querySelector(`#seasonal-${season.id}`)?.checked === true })) }; const cache = { DEFAULT_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-DEFAULT_TTL')?.value || '30', 10)), VERY_SHORT_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-VERY_SHORT_TTL')?.value || '1', 10)), SHORT_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-SHORT_TTL')?.value || '5', 10)), LONG_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-LONG_TTL')?.value || '1440', 10)), STATIC_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-STATIC_TTL')?.value || '10080', 10)), DISCOVERY_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-DISCOVERY_TTL')?.value || '60', 10)), FORCE_REFRESH_TTL: 0 }; const slideStateVal = dialog.querySelector('#spotlight-slideState')?.value || 'dots'; const tileCountVal = parseInt(dialog.querySelector('#spotlight-tileCount')?.value || '1', 10); const spotlight = { spotlightLayout: dialog.querySelector('#spotlight-spotlightLayout')?.value || 'Border', spotlightSize: dialog.querySelector('#spotlight-spotlightSize')?.value || 'normal', tileCount: Math.max(1, Math.min(3, tileCountVal)), autoPlay: dialog.querySelector('#spotlight-autoPlay')?.checked !== false, interval: parseInt(dialog.querySelector('#spotlight-interval')?.value || '10000', 10), showSlideState: slideStateVal !== 'none', showDots: slideStateVal === 'dots', showNavButtons: dialog.querySelector('#spotlight-showNavButtons')?.checked !== false, showClearArt: dialog.querySelector('#spotlight-showClearArt')?.checked === true, panAnimation: dialog.querySelector('#spotlight-panAnimation')?.checked !== false, entranceAnimationFirst: dialog.querySelector('#spotlight-entranceAnimationFirst')?.value || 'fadeIn', entranceAnimationSecond: dialog.querySelector('#spotlight-entranceAnimationSecond')?.value || 'fadeIn', entranceAnimationThird: dialog.querySelector('#spotlight-entranceAnimationThird')?.value || 'fadeIn', slideAnimationFirst: dialog.querySelector('#spotlight-slideAnimationFirst')?.value || 'kenBurnsZoomIn', slideAnimationSecond: dialog.querySelector('#spotlight-slideAnimationSecond')?.value || 'kenBurnsZoomIn', slideAnimationThird: dialog.querySelector('#spotlight-slideAnimationThird')?.value || 'kenBurnsZoomIn', cycleBackdrops: dialog.querySelector('#spotlight-cycleBackdrops')?.checked === true, cycleBackdropsTime: parseInt(dialog.querySelector('#spotlight-cycleBackdropsTime')?.value || '10000', 10) }; const homeSettings = { fadeInSections: dialog.querySelector('#home-fadeInSections')?.checked === true, ensureThumbsForPopularTVNetworks: dialog.querySelector('#home-ensureThumbsForPopularTVNetworks')?.checked === true }; const mergeNextUp = dialog.querySelector('#merge-next-up')?.checked === true; return { DISCOVERY_SETTINGS: discovery, SEASONAL_THEME_SETTINGS: seasonal, CACHE: cache, SPOTLIGHT_SETTINGS: spotlight, HOME_SETTINGS: homeSettings, MERGE_NEXT_UP: mergeNextUp }; } /** * Open section editor modal */ async function openSectionEditor(section, type = 'home') { const isDiscovery = type === 'discovery'; const modalId = isDiscovery ? DISCOVERY_EDITOR_MODAL_ID : SECTION_EDITOR_MODAL_ID; // Close if already open if (window.ModalSystem.isOpen(modalId)) { window.ModalSystem.close(modalId); } // Store original section for default sections (to preserve hidden fields) const originalSection = JSON.parse(JSON.stringify(section)); const content = document.createElement('div'); content.innerHTML = buildSectionEditorHTML(section, isDiscovery); 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 = ` `; window.ModalSystem.create({ id: modalId, title: `Edit Section: ${section.name || 'Unnamed'}`, content: content, footer: footer, closeOnBackdrop: false, closeOnEscape: true, showCloseButton: true, onOpen: (modalInstance) => { // Attach event listeners const saveBtn = modalInstance.dialogFooter.querySelector('.section-save-btn'); if (saveBtn) { saveBtn.addEventListener('click', async () => { const sectionData = collectSectionEditorData(isDiscovery, originalSection); if (!sectionData) { showToast('Error collecting section data'); return; } // Preserve section ID sectionData.id = section.id; updateSectionInConfig(sectionData); // Save immediately await saveConfig(currentConfig); // Close editor first window.ModalSystem.close(modalId); // Refresh main modal and restore tab functionality refreshMainModal(); }); } // 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; 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); } }); } const updateSectionInConfig = (sectionData) => { const found = findSectionInAllGroups(currentConfig, sectionData.id); // Handle group assignment const targetGroupName = sectionData._targetGroupName; delete sectionData._targetGroupName; // Remove metadata if (found) { // Check if the section is in the target group if (targetGroupName && targetGroupName !== found.group.name) { // Remove the section from the current group found.group.sections.splice(found.sectionIndex, 1); // Add the section to the target group addSectionToGroup(currentConfig[found.groupType], targetGroupName, sectionData); return; } // Update the current config with the updated section data updateSectionInGroups(currentConfig[found.groupType], sectionData.id, sectionData); return; } // Add the section to the config addSectionToGroup(currentConfig.CUSTOM_SECTION_GROUPS, 'Custom Sections', sectionData); } // Preview button handler const previewBtn = modalInstance.dialogFooter.querySelector('.section-preview-btn'); if (previewBtn) { previewBtn.addEventListener('click', async () => { // Collect current section data from editor const sectionData = collectSectionEditorData(isDiscovery, originalSection); if (!sectionData) { showToast('Error collecting section data for preview'); return; } // Merge with original section to preserve ID and all queryOptions fields const previewSectionConfig = { ...originalSection, ...sectionData }; await previewSection(previewSectionConfig, type, previewBtn); }); } // Add query button handler const addQueryBtn = modalInstance.dialogContent.querySelector('.add-query-btn'); if (addQueryBtn) { 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 && !isDiscovery) { // 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); }); } } }); } /** * Refresh main modal content */ function refreshMainModal() { if (!mainModalInstance) return; // Store current section type if on sections tab let currentSectionType = 'home'; if (currentActiveTab === 'sections') { const activeNavBtn = mainModalInstance.dialogContent.querySelector('.section-type-nav-btn.active'); if (activeNavBtn) { currentSectionType = activeNavBtn.dataset.sectionType; } } // Store current active tab before refresh const savedActiveTab = currentActiveTab || 'settings'; const content = document.createElement('div'); content.innerHTML = buildMainConfigHTML(); mainModalInstance.updateContent(content); // Reset the listeners flag so they can be re-attached to the new content /* if (mainModalInstance.dialog) { delete mainModalInstance.dialog.dataset.mainModalListenersAttached; delete mainModalInstance.dialog.dataset.sectionListenersAttached; } // Re-attach event listeners (including tab listeners) attachMainModalListeners(); attachTabListeners(mainModalInstance); */ // Restore active tab if (savedActiveTab) { const dialog = mainModalInstance.dialogContainer; if (dialog) { const tabBtn = dialog.querySelector(`.config-tab-btn[data-tab="${savedActiveTab}"]`); if (tabBtn) { tabBtn.click(); } } } // If we were on sections tab, restore the section type navigation if (currentActiveTab === 'sections') { const dialog = mainModalInstance.dialogContainer; const navBtns = dialog.querySelectorAll('.section-type-nav-btn'); navBtns.forEach(btn => { if (btn.dataset.sectionType === currentSectionType) { btn.click(); } }); } } /** * Setup additional options add/remove listeners for a query */ 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 = `
`; } else { inputField = `
${meta.label}
`; } 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} `; // 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(); }); }); } } /** * Render home sections order editor * @param {Array} sections - Flattened array of sections from HOME_SECTIONS, SEASONAL_SECTIONS, CUSTOM_SECTIONS */ function renderHomeSectionsOrderEditor(sections) { if (!sections || !Array.isArray(sections)) { return '
No sections found.
'; } const filteredSections = sections.filter(s => !s.hidden); // Verify sections are ordered by .order value and sort if needed const sortedSections = [...filteredSections].sort((a, b) => { const orderA = a.order || 0; const orderB = b.order || 0; return orderA - orderB; }); if (sortedSections.length === 0) { return '
No sections to order.
'; } // Create a dummy group with all sections to reuse buildGroupHTML const dummyGroup = { name: 'All Home Sections', sections: sortedSections, author: '' }; return ` ${buildGroupHTML(dummyGroup, 'all')} `; } /** * Attach tab switching listeners */ function attachTabListeners(modalInstance) { const dialog = modalInstance.dialogContainer; if (!dialog) return; // Use event delegation to prevent stacking listeners when switching tabs dialog.addEventListener('click', (e) => { const btn = e.target.closest('.config-tab-btn'); if (!btn) return; const targetTab = btn.dataset.tab; // Update the current active tab tracker currentActiveTab = targetTab; const tabButtons = dialog.querySelectorAll('.config-tab-btn'); const tabContents = dialog.querySelectorAll('.config-tab-content'); // Update button states tabButtons.forEach(b => { b.classList.remove('active'); b.style.borderBottomColor = 'transparent'; b.style.color = 'rgba(255,255,255,0.7)'; }); btn.classList.add('active'); btn.style.borderBottomColor = 'var(--theme-primary-color, #00a4dc)'; btn.style.color = 'var(--theme-primary-color, #00a4dc)'; // Update tab content visibility tabContents.forEach(content => { content.style.display = 'none'; }); const targetContent = dialog.querySelector(`#tab-${targetTab}`); if (targetContent) { targetContent.style.display = 'block'; // Refresh tab content based on currentConfig when switching tabs if (targetTab === 'order') { // Order Sections tab - refresh with current config const container = targetContent.querySelector('#order-sections-container'); if (container) { const allSections = [ ...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })), ...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })), ...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' })) ]; container.innerHTML = renderHomeSectionsOrderEditor(allSections); } } else if (targetTab === 'sections') { // Edit Sections tab - refresh section content based on current active section type const contentContainer = dialog.querySelector('#section-content'); if (contentContainer) { // Get the currently active section type navigation button const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active'); const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home'; // Re-render the section content with current config contentContainer.innerHTML = buildSectionContentHTML(activeType); } } else if (targetTab === 'community') { // Community Sections tab - refresh community sections content setupCommunityCollectionsTab(dialog); } } }); /* const tabButtons = dialog.querySelectorAll('.config-tab-btn'); const tabContents = dialog.querySelectorAll('.config-tab-content'); tabButtons.forEach(btn => { btn.addEventListener('click', () => { const targetTab = btn.dataset.tab; // Update the current active tab tracker currentActiveTab = targetTab; // Update button states tabButtons.forEach(b => { b.classList.remove('active'); b.style.borderBottomColor = 'transparent'; b.style.color = 'rgba(255,255,255,0.7)'; }); btn.classList.add('active'); btn.style.borderBottomColor = 'var(--theme-primary-color, #00a4dc)'; btn.style.color = 'var(--theme-primary-color, #00a4dc)'; // Update tab content visibility tabContents.forEach(content => { content.style.display = 'none'; }); const targetContent = dialog.querySelector(`#tab-${targetTab}`); if (targetContent) { targetContent.style.display = 'block'; // Refresh tab content based on currentConfig when switching tabs if (targetTab === 'order') { // Order Sections tab - refresh with current config const container = targetContent.querySelector('#order-sections-container'); if (container) { const allSections = [ ...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })), ...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })), ...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' })) ]; container.innerHTML = renderHomeSectionsOrderEditor(allSections); } } else if (targetTab === 'sections') { // Edit Sections tab - refresh section content based on current active section type const contentContainer = dialog.querySelector('#section-content'); if (contentContainer) { // Get the currently active section type navigation button const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active'); const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home'; // Re-render the section content with current config contentContainer.innerHTML = buildSectionContentHTML(activeType); } } } }); }); */ } /** * Attach event listeners to main modal */ function attachMainModalListeners(modalInstance) { const dialog = modalInstance?.dialogContainer ?? document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"]`); if (!dialog) return; if (dialog.dataset.mainModalListenersAttached === 'true') { return; // Listeners already attached, skip } dialog.dataset.mainModalListenersAttached = 'true'; // Attach initial section row listeners attachSectionRowListeners(dialog); // Merge Next Up toggle - update currentConfig immediately and refresh sections tab // Use event delegation to prevent stacking listeners when switching tabs /* dialog.addEventListener('click', async (e) => { const btn = e.target.closest('#merge-next-up'); if (!btn) return; currentConfig.MERGE_NEXT_UP = btn.checked; // Hide continue watching and next up sections if merge next up is enabled const continueWatchingGroup = currentConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Continue Watching'); if (continueWatchingGroup) { continueWatchingGroup.sections = continueWatchingGroup.sections.map(s => { if (s.id === 'continueWatchingAndNextUp') { s.enabled = currentConfig.MERGE_NEXT_UP; s.hidden = !currentConfig.MERGE_NEXT_UP; } else { s.enabled = !currentConfig.MERGE_NEXT_UP; s.hidden = currentConfig.MERGE_NEXT_UP; } return s; }); // Update continueWatchingGroup in currentConfig currentConfig.HOME_SECTION_GROUPS = currentConfig.HOME_SECTION_GROUPS.map(g => { if (g.name === 'Continue Watching') { return continueWatchingGroup; } return g; }); } await saveConfig(currentConfig); refreshMainModal(); }); */ /* const mergeNextUpCheckbox = dialog.querySelector('#merge-next-up'); if (mergeNextUpCheckbox) { mergeNextUpCheckbox.addEventListener('change', async () => { currentConfig.MERGE_NEXT_UP = mergeNextUpCheckbox.checked; // Hide continue watching and next up sections if merge next up is enabled const continueWatchingGroup = currentConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Continue Watching'); if (continueWatchingGroup) { continueWatchingGroup.sections = continueWatchingGroup.sections.map(s => { if (s.id === 'continueWatchingAndNextUp') { s.enabled = currentConfig.MERGE_NEXT_UP; s.hidden = !currentConfig.MERGE_NEXT_UP; } else { s.enabled = !currentConfig.MERGE_NEXT_UP; s.hidden = currentConfig.MERGE_NEXT_UP; } return s; }); // Update continueWatchingGroup in currentConfig currentConfig.HOME_SECTION_GROUPS = currentConfig.HOME_SECTION_GROUPS.map(g => { if (g.name === 'Continue Watching') { return continueWatchingGroup; } return g; }); } await saveConfig(currentConfig); // Refresh sections tab if it's currently visible const sectionsTab = dialog.querySelector('#tab-sections'); if (sectionsTab && sectionsTab.style.display !== 'none') { const contentContainer = dialog.querySelector('#section-content'); if (contentContainer) { const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active'); const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home'; contentContainer.innerHTML = buildSectionContentHTML(activeType); } } }); } */ // Group toggle-all buttons // Use event delegation to prevent stacking listeners when switching tabs dialog.addEventListener('click', async (e) => { const btn = e.target.closest('.group-toggle-all-btn'); if (!btn) return; e.stopPropagation(); const groupId = btn.dataset.groupId; const currentEnabled = btn.dataset.enabled === 'true'; const newEnabled = !currentEnabled; const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); if (!groupElement) return; const sectionsList = groupElement.querySelector('.group-sections-list'); if (!sectionsList) return; // Find all sections in this group and update them const sectionRows = sectionsList.querySelectorAll('.section-row'); const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId); // Update all sections in the group sectionIds.forEach(sectionId => { const found = findSectionInAllGroups(currentConfig, sectionId); if (found && found.section) { found.section.enabled = newEnabled; } }); await saveConfig(currentConfig); // Update UI btn.dataset.enabled = newEnabled; btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All'; // Update all toggle switches in the group sectionRows.forEach(row => { const toggleBtn = row.querySelector('.toggle-slider'); if (toggleBtn) { updateToggleSliderUI(toggleBtn, newEnabled); } }); }); /* dialog.querySelectorAll('.group-toggle-all-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const groupId = btn.dataset.groupId; const currentEnabled = btn.dataset.enabled === 'true'; const newEnabled = !currentEnabled; const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); if (!groupElement) return; const sectionsList = groupElement.querySelector('.group-sections-list'); if (!sectionsList) return; // Find all sections in this group and update them const sectionRows = sectionsList.querySelectorAll('.section-row'); const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId); // Update all sections in the group sectionIds.forEach(sectionId => { const found = findSectionInAllGroups(currentConfig, sectionId); if (found && found.section) { found.section.enabled = newEnabled; } }); await saveConfig(currentConfig); // Update UI btn.dataset.enabled = newEnabled; btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All'; // Update all toggle switches in the group sectionRows.forEach(row => { const toggleBtn = row.querySelector('.toggle-slider'); if (toggleBtn) { updateToggleSliderUI(toggleBtn, newEnabled); } }); }); }); */ // Add custom section button - use event delegation dialog.addEventListener('click', (e) => { const btn = e.target.closest('.add-custom-section-btn') || e.target.closest('.section-type-nav-btn[data-section-type="create"]'); if (!btn) return; const newSection = { id: `custom-${Date.now()}`, name: 'New Custom Section', enabled: true, order: 100, cardFormat: 'Poster', queries: [{ queryOptions: { SortBy: 'Random', Limit: 20 } }] }; openSectionEditor(newSection, 'custom'); }); // Section type toggle buttons - use event delegation dialog.addEventListener('click', (e) => { const btn = e.target.closest('.section-type-toggle-btn'); if (!btn) return; toggleSectionType(e); }); // Section type navigation buttons // Use event delegation to prevent stacking listeners when switching tabs dialog.addEventListener('click', (e) => { const btn = e.target.closest('.section-type-nav-btn:not([data-section-type="create"])'); if (!btn) return; const sectionType = btn.dataset.sectionType; // Update active button dialog.querySelectorAll('.section-type-nav-btn').forEach(b => { b.classList.remove('active'); b.style.background = 'rgba(255,255,255,0.05)'; b.style.borderColor = 'rgba(255,255,255,0.1)'; b.style.color = 'inherit'; const label = b.querySelector('.listItemBodyText'); if (label) label.style.fontWeight = '400'; }); btn.classList.add('active'); btn.style.background = 'rgba(0, 164, 220, 0.2)'; btn.style.borderColor = 'rgba(0, 164, 220, 0.5)'; btn.style.color = 'var(--theme-primary-color, #00a4dc)'; const label = btn.querySelector('.listItemBodyText'); if (label) label.style.fontWeight = '500'; // Update content const contentContainer = dialog.querySelector('#section-content'); if (contentContainer) { contentContainer.innerHTML = buildSectionContentHTML(sectionType); } }); // Global Settings sub-tab navigation (left nav in Global Settings tab) dialog.addEventListener('click', (e) => { const btn = e.target.closest('.global-settings-nav-btn'); if (!btn) return; const subTab = btn.dataset.globalSettingsTab; if (!subTab) return; currentGlobalSettingsSubTab = subTab; dialog.querySelectorAll('.global-settings-nav-btn').forEach(b => { b.classList.remove('active'); b.style.background = 'rgba(255,255,255,0.05)'; b.style.borderColor = 'rgba(255,255,255,0.1)'; b.style.color = 'inherit'; const label = b.querySelector('.listItemBodyText'); if (label) label.style.fontWeight = '400'; }); btn.classList.add('active'); btn.style.background = 'rgba(0, 164, 220, 0.2)'; btn.style.borderColor = 'rgba(0, 164, 220, 0.5)'; btn.style.color = 'var(--theme-primary-color, #00a4dc)'; const label = btn.querySelector('.listItemBodyText'); if (label) label.style.fontWeight = '500'; const contentContainer = dialog.querySelector('#global-settings-content'); if (contentContainer) { contentContainer.innerHTML = buildGlobalSettingsPanelContent(subTab); if (subTab === 'spotlight') attachSpotlightSettingsListeners(contentContainer, 'spotlight-'); } }); /* dialog.querySelectorAll('.section-type-nav-btn').forEach(btn => { btn.addEventListener('click', () => { const sectionType = btn.dataset.sectionType; // Update active button dialog.querySelectorAll('.section-type-nav-btn').forEach(b => { b.classList.remove('active'); b.style.background = 'rgba(255,255,255,0.05)'; b.style.borderColor = 'rgba(255,255,255,0.1)'; b.style.color = 'inherit'; const label = b.querySelector('.listItemBodyText'); if (label) label.style.fontWeight = '400'; }); btn.classList.add('active'); btn.style.background = 'rgba(0, 164, 220, 0.2)'; btn.style.borderColor = 'rgba(0, 164, 220, 0.5)'; btn.style.color = 'var(--theme-primary-color, #00a4dc)'; const label = btn.querySelector('.listItemBodyText'); if (label) label.style.fontWeight = '500'; // Update content const contentContainer = dialog.querySelector('#section-content'); if (contentContainer) { contentContainer.innerHTML = buildSectionContentHTML(sectionType); } }); }); */ // Community Collections tab setup setupCommunityCollectionsTab(dialog); // Export Sections button const exportSectionsBtn = dialog.querySelector('.export-sections-btn'); if (exportSectionsBtn) { exportSectionsBtn.addEventListener('click', () => { showExportDialog(); }); } // Import Sections button const importSectionsBtn = dialog.querySelector('.import-sections-btn'); if (importSectionsBtn) { importSectionsBtn.addEventListener('click', () => { showImportDialog(); }); } } /** * Get section order value from config by section ID */ function getSectionOrder(sectionId) { const found = findSectionInAllGroups(currentConfig, sectionId); return found?.section?.order || 0; } /** * Update section position in place without refreshing the entire modal * This preserves scroll position and provides a smoother UX */ function updateSectionPositionInPlace(draggedElement, container) { if (!draggedElement || !container) return; // Get all section rows in the container const allRows = Array.from(container.querySelectorAll('.section-row')); if (allRows.length === 0) return; // Store scroll position - try to find the scrollable parent const scrollableParent = container.closest('.dialogContent') || container.closest('.dialog') || container.parentElement; const scrollTop = scrollableParent?.scrollTop || 0; // Sort rows by their order value from config allRows.sort((a, b) => { const aId = a.dataset.sectionId; const bId = b.dataset.sectionId; const aOrder = getSectionOrder(aId); const bOrder = getSectionOrder(bId); return aOrder - bOrder; }); // Re-append rows in sorted order and update order display allRows.forEach(row => { container.appendChild(row); // Update the order display in the row const sectionId = row.dataset.sectionId; const order = getSectionOrder(sectionId); // Find all secondary text spans and update the one that shows Order const secondarySpans = row.querySelectorAll('.listItemBodyText.secondary'); secondarySpans.forEach(span => { const text = span.textContent.trim(); if (text.startsWith('Order:')) { span.textContent = `Order: ${order}`; } }); }); // Restore scroll position if (scrollableParent && scrollableParent.scrollTop !== undefined) { scrollableParent.scrollTop = scrollTop; } } /** * Calculate new order value based on drag position using DOM elements * New logic: if section after dropped position exists, use its order - 1; if not, use preceding section order + 1 */ function calculateNewOrder(draggedElement, targetElement, dropPosition) { const container = targetElement.parentElement; const allRows = Array.from(container.querySelectorAll('.section-row')); // Find the target's position in the DOM const targetIndex = allRows.indexOf(targetElement); if (dropPosition === 'above') { // Dropped above target element if (targetIndex === 0) { // Dropped at the beginning - use first section order - 1 const firstSectionId = targetElement.dataset.sectionId; const firstOrder = getSectionOrder(firstSectionId); return Math.max(0, firstOrder - 1); } else { // Dropped between elements - use target's order - 1 (section after dropped position) const targetSectionId = targetElement.dataset.sectionId; const targetOrder = getSectionOrder(targetSectionId); return Math.max(0, targetOrder - 1); } } else { // dropPosition === 'below' if (targetIndex === allRows.length - 1) { // Dropped at the end - use last section order + 1 (preceding section + 1) const lastSectionId = targetElement.dataset.sectionId; const lastOrder = getSectionOrder(lastSectionId); return lastOrder + 1; } else { // Dropped between elements - check if section after exists const nextElement = allRows[targetIndex + 1]; if (nextElement && nextElement !== draggedElement) { // Section after exists - use its order - 1 const nextSectionId = nextElement.dataset.sectionId; const nextOrder = getSectionOrder(nextSectionId); return Math.max(0, nextOrder - 1); } else { // No section after - use preceding section (target) order + 1 const targetSectionId = targetElement.dataset.sectionId; const targetOrder = getSectionOrder(targetSectionId); return targetOrder + 1; } } } } /** * Attach listeners to section rows (helper for dynamic content) * Uses event delegation to prevent stacking listeners when switching tabs */ function attachSectionRowListeners(dialog) { // Check if listeners have already been attached using a data attribute if (dialog.dataset.sectionListenersAttached === 'true') { return; // Listeners already attached, skip } // Mark that listeners have been attached dialog.dataset.sectionListenersAttached = 'true'; const defaultSections = flattenSectionGroups(window.KefinHomeConfig2?.HOME_SECTION_GROUPS || []); const defaultIds = new Set(defaultSections.map(s => s.id)); // Consolidated handler for editable section and group names - use event delegation dialog.addEventListener('blur', async (e) => { // Handle section names const sectionNameEl = e.target.closest('.editable-section-name'); if (sectionNameEl) { const sectionId = sectionNameEl.dataset.sectionId; const newName = sectionNameEl.value.trim(); if (!newName) { showToast('Section name cannot be empty'); // Restore old name const found = findSectionInAllGroups(currentConfig, sectionId); if (found?.section) { sectionNameEl.value = found.section.name || 'Unnamed Section'; } return; } // Update section name in config const found = findSectionInAllGroups(currentConfig, sectionId); if (found?.section && found.section.name !== newName) { found.section.name = newName; await saveConfig(currentConfig); showToast(`Section renamed to "${newName}"`); } return; } // Handle group names const groupNameEl = e.target.closest('.editable-group-name'); if (groupNameEl) { const groupId = groupNameEl.dataset.groupId; const newName = groupNameEl.value.trim(); const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); const oldName = groupElement?.dataset.groupName; if (!newName) { showToast('Group name cannot be empty'); // Restore old name groupNameEl.value = oldName || 'Unnamed Group'; return; } if (oldName === newName) { return; // No change } // Find and update group in config let groupFound = false; let foundGroup = null; let groupArrayType = null; const allGroupArrays = [ { groups: currentConfig.HOME_SECTION_GROUPS, type: 'HOME_SECTION_GROUPS' }, { groups: currentConfig.SEASONAL_SECTION_GROUPS, type: 'SEASONAL_SECTION_GROUPS' }, { groups: currentConfig.DISCOVERY_SECTION_GROUPS, type: 'DISCOVERY_SECTION_GROUPS' }, { groups: currentConfig.CUSTOM_SECTION_GROUPS, type: 'CUSTOM_SECTION_GROUPS' } ]; for (const { groups, type } of allGroupArrays) { if (!groups) continue; const group = groups.find(g => { const gId = `group-${g.name || 'unnamed'}-${g.author || ''}`.replace(/[^a-zA-Z0-9-]/g, '-'); return gId === groupId; }); if (group) { foundGroup = group; groupArrayType = type; groupFound = true; break; } } if (groupFound && foundGroup) { // Check if this is a default group (not CUSTOM) and handle _originalName if (groupArrayType !== 'CUSTOM_SECTION_GROUPS') { // If _originalName is not set, check if this is a default group if (!foundGroup._originalName) { const defaults = window.KefinHomeConfig2; if (defaults) { const defaultGroups = defaults[groupArrayType] || []; const isDefaultGroup = defaultGroups.some(dg => { const dgKey = dg.author ? `${dg.name}::${dg.author}` : dg.name || 'Unnamed'; const currentKey = foundGroup.author ? `${oldName}::${foundGroup.author}` : oldName || 'Unnamed'; return dgKey === currentKey; }); // If it's a default group, store the original name if (isDefaultGroup) { foundGroup._originalName = oldName; } } } // If _originalName is already set, preserve it (don't overwrite) } // Update the group name foundGroup.name = newName; // Update data attribute groupElement.dataset.groupName = newName; await saveConfig(currentConfig); showToast(`Group renamed to "${newName}"`); } else { showToast('Could not find group to rename'); groupNameEl.value = oldName || 'Unnamed Group'; } } }, true); // Use capture phase // Consolidated handler for Enter key - prevents default behavior and blurs input dialog.addEventListener('keydown', (e) => { const nameEl = e.target.closest('.editable-section-name, .editable-group-name'); if (!nameEl) return; if (e.key === 'Enter') { e.preventDefault(); nameEl.blur(); } }, true); // Drag and drop handlers - use event delegation let draggedElement = null; let dropPosition = 'above'; // Store drop position for drop handler dialog.addEventListener('dragstart', (e) => { const row = e.target.closest('.section-row'); if (!row) return; draggedElement = row; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', row.innerHTML); row.style.opacity = '0.5'; }); dialog.addEventListener('dragend', (e) => { const row = e.target.closest('.section-row'); if (!row) return; row.style.opacity = '1'; // Remove drag-over styling from all rows dialog.querySelectorAll('.section-row').forEach(r => { r.style.borderTop = ''; }); }); dialog.addEventListener('dragover', (e) => { const row = e.target.closest('.section-row'); if (!row) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; // Calculate drop position based on cursor Y position relative to element center const rect = row.getBoundingClientRect(); const elementCenterY = rect.top + rect.height / 2; dropPosition = e.clientY < elementCenterY ? 'above' : 'below'; // Visual feedback: show drop indicator dialog.querySelectorAll('.section-row').forEach(r => { r.style.borderTop = ''; }); // Show indicator on the target row based on drop position row.style.borderTop = '2px solid var(--theme-primary-color, #00a4dc)'; }); dialog.addEventListener('drop', async (e) => { const row = e.target.closest('.section-row'); if (!row) return; e.preventDefault(); if (!draggedElement || draggedElement === row) return; const sectionType = row.dataset.sectionType; const draggedSectionId = draggedElement.dataset.sectionId; const draggedSectionType = draggedElement.dataset.sectionType; // Check if we're on the Order Sections tab const isOrderTab = currentActiveTab === 'order'; // If on sections tab, restrict dragging within same group if (!isOrderTab) { // Find which group the dragged section belongs to const draggedFound = findSectionInAllGroups(currentConfig, draggedSectionId); const targetFound = findSectionInAllGroups(currentConfig, row.dataset.sectionId); // Only allow drop if both sections are in the same group if (!draggedFound || !targetFound || draggedFound.group !== targetFound.group) { return; } } // Calculate new order using DOM elements const newOrder = calculateNewOrder(draggedElement, row, dropPosition); // Update order in config - find section across all groups const found = findSectionInAllGroups(currentConfig, draggedSectionId); if (found) { found.section.order = newOrder; // Sort sections within the group by order found.group.sections.sort((a, b) => (a.order || 0) - (b.order || 0)); } await saveConfig(currentConfig); // Update section position in place instead of refreshing entire modal // This preserves scroll position let container = e.target.closest('.group-sections-list'); if (container) { updateSectionPositionInPlace(draggedElement, container); } else { // Fallback to refresh if container not found refreshMainModal(); } }); // Unified toggle slider click handler - handles both section toggles and settings toggles dialog.addEventListener('click', async (e) => { // Check if click is on toggle slider button or its children const btn = e.target.closest('.toggle-slider'); if (!btn) return; // Stop propagation to prevent section row click handler from firing e.stopPropagation(); // Check if this is a section toggle (has data-section-id) const sectionId = btn.dataset.sectionId; if (sectionId) { // Section toggle: update section config and save const sectionType = btn.dataset.sectionType; const found = findSectionInAllGroups(currentConfig, sectionId); const section = found?.section; if (section) { section.enabled = !section.enabled; await saveConfig(currentConfig); // Update the toggle switch UI updateToggleSliderUI(btn, section.enabled); } } else { // Settings toggle: toggle hidden checkbox and update UI const checkboxId = btn.dataset.checkboxId; if (checkboxId) { const checkbox = document.getElementById(checkboxId); if (checkbox) { checkbox.checked = !checkbox.checked; // Trigger change event for any listeners (like merge-next-up) checkbox.dispatchEvent(new Event('change', { bubbles: true })); updateToggleSliderUI(btn, checkbox.checked); if (checkboxId === 'merge-next-up') { currentConfig.MERGE_NEXT_UP = checkbox.checked; // Hide continue watching and next up sections if merge next up is enabled const continueWatchingGroup = currentConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Continue Watching'); if (continueWatchingGroup) { continueWatchingGroup.sections = continueWatchingGroup.sections.map(s => { if (s.id === 'continueWatchingAndNextUp') { s.enabled = currentConfig.MERGE_NEXT_UP; s.hidden = !currentConfig.MERGE_NEXT_UP; } else { s.enabled = !currentConfig.MERGE_NEXT_UP; s.hidden = currentConfig.MERGE_NEXT_UP; } return s; }); // Update continueWatchingGroup in currentConfig currentConfig.HOME_SECTION_GROUPS = currentConfig.HOME_SECTION_GROUPS.map(g => { if (g.name === 'Continue Watching') { return continueWatchingGroup; } return g; }); } await saveConfig(currentConfig); refreshMainModal(); } } } else { // Fallback: just toggle the visual state const currentState = btn.dataset.enabled === 'true'; updateToggleSliderUI(btn, !currentState); } } }); // Group toggle-all buttons - use event delegation dialog.addEventListener('click', async (e) => { const btn = e.target.closest('.group-toggle-all-btn'); if (!btn) return; e.stopPropagation(); const groupId = btn.dataset.groupId; const currentEnabled = btn.dataset.enabled === 'true'; const newEnabled = !currentEnabled; const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`); if (!groupElement) return; const sectionsList = groupElement.querySelector('.group-sections-list'); if (!sectionsList) return; // Find all sections in this group and update them const sectionRows = sectionsList.querySelectorAll('.section-row'); const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId); // Update all sections in the group sectionIds.forEach(sectionId => { const found = findSectionInAllGroups(currentConfig, sectionId); if (found && found.section) { found.section.enabled = newEnabled; } }); await saveConfig(currentConfig); // Update UI btn.dataset.enabled = newEnabled; btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All'; // Update all toggle switches in the group sectionRows.forEach(row => { const toggleBtn = row.querySelector('.toggle-slider'); if (toggleBtn) { updateToggleSliderUI(toggleBtn, newEnabled); } }); }); // Preview section buttons - use event delegation dialog.addEventListener('click', async (e) => { const btn = e.target.closest('.section-preview-btn'); if (!btn) return; e.stopPropagation(); const sectionId = btn.dataset.sectionId; const sectionType = btn.dataset.sectionType; // Find section across all groups const found = findSectionInAllGroups(currentConfig, sectionId); const section = found?.section; if (section) { await previewSection(section, sectionType, btn); } }); // Click on section row to edit - use event delegation dialog.addEventListener('click', (e) => { const row = e.target.closest('.section-row'); if (!row) return; // Don't trigger if clicking on delete button, toggle switch, preview button, or editable name if (e.target.closest('.section-delete-btn') || e.target.closest('.toggle-slider') || e.target.closest('.section-preview-btn') || e.target.closest('.editable-section-name')) { return; } const sectionId = row.dataset.sectionId; const sectionType = row.dataset.sectionType; // Find section across all groups const found = findSectionInAllGroups(currentConfig, sectionId); const section = found?.section; if (section) { openSectionEditor(section, sectionType); } }); // Delete custom section buttons - use event delegation dialog.addEventListener('click', async (e) => { const btn = e.target.closest('.section-delete-btn'); if (!btn) return; e.stopPropagation(); const sectionId = btn.dataset.sectionId; const groups = currentConfig.CUSTOM_SECTION_GROUPS || []; const found = findSectionInGroups(groups, sectionId); const sectionName = found?.section?.name || sectionId; if (confirm(`Delete section "${sectionName}"?`)) { removeSectionFromGroups(groups, sectionId); await saveConfig(currentConfig); showToast(`Section "${sectionName}" deleted`); refreshMainModal(); } }); } /** * Setup community collections tab with card grid layout */ function setupCommunityCollectionsTab(dialog) { // Ensure community config is available if (!window.KefinCommunityConfig) { const communityTab = dialog.querySelector('#tab-community'); if (communityTab) { communityTab.innerHTML = '
Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.
'; } return; } const communityCollections = window.KefinCommunityConfig.collections || []; if (communityCollections.length === 0) { const communityTab = dialog.querySelector('#tab-community'); if (communityTab) { communityTab.innerHTML = '
No community collections available.
'; } return; } const collectionsGrid = dialog.querySelector('#communityCollectionsGrid'); const sectionsContainer = dialog.querySelector('#communitySectionsContainer'); const sectionsList = dialog.querySelector('#communitySectionsList'); const sectionsEmpty = dialog.querySelector('#communitySectionsEmpty'); const confirmBtn = dialog.querySelector('#confirmCommunityImportBtn'); if (!collectionsGrid || !sectionsContainer || !sectionsList || !sectionsEmpty || !confirmBtn) { return; } let selectedCollection = null; let selectedCollectionIndex = null; // Build collection cards collectionsGrid.innerHTML = communityCollections.map((collection, index) => { const description = collection.description || 'No description available.'; return `
${collection.name || 'Unnamed Collection'}
by ${collection.author || 'Anonymous'}
${collection.sections ? collection.sections.length : 0} section${collection.sections && collection.sections.length !== 1 ? 's' : ''}
`; }).join(''); // Add click handlers to collection cards collectionsGrid.querySelectorAll('.community-collection-card').forEach((card, index) => { card.addEventListener('click', (e) => { // Don't trigger if clicking the info button if (e.target.closest('.community-collection-info-btn')) { return; } // Remove previous selection collectionsGrid.querySelectorAll('.community-collection-card').forEach(c => { c.style.borderColor = 'rgba(255,255,255,0.1)'; c.style.background = 'rgba(255,255,255,0.05)'; }); // Highlight selected card card.style.borderColor = 'rgba(0, 164, 220, 0.8)'; card.style.background = 'rgba(0, 164, 220, 0.15)'; selectedCollection = communityCollections[index]; selectedCollectionIndex = index; // Render sections list renderSectionsList(selectedCollection, sectionsList, sectionsContainer, sectionsEmpty, confirmBtn); }); }); // Add info button handlers for popover collectionsGrid.querySelectorAll('.community-collection-info-btn').forEach((btn, index) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const collection = communityCollections[index]; const description = collection.description || 'No description available.'; // Create and show popover showInfoPopover(btn, description, collection.name || 'Collection'); }); }); // Import button handler confirmBtn.addEventListener('click', async () => { if (!selectedCollection) return; const selectedIndices = Array.from(dialog.querySelectorAll('.import-community-check:checked')).map(c => parseInt(c.dataset.index)); if (selectedIndices.length === 0) { showToast('Please select at least one section to import.'); return; } // Import logic - import entire groups to CUSTOM_SECTION_GROUPS if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = []; const selectedSections = selectedIndices.map(i => { const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); // Deep copy return convertToHomeScreenSection(section); }); // Create a group from the collection const importedGroup = { name: selectedCollection.name || 'Imported Collection', author: selectedCollection.author, description: selectedCollection.description, sections: selectedSections.map(section => { // Check for ID collision - only regenerate if needed if (section.id && sectionIdExists(currentConfig, section.id)) { section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random()*1000)}`; } return section; }) }; currentConfig.CUSTOM_SECTION_GROUPS.push(importedGroup); const importedCount = selectedSections.length; if (importedCount > 0) { await saveConfig(currentConfig); showToast(`Successfully imported ${importedCount} sections. Refreshing...`); refreshMainModal(); } }); } /** * Render sections list for selected collection */ function renderSectionsList(collection, sectionsList, sectionsContainer, sectionsEmpty, confirmBtn) { if (!collection || !collection.sections || collection.sections.length === 0) { sectionsList.innerHTML = '
No sections found in this collection.
'; sectionsContainer.style.display = 'block'; sectionsEmpty.style.display = 'none'; confirmBtn.style.display = 'none'; return; } sectionsList.innerHTML = `
${collection.sections.map((s, i) => ` `).join('')}
`; sectionsContainer.style.display = 'block'; sectionsEmpty.style.display = 'none'; confirmBtn.style.display = 'inline-block'; } /** * Show info popover for collection description */ function showInfoPopover(triggerElement, description, title) { // Remove any existing popover const existingPopover = document.querySelector('.community-info-popover'); if (existingPopover) { existingPopover.remove(); } // Create popover element const popover = document.createElement('div'); popover.className = 'community-info-popover'; popover.style.cssText = ` position: absolute; background: rgba(26,26,26,0.98); border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; padding: 1em; box-shadow: 0 4px 16px rgba(0,0,0,0.4); z-index: 10000; max-width: 400px; min-width: 250px; `; popover.innerHTML = `
${title}
${description}
`; // Position popover relative to trigger const rect = triggerElement.getBoundingClientRect(); const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; popover.style.top = `${rect.bottom + scrollTop + 8}px`; popover.style.left = `${rect.left + scrollLeft}px`; // Add close button handler popover.querySelector('.community-popover-close').addEventListener('click', () => { popover.remove(); }); // Close on outside click const closeOnOutsideClick = (e) => { if (!popover.contains(e.target) && e.target !== triggerElement) { popover.remove(); document.removeEventListener('click', closeOnOutsideClick); } }; setTimeout(() => { document.addEventListener('click', closeOnOutsideClick); }, 0); document.body.appendChild(popover); } /** * Convert legacy section format to HomeScreenSection format */ function convertToHomeScreenSection(section) { // If already in new format, return as-is if (section.queries && Array.isArray(section.queries)) { return section; } // Convert legacy format const newSection = { id: section.id, name: section.name, enabled: section.enabled !== false, order: section.order || 100, cardFormat: section.cardFormat || 'Poster', itemLimit: section.itemLimit || 20, queries: [] }; // Build query from legacy fields const query = { queryOptions: {} }; if (section.path) { query.path = section.path; } if (section.dataSource) { query.dataSource = section.dataSource; } // Copy all other fields to queryOptions Object.keys(section).forEach(key => { if (!['id', 'name', 'enabled', 'order', 'cardFormat', 'itemLimit', 'path', 'dataSource', 'queries', 'type'].includes(key)) { query.queryOptions[key] = section[key]; } }); newSection.queries.push(query); return newSection; } /** * Check if section ID exists in config */ function sectionIdExists(config, sectionId) { const allHomeSections = flattenSectionGroups(config.HOME_SECTION_GROUPS || []); const allSeasonalSections = flattenSectionGroups(config.SEASONAL_SECTION_GROUPS || []); const allDiscoverySections = flattenSectionGroups(config.DISCOVERY_SECTION_GROUPS || []); const allCustomSections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS || []); const allIds = [ ...allHomeSections.map(s => s.id), ...allSeasonalSections.map(s => s.id), ...allDiscoverySections.map(s => s.id), ...allCustomSections.map(s => s.id) ]; return allIds.includes(sectionId); } /** * Show community import dialog */ async function showCommunityImportDialog() { // Ensure community config is available if (!window.KefinCommunityConfig) { showToast('Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.'); return; } const communityCollections = window.KefinCommunityConfig.collections || []; if (communityCollections.length === 0) { showToast('No community collections available.'); return; } const content = document.createElement('div'); content.innerHTML = `
Select Home Screen Sections to import:
These sections are created and submitted by community members like you!
Submit your own Home Screen Sections here.
`; const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.gap = '0.75em'; footer.innerHTML = ` `; let selectedCollection = null; window.ModalSystem.create({ id: 'kefinCommunityImportModal', title: 'Import Community Sections', content: content, footer: footer, closeOnBackdrop: false, closeOnEscape: true, onOpen: (modalInstance) => { const select = content.querySelector('#communityCollectionSelect'); const descContainer = content.querySelector('#collectionDescription'); const selectionContainer = content.querySelector('#importSelectionContainer'); const selectionList = content.querySelector('#importSelectionList'); const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmCommunityImportBtn'); select.addEventListener('change', () => { const index = select.value; if (index === '') { selectedCollection = null; descContainer.style.display = 'none'; selectionContainer.style.display = 'none'; confirmBtn.style.display = 'none'; return; } selectedCollection = communityCollections[parseInt(index)]; // Show description descContainer.textContent = selectedCollection.description || 'No description.'; descContainer.style.display = 'block'; // Render sections list let html = ''; if (selectedCollection.sections && selectedCollection.sections.length > 0) { html += `
${selectedCollection.sections.map((s, i) => ` `).join('')}
`; } else { html = '
No sections found in this collection.
'; } selectionList.innerHTML = html; selectionContainer.style.display = 'block'; confirmBtn.style.display = 'inline-block'; }); confirmBtn.addEventListener('click', async () => { if (!selectedCollection) return; const selectedIndices = Array.from(content.querySelectorAll('.import-community-check:checked')).map(c => parseInt(c.dataset.index)); if (selectedIndices.length === 0) { showToast('Please select at least one section to import.'); return; } // Import logic - import entire groups to CUSTOM_SECTION_GROUPS if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = []; const selectedSections = selectedIndices.map(i => { const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); // Deep copy return convertToHomeScreenSection(section); }); // Create a group from the collection const importedGroup = { name: selectedCollection.name || 'Imported Collection', author: selectedCollection.author, description: selectedCollection.description, sections: selectedSections.map(section => { // Check for ID collision - only regenerate if needed if (section.id && sectionIdExists(currentConfig, section.id)) { section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random()*1000)}`; } return section; }) }; currentConfig.CUSTOM_SECTION_GROUPS.push(importedGroup); const importedCount = selectedSections.length; if (importedCount > 0) { await saveConfig(currentConfig); showToast(`Successfully imported ${importedCount} sections. Refreshing...`); refreshMainModal(); window.ModalSystem.close('kefinCommunityImportModal'); } else { window.ModalSystem.close('kefinCommunityImportModal'); } }); } }); } /** * Show export dialog */ async function showExportDialog() { const sectionGroups = currentConfig.CUSTOM_SECTION_GROUPS || []; if (sectionGroups.length === 0 || !sectionGroups.some(g => g.sections && g.sections.length > 0)) { alert('No custom sections found to export.'); return; } const content = document.createElement('div'); content.innerHTML = `
`; const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.gap = '0.75em'; footer.innerHTML = ` `; window.ModalSystem.create({ id: 'kefinExportModal', title: 'Export Custom Sections', content: content, footer: footer, closeOnBackdrop: true, closeOnEscape: true, onOpen: (modalInstance) => { const sectionGroupsList = content.querySelector('#exportSectionGroupsList'); renderSectionGroupsCheckboxList(sectionGroupsList, sectionGroups, { checkboxClass: 'export-custom-check', groupCheckboxClass: 'export-custom-group-check' }); const generateBtn = modalInstance.dialogFooter.querySelector('#generateExportBtn'); generateBtn.addEventListener('click', () => { // Collect selected sections by group const selectedGroups = []; sectionGroups.forEach((group, groupIndex) => { if (!group || !Array.isArray(group.sections)) return; const selectedSections = []; group.sections.forEach((section, sectionIndex) => { const checkbox = content.querySelector(`.export-custom-check[data-group-index="${groupIndex}"][data-section-index="${sectionIndex}"]`); if (checkbox && checkbox.checked) { selectedSections.push(section); } }); // Only include groups that have at least one section selected if (selectedSections.length > 0) { selectedGroups.push({ name: group.name, author: group.author, description: group.description, sections: selectedSections }); } }); if (selectedGroups.length === 0) { alert('Please select at least one section to export.'); return; } // Export as array of section groups const json = JSON.stringify(selectedGroups, null, 2); const outputContainer = content.querySelector('#exportOutputContainer'); const output = content.querySelector('#exportOutput'); output.value = json; outputContainer.style.display = 'block'; generateBtn.style.display = 'none'; content.querySelector('#copyExportBtn').addEventListener('click', () => { output.select(); document.execCommand('copy'); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Copied to clipboard'); } else { alert('Copied to clipboard'); } }); content.querySelector('#downloadExportBtn').addEventListener('click', () => { const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `kefin-tweaks-export-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); }); } }); } /** * Show import dialog */ async function showImportDialog() { const content = document.createElement('div'); content.innerHTML = `
Paste JSON content or a URL (Raw GitHub, Gist, Pastebin):
`; const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.gap = '0.75em'; footer.innerHTML = ` `; let parsedData = null; let sectionGroups = null; window.ModalSystem.create({ id: 'kefinImportModal', title: 'Import Custom Sections', content: content, footer: footer, closeOnBackdrop: false, closeOnEscape: true, onOpen: (modalInstance) => { const loadBtn = modalInstance.dialogFooter.querySelector('#loadImportBtn'); const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmImportBtn'); const input = content.querySelector('#importInput'); const selectionContainer = content.querySelector('#importSelectionContainer'); const selectionList = content.querySelector('#importSelectionList'); loadBtn.addEventListener('click', async () => { const rawInput = input.value.trim(); if (!rawInput) return; loadBtn.disabled = true; loadBtn.textContent = 'Loading...'; try { let jsonStr = rawInput; // Check if URL if (rawInput.startsWith('http://') || rawInput.startsWith('https://')) { const res = await fetch(rawInput); if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`); jsonStr = await res.text(); } parsedData = JSON.parse(jsonStr); // Accept array of section groups or object with sectionGroups property if (Array.isArray(parsedData)) { sectionGroups = parsedData; } else if (parsedData.sectionGroups && Array.isArray(parsedData.sectionGroups)) { sectionGroups = parsedData.sectionGroups; } else { throw new Error('Invalid format: Expected array of section groups or object with sectionGroups property.'); } if (!sectionGroups || sectionGroups.length === 0) { throw new Error('No section groups found.'); } // Render selection using shared helper renderSectionGroupsCheckboxList(selectionList, sectionGroups, { checkboxClass: 'import-custom-check', groupCheckboxClass: 'import-custom-group-check' }); selectionContainer.style.display = 'block'; confirmBtn.style.display = 'inline-block'; } catch (error) { showToast(`Error loading data: ${error.message}`); } finally { loadBtn.disabled = false; loadBtn.textContent = 'Load'; } }); confirmBtn.addEventListener('click', async () => { if (!sectionGroups || sectionGroups.length === 0) return; // Collect selected sections by group const groupsToImport = []; sectionGroups.forEach((group, groupIndex) => { if (!group || !Array.isArray(group.sections)) return; const selectedSections = []; group.sections.forEach((section, sectionIndex) => { const checkbox = content.querySelector(`.import-custom-check[data-group-index="${groupIndex}"][data-section-index="${sectionIndex}"]`); if (checkbox && checkbox.checked) { const sectionCopy = JSON.parse(JSON.stringify(section)); // Deep copy const convertedSection = convertToHomeScreenSection(sectionCopy); // Check for ID collision if (convertedSection.id && sectionIdExists(currentConfig, convertedSection.id)) { convertedSection.id = `${convertedSection.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`; } selectedSections.push(convertedSection); } }); // Only include groups that have at least one section selected if (selectedSections.length > 0) { groupsToImport.push({ name: group.name, author: group.author, description: group.description, sections: selectedSections }); } }); if (groupsToImport.length === 0) { showToast('Please select at least one section to import.'); return; } if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = []; // Append groups to CUSTOM_SECTION_GROUPS groupsToImport.forEach(group => { currentConfig.CUSTOM_SECTION_GROUPS.push(group); }); const importedCount = groupsToImport.reduce((sum, g) => sum + g.sections.length, 0); if (importedCount > 0) { await saveConfig(currentConfig); showToast(`Successfully imported ${importedCount} sections in ${groupsToImport.length} group${groupsToImport.length !== 1 ? 's' : ''}. Refreshing...`); refreshMainModal(); window.ModalSystem.close('kefinImportModal'); } else { window.ModalSystem.close('kefinImportModal'); } }); } }); // Community Collections tab setup setupCommunityCollectionsTab(dialog); // Export Sections button const exportSectionsBtn = dialog.querySelector('.export-sections-btn'); if (exportSectionsBtn) { exportSectionsBtn.addEventListener('click', () => { showExportDialog(); }); } // Import Sections button const importSectionsBtn = dialog.querySelector('.import-sections-btn'); if (importSectionsBtn) { importSectionsBtn.addEventListener('click', () => { showImportDialog(); }); } } /** * Open main configuration modal */ async function openConfigModal() { try { // Load config const config = await loadConfig(); await verifyLibrarySectionsConfig(config); // Build content const content = document.createElement('div'); content.innerHTML = buildMainConfigHTML(); // Build footer const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.gap = '0.75em'; footer.style.justifyContent = 'flex-end'; footer.innerHTML = ` `; // Create modal mainModalInstance = window.ModalSystem.create({ id: MAIN_MODAL_ID, title: 'Home Screen Configuration', content: content, footer: footer, closeOnBackdrop: true, closeOnEscape: true, showCloseButton: true, onOpen: (modalInstance) => { // Attach tab switching attachTabListeners(modalInstance); // Attach event listeners attachMainModalListeners(modalInstance); // Save All button const saveAllBtn = modalInstance.dialogFooter.querySelector('#save-all-btn'); if (saveAllBtn) { saveAllBtn.addEventListener('click', async () => { const globalSettings = collectGlobalSettings(); Object.assign(currentConfig, globalSettings); // If Order Sections tab was used, ensure order values are up to date const orderList = modalInstance.dialogContent.querySelector('#order-sections-list'); if (orderList) { const allRows = Array.from(orderList.querySelectorAll('.section-order-row')); allRows.forEach((r, index) => { const sectionId = r.dataset.sectionId; if (sectionId) { const found = findSectionInAllGroups(currentConfig, sectionId); if (found?.section) { found.section.order = (index + 1) * 10; } } }); } await saveConfig(currentConfig); showToast('Configuration saved!'); }); } // Populate Order Sections tab if it's the active tab if (currentActiveTab === 'order') { const orderContainer = modalInstance.dialogContent.querySelector('#order-sections-container'); if (orderContainer && orderContainer.children.length === 0) { const allSections = [ ...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })), ...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })), ...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' })) ]; orderContainer.innerHTML = renderHomeSectionsOrderEditor(allSections); } } } }); mainModalInstance.dialog.style.maxWidth = '90vw'; mainModalInstance.dialog.style.width = '1400px'; mainModalInstance.dialog.style.height = '90vh'; LOG('Main configuration modal opened'); } catch (error) { ERR('Error opening config modal:', error); showToast('Error opening configuration: ' + error.message); } } /** * Preview section by fetching items and rendering them */ async function previewSection(section, sectionType, btn) { if (!window.ApiClient || !window.apiHelper || !window.cardBuilder) { showToast('Required dependencies not available for preview'); return; } Dashboard.showLoadingMsg(); try { const userId = ApiClient.getCurrentUserId(); const serverUrl = ApiClient.serverAddress(); if (!userId || !serverUrl) { showToast('Unable to get user ID or server URL'); return; } // Build queries from section configuration const queries = section.queries || []; if (queries.length === 0) { showToast('Section has no queries to preview'); return; } // Fetch items from all queries let allItems = []; for (const query of queries) { let queryUrl = null; // Build query URL using shared builder (handles minAge/maxAge conversion) if (query.dataSource) { // Data source - skip for preview (would need special handling) queryUrl = null; } else if (window.apiHelper && window.apiHelper.buildQueryFromSection) { // Use shared builder for consistency queryUrl = window.apiHelper.buildQueryFromSection(query, userId, serverUrl, section.renderMode === 'Spotlight'); } else { // Fallback to individual builders if (query.path) { queryUrl = buildCustomEndpointForPreview(query.path, query.queryOptions || {}, userId, serverUrl); } else { queryUrl = buildStandardQueryForPreview(query.queryOptions || {}, userId, serverUrl); } } try { let items = []; if (query.dataSource) { const response = await window.apiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}, false); items = response.Items || response || []; } else if (queryUrl && typeof queryUrl === 'string') { const result = await apiHelper.getQuery(queryUrl, { useCache: false }); items = result.Items || result || []; } allItems = allItems.concat(items); } catch (error) { console.error('[Preview] Error fetching query:', error); showToast(`Error fetching query: ${error.message}`); } } if (allItems.length === 0) { showToast('No items found for preview'); // Additionally temporarily change the visual style of the Preview button // Change the text to "No Items" and give it a distinct red border or make it clear that it's not working if (btn) { btn.textContent = 'No Items'; btn.style.border = '1px solid #ff6b6b'; btn.style.color = '#ff6b6b'; } // After a short period remove this style and restore the original text and color setTimeout(() => { if (btn) { btn.textContent = 'Preview'; btn.style.border = '1px solid #ccc'; btn.style.color = '#ccc'; } }, 3000); return; } // Apply limit if specified const limit = section.queries?.[0]?.queryOptions?.Limit || section.itemLimit || 20; allItems = window.cardBuilder.postProcessItems(section, allItems); const limitedItems = allItems.slice(0, parseInt(limit, 10)); // Determine render mode const renderMode = section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal'); const useSpotlight = renderMode === 'Spotlight' || (renderMode === 'Random' && Math.random() < 0.5); // Determine card format let cardFormat = section.cardFormat || 'Poster'; if (cardFormat === 'Random') { const formats = ['Poster', 'Thumb', 'Backdrop']; cardFormat = formats[Math.floor(Math.random() * formats.length)]; } // Create preview modal const previewModalId = 'kefin-preview-section'; const content = document.createElement('div'); content.style.cssText = 'padding: 1em; max-width: 1400px; width: 100%;'; content.innerHTML = `
Preview: ${section.name || 'Unnamed Section'} (${limitedItems.length} item${limitedItems.length !== 1 ? 's' : ''})
`; const footer = document.createElement('div'); footer.innerHTML = ``; window.ModalSystem.create({ id: previewModalId, title: 'Section Preview', content: content, footer: footer, size: 'large', closeOnBackdrop: true, closeOnEscape: true, onOpen: (modalInstance) => { const container = modalInstance.dialogContent.querySelector('#preview-container'); modalInstance.dialog.style.width = '1400px'; let previewElement = null; if (useSpotlight && typeof window.cardBuilder.renderSpotlightSection === 'function') { previewElement = window.cardBuilder.renderSpotlightSection(limitedItems, section.name || 'Preview', { autoPlay: false, showDots: true, showNavButtons: true, viewMoreUrl: null }); } else if (typeof window.cardBuilder.renderCards === 'function') { previewElement = window.cardBuilder.renderCards( limitedItems, section.name || 'Preview', null, true, cardFormat, section.sortOrder || null, section.sortOrderDirection || null ); } if (previewElement) { container.innerHTML = ''; container.appendChild(previewElement); } else { container.innerHTML = '
Unable to render preview with the current configuration.
'; } } }); } catch (error) { console.error('[Preview] Error:', error); showToast(`Preview error: ${error.message}`); } finally { Dashboard.hideLoadingMsg(); } } /** * Build standard query URL for preview * Uses shared apiHelper.buildQueryFromSection for consistency */ function buildStandardQueryForPreview(queryOptions, userId, serverUrl) { if (window.apiHelper && window.apiHelper.buildStandardQuery) { return window.apiHelper.buildStandardQuery(queryOptions, userId, serverUrl); } // Fallback if apiHelper not available // Fields that require pipe delimiter instead of comma const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds']; const params = new URLSearchParams({ Recursive: 'true', Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData', ImageTypeLimit: '1', UserId: userId }); Object.entries(queryOptions || {}).forEach(([key, value]) => { if (value !== undefined && value !== null) { if (Array.isArray(value)) { // Use pipe delimiter for specific fields, comma for others const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ','; params.set(key, value.join(delimiter)); } else { params.set(key, value); } } }); return `${serverUrl}/Items?${params.toString()}`; } /** * Build custom endpoint URL for preview * Uses shared apiHelper.buildQueryFromSection for consistency */ function buildCustomEndpointForPreview(path, queryOptions, userId, serverUrl) { if (window.apiHelper && window.apiHelper.buildCustomEndpoint) { return window.apiHelper.buildCustomEndpoint(path, queryOptions, userId, serverUrl); } // Fallback if apiHelper not available // Fields that require pipe delimiter instead of comma const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds']; const params = new URLSearchParams({ UserId: userId }); Object.entries(queryOptions || {}).forEach(([key, value]) => { if (value !== undefined && value !== null) { if (Array.isArray(value)) { // Use pipe delimiter for specific fields, comma for others const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ','; params.set(key, value.join(delimiter)); } else { params.set(key, value); } } }); if (!params.has('Fields')) { params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,MediaSourceCount,UserData'); } if (!params.has('ImageTypeLimit')) { params.set('ImageTypeLimit', '1'); } if (!params.has('EnableTotalRecordCount')) { params.set('EnableTotalRecordCount', 'false'); } return `${serverUrl}${path}?${params.toString()}`; } window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; window.KefinTweaksFeatureConfigs.homeScreen = { openConfigModal }; // Export getConfig for use by other scripts window.KefinHomeScreen = window.KefinHomeScreen || {}; window.KefinHomeScreen.getConfig = getConfig; window.KefinHomeScreen.getSections = getSections; LOG('Home Screen Configuration UI loaded'); // Load user configuration script after this script is initialized function loadUserConfigurationScript() { try { const root = window.KefinTweaksConfig?.kefinTweaksRoot; if (!root) { WARN('kefinTweaksRoot not configured, cannot load user configuration script'); return; } // Check if script is already loaded if (document.querySelector('script[data-kefin-user-homescreen-config]')) { LOG('User home screen configuration script already loaded'); return; } const scriptUrl = `${root}configuration/homeScreen-user-configuration.js`; const script = document.createElement('script'); script.src = scriptUrl; script.async = true; script.setAttribute('data-kefin-user-homescreen-config', 'true'); script.onload = () => { LOG('User home screen configuration script loaded successfully'); }; script.onerror = (error) => { ERR('Failed to load user home screen configuration script:', scriptUrl, error); }; document.head.appendChild(script); } catch (error) { ERR('Error loading user configuration script:', error); } } // Load user config script after a short delay to ensure this script is fully initialized setTimeout(loadUserConfigurationScript, 100); })();