diff --git a/README.md b/README.md index f049a8f..c91b738 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +
+ + KefinTweaks +
+ # KefinTweaks for Jellyfin KefinTweaks is a comprehensive collection of enhancements and customizations for Jellyfin, focused on, but not limited to, filling gaps in functionality based on the community's most desired [Feature Requests](https://features.jellyfin.org/?view=most-wanted). @@ -61,6 +66,7 @@ Note: The Slideshow at the top of the Home Screen in the video below is from the - [License](#license) - [Acknowledgments](#acknowledgments) - [Support](#support) +- [FAQ](#faq) - [Roadmap](#roadmap) - [Planned Features](#planned-features) - [Version History](#version-history) @@ -559,7 +565,7 @@ A few things to note about KefinTweaks functionality in general and how it inter - Search - The default Jellyfin search is hidden in favor of the KefinTweaks search - - KefinTweaks blocks default Jellyfin search requests, and only performs its own search requests + - KefinTweaks does not block default Jellyfin search requests, but they will only be triggered when manually navigating to the search page with a searchTerm in the url. In this circumstance, the duplicate search results are hidden. - Dashboard Button Fix - This fix prevents the Back button in the Dashboad from navigating back to the "new tab" page in your browser
@@ -598,6 +604,26 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - **[movie-monk-b0t](https://github.com/movie-monk-b0t)**: For the IMDb Top 250 JSON data - **[joshmcrty](https://github.com/joshmcrty)**: For the [Flurry](https://github.com/joshmcrty/Flurry) jQuery plugin +## FAQ + +> ### I saved the KefinTweaks Plugin script in JS Injector but I don't see KefinTweaks in the Dashboard menu + +You must complete the installation by clicking the KefinTweaks plugin and selecting the installation source (either GitHub or local) from the KefinTweaks plugin card at the very bottom of your Plugins page: + +
+ jsinjector publicjs +

+ + +> ### KefinTweaks Plugin never appears in the Plugins page even though I saved the KefinTweaks Plugin script in JS Injector + +1. Ensure `JavaScriptInjector\public.js` is loaded by the client: + - Open DevTools (right-click page > inspect) + - Click Sources tab +
+ jsinjector publicjs +
+ ## Support - **Issues**: diff --git a/configuration/customMenuLinks-configuration.js b/configuration/customMenuLinks-configuration.js new file mode 100644 index 0000000..192c41c --- /dev/null +++ b/configuration/customMenuLinks-configuration.js @@ -0,0 +1,174 @@ +// KefinTweaks Custom Menu Links Configuration UI +// Modular configuration modal for managing custom menu links + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks CustomMenuLinks Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks CustomMenuLinks Config]', ...args); + + const MODAL_ID = 'kefin-custommenulinks-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.customMenuLinks || []; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(customMenuLinks) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update customMenuLinks config + window.KefinTweaksConfig.customMenuLinks = customMenuLinks; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + return ` +
+
+
+
Custom Menu Links JSON
+
Add custom menu links that will appear in the custom menu. Use Material icon names for icons.
+ +
+ View Example Format +
[
+  {
+    "name": "My Custom Link",
+    "icon": "link",
+    "url": "#/userpluginsettings.html?pageUrl=https://domain.com/custom-page",
+    "openInNewTab": false
+  },
+  {
+    "name": "External Link",
+    "icon": "open_in_new",
+    "url": "https://example.com",
+    "openInNewTab": true
+  }
+]
+
+
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + try { + const jsonText = document.getElementById('customMenuLinksJson')?.value || '[]'; + return JSON.parse(jsonText); + } catch (e) { + ERR('Error parsing custom menu links JSON:', e); + alert('Invalid JSON format. Please check your input.'); + return []; + } + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + const modalInstance = window.ModalSystem.create({ + id: MODAL_ID, + title: 'Custom Menu Links Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-custommenulinks-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const customMenuLinks = collectConfig(); + const success = await saveConfig(customMenuLinks); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Custom Menu Links configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + modalInstance.dialog.style.maxWidth = '90vw'; + modalInstance.dialog.style.width = '1400px'; + modalInstance.dialog.style.height = '90vh'; + + LOG('Custom Menu Links configuration modal opened'); + } catch (error) { + ERR('Error opening custom menu links config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.customMenuLinks = { openConfigModal }; + + console.log('[KefinTweaks CustomMenuLinks Config] Script loaded'); + +})(); + diff --git a/configuration/homeScreen-configuration.css b/configuration/homeScreen-configuration.css new file mode 100644 index 0000000..0457130 --- /dev/null +++ b/configuration/homeScreen-configuration.css @@ -0,0 +1,95 @@ +[data-modal-id="kefin-homescreen-config-main"] .config-tab-content details { + max-height: calc(90vh - 19em); + overflow: auto; +} +[data-type="Collections"], [data-type="Playlists"], [data-type="Generic Parent"] { + display: none; +} +[data-parent-item-type="Collection"] [data-type="Collections"] { + display: block; +} +[data-parent-item-type="Playlist"] [data-type="Playlists"] { + display: block; +} +[data-parent-item-type="Generic Parent"] [data-type="Generic Parent"] { + display: block; +} + +[data-modal-id="kefin-homescreen-config-main"] details[open] summary.listItemBodyText::after { + content: ''; +} + +[data-modal-id="kefin-homescreen-config-main"] #section-content { + padding-right: 1em; +} + +[data-modal-id="kefin-homescreen-config-main"] div#section-content { + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 0.75em; + max-height: calc(90vh - 19em); + overflow: auto; +} + +div#order-sections-container .editable-group-name { + pointer-events: none; +} + +.section-group[data-section-type-seasonal="false"] .section-row[data-section-type="Seasonal"] { + display: none !important; +} +.section-group[data-section-type-default="false"] .section-row[data-section-type="Default"] { + display: none !important; +} +.section-group[data-section-type-custom="false"] .section-row[data-section-type="Custom"] { + display: none !important; +} + +div#discovery-sections-list .section-preview-btn { + display: none; +} + +.listItem.section-row:has(.discovery-tag) { + box-shadow: black inset 1px 1px 2px 1px; +} + +div#order-sections-container .section-group .group-sections-list { + max-height: calc(90vh - 25em); + overflow: auto; + margin-bottom: -1em; +} + +span.listItemBodyText.secondary.discovery-tag { + background: #3F51B5; + padding: 2px 5px; +} +span.listItemBodyText.secondary.spotlight-tag { + background: #501515; + padding: 2px 5px; +} +span.listItemBodyText.secondary.seasonal-tag { + background: #3b5562; + padding: 2px 5px; +} + +.discovery-tag, .spotlight-tag, .seasonal-tag { + color: rgba(255, 255, 255, 0.9) !important; + border-radius: 5px; +} + +.homeScreenSettingsContainer:has(#kefin-user-home-sections-editor) form>div:first-child { + display: none; +} + +.homeScreenSettingsContainer form { + display: flex; + flex-direction: column; +} + +.homeScreenSettingsContainer form > * { + order: 2; +} + +.homeScreenSettingsContainer #kefin-user-home-sections-editor { + order: 1; +} \ No newline at end of file diff --git a/configuration/homeScreen-configuration.js b/configuration/homeScreen-configuration.js new file mode 100644 index 0000000..3b2a379 --- /dev/null +++ b/configuration/homeScreen-configuration.js @@ -0,0 +1,5693 @@ +// 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 = { + 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' }, + 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']; + const SORT_ORDERS = ['Random', 'Name', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber']; + const SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending']; + + // Current config state + let currentConfig = null; + let mainModalInstance = null; + let currentActiveTab = 'settings'; // Track the currently active tab + + /** + * 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 recently-added library sections with actual Jellyfin libraries + * @param {Object} config - Current configuration object + * @returns {Object} Updated configuration with synced recently-added sections + */ + async function verifyRecentlyAddedInLibraryConfig(config) { + try { + if (!window.dataHelper || !window.dataHelper.getLibraries) { + WARN('dataHelper.getLibraries not available, skipping recently-added verification'); + return config; + } + + // Get current libraries + const libraries = await window.dataHelper.getLibraries(); + if (!libraries || libraries.length === 0) { + LOG('No libraries found, skipping recently-added verification'); + return config; + } + + // Flatten groups to get all sections + const allSections = flattenSectionGroups(config.HOME_SECTION_GROUPS || []); + + // Extract existing recently-added sections + const recentlyAddedSections = allSections.filter(s => s.id && s.id.startsWith('recently-added-')); + const existingLibraryIds = new Set(recentlyAddedSections.map(s => s.id.replace('recently-added-', ''))); + + // Get current library IDs + const currentLibraryIds = new Set(libraries.map(l => l.Id)); + + // Find sections to remove (libraries that no longer exist) + const sectionsToRemove = recentlyAddedSections.filter(s => { + const libId = s.id.replace('recently-added-', ''); + return !currentLibraryIds.has(libId); + }); + + // Find libraries to add (new libraries not in config) + const librariesToAdd = libraries.filter(l => !existingLibraryIds.has(l.Id) && l.CollectionType && l.CollectionType !== 'boxsets' && l.CollectionType !== 'playlists'); + + if (sectionsToRemove.length === 0 && librariesToAdd.length === 0) { + LOG('Recently-added sections are in sync with libraries'); + return config; + } + + LOG(`Syncing recently-added sections: Removing ${sectionsToRemove.length}, Adding ${librariesToAdd.length}`); + + // Remove sections for non-existent libraries + const sectionsToRemoveIds = new Set(sectionsToRemove.map(s => s.id)); + + // Update groups by removing sections + const updatedGroups = (config.HOME_SECTION_GROUPS || []).map(group => { + if (!group.sections) return group; + const filteredSections = group.sections.filter(s => !sectionsToRemoveIds.has(s.id)); + return { + ...group, + sections: filteredSections + }; + }); + + // Add sections for new libraries + librariesToAdd.forEach(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`; + } + + const newSection = { + id: `recently-added-${library.Id}`, + name: `Recently Added ${library.Name}`, + enabled: false, // Default to disabled + order: 61, + cardFormat: 'Poster', + viewMoreUrl: viewMoreUrl, + queries: [{ + path: '/Items/Latest', + queryOptions: { + ParentId: library.Id, + SortBy: 'DateCreated', + SortOrder: 'Descending', + Limit: 16 + } + }] + }; + + // Find or create "Recently Added" group + let recentlyAddedGroup = updatedGroups.find(g => g.name === 'Recently Added'); + if (!recentlyAddedGroup) { + recentlyAddedGroup = { + name: 'Recently Added', + sections: [] + }; + updatedGroups.push(recentlyAddedGroup); + } + + recentlyAddedGroup.sections.push(newSection); + }); + + // Update config + config.HOME_SECTION_GROUPS = updatedGroups; + + LOG('Recently-added sections synced successfully'); + return config; + } catch (error) { + ERR('Error verifying recently-added library config:', error); + return config; + } + } + + /** + * Load configuration from JS Injector and merge with defaults + */ + async 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 || {}) }, + MERGE_NEXT_UP: existingConfig.MERGE_NEXT_UP ?? defaults.MERGE_NEXT_UP ?? false + }; + + // Verify and sync recently-added library sections + await verifyRecentlyAddedInLibraryConfig(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 + */ + async function getConfig() { + return await loadConfig(); + } + + /** + * 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 || {}) + }, + 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 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.
'; + + // Determine if settings should be expanded (expanded by default unless mobile) + const settingsExpanded = !isMobile(); + + // Use currentActiveTab to determine which tab/content should be displayed + const activeTab = currentActiveTab || 'settings'; + + return ` +
+ +
+ + + + + +
+ + +
+
+ +
+ General Settings +
+ ${buildGeneralSettingsHTML()} +
+
+ + +
+ Discovery Settings +
+ ${buildDiscoverySettingsHTML()} +
+
+ + +
+ Cache Settings +
+ ${buildCacheSettingsHTML()} +
+
+
+
+ + +
+
+ +
+ ${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 || {}; + 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 })} +
+
+ `; + } + + /** + * 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 })} + ${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')} +
+
+ `; + } + + /** + * 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); + } + } 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) { + 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')} +
+ `; + } + } + + return ` +
+ +
+ ${buildEnabledToggleButtons('section-enabled', section.enabled)} +
+ + +
+ ${basicPropertiesHTML} +
+ ${additionalFieldsHTML} + ${!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...' }, + { 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 || '' }, + { 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')} +
+ `).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) { + 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')} + ${buildCheckbox('discovery-spotlight', section.spotlight === true, 'Spotlight')} +
+ `; + } + + /** + * 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}"]`); + 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); + } + } + + 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'; + } + + 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).join(','); + } + } + } + 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).join(','); + } + } + } +/* 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; + + // 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 + }; + + 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 mergeNextUp = dialog.querySelector('#merge-next-up')?.checked === true; + + return { + DISCOVERY_SETTINGS: discovery, + SEASONAL_THEME_SETTINGS: seasonal, + CACHE: cache, + 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(); + }); + } + + 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 + const renderModeSelect = modalInstance.dialogContent.querySelector('#section-renderMode'); + const cardFormatContainer = modalInstance.dialogContent.querySelector('#section-cardFormat-container'); + if (renderModeSelect && cardFormatContainer) { + const updateCardFormatVisibility = () => { + cardFormatContainer.style.display = renderModeSelect.value === 'Spotlight' ? 'none' : 'block'; + }; + renderModeSelect.addEventListener('change', updateCardFormatVisibility); + updateCardFormatVisibility(); // 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); + } + }); + /* 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(); + + // 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) + showToast('Data source queries cannot be previewed'); + continue; + } else if (window.apiHelper && window.apiHelper.buildQueryFromSection) { + // Use shared builder for consistency + queryUrl = window.apiHelper.buildQueryFromSection(query, userId, serverUrl); + } else { + // Fallback to individual builders + if (query.path) { + queryUrl = buildCustomEndpointForPreview(query.path, query.queryOptions || {}, userId, serverUrl); + } else { + queryUrl = buildStandardQueryForPreview(query.queryOptions || {}, userId, serverUrl); + } + } + + if (queryUrl && typeof queryUrl === 'string') { + try { + const result = await apiHelper.getQuery(queryUrl, { useCache: false }); + const 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; + + 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); + +})(); \ No newline at end of file diff --git a/configuration/homeScreen-migration.js b/configuration/homeScreen-migration.js new file mode 100644 index 0000000..b847163 --- /dev/null +++ b/configuration/homeScreen-migration.js @@ -0,0 +1,649 @@ +// KefinTweaks HomeScreen Configuration Migration +// Migrates legacy KefinTweaksConfig.homeScreen to new KefinTweaksConfig.homeScreenConfig format +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HomeScreen Migration]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeScreen Migration]', ...args); + const ERR = (...args) => console.error('[KefinTweaks HomeScreen Migration]', ...args); + + /** + * Check if migration is needed and perform migration + * @returns {Promise} True if migration was performed, false otherwise + */ + async function migrateHomeScreenConfig() { + // Check if migration is needed + if (!window.KefinTweaksConfig) { + LOG('KefinTweaksConfig not found, skipping migration'); + return false; + } + + // If homeScreenConfig already exists, skip migration + if (window.KefinTweaksConfig.homeScreenConfig) { + LOG('homeScreenConfig already exists, skipping migration'); + return false; + } + + // If homeScreen doesn't exist, nothing to migrate + if (!window.KefinTweaksConfig.homeScreen) { + LOG('No legacy homeScreen config found, skipping migration'); + return false; + } + + // Check if KefinHomeConfig2 defaults are available + if (!window.KefinHomeConfig2) { + WARN('KefinHomeConfig2 defaults not available, cannot perform migration'); + return false; + } + + LOG('Starting migration from legacy homeScreen to homeScreenConfig...'); + + try { + const legacyHomeScreen = window.KefinTweaksConfig.homeScreen; + const defaults = window.KefinHomeConfig2; + + // Initialize new config structure with defaults + const newHomeScreenConfig = { + HOME_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.HOME_SECTION_GROUPS || [])), + SEASONAL_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.SEASONAL_SECTION_GROUPS || [])), + DISCOVERY_SECTION_GROUPS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SECTION_GROUPS || [])), + CUSTOM_SECTION_GROUPS: [], + REMOVE_CONFLICTING_SECTIONS: legacyHomeScreen.removeConflictingSections !== false, + MERGE_NEXT_UP: false, + DISCOVERY_SETTINGS: JSON.parse(JSON.stringify(defaults.DISCOVERY_SETTINGS || {})), + SEASONAL_THEME_SETTINGS: JSON.parse(JSON.stringify(defaults.SEASONAL_THEME_SETTINGS || {})) + }; + + // Migrate discovery settings + migrateDiscoverySettings(legacyHomeScreen, newHomeScreenConfig); + + // Migrate seasonal theme settings + migrateSeasonalThemeSettings(legacyHomeScreen, newHomeScreenConfig); + + // Migrate HOME sections + migrateHomeSections(legacyHomeScreen, newHomeScreenConfig); + + // Migrate SEASONAL sections + migrateSeasonalSections(legacyHomeScreen, newHomeScreenConfig); + + // Migrate DISCOVERY sections + migrateDiscoverySections(legacyHomeScreen, newHomeScreenConfig); + + // Migrate CUSTOM sections + migrateCustomSections(legacyHomeScreen, newHomeScreenConfig); + + // Save migrated config to JS Injector plugin + window.KefinTweaksConfig.homeScreenConfig = newHomeScreenConfig; + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + window.KefinTweaksUtils.saveConfigToJavaScriptInjector().catch(err => { + ERR('Failed to save migrated config to JS Injector:', err); + }); + } else { + WARN('KefinTweaksUtils.saveConfigToJavaScriptInjector not available, config migration not persisted'); + } + LOG('Migration completed successfully'); + } catch (error) { + ERR('Migration failed:', error); + } finally { + return newHomeScreenConfig; + } + } + + /** + * Migrate discovery settings + */ + function migrateDiscoverySettings(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.discovery) return; + + const discovery = legacyHomeScreen.discovery; + const settings = newConfig.DISCOVERY_SETTINGS; + + LOG('Migrating discovery settings...'); + + if (discovery.enabled !== undefined) settings.enabled = discovery.enabled; + if (discovery.infiniteScroll !== undefined) settings.infiniteScroll = discovery.infiniteScroll; + if (discovery.minPeopleAppearances !== undefined) settings.minPeopleAppearances = discovery.minPeopleAppearances; + if (discovery.minGenreMovieCount !== undefined) settings.minGenreMovieCount = discovery.minGenreMovieCount; + if (discovery.defaultItemLimit !== undefined) settings.defaultItemLimit = discovery.defaultItemLimit; + if (discovery.defaultSortOrder !== undefined) settings.defaultSortOrder = discovery.defaultSortOrder; + if (discovery.defaultCardFormat !== undefined) settings.defaultCardFormat = discovery.defaultCardFormat; + if (discovery.spotlightDiscoveryChance !== undefined) settings.spotlightDiscoveryChance = discovery.spotlightDiscoveryChance; + if (discovery.renderSpotlightAboveMatching !== undefined) settings.renderSpotlightAboveMatching = discovery.renderSpotlightAboveMatching; + if (discovery.randomizeOrder !== undefined) settings.randomizeOrder = discovery.randomizeOrder; + if (discovery.spotlight && typeof discovery.spotlight === 'object') { + Object.assign(settings.spotlight, discovery.spotlight); + } + } + + /** + * Migrate seasonal theme settings + */ + function migrateSeasonalThemeSettings(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.seasonal) return; + + const seasonal = legacyHomeScreen.seasonal; + const settings = newConfig.SEASONAL_THEME_SETTINGS; + + LOG('Migrating seasonal theme settings...'); + + if (seasonal.enabled !== undefined) settings.enabled = seasonal.enabled; + if (seasonal.enableSeasonalAnimations !== undefined) settings.enableSeasonalAnimations = seasonal.enableSeasonalAnimations; + if (seasonal.enableSeasonalBackground !== undefined) settings.enableSeasonalBackground = seasonal.enableSeasonalBackground; + } + + /** + * Find section in default groups by ID + */ + function findSectionInDefaults(sectionId, groups) { + for (const group of groups) { + if (group.sections) { + const section = group.sections.find(s => s.id === sectionId); + if (section) { + return { section, group, sectionIndex: group.sections.indexOf(section) }; + } + } + } + return null; + } + + /** + * Find seasonal section using matching algorithm + */ + function findSeasonalSectionByMatch(legacySection, seasonGroup, seasonId) { + if (!seasonGroup || !seasonGroup.sections) return null; + + const sections = seasonGroup.sections; + + // Try exact ID match (for descriptive IDs like halloween-tag) + if (legacySection.id && !legacySection.id.startsWith('seasonal-')) { + const exactMatch = sections.find(s => { + const expectedId = `seasonal.${seasonId}.${legacySection.id}`; + return s.id === expectedId || s.id.endsWith(`.${legacySection.id}`); + }); + if (exactMatch) { + LOG(` Matched section by exact ID: ${legacySection.id} → ${exactMatch.id}`); + return exactMatch; + } + } + + // Try name match + if (legacySection.name) { + const nameMatch = sections.find(s => s.name === legacySection.name); + if (nameMatch) { + LOG(` Matched section by name: "${legacySection.name}" → ${nameMatch.id}`); + return nameMatch; + } + } + + // Try type + source pattern match + if (legacySection.type && legacySection.source !== undefined) { + const typeSourceMatch = sections.find(s => { + if (!s.queries || !s.queries[0] || !s.queries[0].queryOptions) return false; + const queryOptions = s.queries[0].queryOptions; + + if (legacySection.type === 'Tag' && queryOptions.Tags === legacySection.source) return true; + if (legacySection.type === 'Genre' && (queryOptions.Genres === legacySection.source || queryOptions.GenreIds === legacySection.source)) return true; + if (legacySection.type === 'Parent' && queryOptions.ParentId === legacySection.source) return true; + return false; + }); + if (typeSourceMatch) { + LOG(` Matched section by type+source: ${legacySection.type}/${legacySection.source} → ${typeSourceMatch.id}`); + return typeSourceMatch; + } + } + + return null; + } + + /** + * Find discovery section by ID + */ + function findDiscoverySectionById(sectionId, groups) { + for (const group of groups) { + if (group.sections) { + const section = group.sections.find(s => s.id === sectionId); + if (section) { + return { section, group, sectionIndex: group.sections.indexOf(section) }; + } + } + } + return null; + } + + /** + * Merge legacy section config into default section + */ + function mergeSectionConfig(defaultSection, legacyConfig) { + const merged = JSON.parse(JSON.stringify(defaultSection)); + + // Map root fields + if (legacyConfig.enabled !== undefined) merged.enabled = legacyConfig.enabled; + if (legacyConfig.name !== undefined) merged.name = legacyConfig.name; + if (legacyConfig.order !== undefined) merged.order = legacyConfig.order; + if (legacyConfig.cardFormat !== undefined) merged.cardFormat = legacyConfig.cardFormat; + if (legacyConfig.startDate !== undefined) merged.startDate = legacyConfig.startDate; + if (legacyConfig.endDate !== undefined) merged.endDate = legacyConfig.endDate; + if (legacyConfig.discoveryEnabled !== undefined) merged.discoveryEnabled = legacyConfig.discoveryEnabled; + + // RenderMode mapping: only use spotlight if renderMode doesn't exist + if (legacyConfig.renderMode !== undefined) { + merged.renderMode = legacyConfig.renderMode; + } else if (legacyConfig.spotlight === true) { + merged.renderMode = 'Spotlight'; + } + + if (legacyConfig.spotlightConfig !== undefined) merged.spotlightConfig = legacyConfig.spotlightConfig; + if (legacyConfig.overflowCard !== undefined) merged.overflowCard = legacyConfig.overflowCard; + if (legacyConfig.minimumShowsForNetwork !== undefined) merged.minimumShowsForNetwork = legacyConfig.minimumShowsForNetwork; + if (legacyConfig.flattenSeries !== undefined) merged.flattenSeries = legacyConfig.flattenSeries; + if (legacyConfig.viewMoreUrl !== undefined) merged.viewMoreUrl = legacyConfig.viewMoreUrl; + + // Map query options fields + if (merged.queries && merged.queries.length > 0) { + const query = merged.queries[0]; + if (!query.queryOptions) query.queryOptions = {}; + + if (legacyConfig.itemLimit !== undefined) query.queryOptions.Limit = legacyConfig.itemLimit; + if (legacyConfig.sortOrder !== undefined) query.queryOptions.SortBy = legacyConfig.sortOrder; + if (legacyConfig.sortOrderDirection !== undefined) query.queryOptions.SortOrder = legacyConfig.sortOrderDirection; + if (legacyConfig.searchTerm !== undefined && legacyConfig.searchTerm) query.queryOptions.SearchTerm = legacyConfig.searchTerm; + if (legacyConfig.includeItemTypes !== undefined) { + query.queryOptions.IncludeItemTypes = Array.isArray(legacyConfig.includeItemTypes) + ? legacyConfig.includeItemTypes + : legacyConfig.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean); + } + if (legacyConfig.isPlayed !== undefined && legacyConfig.isPlayed !== null) { + query.queryOptions.IsUnplayed = legacyConfig.isPlayed === false; + } + if (legacyConfig.minAgeInDays !== undefined && legacyConfig.minAgeInDays !== null) { + query.minAge = legacyConfig.minAgeInDays; + } + if (legacyConfig.maxAgeInDays !== undefined && legacyConfig.maxAgeInDays !== null) { + query.maxAge = legacyConfig.maxAgeInDays; + } + + // Merge additionalQueryOptions + if (legacyConfig.additionalQueryOptions && Array.isArray(legacyConfig.additionalQueryOptions)) { + legacyConfig.additionalQueryOptions.forEach(opt => { + if (opt && opt.key && opt.value !== undefined) { + query.queryOptions[opt.key] = opt.value; + } + }); + } + } + + return merged; + } + + /** + * Convert legacy Type/source to queries array + */ + function convertLegacyTypeToQuery(type, source, legacySection) { + const query = { + queryOptions: {} + }; + + // Build base queryOptions from legacy section fields + if (legacySection.itemLimit !== undefined) query.queryOptions.Limit = legacySection.itemLimit; + if (legacySection.sortOrder !== undefined) query.queryOptions.SortBy = legacySection.sortOrder; + if (legacySection.sortOrderDirection !== undefined) query.queryOptions.SortOrder = legacySection.sortOrderDirection; + if (legacySection.searchTerm !== undefined && legacySection.searchTerm) query.queryOptions.SearchTerm = legacySection.searchTerm; + if (legacySection.includeItemTypes !== undefined) { + query.queryOptions.IncludeItemTypes = Array.isArray(legacySection.includeItemTypes) + ? legacySection.includeItemTypes + : legacySection.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean); + } + if (legacySection.isPlayed !== undefined && legacySection.isPlayed !== null) { + query.queryOptions.IsUnplayed = legacySection.isPlayed === false; + } + if (legacySection.minAgeInDays !== undefined && legacySection.minAgeInDays !== null) { + query.minAge = legacySection.minAgeInDays; + } + if (legacySection.maxAgeInDays !== undefined && legacySection.maxAgeInDays !== null) { + query.maxAge = legacySection.maxAgeInDays; + } + + // Convert Type/source to queryOptions + if (type === 'Playlist' || type === 'Collection' || type === 'Parent') { + if (source && source.trim()) { + query.queryOptions.ParentId = source.trim(); + } + } else if (type === 'Genre') { + const genreSource = source ? source.trim() : ''; + if (genreSource) { + // Check if it's a 32-char hex ID + if (/^[a-f0-9]{32}$/i.test(genreSource)) { + query.queryOptions.GenreIds = genreSource; + } else { + // Comma-separated genre names + query.queryOptions.Genres = genreSource; + } + } + } else if (type === 'Tag') { + if (source && source.trim()) { + query.queryOptions.Tags = source.trim(); + } + } + + // Merge additionalQueryOptions + if (legacySection.additionalQueryOptions && Array.isArray(legacySection.additionalQueryOptions)) { + legacySection.additionalQueryOptions.forEach(opt => { + if (opt && opt.key && opt.value !== undefined) { + query.queryOptions[opt.key] = opt.value; + } + }); + } + + return query; + } + + /** + * Migrate HOME sections + */ + function migrateHomeSections(legacyHomeScreen, newConfig) { + LOG('Migrating HOME sections...'); + + // Migrate recentlyReleased.movies + if (legacyHomeScreen.recentlyReleased?.movies) { + const found = findSectionInDefaults('recentlyReleased.movies', newConfig.HOME_SECTION_GROUPS); + if (found) { + const moviesConfig = legacyHomeScreen.recentlyReleased.movies; + const parentEnabled = legacyHomeScreen.recentlyReleased.enabled !== false; + const merged = mergeSectionConfig(found.section, { + ...moviesConfig, + enabled: moviesConfig.enabled !== undefined ? moviesConfig.enabled : parentEnabled + }); + Object.assign(found.section, merged); + LOG(' Migrated recentlyReleased.movies'); + } + } + + // Migrate recentlyReleased.episodes + if (legacyHomeScreen.recentlyReleased?.episodes) { + const found = findSectionInDefaults('recentlyReleased.episodes', newConfig.HOME_SECTION_GROUPS); + if (found) { + const episodesConfig = legacyHomeScreen.recentlyReleased.episodes; + const parentEnabled = legacyHomeScreen.recentlyReleased.enabled !== false; + const merged = mergeSectionConfig(found.section, { + ...episodesConfig, + enabled: episodesConfig.enabled !== undefined ? episodesConfig.enabled : parentEnabled + }); + Object.assign(found.section, merged); + LOG(' Migrated recentlyReleased.episodes'); + } + } + + // Migrate recentlyAddedInLibrary + if (legacyHomeScreen.recentlyAddedInLibrary && typeof legacyHomeScreen.recentlyAddedInLibrary === 'object') { + const libraryConfigs = legacyHomeScreen.recentlyAddedInLibrary; + let recentlyAddedGroup = newConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Recently Added' || g.id === 'home-recently-added'); + + if (!recentlyAddedGroup) { + recentlyAddedGroup = { + id: 'home-recently-added', + name: 'Recently Added', + sections: [] + }; + newConfig.HOME_SECTION_GROUPS.push(recentlyAddedGroup); + } + + Object.entries(libraryConfigs).forEach(([libraryId, libConfig]) => { + const sectionId = `recently-added-${libraryId}`; + let includeItemTypes = ['Movie', 'Episode']; + + if (libConfig.includeItemTypes) { + includeItemTypes = Array.isArray(libConfig.includeItemTypes) + ? libConfig.includeItemTypes + : libConfig.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean); + } + + const newSection = { + id: sectionId, + name: libConfig.name || `Recently Added Media`, + enabled: libConfig.enabled !== false, + order: libConfig.order || 61, + cardFormat: libConfig.cardFormat || 'Poster', + queries: [{ + path: '/Items/Latest', + queryOptions: { + ParentId: libraryId, + IncludeItemTypes: includeItemTypes, + SortBy: 'DateCreated', + SortOrder: 'Descending', + Limit: libConfig.itemLimit || 16 + } + }] + }; + + recentlyAddedGroup.sections.push(newSection); + LOG(` Migrated recentlyAddedInLibrary[${libraryId}]`); + }); + } + + // Migrate other home sections + const sectionMappings = { + watchAgain: 'watchAgain', + watchlist: 'watchlist', + upcoming: 'upcoming', + imdbTop250: 'imdbTop250', + popularTVNetworks: 'popularTVNetworks' + }; + + Object.entries(sectionMappings).forEach(([legacyKey, sectionId]) => { + if (legacyHomeScreen[legacyKey]) { + const found = findSectionInDefaults(sectionId, newConfig.HOME_SECTION_GROUPS); + if (found) { + const merged = mergeSectionConfig(found.section, legacyHomeScreen[legacyKey]); + Object.assign(found.section, merged); + LOG(` Migrated ${legacyKey}`); + } + } + }); + } + + /** + * Migrate SEASONAL sections + */ + function migrateSeasonalSections(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.seasonal?.seasons || !Array.isArray(legacyHomeScreen.seasonal.seasons)) { + return; + } + + LOG('Migrating SEASONAL sections...'); + + legacyHomeScreen.seasonal.seasons.forEach(legacySeason => { + const seasonId = legacySeason.id || legacySeason.name?.toLowerCase().replace(/\s+/g, '-'); + if (!seasonId) { + WARN(' Skipping season without id or name'); + return; + } + + // Find matching seasonal group + const seasonalGroup = newConfig.SEASONAL_SECTION_GROUPS.find(g => { + const groupId = g.id || g.name?.toLowerCase().replace(/\s+/g, '-'); + return groupId === `seasonal-${seasonId}` || groupId === seasonId; + }); + + if (!seasonalGroup) { + WARN(` No matching seasonal group found for season: ${seasonId}`); + return; + } + + LOG(` Processing season: ${seasonId}`); + + if (legacySeason.sections && Array.isArray(legacySeason.sections)) { + legacySeason.sections.forEach(legacySection => { + // Try to find matching section + const matchedSection = findSeasonalSectionByMatch(legacySection, seasonalGroup, seasonId); + + if (matchedSection) { + // Merge legacy config into matched section + const merged = mergeSectionConfig(matchedSection, { + ...legacySection, + startDate: legacySeason.startDate || matchedSection.startDate, + endDate: legacySeason.endDate || matchedSection.endDate + }); + Object.assign(matchedSection, merged); + LOG(` Migrated section: ${legacySection.id || legacySection.name} → ${matchedSection.id}`); + } else { + LOG(`Converting generic section to custom: ${legacySection.id}`); + convertSeasonalSectionToCustom(legacySection, legacySeason, newConfig); + } + }); + } + }); + } + + /** + * Convert seasonal section with generic ID to custom section + */ + function convertSeasonalSectionToCustom(legacySection, legacySeason, newConfig) { + const query = convertLegacyTypeToQuery(legacySection.type, legacySection.source, legacySection); + + const customSection = { + id: `custom-${Date.now()}-${Math.random()}`, + name: legacySection.name || 'Custom Section', + enabled: legacySection.enabled !== false, + order: legacySection.order, + cardFormat: legacySection.cardFormat || 'Poster', + queries: [query] + }; + + // Map _parentItemType based on type + if (legacySection.type === 'Playlist') { + customSection._parentItemType = 'Playlist'; + } else if (legacySection.type === 'Collection') { + customSection._parentItemType = 'Collection'; + } else if (legacySection.type === 'Parent') { + customSection._parentItemType = 'Generic Parent'; + } + // Tag and Genre don't need _parentItemType + + // Map additional fields + if (legacySection.renderMode !== undefined) { + customSection.renderMode = legacySection.renderMode; + } else if (legacySection.spotlight === true) { + customSection.renderMode = 'Spotlight'; + } + if (legacySection.discoveryEnabled !== undefined) customSection.discoveryEnabled = legacySection.discoveryEnabled; + if (legacySection.spotlightConfig !== undefined) customSection.spotlightConfig = legacySection.spotlightConfig; + if (legacySection.overflowCard !== undefined) customSection.overflowCard = legacySection.overflowCard; + + const startDate = legacySection.startDate || legacySeason.startDate; + const endDate = legacySection.endDate || legacySeason.endDate; + if (startDate !== undefined) customSection.startDate = startDate; + if (endDate !== undefined) customSection.endDate = endDate; + + // Add to custom sections group + let customGroup = newConfig.CUSTOM_SECTION_GROUPS.find(g => g.name === 'Custom Sections' || g.id === 'custom-sections'); + if (!customGroup) { + customGroup = { + id: 'custom-sections', + name: 'Custom Sections', + sections: [] + }; + newConfig.CUSTOM_SECTION_GROUPS.push(customGroup); + } + + customGroup.sections.push(customSection); + LOG(` Added as custom section: ${customSection.id}`); + } + + /** + * Migrate DISCOVERY sections + */ + function migrateDiscoverySections(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.discovery?.sectionTypes) { + return; + } + + LOG('Migrating DISCOVERY sections...'); + + const discoverySectionTypes = legacyHomeScreen.discovery.sectionTypes; + Object.entries(discoverySectionTypes).forEach(([sectionKey, legacySectionConfig]) => { + // Use the key as the section ID + const found = findDiscoverySectionById(sectionKey, newConfig.DISCOVERY_SECTION_GROUPS); + + if (found) { + // Discovery sections still use legacy format, so we map fields directly + if (legacySectionConfig.enabled !== undefined) found.section.enabled = legacySectionConfig.enabled; + if (legacySectionConfig.name !== undefined) found.section.name = legacySectionConfig.name; + if (legacySectionConfig.itemLimit !== undefined) found.section.itemLimit = legacySectionConfig.itemLimit; + if (legacySectionConfig.cardFormat !== undefined) found.section.cardFormat = legacySectionConfig.cardFormat; + if (legacySectionConfig.isPlayed !== undefined && legacySectionConfig.isPlayed !== null) { + found.section.isPlayed = legacySectionConfig.isPlayed; + } + if (legacySectionConfig.minimumItems !== undefined) found.section.minimumItems = legacySectionConfig.minimumItems; + if (legacySectionConfig.sortOrder !== undefined) found.section.sortOrder = legacySectionConfig.sortOrder; + if (legacySectionConfig.sortOrderDirection !== undefined) found.section.sortOrderDirection = legacySectionConfig.sortOrderDirection; + + LOG(` Migrated discovery section: ${sectionKey}`); + } else { + WARN(` Discovery section not found in defaults: ${sectionKey}`); + } + }); + } + + /** + * Migrate CUSTOM sections + */ + function migrateCustomSections(legacyHomeScreen, newConfig) { + if (!legacyHomeScreen.customSections || !Array.isArray(legacyHomeScreen.customSections)) { + return; + } + + LOG('Migrating CUSTOM sections...'); + + let customGroup = newConfig.CUSTOM_SECTION_GROUPS.find(g => g.name === 'Custom Sections' || g.id === 'custom-sections'); + if (!customGroup) { + customGroup = { + id: 'custom-sections', + name: 'Custom Sections', + sections: [] + }; + newConfig.CUSTOM_SECTION_GROUPS.push(customGroup); + } + + legacyHomeScreen.customSections.forEach(legacySection => { + const query = convertLegacyTypeToQuery(legacySection.type, legacySection.source, legacySection); + + const customSection = { + id: `custom-${Date.now()}-${Math.random()}`, + name: legacySection.name || 'Custom Section', + enabled: legacySection.enabled !== false, + order: legacySection.order, + cardFormat: legacySection.cardFormat || 'Poster', + queries: [query] + }; + + // Map _parentItemType based on type + if (legacySection.type === 'Playlist') { + customSection._parentItemType = 'Playlist'; + } else if (legacySection.type === 'Collection') { + customSection._parentItemType = 'Collection'; + } else if (legacySection.type === 'Parent') { + customSection._parentItemType = 'Generic Parent'; + } + // Tag and Genre don't need _parentItemType + + // Map additional fields + if (legacySection.renderMode !== undefined) { + customSection.renderMode = legacySection.renderMode; + } else if (legacySection.spotlight === true) { + customSection.renderMode = 'Spotlight'; + } + if (legacySection.discoveryEnabled !== undefined) customSection.discoveryEnabled = legacySection.discoveryEnabled; + if (legacySection.spotlightConfig !== undefined) customSection.spotlightConfig = legacySection.spotlightConfig; + if (legacySection.overflowCard !== undefined) customSection.overflowCard = legacySection.overflowCard; + + customGroup.sections.push(customSection); + LOG(` Migrated custom section: ${customSection.id}`); + }); + } + + // Expose migration function + window.migrateHomeScreenConfig = migrateHomeScreenConfig; + + LOG('HomeScreen migration module loaded'); + +})(); diff --git a/configuration/homeScreen-user-configuration.js b/configuration/homeScreen-user-configuration.js new file mode 100644 index 0000000..e6117f7 --- /dev/null +++ b/configuration/homeScreen-user-configuration.js @@ -0,0 +1,650 @@ +// KefinTweaks User Home Screen Configuration +// Allows users to customize their home screen by re-ordering and enabling/disabling sections + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks User HomeScreen Config]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks User HomeScreen Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks User HomeScreen Config]', ...args); + + // All Jellyfin home section base IDs (homesection0-8) + const JELLYFIN_HOME_SECTIONS = [ + 'smalllibrarytiles', + 'librarybuttons', + 'resume', + 'resumeaudio', + 'resumebook', + 'nextup', + 'latestmedia', + 'livetv', + 'activerecordings' + ]; + + // Map Jellyfin sections to their KefinTweaks equivalents + // When a KefinTweaks section exists, it replaces the Jellyfin section in the UI + const JELLYFIN_HOME_SECTIONS_MAP = { + 'resume': 'continueWatching', + 'nextup': 'nextUp', + 'latestmedia': 'recently-added*' // Pattern match - any section starting with 'recently-added-' + }; + + /** + * Flatten section groups into a flat array + */ + 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 Jellyfin default sections from display preferences + */ + function getJellyfinSectionsFromPrefs(displayPrefs) { + const sections = []; + const customPrefs = displayPrefs?.CustomPrefs || {}; + + // Build a set of all base IDs that appear in any homesectionN field (0-8) + const enabledBaseIds = new Set(); + for (let i = 0; i <= 8; i++) { + const homesectionValue = customPrefs[`homesection${i}`]; + if (homesectionValue && homesectionValue !== '' && homesectionValue !== 'none') { + enabledBaseIds.add(homesectionValue); + } + } + + // Parse kefinHomeScreen if it's a string (Jellyfin stores CustomPrefs as strings) + let kefinHomeScreen = []; + if (customPrefs.kefinHomeScreen) { + if (typeof customPrefs.kefinHomeScreen === 'string') { + try { + kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen); + } catch (e) { + // Ignore parse errors, use empty array + } + } else if (Array.isArray(customPrefs.kefinHomeScreen)) { + kefinHomeScreen = customPrefs.kefinHomeScreen; + } + } + + // Process each Jellyfin section + JELLYFIN_HOME_SECTIONS.forEach((baseId, index) => { + const homesectionKey = `homesection${index}`; + + // Section is enabled if its base ID appears in ANY homesectionN field (0-8) + const isEnabled = enabledBaseIds.has(baseId); + + // Get order and enabled status from kefinHomeScreen if available + let order = index; + let enabledFromPrefs = isEnabled; // Default to homesectionN value + + if (Array.isArray(kefinHomeScreen) && kefinHomeScreen.length > 0) { + // Look for section by base ID (new format) or homesectionKey (old format for backward compatibility) + const sectionPref = kefinHomeScreen.find(s => + s.id === baseId || s.id === homesectionKey + ); + if (sectionPref) { + if (sectionPref.order !== undefined) { + order = sectionPref.order; + } + if (sectionPref.enabled !== undefined) { + enabledFromPrefs = sectionPref.enabled; + } + } + } + + sections.push({ + id: baseId, // Use base ID directly, no -jellyfin suffix + name: getJellyfinSectionName(baseId), + enabled: enabledFromPrefs, + order: order, + hidden: false, + isJellyfin: true, + jellyfinBaseId: baseId, // Track the Jellyfin base ID + homesectionKey: homesectionKey // Keep for backward compatibility + }); + }); + + return sections; + } + + /** + * Get display name for Jellyfin section + */ + function getJellyfinSectionName(baseId) { + const names = { + 'smalllibrarytiles': 'My Media', + 'librarybuttons': 'My Media (small)', + 'resume': 'Resume', + 'resumeaudio': 'Resume Audio', + 'resumebook': 'Resume Book', + 'nextup': 'Next Up', + 'latestmedia': 'Latest Media', + 'livetv': 'Live TV', + 'activerecordings': 'Active Recordings' + }; + return names[baseId] || baseId; + } + + /** + * Load user preferences from display preferences + * @param {Object} displayPrefs - Optional display preferences object to use instead of fetching + */ + async function loadUserPreferences(displayPrefs = null) { + try { + if (!displayPrefs) { + if (!window.userHelper || !window.userHelper.getUserDisplayPreferences) { + WARN('userHelper not available'); + return { kefinHomeScreen: [], homesections: {} }; + } + + const { promise } = await window.userHelper.getUserDisplayPreferences(); + displayPrefs = await promise; + } + + if (!displayPrefs || !displayPrefs.CustomPrefs) { + return { kefinHomeScreen: [], homesections: {} }; + } + + const customPrefs = displayPrefs.CustomPrefs; + let kefinHomeScreen = []; + + // kefinHomeScreen is stored as a JSON string in CustomPrefs + if (customPrefs.kefinHomeScreen) { + if (typeof customPrefs.kefinHomeScreen === 'string') { + try { + kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen); + } catch (e) { + WARN('Failed to parse kefinHomeScreen:', e); + kefinHomeScreen = []; + } + } else if (Array.isArray(customPrefs.kefinHomeScreen)) { + // Handle legacy format (direct array) + kefinHomeScreen = customPrefs.kefinHomeScreen; + } + } + + // Extract homesectionN values + const homesections = {}; + for (let i = 0; i <= 8; i++) { + const key = `homesection${i}`; + homesections[key] = customPrefs[key] || 'none'; + } + + return { + kefinHomeScreen: Array.isArray(kefinHomeScreen) ? kefinHomeScreen : [], + homesections: homesections + }; + } catch (error) { + ERR('Error loading user preferences:', error); + return { kefinHomeScreen: [], homesections: {} }; + } + } + + /** + * Merge user preferences with sections + * @param {Array} sections - Sections from config + * @param {Object} userPrefs - User preferences object + * @param {Object} mergedConfig - Merged config from getConfig() for fallback defaults + * @param {Object} displayPrefs - Display preferences object to check homesectionN fields + */ + function mergeUserPreferences(sections, userPrefs, mergedConfig = null, displayPrefs = null) { + const kefinHomeScreen = userPrefs.kefinHomeScreen || []; + const customPrefs = displayPrefs?.CustomPrefs || {}; + + // Create maps of section-id -> order and enabled status + const orderMap = new Map(); + const enabledMap = new Map(); + kefinHomeScreen.forEach(pref => { + if (pref.id) { + if (pref.order !== undefined) { + orderMap.set(pref.id, pref.order); + } + if (pref.enabled !== undefined) { + enabledMap.set(pref.id, pref.enabled); + } + } + }); + + // Build set of base IDs that appear in ANY homesectionN field (0-8) for Jellyfin sections + const enabledJellyfinBaseIds = new Set(); + for (let i = 0; i <= 8; i++) { + const homesectionValue = customPrefs[`homesection${i}`]; + if (homesectionValue && homesectionValue !== '' && homesectionValue !== 'none') { + enabledJellyfinBaseIds.add(homesectionValue); + } + } + + // Helper to get default enabled status from config + const getDefaultEnabled = (sectionId) => { + if (!mergedConfig) return true; // Default to enabled if no config + + // Search through all section groups + const allGroups = [ + ...(mergedConfig.HOME_SECTION_GROUPS || []), + ...(mergedConfig.SEASONAL_SECTION_GROUPS || []), + ...(mergedConfig.DISCOVERY_SECTION_GROUPS || []), + ...(mergedConfig.CUSTOM_SECTION_GROUPS || []) + ]; + + for (const group of allGroups) { + if (group.sections) { + const section = group.sections.find(s => s.id === sectionId); + if (section) { + return section.enabled !== false; + } + } + } + + return true; // Default to enabled if not found + }; + + // Helper to check if a section ID is a Jellyfin section + const isJellyfinSection = (sectionId) => { + return JELLYFIN_HOME_SECTIONS.includes(sectionId); + }; + + // Helper to get the Jellyfin base ID for a KefinTweaks section that maps to Jellyfin + const getMappedJellyfinId = (sectionId) => { + // Check if this KefinTweaks section maps to a Jellyfin section + for (const [jellyfinId, kefinId] of Object.entries(JELLYFIN_HOME_SECTIONS_MAP)) { + if (jellyfinId === 'latestmedia' && sectionId.startsWith('recently-added-')) { + return jellyfinId; + } + if (kefinId === sectionId) { + return jellyfinId; + } + } + return null; + }; + + // Update sections with user preferences + return sections.map(section => { + const sectionId = section.id; + let isEnabled; + + // For Jellyfin sections, check if base ID appears in any homesectionN field + if (section.isJellyfin || isJellyfinSection(sectionId)) { + const baseId = section.jellyfinBaseId || sectionId; + isEnabled = enabledJellyfinBaseIds.has(baseId); + } else { + // For KefinTweaks sections, check if user has explicit enabled/disabled preference, otherwise use default from config + // But also check homesectionN if this section maps to a Jellyfin section + const mappedJellyfinId = getMappedJellyfinId(sectionId); + if (mappedJellyfinId && enabledJellyfinBaseIds.has(mappedJellyfinId)) { + // If mapped Jellyfin section is enabled in homesectionN, use that + isEnabled = true; + } else { + const hasUserEnabledPref = enabledMap.has(sectionId); + isEnabled = hasUserEnabledPref + ? enabledMap.get(sectionId) + : getDefaultEnabled(sectionId); + } + } + + const userOrder = orderMap.get(sectionId); + + return { + ...section, + enabled: isEnabled, + order: userOrder !== undefined ? userOrder : (section.order || 0) + }; + }); + } + + /** + * Save user preferences to display preferences + */ + async function saveUserPreferences(sections) { + try { + if (!window.userHelper || !window.userHelper.getUserDisplayPreferences || !window.userHelper.updateDisplayPreferences) { + ERR('userHelper not available'); + return false; + } + + // Get current display preferences + const { promise } = await window.userHelper.getUserDisplayPreferences(); + const displayPrefs = await promise; + + if (!displayPrefs) { + ERR('Could not load display preferences'); + return false; + } + + if (!displayPrefs.CustomPrefs) { + displayPrefs.CustomPrefs = {}; + } + + const customPrefs = displayPrefs.CustomPrefs; + + // Helper to check if a section ID is a Jellyfin section + const isJellyfinSection = (sectionId) => { + if (JELLYFIN_HOME_SECTIONS.includes(sectionId)) { + return true; + } + return getMappedJellyfinId(sectionId) !== null; + }; + + // Helper to get the Jellyfin base ID for a KefinTweaks section that maps to Jellyfin + const getMappedJellyfinId = (sectionId) => { + for (const [jellyfinId, kefinId] of Object.entries(JELLYFIN_HOME_SECTIONS_MAP)) { + if (jellyfinId === 'latestmedia' && sectionId.startsWith('recently-added-')) { + return jellyfinId; + } + if (kefinId === sectionId) { + return jellyfinId; + } + } + return null; + }; + + // Build kefinHomeScreen array with all sections (including enabled status) + // Use section IDs directly, no homesectionKey + const kefinHomeScreen = sections.map(section => ({ + id: section.id, + order: section.order || 0, + enabled: section.enabled !== false + })); + + // Jellyfin expects CustomPrefs values to be JSON strings + customPrefs.kefinHomeScreen = JSON.stringify(kefinHomeScreen); + + // Update homesectionN properties for Jellyfin sections + // First, clear all homesectionN (0-9) + for (let i = 0; i <= 9; i++) { + customPrefs[`homesection${i}`] = 'none'; + } + + // Collect sections that should be saved to homesectionN fields + const sectionsForHomesectionN = []; + + // Add non-mapped Jellyfin sections + sections.forEach(section => { + if (section.enabled !== false && (section.isJellyfin || isJellyfinSection(section.id) || section.mapsToJellyfin)) { + const baseId = section.mapsToJellyfin || section.jellyfinBaseId || section.id; + sectionsForHomesectionN.push({ + jellyfinBaseId: baseId, + order: section.order || 0 + }); + } + }); + + // Sort all sections by order + sectionsForHomesectionN.sort((a, b) => (a.order || 0) - (b.order || 0)); + + // Assign each section to homesectionN sequentially + sectionsForHomesectionN.forEach((section, index) => { + if (index <= 8) { // Only homesection0 through homesection8 + customPrefs[`homesection${index}`] = section.jellyfinBaseId; + } + }); + + // Save + const success = await window.userHelper.updateDisplayPreferences(displayPrefs); + if (success) { + LOG('User preferences saved successfully'); + } else { + ERR('Failed to save user preferences'); + } + return success; + } catch (error) { + ERR('Error saving user preferences:', error); + return false; + } + } + + /** + * Render user home sections editor + */ + async function renderUserHomeSectionsEditor(container) { + if (!container) { + ERR('Container not provided'); + return; + } + + try { + // Check dependencies + if (!window.KefinHomeScreen || !window.KefinHomeScreen.getConfig) { + ERR('KefinHomeScreen.getConfig not available'); + return; + } + + if (!window.KefinTweaksUI || !window.KefinTweaksUI.renderHomeSectionsOrderEditor) { + ERR('KefinTweaksUI.renderHomeSectionsOrderEditor not available'); + return; + } + + // Get merged config + const mergedConfig = await window.KefinHomeScreen.getConfig(); + + // Flatten all section groups + const allGroups = [ + ...(mergedConfig.HOME_SECTION_GROUPS || []), + ...(mergedConfig.SEASONAL_SECTION_GROUPS || []), + /* ...(mergedConfig.DISCOVERY_SECTION_GROUPS || []), */ + ...(mergedConfig.CUSTOM_SECTION_GROUPS || []) + ]; + + let kefinSections = flattenSectionGroups(allGroups); + + // Filter out hidden sections + kefinSections = kefinSections.filter(s => !s.hidden); + + // Filter out disabled sections + kefinSections = kefinSections.filter(s => s.enabled !== false); + + // Filter out discovery sections + kefinSections = kefinSections.filter(s => s.type !== 'discovery'); + + // Filter out discovery sections + kefinSections = kefinSections.filter(s => s.discoveryEnabled !== true); + + // Get display preferences once + if (!window.userHelper || !window.userHelper.getUserDisplayPreferences) { + ERR('userHelper not available'); + container.innerHTML = '
User helper not available.
'; + return; + } + + const { promise } = await window.userHelper.getUserDisplayPreferences(); + const displayPrefs = await promise; + + // Load user preferences using the fetched displayPrefs + const userPrefs = await loadUserPreferences(displayPrefs); + + // Get Jellyfin sections from display preferences + let jellyfinSections = getJellyfinSectionsFromPrefs(displayPrefs); + + // Mark KefinTweaks sections that map to Jellyfin sections + kefinSections = kefinSections.map(section => { + const mappedJellyfinId = Object.entries(JELLYFIN_HOME_SECTIONS_MAP).find(([jellyfinId, kefinId]) => { + if (jellyfinId === 'latestmedia' && section.id && section.id.startsWith('recently-added-')) { + return true; + } + return kefinId === section.id; + }); + + if (mappedJellyfinId) { + return { + ...section, + mapsToJellyfin: mappedJellyfinId[0] // Store the Jellyfin base ID + }; + } + return section; + }); + + // Filter out mapped Jellyfin sections if their KefinTweaks equivalent exists + const kefinSectionIds = new Set(kefinSections.map(s => s.id)); + const hasRecentlyAdded = kefinSections.some(s => s.id && s.id.startsWith('recently-added-')); + + jellyfinSections = jellyfinSections.filter(jellyfinSection => { + const baseId = jellyfinSection.jellyfinBaseId || jellyfinSection.id; + const mappedKefinId = JELLYFIN_HOME_SECTIONS_MAP[baseId]; + + // For latestmedia, check if any recently-added-* section exists + if (baseId === 'latestmedia') { + return !hasRecentlyAdded; // Remove if any recently-added exists + } + + // For other mapped sections, check if the mapped KefinTweaks section exists + if (mappedKefinId) { + return !kefinSectionIds.has(mappedKefinId); // Remove if mapped Kefin section exists + } + + return true; // Keep non-mapped sections + }); + + // Combine all sections + let allSections = [...kefinSections, ...jellyfinSections]; + + // Merge user preferences (pass mergedConfig for fallback defaults and displayPrefs for homesectionN checks) + allSections = mergeUserPreferences(allSections, userPrefs, mergedConfig, displayPrefs); + + // Sort by order + allSections.sort((a, b) => (a.order || 0) - (b.order || 0)); + + // Set any section types that aren't "seasonal" to "home" + allSections.forEach(section => { + if (section.type && section.type.toLowerCase() !== 'seasonal') { + section.type = 'home'; + } + }); + + // Create editor HTML + const editorHTML = window.KefinTweaksUI.renderHomeSectionsOrderEditor(allSections, { + showUpDownButtons: true + }); + + container.innerHTML = ` +
+

Home Sections Order

+ ${editorHTML} +
+ `; + + // Setup event listeners + // Find the sections-container which wraps both enabled and disabled lists + const editorContainer = container.querySelector('.sections-container') || container.querySelector('.group-sections-list'); + if (editorContainer && window.KefinTweaksUI.setupOrderEditorListeners) { + // Track current section state + let currentSections = [...allSections]; + let isSaving = false; + + // Extract save logic into reusable function + const performSave = async () => { + if (isSaving) return; // Prevent concurrent saves + isSaving = true; + + try { + // Read DOM order and update sections array + // Only read order from enabled sections list (disabled sections preserve their order) + const enabledList = container.querySelector('.enabled-sections-list') || editorContainer.querySelector('.enabled-sections-list'); + const sectionIdToOrder = new Map(); + + if (enabledList) { + // Get all section rows in enabled list in their current DOM order + const sectionRows = Array.from(enabledList.querySelectorAll('.section-row')); + + // Assign order values starting from 1, incrementing by 1 for each section in DOM order + sectionRows.forEach((row, index) => { + const sectionId = row.dataset.sectionId; + if (sectionId) { + sectionIdToOrder.set(sectionId, index + 1); + } + }); + } + + // Update order values in currentSections based on DOM order (only for enabled sections) + currentSections.forEach(section => { + const domOrder = sectionIdToOrder.get(section.id); + if (domOrder !== undefined) { + // Only update order if section is in enabled list + section.order = domOrder; + } + // Disabled sections keep their existing order value (not updated) + }); + + await saveUserPreferences(currentSections); + } finally { + isSaving = false; + } + }; + + const handleToggleChange = async (sectionId, enabled) => { + const section = currentSections.find(s => (s.id || '') === sectionId); + if (section) { + section.enabled = enabled; + } + // Auto-save when toggle changes + await performSave(); + }; + + // Setup listeners with auto-save on order changes + window.KefinTweaksUI.setupOrderEditorListeners(editorContainer, { + onOrderChange: null, // Don't update order values during drag/drop, we'll read from DOM + onToggleChange: handleToggleChange, + getSectionOrder: null, // Not needed since we're not calculating orders + sections: currentSections, + onSave: performSave // Pass save function to be called on drag/drop and up/down + }); + } + + LOG('User home sections editor rendered'); + } catch (error) { + ERR('Error rendering user home sections editor:', error); + container.innerHTML = '
Error loading editor. Please refresh the page.
'; + } + } + + // Register onViewPage handler for mypreferenceshome page + function registerPreferencesPageHandler() { + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { + setTimeout(registerPreferencesPageHandler, 1000); + return; + } + + window.KefinTweaksUtils.onViewPage(async (view, element, hash) => { + // Check if we're on the mypreferenceshome page + LOG('Preferences home page detected'); + + const form = document.querySelector('.libraryPage:not(.hide) .homeScreenSettingsContainer > form'); + if (form && form.firstChild) { + // Check if editor already rendered + if (form.querySelector('#kefin-user-home-sections-editor')) { + return; + } + + // Create container for editor + const editorContainer = document.createElement('div'); + editorContainer.id = 'kefin-user-home-sections-editor'; + + // Insert as last child of the form + form.appendChild(editorContainer); + + // Render editor + await renderUserHomeSectionsEditor(editorContainer); + } + }, { + pages: ['mypreferenceshome', 'userpreferences'] + }); + + LOG('Registered preferences page handler'); + } + + // Initialize + registerPreferencesPageHandler(); + + // Expose render function globally + window.KefinUserHomeScreenConfig = { + renderUserHomeSectionsEditor + }; + + LOG('User Home Screen Configuration loaded'); + +})(); diff --git a/configuration/search-configuration.js b/configuration/search-configuration.js new file mode 100644 index 0000000..3b95fcf --- /dev/null +++ b/configuration/search-configuration.js @@ -0,0 +1,156 @@ +// KefinTweaks Search Configuration UI +// Modular configuration modal for managing search settings + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Search Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks Search Config]', ...args); + + const MODAL_ID = 'kefin-search-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.search || {}; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(searchConfig) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update search config + window.KefinTweaksConfig.search = searchConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + return ` +
+
+
+
Enable Jellyseerr
+
Enable Jellyseerr integration for request functionality
+ +
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + const enableJellyseerr = document.getElementById('search_enableJellyseerr')?.checked === true; + return { + enableJellyseerr: enableJellyseerr + }; + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + const modalInstance = window.ModalSystem.create({ + id: MODAL_ID, + title: 'Search Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-search-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const searchConfig = collectConfig(); + const success = await saveConfig(searchConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Search configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + modalInstance.dialog.style.maxWidth = '90vw'; + modalInstance.dialog.style.width = '1400px'; + modalInstance.dialog.style.height = '90vh'; + + LOG('Search configuration modal opened'); + } catch (error) { + ERR('Error opening search config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.search = { openConfigModal }; + + console.log('[KefinTweaks Search Config] Script loaded'); + +})(); + diff --git a/configuration/seriesEpisodes-configuration.js b/configuration/seriesEpisodes-configuration.js new file mode 100644 index 0000000..80233b6 --- /dev/null +++ b/configuration/seriesEpisodes-configuration.js @@ -0,0 +1,157 @@ +// KefinTweaks Series Episodes Configuration UI +// Modular configuration modal for managing series episodes settings + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SeriesEpisodes Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SeriesEpisodes Config]', ...args); + + const MODAL_ID = 'kefin-seriesepisodes-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.flattenSingleSeasonShows || {}; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(flattenShowsConfig) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update flattenSingleSeasonShows config + window.KefinTweaksConfig.flattenSingleSeasonShows = flattenShowsConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + const hideSingleSeasonContainer = config.hideSingleSeasonContainer === true; + return ` +
+
+
+
Flatten Single Season Shows
+
When enabled, hides the season container section when a series has only one season, since episodes are already displayed directly.
+ +
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + const hideSingleSeasonContainer = document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true; + return { + hideSingleSeasonContainer: hideSingleSeasonContainer + }; + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + const modalInstance = window.ModalSystem.create({ + id: MODAL_ID, + title: 'Episodes On Series Page Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-seriesepisodes-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const flattenShowsConfig = collectConfig(); + const success = await saveConfig(flattenShowsConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + modalInstance.dialog.style.maxWidth = '90vw'; + modalInstance.dialog.style.width = '1400px'; + modalInstance.dialog.style.height = '90vh'; + + LOG('Series Episodes configuration modal opened'); + } catch (error) { + ERR('Error opening series episodes config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.seriesEpisodes = { openConfigModal }; + + console.log('[KefinTweaks SeriesEpisodes Config] Script loaded'); + +})(); + diff --git a/configuration/seriesInfo-configuration.js b/configuration/seriesInfo-configuration.js new file mode 100644 index 0000000..50feace --- /dev/null +++ b/configuration/seriesInfo-configuration.js @@ -0,0 +1,157 @@ +// KefinTweaks Series Episodes Configuration UI +// Modular configuration modal for managing series episodes settings + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SeriesInfo Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SeriesInfo Config]', ...args); + + const MODAL_ID = 'kefin-seriesinfo-config'; + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + return config.seriesInfo || {}; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(seriesInfoConfig) { + try { + // Ensure window.KefinTweaksConfig exists + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update flattenSingleSeasonShows config + window.KefinTweaksConfig.seriesInfo = seriesInfoConfig; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + const use24HourTimeFormat = config.use24HourTimeFormat === true; + return ` +
+
+
+
Use 24 Hour Time Format
+
When enabled, uses a 24 hour time format for the series info.
+ +
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + const use24HourTimeFormat = document.getElementById('seriesInfo_use24HourTimeFormat')?.checked === true; + return { + use24HourTimeFormat: use24HourTimeFormat + }; + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + const modalInstance = window.ModalSystem.create({ + id: MODAL_ID, + title: 'Series Info Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-seriesinfo-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const seriesInfoConfig = collectConfig(); + const success = await saveConfig(seriesInfoConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + modalInstance.dialog.style.maxWidth = '90vw'; + modalInstance.dialog.style.width = '1400px'; + modalInstance.dialog.style.height = '90vh'; + + LOG('Series Info configuration modal opened'); + } catch (error) { + ERR('Error opening series info config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.seriesInfo = { openConfigModal }; + + console.log('[KefinTweaks SeriesInfo Config] Script loaded'); + +})(); + diff --git a/configuration/skinManager-configuration.js b/configuration/skinManager-configuration.js new file mode 100644 index 0000000..828acb7 --- /dev/null +++ b/configuration/skinManager-configuration.js @@ -0,0 +1,649 @@ +// KefinTweaks Skin Manager Configuration UI +// Modular configuration modal for managing skins, themes, and optional includes + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks SkinManager Config]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SkinManager Config]', ...args); + + const MODAL_ID = 'kefin-skinmanager-config'; + + // Store loaded config for use in listeners + let currentLoadedConfig = null; + + // Helper function to build Jellyfin checkbox + function buildJellyfinCheckbox(id, checked, label, dataAttributes = {}) { + let className = 'emby-checkbox'; + const otherAttrs = {}; + + for (const [key, value] of Object.entries(dataAttributes)) { + if (key === 'class') { + className += ' ' + value; + } else { + otherAttrs[key] = value; + } + } + + const dataAttrs = Object.entries(otherAttrs).map(([key, value]) => `${key}="${value}"`).join(' '); + const checkedAttr = checked ? 'checked' : ''; + return ` +
+ +
+ `; + } + + // Extract GitHub repository URL from skin configuration + function getSkinGitHubUrl(skin) { + if (skin.github) { + return skin.github; + } + + if (skin.url && Array.isArray(skin.url)) { + for (const urlObj of skin.url) { + if (urlObj.urls && Array.isArray(urlObj.urls)) { + for (const url of urlObj.urls) { + const match = url.match(/https:\/\/cdn\.jsdelivr\.net\/gh\/([^\/]+)\/([^\/@]+)/); + if (match) { + const owner = match[1]; + const repo = match[2]; + return `https://github.com/${owner}/${repo}`; + } + } + } + } + } + + return null; + } + + // Build skin toggle switches + function buildSkinToggles(skins, skinSources = {}) { + return skins.map(skin => { + const isEnabled = skin.enabled !== false; + const githubUrl = getSkinGitHubUrl(skin); + const source = skinSources[skin.name] || 'default'; + + let borderStyle = '1px solid rgba(255,255,255,0.1)'; + let backgroundStyle = 'rgba(255,255,255,0.02)'; + let badgeText = ''; + let badgeStyle = ''; + let badgeIcon = ''; + + if (source === 'default') { + backgroundStyle = 'rgba(255,255,255,0.01)'; + badgeText = 'D'; + badgeStyle = 'background: rgba(0,122,255,0.2); color: rgba(0,122,255,0.9);'; + } else if (source === 'overridden') { + borderStyle = '2px solid rgba(0, 122, 255, 0.5)'; + backgroundStyle = 'rgba(0,122,255,0.05)'; + badgeIcon = 'warning'; + badgeStyle = 'color: rgba(255,165,0,0.9);'; + } + + let tooltipText = ''; + if (source === 'default') { + tooltipText = 'This skin is included automatically by KefinTweaks'; + } else if (source === 'overridden') { + tooltipText = 'This default skin has been overridden by a custom configuration'; + } else if (githubUrl) { + tooltipText = 'View on GitHub'; + } + + return ` +
+
+
+ ${githubUrl ? ` + + ${skin.name} + info + + ` : ` +
${skin.name}
+ `} + ${badgeIcon ? `${badgeIcon}` : ''} + ${badgeText ? `${badgeText}` : ''} +
+ ${buildJellyfinCheckbox(`skin_${skin.name.replace(/[^a-zA-Z0-9]/g, '_')}`, isEnabled, '', { 'data-skin-name': skin.name })} +
+
+ `; + }).join(''); + } + + // Build optional includes skin options + function buildOptionalIncludesSkinOptions(skins) { + const skinsWithOptionalIncludes = skins.filter(skin => + skin.optionalIncludes && skin.optionalIncludes.length > 0 + ); + + return skinsWithOptionalIncludes.map(skin => + `` + ).join(''); + } + + // Extract author from URL + function extractAuthorFromUrl(url) { + if (!url) return ''; + try { + const jsdelivrMatch = url.match(/cdn\.jsdelivr\.net\/gh\/([^\/]+)\//); + if (jsdelivrMatch) { + return jsdelivrMatch[1]; + } + } catch (e) { + // Ignore errors + } + return ''; + } + + // Generate optional include key + function generateOptionalIncludeKey(skinName, author, url) { + const normalizedSkinName = skinName === 'global' ? 'global' : skinName.replace(/\s+/g, '_'); + const filename = url ? url.split('?')[0].split('#')[0].split('/').pop() : ''; + const normalizedAuthor = (author || '').replace(/\s+/g, '_'); + return `${normalizedSkinName}-${normalizedAuthor}-${filename}`; + } + + // Extract filename from URL + function extractFilenameFromUrl(url) { + if (!url) return ''; + try { + const urlWithoutParams = url.split('?')[0].split('#')[0]; + const pathParts = urlWithoutParams.split('/'); + return pathParts[pathParts.length - 1] || ''; + } catch (e) { + return ''; + } + } + + // Build optional includes editor (simplified version) + function buildOptionalIncludesEditor(category, config, skins) { + let optionalIncludes = []; + + if (category === 'global') { + const defaultGlobalIncludes = window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || []; + optionalIncludes = defaultGlobalIncludes.map(include => ({ ...include })); + + const adminOptionalIncludes = config.optionalIncludes || []; + adminOptionalIncludes.forEach(adminEntry => { + if (typeof adminEntry === 'object' && adminEntry.key) { + const keyParts = adminEntry.key.split('-'); + if (keyParts[0] === 'global' && keyParts.length >= 3) { + const author = keyParts[1]; + const filename = keyParts.slice(2).join('-'); + + const matchingInclude = optionalIncludes.find(inc => { + const incAuthor = extractAuthorFromUrl(inc.url); + const incFilename = extractFilenameFromUrl(inc.url); + return incAuthor === author && incFilename === filename; + }); + + if (matchingInclude) { + matchingInclude.enabled = adminEntry.enabled; + } + } + } + }); + } else { + const skin = skins.find(s => s.name === category); + if (skin && skin.optionalIncludes) { + optionalIncludes = skin.optionalIncludes.map(include => { + const author = extractAuthorFromUrl(include.url); + const key = generateOptionalIncludeKey(category, author, include.url); + + const adminOptionalIncludes = config.optionalIncludes || []; + const adminEntry = adminOptionalIncludes.find(entry => + typeof entry === 'object' && entry.key === key + ); + + return { + ...include, + key: key, + enabled: adminEntry ? adminEntry.enabled : (include.enabled || false) + }; + }); + } + } + + if (optionalIncludes.length === 0) { + return ` +
+ No optional CSS modules available for ${category === 'global' ? 'global options' : category}. +
+ `; + } + + return ` +
+ Configure which optional CSS modules are enabled by default. Users can override these settings individually. +
+
+ ${optionalIncludes.map((include, index) => { + const author = extractAuthorFromUrl(include.url); + const key = include.key || generateOptionalIncludeKey(category, author, include.url); + const filename = include.url ? extractFilenameFromUrl(include.url) : ''; + + return ` +
+
+
+
+
${include.name}
+ ${author ? `` : ''} +
+ +
+ ${filename ? `
${filename}
` : ''} +
+
+ `; + }).join('')} +
+ `; + } + + /** + * Load configuration from window.KefinTweaksConfig + */ + async function loadConfig() { + const config = window.KefinTweaksConfig || {}; + + // Reconstruct full skin list (enabled + disabled) for configuration + const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || []; + const adminSkins = config.skins || []; + + const mergedSkins = []; + + // Add all default skins first + defaultSkins.forEach(skin => { + mergedSkins.push({ ...skin, enabled: true }); + }); + + // Override/Add with admin skins + adminSkins.forEach(adminSkin => { + const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name); + if (existingIndex >= 0) { + mergedSkins[existingIndex] = { ...mergedSkins[existingIndex], ...adminSkin }; + } else { + mergedSkins.push({ ...adminSkin }); + } + }); + + return { + skins: mergedSkins, + themes: config.themes || [], + defaultSkin: config.defaultSkin || null, + optionalIncludes: config.optionalIncludes || [] + }; + } + + /** + * Save configuration to JS Injector + */ + async function saveConfig(skinManagerConfig) { + try { + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + + // Update skin manager config + window.KefinTweaksConfig.skins = skinManagerConfig.skins; + window.KefinTweaksConfig.themes = skinManagerConfig.themes; + window.KefinTweaksConfig.defaultSkin = skinManagerConfig.defaultSkin; + window.KefinTweaksConfig.optionalIncludes = skinManagerConfig.optionalIncludes; + + // Save to JS Injector using utils + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(); + LOG('Configuration saved to JS Injector'); + return true; + } else { + // Fallback: use findJavaScriptInjectorPlugin and saveConfigToJavaScriptInjector from configuration.js + if (window.KefinTweaksConfiguration && window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin) { + const pluginId = await window.KefinTweaksConfiguration.findJavaScriptInjectorPlugin(); + if (pluginId) { + await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig); + LOG('Configuration saved to JS Injector (fallback)'); + return true; + } + } + ERR('saveConfigToJavaScriptInjector not available'); + return false; + } + } catch (error) { + ERR('Error saving config:', error); + return false; + } + } + + /** + * Build configuration HTML + */ + function buildConfigHTML(config) { + const allSkinNames = config.skins.map(skin => skin.name).filter(Boolean); + const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {}; + const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden'); + + return ` +
+ +
+
+
Default Skin
+ +
Select a default skin for all users
+
+
+ + +
+
+

Enable/Disable Skins

+
Disabled skins will not appear in the appearance dropdowns for users
+
+ All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations. + KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility. +
+
+ ${buildSkinToggles(config.skins, skinSources)} +
+
+
+ + ${hasOverriddenSkins ? ` +
+
+
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
+ +
+
+ ` : ''} + + +
+
+

Optional CSS Modules

+
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
+
Select Skin
+ +
+ ${buildOptionalIncludesEditor('global', { optionalIncludes: config.optionalIncludes }, config.skins)} +
+
+
+ + +
+ + chevron_right + Skins JSON + +
+
+
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
+ +
+
+
+ + +
+ + chevron_right + Themes JSON + +
+
+
Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.
+ +
+
+
+
+ `; + } + + /** + * Collect configuration from form + */ + function collectConfig() { + // Get default skin + const defaultSkin = document.getElementById('defaultSkin')?.value || null; + if (defaultSkin === '') { + defaultSkin = null; + } + + // Get skin enabled states + const skinEnabledStates = {}; + document.querySelectorAll('[data-skin-name]').forEach(checkbox => { + const skinName = checkbox.getAttribute('data-skin-name'); + skinEnabledStates[skinName] = checkbox.checked; + }); + + // Get default skin names to filter them out + const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name)); + + // Parse skins JSON + let skinsFromJson = []; + try { + const skinsJsonText = document.getElementById('skinsJson')?.value || '[]'; + skinsFromJson = JSON.parse(skinsJsonText); + } catch (e) { + ERR('Error parsing skins JSON:', e); + } + + // Only save admin skins (from JSON textarea) - filter out any that match default names + const customSkins = skinsFromJson + .filter(skin => !defaultSkinNames.has(skin.name)) + .map(skin => ({ + ...skin, + enabled: skinEnabledStates[skin.name] !== false, + hidden: false + })); + + // Handle disabled default skins + const disabledDefaultSkins = []; + defaultSkinNames.forEach(name => { + if (skinEnabledStates[name] === false) { + disabledDefaultSkins.push({ + name: name, + enabled: false + }); + } + }); + + // Combine custom skins and disabled default skins + const skins = [...customSkins, ...disabledDefaultSkins]; + + // Parse themes JSON + let themes = []; + try { + const themesJsonText = document.getElementById('themesJson')?.value || '[]'; + themes = JSON.parse(themesJsonText); + } catch (e) { + ERR('Error parsing themes JSON:', e); + } + + // Collect optional includes for the currently selected category only + const categorySelect = document.getElementById('optionalIncludesCategory'); + const currentCategory = categorySelect ? categorySelect.value : 'global'; + + // Get existing optional includes from config + const existingOptionalIncludes = (window.KefinTweaksConfig?.optionalIncludes || []).slice(); + + // Collect only the optional includes for the current category + const currentCategoryOptionalIncludes = []; + document.querySelectorAll('.optionalIncludeCheckbox').forEach(checkbox => { + const category = checkbox.getAttribute('data-category'); + const key = checkbox.getAttribute('data-key'); + const enabled = checkbox.checked; + + // Only collect if it matches the current category + if (category === currentCategory) { + currentCategoryOptionalIncludes.push({ + key: key, + enabled: enabled + }); + } + }); + + // Merge: remove existing entries for this category, then add new ones + const optionalIncludes = existingOptionalIncludes.filter(entry => { + if (typeof entry === 'object' && entry.key) { + const keyParts = entry.key.split('-'); + const entryCategory = keyParts[0]; + return entryCategory !== currentCategory; + } + return true; + }); + + // Add the current category's optional includes + optionalIncludes.push(...currentCategoryOptionalIncludes); + + return { + skins: skins, + themes: themes, + defaultSkin: defaultSkin, + optionalIncludes: optionalIncludes + }; + } + + /** + * Attach event listeners + */ + function attachListeners(modalInstance) { + // Optional includes category change + const categorySelect = modalInstance.dialogContent.querySelector('#optionalIncludesCategory'); + if (categorySelect) { + categorySelect.addEventListener('change', () => { + const category = categorySelect.value; + const editor = modalInstance.dialogContent.querySelector('#optionalIncludesEditor'); + if (editor) { + // Use saved config from window.KefinTweaksConfig to get the actual saved optionalIncludes + // This ensures the UI reflects the saved state, not just what's currently visible + const savedOptionalIncludes = window.KefinTweaksConfig?.optionalIncludes || []; + const config = { optionalIncludes: savedOptionalIncludes }; + const mergedSkins = currentLoadedConfig ? currentLoadedConfig.skins : collectConfig().skins; + editor.innerHTML = buildOptionalIncludesEditor(category, config, mergedSkins); + attachListeners(modalInstance); // Re-attach listeners for new checkboxes + } + }); + } + + // Remove duplicate defaults button + const removeBtn = modalInstance.dialogContent.querySelector('#removeDuplicateDefaultsBtn'); + if (removeBtn) { + removeBtn.addEventListener('click', () => { + // This would need to be implemented to remove overridden skins + alert('This feature will remove custom skins that override defaults. Implementation needed.'); + }); + } + } + + /** + * Open configuration modal + */ + async function openConfigModal() { + try { + // Load config + const config = await loadConfig(); + + // Store loaded config for use in listeners + currentLoadedConfig = config; + + // Build content + const content = document.createElement('div'); + content.innerHTML = buildConfigHTML(config); + + // Build footer + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.justifyContent = 'flex-end'; + footer.innerHTML = ` + + + `; + + // Create modal + const modalInstance = window.ModalSystem.create({ + id: MODAL_ID, + title: 'Skin Manager Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + showCloseButton: true, + onOpen: (modal) => { + attachListeners(modal); + + // Save button + const saveBtn = modal.dialogFooter.querySelector('#save-skinmanager-config-btn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const skinManagerConfig = collectConfig(); + const success = await saveConfig(skinManagerConfig); + if (success) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Skin Manager configuration saved!'); + } + window.ModalSystem.close(MODAL_ID); + } else { + alert('Error saving configuration. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.'); + } + }); + } + } + }); + + modalInstance.dialog.style.maxWidth = '90vw'; + modalInstance.dialog.style.width = '1400px'; + modalInstance.dialog.style.height = '90vh'; + + LOG('Skin Manager configuration modal opened'); + } catch (error) { + ERR('Error opening skin manager config modal:', error); + alert('Error opening configuration: ' + error.message); + } + } + + // Export + window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {}; + window.KefinTweaksFeatureConfigs.skinManager = { openConfigModal }; + + console.log('[KefinTweaks SkinManager Config] Script loaded'); + +})(); + diff --git a/injector.js b/injector.js index 9d0e465..798e740 100644 --- a/injector.js +++ b/injector.js @@ -63,7 +63,6 @@ search: true, // Enhanced search functionality headerTabs: true, // Header tab enhancements customMenuLinks: true, // Custom menu links functionality - exclusiveElsewhere: true, // Exclusive elsewhere branding updoot: false, // Upvote functionality backdropLeakFix: true, // Memory leak fixes dashboardButtonFix: true, // Dashboard button fix @@ -107,6 +106,104 @@ dependencies: [], description: 'Default skin configuration for KefinTweaks' }, + { + name: 'ui', + script: 'ui.js', + css: null, + dependencies: [], + description: 'UI helper functions for KefinTweaks' + }, + { + name: 'homeScreenConfig', + script: 'homeScreenConfig.js', + css: null, + dependencies: [], + description: 'Default home screen configuration for KefinTweaks' + }, + { + name: 'homeScreenConfig2', + script: 'homeScreenConfig2.js', + css: null, + dependencies: [], + description: 'Default home screen configuration for KefinTweaks' + }, + { + name: 'homeScreen-migration', + script: '../configuration/homeScreen-migration.js', + css: null, + dependencies: [], + description: 'Home screen migration for KefinTweaks' + }, + { + name: 'homeScreen-configuration', + script: '../configuration/homeScreen-configuration.js', + css: '../configuration/homeScreen-configuration.css', + dependencies: ['modal', 'toaster', 'utils', 'cardBuilder', 'ui'], + description: 'Home screen configuration for KefinTweaks' + }, + { + name: 'search-configuration', + script: '../configuration/search-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Search configuration for KefinTweaks' + }, + { + name: 'seriesEpisodes-configuration', + script: '../configuration/seriesEpisodes-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Series episodes configuration for KefinTweaks' + }, + { + name: 'seriesInfo-configuration', + script: '../configuration/seriesInfo-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Series info configuration for KefinTweaks' + }, + { + name: 'skinManager-configuration', + script: '../configuration/skinManager-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Skin manager configuration for KefinTweaks' + }, + { + name: 'customMenuLinks-configuration', + script: '../configuration/customMenuLinks-configuration.js', + css: null, + dependencies: ['modal', 'toaster', 'utils'], + description: 'Custom menu links configuration for KefinTweaks' + }, + { + name: 'homeScreenConfigCommunity', + script: 'homeScreenConfig-community.js', + css: null, + dependencies: [], + description: 'Community home screen sections for KefinTweaks' + }, + { + name: 'peopleCache', + script: 'peopleCache.js', + css: null, + dependencies: [], + description: 'People cache for KefinTweaks' + }, + { + name: 'studiosCache', + script: 'studiosCache.js', + css: null, + dependencies: [], + description: 'Studios cache for KefinTweaks' + }, + { + name: 'moviesCache', + script: 'moviesCache.js', + css: null, + dependencies: [], + description: 'Movies cache for KefinTweaks' + }, { name: 'skinManager', script: 'skinManager.js', @@ -119,14 +216,14 @@ name: 'apiHelper', script: 'apiHelper.js', css: null, - dependencies: [], + dependencies: ['dataHelper'], description: 'API helper functions for common Jellyfin operations' }, { name: 'cardBuilder', script: 'cardBuilder.js', css: 'cardBuilder.css', - dependencies: ['apiHelper'], + dependencies: ['apiHelper', 'userHelper'], description: 'Core card building functionality (required by other scripts)' }, { @@ -150,6 +247,27 @@ dependencies: [], description: 'Generic modal system for Jellyfin-style dialogs' }, + { + name: 'userHelper', + script: 'userHelper.js', + css: null, + dependencies: [], + description: 'User helper functions for Jellyfin operations' + }, + { + name: 'dataHelper', + script: '../helpers/dataHelper.js', + css: null, + dependencies: [], + description: 'Data helper functions for Jellyfin operations' + }, + { + name: 'websocketHelper', + script: '../helpers/websocketHelper.js', + css: null, + dependencies: [], + description: 'WebSocket helper functions for Jellyfin operations' + }, { name: 'toaster', script: 'toaster.js', @@ -166,9 +284,10 @@ }, { name: 'homeScreen', - script: 'homeScreen.js', + script: 'homeScreen3.js', css: 'homeScreen.css', - dependencies: ['cardBuilder', 'localStorageCache', 'utils'], + dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'homeScreenConfig', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'websocketHelper', 'homeScreen-migration', 'homeScreen-user-configuration'], + priority: true, // Load immediately after dependencies to reduce UI disruption description: 'Adds custom home screen sections' }, { @@ -192,13 +311,6 @@ dependencies: ['utils'], description: 'Load and add custom menu links from configuration' }, - { - name: 'exclusiveElsewhere', - script: 'exclusiveElsewhere.js', - css: null, - dependencies: [], - description: 'Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality to add custom branding when items are not available on streaming services' - }, { name: 'backdropLeakFix', script: 'backdropLeakFix.js', @@ -604,7 +716,7 @@ } async function loadConfigurationJS() { - const configDependencyNames = ['modal', 'toaster', 'utils']; + const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'skinManager-configuration', 'customMenuLinks-configuration']; for (const depName of configDependencyNames) { const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName); if (depScript) { diff --git a/pages/images/faq/pending.png b/pages/images/faq/pending.png new file mode 100644 index 0000000..43ce73a Binary files /dev/null and b/pages/images/faq/pending.png differ diff --git a/pages/images/faq/publicjs.png b/pages/images/faq/publicjs.png new file mode 100644 index 0000000..585aa93 Binary files /dev/null and b/pages/images/faq/publicjs.png differ diff --git a/scripts/cardBuilder.css b/scripts/cardBuilder.css index 88c9bd6..59f9eb6 100644 --- a/scripts/cardBuilder.css +++ b/scripts/cardBuilder.css @@ -3,7 +3,7 @@ .spotlight-section { position: relative; width: calc(100% - (2 * max(env(safe-area-inset-left), 3.3%))); - margin-bottom: 2em; + margin: 2em 0; } .spotlight-banner-container { @@ -85,6 +85,9 @@ object-fit: contain; margin-bottom: 16px; margin-top: 10px; + background-size: contain; + background-repeat: no-repeat; + background-position: left; } .spotlight-clearart { @@ -408,3 +411,150 @@ height: 125px; } } + +.skeleton-card { + margin-bottom: 45px !important; +} + +[data-item-type="Movie"] .spotlight-item-logo { + height: 120px; + max-width: 310.2px; +} + +[data-item-type="Movie"] .spotlight-tagline { + -webkit-line-clamp: 1; + min-height: 30px; + max-height: 30px; +} + +// Remove this if we switch back to Jellyfin's default scroll buttons +.emby-scrollbuttons.padded-right { + -webkit-align-items: center; + align-items: center; + color: #fff; + display: -webkit-flex; + display: flex; + -webkit-justify-content: center; + justify-content: center; + min-height: 24px; + min-width: 104px; + padding-top: .85em; + position: absolute; + top: 0; + z-index: 1; +} + +/* Progressive Section Reveal Animation */ +.cardbuilder-section-reveal { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* When section enters viewport, trigger animation */ +.cardbuilder-section-reveal.in-viewport { + opacity: 1; + transform: translateY(0); +} + +.show-all-button { + margin-left: 10px; + font-size: 12px; + padding: 4px 8px; + min-width: auto; + background: transparent; + border-radius: 4px; + cursor: pointer; + align-self: center; + border: 1px solid rgba(255, 255, 255, 0.3) !important; + color: var(--main-text, #fff) !important; + position: relative; + top: -3px; + display: none; /* Hidden by default */ +} + +/* Show button when section is expandable */ +.emby-scroller-container[data-expandable="true"] .show-all-button { + display: initial !important; +} + +/* Expanded grid view styles */ +.itemsContainer[data-expanded="true"] { + display: flex !important; + flex-wrap: wrap !important; + white-space: normal !important; + transform: none !important; + transition: none !important; +} + +/* Hide scroll buttons when expanded */ +.emby-scroller-container:has(.itemsContainer[data-expanded="true"]) .emby-scrollbuttons { + display: none !important; +} + +/* Refresh Button Styles */ +.section-refresh-button { + margin-left: 8px; + padding: 4px; + min-width: auto; + width: 32px; + height: 32px; + background: transparent; + border: none; + cursor: pointer; + display: inline-block; + align-items: center; + justify-content: center; + color: var(--main-text, rgba(255, 255, 255, 0.87)); + border-radius: 50%; + font-size: 18px; + transition: transform 0.3s ease, color 0.3s ease, background 0.2s ease; + opacity: 0.65; +} + +.section-refresh-button:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* Spinning animation when refreshing */ +[data-refreshing="true"] .section-refresh-button { + animation: spin 1s linear infinite; +} + +/* Checkmark state - green color to indicate success */ +.section-refresh-button.check_circle { + animation: none; +} + +/* Smooth transition when changing icons */ +.section-refresh-button.refresh, +.section-refresh-button.check_circle { + transition: transform 0.3s ease, color 0.3s ease, background 0.2s ease; +} + +/* Fade in/out transitions for icon changes */ +.section-refresh-button::after { + transition: opacity 0.2s ease; +} + +.section-refresh-button.icon-fade-out::after { + opacity: 0; +} + +.section-refresh-button.icon-fade-in::after { + opacity: 1; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} +/* Ensure button remains visible when expanded (data-expandable takes precedence, but this ensures it) */ +.emby-scroller-container:has(.itemsContainer[data-expanded="true"]) .show-all-button { + display: initial !important; +} \ No newline at end of file diff --git a/scripts/configuration.js b/scripts/configuration.js index dbe8b81..51159a3 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -145,55 +145,67 @@ }); } + // Migrate legacy homeScreen config to new homeScreenConfig format + async function migrateLegacyHomeScreenConfig() { + return await window.migrateHomeScreenConfig(); + } + // Get KefinTweaks configuration from JS Injector or load defaults async function getKefinTweaksConfig() { try { + await migrateLegacyHomeScreenConfig(); // First, try to get config from JS Injector - const pluginId = await findJavaScriptInjectorPlugin(); +/* const pluginId = await findJavaScriptInjectorPlugin(); const injectorConfig = await getJavaScriptInjectorConfig(pluginId); // Find KefinTweaks-Config script const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( script => script.Name === 'KefinTweaks-Config' - ); + ); */ - if (kefinTweaksScript && kefinTweaksScript.Script) { +/* if (kefinTweaksScript && kefinTweaksScript.Script) { // Extract config from script content // The script should contain: window.KefinTweaksConfig = {...}; const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/); if (scriptMatch && scriptMatch[1]) { try { - const config = JSON.parse(scriptMatch[1]); + let config = JSON.parse(scriptMatch[1]); // Store enabled state globally for use in modal (defaults to true if not set) window.KefinTweaksConfigEnabled = config.enabled !== false; console.log('[KefinTweaks Configuration] Loaded config from JS Injector:', config); + + // Run migration if needed + config = await migrateLegacyHomeScreenConfig(); + return config; } catch (parseError) { console.error('[KefinTweaks Configuration] Error parsing config from script:', parseError); } } - } + } */ // If no config found in JS Injector, load defaults - console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults'); - const rawDefaultConfig = await loadDefaultConfig(); - const defaultConfig = JSON.parse(JSON.stringify(rawDefaultConfig)); +/* console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults'); + const rawDefaultConfig = await loadDefaultConfig(); + const defaultConfig = JSON.parse(JSON.stringify(rawDefaultConfig)); - const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; - const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot; - defaultConfig.kefinTweaksRoot = currentKefinRoot; - // Ensure enabled field is set (defaults to true if not present) - if (defaultConfig.enabled === undefined) { - defaultConfig.enabled = true; - } - // Store enabled state globally for use in modal - window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false; - // Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/' + const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; + const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot; + defaultConfig.kefinTweaksRoot = currentKefinRoot; + // Ensure enabled field is set (defaults to true if not present) + if (defaultConfig.enabled === undefined) { + defaultConfig.enabled = true; + } + // Store enabled state globally for use in modal + window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false; - // Save defaults to JS Injector for future use - await saveConfigToJavaScriptInjector(defaultConfig); + // Run migration if needed + const migratedConfig = await migrateLegacyHomeScreenConfig(defaultConfig); - return defaultConfig; + // Save migrated config to JS Injector for future use + await saveConfigToJavaScriptInjector(migratedConfig); */ + + return window.KefinTweaksConfig; } catch (error) { console.error('[KefinTweaks Configuration] Error getting config, falling back to defaults:', error); // Fallback to defaults if anything fails @@ -217,6 +229,432 @@ } } + // Import/Export Logic + async function showExportDialog(config) { + const customSections = config.homeScreen?.customSections || []; + const seasonalSeasons = config.homeScreen?.seasonal?.seasons || []; + + if (customSections.length === 0 && seasonalSeasons.length === 0) { + alert('No custom sections or seasonal configurations found to export.'); + return; + } + + const content = document.createElement('div'); + content.innerHTML = ` +
+
Select sections to export:
+ + ${customSections.length > 0 ? ` +
+
+

Custom Sections

+
+ + +
+
+
+ ${customSections.map((section, i) => ` + + `).join('')} +
+
` : ''} + + ${seasonalSeasons.length > 0 ? ` +
+
+

Seasonal Seasons

+
+ + +
+
+
+ ${seasonalSeasons.map((season, i) => ` + + `).join('')} +
+
` : ''} + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + `; + + window.ModalSystem.create({ + id: 'kefinTweaksExportModal', + title: 'Export Custom Sections', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + const generateBtn = modalInstance.dialogFooter.querySelector('#generateExportBtn'); + generateBtn.addEventListener('click', () => { + const selectedCustomIndices = Array.from(content.querySelectorAll('.export-custom-check:checked')).map(c => parseInt(c.dataset.index)); + const selectedSeasonalIndices = Array.from(content.querySelectorAll('.export-seasonal-check:checked')).map(c => parseInt(c.dataset.index)); + + if (selectedCustomIndices.length === 0 && selectedSeasonalIndices.length === 0) { + alert('Please select at least one item to export.'); + return; + } + + const exportData = { + meta: { + version: '1.0', + date: new Date().toISOString(), + source: 'KefinTweaks' + }, + customSections: selectedCustomIndices.map(i => customSections[i]), + seasonalSeasons: selectedSeasonalIndices.map(i => seasonalSeasons[i]) + }; + + const json = JSON.stringify(exportData, null, 2); + const outputContainer = content.querySelector('#exportOutputContainer'); + const output = content.querySelector('#exportOutput'); + + output.value = json; + outputContainer.style.display = 'block'; + generateBtn.style.display = 'none'; + + content.querySelector('#copyExportBtn').addEventListener('click', () => { + output.select(); + document.execCommand('copy'); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Copied to clipboard'); + } + }); + + content.querySelector('#downloadExportBtn').addEventListener('click', () => { + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `kefin-tweaks-export-${new Date().toISOString().slice(0,10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + }); + } + }); + } + + + async function showCommunityImportDialog(config, saveConfigCallback) { + // Ensure community config is available + if (!window.KefinCommunityConfig) { + alert('Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.'); + return; + } + + const communityCollections = window.KefinCommunityConfig.collections || []; + + const content = document.createElement('div'); + content.innerHTML = ` +
+
Select Home Screen Sections to import:
+
These sections are created and submitted by community members like you!
+
Submit your own Home Screen Sections here.
+ +
+ +
+ + + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + `; + + let selectedCollection = null; + + window.ModalSystem.create({ + id: 'kefinTweaksCommunityImportModal', + title: 'Import Community Sections', + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + onOpen: (modalInstance) => { + const select = content.querySelector('#communityCollectionSelect'); + const descContainer = content.querySelector('#collectionDescription'); + const selectionContainer = content.querySelector('#importSelectionContainer'); + const selectionList = content.querySelector('#importSelectionList'); + const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmCommunityImportBtn'); + + select.addEventListener('change', () => { + const index = select.value; + if (index === '') { + selectedCollection = null; + descContainer.style.display = 'none'; + selectionContainer.style.display = 'none'; + confirmBtn.style.display = 'none'; + return; + } + + selectedCollection = communityCollections[parseInt(index)]; + + // Show description + descContainer.textContent = selectedCollection.description || 'No description.'; + descContainer.style.display = 'block'; + + // Render sections list + let html = ''; + if (selectedCollection.sections && selectedCollection.sections.length > 0) { + html += ` +
+ ${selectedCollection.sections.map((s, i) => ` + + `).join('')} +
`; + } else { + html = '
No sections found in this collection.
'; + } + + selectionList.innerHTML = html; + selectionContainer.style.display = 'block'; + confirmBtn.style.display = 'inline-block'; + }); + + confirmBtn.addEventListener('click', async () => { + if (!selectedCollection) return; + + const selectedIndices = Array.from(content.querySelectorAll('.import-community-check:checked')).map(c => parseInt(c.dataset.index)); + + if (selectedIndices.length === 0) { + alert('Please select at least one section to import.'); + return; + } + + // Import logic + if (!config.homeScreen) config.homeScreen = {}; + if (!config.homeScreen.customSections) config.homeScreen.customSections = []; + + let importedCount = 0; + selectedIndices.forEach(i => { + const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); // Deep copy + // Regenerate ID to avoid collision + if (section.id) section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random()*1000)}`; + config.homeScreen.customSections.push(section); + importedCount++; + }); + + if (importedCount > 0) { + await saveConfigCallback(config); + alert(`Successfully imported ${importedCount} sections. The page will reload.`); + window.location.reload(); + } else { + window.ModalSystem.close('kefinTweaksCommunityImportModal'); + } + }); + } + }); + } + + async function showImportDialog(config, saveConfigCallback) { + const content = document.createElement('div'); + content.innerHTML = ` +
+
Paste JSON content or a URL (Raw GitHub, Gist, Pastebin):
+ + + +
+ `; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.innerHTML = ` + + + + `; + + let parsedData = null; + + window.ModalSystem.create({ + id: 'kefinTweaksImportModal', + title: 'Import Custom Sections', + content: content, + footer: footer, + closeOnBackdrop: false, + closeOnEscape: true, + onOpen: (modalInstance) => { + const loadBtn = modalInstance.dialogFooter.querySelector('#loadImportBtn'); + const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmImportBtn'); + const input = content.querySelector('#importInput'); + const selectionContainer = content.querySelector('#importSelectionContainer'); + const selectionList = content.querySelector('#importSelectionList'); + + loadBtn.addEventListener('click', async () => { + const rawInput = input.value.trim(); + if (!rawInput) return; + + loadBtn.disabled = true; + loadBtn.textContent = 'Loading...'; + + try { + let jsonStr = rawInput; + // Check if URL + if (rawInput.startsWith('http://') || rawInput.startsWith('https://')) { + const res = await fetch(rawInput); + if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`); + jsonStr = await res.text(); + } + + parsedData = JSON.parse(jsonStr); + if (!parsedData.customSections && !parsedData.seasonalSeasons && !Array.isArray(parsedData)) { + throw new Error('Invalid format: No sections found.'); + } + + // Handle legacy/array-only format if necessary (assume customSections) + if (Array.isArray(parsedData)) { + parsedData = { customSections: parsedData }; + } + + // Render selection + let html = ''; + if (parsedData.customSections && parsedData.customSections.length > 0) { + html += ` +
+

Custom Sections

+
+ ${parsedData.customSections.map((s, i) => ` + + `).join('')} +
+
`; + } + if (parsedData.seasonalSeasons && parsedData.seasonalSeasons.length > 0) { + html += ` +
+

Seasonal Seasons

+
+ ${parsedData.seasonalSeasons.map((s, i) => ` + + `).join('')} +
+
`; + } + + if (!html) throw new Error('No valid sections found in data.'); + + selectionList.innerHTML = html; + selectionContainer.style.display = 'block'; + loadBtn.style.display = 'none'; + confirmBtn.style.display = 'inline-block'; + input.disabled = true; + + } catch (err) { + alert('Error loading data: ' + err.message); + loadBtn.disabled = false; + loadBtn.textContent = 'Load'; + } + }); + + confirmBtn.addEventListener('click', async () => { + if (!parsedData) return; + + const selectedCustomIndices = Array.from(content.querySelectorAll('.import-custom-check:checked')).map(c => parseInt(c.dataset.index)); + const selectedSeasonalIndices = Array.from(content.querySelectorAll('.import-seasonal-check:checked')).map(c => parseInt(c.dataset.index)); + + // Import logic + if (!config.homeScreen) config.homeScreen = {}; + + let importedCount = 0; + + // Import Custom Sections + if (selectedCustomIndices.length > 0 && parsedData.customSections) { + if (!config.homeScreen.customSections) config.homeScreen.customSections = []; + + selectedCustomIndices.forEach(i => { + const section = parsedData.customSections[i]; + // Regenerate ID to avoid collision + if (section.id) section.id = `${section.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`; + config.homeScreen.customSections.push(section); + importedCount++; + }); + } + + // Import Seasonal Seasons + if (selectedSeasonalIndices.length > 0 && parsedData.seasonalSeasons) { + if (!config.homeScreen.seasonal) config.homeScreen.seasonal = {}; + if (!config.homeScreen.seasonal.seasons) config.homeScreen.seasonal.seasons = []; + + selectedSeasonalIndices.forEach(i => { + const season = parsedData.seasonalSeasons[i]; + // Regenerate IDs for internal sections if needed? + if (season.sections) { + season.sections.forEach(s => { + if (s.id) s.id = `${s.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`; + }); + } + config.homeScreen.seasonal.seasons.push(season); + importedCount++; + }); + } + + if (importedCount > 0) { + await saveConfigCallback(config); + alert(`Successfully imported ${importedCount} items. The page will reload.`); + window.location.reload(); + } else { + window.ModalSystem.close('kefinTweaksImportModal'); + } + }); + } + }); + } + // Inject responsive CSS for the configuration modal function injectConfigModalCSS() { const styleId = 'kefintweaks-config-modal-css'; @@ -434,43 +872,6 @@ // Build the configuration page content function buildConfigPageContent(config, libraries = []) { const scripts = config.scripts || {}; - const homeScreen = config.homeScreen || {}; - const exclusiveElsewhere = config.exclusiveElsewhere || {}; - const search = config.search || {}; - const flattenSingleSeasonShows = config.flattenSingleSeasonShows || {}; - const customMenuLinks = config.customMenuLinks || []; - // Reconstruct full skin list (enabled + disabled) for configuration - // We can't use window.KefinTweaksSkinConfig directly because it filters out disabled skins - const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || []; - const adminSkins = config.skins || []; - - const mergedSkins = []; - - // 1. Add all default skins first - defaultSkins.forEach(skin => { - mergedSkins.push({ ...skin, enabled: true }); - }); - - // 2. Override/Add with admin skins - adminSkins.forEach(adminSkin => { - const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name); - if (existingIndex >= 0) { - // Override existing default skin - mergedSkins[existingIndex] = { ...mergedSkins[existingIndex], ...adminSkin }; - } else { - // Add new admin skin - mergedSkins.push({ ...adminSkin }); - } - }); - - const skins = mergedSkins; - const themes = config.themes || []; - - // Get all skin names for autocomplete - const allSkinNames = skins.map(skin => skin.name).filter(Boolean); - - // Get skin source info for visual distinctions - const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {}; const isEnabled = window.KefinTweaksConfigEnabled !== false; @@ -501,211 +902,6 @@ -
-
-
-

Home Screen Configuration

-
Configure custom home screen sections and discovery features
-
-
-
- ${buildHomeScreenConfig(homeScreen, libraries)} -
-
- -
-
-
-

Exclusive Elsewhere Configuration

-
Configure exclusive elsewhere branding behavior
-
-
- ${buildExclusiveElsewhereConfig(exclusiveElsewhere)} -
- - - -
-
-
-

Episodes On Series Page Configuration

-
Configure episodes display on series pages
-
-
- ${buildFlattenShowsConfig(flattenSingleSeasonShows)} -
- -
-
-
-

Skin Configuration

-
Configure default skin and available skins for all users
-
-
-
-
-
Default Skin
- -
Select a default skin for all users
-
-
-
-
-

Enable/Disable Skins

-
Disabled skins will not appear in the appearance dropdowns for users
-
- All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations. - KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility. -
-
- ${buildSkinToggles(skins, skinSources)} -
-
-
- ${(() => { - // Check if there are any overridden default skins - const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden'); - if (hasOverriddenSkins) { - return ` -
-
-
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
- -
-
- `; - } - return ''; - })()} -
-
-

Optional CSS Modules

-
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
-
Select Skin
- -
- ${buildOptionalIncludesEditor('global', config, skins)} -
-
-
-
- - chevron_right - Skins JSON - -
-
-
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
- -
- View Example Format -
[
-  {
-    "name": "Custom Skin",
-    "author": "username",
-    "url": [
-      {
-        "majorServerVersions": [10, 11],
-        "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme.css"]
-      }
-    ],
-    "colorSchemes": []
-  },
-  {
-    "name": "Custom Skin 2",
-    "author": "username 2",
-    "url": [
-      {
-        "majorServerVersions": [10],
-        "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme2.css"]
-      }
-    ],
-    "colorSchemes": []
-  }
-]
-
-
-
-
-
- -
-
-
-

Theme Configuration

-
Configure additional themes available to all users
-
-
-
-
-
Themes JSON
-
Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.
- -
- View Example Format -
[
-  {
-    "name": "Custom Dark Theme",
-    "url": "https://cdn.jsdelivr.net/gh/username/custom-dark-theme.css"
-  },
-  {
-    "name": "Custom Light Theme",
-    "url": "https://cdn.jsdelivr.net/gh/username/custom-light-theme.css"
-  }
-]
-
-
-
-
- - `; } @@ -975,6 +1171,9 @@ // Build script toggle switches function buildScriptToggles(scripts) { + // Features that have configuration options (not just toggles) + const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo']); + const scriptNames = [ { key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' }, { key: 'homeScreen', label: 'Enhanced Home Screen', desc: 'Add custom home screen sections and a "discovery engine" to your Home Page. Create sections based on Genre, Tags, Playlists, Collections or Search Terms for specific Item Types. Use the "Spotlight" feature to highlight specific sections with an image carousel/slideshow.' }, @@ -998,12 +1197,20 @@ return scriptNames.map(script => { const isEnabled = scripts[script.key] !== false; // Default to true if not set + const hasConfig = featuresWithConfig.has(script.key); + const configKey = script.key === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : script.key; + return ` -
+
${script.label}
- ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })} +
+ ${hasConfig ? `` : ''} + ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })} +
${script.desc}
@@ -1012,6 +1219,54 @@ }).join(''); } + function openFeatureConfigurationModal(event, featureKey) { + // Check if the click target is the toggle switch or edit button + const target = event.target; + + // Check if clicking on the toggle switch (checkbox, label, or container) + const isCheckbox = target.closest('.checkboxContainer') || + target.closest('.emby-checkbox-label') || + target.closest('.emby-checkbox') || + target.getAttribute('is') === 'emby-checkbox' || + target.hasAttribute('data-embycheckbox'); + + // Don't open modal if clicking on toggle switch or edit button + if (isCheckbox) { + return; + } + + // Get the feature key from the card if not provided + if (!featureKey) { + const featureCard = event.target.closest('.feature-card'); + if (featureCard) { + featureKey = featureCard.getAttribute('data-feature-key'); + } + } + + if (!featureKey) { + console.warn('[KefinTweaks Configuration] Could not determine feature key'); + return; + } + + // Map feature key to config key (for flattenSingleSeasonShows -> seriesEpisodes) + const configKey = featureKey === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : featureKey; + + // Open the appropriate configuration modal + if (window.KefinTweaksFeatureConfigs && window.KefinTweaksFeatureConfigs[configKey]) { + const configModule = window.KefinTweaksFeatureConfigs[configKey]; + if (configModule.openConfigModal) { + configModule.openConfigModal(); + } else { + console.warn(`[KefinTweaks Configuration] No openConfigModal function found for ${configKey}`); + } + } else { + console.warn(`[KefinTweaks Configuration] No configuration module found for ${configKey}`); + } + } + + // Expose function globally so it can be called from inline onclick handlers + window.openFeatureConfigurationModal = openFeatureConfigurationModal; + // Helper function to build Include Item Types badge field function buildIncludeItemTypesField(prefix, sectionIndex, sanitizedPrefix, currentTypes, sourceType) { // All available item types @@ -1714,7 +1969,7 @@ recentlyAddedInLibraryHtml = libraries.map(library => { const libConfig = recentlyAddedInLibrary[library.Id] || {}; const prefix = `homeScreen_recentlyAddedInLibrary_${library.Id}`; - const defaultName = `Recently Added in ${library.Name}`; + const defaultName = `Recently Added ${library.Name}`; return `
@@ -2085,6 +2340,28 @@
+ + +
+ + import_export + Import / Export Custom Sections + +
+
Share your custom home screen sections or import configurations from others.
+
+ + + +
+
+
`; } @@ -2171,16 +2448,13 @@ currentLoadedConfig = config; window.KefinTweaksCurrentConfig = config; - // Fetch libraries for configuration - const libraries = await fetchLibraries(); - // Build the content (without title, as ModalSystem adds it) const content = document.createElement('div'); content.className = 'modal-content-wrapper'; content.id = MODAL_ID; content.innerHTML = `
- ${buildConfigPageContent(config, libraries)} + ${buildConfigPageContent(config)}
`; @@ -2380,6 +2654,34 @@ addCustomSection(); } }); + + // Export custom sections + modalInstance.dialogContent.addEventListener('click', (e) => { + const exportBtn = e.target.closest('.export-custom-sections-btn'); + if (exportBtn) { + showExportDialog(config); + } + }); + + // Import custom sections + modalInstance.dialogContent.addEventListener('click', (e) => { + const importBtn = e.target.closest('.import-custom-sections-btn'); + if (importBtn) { + showImportDialog(config, async (newConfig) => { + await saveConfigToJavaScriptInjector(newConfig); + }); + } + }); + + // Import community sections + modalInstance.dialogContent.addEventListener('click', (e) => { + const importBtn = e.target.closest('.import-community-sections-btn'); + if (importBtn) { + showCommunityImportDialog(config, async (newConfig) => { + await saveConfigToJavaScriptInjector(newConfig); + }); + } + }); // Delete section modalInstance.dialogContent.addEventListener('click', (e) => { @@ -5061,18 +5363,8 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; // Collect all form values // Preserve existing kefinTweaksRoot from current config (no longer editable in UI) - const currentConfig = window.KefinTweaksCurrentConfig || {}; - const config = { - kefinTweaksRoot: currentConfig.kefinTweaksRoot || '', - scripts: {}, - homeScreen: {}, - exclusiveElsewhere: {}, - search: {}, - defaultSkin: document.getElementById('defaultSkin')?.value || null, - skins: [], - themes: [], - customMenuLinks: [] - }; + const currentConfig = window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; + const config = currentConfig; config.enabled = modalInstance.dialogContent.querySelector('#kefinTweaksEnabled')?.checked !== false; // Remove scriptRoot if it exists (legacy field, no longer used) @@ -5086,312 +5378,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; config.scripts[key] = checkbox.checked; }); - // Helper function to get section config - function getSectionConfig(prefix, includeSortOrderOrOptions = true, includeOrderFlag = true) { - let includeSortOrder = true; - let includeOrder = true; - let includeName = false; - let defaultName = ''; - - if (typeof includeSortOrderOrOptions === 'object') { - const opts = includeSortOrderOrOptions || {}; - includeSortOrder = opts.includeSortOrder !== undefined ? opts.includeSortOrder : true; - includeOrder = opts.includeOrder !== undefined ? opts.includeOrder : true; - includeName = opts.includeName === true; - defaultName = opts.defaultName || ''; - } else { - includeSortOrder = includeSortOrderOrOptions !== false; - includeOrder = includeOrderFlag !== false; - } - - const config = { - itemLimit: parseInt(document.getElementById(`${prefix}_itemLimit`)?.value || '16', 10) - }; - - if (includeName) { - config.name = document.getElementById(`${prefix}_name`)?.value || defaultName || ''; - } - - if (includeSortOrder) { - config.sortOrder = document.getElementById(`${prefix}_sortOrder`)?.value || 'Random'; - config.sortOrderDirection = document.getElementById(`${prefix}_sortOrderDirection`)?.value || 'Ascending'; - } - - config.cardFormat = document.getElementById(`${prefix}_cardFormat`)?.value || 'Poster'; - - if (includeOrder) { - config.order = parseInt(document.getElementById(`${prefix}_order`)?.value || '100', 10); - } - - // Check for IsPlayed configuration - const isPlayedEnabledCheckbox = document.getElementById(`${prefix}_isPlayed_enabled`); - if (isPlayedEnabledCheckbox) { - if (isPlayedEnabledCheckbox.checked) { - const isPlayedValueSelect = document.getElementById(`${prefix}_isPlayed_value`); - config.isPlayed = isPlayedValueSelect?.value === 'true'; - } else { - config.isPlayed = null; - } - } - - // Handle Min/Max Age In Days for Recently Released - if (prefix === 'homeScreen_recentlyReleased_movies') { - const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value; - const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value; - config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null; - config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null; - } - if (prefix === 'homeScreen_recentlyReleased_episodes') { - const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value; - const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value; - config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null; - config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null; - } - - return config; - } - - // Collect seasonal seasons - function getSeasonalSeasons() { - const seasons = []; - const seasonItems = modalInstance.dialogContent.querySelectorAll('.seasonal-season-item'); - seasonItems.forEach((item, seasonIndex) => { - // Try to find checkbox by ID first, then fall back to class selector - const enabledCheckbox = item.querySelector(`#seasonal-season-enabled-${seasonIndex}`) || item.querySelector('.seasonal-season-enabled[data-index="${seasonIndex}"]'); - const enabled = enabledCheckbox?.checked !== false; - const name = item.querySelector('.seasonal-season-name')?.value || ''; - const startDate = item.querySelector('.seasonal-season-startDate')?.value || ''; - const endDate = item.querySelector('.seasonal-season-endDate')?.value || ''; - const orderInput = item.querySelector(`.seasonal-season-order-input[data-index="${seasonIndex}"]`); - const order = orderInput ? parseInt(orderInput.value || '100', 10) : 100; - - // Collect nested sections - const sectionsList = item.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`); - const sections = []; - if (sectionsList) { - const sectionItems = sectionsList.querySelectorAll('.seasonal_season_section_item'); - sectionItems.forEach((sectionItem, sectionItemIndex) => { - const fullIndex = `${seasonIndex}_${sectionItemIndex}`; - const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'seasonal_season', fullIndex); - if (sectionConfig) { - sections.push(sectionConfig); - } - }); - } - - const seasonConfig = { - id: name.toLowerCase().replace(/\s+/g, '-') || `season-${seasonIndex}`, - name: name, - enabled: enabled, - startDate: startDate, - endDate: endDate, - order: order, - sections: sections - }; - seasons.push(seasonConfig); - }); - return seasons; - } - - // Collect custom sections - function getCustomSections() { - const sectionsList = modalInstance.dialogContent.querySelector('#customSections_list'); - const sections = []; - - if (!sectionsList) { - return sections; - } - - const sectionItems = sectionsList.querySelectorAll('.customSection_section_item'); - sectionItems.forEach((sectionItem, index) => { - const sectionIndex = sectionItem.getAttribute('data-section-index') ?? index; - const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'customSection', sectionIndex); - if (sectionConfig) { - sections.push(sectionConfig); - } - }); - - return sections; - } - - // Collect home screen config - config.homeScreen = { - defaultItemLimit: 16, - defaultSortOrder: 'Random', - defaultCardFormat: 'Poster', - recentlyReleased: { - enabled: document.getElementById('homeScreen_recentlyReleased_enabled')?.checked !== false, - movies: { - enabled: document.getElementById('homeScreen_recentlyReleased_movies_enabled')?.checked !== false, - ...getSectionConfig('homeScreen_recentlyReleased_movies', { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true }) - }, - episodes: { - enabled: document.getElementById('homeScreen_recentlyReleased_episodes_enabled')?.checked !== false, - ...getSectionConfig('homeScreen_recentlyReleased_episodes', { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true }) - } - }, - recentlyAddedInLibrary: (() => { - const libraryConfigs = {}; - const libraryCheckboxes = modalInstance.dialogContent.querySelectorAll('.recently-added-library-enabled'); - libraryCheckboxes.forEach(checkbox => { - const libraryId = checkbox.getAttribute('data-library-id'); - if (libraryId) { - const prefix = `homeScreen_recentlyAddedInLibrary_${libraryId}`; - libraryConfigs[libraryId] = { - enabled: checkbox.checked !== false, - ...getSectionConfig(prefix, { - includeName: true, - defaultName: '', - includeSortOrder: false, - includeOrder: true - }) - }; - } - }); - return libraryConfigs; - })(), - trending: { - enabled: document.getElementById('homeScreen_trending_enabled')?.checked === true, - ...getSectionConfig('homeScreen_trending', { includeName: true, defaultName: 'Trending' }) - }, - popularTVNetworks: { - enabled: document.getElementById('homeScreen_popularTVNetworks_enabled')?.checked === true, - minimumShowsForNetwork: parseInt(document.getElementById('homeScreen_popularTVNetworks_minimumShowsForNetwork')?.value || '5'), - ...getSectionConfig('homeScreen_popularTVNetworks', { includeName: true, defaultName: 'Popular TV Networks' }) - }, - watchlist: { - enabled: document.getElementById('homeScreen_watchlist_enabled')?.checked === true, - ...getSectionConfig('homeScreen_watchlist', { includeName: true, defaultName: 'Watchlist' }) - }, - watchAgain: { - enabled: document.getElementById('homeScreen_watchAgain_enabled')?.checked === true, - ...getSectionConfig('homeScreen_watchAgain', { includeName: true, defaultName: 'Watch Again' }) - }, - upcoming: { - enabled: document.getElementById('homeScreen_upcoming_enabled')?.checked !== false, - ...getSectionConfig('homeScreen_upcoming', { includeSortOrder: false, includeName: true, defaultName: 'Upcoming' }) - }, - imdbTop250: { - enabled: document.getElementById('homeScreen_imdbTop250_enabled')?.checked !== false, - ...getSectionConfig('homeScreen_imdbTop250', { includeName: true, defaultName: 'IMDb Top 250' }) - }, - seasonal: { - enabled: document.getElementById('homeScreen_seasonal_enabled')?.checked !== false, - enableSeasonalAnimations: document.getElementById('homeScreen_seasonal_enableSeasonalAnimations')?.checked !== false, - enableSeasonalBackground: document.getElementById('homeScreen_seasonal_enableSeasonalBackground')?.checked !== false, - defaultItemLimit: 16, - defaultSortOrder: 'Random', - defaultCardFormat: 'Poster', - seasons: getSeasonalSeasons() - }, - discovery: { - enabled: document.getElementById('homeScreen_discovery_enabled')?.checked !== false, - infiniteScroll: document.getElementById('homeScreen_discovery_infiniteScroll')?.checked !== false, - randomizeOrder: document.getElementById('homeScreen_discovery_randomizeOrder')?.checked === true, - minPeopleAppearances: parseInt(document.getElementById('homeScreen_discovery_minPeopleAppearances')?.value || '10'), - minGenreMovieCount: parseInt(document.getElementById('homeScreen_discovery_minGenreMovieCount')?.value || '50'), - spotlightDiscoveryChance: parseFloat(document.getElementById('homeScreen_discovery_spotlightDiscoveryChance')?.value || '0.5'), - renderSpotlightAboveMatching: document.getElementById('homeScreen_discovery_renderSpotlightAboveMatching')?.checked === true, - defaultItemLimit: parseInt(document.getElementById('homeScreen_discovery_itemLimit')?.value || '16'), - defaultSortOrder: document.getElementById('homeScreen_discovery_sortOrder')?.value || 'Random', - defaultCardFormat: document.getElementById('homeScreen_discovery_cardFormat')?.value || 'Poster', - sectionTypes: (() => { - const sectionConfigs = {}; - DISCOVERY_SECTION_DEFINITIONS.forEach(section => { - const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; - const collectedConfig = getSectionConfig(prefix, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName }); - collectedConfig.enabled = document.getElementById(`${prefix}_enabled`)?.checked !== false; - if (section.extras?.minimumItems !== undefined) { - const minimumValue = parseInt(document.getElementById(`${prefix}_minimumItems`)?.value || `${section.extras.minimumItems}`, 10); - collectedConfig.minimumItems = Number.isFinite(minimumValue) ? minimumValue : section.extras.minimumItems; - } - sectionConfigs[section.key] = collectedConfig; - }); - return sectionConfigs; - })() - }, - customSections: getCustomSections() - }; - - // Collect exclusive elsewhere config - config.exclusiveElsewhere = { - hideServerName: document.getElementById('exclusiveElsewhere_hideServerName')?.checked === true - }; - - // Collect search config - config.search = { - enableJellyseerr: document.getElementById('search_enableJellyseerr')?.checked === true - }; - - // Collect flattenSingleSeasonShows config - config.flattenSingleSeasonShows = { - hideSingleSeasonContainer: document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true - }; - - // Parse JSON fields - const skinsFromJson = parseJSONField('skinsJson', []); - config.themes = parseJSONField('themesJson', []); - config.customMenuLinks = parseJSONField('customMenuLinksJson', []); - - // Collect optional includes configuration - if (overrideOptionalIncludes) { - config.optionalIncludes = overrideOptionalIncludes; - } else { - // Fallback: Use existing config.optionalIncludes to prevent data loss - // DOM scraping caused partial overwrites because it only found visible checkboxes for the current category - console.warn('[KefinTweaks Configuration] handleSaveConfig called without overrideOptionalIncludes - preserving existing optionalIncludes'); - if (!config.optionalIncludes) { - config.optionalIncludes = []; - } - } - - // Collect skin enabled toggles and merge with JSON skins - // Only save admin-configured skins (not defaults) - // Get default skin names to filter them out - const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name)); - - // Collect enabled states from toggles (for all skins, including defaults) - const skinEnabledStates = {}; - modalInstance.dialogContent.querySelectorAll('[data-skin-name]').forEach(checkbox => { - const skinName = checkbox.getAttribute('data-skin-name'); - skinEnabledStates[skinName] = checkbox.checked; - }); - - // Only save admin skins (from JSON textarea) - filter out any that match default names - // This ensures we don't accidentally save default skins - const customSkins = skinsFromJson - .filter(skin => !defaultSkinNames.has(skin.name)) // Exclude defaults - .map(skin => ({ - ...skin, - enabled: skinEnabledStates[skin.name] !== false, // Default to true if not set - hidden: false // Explicitly set for admin skins - })); - - // Handle disabled default skins - // We need to explicitly save them in config.skins with enabled: false to override the default - const disabledDefaultSkins = []; - defaultSkinNames.forEach(name => { - // If explicitly disabled in UI - if (skinEnabledStates[name] === false) { - disabledDefaultSkins.push({ - name: name, - enabled: false - }); - } - }); - - // Combine custom skins and disabled default skins - config.skins = [...customSkins, ...disabledDefaultSkins]; - - // Note: Enabled states for default skins are collected but not saved in config.skins - // Default skins will use their default enabled state from skinConfig.js - // If we need to save enabled states for defaults, we'd need a separate field (future enhancement) - - // Handle defaultSkin - if (config.defaultSkin === '') { - config.defaultSkin = null; - } - console.log('[KefinTweaks Configuration] Configuration collected:', config); // Save to JavaScript Injector plugin @@ -5556,7 +5542,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const pluginsSection = document.querySelector('ul[aria-labelledby="plugins-subheader"]'); if (!pluginsSection) { console.log('[KefinTweaks Configuration] Plugins section not found, retrying...'); - setTimeout(addConfigButtonToDashboard, 500); + //setTimeout(addConfigButtonToDashboard, 500); return; } @@ -5583,7 +5569,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const adminSection = document.querySelector('.adminSection.verticalSection'); if (!adminSection) { console.log('[KefinTweaks Configuration] Administration section not found, retrying...'); - setTimeout(addConfigButtonToAdminSection, 500); + //setTimeout(addConfigButtonToAdminSection, 500); return; } @@ -5717,6 +5703,14 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; return; } + // Run migration check during initialization + try { + const config = await getKefinTweaksConfig(); + // Migration is handled inside getKefinTweaksConfig + } catch (error) { + console.warn('[KefinTweaks Configuration] Error during initialization migration check:', error); + } + // Register view page handler registerViewPageHandler(); diff --git a/scripts/homeScreen.css b/scripts/homeScreen.css index 3ffb133..73d0079 100644 --- a/scripts/homeScreen.css +++ b/scripts/homeScreen.css @@ -21,6 +21,7 @@ .sections.homeSectionsContainer { display: flex; flex-direction: column; + padding-bottom: 100px; } .homeSectionsContainer > .section0 { @@ -96,7 +97,7 @@ margin-top: 0; } -div#discovery-loading-indicator:not(.show)::before { +/* div#discovery-loading-indicator:not(.show)::before { content: 'Loading...'; position: absolute; top: 50%; @@ -107,7 +108,7 @@ div#discovery-loading-indicator:not(.show)::before { visibility: visible; text-align: center; opacity: 1; -} +} */ #indexPage[data-discovery-ready] div#discovery-loading-indicator:not(.show)::before { content: 'Discover More'; @@ -126,13 +127,13 @@ div#discovery-loading-indicator:not(.show)::after { opacity: 1; } -.libraryPage:has(.load-more-discovery-btn) div#discovery-loading-indicator { - opacity: 0; +[data-loading-discovery="true"] div#discovery-loading-indicator { + display: flex; } -#indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator { +/* #indexPage[data-discovery-ready]:not([data-infinite-scroll='true']) div#discovery-loading-indicator { display: none; -} +} */ #indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after { content: ''; @@ -144,4 +145,134 @@ div#discovery-loading-indicator:not(.show)::after { } .spotlight-section { width: calc(100% - (2 * max(env(safe-area-inset-left),3.3%))); +} + + +.load-more-discovery-btn { + display: block; + visibility: visible; + order: 99998 !important; + margin: 0 auto; +} + +.sections.loading-discovery .load-more-discovery-btn { + visibility: hidden !important; +} + +/* Discovery section loading indicator */ +.sections.loading-discovery .discovery-loading-indicator { + visibility: visible !important; + opacity: 1 !important; +} + +.discovery-loading-indicator, .load-more-discovery-btn { + height: 50px; +} + +.discovery-loading-indicator { + display: none; + opacity: 1; + flex-direction: column; + gap: 10px; + justify-content: center; + align-items: center; + border-radius: 8px; + font-size: 14px; + color: #666; + transition: opacity 0.3s ease; + order: 99999 !important; + position: relative; + justify-content: start; +} + +.discovery-loading-indicator .spinner { + width: 30px; + height: 30px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: var(--theme-primary-color, #3498db); + border-radius: 50%; + animation: discovery-spin 1s linear infinite; +} + +@keyframes discovery-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Hide next up section if next up if KefinTweaks next up section is present */ +.homeSectionsContainer:has([data-section-id="nextUp"]) > div:nth-child(-n+10):has(a[href*="nextup"]) { + display: none; +} +/* Hide continue watching section if continue watching if KefinTweaks continue watching section is present */ +.homeSectionsContainer:has([data-section-id="continueWatching"]) > div:nth-child(-n+10):has([data-positionticks][data-mediatype="Video"]) { + display: none; +} +/* Hide recently added section if recently added if KefinTweaks recently added section is present */ +.homeSectionsContainer:has([data-section-id*="recently-added"]) > div:nth-child(-n+10):has(a[href*="movies&tab=1"]), +.homeSectionsContainer:has([data-section-id*="recently-added"]) > div:nth-child(-n+10):has(a[href*="tv&tab=1"]), +.homeSectionsContainer:has([data-section-id*="recently-added"]) > div:nth-child(-n+10):has(a[href*="music&tab=1"]) + { + display: none; +} + +/* Hide scroll buttons on mobile and tablet layouts */ +@media (max-width: 768px) { + .emby-scrollbuttons { + display: none !important; + } +} + +/* Hide scroll buttons on mobile using Jellyfin's layout classes */ +.layout-mobile .emby-scrollbuttons, +.layout-tv .emby-scrollbuttons { + display: none !important; +} + +.load-more-discovery-btn { + display: block; +} + +.sections[data-loading-discovery="true"] .load-more-discovery-btn { + display: none !important; +} + +.sections[data-loading-discovery="true"] .discovery-loading-indicator { + display: flex !important; + opacity: 1 !important; +} + +.discovery-loading-indicator { + display: none; + opacity: 0; + flex-direction: column; + gap: 10px; + justify-content: center; + align-items: center; + padding: 20px; + border-radius: 8px; + font-size: 14px; + color: #666; + transition: opacity 0.3s ease; + order: 99999 !important; + position: relative; + justify-content: start; + min-height: 200px; +} + +.discovery-loading-indicator .spinner { + width: 30px; + height: 30px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: var(--theme-primary-color, #3498db); + border-radius: 50%; + animation: discovery-spin 1s linear infinite; +} + +@keyframes discovery-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.homeSectionsContainer [data-section-id="popularTVNetworks"] .card .cardText { + visibility: hidden; } \ No newline at end of file diff --git a/scripts/homeScreen3.js b/scripts/homeScreen3.js new file mode 100644 index 0000000..4f62777 --- /dev/null +++ b/scripts/homeScreen3.js @@ -0,0 +1,2230 @@ +// KefinTweaks Home Screen v3 (Config-Driven & Streamlined) +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks HomeScreen3]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks HomeScreen3]', ...args); + const ERR = (...args) => console.error('[KefinTweaks HomeScreen3]', ...args); + + // Configuration + const PRE_FETCH_DISCOVERY_DATA = true; // If true, fetches first discovery group immediately. If false, waits for scroll/click. + + // Dependencies + const ApiHelper = window.apiHelper; + const Config = window.KefinHomeConfig2; // Updated to use Config2 + const PeopleCache = window.PeopleCache; + const StudiosCache = window.StudiosCache; + + if (!Config) { + ERR('Configuration not found! Ensure homeScreenConfig2.js is loaded.'); + return; + } + + /** + * Flatten section groups into a flat array of sections + * @param {Array} groups - Array of HomeScreenSectionGroup objects + * @returns {Array} Flat array of all sections + */ + function flattenSectionGroups(groups) { + if (!Array.isArray(groups)) return []; + const flattened = []; + groups.forEach(group => { + if (group && Array.isArray(group.sections)) { + flattened.push(...group.sections); + } + }); + return flattened; + } + + // State + const state = { + renderedSections: new Set(), + renderedDiscoveryIds: { + genres: new Set(), + networks: new Set(), + collections: new Set(), + studios: new Set(), + people: new Set(), + actors: new Set(), + directors: new Set(), + writers: new Set(), + watchedMovies: new Set(), + likedMovies: new Set(), + customDiscoverySections: new Set() + }, + currentDiscoveryGenre: null, + currentDiscoveryStudio: null, + cachedFavorites: null, + isPeopleCacheComplete: false, + discoveryGroupIndex: 0, + isRenderingDiscovery: false, + discoveryEnabled: true, + discoveryBufferPromise: null, + discoveryBuffer: [], + discoverySectionOrder: 1000, + discoverySectionsRemain: true, + ensuringDiscoveryBuffer: false, + infiniteScrollHandler: null, + userDisplayPreferences: null, + firstDiscoveryBuffer: null, + getDisplayPrefernces: fetchDisplayPreferences, + kefinNextUp: false, + kefinContinueWatching: false, + kefinLatestMedia: false, + }; + + const performanceTimer = { + loadTimeStart: null, + loadTimeEnd: null, + }; + + // Cache used to support re-rendering our sections after Jellyfin overwrites the container + const sectionCache = { + renderedSections: [], // Array of section configs + results + fragmentCache: null, // Reserved for future use if we cache DOM fragments + isInitialRender: true, // Track if this is first render for the current view + jellyfinHasRendered: false // Track if Jellyfin has rendered native home sections + }; + + // Basic performance metrics for measuring initial and recovery renders + const performanceMetrics = { + initialRenderStart: null, + initialRenderEnd: null, + jellyfinDetected: null, + recoveryRenderEnd: null + }; + + // Create loading indicator element + function createDiscoveryLoadingIndicator(container) { + let loadingDiv = document.querySelector('.homePage:not(.hide) #discovery-loading-indicator'); + if (loadingDiv) { + return loadingDiv; + } + + // Prefer the provided container, but fall back to the standard sections container + if (!container) { + container = document.querySelector('.homePage:not(.hide) #homeTab .sections'); + } + + if (!container) { + WARN('Discovery loading indicator: sections container not found'); + return null; + } + + loadingDiv = document.createElement('div'); + loadingDiv.className = 'discovery-loading-indicator'; + loadingDiv.id = 'discovery-loading-indicator'; + loadingDiv.style.order = '99999'; + loadingDiv.innerHTML = ` +
+ `; + + container.appendChild(loadingDiv); + LOG('Created discovery loading indicator inside sections container'); + + return loadingDiv; + } + + // Legacy helper kept for compatibility – no longer used in the main flow + async function waitForHomeSectionsContainer() { + + const maxAttempts = 50; + let attempts = 0; + let homeSectionsContainer = null; + while (attempts < maxAttempts && !homeSectionsContainer) { + homeSectionsContainer = document.querySelector('.homePage:not(.hide) #homeTab .homeSectionsContainer'); + if (homeSectionsContainer) { + break; + } + attempts++; + await new Promise(resolve => setTimeout(resolve, 50)); + } + + if (!homeSectionsContainer) { + LOG('Home sections container not found'); + return false; + } + + return homeSectionsContainer; + } + + // Observe the home sections container so we can react when Jellyfin renders + function observeContainerMutations(container, onJellyfinRender) { + if (!container || typeof MutationObserver === 'undefined') { + return null; + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // Check if .homeSectionsContainer class was added + if (mutation.type === 'attributes' && + mutation.attributeName === 'class' && + container.classList.contains('homeSectionsContainer')) { + + LOG('Jellyfin rendered homeSectionsContainer, re-rendering custom sections'); + sectionCache.jellyfinHasRendered = true; + performanceMetrics.jellyfinDetected = performance.now(); + observer.disconnect(); + if (typeof onJellyfinRender === 'function') { + onJellyfinRender(); + } + break; + } + + // Check for childList changes (innerHTML overwrite before class is applied) + if (mutation.type === 'childList' && + !sectionCache.jellyfinHasRendered) { + + const hasKefinSections = Array.from(container.children) + .some(el => el.dataset && el.dataset.sectionId); + + if (!hasKefinSections && sectionCache.renderedSections.length > 0) { + LOG('Home sections container content cleared; awaiting Jellyfin homeSectionsContainer class.'); + // We intentionally keep observing for the class change. + } + } + } + }); + + observer.observe(container, { + childList: true, + attributes: true, + attributeFilter: ['class'] + }); + + return observer; + } + + function manageBodyClasses() { + const body = document.body; + const currentView = window.KefinTweaksUtils?.getCurrentView ? window.KefinTweaksUtils.getCurrentView() : null; + const isHomePage = currentView === 'home' || currentView === 'home.html' || window.location.href.includes('home.html'); + + const homeScreenConfig = window.KefinTweaksConfig?.homeScreen || {}; + const seasonalConfig = homeScreenConfig.seasonal || {}; + const enableSeasonalAnimations = seasonalConfig.enableSeasonalAnimations !== false; + const enableSeasonalBackground = seasonalConfig.enableSeasonalBackground !== false; + + if (isHomePage && body) { + body.classList.add('home-screen'); + } else if (body) { + body.classList.remove('home-screen'); + } + + if (body) { + const themeClasses = ['halloween-theme', 'christmas-theme', 'valentines-theme', 'newyear-theme', 'with-background']; + themeClasses.forEach(themeClass => body.classList.remove(themeClass)); + + if (isHomePage) { + if (isHalloweenPeriod()) { + body.classList.add('halloween-theme'); + } else if (isChristmasPeriod()) { + body.classList.add('christmas-theme'); + + if (enableSeasonalBackground) { + body.classList.add('with-background'); + } + + if (enableSeasonalAnimations) { + const snowverlayScript = document.querySelector('script[src*="snowverlay.js"]'); + + if (!snowverlayScript) { + const snowverlayScriptSrc = (window.KefinTweaksConfig.kefinTweaksRoot || '') + 'scripts/snowverlay.js'; + const script = document.createElement('script'); + script.src = snowverlayScriptSrc; + document.head.appendChild(script); + } + } + + } else if (isValentinesPeriod()) { + body.classList.add('valentines-theme'); + } else if (isNewYearsPeriod()) { + body.classList.add('newyear-theme'); + } + } + } + } + + async function handleUserDataChanged(event, nextupSectionConfig, continueWatchingSectionConfig, continueWatchingAndNextUpSectionConfig) { + LOG('UserDataChanged event received:', event); + const userData = event.Data?.UserDataList?.[0]; + const parentItemUserData = event.Data?.UserDataList?.[1]; + + if (parentItemUserData.Key.includes('MusicAlbum')) { + return; + } + + if (userData?.ItemId) { + LOG(`Detected UserDataChanged for item: ${userData.ItemId}, Played: ${userData.Played}`); + + // Invalidate cache for Next Up section + if (nextupSectionConfig && nextupSectionConfig.enabled && nextupSectionConfig.queries) { + for (const query of nextupSectionConfig.queries) { + if (query.path || !query.dataSource) { + const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress()); + if (queryUrl && typeof queryUrl === 'string') { + ApiHelper.invalidateCache(queryUrl); + } + } + } + } + + // Invalidate cache for Continue Watching section + if (continueWatchingSectionConfig && continueWatchingSectionConfig.enabled && continueWatchingSectionConfig.queries) { + for (const query of continueWatchingSectionConfig.queries) { + if (query.path || !query.dataSource) { + const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress()); + if (queryUrl && typeof queryUrl === 'string') { + ApiHelper.invalidateCache(queryUrl); + } + } + } + } + + // Invalidate cache for Continue Watching and Next Up section + if (continueWatchingAndNextUpSectionConfig && continueWatchingAndNextUpSectionConfig.enabled && continueWatchingAndNextUpSectionConfig.queries) { + for (const query of continueWatchingAndNextUpSectionConfig.queries) { + if (query.path || !query.dataSource) { + const queryUrl = ApiHelper.buildQueryFromSection(query, ApiClient.getCurrentUserId(), ApiClient.serverAddress()); + if (queryUrl && typeof queryUrl === 'string') { + ApiHelper.invalidateCache(queryUrl); + } + } + } + } + } + } + + /** + * Main Entry Point + */ + async function enhanceHomeScreen() { + LOG('Initializing Home Screen v3...'); + performanceTimer.loadTimeStart = performance.now(); + performanceMetrics.initialRenderStart = performance.now(); + + // Reset cache/metrics for a new initialization cycle + sectionCache.renderedSections = []; + sectionCache.fragmentCache = null; + sectionCache.isInitialRender = true; + sectionCache.jellyfinHasRendered = false; + performanceMetrics.initialRenderEnd = null; + performanceMetrics.jellyfinDetected = null; + performanceMetrics.recoveryRenderEnd = null; + + // Run migration if needed (check for legacy homeScreen config) + if (window.migrateHomeScreenConfig) { + await window.migrateHomeScreenConfig(); + } + + const container = document.querySelector('.homePage:not(.hide) #homeTab .sections'); + if (!container) { + LOG('Home sections container not found'); + return; + } + + // If we've already initialized and Jellyfin has completed its render, avoid re-running + if (container.dataset.kefinHomeScreen && sectionCache.jellyfinHasRendered) { + LOG('Home screen already initialized and Jellyfin has rendered'); + return; + } + + let performanceStartTime = performance.now(); + + // Fetch display preferences + //state.userDisplayPreferences = await fetchDisplayPreferences(); + + let performanceEndTime = performance.now(); + let performanceDuration = performanceEndTime - performanceStartTime; + LOG(`Home Screen v3 Fetch display preferences initialization time: ${performanceDuration.toFixed(2)}ms`); + + performanceStartTime = performance.now(); + + // Merge configs - flatten groups first + const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); + const mergedHomeSections = await mergeHomeSectionConfigs(defaultHomeSections); + + performanceEndTime = performance.now(); + performanceDuration = performanceEndTime - performanceStartTime; + LOG(`Home Screen v3 Merge home section configs initialization time: ${performanceDuration.toFixed(2)}ms`); + + performanceStartTime = performance.now(); + + + const nextupSectionConfig = mergedHomeSections.find(s => s.id === 'nextUp'); + const continueWatchingSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatching'); + const continueWatchingAndNextUpSectionConfig = mergedHomeSections.find(s => s.id === 'continueWatchingAndNextUp'); + state.kefinNextUp = nextupSectionConfig?.enabled === true; + state.kefinContinueWatching = continueWatchingSectionConfig?.enabled === true; + state.kefinLatestMedia = continueWatchingAndNextUpSectionConfig?.enabled === true; + + // Setup event listener for UserDataChanged + if (window.ApiClient && window.ApiClient.addEventListener) { + window.ApiClient.addEventListener('userdatachanged', (event) => { + handleUserDataChanged(event, nextupSectionConfig, continueWatchingSectionConfig, continueWatchingAndNextUpSectionConfig); + }); + } + + performanceEndTime = performance.now(); + performanceDuration = performanceEndTime - performanceStartTime; + LOG(`Home Screen v3 Setup event listener initialization time: ${performanceDuration.toFixed(2)}ms`); + + // Load user preferences and apply filtering/ordering + performanceStartTime = performance.now(); + + let userHomeScreenSections = await loadUserHomeScreenSections(); + + let filteredHomeSections = mergedHomeSections.map(section => { + const userSection = userHomeScreenSections.find(s => s.id === section.id); + if (userSection) { + section.enabled = userSection.enabled; + section.order = userSection.order; + } + return section; + }); + + performanceEndTime = performance.now(); + performanceDuration = performanceEndTime - performanceStartTime; + LOG(`Home Screen v3 Load user home screen sections initialization time: ${performanceDuration.toFixed(2)}ms`); + + // Set flag to indicate we've started initializing this container + container.dataset.kefinHomeScreen = 'true'; + + // Set up mutation observer BEFORE rendering, so we can react when Jellyfin overwrites the container + const handleJellyfinRender = async () => { + if (!container.dataset.sectionsPrerendered) { + LOG('Jellyfin rendering detected, sections not pre-rendered yet, skipping'); + return; + } + + LOG('Jellyfin rendering detected, re-rendering from cache'); + await renderHomeSections(filteredHomeSections, container, true); // useCache = true + await setupDiscoveryInteraction(container); + performanceMetrics.recoveryRenderEnd = performance.now(); + if (performanceMetrics.jellyfinDetected) { + LOG(`Home Screen v3 recovery render duration: ${(performanceMetrics.recoveryRenderEnd - performanceMetrics.jellyfinDetected).toFixed(2)}ms`); + } + }; + + const observer = observeContainerMutations(container, handleJellyfinRender); + + // Initial render directly into .sections (no waiting for Jellyfin) + performanceStartTime = performance.now(); + LOG('Rendering Standard/Seasonal Sections (initial render)...'); + await renderHomeSections(filteredHomeSections, container, false); + performanceEndTime = performance.now(); + performanceDuration = performanceEndTime - performanceStartTime; + LOG(`Home Screen v3 Render home sections (initial) initialization time: ${performanceDuration.toFixed(2)}ms`); + + performanceMetrics.initialRenderEnd = performanceEndTime; + + performanceTimer.loadTimeEnd = performance.now(); + LOG(`Home Screen v3 load time initialization: ${(performanceTimer.loadTimeEnd - performanceTimer.loadTimeStart).toFixed(2)}ms`); + + LOG('Initializing Discovery Sections...'); + await setupDiscoveryInteraction(container); + + // If Jellyfin hasn't rendered after 5 seconds, assume it won't and clean up the observer + setTimeout(() => { + if (!sectionCache.jellyfinHasRendered && observer) { + LOG('Jellyfin render timeout - assuming native sections disabled'); + sectionCache.jellyfinHasRendered = true; + observer.disconnect(); + } + }, 5000); + } + + function getHomeScreenConfig() { + const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen; + if (!userConfig) { + return Config; + } + + const defaultHomeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); + const homeSections = mergeHomeSectionConfigs(defaultHomeSections); + } + + /** + * Merges default Home Sections with Admin/User Config overrides + * Now reads from all 4 groups: HOME, SEASONAL, DISCOVERY, CUSTOM + */ + async function mergeHomeSectionConfigs(defaultSections) { + const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen; + const homeScreenConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig; + + const merged = []; + + // Helper function to merge sections with user config overrides + const mergeSectionWithUserConfig = (section) => { + let userSectionConfig = null; + + if (section.id.includes('.')) { + const parts = section.id.split('.'); + if (userConfig && userConfig[parts[0]] && userConfig[parts[0]][parts[1]]) { + userSectionConfig = userConfig[parts[0]][parts[1]]; + } + } else if (userConfig) { + userSectionConfig = userConfig[section.id]; + } + + if (userSectionConfig) { + return { + ...section, + ...userSectionConfig, + id: section.id + }; + } + return section; + }; + + // Process HOME_SECTION_GROUPS (default + admin overrides) + const defaultHome = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); + const adminHome = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.HOME_SECTION_GROUPS || []) : []; + const defaultHomeIds = new Set(defaultHome.map(s => s.id)); + + // Merge default home sections with user config + defaultHome.forEach(section => { + merged.push(mergeSectionWithUserConfig(section)); + }); + + // Add admin overrides for home sections (including recently-added) + adminHome.forEach(section => { + const existingIndex = merged.findIndex(s => s.id === section.id); + if (existingIndex !== -1) { + // Update existing section + merged[existingIndex] = { ...merged[existingIndex], ...section }; + } else if (!defaultHomeIds.has(section.id)) { + // New section from admin config (e.g., recently-added) + merged.push(section); + } + }); + + // Process SEASONAL_SECTION_GROUPS (default + admin overrides) + const defaultSeasonal = flattenSectionGroups(Config.SEASONAL_SECTION_GROUPS || []); + const adminSeasonal = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.SEASONAL_SECTION_GROUPS || []) : []; + const defaultSeasonalIds = new Set(defaultSeasonal.map(s => s.id)); + + // Merge default seasonal sections with user config + defaultSeasonal.forEach(section => { + merged.push(mergeSectionWithUserConfig(section)); + }); + + // Add admin overrides for seasonal sections + adminSeasonal.forEach(section => { + const existingIndex = merged.findIndex(s => s.id === section.id); + if (existingIndex !== -1) { + // Update existing section + merged[existingIndex] = { ...merged[existingIndex], ...section }; + } else if (!defaultSeasonalIds.has(section.id)) { + // New seasonal section from admin config + merged.push(section); + } + }); + + // Process CUSTOM_SECTION_GROUPS (user-created/imported sections) + const customSections = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.CUSTOM_SECTION_GROUPS || []) : []; + customSections.forEach(section => { + if (section.discoveryEnabled === true) { + // Skip if discovery is enabled, the section will be rendered in the discovery sections + return; + } + if (!merged.find(s => s.id === section.id)) { + merged.push(section); + } + }); + + // Legacy support: Also check old recentlyAddedInLibrary location for backward compatibility + if (isRecentlyAddedEnabled() && userConfig) { + const libraryIds = Object.keys(userConfig.recentlyAddedInLibrary || {}); + const libraries = await window.dataHelper.getLibraries(); + + libraryIds.forEach(libId => { + const libConfig = userConfig.recentlyAddedInLibrary[libId]; + const sectionId = `recently-added-${libId}`; + + // Skip if already added from HOME_SECTION_GROUPS + if (merged.find(s => s.id === sectionId)) { + return; + } + + if (libConfig && libConfig.enabled !== false) { + const library = libraries.find(l => l.Id === libId); + + if (!library) { + LOG(`Library not found: ${libId}`); + return; + } + + if (library.CollectionType && + library.CollectionType !== 'movies' && + library.CollectionType !== 'tvshows' && + library.CollectionType !== 'music' && + library.CollectionType !== 'homevideos' && + library.CollectionType !== 'musicvideos' && + library.CollectionType !== 'musicvideos' && + library.CollectionType !== 'books') { + LOG(`Skipping library: ${library.CollectionType}`); + return; + } + + let viewMoreUrl = null; + if (library.CollectionType === 'movies') { + viewMoreUrl = `#/movies.html?topParentId=${libId}&collectionType=movies&tab=1`; + } else if (library.CollectionType === 'tvshows') { + viewMoreUrl = `#/tv.html?topParentId=${libId}&collectionType=tvshows&tab=1`; + } + + // Convert to new format with queries array + const newSection = { + id: sectionId, + name: libConfig.name || 'Recently Added', + enabled: true, + order: libConfig.order || 61, + cardFormat: libConfig.cardFormat || 'Poster', + viewMoreUrl: viewMoreUrl, + queries: [{ + path: `/Items/Latest`, + queryOptions: { + ParentId: libId, + SortBy: 'DateCreated', + SortOrder: 'Descending', + Limit: libConfig.itemLimit || 16 + } + }] + } + + merged.push(newSection); + } + }); + } + + // Handle MergeNextUp setting - check if continueWatchingAndNextUp should be enabled + const mergeNextUp = getMergeNextUpSetting(); + if (mergeNextUp) { + // Find and disable nextUp and continueWatching sections when merged + const nextUpIndex = merged.findIndex(s => s.id === 'nextUp'); + const continueWatchingIndex = merged.findIndex(s => s.id === 'continueWatching'); + if (nextUpIndex !== -1) { + merged[nextUpIndex].enabled = false; + } + if (continueWatchingIndex !== -1) { + merged[continueWatchingIndex].enabled = false; + } + // Enable the merged section + const mergedIndex = merged.findIndex(s => s.id === 'continueWatchingAndNextUp'); + if (mergedIndex !== -1) { + merged[mergedIndex].enabled = true; + LOG('MergeNextUp enabled: Using continueWatchingAndNextUp section'); + } + } else { + // Disable merged section if merge is not enabled + const mergedIndex = merged.findIndex(s => s.id === 'continueWatchingAndNextUp'); + if (mergedIndex !== -1) { + merged[mergedIndex].enabled = false; + } + } + + // Filter seasonal sections based on date + const filteredMerged = merged.filter(section => { + if (section.startDate && section.endDate) { + if (section.enabled === false) { + LOG(`Skipping disabled seasonal section: ${section.id}`); + return false; + } + if (!isInSeasonalPeriod(section.startDate, section.endDate)) { + LOG(`Skipping seasonal section (out of date): ${section.id}`); + return false; + } + } + return true; + }); + + // Legacy support: Also check old customSections location + if (userConfig && userConfig.customSections) { + Object.keys(userConfig.customSections).forEach(key => { + if (!filteredMerged.find(s => s.id === key)) { + filteredMerged.push(userConfig.customSections[key]); + } + }); + } + + // Legacy support: Also check old seasonal location + if (userConfig && userConfig.seasonal?.seasons) { + const seasonalSections = userConfig.seasonal.seasons.flatMap(s => s.sections.map(section => ({ + ...section, + startDate: s.startDate, + endDate: s.endDate + }))); + seasonalSections.forEach(section => { + if (!filteredMerged.find(s => s.id === section.id)) { + if (section.enabled === false) { + LOG(`Skipping disabled seasonal section: ${section.id}`); + return; + } + if (section.startDate && section.endDate) { + if (!isInSeasonalPeriod(section.startDate, section.endDate)) { + LOG(`Skipping seasonal section (out of date): ${section.id}`); + return; + } + } + filteredMerged.push(section); + } + }); + } + + return filteredMerged; + } + + function getDiscoverySections() { + const settings = getDiscoverySettings(); + + if (!settings || settings.enabled === false) { + LOG('Discovery is disabled'); + return []; + } + + const homeScreenConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig; + + // Get default discovery sections + const defaultDiscovery = flattenSectionGroups(Config.DISCOVERY_SECTION_GROUPS || []); + + // Get admin overrides for discovery sections + const adminDiscoverySections = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig && window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS && flattenSectionGroups(window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS); + const mappedSections = Object.keys(adminDiscoverySections || {}).map(key => ({ + id: key, + ...adminDiscoverySections[key] + })); + + // Merge default discovery sections with admin overrides + const mergedSections = defaultDiscovery.map(section => { + const mappedSection = mappedSections.find(s => s.id === section.id); + if (mappedSection) { + return { ...section, ...mappedSection, type: section.type }; + } + return section; + }); + + // Get custom discovery sections from CUSTOM_SECTION_GROUPS + // Filter for sections that have discovery-related properties (type, source: 'Dynamic', etc.) + const customSections = homeScreenConfig ? flattenSectionGroups(homeScreenConfig.CUSTOM_SECTION_GROUPS || []) : []; + const customDiscoverySections = customSections.filter(section => { + return section.discoveryEnabled === true && section.enabled === true; + }); + + // Add one additional custom discovery section that hasn't been rendered yet + if (customDiscoverySections.length > 0) { + const availableSections = customDiscoverySections.filter(section => !state.renderedDiscoveryIds.customDiscoverySections.has(section.id)); + if (availableSections.length > 0) { + const selectedSection = availableSections[Math.floor(Math.random() * availableSections.length)]; + if (selectedSection) { + mergedSections.push(selectedSection); + state.renderedDiscoveryIds.customDiscoverySections.add(selectedSection.id); + } + } + } + + // Legacy support: Also check old customSections location + const legacyCustomSections = window.KefinTweaksConfig?.homeScreen?.customSections || []; + if (legacyCustomSections.length > 0) { + legacyCustomSections.forEach(section => { + if (section.enabled && section.discoveryEnabled) { + if (state.renderedDiscoveryIds.customDiscoverySections.has(section.id)) { + return; + } + mergedSections.push(section); + state.renderedDiscoveryIds.customDiscoverySections.add(section.id); + } + }); + } + + return mergedSections; + } + + function getDiscoverySettings() { + const userConfig = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig; + if (!userConfig) { + return Config.DISCOVERY_SETTINGS; + } + + return { + ...Config.DISCOVERY_SETTINGS, + ...userConfig.DISCOVERY_SETTINGS + }; + } + + /** + * Load user home screen preferences + */ + async function loadUserHomeScreenSections() { + try { + //if (!state.userDisplayPreferences) { + // state.userDisplayPreferences = await fetchDisplayPreferences(); + //} + const userDisplayPreferences = await state.getDisplayPrefernces(); + + const customPrefs = userDisplayPreferences?.CustomPrefs || {}; + const kefinHomeScreen = JSON.parse(customPrefs.kefinHomeScreen || '[]'); + + // Create a homesectionN : order value mapping for the custom CSS orders to be applied + const jellyfinOrders = {}; + for (let i = 0; i <= 8; i++) { + const homeSection = customPrefs[`homesection${i}`]; + + if (homeSection.toLowerCase() === 'nextup' && state.kefinNextUp) { + continue; + } + if (homeSection.toLowerCase() === 'resume' && state.kefinContinueWatching) { + continue; + } + if (homeSection.toLowerCase() === 'latestmedia' && state.kefinLatestMedia) { + continue; + } + + // Match to homeSection in kefinHomeScreen with .toLowerCase() id + let kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase() === homeSection.toLowerCase()); + + if (!kefinHomeScreenSection) { + // Check if it's "latestmedia" + if (homeSection.toLowerCase() === 'latestmedia' && !state.kefinLatestMedia) { + kefinHomeScreenSection = kefinHomeScreen.find(s => s.id.toLowerCase().startsWith('recently-added')); + } + } + + if (kefinHomeScreenSection) { + jellyfinOrders[`homesection${i}`] = kefinHomeScreenSection.order; + } + } + + addUserHomeScreenOrderCSS(jellyfinOrders); + + return kefinHomeScreen; + + /* if (!Array.isArray(kefinHomeScreen)) { + return { enabledSectionIds: new Set(), orderMap: new Map(), jellyfinOrders: {} }; + } + + // Create set of enabled section IDs + const enabledSectionIds = new Set(kefinHomeScreen.map(s => s['section-id'])); + + // Create map of section-id -> order + const orderMap = new Map(); + kefinHomeScreen.forEach(pref => { + if (pref['section-id'] && pref.order !== undefined) { + orderMap.set(pref['section-id'], pref.order); + } + }); + + // Get Jellyfin section orders (homesectionN -> order mapping) + const jellyfinOrders = {}; + kefinHomeScreen.forEach(pref => { + const sectionId = pref['section-id']; + if (sectionId && sectionId.startsWith('homesection') && pref.order !== undefined) { + jellyfinOrders[sectionId] = pref.order; + } + }); + + return { enabledSectionIds, orderMap, jellyfinOrders }; */ + } catch (error) { + ERR('Error loading user home screen preferences:', error); + return null; + } + } + + /** + * Add CSS for user-defined section ordering + */ + function addUserHomeScreenOrderCSS(jellyfinOrders) { + // Remove existing style tag if present + const existingStyle = document.getElementById('kefin-user-homescreen-order-css'); + if (existingStyle) { + existingStyle.remove(); + } + + // Create new style tag + const style = document.createElement('style'); + style.id = 'kefin-user-homescreen-order-css'; + + let css = ''; + + // Add CSS for Jellyfin sections + Object.entries(jellyfinOrders).forEach(([homesectionKey, order]) => { + // Map homesectionN to class selector + const classSelector = `.${homesectionKey.replace('home','')}`; + css += `${classSelector} { order: ${order} !important; }\n`; + }); + + // Add display: none to all .homeectionN not in jellyfinOrders + for (let i = 0; i <= 8; i++) { + const homesectionKey = `homesection${i}`; + if (!jellyfinOrders[homesectionKey]) { + const classSelector = `.${homesectionKey.replace('home','')}`; + css += `${classSelector} { display: none !important; }\n`; + } + } + + style.textContent = css; + document.head.appendChild(style); + + LOG('User home screen order CSS applied'); + } + + /** + * Filter sections based on user preferences + */ + function filterSectionsByUserPreferences(sections, userPrefs) { + const { enabledSectionIds } = userPrefs; + + // If no user preferences, return all sections + if (enabledSectionIds.size === 0) { + return sections; + } + + // Filter out disabled sections (not in enabledSectionIds) + return sections.filter(section => { + const sectionId = section.id; + return enabledSectionIds.has(sectionId); + }); + } + + /** + * Apply user-defined orders to sections + */ + function applyUserSectionOrders(sections, userPrefs) { + const { orderMap } = userPrefs; + + return sections.map(section => { + const sectionId = section.id; + const userOrder = orderMap.get(sectionId); + + if (userOrder !== undefined) { + return { + ...section, + order: userOrder + }; + } + + return section; + }); + } + + async function renderHomeSections(sections, container, useCache = false) { + if (!container) { + WARN('renderHomeSections called without a valid container'); + return; + } + + // Skip if we've already rendered into this container for this cycle and we're not explicitly re-using the cache + if (container.dataset.sectionsRendered && !useCache) return; + + // Check if children with [data-section-id] are present + const children = container.children; + const hasSections = Array.from(children).some(child => child.dataset && child.dataset.sectionId); + if (hasSections) { + LOG('Sections already rendered, skipping'); + return; + } + + container.dataset.sectionsRendered = 'true'; + + const sectionsToRender = []; + + if (useCache && sectionCache.renderedSections.length > 0) { + LOG('Re-rendering home sections from cache'); + sectionsToRender.push(...sectionCache.renderedSections); + } else { + const sortedSections = [...sections].sort((a, b) => (a.order || 99) - (b.order || 99)); + const sectionPromises = []; + + let performanceStartTime = performance.now(); + let performanceEndTime; + let performanceDuration; + + const performanceTimes = []; + for (const sectionConfig of sortedSections) { + const loopStartTime = performance.now(); + if (sectionConfig.enabled === false) { + LOG(`Skipping disabled section: ${sectionConfig.id}`); + continue; + } + + if (sectionConfig.startDate && sectionConfig.endDate) { + if (!isInSeasonalPeriod(sectionConfig.startDate, sectionConfig.endDate)) { + LOG(`Skipping seasonal section (out of date): ${sectionConfig.id}`); + continue; + } + } + + const existingSection = container.querySelector(`[data-section-id="${sectionConfig.id}"]`); + + if (existingSection && !useCache) { + LOG(`Section already rendered: ${sectionConfig.id}`); + continue; + } + + // Check merge setting for continueWatchingAndNextUp + if (sectionConfig.id === 'continueWatchingAndNextUp' && !getMergeNextUpSetting()) { + LOG(`Skipping continueWatchingAndNextUp: merge not enabled`); + continue; + } + + // Skip individual sections when merge is enabled + if (getMergeNextUpSetting() && + (sectionConfig.id === 'continueWatching' || sectionConfig.id === 'nextUp')) { + LOG(`Skipping ${sectionConfig.id}: merge enabled, using continueWatchingAndNextUp`); + continue; + } + + sectionPromises.push(loadSectionForRendering(sectionConfig)); + + const loopEndTime = performance.now(); + const loopDuration = loopEndTime - loopStartTime; + performanceTimes.push(loopDuration); + LOG(`Section ${sectionConfig.id} initialization time: ${loopDuration.toFixed(2)}ms`); + } + + performanceEndTime = performance.now(); + performanceDuration = performanceEndTime - performanceStartTime; + LOG(`Render home sections configs loaded initialization time: ${performanceDuration.toFixed(2)}ms`); + + LOG(`Total performance time: ${performanceTimes.reduce((a, b) => a + b, 0).toFixed(2)}ms`); + + performanceStartTime = performance.now(); + + // Resolve all section promises + let sectionResults = await Promise.all(sectionPromises); + sectionResults = sectionResults.filter(result => result !== null); + sectionsToRender.push(...sectionResults); + + performanceEndTime = performance.now(); + performanceDuration = performanceEndTime - performanceStartTime; + LOG(`Home Screen v3 Resolve all section promises initialization time: ${performanceDuration.toFixed(2)}ms`); + + // Cache the rendered sections for potential re-render after Jellyfin overwrites the container + sectionCache.renderedSections = sectionsToRender.slice(); + } + + const targetContainer = container; + + const renderStartTime = performance.now(); + await window.cardBuilder.renderProgressiveSections(targetContainer, sectionsToRender); + const renderEndTime = performance.now(); + const renderDuration = renderEndTime - renderStartTime; + LOG(`Home Screen v3 Render progressive sections initialization time: ${renderDuration.toFixed(2)}ms`); + + // Check if target container is homeSectionsContainer + if (!targetContainer.classList.contains('homeSectionsContainer')) { + targetContainer.dataset.sectionsPrerendered = 'true'; + } + } + + function flattenSeriesEpisodes(items) { + if (!items || items.length === 0) return items; + + // Keep only the first item in the array for any given series + const seriesMap = new Map(); + items.forEach(item => { + if (item.Type !== 'Episode') { + return; + } + + if (seriesMap.has(item.SeriesId)) { + return; + } + + seriesMap.set(item.SeriesId, item); + }); + + return Array.from(seriesMap.values()); + } + + function formatAirDate(premiereDate) { + if (!premiereDate) return ''; + + // Ignore timezone offset + const date = new Date(premiereDate.replace('Z', '')); + + // Use format: Tues Jan 4 + const options = { weekday: 'short', month: 'short', day: 'numeric' }; + return date.toLocaleDateString('en-US', options); + } + + function getMergeNextUpSetting() { + const userConfig = window.KefinTweaksConfig?.homeScreen; + const defaultConfig = Config?.MERGE_NEXT_UP; + + if (userConfig?.mergeNextUp !== undefined) { + return userConfig.mergeNextUp; + } + return defaultConfig !== false; + } + + let _displayPreferencesPromise = null; + + async function fetchDisplayPreferences() { + try { + const { promise, cached } = await window.userHelper.getUserDisplayPreferences(); + return await promise; +/* if (state.userDisplayPreferences) { + return state.userDisplayPreferences; + } + + const { promise, cached } = await window.userHelper.getUserDisplayPreferences(); + state.userDisplayPreferences = cached; + + _displayPreferencesPromise = promise; + + _displayPreferencesPromise.then(data => { + state.userDisplayPreferences = data; + }); + + return state.userDisplayPreferences; */ + } catch (e) { + ERR('Failed to fetch display preferences:', e); + return null; + } + } + + async function removeConflictingSection() { + return; // Disabled - kept for compatibility + } + + function isNextUpEnabled() { + const userConfig = window.KefinTweaksConfig?.homeScreen; + const homeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); + const defaultConfig = homeSections.find(s => s.id === 'nextUp'); + + if (userConfig?.nextUp) { + return userConfig.nextUp.enabled !== false; + } + return defaultConfig?.enabled !== false; + } + + function isContinueWatchingEnabled() { + const userConfig = window.KefinTweaksConfig?.homeScreen; + const homeSections = flattenSectionGroups(Config.HOME_SECTION_GROUPS || []); + const defaultConfig = homeSections.find(s => s.id === 'continueWatching'); + + if (userConfig?.continueWatching) { + return userConfig.continueWatching.enabled !== false; + } + return defaultConfig?.enabled !== false; + } + + function isRecentlyAddedEnabled() { + const userConfig = window.KefinTweaksConfig?.homeScreen; + return userConfig?.recentlyAddedInLibrary && Object.keys(userConfig.recentlyAddedInLibrary).length > 0; + } + + /** + * DEPRECATED: Use ApiHelper.buildQueryFromSection() instead + * Kept for backward compatibility + */ + function buildQueryFromConfig(query, userId, serverUrl) { + return ApiHelper.buildQueryFromSection(query, userId, serverUrl); + } + + /** + * DEPRECATED: Use ApiHelper.buildCustomEndpoint() instead + * Kept for backward compatibility + */ + function buildCustomEndpoint(path, queryOptions, userId) { + return ApiHelper.buildCustomEndpoint(path, queryOptions, userId, ApiClient.serverAddress()); + } + + /** + * Refactored: Load section for rendering using queries array + */ + async function loadSectionForRendering(sectionConfig) { + LOG(`Loading Section for Rendering: ${sectionConfig.id} (${sectionConfig.name})`); + + const loadSectionTimerStart = performance.now(); + + // Prepare section (template replacements, view more URL, etc.) + const viewMoreUrl = await resolveViewMoreUrl(sectionConfig); + const cardFormat = resolveCardFormat(sectionConfig); + const sectionName = fillTemplate(sectionConfig.name, sectionConfig.metadata); +/* const viewMoreUrl = ''; + const cardFormat = ''; + const sectionName = ''; */ + + sectionConfig.overflowCard = true; + + if (viewMoreUrl) { + sectionConfig.viewMoreUrl = viewMoreUrl; + } + if (cardFormat) { + sectionConfig.cardFormat = cardFormat; + } + if (sectionName) { + sectionConfig.name = sectionName; + } + + // Check for forced image type preference + let forcedImageType = null; + if ((sectionConfig.id === 'nextUp' || sectionConfig.id === 'continueWatching' || sectionConfig.id === 'continueWatchingAndNextUp')) { + const userDisplayPreferences = await state.getDisplayPrefernces(); + if (userDisplayPreferences && userDisplayPreferences.CustomPrefs && userDisplayPreferences.CustomPrefs.useEpisodeImagesInNextUpAndResume === 'true') { + forcedImageType = 'Primary'; + } + } + + if (forcedImageType) { + sectionConfig.forcedImageType = forcedImageType; + } + + // Check renderMode first (preferred), fallback to spotlight boolean for backward compatibility + const shouldRenderSpotlight = sectionConfig.renderMode === 'Spotlight' || sectionConfig.renderMode === 'Random' || sectionConfig.spotlight === true; + if (shouldRenderSpotlight) { + const spotlightConfig = { ...Config.DISCOVERY_SETTINGS.spotlight, ...window.KefinTweaksConfig?.homeScreen?.spotlight }; + sectionConfig.spotlightConfig = { + ...spotlightConfig + }; + // Set renderMode if not already set (for backward compatibility) + if (!sectionConfig.renderMode && sectionConfig.spotlight) { + sectionConfig.renderMode = 'Spotlight'; + } + } + + // Ensure queries array exists + if (!sectionConfig.queries || !Array.isArray(sectionConfig.queries) || sectionConfig.queries.length === 0) { + WARN(`Section ${sectionConfig.id} has no queries array`); + return null; + } + + const userId = ApiClient.getCurrentUserId(); + const serverUrl = ApiClient.serverAddress(); + const results = []; + + // Process each query in the queries array + for (const query of sectionConfig.queries) { + let queryResult; + + if (query.dataSource) { + // Handle cache-based data sources + queryResult = await ApiHelper.fetchFromDataSource(query.dataSource, query.queryOptions || {}); + } else { + // Build and execute query + const queryUrl = ApiHelper.buildQueryFromSection(query, userId, serverUrl); + + if (typeof queryUrl === 'string') { + // Standard query + let sectionTtl = Config.CACHE.DEFAULT_TTL; + if (Number(sectionConfig.ttl) >= 0) { + sectionTtl = Number(sectionConfig.ttl); + } + + queryResult = await ApiHelper.getQuery(queryUrl, { + useCache: true, + ttl: sectionTtl + }); + } else { + WARN(`Invalid query URL for section ${sectionConfig.id}`); + continue; + } + } + + results.push(queryResult); + } + + // If multiple queries, merge results using section-level sortBy/sortOrder + if (results.length > 1) { + const loadSectionTimerEnd = performance.now(); + const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; + LOG(`Section ${sectionConfig.id} loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); + return ApiHelper.mergeMultiQueryResults(results, sectionConfig); + } + + const loadSectionTimerEnd = performance.now(); + const loadSectionDuration = loadSectionTimerEnd - loadSectionTimerStart; + LOG(`Section ${sectionConfig.id} loaded for rendering in time: ${loadSectionDuration.toFixed(2)}ms`); + + // Single query result + return { + config: sectionConfig, + queryUrl: null, + result: { + data: window.cardBuilder.postProcessItems(sectionConfig, results[0].data), + dataPromise: results[0].dataPromise.then(data => window.cardBuilder.postProcessItems(sectionConfig, data)), + isStalePromise: results[0].isStalePromise + } + }; + } + + function deduplicateItems(items) { + const deduplicated = []; + const ids = new Set(); + for (const item of items) { + if (!item.ProviderIds) { + deduplicated.push(item); + continue; + } + + if (item.ProviderIds.Imdb && ids.has(item.ProviderIds.Imdb)) continue; + if (item.ProviderIds.Tmdb && ids.has(item.ProviderIds.Tmdb)) continue; + if (item.ProviderIds.Tvdb && ids.has(item.ProviderIds.Tvdb)) continue; + + deduplicated.push(item); + ids.add(item.ProviderIds.Imdb); + ids.add(item.ProviderIds.Tmdb); + ids.add(item.ProviderIds.Tvdb); + } + return deduplicated; + } + + let _getWatchlistUrlPromise = null; + + async function getWatchlistUrl() { + const watchlistTabIndex = localStorage.getItem(`kefinTweaks_watchlistTabIndex_${ApiClient.serverId()}`); + + if (watchlistTabIndex) { + return `#/home.html?tab=${watchlistTabIndex}`; + } + + LOG('Watchlist tab index not found in local storage'); + // Fetch the tab in the background so that next time it's available + window.KefinTweaksUtils.getWatchlistTabIndex(); + return null; + + + let homePageSuffix = '.html'; + if (window.ApiClient && window.ApiClient.serverInfo && window.ApiClient.serverInfo().Version?.split('.')[1] > 10) { + homePageSuffix = ''; + } + + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.getWatchlistTabIndex) { + WARN('KefinTweaksUtils not available'); + return null; + } + + const visibleWatchlistTabIndex = await window.KefinTweaksUtils.getWatchlistTabIndex(); + + if (visibleWatchlistTabIndex) { + return `#/home${homePageSuffix}?tab=${visibleWatchlistTabIndex}`; + } else { + WARN('Watchlist tab not found'); + return null; + } + } + + /** + * Updated: Resolve view more URL from queryOptions + */ + async function resolveViewMoreUrl(sectionConfig) { + if (sectionConfig.viewMoreUrl) return sectionConfig.viewMoreUrl; + + const { id } = sectionConfig; + + // Handle specific section IDs + if (id === 'continueWatching') { + return `#/tv.html?collectionType=tvshows&tab=1&serverId=${ApiClient.serverId()}`; + } + + if (id === 'popularTVNetworks') { + return `#/tv.html?collectionType=tvshows&tab=4&serverId=${ApiClient.serverId()}`; + } + + if (id === 'upcoming' || id === 'recentlyReleased.episodes') { + return `#/tv.html?collectionType=tvshows&tab=2&serverId=${ApiClient.serverId()}`; + } + + if (id === 'nextUp') { + return `#/list.html?type=nextup&serverId=${ApiClient.serverId()}`; + } + + if (id === 'watchlist') { + return await getWatchlistUrl(); + } + + if (id === 'watchAgain') { + const watchlistUrl = await getWatchlistUrl(); + + if (watchlistUrl) { + return `${watchlistUrl}&pageTab=history`; + } + return null; + } + + // Infer from queries array + if (sectionConfig.queries && sectionConfig.queries.length > 0) { + const firstQuery = sectionConfig.queries[0]; + const queryOptions = firstQuery.queryOptions || {}; + const serverId = window.ApiClient ? window.ApiClient.serverId() : ''; + + // Check for Tags + if (queryOptions.Tags) { + return `#/list.html?type=tag&tag=${encodeURIComponent(queryOptions.Tags)}&serverId=${serverId}`; + } + + // Check for Genres + if (queryOptions.Genres) { + let genreId = queryOptions.Genres; + if (typeof genreId === 'string' && !genreId.match(/^\d+$/)) { + const id = await ApiHelper.getGenreId(genreId); + if (id) { + genreId = id; + } + } + return `#/list.html?genreId=${genreId}&serverId=${serverId}`; + } + + // Check for GenreIds + if (queryOptions.GenreIds) { + return `#/list.html?genreId=${queryOptions.GenreIds.join(',')}&serverId=${serverId}`; + } + + // Check for PersonIds + if (queryOptions.PersonIds) { + return `#/details?id=${queryOptions.PersonIds.join(',')}&serverId=${serverId}`; + } + + // Check for StudioIds + if (queryOptions.StudioIds) { + return `#/list.html?studioId=${queryOptions.StudioIds.join(',')}&serverId=${serverId}`; + } + + // Check for Studios +/* if (queryOptions.Studios) { + return `#/list.html?studioId=${queryOptions.Studios}&serverId=${serverId}`; + } */ + + // Check for ParentId + if (queryOptions.ParentId) { + if (sectionConfig.type === 'Collection' || sectionConfig.type === 'Playlist') { + return `#/details?id=${queryOptions.ParentId}&serverId=${serverId}`; + } + + return `#/list.html?parentId=${queryOptions.ParentId}&serverId=${serverId}`; + } + + // Check for custom path + if (firstQuery.path) { + // Derive URL from path if possible + if (firstQuery.path.includes('NextUp')) { + return `#/tv.html?collectionType=tvshows&tab=1`; + } + if (firstQuery.path.includes('Upcoming')) { + return `#/tv.html?collectionType=tvshows&tab=2`; + } + // If query path is Items/itemid/Similar then return the details page for the item + if (firstQuery.path.includes('Similar')) { + const itemId = firstQuery.path.split('/')[2]; + return `#/details?id=${itemId}&serverId=${serverId}`; + } + } + } + + return null; + } + + function postProcessItems(sectionConfig, itemsData) { + let processed = itemsData?.Items || itemsData || []; + + // Flatten Series Episodes + if (sectionConfig.flattenSeries === true) { + processed = flattenSeriesEpisodes(processed); + } + + if (sectionConfig.id === 'upcoming') { + // Remove items which aired before today + const today = new Date(); + today.setHours(0, 0, 0, 0); + processed = processed.filter(item => { + if (!item.PremiereDate || item.LocationType !== 'Virtual') + return false; + const premiereDate = new Date(item.PremiereDate); + premiereDate.setHours(0, 0, 0, 0); + return premiereDate >= today; + }); + } + + // Add Custom Secondary Text for Recently Released Movies + if (sectionConfig.id === 'recentlyReleased.movies' || sectionConfig.id === 'upcoming' || sectionConfig.id === 'recentlyReleased.episodes') { + processed.forEach(item => { + if (item.PremiereDate) { + item.CustomFooterText = formatAirDate(item.PremiereDate); + } + }); + } + + // Apply local limits for non-API sources + if (sectionConfig.itemLimit) { + processed = processed.slice(0, sectionConfig.itemLimit); + } + + // Deduplicate items based on ProviderIds. Look for match imdb, tmdb or tvdb ids + processed = deduplicateItems(processed); + + return processed; + } + + function resolveCardFormat(sectionConfig) { + return sectionConfig.cardFormat || 'Poster'; + } + + /** + * Logic to determine the "Source" for dynamic sections + * Returns { id, name, metadata: {} } + */ + async function resolveDynamicSource(config) { + switch (config.type) { + case 'Genre': + const genre = await getRandomGenre(); + return genre ? { id: genre.Id, name: genre.Name, metadata: { Genre: genre.Name } } : null; + + case 'Person': + if (config.sourceType && config.sourceType.includes('watched')) { + const result = await getRandomPersonFromHistory(config.personType, config.sourceType); + if (!result) return null; + + return { + id: result.person.id, + name: result.person.name, + excludeItemId: result.sourceItem.Id, + metadata: { + Person: result.person.name, + Actor: result.person.name, + Director: result.person.name, + Writer: result.person.name, + Title: result.sourceItem.Name, + Movie: result.sourceItem.Name + } + }; + } + + const person = await getRandomPerson(config.personType); + return person ? { + id: person.id, + name: person.name, + metadata: { + Person: person.name, + Actor: person.name, + Director: person.name, + Writer: person.name + } + } : null; + + case 'Studio': + const networks = await StudiosCache.getPopularTVNetworks(); // getPopularTVNetworks(); + if (!networks || !networks.length) return null; + const network = networks[Math.floor(Math.random() * networks.length)]; + return { id: network.Id, name: network.Name, metadata: { Studio: network.Name } }; + + case 'Similar': + if (config.sourceType === 'watched') { + const m = getRandomWatchedMovie(); + return m ? { + id: m.Id, + metadata: { + Title: m.Name, + Movie: m.Name + } + } : null; + } + if (config.sourceType === 'liked') { + const m = await getRandomFavoriteMovie(); + return m ? { + id: m.Id, + metadata: { + Title: m.Name, + Movie: m.Name + } + } : null; + } + if (config.sourceType === 'watched-recent') { + const m = getRandomWatchedMovie(); + return m ? { + id: m.Id, + metadata: { + Title: m.Name, + Movie: m.Name + } + } : null; + } + return null; + + case 'Collection': + const selectedCollection = await getRandomCollectionForDiscovery(config.minimumItems || 3); + if (!selectedCollection) return null; + + return { + id: selectedCollection.Id, + name: selectedCollection.Name, + metadata: { Collection: selectedCollection.Name, "Collection Name": selectedCollection.Name } + }; + } + + return null; + } + + function fillTemplate(template, data) { + if (!data || !template) return template; + + // Support both {Key} and [Key] formats, allowing spaces in keys + return template.replace(/\{([^}]+)\}|\[([^\]]+)\]/g, (match, key1, key2) => { + const key = key1 || key2; + return data[key] || match; + }); + } + + function disconnectDiscoveryInteraction() { + // If we are using infinite scroll, disconnect the scroll event listener + if (state.infiniteScrollHandler) { + window.removeEventListener('scroll', state.infiniteScrollHandler); + state.infiniteScrollHandler = null; + } else { + const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); + if (loadMoreButton) { + loadMoreButton.remove(); + } + } + + // Remove the discovery loading indicator + const loadingIndicator = document.querySelector('.libraryPage:not(.hide) #discovery-loading-indicator'); + if (loadingIndicator) { + loadingIndicator.remove(); + } + } + + /** + * Ensures discovery buffer is populated (pre-fetches next group) + */ + async function ensureDiscoveryBuffer() { + if (state.ensuringDiscoveryBuffer) { + LOG('Already ensuring discovery buffer...'); + + while (state.ensuringDiscoveryBuffer) { + LOG('Waiting for discovery buffer to be populated...'); + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return; + } + LOG('Ensuring discovery buffer...'); + + state.ensuringDiscoveryBuffer = true; + + if (state.discoveryBuffer && state.discoveryBuffer.length > 0) { + LOG('Discovery buffer already exists...'); + state.ensuringDiscoveryBuffer = false; + return state.discoveryBuffer; + } + + LOG('Fetching next discovery group data...'); + + const groupSections = await generateDiscoveryGroup(); + + if (!groupSections || groupSections.length === 0) { + LOG('No more discovery sections available for buffer.'); + state.ensuringDiscoveryBuffer = false; + state.discoverySectionsRemain = false; + disconnectDiscoveryInteraction(); + return; + } + + state.discoveryBuffer = await Promise.all(groupSections.map(async (sectionConfig) => { + // Use loadSectionForRendering which handles all queries, multi-query merging, and post-processing + return await loadSectionForRendering(sectionConfig); + })); + + // Filter out any null results + state.discoveryBuffer = state.discoveryBuffer.filter(result => result !== null); + + state.ensuringDiscoveryBuffer = false; + return state.discoveryBuffer; + } + + + async function setupDiscoveryInteraction(container) { + const userConfig = getDiscoverySettings(); + + if (userConfig && userConfig.enabled === false) { + LOG('Discovery sections disabled in config.'); + state.discoveryEnabled = false; + return; + } + + if (!container) { + WARN('setupDiscoveryInteraction called without a valid container'); + return; + } + + if (PRE_FETCH_DISCOVERY_DATA) { + ensureDiscoveryBuffer(); + } + + createDiscoveryLoadingIndicator(container); + + const useInfiniteScroll = userConfig ? (userConfig.infiniteScroll !== false) : true; + + if (useInfiniteScroll) { + setupInfiniteScroll(container); + } else { + setupLoadMoreButton(container); + } + } + + async function renderNextDiscoveryGroup(container) { + if (state.isRenderingDiscovery) return; + state.isRenderingDiscovery = true; + container.dataset.loadingDiscovery = 'true'; + + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => setTimeout(resolve, 0)); + + try { + await ensureDiscoveryBuffer(); + + const bufferedSections = state.discoveryBuffer; + + if (!bufferedSections || bufferedSections.length === 0) { + LOG('No more discovery sections available.'); + state.discoveryBuffer = null; + return; + } + + LOG(`Rendering Discovery Group ${state.discoveryGroupIndex + 1} (${bufferedSections.length} sections)...`); + + await window.cardBuilder.renderProgressiveSections(container, bufferedSections); + + if (!getDiscoverySettings().infiniteScroll) { + setupLoadMoreButton(container); + } + + container.dataset.loadingDiscovery = 'false'; + container.classList.remove('loading-discovery'); + + state.discoveryGroupIndex++; + + state.discoveryBuffer = null; + if (PRE_FETCH_DISCOVERY_DATA) { + ensureDiscoveryBuffer(); + } + + } catch (e) { + ERR('Error rendering discovery group:', e); + state.discoveryBuffer = null; + } finally { + state.isRenderingDiscovery = false; + updateLoadMoreButtonVisibility(); + } + } + + /** + * Generates a balanced group of discovery sections + */ + async function generateDiscoveryGroup() { + if (state.firstDiscoveryBuffer) { + const firstBufferConfig = state.firstDiscoveryBuffer; + delete state.firstDiscoveryBuffer; + localCache.clear('firstDiscoveryBuffer'); + + firstBufferConfig.forEach(config => { + config.order = state.discoverySectionOrder++; + }); + + return firstBufferConfig; + } + +/* let discoveryTemplates = flattenSectionGroups(Config.DISCOVERY_SECTION_GROUPS || []); + const userDiscoverySectionGroups = window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig && window.KefinTweaksConfig.homeScreenConfig.DISCOVERY_SECTION_GROUPS; + const userDiscoverySections = userDiscoverySectionGroups ? flattenSectionGroups(userDiscoverySectionGroups) : []; + + if (userDiscoverySections.length > 0) { + discoveryTemplates = discoveryTemplates.map(tpl => { + const userSection = userDiscoverySections.find(s => s.id === tpl.id); + if (userSection) { + return { ...tpl, ...userSection }; + } + return tpl; + }); + } */ + + const selectedConfigs = []; + + let discoveryTemplates = getDiscoverySections(); + + if (getDiscoverySettings().randomizeOrder) { + discoveryTemplates = [...discoveryTemplates].sort(() => 0.5 - Math.random()); + } + + for (const template of discoveryTemplates) { + if (template.enabled === false) continue; + + if (template.queries && template.queries.length > 0) { + selectedConfigs.push(template); + continue; + } + + const instanceConfig = { + ...template, + id: `${template.id}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + }; + + const dynamicResult = await resolveDynamicSource(instanceConfig); + if (dynamicResult) { + instanceConfig.source = dynamicResult.id || dynamicResult.name; + instanceConfig.metadata = dynamicResult.metadata; + instanceConfig.excludeItemId = dynamicResult.excludeItemId; + + if (getDiscoverySettings().renderSpotlightAboveMatching) { + const configId = instanceConfig.id.split('-')[0]; + + if (configId === 'genreMovies' || configId === 'spotlightGenre') { + if (state.currentDiscoveryGenre) { + instanceConfig.source = state.currentDiscoveryGenre.id; + dynamicResult.metadata = state.currentDiscoveryGenre.metadata; + state.currentDiscoveryGenre = null; + } + else { + state.currentDiscoveryGenre = dynamicResult; + } + } + if (configId === 'studioShows' || configId === 'spotlightNetwork') { + if (state.currentDiscoveryStudio) { + instanceConfig.source = state.currentDiscoveryStudio.id; + dynamicResult.metadata = state.currentDiscoveryStudio.metadata; + state.currentDiscoveryStudio = null; + } + else { + state.currentDiscoveryStudio = dynamicResult; + } + } + } + + // Create queries array with proper queryOptions + const queryOptions = { + Recursive: 'true', + Limit: instanceConfig.itemLimit || 20 + }; + + // Add IncludeItemTypes if specified + if (instanceConfig.includeItemTypes && instanceConfig.includeItemTypes.length) { + queryOptions.IncludeItemTypes = instanceConfig.includeItemTypes; + } + + // Add SortBy and SortOrder if specified + if (instanceConfig.sortOrder) { + queryOptions.SortBy = instanceConfig.sortOrder; + } + if (instanceConfig.sortOrderDirection) { + queryOptions.SortOrder = instanceConfig.sortOrderDirection; + } + + // Handle type-specific query options + const resolvedSource = instanceConfig.source; + if (instanceConfig.type === 'Similar') { + // Similar type uses custom path + instanceConfig.queries = [{ + path: `/Items/${resolvedSource}/Similar`, + queryOptions: { + Limit: instanceConfig.itemLimit || 20, + Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData' + } + }]; + } else { + // Standard query types + switch (instanceConfig.type) { + case 'Genre': + if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { + queryOptions.GenreIds = [resolvedSource]; + } else if (resolvedSource) { + queryOptions.Genres = resolvedSource; + } + break; + case 'Tag': + if (resolvedSource) { + queryOptions.Tags = resolvedSource; + } + break; + case 'Person': + if (resolvedSource) { + queryOptions.PersonIds = [resolvedSource]; + } + if (instanceConfig.excludeItemId) { + queryOptions.ExcludeItemIds = Array.isArray(instanceConfig.excludeItemId) + ? instanceConfig.excludeItemId + : [instanceConfig.excludeItemId]; + } + break; + case 'Studio': + if (resolvedSource && resolvedSource.match(/^[a-f0-9]{32}$/)) { + queryOptions.StudioIds = [resolvedSource]; + } else if (resolvedSource) { + queryOptions.Studios = resolvedSource; + } + break; + case 'Collection': + case 'Parent': + if (resolvedSource) { + queryOptions.ParentId = resolvedSource; + } + break; + } + + // Add spotlight fields if needed + if (instanceConfig.spotlight) { + queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData,People,Genres'; + } else { + queryOptions.Fields = 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData'; + } + + instanceConfig.queries = [{ + queryOptions: queryOptions + }]; + } + + const sectionName = fillTemplate(instanceConfig.name, dynamicResult.metadata); + const cardFormat = resolveCardFormat(instanceConfig); + const viewMoreUrl = await resolveViewMoreUrl(instanceConfig); + + instanceConfig.name = sectionName; + instanceConfig.cardFormat = cardFormat; + instanceConfig.viewMoreUrl = viewMoreUrl; + + selectedConfigs.push(instanceConfig); + } + } + + if (getDiscoverySettings().renderSpotlightAboveMatching) { + const spotlightGenreSection = selectedConfigs.find(section => section.id.split('-')[0] === 'spotlightGenre'); + const spotlightNetworkSection = selectedConfigs.find(section => section.id.split('-')[0] === 'spotlightNetwork'); + + if (spotlightGenreSection) { + const genreSection = selectedConfigs.find(section => section.id.split('-')[0] === 'genreMovies'); + if (genreSection) { + const genreSectionIndex = selectedConfigs.indexOf(genreSection); + const spotlightGenreSectionIndex = selectedConfigs.indexOf(spotlightGenreSection); + + if (genreSectionIndex < spotlightGenreSectionIndex) { + selectedConfigs.splice(genreSectionIndex, 1); + selectedConfigs.splice(spotlightGenreSectionIndex, 0, genreSection); + } else if (genreSectionIndex > spotlightGenreSectionIndex) { + selectedConfigs.splice(genreSectionIndex, 1); + selectedConfigs.splice(spotlightGenreSectionIndex + 1, 0, genreSection); + } + } + } + if (spotlightNetworkSection) { + const studioSection = selectedConfigs.find(section => section.id.split('-')[0] === 'studioShows'); + if (studioSection) { + const studioSectionIndex = selectedConfigs.indexOf(studioSection); + const spotlightNetworkSectionIndex = selectedConfigs.indexOf(spotlightNetworkSection); + if (studioSectionIndex < spotlightNetworkSectionIndex) { + selectedConfigs.splice(studioSectionIndex, 1); + selectedConfigs.splice(spotlightNetworkSectionIndex, 0, studioSection); + } else if (studioSectionIndex > spotlightNetworkSectionIndex) { + selectedConfigs.splice(studioSectionIndex, 1); + selectedConfigs.splice(spotlightNetworkSectionIndex + 1, 0, studioSection); + } + } + } + } + + selectedConfigs.forEach((section) => { + section.order = state.discoverySectionOrder++; + }); + + localCache.set('discoveryBuffer', selectedConfigs); + + return selectedConfigs; + } + + function setupInfiniteScroll(container) { + if (container.dataset.discoveryHandler) return; + container.dataset.discoveryHandler = 'true'; + + state.infiniteScrollHandler = window.addEventListener('scroll', () => { + // Check if the user is on the home page + const currentView = window.KefinTweaksUtils?.getCurrentView(); + const isHomePage = currentView === 'home' || currentView === 'home.html'; + if (!isHomePage) return; + + // Check if the user is on the first tab + const activeTab = document.querySelector('.headerTabs .emby-tab-button-active').getAttribute('data-index'); + if (activeTab !== '0') return; + + const activeContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + if (!activeContainer) return; + + + if (!activeContainer.dataset.sectionsRendered) return; + + if (state.isRenderingDiscovery) return; + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + const threshold = windowHeight * 1.5; + + if (scrollTop + windowHeight >= documentHeight - threshold) { + renderNextDiscoveryGroup(activeContainer); + } + }, { passive: true }); + LOG('Infinite scroll enabled for discovery.'); + } + + function setupLoadMoreButton(container) { + const loadMoreButton = container.querySelector('.load-more-discovery-btn'); + if (loadMoreButton) return; + + let btn = document.createElement('button'); + btn.className = 'raised button-submit emby-button load-more-discovery-btn'; + btn.textContent = 'Discover More'; + btn.onclick = () => { + btn.remove(); + renderNextDiscoveryGroup(container) + }; + container.appendChild(btn); + LOG('Load More button enabled for discovery.'); + } + + function updateLoadMoreButtonVisibility() { + const btn = document.querySelector('.load-more-discovery-btn'); + if (btn) { + btn.style.display = 'block'; + } + } + + function isInSeasonalPeriod(start, end) { + const now = new Date(); + const currentYear = now.getFullYear(); + const startDate = new Date(`${start}-${currentYear}`); + + let endDate = new Date(`${end}-${currentYear}`); + if (endDate < startDate) endDate.setFullYear(currentYear + 1); + else if (endDate > startDate) endDate.setFullYear(currentYear); + + return now >= startDate && now <= endDate; + } + + function isHalloweenPeriod() { + return isInSeasonalPeriod('10-01', '10-31'); + } + + function isChristmasPeriod() { + return isInSeasonalPeriod('12-01', '12-31'); + } + + function isNewYearsPeriod() { + return isInSeasonalPeriod('01-01', '01-05'); + } + + function isValentinesPeriod() { + return isInSeasonalPeriod('02-01', '02-15'); + } + + const localCache = new window.LocalStorageCache(); + + async function getRandomGenre() { + let movieGenres = localCache.get('movieGenres'); + if (!movieGenres) movieGenres = await fetchAndCacheMovieGenres(); + if (!movieGenres || !movieGenres.length) return null; + + const minCount = (Config.DISCOVERY_SETTINGS && Config.DISCOVERY_SETTINGS.minGenreMovieCount) + || (window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreen && window.KefinTweaksConfig.homeScreen.discovery && window.KefinTweaksConfig.homeScreen.discovery.minGenreMovieCount) + || 50; + + const valid = movieGenres.filter(g => !state.renderedDiscoveryIds.genres.has(g.Id) && (g.MovieCount || 0) > minCount); + if (!valid.length) return null; + + const selected = valid[Math.floor(Math.random() * valid.length)]; + state.renderedDiscoveryIds.genres.add(selected.Id); + return selected; + } + + async function fetchAndCacheMovieGenres() { + try { + const userId = ApiClient.getCurrentUserId(); + const response = await fetch(`${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`, { + headers: { 'X-Emby-Token': ApiClient.accessToken() } + }); + const data = await response.json(); + const genres = (data.Items || []).map(g => ({ Id: g.Id, Name: g.Name, MovieCount: g.MovieCount })); + localCache.set('movieGenres', genres); + return genres; + } catch (e) { + ERR('Failed to fetch genres', e); + return []; + } + } + + async function getRandomCollectionForDiscovery(minimumItems) { + const collections = await getCollectionsData(); + if (!collections || !collections.length) return null; + const unrenderedCollections = collections.filter(c => !state.renderedDiscoveryIds.collections.has(c.Id)); + const eligibleCollections = unrenderedCollections.filter(c => c.TotalRecordCount ?? c.RecursiveItemCount ?? c.ChildCount ?? 0 >= minimumItems); + if (!eligibleCollections.length) return null; + const selected = eligibleCollections[Math.floor(Math.random() * eligibleCollections.length)]; + state.renderedDiscoveryIds.collections.add(selected.Id); + return selected; + } + + async function getCollectionsData() { + const query = `${ApiClient.serverAddress()}/Items?IncludeItemTypes=BoxSet&Recursive=true&Fields=RecursiveItemCount,ChildCount,TotalRecordCount&StartIndex=0&Limit=500&SortBy=TotalRecordCount`; + + try { + const result = await ApiHelper.getQuery(query, { + useCache: true, + ttl: Config.CACHE.LONG_TTL + }); + + let items = []; + if (result.data && result.data.Items) { + items = result.data.Items; + } else { + const fresh = await result.dataPromise; + items = fresh.Items || []; + } + return items; + } catch (e) { + ERR('Failed to fetch collections', e); + return []; + } + } + + async function getRandomPersonFromHistory(personType, sourceType) { + let sourceItems = []; + if (sourceType === 'watched-recent') { + sourceItems = localCache.get('movies') || []; + if (sourceItems.length === 0) return null; + + sourceItems = sourceItems.sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate)).slice(0, 5); + } else if (sourceType === 'watched') { + sourceItems = localCache.get('movies') || []; + if (sourceItems.length === 0) return null; + sourceItems = sourceItems.sort(() => 0.5 - Math.random()).slice(0, 20); + } + + if (!sourceItems.length) return null; + + sourceItems.sort(() => 0.5 - Math.random()); + + for (const item of sourceItems) { + if (!item.People || !item.People.length) continue; + + const candidates = item.People.filter(p => p.Type === personType); + if (!candidates.length) continue; + + const person = candidates[Math.floor(Math.random() * candidates.length)]; + + let trackedSet = null; + if (personType === 'Director') trackedSet = state.renderedDiscoveryIds.directors; + else if (personType === 'Actor') trackedSet = state.renderedDiscoveryIds.actors; + else if (personType === 'Writer') trackedSet = state.renderedDiscoveryIds.writers; + + if (trackedSet && !trackedSet.has(person.Id)) { + trackedSet.add(person.Id); + return { + person: { id: person.Id, name: person.Name }, + sourceItem: item + }; + } + } + + return null; + } + + async function getRandomPerson(type) { + if (!PeopleCache) return null; + PeopleCache.init(); + + const peopleData = await PeopleCache.getTopPeople(); + if (!peopleData) return null; + + let list = []; + let trackedSet = null; + + if (type === 'Director') { + list = peopleData.directors; + trackedSet = state.renderedDiscoveryIds.directors; + } else if (type === 'Actor') { + list = peopleData.actors; + trackedSet = state.renderedDiscoveryIds.actors; + } else if (type === 'Writer') { + list = peopleData.writers; + trackedSet = state.renderedDiscoveryIds.writers; + } + + if (!list || !list.length || !trackedSet) return null; + + const valid = list.filter(p => !trackedSet.has(p.id)); + if (!valid.length) return null; + + const selected = valid[Math.floor(Math.random() * valid.length)]; + trackedSet.add(selected.id); + return selected; + } + + function getRandomWatchedMovie() { + const movies = localCache.get('movies') || []; + if (!movies.length) return null; + const valid = movies.filter(m => !state.renderedDiscoveryIds.watchedMovies.has(m.Id)); + if (!valid.length) return null; + const selected = valid[Math.floor(Math.random() * Math.min(valid.length, 10))]; + state.renderedDiscoveryIds.watchedMovies.add(selected.Id); + return selected; + } + + async function getRandomFavoriteMovie() { + if (state.cachedFavorites) { + const valid = state.cachedFavorites.filter(m => !state.renderedDiscoveryIds.likedMovies.has(m.Id)); + if (valid.length > 0) { + const selected = valid[Math.floor(Math.random() * valid.length)]; + state.renderedDiscoveryIds.likedMovies.add(selected.Id); + return selected; + } + } + const userId = ApiClient.getCurrentUserId(); + try { + const response = await ApiClient.getItems(userId, { IncludeItemTypes: 'Movie', Filters: 'IsFavorite', Limit: 50, SortBy: 'Random', Recursive: true }); + const items = response.Items || []; + if (!items.length) return null; + state.cachedFavorites = items; + const valid = items.filter(m => !state.renderedDiscoveryIds.likedMovies.has(m.Id)); + if (!valid.length) return null; + const selected = valid[0]; + state.renderedDiscoveryIds.likedMovies.add(selected.Id); + return selected; + } catch (e) { + ERR('Failed to get favorite movies:', e); + return null; + } + } + + async function getPopularTVNetworks() { + if (!StudiosCache) return []; + + // Get Minimum Series Count from config + + await StudiosCache.init(); + return StudiosCache.getPopularTVNetworks(); + } + + window.homeScreen3 = { init: enhanceHomeScreen }; + + if (window.KefinTweaksUtils && typeof window.KefinTweaksUtils.onViewPage === 'function') { + window.KefinTweaksUtils.onViewPage((view, element, hash) => { + // Get selected tab from hash + const hashParams = hash.includes('?') ? hash.split('?')[1] : ''; + const urlParams = new URLSearchParams(hashParams); + const currentTab = urlParams.get('tab'); + const currentTabIndex = currentTab ? parseInt(currentTab, 10) : 0; + + // If the tab isn't 0, don't render the home screen + if (currentTabIndex !== 0) { + return; + } + + try { + enhanceHomeScreen(); + } catch (err) { ERR('Home screen page change handler failed:', err); } + }, { pages: ['home', 'home.html'] }); + } + + if (window.KefinTweaksUtils && typeof window.KefinTweaksUtils.onViewPage === 'function') { + window.KefinTweaksUtils.onViewPage(manageBodyClasses, { pages: [] }); + } + +})(); + diff --git a/scripts/homeScreenConfig-community.js b/scripts/homeScreenConfig-community.js new file mode 100644 index 0000000..e06dc21 --- /dev/null +++ b/scripts/homeScreenConfig-community.js @@ -0,0 +1,97 @@ +// KefinTweaks Community Home Screen Sections +// This file contains curated collections of home screen sections submitted by the community. +// Submit your own: [URL Placeholder] + +(function() { + 'use strict'; + + const COMMUNITY_COLLECTIONS = [ + { + "name": "Based on Your Watched History [Discovery]", + "author": "HighImKevin", + "sections": [ + { + "name": "Movies You Forgot About", + "enabled": true, + "order": 22, + "discoveryEnabled": true, + "renderMode": "Normal", + "cardFormat": "Poster", + "queries": [ + { + "queryOptions": { + "IncludeItemTypes": [ + "Movie" + ], + "SortBy": "PlayCount", + "SortOrder": "Descending", + "Filters": "IsResumable", + "Limit": 20 + } + } + ], + "flattenSeries": false, + "id": "custom-1768607068997", + "type": "custom" + }, + { + "name": "Best Movies You've Never Seen", + "enabled": true, + "order": 100, + "discoveryEnabled": true, + "renderMode": "Normal", + "cardFormat": "Poster", + "queries": [ + { + "queryOptions": { + "IsUnplayed": true, + "IncludeItemTypes": [ + "Movie" + ], + "SortBy": "Random", + "SortOrder": "Descending", + "Limit": 20, + "MinCommunityRating": 9 + } + } + ], + "flattenSeries": false, + "id": "custom-1769640868950", + "type": "custom" + }, + { + "name": "Rewatch One Of Your Favorites", + "enabled": true, + "order": 100, + "discoveryEnabled": true, + "renderMode": "Normal", + "cardFormat": "Poster", + "queries": [ + { + "queryOptions": { + "IncludeItemTypes": [ + "Movie" + ], + "SortBy": "Random", + "SortOrder": "Ascending", + "Filters": "IsFavorite", + "Limit": 20 + } + } + ], + "flattenSeries": false, + "id": "custom-1769644637599", + "type": "custom" + } + ] + } + // Add more collections here + ]; + + // Expose to window + window.KefinCommunityConfig = { + collections: COMMUNITY_COLLECTIONS + }; + +})(); + diff --git a/scripts/homeScreenConfig2.js b/scripts/homeScreenConfig2.js new file mode 100644 index 0000000..4f7e153 --- /dev/null +++ b/scripts/homeScreenConfig2.js @@ -0,0 +1,868 @@ +// KefinTweaks Home Screen Configuration 2 +// Streamlined configuration with queries array format +// Defines default sections, discovery templates, and cache settings +(function() { + 'use strict'; + + // Default Cache Configuration (times in milliseconds) + const CACHE_CONFIG = { + DEFAULT_TTL: 60 * 60 * 1000, // 1 hour + VERY_SHORT_TTL: 1 * 60 * 1000, // 1 minute + SHORT_TTL: 5 * 60 * 1000, // 5 minutes + LONG_TTL: 24 * 60 * 60 * 1000, // 24 hours + STATIC_TTL: 7 * 24 * 60 * 60 * 1000, // 1 week + DISCOVERY_TTL: 6 * 60 * 60 * 1000, // 6 hours + FORCE_REFRESH_TTL: 0 // 0 + }; + + // Standard Home Sections (non-seasonal) + // New streamlined format with queries array, organized into groups + const KEFINTWEAKS_HOME_SECTION_GROUPS = [ + // Continue Watching Group + { + id: 'home-continue-watching', + name: 'Continue Watching', + sections: [ + { + id: 'continueWatching', + name: 'Resume', + enabled: false, + hidden: false, + order: 10, + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, + queries: [ + { + queryOptions: { + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Filters: 'IsResumable', + IncludeItemTypes: ['Episode', 'Movie'], + Limit: 24 + } + } + ] + }, + { + id: 'nextUp', + name: 'Next Up', + enabled: false, + hidden: false, + order: 11, + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, + queries: [ + { + path: '/Shows/NextUp', + queryOptions: { + Limit: 24, + EnableResumable: 'false' + } + } + ] + }, + { + id: 'continueWatchingAndNextUp', + name: 'Continue Watching', + enabled: false, // Disabled by default - only used when merge setting is enabled + order: 10, + hidden: true, + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.FORCE_REFRESH_TTL, + sortBy: 'DatePlayed', // Used to sort merged results from both queries + sortOrder: 'Descending', // Most recent first + queries: [ + { + // Continue Watching (Movies only when merged) + queryOptions: { + SortBy: 'DatePlayed', + SortOrder: 'Descending', + Filters: 'IsResumable', + IncludeItemTypes: ['Movie'], + Limit: 24 + } + }, + { + // Next Up (Episodes) + path: '/Shows/NextUp', + queryOptions: { + Limit: 24, + EnableResumable: 'true' + } + } + ] + } + ] + }, + // Upcoming Group + { + id: 'home-upcoming', + name: 'Upcoming', + sections: [ + { + id: 'upcoming', + name: 'Coming Soon', + enabled: true, + order: 60, + cardFormat: 'Thumb', + flattenSeries: true, + ttl: CACHE_CONFIG.LONG_TTL, + queries: [ + { + path: '/Shows/Upcoming', + queryOptions: { + Limit: 48, + } + } + ] + } + ] + }, + // Recently Released Group + { + id: 'home-recently-released', + name: 'Recently Released', + sections: [ + { + id: 'recentlyReleased.movies', + name: 'Recently Released Movies', + enabled: true, + order: 20, + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DEFAULT_TTL, + queries: [ + { + minAge: 0, + maxAge: 30, + queryOptions: { + IncludeItemTypes: ['Movie'], + SortBy: 'PremiereDate', + SortOrder: 'Descending', + Limit: 16, + Fields: 'PremiereDate' + } + } + ] + }, + { + id: 'recentlyReleased.episodes', + name: 'Recently Aired Episodes', + enabled: true, + order: 21, + cardFormat: 'Thumb', + flattenSeries: true, + ttl: CACHE_CONFIG.DEFAULT_TTL, + queries: [ + { + minAge: 0, + maxAge: 7, + queryOptions: { + IncludeItemTypes: ['Episode'], + IsMissing: 'false', + SortBy: 'PremiereDate', + SortOrder: 'Descending', + Limit: 100 // Fetch more for dedupe + } + } + ] + } + ] + }, + // IMDb Top 250 Group + { + id: 'home-imdb-top-250', + name: 'IMDb Top 250', + sections: [ + { + id: 'imdbTop250', + name: 'IMDb Top 250', + enabled: true, + order: 70, + cardFormat: 'Poster', + ttl: CACHE_CONFIG.LONG_TTL, + queries: [ + { + dataSource: 'MoviesCache.getImdbTop250Movies', + queryOptions: { + Limit: 20, + SortBy: 'Random', + IncludeItemTypes: ['Movie'] + } + } + ] + } + ] + }, + // Popular TV Networks Group + { + id: 'home-popular-tv-networks', + name: 'Popular TV Networks', + sections: [ + { + id: 'popularTVNetworks', + name: 'Popular TV Networks', + enabled: true, + order: 72, + cardFormat: 'Thumb', + ttl: CACHE_CONFIG.STATIC_TTL, + queries: [ + { + dataSource: 'StudiosCache.getPopularTVNetworks', + queryOptions: { + SortBy: 'SortName', + Limit: 20 + } + } + ] + } + ] + }, + // Watch Again Group + { + id: 'home-watch-again', + name: 'Watch Again', + sections: [ + { + id: 'watchAgain', + name: 'Watch Again', + enabled: true, + order: 73, + cardFormat: 'Random', + ttl: CACHE_CONFIG.DEFAULT_TTL, + queries: [ + { + queryOptions: { + Filters: 'IsPlayed', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + } + ] + }, + // Watchlist Group + { + id: 'home-watchlist', + name: 'Watchlist', + sections: [ + { + id: 'watchlist', + name: 'Watchlist', + enabled: true, + order: 71, + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DEFAULT_TTL, + queries: [ + { + queryOptions: { + Filters: 'Likes', + SortBy: 'DateCreated', + SortOrder: 'Descending', + Limit: 20 + } + } + ] + } + ] + }, + // Resume Playlists Group + { + id: 'home-resume-playlists', + name: 'Pick Up Where You Left Off', + sections: [ + { + id: 'resumePlaylists', + name: 'Continue Watching Your Playlists', + enabled: false, + order: 74, + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DEFAULT_TTL, + queries: [ + { + queryOptions: { + IsUnplayed: true, + IncludeItemTypes: ['Playlist'], + SortBy: 'PlayedPercentage', + SortOrder: 'Descending', + Limit: 0 + } + } + ] + }, + { + id: 'completeCollections', + name: 'Finish Watching Your Collections', + enabled: false, + order: 75, + cardFormat: 'Poster', + ttl: CACHE_CONFIG.DEFAULT_TTL, + queries: [ + { + queryOptions: { + IsUnplayed: true, + IncludeItemTypes: ['BoxSet'], + SortBy: 'PlayedPercentage', + SortOrder: 'Descending' + } + } + ] + } + ] + } + ]; + + // Seasonal Sections + // Sections that only appear during specific date ranges + const KEFINTWEAKS_SEASONAL_SECTION_GROUPS = [ + // Seasonal Sections - Halloween + { + id: 'seasonal-halloween', + name: 'Halloween', + sections: [ + { + id: 'seasonal.halloween.halloween-tag', + name: 'Halloween Movies', + enabled: true, + startDate: '10-01', + endDate: '10-31', + order: 50, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + Tags: 'halloween', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + }, + { + id: 'seasonal.halloween.halloween-horror', + name: 'Horror Movies', + enabled: true, + startDate: '10-01', + endDate: '10-31', + order: 51, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + Genres: 'Horror', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + }, + { + id: 'seasonal.halloween.halloween-thriller', + name: 'Thriller Movies', + enabled: true, + startDate: '10-01', + endDate: '10-31', + order: 52, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + Genres: 'Thriller', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + } + ] + }, + // Seasonal Sections - Christmas + { + id: 'seasonal-christmas', + name: 'Christmas', + sections: [ + { + id: 'seasonal.christmas.christmas-movies', + name: 'Christmas Movies', + enabled: true, + startDate: '12-01', + endDate: '12-31', + order: 50, + cardFormat: 'Poster', + renderMode: 'Spotlight', + queries: [ + { + queryOptions: { + Tags: 'christmas', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + }, + { + id: 'seasonal.christmas.christmas-episodes', + name: 'Christmas Episodes', + enabled: true, + startDate: '12-01', + endDate: '12-31', + order: 51, + cardFormat: 'Thumb', + queries: [ + { + queryOptions: { + SearchTerm: 'christmas', + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20 + } + } + ] + } + ] + }, + // Seasonal Sections - New Year's + { + id: 'seasonal-new-years', + name: 'New Year\'s', + sections: [ + { + id: 'seasonal.new-years.new-years-movies', + name: 'New Year\'s Movies', + enabled: true, + startDate: '01-01', + endDate: '01-02', + order: 50, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + Tags: 'new year\'s eve', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + }, + { + id: 'seasonal.new-years.new-years-episodes', + name: 'New Year\'s Episodes', + enabled: true, + startDate: '01-01', + endDate: '01-02', + order: 51, + cardFormat: 'Thumb', + queries: [ + { + queryOptions: { + SearchTerm: 'new year', + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20 + } + } + ] + } + ] + }, + // Seasonal Sections - Valentine's + { + id: 'seasonal-valentines', + name: 'Valentine\'s', + sections: [ + { + id: 'seasonal.valentines.valentines-movies', + name: 'Valentine\'s Movies', + enabled: true, + startDate: '02-01', + endDate: '02-15', + order: 50, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + Tags: 'valentine\'s day', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + }, + { + id: 'seasonal.valentines.valentines-episodes', + name: 'Valentine\'s Episodes', + enabled: true, + startDate: '02-01', + endDate: '02-15', + order: 51, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + SearchTerm: 'valentine', + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20 + } + } + ] + } + ] + }, + // Seasonal Sections - Thanksgiving + { + id: 'seasonal-thanksgiving', + name: 'Thanksgiving', + sections: [ + { + id: 'seasonal.thanksgiving.thanksgiving-movies', + name: 'Thanksgiving Movies', + enabled: true, + startDate: '11-20', + endDate: '11-30', + order: 50, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + Tags: 'thanksgiving', + IncludeItemTypes: ['Movie'], + SortBy: 'Random', + Limit: 20 + } + } + ] + }, + { + id: 'seasonal.thanksgiving.thanksgiving-episodes', + name: 'Thanksgiving Episodes', + enabled: true, + startDate: '11-20', + endDate: '11-30', + order: 51, + cardFormat: 'Poster', + queries: [ + { + queryOptions: { + SearchTerm: 'thanksgiving', + IncludeItemTypes: ['Episode'], + SortBy: 'Random', + Limit: 20 + } + } + ] + } + ] + } + ]; + + // Discovery Templates + // These use "Dynamic" types where 'source' is determined at runtime + // Organized into groups + const KEFINTWEAKS_DISCOVERY_SECTION_GROUPS = [ + // Genre Movies Group + { + id: 'discovery-genre-movies', + name: 'Genre Movies', + sections: [ + { + id: 'genreMovies', + type: 'Genre', + source: 'Dynamic', // Will be filled with a random Genre ID/Name + name: '{Genre} Movies', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + }, + { + id: 'spotlightGenre', + type: 'Genre', + source: 'Dynamic', + name: 'Top Rated {Genre}', + enabled: true, + itemLimit: 20, + sortOrder: 'CommunityRating', + sortOrderDirection: 'Descending', + includeItemTypes: ['Movie'], + renderMode: 'Spotlight', // Replaces 'type: spotlight-genre' with attribute + ttl: CACHE_CONFIG.DISCOVERY_TTL + } + ] + }, + // People Movies Group + { + id: 'discovery-people-movies', + name: 'People Movies', + sections: [ + { + id: 'directedByTopDirector', + type: 'Person', + source: 'Dynamic', // Random Director ID + personType: 'Director', // Extra metadata for dynamic logic + name: 'Directed by {Person}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + }, + { + id: 'writtenByTopWriter', + type: 'Person', + source: 'Dynamic', // Random Writer ID + personType: 'Writer', + name: 'Written by {Person}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + }, + { + id: 'starringTopActor', + type: 'Person', + source: 'Dynamic', // Random Actor ID + personType: 'Actor', + name: 'Starring {Person}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + } + ] + }, + // Recently Watched People Group + { + id: 'discovery-recently-watched-people', + name: 'Recently Watched People', + sections: [ + { + id: 'starringActorRecentlyWatched', + type: 'Person', + source: 'Dynamic', // Actor from Recently Watched + personType: 'Actor', + sourceType: 'watched-recent', // Hint for source generator + name: 'Starring {Person} since you watched {Title}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + }, + { + id: 'directedByDirectorRecentlyWatched', + type: 'Person', + source: 'Dynamic', // Director from Recently Watched + personType: 'Director', + sourceType: 'watched-recent', + name: 'Directed by {Person} since you watched {Title}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + }, + { + id: 'writtenByWriterRecentlyWatched', + type: 'Person', + source: 'Dynamic', + personType: 'Writer', + sourceType: 'watched-recent', + name: 'Written by {Person} since you watched {Title}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + } + ] + }, + // Similar to Watched Group + { + id: 'discovery-similar-to-watched', + name: 'Similar to Watched', + sections: [ + { + id: 'becauseYouWatched', + type: 'Similar', + source: 'Dynamic', // Random Watched Item ID + sourceType: 'watched', + name: 'Because you watched {Title}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', // Typically 'Similarity' but usually returned sorted by API + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + }, + { + id: 'becauseYouRecentlyWatched', + type: 'Similar', + source: 'Dynamic', // Random Recently Watched ID + sourceType: 'watched-recent', + name: 'Because you recently watched {Title}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + } + ] + }, + // Liked Group + { + id: 'discovery-liked', + name: 'Liked', + sections: [ + { + id: 'becauseYouLiked', + type: 'Similar', + source: 'Dynamic', // Random Favorite Item ID + sourceType: 'liked', + name: 'Because you liked {Title}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Movie'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + } + ] + }, + // Studios Group + { + id: 'discovery-studios', + name: 'Studios', + sections: [ + { + id: 'studioShows', + type: 'Studio', + source: 'Dynamic', // Random Studio ID + name: 'Shows from {Studio}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', + cardFormat: 'Poster', + includeItemTypes: ['Series'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + }, + { + id: 'spotlightNetwork', + type: 'Studio', + source: 'Dynamic', + name: 'Top Rated from {Studio}', + enabled: true, + itemLimit: 20, + sortOrder: 'CommunityRating', + sortOrderDirection: 'Descending', + includeItemTypes: ['Series'], + renderMode: 'Spotlight', + ttl: CACHE_CONFIG.DISCOVERY_TTL + } + ] + }, + // Collections Group + { + id: 'discovery-collections', + name: 'Collections', + sections: [ + { + id: 'collections', + type: 'Collection', // Standard type, but source is dynamic here + source: 'Dynamic', + name: '{Collection}', + enabled: true, + itemLimit: 20, + sortOrder: 'Random', // Order within the collection + includeItemTypes: ['Movie', 'Series'], + ttl: CACHE_CONFIG.DISCOVERY_TTL + } + ] + } + ]; + + const DISCOVERY_SETTINGS = { + enabled: true, + infiniteScroll: true, + minPeopleAppearances: 10, + minGenreMovieCount: 50, + defaultItemLimit: 16, + defaultSortOrder: "Random", + defaultCardFormat: "Poster", + spotlightDiscoveryChance: 0.5, + renderSpotlightAboveMatching: false, + spotlight: { + autoPlay: true, + interval: 5000, + showDots: true, + showNavButtons: true, + showClearArt: true + }, + randomizeOrder: false + } + + const SEASONAL_THEME_SETTINGS = { + enabled: true, + enableSeasonalAnimations: true, + enableSeasonalBackground: true, + seasonToggles: [ + { + id: 'halloween', + name: 'Halloween', + enabled: false + }, + { + id: 'christmas', + name: 'Christmas', + enabled: false + }, + { + id: 'new-years', + name: 'New Year\'s', + enabled: false + }, + { + id: 'valentines', + name: 'Valentine\'s Day', + enabled: false + } + ] + } + const HOME_SETTINGS = { + minimumSeriesForPopularTVNetworks: 10, + } + + // Expose configuration + window.KefinHomeConfig2 = { + CACHE: CACHE_CONFIG, + HOME_SECTION_GROUPS: KEFINTWEAKS_HOME_SECTION_GROUPS, + SEASONAL_SECTION_GROUPS: KEFINTWEAKS_SEASONAL_SECTION_GROUPS, + DISCOVERY_SECTION_GROUPS: KEFINTWEAKS_DISCOVERY_SECTION_GROUPS, + CUSTOM_SECTION_GROUPS: [], // Empty by default - user-created/imported sections + REMOVE_CONFLICTING_SECTIONS: true, + MERGE_NEXT_UP: false, + HOME_SETTINGS: HOME_SETTINGS, + DISCOVERY_SETTINGS: DISCOVERY_SETTINGS, + SEASONAL_THEME_SETTINGS: SEASONAL_THEME_SETTINGS + }; + + console.log('[KefinTweaks HomeConfig2] Configuration loaded'); + +})(); \ No newline at end of file diff --git a/scripts/ui.js b/scripts/ui.js new file mode 100644 index 0000000..e1312e3 --- /dev/null +++ b/scripts/ui.js @@ -0,0 +1,1566 @@ +// KefinTweaks UI Utilities +// Shared UI components for configuration interfaces +// Provides badge autocomplete system for Tags, Genres, Collections, Playlists, and Include Item Types + +(function() { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks UI]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks UI]', ...args); + const ERR = (...args) => console.error('[KefinTweaks UI]', ...args); + + /** + * Badge System - Manages badge creation, addition, removal, and updates + */ + const BadgeSystem = { + /** + * Creates a badge element + */ + createBadge(value, displayText) { + const badge = document.createElement('span'); + badge.className = 'tag-badge'; + badge.setAttribute('data-value', value); + badge.style.cssText = 'display: inline-flex; align-items: center; gap: 0.25em; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; padding: 0.25em 0.5em; margin: 0.25em; font-size: 0.9em;'; + badge.innerHTML = `${displayText}`; + return badge; + }, + + /** + * Adds a badge to the container + */ + addBadge(prefix, sectionIndex, value, displayText, badgeContainer, input, hiddenInput) { + // Check if badge already exists + const existingBadges = badgeContainer.querySelectorAll('.tag-badge'); + const alreadyExists = Array.from(existingBadges).some(badge => + badge.getAttribute('data-value') === value + ); + if (alreadyExists) return; + + // Find the badges wrapper div - look for the div with class tag-badge-wrapper + let badgesWrapper = badgeContainer.querySelector('.tag-badge-wrapper'); + if (!badgesWrapper) { + // Fallback: look for includeItemTypes-badges (for IncludeItemTypes type) + badgesWrapper = badgeContainer.querySelector('.includeItemTypes-badges'); + } + if (!badgesWrapper) { + // For standard autocomplete, find the div that's a sibling of the input container div + if (input && input.parentElement) { + const inputContainer = input.parentElement; + // The badges wrapper is the next sibling div after the input container + badgesWrapper = inputContainer.nextElementSibling; + // Verify it's a div + if (!badgesWrapper || !badgesWrapper.matches('div')) { + // Look for any div sibling that might contain badges + badgesWrapper = Array.from(badgeContainer.children).find(child => + child.matches('div') && + child !== inputContainer && + (child.querySelector('.tag-badge') || child.style.display === 'flex' || child.style.flexWrap === 'wrap') + ); + } + } + // Final fallback + if (!badgesWrapper) { + badgesWrapper = badgeContainer; + } + } + + const badge = this.createBadge(value, displayText); + badgesWrapper.appendChild(badge); + if (input) input.value = ''; + this.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + }, + + /** + * Removes a badge from the container + */ + removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput) { + badge.remove(); + this.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + }, + + /** + * Removes all badges from the container + */ + clearAll(prefix, sectionIndex, badgeContainer, hiddenInput) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + badges.forEach(badge => badge.remove()); + this.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + }, + + /** + * Updates hidden input from badges + */ + updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + const values = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean); + if (hiddenInput) { + hiddenInput.value = values.join(', '); + } + }, + + /** + * Gets existing badge values (for deduplication) + */ + getExistingBadges(badgeContainer) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + return Array.from(badges).map(badge => badge.getAttribute('data-value').toLowerCase()); + } + }; + + /** + * Creates an autocomplete input with badge system + * @param {Object} options - Configuration options + * @param {string} options.type - Type of autocomplete: 'Tag', 'Genre', 'Collection', 'Playlist', 'IncludeItemTypes' + * @param {string} options.prefix - Prefix for element IDs (e.g., 'query', 'section') + * @param {string|number} options.sectionIndex - Section/query index for unique IDs + * @param {HTMLElement} options.container - Container element where the autocomplete will be inserted + * @param {string} options.label - Label text for the field + * @param {Array} options.currentValues - Current selected values + * @param {Array} options.availableTypes - For IncludeItemTypes: list of available types + * @param {Function} options.onChange - Callback when values change (receives array of values) + * @returns {Object} - { element: HTMLElement, setup: Function } - The created element and setup function + */ + function createAutoCompleteInput(options) { + const { + type, + prefix, + sectionIndex, + container, + label = type, + currentValuesArray = [], + availableTypes = null, + includeItemTypes = null, + onChange = null + } = options; + + if (!type || !prefix || sectionIndex === undefined || !container) { + ERR('createAutoCompleteInput: Missing required options (type, prefix, sectionIndex, container)'); + return null; + } + + const sanitizedPrefix = prefix.replace(/[^a-zA-Z0-9]/g, '_'); + const uniqueId = `${sanitizedPrefix}_${sectionIndex}`; + + // Build HTML based on type - all types now use text input with autocomplete + let html = ''; + const allAvailableItemTypes = availableTypes || [ + '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' + ]; + + let currentValues = currentValuesArray; + + // For IncludeItemTypes, normalize currentValues to strings + if (type === 'IncludeItemTypes') { + currentValues = currentValues.map(v => typeof v === 'string' ? v : String(v)); + } + + // Tag, Genre, Collection, Playlist, IncludeItemTypes all use text input with autocomplete + const badgesHtml = currentValues.map(val => { + const displayText = (type === 'Collection' || type === 'Playlist') && typeof val === 'object' ? val.name : val; + const value = (type === 'Collection' || type === 'Playlist') && typeof val === 'object' ? val.id : val; + return ` + + ${displayText} + + + `; + }).join(''); + + const typeKey = type === 'IncludeItemTypes' ? 'includeItemTypes' : type.toLowerCase(); + const placeholderText = type === 'IncludeItemTypes' ? 'Type to add item type...' : `Type to add ${type.toLowerCase()}...`; + + // Determine if refresh button should be shown (for cached data types) + const showRefresh = (type === 'Tag' || type === 'Genre' || type === 'Collection' || type === 'Playlist'); + + // Determine if badges should appear below input (for IncludeItemTypes, Genres, Tags) + // Check for both singular and plural forms since type may be normalized + const badgesBelowInput = (type === 'IncludeItemTypes' || type === 'Genres' || type === 'Tags' || type === 'Genre' || type === 'Tag'); + + html = ` +
+
+
+
${label}
+
+ ${showRefresh ? `` : ''} + +
+
+
+
+ + +
+
+ ${badgesHtml} +
+
+ + +
+
+ `; + + // Create wrapper element + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const element = wrapper.firstElementChild; + + // Setup function to initialize autocomplete behavior + const setup = async () => { + // All types now use standard autocomplete (text input) + return setupStandardAutocomplete(element, prefix, sectionIndex, type, onChange, availableTypes, includeItemTypes); + }; + + return { element, setup }; + } + + /** + * Setup Include Item Types autocomplete (dropdown with checkboxes) + */ + async function setupIncludeItemTypesAutocomplete(container, prefix, sectionIndex, availableTypes, onChange) { + const badgeContainer = container.querySelector('.includeItemTypes-badge-container'); + const toggleButton = badgeContainer?.querySelector('.includeItemTypes-dropdown-toggle'); + const dropdown = badgeContainer?.querySelector('.includeItemTypes-dropdown'); + const checkboxNodes = badgeContainer ? Array.from(badgeContainer.querySelectorAll('.includeItemTypes-option')) : []; + const proxyInput = container.querySelector(`.${prefix}_includeItemTypes`); + const hiddenInput = container.querySelector(`.${prefix}_includeItemTypes_hidden`); + const badgesWrapper = badgeContainer?.querySelector('.includeItemTypes-badges'); + const emptyState = badgeContainer?.querySelector('.includeItemTypes-empty'); + const clearAllBtn = container.querySelector('.badge-clear-all[data-field="includeItemTypes"]'); + const allowedTypeSet = new Set((availableTypes || []).map(type => type.toLowerCase())); + + if (!badgeContainer || !dropdown || !proxyInput || !hiddenInput || !badgesWrapper) { + WARN('IncludeItemTypes autocomplete: Missing required elements'); + return; + } + + const closeDropdown = () => { + dropdown.style.display = 'none'; + toggleButton?.setAttribute('aria-expanded', 'false'); + }; + + const openDropdown = () => { + dropdown.style.display = 'block'; + toggleButton?.setAttribute('aria-expanded', 'true'); + }; + + toggleButton?.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = dropdown.style.display === 'block'; + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }); + + document.addEventListener('click', (e) => { + if (!badgeContainer.contains(e.target)) { + closeDropdown(); + } + }); + + const updateEmptyState = () => { + const hasBadges = badgesWrapper.querySelector('.tag-badge'); + if (hasBadges) { + emptyState?.setAttribute('hidden', 'true'); + } else { + emptyState?.removeAttribute('hidden'); + } + }; + + const syncToggleLabel = () => { + const count = badgesWrapper.querySelectorAll('.tag-badge').length; + if (toggleButton) { + const textSpan = toggleButton.querySelector('.includeItemTypes-toggle-label'); + if (textSpan) { + textSpan.textContent = count > 0 ? `${count} selected` : 'Select item types'; + } + } + }; + + const notifyChange = () => { + if (onChange) { + const badges = badgesWrapper.querySelectorAll('.tag-badge'); + const values = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean); + onChange(values); + } + }; + + // Setup badge remove handlers + badgeContainer.addEventListener('click', (e) => { + if (e.target.classList.contains('tag-badge-remove') || e.target.closest('.tag-badge-remove')) { + const badge = e.target.closest('.tag-badge'); + if (badge) { + const value = badge.getAttribute('data-value'); + const checkbox = checkboxNodes.find(cb => cb.value === value); + if (checkbox) { + checkbox.checked = false; + } + BadgeSystem.removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput); + updateEmptyState(); + syncToggleLabel(); + notifyChange(); + } + } + }); + + const handleCheckboxChange = (checkbox) => { + const value = checkbox.value; + if (!value || (allowedTypeSet.size && !allowedTypeSet.has(value.toLowerCase()))) return; + + if (checkbox.checked) { + BadgeSystem.addBadge(prefix, sectionIndex, value, value, badgeContainer, proxyInput, hiddenInput); + } else { + const badge = badgesWrapper.querySelector(`.tag-badge[data-value="${value}"]`); + if (badge) { + BadgeSystem.removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput); + } else { + BadgeSystem.updateHiddenInput(prefix, sectionIndex, badgeContainer, hiddenInput); + } + } + updateEmptyState(); + syncToggleLabel(); + notifyChange(); + }; + + checkboxNodes.forEach(checkbox => { + checkbox.addEventListener('change', () => handleCheckboxChange(checkbox)); + }); + + // Setup clear all button + if (clearAllBtn) { + clearAllBtn.addEventListener('click', () => { + checkboxNodes.forEach(cb => { + cb.checked = false; + }); + BadgeSystem.clearAll(prefix, sectionIndex, badgeContainer, hiddenInput); + updateEmptyState(); + syncToggleLabel(); + notifyChange(); + }); + } + + // Ensure badges match checked state on init + checkboxNodes.forEach(cb => { + if (cb.checked && !badgesWrapper.querySelector(`.tag-badge[data-value="${cb.value}"]`)) { + BadgeSystem.addBadge(prefix, sectionIndex, cb.value, cb.value, badgeContainer, proxyInput, hiddenInput); + } + }); + updateEmptyState(); + syncToggleLabel(); + } + + /** + * Setup standard autocomplete (Tag, Genre, Collection, Playlist) + */ + async function setupStandardAutocomplete(container, prefix, sectionIndex, type, onChange, availableTypes = null, includeItemTypes = null, forceRefresh = false) { + const badgeContainer = container.querySelector(`.tag-badge-container[data-type="${type}"]`); + const input = container.querySelector(`.autocomplete-input[data-type="${type}"]`); + const suggestionsDiv = container.querySelector(`.autocomplete-suggestions[data-section-index="${sectionIndex}"]`); + const typeKey = type === 'IncludeItemTypes' ? 'includeItemTypes' : type.toLowerCase(); + const hiddenInput = container.querySelector(`.${prefix}_${typeKey}_hidden`); + const clearAllBtn = container.querySelector(`.badge-clear-all[data-type="${type}"]`); + const refreshBtn = container.querySelector(`.badge-refresh[data-type="${type}"]`); + const chevronBtn = container.querySelector(`.autocomplete-chevron[data-type="${type}"]`); + + if (!badgeContainer || !input || !suggestionsDiv) { + WARN(`Standard autocomplete (${type}): Missing required elements`); + return; + } + + // Setup badge remove handlers + badgeContainer.addEventListener('click', (e) => { + if (e.target.classList.contains('tag-badge-remove') || e.target.closest('.tag-badge-remove')) { + const badge = e.target.closest('.tag-badge'); + if (badge) { + BadgeSystem.removeBadge(prefix, sectionIndex, badge, badgeContainer, hiddenInput); + notifyChange(); + } + } + }); + + // Setup clear all button + if (clearAllBtn) { + clearAllBtn.addEventListener('click', () => { + BadgeSystem.clearAll(prefix, sectionIndex, badgeContainer, hiddenInput); + notifyChange(); + }); + } + + // Fetch available options based on type + let options = []; + let fetchOptions = async (refresh = false) => { + // IncludeItemTypes doesn't need API - use provided list, container data attribute, or default + if (type === 'IncludeItemTypes') { + // Try to get from parameter first, then from container data attribute, then default + if (availableTypes && availableTypes.length > 0) { + options = availableTypes; + } else if (badgeContainer && badgeContainer.dataset.availableTypes) { + options = badgeContainer.dataset.availableTypes.split(',').map(s => s.trim()).filter(s => s); + } else { + options = [ + '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' + ]; + } + } else { + // Tag, Genre, Collection, Playlist need API + try { + const userId = window.ApiClient?.getCurrentUserId(); + const serverAddress = window.ApiClient?.serverAddress(); + + if (!userId || !serverAddress) { + WARN('ApiClient not available for fetching options'); + return; + } + + if (type === 'Tag' || type === 'Genre') { + // Use dataHelper.getFilters with includeItemTypes + const itemTypes = includeItemTypes || ['Movie']; + if (window.dataHelper && window.dataHelper.getFilters) { + const filters = await window.dataHelper.getFilters(itemTypes, !refresh, refresh); + options = type === 'Tag' ? (filters?.Tags || []) : (filters?.Genres || []); + } else { + // Fallback to old method + const url = `${serverAddress}/Items/Filters?UserId=${userId}&IncludeItemTypes=${Array.isArray(itemTypes) ? itemTypes.join(',') : itemTypes}`; + const data = await window.apiHelper?.getData(url, !refresh); + options = type === 'Tag' ? (data?.Tags || []) : (data?.Genres || []); + } + } else if (type === 'Collection' || type === 'Playlist') { + // Fetch Collections/Playlists using apiHelper.getQuery with daily cache + const itemType = type === 'Playlist' ? 'Playlist' : 'BoxSet,CollectionFolder'; + const userId = window.ApiClient.getCurrentUserId(); + const serverAddress = window.ApiClient.serverAddress(); + const url = `${serverAddress}/Users/${userId}/Items?IncludeItemTypes=${itemType}&Recursive=true&Fields=ItemCounts`; + + if (window.apiHelper && window.apiHelper.getQuery) { + const cacheOptions = { + useCache: !refresh, + ttl: 24 * 60 * 60 * 1000, // 24 hours + forceRefresh: refresh + }; + const data = await window.apiHelper.getQuery(url, cacheOptions); + const items = (data && data.data) ? data.data : data; + options = (items?.Items || []).map(item => ({ + id: item.Id, + name: item.Name + })); + } else { + // Fallback to getItems + const data = await window.apiHelper?.getItems( + { + IncludeItemTypes: itemType, + Recursive: true, + Fields: 'ItemCounts' + }, + !refresh, + 24 * 60 * 60 * 1000 // 24 hours + ); + options = (data?.Items || []).map(item => ({ + id: item.Id, + name: item.Name + })); + } + + // Load names for existing badges + await loadBadgeNames(container, prefix, sectionIndex, type); + } + } catch (err) { + ERR(`Error fetching ${type} options:`, err); + } + } + }; + + // Initial fetch + await fetchOptions(forceRefresh); + + let currentSuggestions = []; + let selectedIndex = -1; + + const getExistingBadges = () => BadgeSystem.getExistingBadges(badgeContainer); + + const notifyChange = () => { + if (onChange) { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + const values = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean); + onChange(values); + } + }; + + input.addEventListener('input', (e) => { + const value = e.target.value.trim(); + if (value.length === 0) { + suggestionsDiv.style.display = 'none'; + return; + } + + const existing = getExistingBadges(); + + // Filter options based on type + if (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') { + currentSuggestions = options.filter(opt => + opt.toLowerCase().includes(value.toLowerCase()) && + !existing.includes(opt.toLowerCase()) + ).slice(0, 10); + } else { + // Collections/Playlists: search by name, exclude by ID + currentSuggestions = options.filter(opt => + opt.name.toLowerCase().includes(value.toLowerCase()) && + !existing.includes(opt.id.toLowerCase()) + ).slice(0, 10); + } + + if (currentSuggestions.length > 0) { + suggestionsDiv.innerHTML = currentSuggestions.map((opt, idx) => { + // Fix: IncludeItemTypes is a string, not an object + const display = (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') ? opt : opt.name; + const dataValue = (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') ? opt : opt.id; + return `
${display}
`; + }).join(''); + suggestionsDiv.style.display = 'block'; + selectedIndex = -1; + // Set up mouse event handlers for each suggestion + const suggestionItems = suggestionsDiv.querySelectorAll('.autocomplete-suggestion'); + suggestionItems.forEach((item, idx) => { + item.addEventListener('mouseenter', () => { + selectedIndex = -1; + updateSuggestionHighlight(); + item.style.background = 'rgba(255,255,255,0.1)'; + }); + item.addEventListener('mouseleave', () => { + item.style.background = ''; + }); + }); + } else { + suggestionsDiv.style.display = 'none'; + } + }); + + // Handle suggestion clicks + suggestionsDiv.addEventListener('click', (e) => { + const suggestion = e.target.closest('.autocomplete-suggestion'); + if (!suggestion) return; + + const selectedValue = suggestion.getAttribute('data-value'); + const displayText = suggestion.getAttribute('data-display') || selectedValue; + + BadgeSystem.addBadge(prefix, sectionIndex, selectedValue, displayText, badgeContainer, input, hiddenInput); + suggestionsDiv.style.display = 'none'; + selectedIndex = -1; + input.focus(); + notifyChange(); + }); + + // Function to show all available suggestions (excluding already selected) + function showAllSuggestions() { + const existing = getExistingBadges(); + + // Filter options based on type, excluding already selected + if (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') { + currentSuggestions = options.filter(opt => + !existing.includes(opt.toLowerCase()) + ).slice(0, 30); + } else { + // Collections/Playlists: exclude by ID + currentSuggestions = options.filter(opt => + !existing.includes(opt.id.toLowerCase()) + ).slice(0, 30); + } + + if (currentSuggestions.length > 0) { + suggestionsDiv.innerHTML = currentSuggestions.map((opt, idx) => { + const display = (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') ? opt : opt.name; + const dataValue = (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') ? opt : opt.id; + return `
${display}
`; + }).join(''); + suggestionsDiv.style.display = 'block'; + selectedIndex = 0; + updateSuggestionHighlight(); + // Set up mouse event handlers for each suggestion + const suggestionItems = suggestionsDiv.querySelectorAll('.autocomplete-suggestion'); + suggestionItems.forEach((item, idx) => { + item.addEventListener('mouseenter', () => { + selectedIndex = -1; + updateSuggestionHighlight(); + item.style.background = 'rgba(255,255,255,0.1)'; + }); + item.addEventListener('mouseleave', () => { + item.style.background = ''; + }); + }); + } else { + suggestionsDiv.style.display = 'none'; + } + } + + // Handle keyboard navigation + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + // If input is empty and no suggestions shown, show all suggestions + if (input.value.trim().length === 0 && suggestionsDiv.style.display === 'none') { + showAllSuggestions(); + return; + } + // If suggestions are shown, navigate + if (currentSuggestions.length > 0) { + selectedIndex = Math.min(selectedIndex + 1, currentSuggestions.length - 1); + updateSuggestionHighlight(); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (currentSuggestions.length > 0) { + selectedIndex = Math.max(selectedIndex - 1, -1); + updateSuggestionHighlight(); + // If we go above the first item, hide suggestions + if (selectedIndex < 0) { + suggestionsDiv.style.display = 'none'; + } + } + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && currentSuggestions[selectedIndex]) { + const selected = currentSuggestions[selectedIndex]; + const value = (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') ? selected : selected.id; + const display = (type === 'Tag' || type === 'Genre' || type === 'IncludeItemTypes') ? selected : selected.name; + BadgeSystem.addBadge(prefix, sectionIndex, value, display, badgeContainer, input, hiddenInput); + suggestionsDiv.style.display = 'none'; + selectedIndex = -1; + notifyChange(); + } + } else if (e.key === 'Escape') { + suggestionsDiv.style.display = 'none'; + selectedIndex = -1; + } else if (e.key === 'Backspace' && input.value === '') { + const badges = badgeContainer.querySelectorAll('.tag-badge'); + if (badges.length > 0) { + const lastBadge = badges[badges.length - 1]; + BadgeSystem.removeBadge(prefix, sectionIndex, lastBadge, badgeContainer, hiddenInput); + notifyChange(); + } + } + }); + + function updateSuggestionHighlight() { + const items = suggestionsDiv.querySelectorAll('.autocomplete-suggestion'); + items.forEach((item, idx) => { + if (idx === selectedIndex) { + item.style.background = 'rgba(0, 122, 255, 0.3)'; + item.style.borderLeft = '3px solid rgba(0, 122, 255, 0.8)'; + item.style.fontWeight = '500'; + // Scroll into view if needed + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } else { + item.style.background = ''; + item.style.borderLeft = '3px solid transparent'; + item.style.fontWeight = ''; + } + }); + } + + // Hide suggestions when clicking outside + document.addEventListener('click', (e) => { + if (!badgeContainer.contains(e.target) && !suggestionsDiv.contains(e.target)) { + suggestionsDiv.style.display = 'none'; + } + }); + + // Setup chevron button (triggers dropdown like down arrow) + if (chevronBtn) { + chevronBtn.addEventListener('click', (e) => { + e.stopPropagation(); + // Toggle dropdown: if already open, close it; otherwise open it + if (suggestionsDiv.style.display === 'block' || suggestionsDiv.style.display === '') { + // Dropdown is open, close it + suggestionsDiv.style.display = 'none'; + selectedIndex = -1; + } else { + // Dropdown is closed, open it + if (input.value.trim().length === 0) { + showAllSuggestions(); + } else { + // Trigger input event to show filtered suggestions + input.dispatchEvent(new Event('input', { bubbles: true })); + } + } + }); + } + + // Setup refresh button (bypasses cache) + if (refreshBtn) { + refreshBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + refreshBtn.disabled = true; + refreshBtn.textContent = '⏳'; + try { + await fetchOptions(true); // Force refresh + // Re-trigger input to update suggestions if input has value + if (input.value.trim().length > 0) { + input.dispatchEvent(new Event('input', { bubbles: true })); + } + } catch (err) { + ERR(`Error refreshing ${type} options:`, err); + } finally { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄'; + } + }); + } + } + + /** + * Loads and updates badge display text for Collections/Playlists + * Replaces IDs with names after fetching from API + */ + async function loadBadgeNames(container, prefix, sectionIndex, type) { + if (type !== 'Collection' && type !== 'Playlist') return; + + const badgeContainer = container.querySelector( + `.tag-badge-container[data-section-index="${sectionIndex}"][data-type="${type}"]` + ); + if (!badgeContainer) return; + + const badges = badgeContainer.querySelectorAll('.tag-badge[data-loading="true"]'); + if (badges.length === 0) return; + + try { + const itemType = type === 'Playlist' ? 'Playlist' : 'BoxSet,CollectionFolder'; + const data = await window.apiHelper?.getItems( + { + IncludeItemTypes: itemType, + Recursive: true, + Fields: 'ItemCounts' + }, + true, + 300000 + ); + + const items = data?.Items || []; + const itemMap = new Map(items.map(item => [item.Id, item])); + + badges.forEach(badge => { + const id = badge.getAttribute('data-value'); + const item = itemMap.get(id); + if (item) { + badge.removeAttribute('data-loading'); + badge.innerHTML = `${item.Name}`; + } + }); + } catch (err) { + ERR(`Error loading ${type} names:`, err); + } + } + + /** + * Get section type toggle states from localStorage + * @returns {Object} Object mapping section types (lowercase) to their enabled state (boolean) + */ + function getSectionTypeToggleStates() { + try { + const stored = localStorage.getItem('kefinTweaks-sectionTypeToggles'); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + // Ignore parse errors, return defaults + } + return {}; + } + + /** + * Save section type toggle states to localStorage + * @param {Object} states - Object mapping section types (lowercase) to their enabled state (boolean) + */ + function saveSectionTypeToggleStates(states) { + try { + localStorage.setItem('kefinTweaks-sectionTypeToggles', JSON.stringify(states)); + } catch (e) { + // Ignore storage errors (e.g., quota exceeded) + } + } + + /** + * Render section type toggle buttons + * @param {Array} sectionTypes - Array of unique section types + * @param {Array} enabledTypes - Array of types that should be initially enabled (defaults to all) + * @returns {string} HTML string + */ + function renderSectionTypeToggles(sectionTypes, enabledTypes = null) { + if (!sectionTypes || sectionTypes.length === 0) return ''; + + // If enabledTypes not provided, all types are enabled by default + const enabledSet = enabledTypes ? new Set(enabledTypes.map(t => t.toLowerCase())) : new Set(sectionTypes.map(t => t.toLowerCase())); + + // Helper to capitalize first letter for display + const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + + return ` +
+ ${sectionTypes.map(type => { + const typeLower = type.toLowerCase(); + const isEnabled = enabledSet.has(typeLower); + const buttonClasses = `emby-button section-type-toggle-btn raised block${isEnabled ? ' button-submit' : ''}`; + return ` + + `; + }).join('')} +
+ `; + } + + /** + * Build toggle slider HTML (simplified version for user editor) + */ + function buildToggleSliderHTML(checked, sectionId, onToggle) { + const isEnabled = checked !== false; + return ` + + `; + } + + /** + * Build section row HTML for order editor + */ + function buildSectionRowHTMLForEditor(section, options = {}) { + const { showUpDownButtons = false, isFirst = false, isLast = false } = options; + const isEnabled = section.enabled !== false; + const sectionId = section.id || ''; + const sectionName = section.name || 'Unnamed Section'; + const isJellyfin = sectionId.endsWith('-jellyfin') || section.isJellyfin === true; + + // Determine section type (always lowercase for consistency with CSS) + let sectionType = section.type ? section.type.toLowerCase() : null; + if (!sectionType) { + sectionType = 'home'; + } + + // Disabled sections should not be draggable + const draggableAttr = isEnabled ? 'draggable="true"' : ''; + + // Build data attributes for first/last state + const dataAttrs = []; + if (isFirst) dataAttrs.push('data-is-first="true"'); + if (isLast) dataAttrs.push('data-is-last="true"'); + const dataAttrsStr = dataAttrs.join(' '); + + return ` +
+ + ${buildToggleSliderHTML(isEnabled, sectionId)} +
+
+ ${sectionName.replace(/"/g, '"')} +
+
+ ${showUpDownButtons ? ` + + + ` : ''} +
+ `; + } + + /** + * Build group HTML for order editor + * @param {Array} enabledSections - Enabled sections to render in reorderable list + * @param {Array} disabledSections - Disabled sections to render in non-reorderable list + * @param {Object} options - Options object + */ + function buildGroupHTMLForEditor(enabledSections, disabledSections = [], options = {}) { + const { showUpDownButtons = false } = options; + + const sortedEnabled = [...(enabledSections || [])].sort((a, b) => (a.order || 0) - (b.order || 0)); + const sortedDisabled = [...(disabledSections || [])].sort((a, b) => (a.order || 0) - (b.order || 0)); + + let html = ''; + + // Enabled sections list (reorderable) + if (sortedEnabled.length > 0) { + html += ` +
+ ${sortedEnabled.map((section, index) => + buildSectionRowHTMLForEditor(section, { + showUpDownButtons, + isFirst: index === 0, + isLast: index === sortedEnabled.length - 1 + }) + ).join('')} +
+ `; + } + + // Disabled sections list (non-reorderable) + // Buttons are always rendered, CSS hides them for disabled sections + if (sortedDisabled.length > 0) { + html += ` +
+
Disabled Sections
+
+ ${sortedDisabled.map((section) => + buildSectionRowHTMLForEditor(section, { + showUpDownButtons: showUpDownButtons, // Use same setting as enabled sections + isFirst: false, + isLast: false + }) + ).join('')} +
+
+ `; + } + + return html; + } + + /** + * Calculate new order with enhanced logic for same-order sections + */ + function calculateNewOrderEnhanced(draggedElement, targetElement, dropPosition, getSectionOrder) { + 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); + const draggedIndex = allRows.indexOf(draggedElement); + + 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 { + // Get preceding and following sections + const precedingElement = allRows[targetIndex - 1]; + const followingElement = targetElement; + + if (precedingElement && followingElement) { + const precedingId = precedingElement.dataset.sectionId; + const followingId = followingElement.dataset.sectionId; + const precedingOrder = getSectionOrder(precedingId); + const followingOrder = getSectionOrder(followingId); + + // If same order, use that order + if (precedingOrder === followingOrder) { + return precedingOrder; + } + } + + // Different orders or missing - use target's order - 1 + 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 + const lastSectionId = targetElement.dataset.sectionId; + const lastOrder = getSectionOrder(lastSectionId); + return lastOrder + 1; + } else { + // Get preceding and following sections + const precedingElement = targetElement; + const followingElement = allRows[targetIndex + 1]; + + if (precedingElement && followingElement && followingElement !== draggedElement) { + const precedingId = precedingElement.dataset.sectionId; + const followingId = followingElement.dataset.sectionId; + const precedingOrder = getSectionOrder(precedingId); + const followingOrder = getSectionOrder(followingId); + + // If same order, use that order + if (precedingOrder === followingOrder) { + return precedingOrder; + } + } + + // Different orders or missing - use following section order - 1, or preceding + 1 + if (followingElement && followingElement !== draggedElement) { + const nextSectionId = followingElement.dataset.sectionId; + const nextOrder = getSectionOrder(nextSectionId); + return Math.max(0, nextOrder - 1); + } else { + const targetSectionId = targetElement.dataset.sectionId; + const targetOrder = getSectionOrder(targetSectionId); + return targetOrder + 1; + } + } + } + } + + /** + * Render home sections order editor + * @param {Array} sections - Array of section objects + * @param {Object} options - Options object + * @param {boolean} options.showUpDownButtons - Show up/down buttons on hover + * @param {Function} options.onOrderChange - Callback when order changes (sectionId, newOrder) + * @param {Function} options.onToggleChange - Callback when toggle changes (sectionId, enabled) + * @param {Function} options.getSectionOrder - Function to get current order of a section + * @returns {string} HTML string + */ + function renderHomeSectionsOrderEditor(sections, options = {}) { + if (!sections || !Array.isArray(sections)) { + return '
No sections found.
'; + } + + const { + showUpDownButtons = false, + onOrderChange = null, + onToggleChange = null, + getSectionOrder = (id) => { + const section = sections.find(s => (s.id || '') === id); + return section?.order || 0; + } + } = options; + + const filteredSections = sections.filter(s => !s.hidden); + + // Determine section types for each section and collect unique types + const sectionTypesSet = new Set(); + const sectionsWithTypes = filteredSections.map(section => { + let sectionType = section.type; + if (!sectionType) { + sectionType = 'home'; + } + sectionTypesSet.add(sectionType); + return { ...section, _computedType: sectionType }; + }); + + // Split into enabled and disabled sections + const enabledSections = sectionsWithTypes.filter(s => s.enabled !== false); + const disabledSections = sectionsWithTypes.filter(s => s.enabled === false); + + if (enabledSections.length === 0 && disabledSections.length === 0) { + return '
No sections to order.
'; + } + + // Get unique section types (sorted for consistent display) all in lowercase + const renderedSectionTypes = Array.from(sectionTypesSet).sort().map(type => type.toLowerCase()); + + // Preferred Section Types order in array: Jellyfin, Home, Seasonal, Discovery, Custom + let sectionTypes = ['jellyfin', 'home', 'seasonal', 'discovery', 'custom']; + sectionTypes = sectionTypes.filter(type => renderedSectionTypes.includes(type)); + + // Load saved toggle states from localStorage + const savedToggleStates = getSectionTypeToggleStates(); + + // Build data attributes for type filtering + // Use saved states if available, otherwise use defaults (seasonal = false, others = true) + const typeFilterAttrs = sectionTypes.map(type => { + const typeLower = type.toLowerCase(); + // Check if we have a saved state for this type + const hasSavedState = savedToggleStates.hasOwnProperty(typeLower); + let isEnabled; + if (hasSavedState) { + // Use saved state + isEnabled = savedToggleStates[typeLower] ? 'true' : 'false'; + } else { + // Use default: seasonal is hidden by default, all others are visible + isEnabled = typeLower === 'seasonal' ? 'false' : 'true'; + } + return `data-section-type-${typeLower}="${isEnabled}"`; + }).join(' '); + + // Determine which types should have their toggle buttons initially enabled + const enabledTypes = sectionTypes.filter(type => { + const typeLower = type.toLowerCase(); + const hasSavedState = savedToggleStates.hasOwnProperty(typeLower); + if (hasSavedState) { + return savedToggleStates[typeLower]; + } + // Default: seasonal is off, others are on + return typeLower !== 'seasonal'; + }); + + return ` +
+ ${sectionTypes.length > 1 ? renderSectionTypeToggles(sectionTypes, enabledTypes) : ''} + ${buildGroupHTMLForEditor(enabledSections, disabledSections, { showUpDownButtons })} +
+ `; + } + + /** + * Setup event listeners for the order editor + * @param {HTMLElement} container - Container element containing the editor + * @param {Object} options - Options object + */ + function setupOrderEditorListeners(container, options = {}) { + const { + onOrderChange = null, + onToggleChange = null, + getSectionOrder = null, + sections = [], + onSave = null + } = options; + + if (!container) return; + + const getOrder = (id) => { + if (getSectionOrder) return getSectionOrder(id); + const section = sections.find(s => (s.id || '') === id); + return section?.order || 0; + }; + + // Helper function to update first/last data attributes for enabled list + const updateFirstLastAttributes = (enabledList) => { + if (!enabledList) return; + const rows = Array.from(enabledList.querySelectorAll('.section-row')); + // Filter to only visible rows + const visibleRows = rows.filter(row => { + const computedStyle = window.getComputedStyle(row); + return computedStyle.display !== 'none'; + }); + + visibleRows.forEach((row, index) => { + if (index === 0) { + row.setAttribute('data-is-first', 'true'); + row.removeAttribute('data-is-last'); + } else if (index === visibleRows.length - 1) { + row.setAttribute('data-is-last', 'true'); + row.removeAttribute('data-is-first'); + } else { + row.removeAttribute('data-is-first'); + row.removeAttribute('data-is-last'); + } + }); + }; + + // Add CSS for hover effects, toggle positioning, type filtering, and disabled sections + if (!document.getElementById('kefin-order-editor-styles')) { + const style = document.createElement('style'); + style.id = 'kefin-order-editor-styles'; + style.textContent = ` + .section-row { + display: flex; + align-items: center; + gap: 0.5em; + } + /* Type filtering - hide sections when their type toggle is off */ + /* This will be dynamically generated for each section type, but we include common ones */ + .sections-container[data-section-type-jellyfin="false"] .section-row[data-section-type="jellyfin"], + .sections-container[data-section-type-custom="false"] .section-row[data-section-type="custom"], + .sections-container[data-section-type-home="false"] .section-row[data-section-type="home"], + .sections-container[data-section-type-seasonal="false"] .section-row[data-section-type="seasonal"], + .sections-container[data-section-type-discovery="false"] .section-row[data-section-type="discovery"] { + display: none !important; + } + /* Disabled sections styling */ + .disabled-sections-list .section-row { + opacity: 0.6; + cursor: default !important; + } + .disabled-sections-list .section-row[draggable] { + cursor: default !important; + pointer-events: none; + } + /* Hide up/down buttons for disabled sections */ + .disabled-sections-list .section-row .section-move-up-btn, + .disabled-sections-list .section-row .section-move-down-btn { + display: none !important; + } + .disabled-sections-list .section-row .section-toggle-switch { + pointer-events: auto; + cursor: pointer; + } + /* Disable/grey-out up button for first item in enabled list */ + .enabled-sections-list .section-row[data-is-first="true"] .section-move-up-btn { + opacity: 0.4; + pointer-events: none; + cursor: not-allowed; + } + /* Disable/grey-out down button for last item in enabled list */ + .enabled-sections-list .section-row[data-is-last="true"] .section-move-down-btn { + opacity: 0.4; + pointer-events: none; + cursor: not-allowed; + } + `; + document.head.appendChild(style); + } + + // Up/Down button handlers + container.addEventListener('click', (e) => { + const upBtn = e.target.closest('.section-move-up-btn'); + const downBtn = e.target.closest('.section-move-down-btn'); + + if (upBtn || downBtn) { + e.preventDefault(); + e.stopPropagation(); + + // Check if button is disabled (via CSS pointer-events: none) + const btn = upBtn || downBtn; + const btnStyle = window.getComputedStyle(btn); + if (btnStyle.pointerEvents === 'none') { + return; // Button is disabled, don't process click + } + + const sectionRow = (upBtn || downBtn).closest('.section-row'); + if (!sectionRow) return; + + // Only operate on enabled sections list + const enabledList = container.querySelector('.enabled-sections-list') + || container.closest('.sections-container')?.querySelector('.enabled-sections-list'); + if (!enabledList || !enabledList.contains(sectionRow)) return; + + // Get all rows in enabled list and filter to only visible ones + const allRows = Array.from(enabledList.querySelectorAll('.section-row')); + const visibleRows = allRows.filter(row => { + const computedStyle = window.getComputedStyle(row); + return computedStyle.display !== 'none'; + }); + + const currentIndex = visibleRows.indexOf(sectionRow); + + if (upBtn && currentIndex > 0) { + // Move up - find previous visible section + const targetRow = visibleRows[currentIndex - 1]; + + // Update order values only if onOrderChange is provided + if (onOrderChange) { + const sectionId = sectionRow.dataset.sectionId; + const targetSectionId = targetRow.dataset.sectionId; + const targetOrder = getOrder(targetSectionId); + const currentOrder = getOrder(sectionId); + + // Swap orders + onOrderChange(sectionId, targetOrder); + onOrderChange(targetSectionId, currentOrder); + } + + // Update DOM position + sectionRow.parentElement.insertBefore(sectionRow, targetRow); + + // Update first/last attributes after moving + updateFirstLastAttributes(enabledList); + + // Auto-save after reordering + if (onSave) { + onSave().catch(err => { + console.error('Error auto-saving after up/down:', err); + }); + } + } else if (downBtn && currentIndex < visibleRows.length - 1) { + // Move down - find next visible section + const targetRow = visibleRows[currentIndex + 1]; + + // Update order values only if onOrderChange is provided + if (onOrderChange) { + const sectionId = sectionRow.dataset.sectionId; + const targetSectionId = targetRow.dataset.sectionId; + const targetOrder = getOrder(targetSectionId); + const currentOrder = getOrder(sectionId); + + // Swap orders + onOrderChange(sectionId, targetOrder); + onOrderChange(targetSectionId, currentOrder); + } + + // Update DOM position + sectionRow.parentElement.insertBefore(targetRow, sectionRow); + + // Update first/last attributes after moving + updateFirstLastAttributes(enabledList); + + // Auto-save after reordering + if (onSave) { + onSave().catch(err => { + console.error('Error auto-saving after up/down:', err); + }); + } + } + } + }); + + // Toggle switch handlers + container.addEventListener('click', (e) => { + const toggle = e.target.closest('.toggle-slider'); + if (!toggle) return; + + e.stopPropagation(); + const sectionId = toggle.dataset.sectionId; + const currentEnabled = toggle.dataset.enabled === 'true'; + const newEnabled = !currentEnabled; + + const sectionRow = toggle.closest('.section-row'); + if (!sectionRow) return; + + // Find the sections container (might be the container itself or a parent) + const sectionsContainer = container.classList.contains('sections-container') + ? container + : (container.closest('.sections-container') || container.querySelector('.sections-container')); + + // Find the lists (might be direct children or descendants) + const enabledList = container.querySelector('.enabled-sections-list') + || sectionsContainer?.querySelector('.enabled-sections-list'); + const disabledList = container.querySelector('.disabled-sections-list') + || sectionsContainer?.querySelector('.disabled-sections-list'); + + if (!enabledList || !disabledList) { + // Fallback to old behavior if lists don't exist + toggle.dataset.enabled = newEnabled; + toggle.style.background = newEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'; + const span = toggle.querySelector('span'); + if (span) { + span.style.left = newEnabled ? '32px' : '2px'; + } + if (onToggleChange) { + onToggleChange(sectionId, newEnabled); + } + return; + } + + // Update toggle UI + toggle.dataset.enabled = newEnabled; + toggle.style.background = newEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'; + const span = toggle.querySelector('span'); + if (span) { + span.style.left = newEnabled ? '32px' : '2px'; + } + + // Move section between lists + if (newEnabled) { + // Moving from disabled to enabled - insert based on order + const sectionOrder = getOrder(sectionId); + const enabledRows = Array.from(enabledList.querySelectorAll('.section-row')); + + // Find the correct position based on order + let insertBefore = null; + for (const row of enabledRows) { + const rowId = row.dataset.sectionId; + const rowOrder = getOrder(rowId); + if (rowOrder > sectionOrder) { + insertBefore = row; + break; + } + } + + // Update section row to be draggable (buttons are always rendered, CSS handles visibility) + sectionRow.setAttribute('draggable', 'true'); + + // Move to enabled list + if (insertBefore) { + enabledList.insertBefore(sectionRow, insertBefore); + } else { + enabledList.appendChild(sectionRow); + } + + // Update first/last data attributes after moving + updateFirstLastAttributes(enabledList); + } else { + // Moving from enabled to disabled + // Remove drag (buttons are always rendered, CSS handles visibility) + sectionRow.removeAttribute('draggable'); + + // Move to disabled list + disabledList.appendChild(sectionRow); + + // Update first/last data attributes after moving + updateFirstLastAttributes(enabledList); + } + + if (onToggleChange) { + onToggleChange(sectionId, newEnabled); + } + }); + + // Section type toggle handlers + container.addEventListener('click', (e) => { + const toggleBtn = e.target.closest('.section-type-toggle-btn'); + if (!toggleBtn) return; + + e.stopPropagation(); + const sectionType = toggleBtn.dataset.sectionType; + const sectionTypeLower = sectionType.toLowerCase(); + + // Find the sections container (might be the container itself or a parent) + const sectionsContainer = container.classList.contains('sections-container') + ? container + : (container.closest('.sections-container') || container.querySelector('.sections-container')); + if (!sectionsContainer) return; + + // Get current state and toggle it + const attrName = `data-section-type-${sectionTypeLower}`; + const currentState = sectionsContainer.getAttribute(attrName); + const newState = currentState === 'false' ? 'true' : 'false'; + const newStateBool = newState === 'true'; + + // Update container attribute + sectionsContainer.setAttribute(attrName, newState); + + // Update button appearance + if (newStateBool) { + toggleBtn.classList.add('button-submit'); + } else { + toggleBtn.classList.remove('button-submit'); + } + + // Save the new state to localStorage + const savedToggleStates = getSectionTypeToggleStates(); + savedToggleStates[sectionTypeLower] = newStateBool; + saveSectionTypeToggleStates(savedToggleStates); + }); + + // Drag and drop handlers - only work within enabled sections list + let draggedElement = null; + let dropPosition = 'below'; + + container.addEventListener('dragstart', (e) => { + const row = e.target.closest('.section-row'); + if (!row) return; + + // Only allow dragging from enabled list + const enabledList = container.querySelector('.enabled-sections-list') + || container.closest('.sections-container')?.querySelector('.enabled-sections-list'); + if (!enabledList || !enabledList.contains(row)) { + e.preventDefault(); + return; + } + + draggedElement = row; + e.dataTransfer.effectAllowed = 'move'; + }); + + container.addEventListener('dragover', (e) => { + const row = e.target.closest('.section-row'); + if (!row || row === draggedElement) return; + + // Only allow dropping in enabled list + const enabledList = container.querySelector('.enabled-sections-list') + || container.closest('.sections-container')?.querySelector('.enabled-sections-list'); + if (!enabledList || !enabledList.contains(row) || !enabledList.contains(draggedElement)) { + return; + } + + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + const rect = row.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + dropPosition = e.clientY < midpoint ? 'above' : 'below'; + + // Visual feedback + row.style.borderTop = dropPosition === 'above' ? '2px solid var(--theme-primary-color, #00a4dc)' : 'none'; + row.style.borderBottom = dropPosition === 'below' ? '2px solid var(--theme-primary-color, #00a4dc)' : 'none'; + }); + + container.addEventListener('dragleave', (e) => { + const row = e.target.closest('.section-row'); + if (row) { + row.style.borderTop = ''; + row.style.borderBottom = ''; + } + }); + + container.addEventListener('drop', (e) => { + const row = e.target.closest('.section-row'); + if (!row || !draggedElement || row === draggedElement) return; + + // Only allow dropping in enabled list + const enabledList = container.querySelector('.enabled-sections-list') + || container.closest('.sections-container')?.querySelector('.enabled-sections-list'); + if (!enabledList || !enabledList.contains(row) || !enabledList.contains(draggedElement)) { + return; + } + + e.preventDefault(); + + // Clear visual feedback + row.style.borderTop = ''; + row.style.borderBottom = ''; + + // Update order values only if onOrderChange is provided + // Otherwise, we'll derive order from DOM position when saving + if (onOrderChange) { + const draggedSectionId = draggedElement.dataset.sectionId; + const newOrder = calculateNewOrderEnhanced(draggedElement, row, dropPosition, getOrder); + onOrderChange(draggedSectionId, newOrder); + } + + // Update DOM position (always do this, regardless of onOrderChange) + if (dropPosition === 'above') { + row.parentElement.insertBefore(draggedElement, row); + } else { + row.parentElement.insertBefore(draggedElement, row.nextSibling); + } + + // Update first/last attributes after moving + updateFirstLastAttributes(enabledList); + + // Auto-save after drag/drop reordering + if (onSave) { + onSave().catch(err => { + console.error('Error auto-saving after drag/drop:', err); + }); + } + + draggedElement = null; + }); + + // Initialize first/last attributes on page load + const initEnabledList = container.querySelector('.enabled-sections-list') + || container.closest('.sections-container')?.querySelector('.enabled-sections-list'); + if (initEnabledList) { + updateFirstLastAttributes(initEnabledList); + } + } + + // Expose to global scope + window.KefinTweaksUI = { + createAutoCompleteInput, + BadgeSystem, + renderHomeSectionsOrderEditor, + setupOrderEditorListeners + }; + + LOG('UI utilities loaded'); + LOG('Available at window.KefinTweaksUI'); + +})(); +