// KefinTweaks Configuration UI // Adds a configuration button to the Administration section in user preferences // Opens configuration in a modal window (function() { 'use strict'; console.log('[KefinTweaks Configuration] Initializing...'); const MODAL_ID = 'kefinTweaksConfigModal'; let currentLoadedConfig = null; const DISCOVERY_SECTION_DEFINITIONS = [ { key: 'spotlightGenre', label: 'Spotlight Sections (Genre)', defaultName: 'Spotlight' }, { key: 'spotlightNetwork', label: 'Spotlight Sections (Network)', defaultName: 'Spotlight' }, { key: 'genreMovies', label: 'Genre Movies', defaultName: '[Genre] Movies' }, { key: 'studioShows', label: 'Shows from [Studio]', defaultName: 'Shows from [Studio]' }, { key: 'collections', label: 'Collection Spotlight', defaultName: '[Collection Name]', extras: { minimumItems: 10 } }, { key: 'becauseYouWatched', label: 'Because you watched [Movie]', defaultName: 'Because you watched [Movie]' }, { key: 'becauseYouLiked', label: 'Because you liked [Movie]', defaultName: 'Because you liked [Movie]' }, { key: 'starringTopActor', label: 'Starring [Top Actor]', defaultName: 'Starring [Actor]' }, { key: 'directedByTopDirector', label: 'Directed by [Top Director]', defaultName: 'Directed by [Director]' }, { key: 'writtenByTopWriter', label: 'Written by [Top Writer]', defaultName: 'Written by [Writer]' }, { key: 'becauseYouRecentlyWatched', label: 'Because you recently watched [Movie]', defaultName: 'Because you recently watched [Movie]' }, { key: 'starringActorRecentlyWatched', label: 'Starring [Person]', defaultName: 'Starring {Person}', extras: { caption: 'because you recently watched {Title}' } }, { key: 'directedByDirectorRecentlyWatched', label: 'Directed by [Person]', defaultName: 'Directed by {Person}', extras: { caption: 'because you recently watched {Title}' } }, { key: 'writtenByWriterRecentlyWatched', label: 'Written by [Person]', defaultName: 'Written by {Person}', extras: { caption: 'because you recently watched {Title}' } } ]; const SUPPORTED_CUSTOM_SECTION_PARAMS = { filters: { label: 'Filters', type: 'array', hint: 'IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes' }, isPlayed: { label: 'Is Played', type: 'boolean' }, person: { label: 'Person Name', type: 'string' }, personTypes: { label: 'Person Types', type: 'array', hint: 'Actor, Director, Writer, etc.' }, years: { label: 'Years', type: 'array', hint: 'Comma-separated years' }, // Dates (ISO 8601) 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' }, // Numbers indexNumber: { label: 'Index Number', type: 'number' }, parentIndexNumber: { label: 'Parent Index Number', type: 'number' }, minCommunityRating: { label: 'Min Community Rating', type: 'number' }, minCriticRating: { label: 'Min Critic Rating', type: 'number' }, // Booleans isFavorite: { label: 'Is Favorite', 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' }, isMissing: { label: 'Is Missing', type: 'boolean' }, isUnaired: { label: 'Is Unaired', type: 'boolean' }, //is3D: { label: 'Is 3D', type: 'boolean' }, //isSeries: { label: 'Is Series (Live TV)', type: 'boolean' }, hasOverview: { label: 'Has Overview', type: 'boolean' }, hasOfficialRating: { label: 'Has Official Rating', type: 'boolean' }, recursive: { label: 'Recursive', type: 'boolean', default: true }, // Strings 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' }, // Arrays (Comma-separated strings in UI) locationTypes: { label: 'Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' }, excludeLocationTypes: { label: 'Exclude Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' }, excludeItemIds: { label: 'Exclude Item IDs', type: 'array', hint: 'Comma-separated GUIDs' }, 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' }, personIds: { label: 'Person IDs', type: 'array', hint: 'Comma-separated GUIDs' }, studioIds: { label: 'Studio IDs', type: 'array', hint: 'Comma-separated GUIDs' }, ids: { label: 'Item IDs', type: 'array', hint: 'Comma-separated GUIDs' } }; // Check if user is admin async function isAdmin() { try { if (window.ApiClient && window.ApiClient.getCurrentUser) { const user = await window.ApiClient.getCurrentUser(); return user && user.Policy && user.Policy.IsAdministrator === true; } } catch (error) { console.warn('[KefinTweaks Configuration] Could not check admin status:', error); } return false; } // Load default configuration from JS file async function loadDefaultConfig() { return new Promise((resolve, reject) => { try { const defaultConfigUrl = window.KefinTweaksConfig?.kefinTweaksRoot ? `${window.KefinTweaksConfig.kefinTweaksRoot}/kefinTweaks-default-config.js` : null; if (!defaultConfigUrl) { reject(new Error('No default config URL found')); return; } // Check if already loaded if (window.KefinTweaksDefaultConfig) { console.log('[KefinTweaks Configuration] Using cached default config'); resolve(window.KefinTweaksDefaultConfig); return; } // Create script element to load the JS file const script = document.createElement('script'); script.src = defaultConfigUrl; script.async = true; script.onload = () => { if (window.KefinTweaksDefaultConfig) { console.log('[KefinTweaks Configuration] Loaded default config:', window.KefinTweaksDefaultConfig); resolve(window.KefinTweaksDefaultConfig); } else { reject(new Error('Default config file loaded but window.KefinTweaksDefaultConfig is not defined')); } }; script.onerror = (error) => { reject(new Error(`Failed to load default config file: ${defaultConfigUrl}`)); }; document.head.appendChild(script); } catch (error) { console.error('[KefinTweaks Configuration] Error loading default config:', error); reject(error); } }); } // 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 injectorConfig = await getJavaScriptInjectorConfig(pluginId); // Find KefinTweaks-Config script const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( script => script.Name === 'KefinTweaks-Config' ); */ /* 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 { 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)); 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; // Run migration if needed const migratedConfig = await migrateLegacyHomeScreenConfig(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 try { return await loadDefaultConfig(); } catch (fallbackError) { console.error('[KefinTweaks Configuration] Error loading default config as fallback:', fallbackError); // Return empty config structure as last resort return { kefinTweaksRoot: '', scripts: {}, homeScreen: {}, search: {}, skins: [], defaultSkin: null, themes: [], customMenuLinks: [] }; } } } // 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'; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` :not(.layout-mobile) #${MODAL_ID} .modal-content-wrapper { max-width: 90vw; width: 1400px; max-height: 90vh; overflow-y: auto; background: var(--background-color, #1a1a1a); border-radius: 8px; } #${MODAL_ID} .content-primary { max-width: 1400px; margin: 0 auto; padding: 1.5em; } #${MODAL_ID} .kefin-config-version-cluster { margin-left: auto; display: inline-flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } #${MODAL_ID} .kefin-config-version-label { margin: 0; opacity: 0.75; font-size: 0.95em; white-space: nowrap; } #${MODAL_ID} .kefin-config-update-badge { display: none; padding: 0.35em 0.85em; font-size: 0.85em; font-weight: 600; border-radius: 999px; border: 1px solid rgba(82, 196, 26, 0.55); background: rgba(82, 196, 26, 0.18); color: inherit; cursor: pointer; white-space: nowrap; } #${MODAL_ID} .kefin-config-update-badge.is-visible { display: inline-flex; align-items: center; } #${MODAL_ID} .kefin-config-update-badge:hover { background: rgba(82, 196, 26, 0.28); } #${MODAL_ID} .paperList { background: rgba(255, 255, 255, 0.03); border-radius: 8px; padding: 1em; } #${MODAL_ID} .listItem { transition: background-color 0.2s ease; } #${MODAL_ID} .listItem:hover { background-color: rgba(255, 255, 255, 0.02); } #${MODAL_ID} textarea.fld { resize: vertical; min-height: 200px; } #${MODAL_ID} details summary { user-select: none; overflow: visible; } #${MODAL_ID} details summary:hover { opacity: 0.8; } #${MODAL_ID} pre { white-space: pre-wrap; word-wrap: break-word; } /* Custom section collapsible styling */ #${MODAL_ID} .custom-section-summary { position: relative; list-style: none; } #${MODAL_ID} .custom-section-summary::-webkit-details-marker { display: none; } #${MODAL_ID} .custom-section-summary::marker { display: none; } #${MODAL_ID} details[open] .custom-section-summary .material-icons { transform: rotate(90deg); } #${MODAL_ID} .custom-section-summary .material-icons { transition: transform 0.2s ease; } /* Recently released subsection styling */ #${MODAL_ID} .recently-released-subsection-summary { position: relative; list-style: none; } #${MODAL_ID} .recently-released-subsection-summary::-webkit-details-marker { display: none; } #${MODAL_ID} .recently-released-subsection-summary::marker { display: none; } #${MODAL_ID} details[open] .recently-released-subsection-summary .material-icons { transform: rotate(90deg); } #${MODAL_ID} .recently-released-subsection-summary .material-icons { transition: transform 0.2s ease; } /* Seasonal season collapsible styling */ #${MODAL_ID} .seasonal-season-summary { position: relative; list-style: none; } #${MODAL_ID} .seasonal-season-summary::-webkit-details-marker { display: none; } #${MODAL_ID} .seasonal-season-summary::marker { display: none; } #${MODAL_ID} details[open] .seasonal-season-summary .material-icons { transform: rotate(90deg); } #${MODAL_ID} .seasonal-season-summary .material-icons { transition: transform 0.2s ease; } /* Seasonal section collapsible styling (sections within seasons) */ #${MODAL_ID} .seasonal-section-summary { position: relative; list-style: none; } #${MODAL_ID} .seasonal-section-summary::-webkit-details-marker { display: none; } #${MODAL_ID} .seasonal-section-summary::marker { display: none; } #${MODAL_ID} details[open] .seasonal-section-summary .material-icons { transform: rotate(90deg); } #${MODAL_ID} .seasonal-section-summary .material-icons { transition: transform 0.2s ease; } /* Discovery section type collapsible styling */ #${MODAL_ID} .discovery-section-type-summary { position: relative; list-style: none; } #${MODAL_ID} .discovery-section-type-summary::-webkit-details-marker { display: none; } #${MODAL_ID} .discovery-section-type-summary::marker { display: none; } #${MODAL_ID} details[open] .discovery-section-type-summary .material-icons { transform: rotate(90deg); } #${MODAL_ID} .discovery-section-type-summary .material-icons { transition: transform 0.2s ease; } /* Skins JSON collapsible styling */ #${MODAL_ID} details.listItem[open] summary .material-icons { transform: rotate(90deg); } #${MODAL_ID} details.listItem summary .material-icons { transition: transform 0.2s ease; } /* Responsive design */ @media (max-width: 768px) { #${MODAL_ID} .modal-content-wrapper { width: 95vw; max-height: 95vh; } #${MODAL_ID} .content-primary { padding: 1em; } #${MODAL_ID} .paperList { padding: 0.75em; } #${MODAL_ID} [style*="grid-template-columns"] { grid-template-columns: 1fr !important; } } @media (max-width: 480px) { #${MODAL_ID} .content-primary { padding: 0.75em; } #${MODAL_ID} .pageTitle { font-size: 1.5em; } #${MODAL_ID} input.fld, #${MODAL_ID} textarea.fld { font-size: 16px; /* Prevents zoom on iOS */ } } `; document.head.appendChild(style); } // Build the configuration page content function buildConfigPageContent(config, libraries = []) { const scripts = config.scripts || {}; const isEnabled = window.KefinTweaksConfig?.enabled !== false; const versionLabelFn = window.KefinTweaksInstaller?.getInstalledVersionLabel || window.getKefinTweaksInstalledVersionLabel; const installedVersion = (typeof versionLabelFn === 'function' ? versionLabelFn(window.KefinTweaksConfig) : '') || 'Unknown'; return `
${installedVersion.replace(/

Feature Configuration

Toggle individual KefinTweaks features on or off. Existing configurations are maintained when disabling features.
${buildScriptToggles(scripts)}
`; } // Extract GitHub repository URL from skin configuration function getSkinGitHubUrl(skin) { // Check if skin has explicit github field if (skin.github) { return skin.github; } // Try to extract from jsdelivr GitHub CDN URLs 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) { // Match pattern: https://cdn.jsdelivr.net/gh/{owner}/{repo}@... 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 /** * Build dropdown options for skins that have optional includes * @param {Array} skins - Array of skin configurations * @returns {string} HTML string for dropdown options */ function buildOptionalIncludesSkinOptions(skins) { const skinsWithOptionalIncludes = skins.filter(skin => skin.optionalIncludes && skin.optionalIncludes.length > 0 ); return skinsWithOptionalIncludes.map(skin => `` ).join(''); } /** * Extract author from URL (for display purposes) * @param {string} url - The URL * @returns {string} Author name or empty string */ function extractAuthorFromUrl(url) { if (!url) return ''; try { // Try to extract GitHub username from jsdelivr URLs const jsdelivrMatch = url.match(/cdn\.jsdelivr\.net\/gh\/([^\/]+)\//); if (jsdelivrMatch) { return jsdelivrMatch[1]; } } catch (e) { // Ignore errors } return ''; } /** * Generate optional include key (same format as skinManager) * @param {string} skinName - The skin name or "global" * @param {string} author - The author name * @param {string} url - The URL * @returns {string} The generated 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 * @param {string} url - The URL * @returns {string} The filename */ 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 the optional includes editor UI * @param {string} category - "global" or skin name * @param {Object} config - The KefinTweaksConfig object * @param {Array} skins - Array of all skins * @returns {string} HTML string for the editor */ function buildOptionalIncludesEditor(category, config, skins) { let optionalIncludes = []; if (category === 'global') { // Get global optional includes from defaults + admin config const defaultGlobalIncludes = window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || []; optionalIncludes = defaultGlobalIncludes.map(include => ({ ...include })); // Merge with admin config (admin can override enabled state) const adminOptionalIncludes = config.optionalIncludes || []; adminOptionalIncludes.forEach(adminEntry => { if (typeof adminEntry === 'object' && adminEntry.key) { // Extract info from key: global-author-filename const keyParts = adminEntry.key.split('-'); if (keyParts[0] === 'global' && keyParts.length >= 3) { const author = keyParts[1]; const filename = keyParts.slice(2).join('-'); // Find matching include by URL 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 { // Admin added a new include (we'd need URL, but for now just show it) optionalIncludes.push({ name: filename.replace('.css', '').replace(/-/g, ' '), url: '', // We don't have the URL stored, would need to enhance enabled: adminEntry.enabled, key: adminEntry.key }); } } } }); } else { // Get skin-specific optional includes 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); // Check admin config for enabled state 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('')}
`; } function buildSkinToggles(skins, skinSources = {}) { return skins.map(skin => { const isEnabled = skin.enabled !== false; // Default to true if not set const githubUrl = getSkinGitHubUrl(skin); const source = skinSources[skin.name] || 'default'; // Determine styling based on source 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);'; } else if (source === 'custom') { // Custom skins use default styling } // Determine tooltip text 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 script toggle switches function buildScriptToggles(scripts) { // Features that have configuration options (not just toggles) const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', 'watchlist', 'userManager']); const scriptNames = [ { key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' }, { key: 'homeScreen', label: 'Enhanced Home Screen', desc: 'Add custom home screen sections and a "discovery engine" to your Home Page. Create sections based on Genre, Tags, Playlists, Collections or Search Terms for specific Item Types. Use the "Spotlight" feature to highlight specific sections with an image carousel/slideshow.' }, { key: 'search', label: 'Enhanced Search', desc: 'Search speed is improved by searching less content types by default. Provides options to specify which content type to search. Fully compatible with both the Jellysearch Search from Jellyfin Enhanced plugin and the Meilisearch plugin.' }, { key: 'infiniteScroll', label: 'Infinite Scroll', desc: 'Adds infinite scrolling to the library pages for Movies and TV' }, { key: 'removeContinue', label: 'Remove Continue', desc: 'Adds the ability to remove items from the Continue Watching sections' }, { key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' }, { key: 'headerTabs', label: 'Header Tabs', desc: 'Allows direct navigation to specific Tab sections of a given library page. Full support for default Jellyfin landing pages.' }, { key: 'customMenuLinks', label: 'Custom Menu Links', desc: 'Add custom menu links to the left side navigation menu' }, { key: 'hamburgerMenu', label: 'Desktop Hamburger Menu', desc: 'Shows an Open Menu button on desktop (900px+) that opens a left navigation drawer like the mobile layout, including libraries and custom menu links.' }, { key: 'games', label: 'Games', desc: 'Add a Games link to the side menu for Movie LUT and other library games' }, { key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' }, { key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' }, { key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' }, { key: 'flattenSingleSeasonShows', label: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' }, { key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' }, { key: 'versionPreferences', label: 'Item Version Preferences', desc: 'Manage how different versions and editions of your items are displayed. Reorders and sanitizes the media source dropdown on item details pages by prioritized version and edition terms.' }, { key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' }, { key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' }, { key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' }, { key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' }, { key: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' }, { key: 'watchTogether', label: 'Watch Together', desc: 'Link additional Jellyfin accounts to a Watch Together group, then sync watched / in-progress playstate to selected accounts on this device.' }, { key: 'userManager', label: 'User Manager', desc: 'Create reusable user policy templates and apply them when adding users or bulk-editing existing accounts. Import/export comes later.' } ]; const defsByName = new Map( (window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def]) ); // Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal const major = window.KefinTweaks?._jellyfinMajorVersion ?? null; const visibleScripts = scriptNames.filter((script) => { const def = defsByName.get(script.key); if (!def?.versions?.length) return true; if (major == null) return false; return def.versions.includes(major); }); return visibleScripts.map(script => { // Match injector defaults: most scripts on unless set false; opt-in scripts require true const defaultDisabled = script.key === 'thumbnailScrubber' || script.key === 'watchTogether' || script.key === 'userManager'; const isEnabled = defaultDisabled ? scripts[script.key] === true : scripts[script.key] !== false; const hasConfig = featuresWithConfig.has(script.key); const configKey = script.key === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : script.key; return `
${script.label}
${hasConfig ? `` : ''} ${buildJellyfinCheckbox(`script_${script.key}`, isEnabled, '', { 'data-script-key': script.key })}
${script.desc}
`; }).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 const allAvailableTypes = [ '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' ]; const selectionLabel = currentTypes.length ? `${currentTypes.length} selected` : 'Select item types'; // Current types as badges const badgesHtml = currentTypes.map(typeName => ` ${typeName} `).join(''); const dropdownOptions = allAvailableTypes.map(typeName => { const isChecked = currentTypes.includes(typeName); return ` `; }).join(''); return `
Include Item Types
No item types selected ${badgesHtml}
`; } // Helper function to build Additional Options Controls (Dropdown + Add Button) function buildAdditionalOptionsControls(prefix, sectionIndex, sanitizedPrefix) { // Build dropdown options from supported params const dropdownOptions = Object.entries(SUPPORTED_CUSTOM_SECTION_PARAMS) //.sort((a, b) => a[1].label.localeCompare(b[1].label)) .map(([key, meta]) => ``) .join(''); return `
Additional Options
`; } // Helper function to build Additional Options List function buildAdditionalOptionsList(prefix, sectionIndex, sanitizedPrefix, currentOptions) { const optionsHtml = (currentOptions || []).map((option, index) => { const meta = SUPPORTED_CUSTOM_SECTION_PARAMS[option.key] || { label: option.key, type: 'string' }; const inputId = `${sanitizedPrefix}_additionalOption_${index}`; let inputField = ''; if (meta.type === 'boolean') { const isChecked = option.value === true || option.value === 'true'; inputField = buildJellyfinCheckbox(inputId, isChecked, meta.label, { 'data-key': option.key, 'class': 'additional-option-value', 'data-type': 'boolean' }); } else { inputField = `
${meta.label}
`; } return `
${inputField}
`; }).join(''); return `
${optionsHtml}
`; } // Helper function to build Jellyfin-style 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 `
`; } // Helper function to build a section UI (for nested sections in seasonal and custom sections) function buildSectionUI(prefix, section, sectionIndex, isSeasonalNested = false) { const sectionId = section.id || `${prefix}_section_${sectionIndex}`; const enabled = section.enabled !== false; const name = section.name || ''; const type = section.type || 'Genre'; const source = section.source || ''; const itemLimit = section.itemLimit || 16; const sortOrder = section.sortOrder || 'Random'; const sortOrderDirection = section.sortOrderDirection || 'Ascending'; const cardFormat = section.cardFormat || 'Poster'; const order = section.order || 100; const spotlightEnabled = section.spotlight === true; const renderMode = section.renderMode || (spotlightEnabled ? 'Spotlight' : 'Normal'); const discoveryEnabled = section.discoveryEnabled === true; const searchTerm = section.searchTerm || ''; const includeItemTypes = Array.isArray(section.includeItemTypes) ? section.includeItemTypes : (section.includeItemTypes ? section.includeItemTypes.split(',').map(s => s.trim()).filter(Boolean) : (type === 'Parent' ? [] : ['Movie'])); const additionalQueryOptions = section.additionalQueryOptions || []; // Determine source field based on type let sourceFieldHtml = ''; if (type === 'Parent') { // Parent type uses simple text input for parent IDs (comma-separated) const parentIds = source || ''; sourceFieldHtml = `
Parent IDs (comma-separated, leave empty for "Any")
`; } else if (type === 'Tag' || type === 'Genre' || type === 'Collection' || type === 'Playlist') { // Parse existing source (comma-separated) into array const sourceArray = source ? source.split(',').map(s => s.trim()).filter(Boolean) : []; // For Collections/Playlists, we'll show IDs initially and load names after // For Tags/Genres, show names directly let badgesHtml = ''; if (type === 'Tag' || type === 'Genre') { badgesHtml = sourceArray.map(val => ` ${val} `).join(''); } else { // Collections/Playlists: display IDs initially, will be replaced with names after fetch badgesHtml = sourceArray.map(id => ` ${id} `).join(''); } sourceFieldHtml = `
${type}
${badgesHtml}
`; } // Build section fields HTML for right column const sanitizedSectionPrefix = `${prefix}_section_${sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_')}`; const spotlightCheckboxId = `${sanitizedSectionPrefix}_spotlight`; const discoveryCheckboxId = `${sanitizedSectionPrefix}_discovery`; const sectionFieldsHtml = createSectionConfiguration( sanitizedSectionPrefix, section, { includeName: true, defaultName: name || 'Unnamed Section', nameInputAttributes: { class: `${prefix}_section_name`, 'data-section-index': sectionIndex, 'data-prefix': prefix } } ); // For custom sections and seasonal nested sections, use the improved layout const useImprovedLayout = prefix === 'customSection' || isSeasonalNested; // Build section content - improved layout for custom sections and seasonal nested sections let sectionContent; if (useImprovedLayout) { // Custom sections: Row 1 (checkboxes + type), Row 2 (source), Row 3+ (remaining fields in 3 columns) sectionContent = `
Enabled
${buildJellyfinCheckbox(`${prefix}_section_enabled_${sectionIndex}`, enabled, '', { 'data-section-index': sectionIndex, 'data-prefix': prefix, 'class': `${prefix}_section_enabled` })}
Render Mode
Discovery
${buildJellyfinCheckbox(discoveryCheckboxId, discoveryEnabled, 'Enable as Discovery', { 'data-section-index': sectionIndex, 'data-prefix': prefix, 'class': `${prefix}_section_discovery` })}
Type
${sourceFieldHtml}
${sectionFieldsHtml}
Search Term
${buildIncludeItemTypesField(prefix, sectionIndex, sanitizedSectionPrefix, includeItemTypes, type)} ${buildAdditionalOptionsControls(prefix, sectionIndex, sanitizedSectionPrefix)}
${buildAdditionalOptionsList(prefix, sectionIndex, sanitizedSectionPrefix, additionalQueryOptions)} `; } // Wrap sections in collapsible details for both custom sections and seasonal nested sections if (useImprovedLayout) { // Determine summary class and name class based on prefix const summaryClass = prefix === 'customSection' ? 'custom-section-summary' : 'seasonal-section-summary'; const nameClass = prefix === 'customSection' ? 'custom-section-name' : 'seasonal-section-name'; const enabledClass = prefix === 'customSection' ? 'custom-section-enabled-display' : 'seasonal-section-enabled-display'; const orderClass = prefix === 'customSection' ? 'custom-section-order' : 'seasonal-section-order'; const showPreviewButton = prefix === 'customSection' || prefix === 'seasonal_season'; const previewButtonHtml = showPreviewButton ? `` : ''; return `
chevron_right ${name || 'Unnamed Section'} (${enabled ? 'Enabled' : 'Disabled'}, Order: ${order})
${previewButtonHtml}
${sectionContent}
`; } else { // Fallback for any other section types (shouldn't happen in current code) const showPreviewButton = prefix === 'customSection' || prefix === 'seasonal_season'; const previewButtonHtml = showPreviewButton ? `` : ''; return `
${name || 'Unnamed Section'}
${previewButtonHtml}
${sectionContent}
`; } } function mergeAttributes(defaultAttrs = {}, customAttrs = {}) { const merged = { ...defaultAttrs }; if (customAttrs && typeof customAttrs === 'object') { Object.entries(customAttrs).forEach(([key, value]) => { if (value === undefined || value === null) { return; } if (key === 'class' && merged.class) { merged.class = `${merged.class} ${value}`.trim(); } else { merged[key] = value; } }); } return merged; } function attributesToString(attrs = {}) { return Object.entries(attrs) .map(([key, value]) => { if (value === undefined || value === null) return ''; return `${key}="${value}"`; }) .filter(Boolean) .join(' '); } function parseNumberOrFallback(value, fallback) { if (value === null || value === undefined || value === '') { return fallback; } const parsed = parseInt(value, 10); return Number.isFinite(parsed) ? parsed : fallback; } function normalizeDiscoverySectionConfigEntry(rawValue, definition, discovery, legacyName = '') { const defaultItemLimit = discovery.defaultItemLimit ?? 16; const defaultSortOrder = discovery.defaultSortOrder ?? 'Random'; const defaultCardFormat = discovery.defaultCardFormat ?? 'Poster'; const defaults = { enabled: true, name: legacyName || definition.defaultName || '', itemLimit: defaultItemLimit, sortOrder: defaultSortOrder, sortOrderDirection: 'Ascending', cardFormat: defaultCardFormat }; if (definition.extras?.minimumItems !== undefined) { defaults.minimumItems = definition.extras.minimumItems; } if (definition.extras?.caption !== undefined) { defaults.caption = definition.extras.caption; } let normalized = { ...defaults }; if (rawValue === false) { normalized.enabled = false; } else if (rawValue && typeof rawValue === 'object') { normalized = { ...normalized, ...rawValue }; } else if (rawValue === true) { normalized.enabled = true; } normalized.itemLimit = parseNumberOrFallback(normalized.itemLimit, defaultItemLimit); normalized.sortOrder = normalized.sortOrder || defaultSortOrder; normalized.sortOrderDirection = normalized.sortOrderDirection || 'Ascending'; normalized.cardFormat = normalized.cardFormat || defaultCardFormat; normalized.name = normalized.name || definition.defaultName || ''; if (definition.extras?.minimumItems !== undefined) { normalized.minimumItems = parseNumberOrFallback( normalized.minimumItems, definition.extras.minimumItems ); } return normalized; } function getNormalizedDiscoverySectionConfigs(discovery = {}) { const sectionTypes = discovery.sectionTypes || {}; const legacyNames = discovery.sectionNames || {}; const normalized = {}; DISCOVERY_SECTION_DEFINITIONS.forEach(def => { normalized[def.key] = normalizeDiscoverySectionConfigEntry( sectionTypes[def.key], def, discovery, legacyNames[def.key] ); }); return normalized; } // Helper function to build section configuration fields (Name, Item Limit, Sort Order, Card Format, Order) function createSectionConfiguration(prefix, config = {}, options = {}) { const { includeSortOrder = true, includeOrder = true, includeName = true, defaultName = '', nameLabel = 'Section Name', nameInputAttributes = {}, includeCardFormat = true, includeItemLimit = true, includeIsPlayed = false, includePremiereDays = false } = options; const nameValue = config.name ?? defaultName ?? ''; const itemLimit = config.itemLimit ?? options.defaultItemLimit ?? 16; const sortOrder = config.sortOrder ?? 'Random'; const sortOrderDirection = config.sortOrderDirection ?? 'Ascending'; const cardFormat = config.cardFormat ?? options.defaultCardFormat ?? 'Poster'; const order = config.order ?? options.defaultOrder ?? 100; // IsPlayed logic // If isPlayed is undefined or null, it means the filter is disabled. // If it's true/false, the filter is enabled and set to that value. const isPlayedFilterEnabled = config.isPlayed !== undefined && config.isPlayed !== null; const isPlayedValue = config.isPlayed === true; // true if played, false if unplayed let html = ''; if (includeName) { const nameAttrs = mergeAttributes( { id: `${prefix}_name`, class: 'fld emby-input' }, nameInputAttributes ); html += `
${nameLabel}
`; } if (includeCardFormat) { html += `
Card Format
`; } if (includeSortOrder) { html += `
Sort Order
Sort Order Direction
`; } if (includeOrder) { html += `
Order
`; } if (includeItemLimit) { html += `
Item Limit
`; } if (includePremiereDays) { const minAgeInDays = config.minAgeInDays ?? 0; const maxAgeInDays = config.maxAgeInDays ?? 30; html += `
Minimum Age (Days)
Optional. Days since release (e.g. 0 for today).
Maximum Age (Days)
Optional. Max days old (e.g. 30).
`; } if (includeIsPlayed) { html += `
${buildJellyfinCheckbox(`${prefix}_isPlayed_enabled`, isPlayedFilterEnabled, 'Filter by Played Status')}
`; } return html; } // Build home screen configuration function buildHomeScreenConfig(homeScreen, libraries = []) { const recentlyReleased = homeScreen.recentlyReleased || {}; const recentlyAddedInLibrary = homeScreen.recentlyAddedInLibrary || {}; const trending = homeScreen.trending || {}; const popularTVNetworks = homeScreen.popularTVNetworks || {}; const watchlist = homeScreen.watchlist || {}; const upcoming = homeScreen.upcoming || {}; const imdbTop250 = homeScreen.imdbTop250 || {}; const seasonal = homeScreen.seasonal || {}; const watchAgain = homeScreen.watchAgain || {}; const discovery = homeScreen.discovery || {}; const customSections = homeScreen.customSections || []; const normalizedDiscoverySections = getNormalizedDiscoverySectionConfigs(discovery); // Build seasonal sections HTML const seasonalSectionsHtml = (seasonal.seasons || []).map((season, seasonIndex) => { const sections = season.sections || []; const sectionsHtml = sections.map((section, sectionIndex) => { return buildSectionUI('seasonal_season', section, `${seasonIndex}_${sectionIndex}`, true); }).join(''); const seasonOrder = season.order || 100; return `
chevron_right ${season.name || 'Unnamed Season'} (${season.enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${seasonOrder})
Enabled
${buildJellyfinCheckbox(`seasonal-season-enabled-${seasonIndex}`, season.enabled !== false, 'Enabled', { 'data-index': seasonIndex, 'class': 'seasonal-season-enabled' })}
Name
Start Date (MM-DD)
End Date (MM-DD)
Order
Sections
${sectionsHtml}
`; }).join(''); // Build library sections HTML for "Recently Added in Library" let recentlyAddedInLibraryHtml = ''; if (libraries && libraries.length > 0) { recentlyAddedInLibraryHtml = libraries.map(library => { const libConfig = recentlyAddedInLibrary[library.Id] || {}; const prefix = `homeScreen_recentlyAddedInLibrary_${library.Id}`; const defaultName = `Recently Added ${library.Name}`; return `
chevron_right ${library.Name} (${libConfig.enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${libConfig.order || 11})
${buildJellyfinCheckbox(`${prefix}_enabled`, libConfig.enabled !== false, 'Enabled', { 'data-library-id': library.Id, 'class': 'recently-added-library-enabled' })}
${createSectionConfiguration(prefix, libConfig, { includeName: true, defaultName: defaultName, includeSortOrder: false, includeOrder: true, defaultOrder: 11, includeItemLimit: true, defaultItemLimit: 30, defaultCardFormat: library.CollectionType === 'tvshows' ? 'Thumb' : 'Poster' })}
`; }).join(''); } // Check for orphaned sections (configured but library no longer exists) const activeLibraryIds = libraries.map(l => l.Id); const configuredLibraryIds = Object.keys(recentlyAddedInLibrary); const orphanedLibraryIds = configuredLibraryIds.filter(id => !activeLibraryIds.includes(id)); if (orphanedLibraryIds.length > 0) { recentlyAddedInLibraryHtml += orphanedLibraryIds.map(id => { const libConfig = recentlyAddedInLibrary[id]; const prefix = `homeScreen_recentlyAddedInLibrary_${id}`; const name = libConfig.name || `Unknown Library (${id})`; return `
warning ${name} (Library Missing)
This library is no longer available. You should remove this section.
${buildJellyfinCheckbox(`${prefix}_enabled`, false, 'Enabled', { 'data-library-id': id, 'class': 'recently-added-library-enabled', 'disabled': 'true' })}
`; }).join(''); } return ` ${(libraries && libraries.length > 0) || orphanedLibraryIds.length > 0 ? `
chevron_right Recently Added in Library Sections
${recentlyAddedInLibraryHtml}
` : ''}
${buildJellyfinCheckbox('homeScreen_recentlyReleased_enabled', recentlyReleased.enabled !== false, 'Recently Released Sections')}
chevron_right Recently Released Movies (${(recentlyReleased.movies || {}).enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${(recentlyReleased.movies || {}).order || 30})
${buildJellyfinCheckbox('homeScreen_recentlyReleased_movies_enabled', (recentlyReleased.movies || {}).enabled !== false, 'Enabled')}
${createSectionConfiguration('homeScreen_recentlyReleased_movies', recentlyReleased.movies || {}, { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true })}
chevron_right Recently Aired Episodes (${(recentlyReleased.episodes || {}).enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${(recentlyReleased.episodes || {}).order || 31})
${buildJellyfinCheckbox('homeScreen_recentlyReleased_episodes_enabled', (recentlyReleased.episodes || {}).enabled !== false, 'Enabled')}
${createSectionConfiguration('homeScreen_recentlyReleased_episodes', recentlyReleased.episodes || {}, { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true })}
${buildJellyfinCheckbox('homeScreen_trending_enabled', trending.enabled === true, 'Trending Sections', { disabled: 'true' })} (Order: ${trending.order || 100}) Under development
${buildJellyfinCheckbox('homeScreen_popularTVNetworks_enabled', popularTVNetworks.enabled === true, 'Popular TV Networks Sections')} (Order: ${popularTVNetworks.order || 100})
${createSectionConfiguration('homeScreen_popularTVNetworks', popularTVNetworks, { includeName: true, defaultName: 'Popular TV Networks' })}
Minimum Shows for Network to Appear
${buildJellyfinCheckbox('homeScreen_watchlist_enabled', watchlist.enabled === true, 'Watchlist')} (Order: ${watchlist.order || 100})
${createSectionConfiguration('homeScreen_watchlist', watchlist, { includeName: true, defaultName: 'Watchlist' })}
${buildJellyfinCheckbox('homeScreen_upcoming_enabled', upcoming.enabled !== false, 'Upcoming')} (Order: ${upcoming.order || 100})
${createSectionConfiguration('homeScreen_upcoming', upcoming, { includeName: true, defaultName: 'Upcoming', includeSortOrder: false })}
${buildJellyfinCheckbox('homeScreen_imdbTop250_enabled', imdbTop250.enabled !== false, 'IMDb Top 250')} (Order: ${imdbTop250.order || 100})
${createSectionConfiguration('homeScreen_imdbTop250', imdbTop250, { includeName: true, defaultName: 'IMDb Top 250', includeIsPlayed: true })}
${buildJellyfinCheckbox('homeScreen_seasonal_enabled', seasonal.enabled !== false, 'Seasonal Sections')}
Enable Seasonal Animations
Enable falling snow/leaves animations for seasonal themes
${buildJellyfinCheckbox('homeScreen_seasonal_enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Enabled')}
Enable Christmas Background
Enable the festive background image for the Christmas theme
${buildJellyfinCheckbox('homeScreen_seasonal_enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Enabled')}
${seasonalSectionsHtml}
${buildJellyfinCheckbox('homeScreen_watchAgain_enabled', watchAgain.enabled === true, 'Watch Again')} (Order: ${watchAgain.order || 100})
${createSectionConfiguration('homeScreen_watchAgain', watchAgain, { includeName: true, defaultName: 'Watch Again' })}
${buildJellyfinCheckbox('homeScreen_discovery_enabled', discovery.enabled !== false, 'Discovery Sections')}
Infinite Scroll / Discover More
${buildJellyfinCheckbox('homeScreen_discovery_infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll')}
Randomize Section Order
${buildJellyfinCheckbox('homeScreen_discovery_randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order')}
Minimum Appearances for Top People
Minimum Movie count for Genres
Spotlight & Collection Spawn Chance
0.0 = never, 1.0 = always
${buildJellyfinCheckbox('homeScreen_discovery_renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Spotlight Grouping')}
Render Spotlight discovery sections alongside the corresponding discovery section (e.g. Action Spotlight above Action Movies).
${createSectionConfiguration('homeScreen_discovery', discovery, { includeName: false, includeOrder: false, includeCardFormat: false, includeSortOrder: false, includeItemLimit: false })}
Discovery Sections
Section names support placeholders such as [Genre], [Director], [Writer], [Actor], [Movie], [Studio], [Collection Name]
${DISCOVERY_SECTION_DEFINITIONS.map(section => { const sectionConfig = normalizedDiscoverySections[section.key]; const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; const configHtml = createSectionConfiguration(prefix, sectionConfig, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName, includeIsPlayed: true }); const extraFields = section.extras?.minimumItems !== undefined ? `
Minimum Items
` : ''; const discoveryOrder = sectionConfig.order || 100; return `
chevron_right ${section.label} (${sectionConfig.enabled !== false ? 'Enabled' : 'Disabled'}, Order: ${discoveryOrder})
${buildJellyfinCheckbox(`${prefix}_enabled`, sectionConfig.enabled !== false, 'Enabled')}
${configHtml} ${extraFields}
`; }).join('')}
Custom Sections
Sections
${(customSections || []).map((section, index) => { return buildSectionUI('customSection', section, index, false); }).join('')}
import_export Import / Export Custom Sections
Share your custom home screen sections or import configurations from others.
`; } // Build exclusive elsewhere configuration function buildExclusiveElsewhereConfig(exclusiveElsewhere) { return `
Hide Server Name
Set to true to hide server name, useful when you want to only show a logo or icon via CSS
${buildJellyfinCheckbox('exclusiveElsewhere_hideServerName', exclusiveElsewhere.hideServerName === true, 'Enabled')}
`; } // Build search configuration function buildSearchConfig(search) { return `
Enable Seerr results from JellyfinEnhanced
If you are using Seerr from Jellyfin Enhanced, you can enable this to add an additional search toggle for Requests
${buildJellyfinCheckbox('search_enableJellyseerr', search.enableJellyseerr === true, 'Enabled')}
`; } // Build flatten shows configuration function buildFlattenShowsConfig(flattenShows) { const hideSingleSeasonContainer = flattenShows?.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.
${buildJellyfinCheckbox('flattenSingleSeasonShows_hideSingleSeasonContainer', hideSingleSeasonContainer, 'Enabled')}
`; } // Open configuration modal // Fetch libraries (Movies and TV) async function fetchLibraries() { if (!window.ApiClient) return []; try { // Get user views (libraries) const result = await window.ApiClient.getUserViews({}, window.ApiClient.getCurrentUserId()); if (result && result.Items) { // Filter for movies and tvshows return result.Items.filter(item => item.CollectionType === 'movies' || item.CollectionType === 'tvshows' ).map(item => ({ Id: item.Id, Name: item.Name, CollectionType: item.CollectionType })); } return []; } catch (error) { console.error('[KefinTweaks Configuration] Error fetching libraries:', error); return []; } } async function openConfigurationModal() { console.log('[KefinTweaks Configuration] Opening configuration modal...'); // Inject CSS if not already injected injectConfigModalCSS(); // Load configuration first let config; try { config = await getKefinTweaksConfig(); } catch (error) { console.error('[KefinTweaks Configuration] Error loading config:', error); alert('Error loading configuration. Please try again.'); return; } currentLoadedConfig = config; window.KefinTweaksCurrentConfig = config; // Ensure Jellyfin major is cached so version-gated feature cards filter correctly try { if (typeof window.KefinTweaks?.getJellyfinMajorVersion === 'function') { await window.KefinTweaks.getJellyfinMajorVersion(); } } catch (e) { console.warn('[KefinTweaks Configuration] Could not resolve Jellyfin major version:', e); } // Build the content (without title, as ModalSystem adds it) const content = document.createElement('div'); content.className = 'modal-content-wrapper'; content.id = MODAL_ID; content.innerHTML = `
${buildConfigPageContent(config)}
`; // Create footer with save (reset actions live in Manage) const footer = document.createElement('div'); footer.style.display = 'flex'; footer.style.gap = '0.75em'; footer.style.alignItems = 'center'; footer.innerHTML = ` `; // Create modal using ModalSystem if (!window.ModalSystem) { console.error('[KefinTweaks Configuration] ModalSystem not available'); alert('Modal system not available. Please ensure modal.js is loaded.'); return; } const modal = window.ModalSystem.create({ id: MODAL_ID, title: 'KefinTweaks Configuration', content: content, footer: footer, closeOnBackdrop: true, closeOnEscape: true, dialogStyle: window.innerWidth >= 900 ? { maxWidth: '90vw', width: '1400px', maxHeight: '90vh' } : undefined, onOpen: (modalInstance) => { // Add event handlers setupConfigModalHandlers(modalInstance, config); } }); } /** * Attach event handlers for optional includes checkboxes * @param {Object} modalInstance - The modal instance * @param {string} category - "global" or skin name * @param {Object} config - The current config */ function attachOptionalIncludesCheckboxHandlers(modalInstance, category, config) { const checkboxes = modalInstance.dialogContent.querySelectorAll(`.optionalIncludeCheckbox[data-category="${category}"]`); checkboxes.forEach(checkbox => { // Remove existing listeners (if any) and add new one const newCheckbox = checkbox.cloneNode(true); checkbox.parentNode.replaceChild(newCheckbox, checkbox); newCheckbox.addEventListener('change', () => { // Update will be saved when user clicks Save button // No need to save immediately }); }); } // Set up event handlers for configuration modal function setupConfigModalHandlers(modalInstance, config) { // Enabled checkbox handler const enabledCheckbox = modalInstance.dialogContent.querySelector('#kefinTweaksEnabled'); const enabledLabel = modalInstance.dialogContent.querySelector('#kefinTweaksEnabledLabel'); if (enabledCheckbox) { // Function to update label text const updateLabel = () => { if (enabledLabel) { enabledLabel.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; } }; enabledCheckbox.addEventListener('change', async (e) => { const isEnabled = e.target.checked; // Update label immediately for better UX updateLabel(); try { // Update the config's enabled field config.enabled = isEnabled; // Save the updated config to the injector await KefinTweaksUtils.saveConfigToJavaScriptInjector(config); window.KefinTweaksConfigEnabled = isEnabled; console.log('[KefinTweaks Configuration] KefinTweaks', isEnabled ? 'enabled' : 'disabled'); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(`KefinTweaks ${isEnabled ? 'enabled' : 'disabled'}. Page refresh required.`); } } catch (error) { console.error('[KefinTweaks Configuration] Error toggling KefinTweaks:', error); // Revert checkbox on error enabledCheckbox.checked = !isEnabled; updateLabel(); alert(`Failed to ${isEnabled ? 'enable' : 'disable'} KefinTweaks: ${error.message}`); } }); } // Change KefinTweaks Source button handler const changeSourceBtn = modalInstance.dialogContent.querySelector('#changeKefinTweaksSourceBtn'); if (changeSourceBtn) { changeSourceBtn.addEventListener('click', async () => { try { // Check if the modal function is available if (window.openKefinTweaksSourceModal) { // Open the KefinTweaks source modal window.openKefinTweaksSourceModal(); } else { throw new Error('KefinTweaks source modal is not available. Please ensure kefinTweaks-plugin.js is loaded.'); } } catch (error) { console.error('[KefinTweaks Configuration] Error opening source modal:', error); alert(`Failed to open source configuration: ${error.message}`); } }); } // Version label + Update Available badge (force-opens update toast even if dismissed) const updateBadge = modalInstance.dialogContent.querySelector('#kefinTweaksUpdateAvailableBadge'); if (updateBadge) { updateBadge.addEventListener('click', async () => { const promptFn = window.KefinTweaksInstaller?.showUpdateAvailablePrompt || window.showKefinTweaksUpdateAvailablePrompt; if (typeof promptFn !== 'function') { console.warn('[KefinTweaks Configuration] Update prompt API unavailable'); return; } try { updateBadge.disabled = true; await promptFn(window.KefinTweaksConfig, updateBadge.dataset.latestTag || ''); } catch (error) { console.error('[KefinTweaks Configuration] Failed to open update prompt:', error); } finally { updateBadge.disabled = false; } }); (async () => { const checkFn = window.KefinTweaksInstaller?.isUpdateAvailable || window.isKefinTweaksUpdateAvailable; if (typeof checkFn !== 'function') return; try { const result = await checkFn(window.KefinTweaksConfig); if (!result?.available) return; if (result.latestTag) updateBadge.dataset.latestTag = result.latestTag; updateBadge.hidden = false; updateBadge.classList.add('is-visible'); } catch (error) { console.warn('[KefinTweaks Configuration] Update availability check failed:', error); } })(); } // No validation needed for default skin dropdown - it only allows valid options // Helper function to prevent click propagation on checkboxes (so they don't toggle details) function setupCheckboxPropagation(checkboxId) { const checkbox = modalInstance.dialogContent.querySelector(checkboxId); if (checkbox) { checkbox.addEventListener('click', (e) => { e.stopPropagation(); }); } } // Helper function to toggle container visibility function setupToggleVisibility(checkboxId, containerId) { const checkbox = modalInstance.dialogContent.querySelector(checkboxId); const container = modalInstance.dialogContent.querySelector(containerId); if (checkbox && container) { const toggle = () => { container.style.display = checkbox.checked ? '' : 'none'; }; checkbox.addEventListener('change', toggle); // Prevent details from toggling when clicking checkbox checkbox.addEventListener('click', (e) => { e.stopPropagation(); }); toggle(); // Set initial state } } // Home Screen: Setup propagation prevention only (don't hide content when disabled) setupCheckboxPropagation('#homeScreen_recentlyReleased_enabled'); setupCheckboxPropagation('#homeScreen_trending_enabled'); setupCheckboxPropagation('#homeScreen_popularTVNetworks_enabled'); setupCheckboxPropagation('#homeScreen_watchlist_enabled'); setupCheckboxPropagation('#homeScreen_upcoming_enabled'); setupCheckboxPropagation('#homeScreen_imdbTop250_enabled'); setupCheckboxPropagation('#homeScreen_seasonal_enabled'); setupCheckboxPropagation('#homeScreen_watchAgain_enabled'); setupCheckboxPropagation('#homeScreen_discovery_enabled'); // Discovery Sections IsPlayed Toggles ['recentlyReleased_movies', 'recentlyReleased_episodes', 'trending', 'popularTVNetworks', 'upcoming', 'imdbTop250'].forEach(key => { setupToggleVisibility(`#homeScreen_${key}_isPlayed_enabled`, `#homeScreen_${key}_isPlayed_options`); }); // Dynamic Discovery Sections IsPlayed Toggles DISCOVERY_SECTION_DEFINITIONS.forEach(section => { const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; setupToggleVisibility(`#${prefix}_isPlayed_enabled`, `#${prefix}_isPlayed_options`); }); // Setup event listeners for section management // Add section to seasonal season modalInstance.dialogContent.addEventListener('click', (e) => { const addBtn = e.target.closest('.add-section-to-seasonal-btn'); if (addBtn) { const seasonIndex = parseInt(addBtn.getAttribute('data-season-index')); addSectionToSeasonalSeason(seasonIndex); } }); // Add custom section modalInstance.dialogContent.addEventListener('click', (e) => { const addBtn = e.target.closest('.add-custom-section-btn'); if (addBtn) { addCustomSection(); } }); // Export custom sections modalInstance.dialogContent.addEventListener('click', (e) => { const exportBtn = e.target.closest('.export-custom-sections-btn'); if (exportBtn) { showExportDialog(config); } }); // Import custom sections modalInstance.dialogContent.addEventListener('click', (e) => { const importBtn = e.target.closest('.import-custom-sections-btn'); if (importBtn) { showImportDialog(config, async (newConfig) => { await KefinTweaksUtils.saveConfigToJavaScriptInjector(newConfig); }); } }); // Import community sections modalInstance.dialogContent.addEventListener('click', (e) => { const importBtn = e.target.closest('.import-community-sections-btn'); if (importBtn) { showCommunityImportDialog(config, async (newConfig) => { await KefinTweaksUtils.saveConfigToJavaScriptInjector(newConfig); }); } }); // Delete section modalInstance.dialogContent.addEventListener('click', (e) => { const deleteBtn = e.target.closest('.delete-section-btn'); if (deleteBtn) { e.preventDefault(); e.stopPropagation(); console.log('[KefinTweaks Configuration] Checking for section to delete...'); const prefix = deleteBtn.getAttribute('data-prefix'); const sectionIndexRaw = deleteBtn.getAttribute('data-section-index'); const isSeasonal = deleteBtn.getAttribute('data-is-seasonal') === 'true'; console.log('[KefinTweaks Configuration] Prefix:', prefix); console.log('[KefinTweaks Configuration] Section Index Raw:', sectionIndexRaw); console.log('[KefinTweaks Configuration] Is Seasonal:', isSeasonal); if (prefix && sectionIndexRaw !== null) { console.log('[KefinTweaks Configuration] Deleting section...'); // For seasonal sections, sectionIndex is a compound string like "0_1", don't parse it const sectionIndex = isSeasonal || sectionIndexRaw.includes('_') ? sectionIndexRaw : parseInt(sectionIndexRaw); deleteSection(prefix, sectionIndex, isSeasonal); } } }); // Preview section modalInstance.dialogContent.addEventListener('click', (e) => { const previewBtn = e.target.closest('.preview-section-btn'); if (previewBtn) { e.preventDefault(); e.stopPropagation(); const prefix = previewBtn.getAttribute('data-prefix'); const sectionIndexRaw = previewBtn.getAttribute('data-section-index'); if (prefix && sectionIndexRaw !== null) { // For seasonal sections, sectionIndex is a compound string like "0_1", don't parse it const isSeasonal = prefix === 'seasonal_season' || sectionIndexRaw.includes('_'); const sectionIndex = isSeasonal ? sectionIndexRaw : parseInt(sectionIndexRaw, 10); previewSection(prefix, sectionIndex); } } }); // Type dropdown change modalInstance.dialogContent.addEventListener('change', (e) => { const typeSelect = e.target.closest(`[class*="_section_type"]`); if (typeSelect && typeSelect.hasAttribute('data-prefix')) { const prefix = typeSelect.getAttribute('data-prefix'); const sectionIndex = typeSelect.getAttribute('data-section-index'); const isSeasonal = typeSelect.getAttribute('data-is-seasonal') === 'true'; updateSectionSourceField(prefix, sectionIndex, isSeasonal); } }); // Initialize autocomplete for all badge-based sections setTimeout(async () => { const badgeContainers = modalInstance.dialogContent.querySelectorAll('.tag-badge-container:not(.includeItemTypes-badge-container)'); for (const container of badgeContainers) { const prefix = container.getAttribute('data-prefix'); const sectionIndex = container.getAttribute('data-section-index'); const type = container.getAttribute('data-type'); if (prefix && sectionIndex && type) { await setupBadgeAutocomplete(prefix, sectionIndex, type); if (type === 'Collection' || type === 'Playlist') { await loadBadgeNames(prefix, sectionIndex, type); } } } // Initialize autocomplete for Include Item Types fields const includeItemTypesContainers = modalInstance.dialogContent.querySelectorAll('.includeItemTypes-badge-container'); for (const container of includeItemTypesContainers) { const prefix = container.getAttribute('data-prefix'); const sectionIndex = container.getAttribute('data-section-index'); const availableTypes = container.getAttribute('data-available-types')?.split(',').filter(Boolean) || []; if (prefix && sectionIndex) { setupIncludeItemTypesDropdown(prefix, sectionIndex, availableTypes); } } // Setup summary update listeners for existing custom sections // Setup summary listeners for custom sections const customSectionItems = modalInstance.dialogContent.querySelectorAll('.customSection_section_item'); customSectionItems.forEach(sectionItem => { const sectionIndex = sectionItem.getAttribute('data-section-index'); if (sectionIndex !== null) { setupCustomSectionSummaryListeners(sectionItem, sectionIndex); } }); // Setup summary listeners for seasonal nested sections const seasonalSectionItems = modalInstance.dialogContent.querySelectorAll('.seasonal_season_section_item'); seasonalSectionItems.forEach(sectionItem => { const sectionIndex = sectionItem.getAttribute('data-section-index'); if (sectionIndex !== null) { setupCustomSectionSummaryListeners(sectionItem, sectionIndex); } }); // Setup order update listeners for home screen sections setupHomeSectionOrderListeners(); // Setup order update listeners for recently released subsections setupRecentlyReleasedOrderListeners(); // Setup order update listeners for seasonal seasons setupSeasonalSeasonOrderListeners(); // Setup order update listeners for discovery section types setupDiscoverySectionTypeOrderListeners(); // Setup name update listeners for seasonal seasons setupSeasonalSeasonNameListeners(); }, 100); // Setup event listeners to update home section order displays function setupHomeSectionOrderListeners() { const orderSpans = modalInstance.dialogContent.querySelectorAll('.home-section-order'); orderSpans.forEach(span => { const prefix = span.getAttribute('data-prefix'); if (prefix) { const orderInput = modalInstance.dialogContent.querySelector(`#${prefix}_order`); if (orderInput) { orderInput.addEventListener('input', () => { span.textContent = orderInput.value || '100'; }); } } }); } // Setup event listeners to update recently released subsection order displays function setupRecentlyReleasedOrderListeners() { const moviesOrderSpan = modalInstance.dialogContent.querySelector('.recently-released-movies-order'); const moviesOrderInput = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_movies_order'); if (moviesOrderSpan && moviesOrderInput) { moviesOrderInput.addEventListener('input', () => { moviesOrderSpan.textContent = moviesOrderInput.value || '30'; }); } const episodesOrderSpan = modalInstance.dialogContent.querySelector('.recently-released-episodes-order'); const episodesOrderInput = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_episodes_order'); if (episodesOrderSpan && episodesOrderInput) { episodesOrderInput.addEventListener('input', () => { episodesOrderSpan.textContent = episodesOrderInput.value || '31'; }); } // Setup enabled status listeners const moviesEnabledSpan = modalInstance.dialogContent.querySelector('.recently-released-movies-enabled'); const moviesEnabledCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_movies_enabled'); if (moviesEnabledSpan && moviesEnabledCheckbox) { moviesEnabledCheckbox.addEventListener('change', () => { moviesEnabledSpan.textContent = moviesEnabledCheckbox.checked ? 'Enabled' : 'Disabled'; }); } const episodesEnabledSpan = modalInstance.dialogContent.querySelector('.recently-released-episodes-enabled'); const episodesEnabledCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_recentlyReleased_episodes_enabled'); if (episodesEnabledSpan && episodesEnabledCheckbox) { episodesEnabledCheckbox.addEventListener('change', () => { episodesEnabledSpan.textContent = episodesEnabledCheckbox.checked ? 'Enabled' : 'Disabled'; }); } } // Setup event listeners to update seasonal season order displays function setupSeasonalSeasonOrderListeners() { const orderInputs = modalInstance.dialogContent.querySelectorAll('.seasonal-season-order-input'); orderInputs.forEach(input => { const seasonIndex = input.getAttribute('data-index'); const orderSpan = modalInstance.dialogContent.querySelector(`.seasonal-season-order[data-season-index="${seasonIndex}"]`); if (orderSpan) { input.addEventListener('input', () => { orderSpan.textContent = input.value || '100'; }); } }); // Setup enabled status listeners const enabledCheckboxes = modalInstance.dialogContent.querySelectorAll('.seasonal-season-enabled'); enabledCheckboxes.forEach(checkbox => { const seasonIndex = checkbox.getAttribute('data-index'); const enabledSpan = modalInstance.dialogContent.querySelector(`.seasonal-season-enabled-display[data-season-index="${seasonIndex}"]`); if (enabledSpan) { checkbox.addEventListener('change', () => { enabledSpan.textContent = checkbox.checked ? 'Enabled' : 'Disabled'; }); } }); } // Setup event listeners to update discovery section type order displays function setupDiscoverySectionTypeOrderListeners() { DISCOVERY_SECTION_DEFINITIONS.forEach(section => { const prefix = `homeScreen_discovery_sectionTypes_${section.key}`; const orderInput = modalInstance.dialogContent.querySelector(`#${prefix}_order`); const orderSpan = modalInstance.dialogContent.querySelector(`.discovery-section-type-order[data-section-key="${section.key}"]`); if (orderInput && orderSpan) { orderInput.addEventListener('input', () => { orderSpan.textContent = orderInput.value || '100'; }); } // Setup enabled status listeners const enabledCheckbox = modalInstance.dialogContent.querySelector(`#${prefix}_enabled`); const enabledSpan = modalInstance.dialogContent.querySelector(`.discovery-section-type-enabled[data-section-key="${section.key}"]`); if (enabledCheckbox && enabledSpan) { enabledCheckbox.addEventListener('change', () => { enabledSpan.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; }); } }); } // Setup event listeners to update seasonal season name displays function setupSeasonalSeasonNameListeners() { const nameInputs = modalInstance.dialogContent.querySelectorAll('.seasonal-season-name'); nameInputs.forEach(input => { const seasonIndex = input.getAttribute('data-index'); const nameSpan = modalInstance.dialogContent.querySelector(`.seasonal-season-name-display[data-season-index="${seasonIndex}"]`); if (nameSpan) { input.addEventListener('input', () => { nameSpan.textContent = input.value || 'Unnamed Season'; }); } }); } // Section management functions async function addSectionToSeasonalSeason(seasonIndex) { const sectionsList = modalInstance.dialogContent.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`); if (!sectionsList) return; const sectionIndex = sectionsList.querySelectorAll('.seasonal_season_section_item').length; const fullIndex = `${seasonIndex}_${sectionIndex}`; const newSection = { id: `seasonal-${seasonIndex}-section-${sectionIndex}`, enabled: true, name: 'New Section', type: 'Genre', source: '', itemLimit: 16, sortOrder: 'Random', sortOrderDirection: 'Ascending', cardFormat: 'Poster', order: 100 }; const sectionHtml = buildSectionUI('seasonal_season', newSection, fullIndex, true); sectionsList.insertAdjacentHTML('beforeend', sectionHtml); // Initialize the new section's source field const newSectionItem = sectionsList.querySelector(`.seasonal_season_section_item[data-section-index="${fullIndex}"]`); if (newSectionItem) { // Setup summary listeners for the new section setupCustomSectionSummaryListeners(newSectionItem, fullIndex); const typeSelect = newSectionItem.querySelector('.seasonal_season_section_type'); if (typeSelect) { const type = typeSelect.value; if (type === 'Parent') { // Setup Parent type input sync const parentInput = newSectionItem.querySelector(`.seasonal_season_section_source[data-section-index="${fullIndex}"]`); const parentHidden = newSectionItem.querySelector(`.seasonal_season_section_source_hidden[data-section-index="${fullIndex}"]`); if (parentInput && parentHidden) { parentInput.addEventListener('input', () => { parentHidden.value = parentInput.value; }); } } else { await setupBadgeAutocomplete('seasonal_season', fullIndex, type); if (type === 'Collection' || type === 'Playlist') { await loadBadgeNames('seasonal_season', fullIndex, type); } } } // Initialize Include Item Types autocomplete const includeItemTypesContainer = newSectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${fullIndex}"]`); if (includeItemTypesContainer) { const availableTypes = includeItemTypesContainer.getAttribute('data-available-types')?.split(',').filter(Boolean) || []; setupIncludeItemTypesDropdown('seasonal_season', fullIndex, availableTypes); } } }; async function addCustomSection() { const sectionsList = modalInstance.dialogContent.querySelector('#customSections_list'); if (!sectionsList) return; const sectionIndex = sectionsList.querySelectorAll('.customSection_section_item').length; const newSection = { id: `custom-section-${sectionIndex}`, enabled: true, name: 'New Custom Section', type: 'Collection', source: '', itemLimit: 16, sortOrder: 'Random', sortOrderDirection: 'Ascending', cardFormat: 'Poster', order: 100, spotlight: false, discoveryEnabled: false }; const sectionHtml = buildSectionUI('customSection', newSection, sectionIndex, false); sectionsList.insertAdjacentHTML('beforeend', sectionHtml); // Initialize the new section's source field const newSectionItem = sectionsList.querySelector(`.customSection_section_item[data-section-index="${sectionIndex}"]`); if (newSectionItem) { const typeSelect = newSectionItem.querySelector('.customSection_section_type'); if (typeSelect) { const type = typeSelect.value; if (type === 'Parent') { // Setup Parent type input sync const parentInput = newSectionItem.querySelector(`.customSection_section_source[data-section-index="${sectionIndex}"]`); const parentHidden = newSectionItem.querySelector(`.customSection_section_source_hidden[data-section-index="${sectionIndex}"]`); if (parentInput && parentHidden) { parentInput.addEventListener('input', () => { parentHidden.value = parentInput.value; }); } } else { await setupBadgeAutocomplete('customSection', sectionIndex, type); if (type === 'Collection' || type === 'Playlist') { await loadBadgeNames('customSection', sectionIndex, type); } } } // Setup summary update listeners for custom sections setupCustomSectionSummaryListeners(newSectionItem, sectionIndex); // Initialize Include Item Types autocomplete const includeItemTypesContainer = newSectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`); if (includeItemTypesContainer) { const availableTypes = includeItemTypesContainer.getAttribute('data-available-types')?.split(',').filter(Boolean) || []; setupIncludeItemTypesDropdown('customSection', sectionIndex, availableTypes); } } }; // Setup event listeners to update section summary when name/order changes // Works for both custom sections and seasonal nested sections function setupCustomSectionSummaryListeners(sectionItem, sectionIndex) { const prefix = sectionItem.classList.contains('customSection_section_item') ? 'customSection' : 'seasonal_season'; const sanitizedIndex = sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_'); // Find inputs based on prefix const nameInput = sectionItem.querySelector(`.${prefix}_section_name[data-section-index="${sectionIndex}"]`); const orderInput = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_order`); const enabledCheckbox = sectionItem.querySelector(`#${prefix}_section_enabled_${sanitizedIndex}`) || sectionItem.querySelector(`.${prefix}_section_enabled[data-section-index="${sectionIndex}"]`); // Find summary display spans based on prefix const nameClass = prefix === 'customSection' ? 'custom-section-name' : 'seasonal-section-name'; const orderClass = prefix === 'customSection' ? 'custom-section-order' : 'seasonal-section-order'; const enabledClass = prefix === 'customSection' ? 'custom-section-enabled-display' : 'seasonal-section-enabled-display'; const nameSpan = sectionItem.querySelector(`.${nameClass}[data-section-index="${sectionIndex}"]`); const orderSpan = sectionItem.querySelector(`.${orderClass}[data-section-index="${sectionIndex}"]`); const enabledSpan = sectionItem.querySelector(`.${enabledClass}[data-section-index="${sectionIndex}"]`); if (nameInput && nameSpan) { nameInput.addEventListener('input', () => { nameSpan.textContent = nameInput.value || 'Unnamed Section'; }); } if (orderInput && orderSpan) { orderInput.addEventListener('input', () => { orderSpan.textContent = orderInput.value || '100'; }); } if (enabledCheckbox && enabledSpan) { enabledCheckbox.addEventListener('change', () => { enabledSpan.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; }); } // Setup additional options listeners const additionalOptionsControls = sectionItem.querySelector(`.${prefix}_section_additionalOptions_controls[data-section-index="${sectionIndex}"]`); const additionalOptionsListContainer = sectionItem.querySelector(`.${prefix}_section_additionalOptions_list_container[data-section-index="${sectionIndex}"]`); if (additionalOptionsControls) { const addBtn = additionalOptionsControls.querySelector('.add-additional-option'); const select = additionalOptionsControls.querySelector('.additional-option-select'); const list = additionalOptionsListContainer ? additionalOptionsListContainer.querySelector('.additional-options-list') : null; if (addBtn && select && list) { addBtn.addEventListener('click', () => { const key = select.value; if (!key) return; const meta = SUPPORTED_CUSTOM_SECTION_PARAMS[key]; if (!meta) return; const index = list.children.length; const sanitizedPrefix = `${prefix}_section_${sanitizedIndex}`; const inputId = `${sanitizedPrefix}_additionalOption_${Date.now()}_${index}`; let inputField = ''; if (meta.type === 'boolean') { inputField = buildJellyfinCheckbox(inputId, meta.default === true, meta.label, { 'data-key': key, 'class': 'additional-option-value', 'data-type': 'boolean' }); } 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 (additionalOptionsListContainer) { additionalOptionsListContainer.querySelectorAll('.remove-additional-option').forEach(btn => { btn.addEventListener('click', (e) => { const row = e.target.closest('.additional-option-row'); if (row) row.remove(); }); }); } } function deleteSection(prefix, sectionIndex, isSeasonalNested) { console.log('[KefinTweaks Configuration] Deleting section:', prefix, sectionIndex, isSeasonalNested); // Try both string and number comparison for data-section-index const sectionItem = modalInstance.dialogContent.querySelector( `.${prefix}_section_item[data-section-index="${sectionIndex}"], .${prefix}_section_item[data-section-index='${sectionIndex}']` ); if (sectionItem && confirm('Are you sure you want to delete this section?')) { sectionItem.remove(); // For seasonal sections, sectionIndex is compound (e.g., "0_1"), need special handling if (isSeasonalNested && typeof sectionIndex === 'string' && sectionIndex.includes('_')) { const [seasonIndex, deletedSectionIndex] = sectionIndex.split('_'); const sectionsList = modalInstance.dialogContent.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`); if (sectionsList) { // Re-index only sections within the same season const remainingItems = Array.from(sectionsList.querySelectorAll(`.${prefix}_section_item`)) .sort((a, b) => { const aIdx = a.getAttribute('data-section-index'); const bIdx = b.getAttribute('data-section-index'); if (!aIdx || !bIdx) return 0; // Extract section index part (after underscore) const aSectionIdx = aIdx.includes('_') ? parseInt(aIdx.split('_')[1]) : parseInt(aIdx); const bSectionIdx = bIdx.includes('_') ? parseInt(bIdx.split('_')[1]) : parseInt(bIdx); return aSectionIdx - bSectionIdx; }); remainingItems.forEach((item, newSectionIndex) => { const newFullIndex = `${seasonIndex}_${newSectionIndex}`; const oldIndex = item.getAttribute('data-section-index'); item.setAttribute('data-section-index', newFullIndex); // Update all inputs within this section item.querySelectorAll(`[data-section-index="${oldIndex}"]`).forEach(el => { el.setAttribute('data-section-index', newFullIndex); }); // Update summary displays const summaryName = item.querySelector(`.seasonal-section-name[data-section-index="${oldIndex}"]`); if (summaryName) { summaryName.setAttribute('data-section-index', newFullIndex); } const summaryEnabled = item.querySelector(`.seasonal-section-enabled-display[data-section-index="${oldIndex}"]`); if (summaryEnabled) { summaryEnabled.setAttribute('data-section-index', newFullIndex); } const summaryOrder = item.querySelector(`.seasonal-section-order[data-section-index="${oldIndex}"]`); if (summaryOrder) { summaryOrder.setAttribute('data-section-index', newFullIndex); } // Update delete and preview button data attributes const deleteBtn = item.querySelector('.delete-section-btn'); if (deleteBtn) { deleteBtn.setAttribute('data-section-index', newFullIndex); } const previewBtn = item.querySelector('.preview-section-btn'); if (previewBtn) { previewBtn.setAttribute('data-section-index', newFullIndex); } }); } } else { // Regular custom sections - simple re-indexing const remainingItems = Array.from(modalInstance.dialogContent.querySelectorAll(`.${prefix}_section_item`)) .sort((a, b) => { const aIndex = parseInt(a.getAttribute('data-section-index')) || 0; const bIndex = parseInt(b.getAttribute('data-section-index')) || 0; return aIndex - bIndex; }); remainingItems.forEach((item, newIndex) => { const oldIndex = item.getAttribute('data-section-index'); item.setAttribute('data-section-index', newIndex); // Update all inputs within this section item.querySelectorAll(`[data-section-index="${oldIndex}"]`).forEach(el => { el.setAttribute('data-section-index', newIndex); }); // Update summary displays that reference section index const summaryName = item.querySelector(`.custom-section-name[data-section-index="${oldIndex}"]`); if (summaryName) { summaryName.setAttribute('data-section-index', newIndex); } const summaryEnabled = item.querySelector(`.custom-section-enabled-display[data-section-index="${oldIndex}"]`); if (summaryEnabled) { summaryEnabled.setAttribute('data-section-index', newIndex); } const summaryOrder = item.querySelector(`.custom-section-order[data-section-index="${oldIndex}"]`); if (summaryOrder) { summaryOrder.setAttribute('data-section-index', newIndex); } // Update delete button data attribute const deleteBtn = item.querySelector('.delete-section-btn'); if (deleteBtn) { deleteBtn.setAttribute('data-section-index', newIndex); } const previewBtn = item.querySelector('.preview-section-btn'); if (previewBtn) { previewBtn.setAttribute('data-section-index', newIndex); } }); } } }; async function previewSection(prefix, sectionIndex) { if (!window.ModalSystem) { alert('Modal system not available. Please ensure modal.js is loaded.'); return; } const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, prefix, sectionIndex); if (!sectionConfig) { alert('Unable to build preview for this section. Please check the configuration values.'); return; } const modalId = `sectionPreview_${prefix}_${sectionIndex}_${Date.now()}`; const contentId = `${modalId}_content`; const previewModal = window.ModalSystem.create({ id: modalId, title: `Preview: ${sectionConfig.name || 'Custom Section'}`, content: `
Preparing preview...
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, size: 'large', onOpen: async (previewInstance) => { previewInstance.dialogContent.style.width = '1400px'; const closeBtn = previewInstance.dialogFooter?.querySelector(`#${modalId}_closeBtn`); if (closeBtn) { closeBtn.addEventListener('click', () => previewInstance.close()); } const container = previewInstance.dialogContent.querySelector(`#${contentId}`); if (!container) { return; } container.innerHTML = `
Loading preview...
`; try { const items = await fetchItemsForSectionPreview(sectionConfig); if (!items || items.length === 0) { container.innerHTML = `
No items match this configuration yet.
`; return; } if (!window.cardBuilder) { container.innerHTML = `
Card builder utilities are not available.
`; return; } let previewElement = null; if (sectionConfig.spotlight && typeof window.cardBuilder.renderSpotlightSection === 'function') { previewElement = window.cardBuilder.renderSpotlightSection(items, sectionConfig.name || 'Preview', { autoPlay: false, showDots: true, showNavButtons: true, viewMoreUrl: null }); } else if (typeof window.cardBuilder.renderCards === 'function') { previewElement = window.cardBuilder.renderCards( items, sectionConfig.name || 'Preview', null, true, sectionConfig.cardFormat || 'Poster', sectionConfig.sortOrder || 'Random', sectionConfig.sortOrderDirection || 'Ascending' ); } container.innerHTML = ''; if (previewElement) { container.appendChild(previewElement); } else { container.innerHTML = `
Unable to render preview with the current configuration.
`; } } catch (error) { console.error('[KefinTweaks Configuration] Error rendering preview:', error); container.innerHTML = `
Failed to load preview: ${error?.message || error}
`; } } }); previewModal.open(); } async function updateSectionSourceField(prefix, sectionIndex, isSeasonalNested) { const sectionItem = modalInstance.dialogContent.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); if (!sectionItem) { console.warn(`[KefinTweaks Configuration] Section item not found for prefix: ${prefix}, index: ${sectionIndex}`); return; } const typeSelect = sectionItem.querySelector(`.${prefix}_section_type[data-section-index="${sectionIndex}"]`); if (!typeSelect) { console.warn(`[KefinTweaks Configuration] Type select not found for prefix: ${prefix}, index: ${sectionIndex}`); return; } const type = typeSelect.value || 'Genre'; // Find the source field container using the specific class const sourceContainer = sectionItem.querySelector(`.${prefix}_section_source_container[data-section-index="${sectionIndex}"]`); if (!sourceContainer) { console.warn(`[KefinTweaks Configuration] Source container not found for prefix: ${prefix}, index: ${sectionIndex}`); return; } // Get current source value const currentSourceInput = sectionItem.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); const badgeContainer = sourceContainer?.querySelector(`.tag-badge-container[data-section-index="${sectionIndex}"][data-type]`) || null; let currentSource = ''; if (badgeContainer) { // Extract from existing badges const badges = badgeContainer.querySelectorAll('.tag-badge'); currentSource = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean).join(', '); } else if (currentSourceInput) { // Extract from dropdown/input currentSource = currentSourceInput.value || ''; } // Clear badges whenever the source type changes // This ensures a clean state when switching between any types const currentType = currentSourceInput?.dataset?.type || badgeContainer?.dataset?.type; if (currentType && currentType !== type) { currentSource = ''; } let newSourceHtml = ''; if (type === 'Parent') { // Parent type uses simple text input newSourceHtml = `
Parent IDs (comma-separated, leave empty for "Any")
`; } else { // Build new badge-based UI for other types const sourceArray = currentSource ? currentSource.split(',').map(s => s.trim()).filter(Boolean) : []; const badgesHtml = sourceArray.map(val => { if (type === 'Tag' || type === 'Genre') { return ` ${val} `; } else { return ` ${val} `; } }).join(''); newSourceHtml = `
${type}
${badgesHtml}
`; } sourceContainer.innerHTML = newSourceHtml; // Setup event listener for Parent type to sync input with hidden field if (type === 'Parent') { const parentInput = sourceContainer.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); const parentHidden = sourceContainer.querySelector(`.${prefix}_section_source_hidden[data-section-index="${sectionIndex}"]`); if (parentInput && parentHidden) { parentInput.addEventListener('input', () => { parentHidden.value = parentInput.value; }); } } else { // Initialize autocomplete for other types await setupBadgeAutocomplete(prefix, sectionIndex, type); // Load names for Collections/Playlists if (type === 'Collection' || type === 'Playlist') { await loadBadgeNames(prefix, sectionIndex, type); } } // Update Include Item Types field to reflect available types based on source type const includeItemTypesContainer = sectionItem.querySelector(`.${prefix}_section_includeItemTypes_container[data-section-index="${sectionIndex}"]`); if (includeItemTypesContainer) { const currentBadges = includeItemTypesContainer.querySelectorAll('.tag-badge'); const currentTypes = Array.from(currentBadges).map(badge => badge.getAttribute('data-value')).filter(Boolean); const availableTypes = type === 'Parent' ? ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book', 'BoxSet', 'Playlist'] : ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum', 'Audio', 'MusicVideo', 'Book']; // Remove invalid types (types not available for current source type) const validTypes = currentTypes.filter(t => availableTypes.includes(t)); // Rebuild the Include Item Types field const sanitizedPrefix = `${prefix}_section_${sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_')}`; const newIncludeItemTypesHtml = buildIncludeItemTypesField(prefix, sectionIndex, sanitizedPrefix, validTypes, type); includeItemTypesContainer.outerHTML = newIncludeItemTypesHtml; // Re-initialize autocomplete for Include Item Types const newContainer = sectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`); if (newContainer) { setupIncludeItemTypesDropdown(prefix, sectionIndex, availableTypes); } } } // Generic badge management system const BadgeSystem = { /** * Creates a badge element * @param {string} value - The value to store (ID for collections/playlists, name for tags/genres) * @param {string} displayText - The text to display * @returns {HTMLElement} 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 (sibling of the input in new layout, or container in old layout) const badgesWrapper = input.nextElementSibling && input.nextElementSibling.matches('div') ? input.nextElementSibling : badgeContainer; const badge = this.createBadge(value, displayText); badgesWrapper.appendChild(badge); 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()); } }; // Generic autocomplete setup for badge-based inputs // Supports: Tag, Genre, Collection, Playlist async function setupBadgeAutocomplete(prefix, sectionIndex, type) { const badgeContainer = modalInstance.dialogContent.querySelector( `.tag-badge-container[data-section-index="${sectionIndex}"][data-type="${type}"]` ); const input = modalInstance.dialogContent.querySelector( `.${prefix}_section_source.autocomplete-input[data-section-index="${sectionIndex}"]` ); const suggestionsDiv = modalInstance.dialogContent.querySelector( `.autocomplete-suggestions[data-section-index="${sectionIndex}"]` ); const hiddenInput = modalInstance.dialogContent.querySelector( `.${prefix}_section_source_hidden[data-section-index="${sectionIndex}"]` ); const clearAllBtn = modalInstance.dialogContent.querySelector( `.badge-clear-all[data-section-index="${sectionIndex}"]` ); if (!badgeContainer || !input || !suggestionsDiv) 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); } } }); // Setup clear all button if (clearAllBtn) { clearAllBtn.addEventListener('click', () => { BadgeSystem.clearAll(prefix, sectionIndex, badgeContainer, hiddenInput); }); } // Fetch available options based on type let options = []; try { const userId = window.ApiClient.getCurrentUserId(); const serverAddress = window.ApiClient.serverAddress(); if (type === 'Tag' || type === 'Genre') { const url = `${serverAddress}/Items/Filters?UserId=${userId}&IncludeItemTypes=Movie`; const data = await window.apiHelper.getData(url, true); options = type === 'Tag' ? (data.Tags || []) : (data.Genres || []); } else if (type === 'Collection' || type === 'Playlist') { // Fetch Collections/Playlists using getItems with caching const itemType = type === 'Playlist' ? 'Playlist' : 'BoxSet,CollectionFolder'; const data = await window.apiHelper.getItems( { IncludeItemTypes: itemType, Recursive: true, Fields: 'ItemCounts' }, true, 300000 ); options = (data.Items || []).map(item => ({ id: item.Id, name: item.Name })); } } catch (err) { console.error('[KefinTweaks Configuration] Error fetching options:', err); } let currentSuggestions = []; let selectedIndex = -1; const getExistingBadges = () => BadgeSystem.getExistingBadges(badgeContainer); 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') { 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) => { const display = type === 'Tag' || type === 'Genre' ? opt : opt.name; const dataValue = type === 'Tag' || type === 'Genre' ? 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', () => { // Clear keyboard selection selectedIndex = -1; updateSuggestionHighlight(); // Highlight hovered item 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(); }); // 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') { 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' ? opt : opt.name; const dataValue = type === 'Tag' || type === 'Genre' ? 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', () => { // Clear keyboard selection selectedIndex = -1; updateSuggestionHighlight(); // Highlight hovered item 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' ? selected : selected.id; const display = type === 'Tag' || type === 'Genre' ? selected : selected.name; BadgeSystem.addBadge(prefix, sectionIndex, value, display, badgeContainer, input, hiddenInput); suggestionsDiv.style.display = 'none'; selectedIndex = -1; } // No free-form entry - require autocomplete selection } 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); } } }); 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 focus leaves the input input.addEventListener('blur', () => { suggestionsDiv.style.display = 'none'; }); } // Loads and updates badge display text for Collections/Playlists // Replaces IDs with names after fetching from API async function loadBadgeNames(prefix, sectionIndex, type) { if (type !== 'Collection' && type !== 'Playlist') return; const badgeContainer = modalInstance.dialogContent.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) { const displayText = item.Name; badge.innerHTML = `${displayText}`; badge.removeAttribute('data-loading'); } else { // Item not found, keep ID display badge.removeAttribute('data-loading'); } }); } catch (err) { console.error('[KefinTweaks Configuration] Error loading badge names:', err); badges.forEach(badge => badge.removeAttribute('data-loading')); } } // Setup dropdown for Include Item Types field function setupIncludeItemTypesDropdown(prefix, sectionIndex, availableTypes) { const badgeContainer = modalInstance.dialogContent.querySelector( `.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]` ); const toggleButton = badgeContainer?.querySelector('.includeItemTypes-dropdown-toggle'); const dropdown = badgeContainer?.querySelector('.includeItemTypes-dropdown'); const checkboxNodes = badgeContainer ? Array.from(badgeContainer.querySelectorAll('.includeItemTypes-option')) : []; const proxyInput = modalInstance.dialogContent.querySelector( `.${prefix}_section_includeItemTypes[data-section-index="${sectionIndex}"]` ); const hiddenInput = modalInstance.dialogContent.querySelector( `.${prefix}_section_includeItemTypes_hidden[data-section-index="${sectionIndex}"]` ); const badgesWrapper = badgeContainer?.querySelector('.includeItemTypes-badges'); const emptyState = badgeContainer?.querySelector('.includeItemTypes-empty'); const clearAllBtn = modalInstance.dialogContent.querySelector( `.badge-clear-all[data-section-index="${sectionIndex}"][data-field="includeItemTypes"]` ); const allowedTypeSet = new Set((availableTypes || []).map(type => type.toLowerCase())); if (!badgeContainer || !dropdown || !proxyInput || !hiddenInput || !badgesWrapper) 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'); } }; // 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(); } } }); 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 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(); }; 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(); }); } // 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(); } // Seasonal season management functions (exposed globally for onclick handlers) window.addNewSeasonalSeason = function() { const seasonsList = modalInstance.dialogContent.querySelector('#homeScreen_seasonal_seasons_list'); if (!seasonsList) return; const newIndex = seasonsList.querySelectorAll('.seasonal-season-item').length; const newSeason = { id: `season-${newIndex}`, enabled: true, name: 'New Season', startDate: '', endDate: '', order: 100, sections: [] }; const newSeasonHtml = `
chevron_right New Season (Enabled, Order: 100)
Enabled
${buildJellyfinCheckbox(`seasonal-season-enabled-${newIndex}`, true, 'Enabled', { 'data-index': newIndex, 'class': 'seasonal-season-enabled' })}
Name
Start Date (MM-DD)
End Date (MM-DD)
Order
Sections
`; seasonsList.insertAdjacentHTML('beforeend', newSeasonHtml); // Setup listeners for the new season const newSeasonItem = seasonsList.querySelector(`.seasonal-season-item[data-season-index="${newIndex}"]`); if (newSeasonItem) { const nameInput = newSeasonItem.querySelector(`.seasonal-season-name[data-index="${newIndex}"]`); const nameSpan = newSeasonItem.querySelector(`.seasonal-season-name-display[data-season-index="${newIndex}"]`); const orderInput = newSeasonItem.querySelector(`.seasonal-season-order-input[data-index="${newIndex}"]`); const orderSpan = newSeasonItem.querySelector(`.seasonal-season-order[data-season-index="${newIndex}"]`); const enabledCheckbox = newSeasonItem.querySelector(`#seasonal-season-enabled-${newIndex}`) || newSeasonItem.querySelector(`.seasonal-season-enabled[data-index="${newIndex}"]`); const enabledSpan = newSeasonItem.querySelector(`.seasonal-season-enabled-display[data-season-index="${newIndex}"]`); if (nameInput && nameSpan) { nameInput.addEventListener('input', () => { nameSpan.textContent = nameInput.value || 'Unnamed Season'; }); } if (orderInput && orderSpan) { orderInput.addEventListener('input', () => { orderSpan.textContent = orderInput.value || '100'; }); } if (enabledCheckbox && enabledSpan) { enabledCheckbox.addEventListener('change', () => { enabledSpan.textContent = enabledCheckbox.checked ? 'Enabled' : 'Disabled'; }); } } }; // Collection and Playlist picker functions window.openCollectionPicker = async function(seasonIndex) { if (!window.ModalSystem) { alert('Modal system not available'); return; } try { // Fetch collections from server const userId = window.ApiClient.getCurrentUserId(); const serverAddress = window.ApiClient.serverAddress(); const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const collections = data.Items || []; // Get currently selected collections const hiddenInput = modalInstance.dialogContent.querySelector(`.seasonal-season-collections[data-index="${seasonIndex}"]`); const selectedIds = hiddenInput ? JSON.parse(hiddenInput.value || '[]') : []; // Create picker modal let pickerModalInstanceRef = null; const pickerModal = window.ModalSystem.create({ id: 'collectionPickerModal', title: 'Select Collections', content: `
${collections.map(collection => { const isSelected = selectedIds.includes(collection.Id); return ` `; }).join('')}
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (pickerModalInstance) => { pickerModalInstanceRef = pickerModalInstance; // Set up search filter const searchInput = pickerModalInstance.dialogContent.querySelector('#collectionPickerSearch'); if (searchInput) { searchInput.addEventListener('input', (e) => { const list = pickerModalInstance.dialogContent.querySelector('#collectionPickerList'); const items = list.querySelectorAll('label'); const term = e.target.value.toLowerCase(); items.forEach(item => { const name = item.querySelector('span').textContent.toLowerCase(); item.style.display = name.includes(term) ? '' : 'none'; }); }); } const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerConfirm'); const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerCancel'); if (confirmBtn) { confirmBtn.addEventListener('click', () => { const checkboxes = pickerModalInstance.dialogContent.querySelectorAll('#collectionPickerList input[type="checkbox"]:checked'); const selectedIds = Array.from(checkboxes).map(cb => cb.value); // Update hidden input if (hiddenInput) { hiddenInput.value = JSON.stringify(selectedIds); // Update button text and selected count const button = modalInstance.dialogContent.querySelector(`button[onclick="openCollectionPicker(${seasonIndex})"]`); const selectedDiv = modalInstance.dialogContent.querySelector(`.seasonal-season-collections-selected[data-index="${seasonIndex}"]`); if (button) { button.querySelector('span').textContent = `Select Collections (${selectedIds.length})`; } if (selectedDiv) { selectedDiv.textContent = selectedIds.length > 0 ? `Selected: ${selectedIds.length} collection(s)` : 'No collections selected'; } } pickerModalInstance.close(); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { pickerModalInstance.close(); }); } } }); } catch (error) { console.error('[KefinTweaks Configuration] Error opening collection picker:', error); alert('Error loading collections. Please try again.'); } }; window.openPlaylistPicker = async function(seasonIndex) { if (!window.ModalSystem) { alert('Modal system not available'); return; } try { // Fetch playlists from server const userId = window.ApiClient.getCurrentUserId(); const serverAddress = window.ApiClient.serverAddress(); const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const playlists = data.Items || []; // Get currently selected playlists const hiddenInput = modalInstance.dialogContent.querySelector(`.seasonal-season-playlists[data-index="${seasonIndex}"]`); const selectedIds = hiddenInput ? JSON.parse(hiddenInput.value || '[]') : []; // Create picker modal let pickerModalInstanceRef = null; const pickerModal = window.ModalSystem.create({ id: 'playlistPickerModal', title: 'Select Playlists', content: `
${playlists.map(playlist => { const isSelected = selectedIds.includes(playlist.Id); return ` `; }).join('')}
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (pickerModalInstance) => { pickerModalInstanceRef = pickerModalInstance; // Set up search filter const searchInput = pickerModalInstance.dialogContent.querySelector('#playlistPickerSearch'); if (searchInput) { searchInput.addEventListener('input', (e) => { const list = pickerModalInstance.dialogContent.querySelector('#playlistPickerList'); const items = list.querySelectorAll('label'); const term = e.target.value.toLowerCase(); items.forEach(item => { const name = item.querySelector('span').textContent.toLowerCase(); item.style.display = name.includes(term) ? '' : 'none'; }); }); } const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerConfirm'); const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerCancel'); if (confirmBtn) { confirmBtn.addEventListener('click', () => { const checkboxes = pickerModalInstance.dialogContent.querySelectorAll('#playlistPickerList input[type="checkbox"]:checked'); const selectedIds = Array.from(checkboxes).map(cb => cb.value); // Update hidden input if (hiddenInput) { hiddenInput.value = JSON.stringify(selectedIds); // Update button text and selected count const button = modalInstance.dialogContent.querySelector(`button[onclick="openPlaylistPicker(${seasonIndex})"]`); const selectedDiv = modalInstance.dialogContent.querySelector(`.seasonal-season-playlists-selected[data-index="${seasonIndex}"]`); if (button) { button.querySelector('span').textContent = `Select Playlists (${selectedIds.length})`; } if (selectedDiv) { selectedDiv.textContent = selectedIds.length > 0 ? `Selected: ${selectedIds.length} playlist(s)` : 'No playlists selected'; } } pickerModalInstance.close(); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { pickerModalInstance.close(); }); } } }); } catch (error) { console.error('[KefinTweaks Configuration] Error opening playlist picker:', error); alert('Error loading playlists. Please try again.'); } }; // Picker functions for sections (used in nested sections) async function openCollectionPickerForSection(prefix, sectionIndex, isSeasonalNested) { if (!window.ModalSystem) { alert('Modal system not available'); return; } try { const userId = window.ApiClient.getCurrentUserId(); const serverAddress = window.ApiClient.serverAddress(); const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=BoxSet,CollectionFolder&Recursive=true&Fields=ItemCounts`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const collections = data.Items || []; const sectionItem = modalInstance.dialogContent.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); const sourceInput = sectionItem?.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); const currentSource = sourceInput ? sourceInput.value : ''; const selectedId = currentSource || null; const pickerModal = window.ModalSystem.create({ id: 'collectionPickerForSectionModal', title: 'Select Collection', content: `
${collections.map(collection => { const isSelected = collection.Id === selectedId; return ` `; }).join('')}
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (pickerModalInstance) => { const searchInput = pickerModalInstance.dialogContent.querySelector('#collectionPickerForSectionSearch'); if (searchInput) { searchInput.addEventListener('input', (e) => { const list = pickerModalInstance.dialogContent.querySelector('#collectionPickerForSectionList'); const items = list.querySelectorAll('label'); const term = e.target.value.toLowerCase(); items.forEach(item => { const name = item.querySelector('span').textContent.toLowerCase(); item.style.display = name.includes(term) ? '' : 'none'; }); }); } const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerForSectionConfirm'); const clearBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerForSectionClear'); const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#collectionPickerForSectionCancel'); if (confirmBtn) { confirmBtn.addEventListener('click', () => { const selected = pickerModalInstance.dialogContent.querySelector('#collectionPickerForSectionList input[type="radio"]:checked'); const selectedId = selected ? selected.value : ''; if (sourceInput) { sourceInput.value = selectedId; const button = sectionItem?.querySelector(`button[onclick*="openCollectionPickerForSection"]`); const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_collection_selected[data-section-index="${sectionIndex}"]`); if (button) { button.querySelector('span').textContent = `Select Collection ${selectedId ? '(1)' : ''}`; } if (selectedDiv) { selectedDiv.textContent = selectedId ? `Selected: 1 collection` : 'No collection selected'; } } pickerModalInstance.close(); }); } if (clearBtn) { clearBtn.addEventListener('click', () => { const radios = pickerModalInstance.dialogContent.querySelectorAll('#collectionPickerForSectionList input[type="radio"]'); radios.forEach(radio => radio.checked = false); if (sourceInput) { sourceInput.value = ''; const button = sectionItem?.querySelector(`button[onclick*="openCollectionPickerForSection"]`); const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_collection_selected[data-section-index="${sectionIndex}"]`); if (button) { button.querySelector('span').textContent = 'Select Collection'; } if (selectedDiv) { selectedDiv.textContent = 'No collection selected'; } } pickerModalInstance.close(); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { pickerModalInstance.close(); }); } } }); } catch (error) { console.error('[KefinTweaks Configuration] Error opening collection picker for section:', error); alert('Error loading collections. Please try again.'); } }; async function openPlaylistPickerForSection(prefix, sectionIndex, isSeasonalNested) { if (!window.ModalSystem) { alert('Modal system not available'); return; } try { const userId = window.ApiClient.getCurrentUserId(); const serverAddress = window.ApiClient.serverAddress(); const token = window.ApiClient.accessToken(); const response = await fetch(`${serverAddress}/Users/${userId}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ItemCounts`, { headers: { 'Authorization': window.apiHelper.getAuthHeader() } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); const playlists = data.Items || []; const sectionItem = modalInstance.dialogContent.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); const sourceInput = sectionItem?.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); const currentSource = sourceInput ? sourceInput.value : ''; const selectedId = currentSource || null; const pickerModal = window.ModalSystem.create({ id: 'playlistPickerForSectionModal', title: 'Select Playlist', content: `
${playlists.map(playlist => { const isSelected = playlist.Id === selectedId; return ` `; }).join('')}
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (pickerModalInstance) => { const searchInput = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionSearch'); if (searchInput) { searchInput.addEventListener('input', (e) => { const list = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionList'); const items = list.querySelectorAll('label'); const term = e.target.value.toLowerCase(); items.forEach(item => { const name = item.querySelector('span').textContent.toLowerCase(); item.style.display = name.includes(term) ? '' : 'none'; }); }); } const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionConfirm'); const clearBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionClear'); const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionCancel'); if (confirmBtn) { confirmBtn.addEventListener('click', () => { const selected = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionList input[type="radio"]:checked'); const selectedId = selected ? selected.value : ''; if (sourceInput) { sourceInput.value = selectedId; const button = sectionItem?.querySelector(`button[onclick*="openPlaylistPickerForSection"]`); const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_playlist_selected[data-section-index="${sectionIndex}"]`); if (button) { button.querySelector('span').textContent = `Select Playlist ${selectedId ? '(1)' : ''}`; } if (selectedDiv) { selectedDiv.textContent = selectedId ? `Selected: 1 playlist` : 'No playlist selected'; } } pickerModalInstance.close(); }); } if (clearBtn) { clearBtn.addEventListener('click', () => { const radios = pickerModalInstance.dialogContent.querySelectorAll('#playlistPickerForSectionList input[type="radio"]'); radios.forEach(radio => radio.checked = false); if (sourceInput) { sourceInput.value = ''; const button = sectionItem?.querySelector(`button[onclick*="openPlaylistPickerForSection"]`); const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_playlist_selected[data-section-index="${sectionIndex}"]`); if (button) { button.querySelector('span').textContent = 'Select Playlist'; } if (selectedDiv) { selectedDiv.textContent = 'No playlist selected'; } } pickerModalInstance.close(); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { pickerModalInstance.close(); }); } } }); } catch (error) { console.error('[KefinTweaks Configuration] Error opening playlist picker for section:', error); alert('Error loading playlists. Please try again.'); } }; window.editSeasonalSeason = function(index) { // For now, editing is done inline - this could open a modal in the future console.log('Edit seasonal season', index); }; window.deleteSeasonalSeason = function(index) { const seasonsList = modalInstance.dialogContent.querySelector('#homeScreen_seasonal_seasons_list'); if (!seasonsList) return; const seasonItem = seasonsList.querySelector(`.seasonal-season-item[data-season-index="${index}"]`); if (seasonItem && confirm('Are you sure you want to delete this season?')) { seasonItem.remove(); // Re-index remaining items const remainingItems = seasonsList.querySelectorAll('.seasonal-season-item'); remainingItems.forEach((item, newIndex) => { item.setAttribute('data-season-index', newIndex); item.querySelectorAll('[data-index]').forEach(el => { el.setAttribute('data-index', newIndex); }); const editBtn = item.querySelector('button[onclick*="editSeasonalSeason"]'); const deleteBtn = item.querySelector('button[onclick*="deleteSeasonalSeason"]'); if (editBtn) editBtn.setAttribute('onclick', `editSeasonalSeason(${newIndex})`); if (deleteBtn) deleteBtn.setAttribute('onclick', `deleteSeasonalSeason(${newIndex})`); }); } }; // Toggle visibility of configuration sections based on script enable state const scriptCheckboxes = { search: modalInstance.dialogContent.querySelector('#script_search'), exclusiveElsewhere: modalInstance.dialogContent.querySelector('#script_exclusiveElsewhere'), skinManager: modalInstance.dialogContent.querySelector('#script_skinManager'), customMenuLinks: modalInstance.dialogContent.querySelector('#script_customMenuLinks'), flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#script_flattenSingleSeasonShows') }; const configSections = { search: modalInstance.dialogContent.querySelector('#configSection_search'), exclusiveElsewhere: modalInstance.dialogContent.querySelector('#configSection_exclusiveElsewhere'), skin: modalInstance.dialogContent.querySelector('#configSection_skin'), theme: modalInstance.dialogContent.querySelector('#configSection_theme'), customMenuLinks: modalInstance.dialogContent.querySelector('#configSection_customMenuLinks'), flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#configSection_flattenSingleSeasonShows') }; // Function to toggle section visibility const toggleSectionVisibility = (scriptKey, sectionKey) => { const checkbox = scriptCheckboxes[scriptKey]; const section = configSections[sectionKey]; if (checkbox && section) { const isEnabled = checkbox.checked; section.style.display = isEnabled ? '' : 'none'; } }; // Set up event listeners for each script checkbox if (scriptCheckboxes.search) { scriptCheckboxes.search.addEventListener('change', () => toggleSectionVisibility('search', 'search')); } if (scriptCheckboxes.exclusiveElsewhere) { scriptCheckboxes.exclusiveElsewhere.addEventListener('change', () => toggleSectionVisibility('exclusiveElsewhere', 'exclusiveElsewhere')); } if (scriptCheckboxes.skinManager) { scriptCheckboxes.skinManager.addEventListener('change', () => { toggleSectionVisibility('skinManager', 'skin'); toggleSectionVisibility('skinManager', 'theme'); }); } // Initialize pending state for optional includes to track changes across category switches // Deep copy initial config to avoid mutating reference before save const pendingOptionalIncludes = JSON.parse(JSON.stringify(config.optionalIncludes || [])); let currentOptionalIncludesCategory = 'global'; // Optional includes category dropdown handler const optionalIncludesCategory = modalInstance.dialogContent.querySelector('#optionalIncludesCategory'); if (optionalIncludesCategory) { optionalIncludesCategory.addEventListener('change', (e) => { const newCategory = e.target.value; // 1. Capture changes from the PREVIOUS category before switching const currentCheckboxes = modalInstance.dialogContent.querySelectorAll(`.optionalIncludeCheckbox[data-category="${currentOptionalIncludesCategory}"]`); currentCheckboxes.forEach(checkbox => { const key = checkbox.getAttribute('data-key'); const enabled = checkbox.checked; const existingIndex = pendingOptionalIncludes.findIndex(item => item.key === key); if (existingIndex >= 0) { pendingOptionalIncludes[existingIndex].enabled = enabled; } else { pendingOptionalIncludes.push({ key, enabled }); } }); // Update current category tracker currentOptionalIncludesCategory = newCategory; const editor = modalInstance.dialogContent.querySelector('#optionalIncludesEditor'); if (editor) { // Create a temporary config object that uses our pending state // This ensures buildOptionalIncludesEditor renders checkboxes with the correct pending state const currentConfig = { ...(window.KefinTweaksConfig || config), optionalIncludes: pendingOptionalIncludes }; editor.innerHTML = buildOptionalIncludesEditor(newCategory, currentConfig, window.KefinTweaksSkinConfig || []); // Re-attach checkbox handlers attachOptionalIncludesCheckboxHandlers(modalInstance, newCategory, currentConfig); } }); } // Attach initial checkbox handlers attachOptionalIncludesCheckboxHandlers(modalInstance, 'global', config); if (scriptCheckboxes.customMenuLinks) { scriptCheckboxes.customMenuLinks.addEventListener('change', () => toggleSectionVisibility('customMenuLinks', 'customMenuLinks')); } if (scriptCheckboxes.flattenSingleSeasonShows) { scriptCheckboxes.flattenSingleSeasonShows.addEventListener('change', () => toggleSectionVisibility('flattenSingleSeasonShows', 'flattenSingleSeasonShows')); } // Save button handler const saveBtn = modalInstance.dialogFooter?.querySelector('#saveConfigBtn'); if (saveBtn) { saveBtn.addEventListener('click', () => { // Capture changes from the CURRENT category one last time const currentCheckboxes = modalInstance.dialogContent.querySelectorAll(`.optionalIncludeCheckbox[data-category="${currentOptionalIncludesCategory}"]`); currentCheckboxes.forEach(checkbox => { const key = checkbox.getAttribute('data-key'); const enabled = checkbox.checked; const existingIndex = pendingOptionalIncludes.findIndex(item => item.key === key); if (existingIndex >= 0) { pendingOptionalIncludes[existingIndex].enabled = enabled; } else { pendingOptionalIncludes.push({ key, enabled }); } }); handleSaveConfig(modalInstance, pendingOptionalIncludes); }); } // Manage button handler (Backups / Export / Import / Resets) const manageBtn = modalInstance.dialog?.querySelector('#manageConfigBtn'); if (manageBtn) { manageBtn.addEventListener('click', () => openManageConfigModal(config, modalInstance)); } // Remove duplicate defaults button handler const removeDuplicateDefaultsBtn = modalInstance.dialogContent.querySelector('#removeDuplicateDefaultsBtn'); if (removeDuplicateDefaultsBtn) { removeDuplicateDefaultsBtn.addEventListener('click', () => handleRemoveDuplicateDefaults(modalInstance)); } } // Handle removing custom skins that override defaults function handleRemoveDuplicateDefaults(modalInstance) { const skinsTextarea = modalInstance.dialogContent.querySelector('#skinsJson'); if (!skinsTextarea) return; try { const currentSkins = JSON.parse(skinsTextarea.value || '[]'); const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name)); // Filter out skins that match default names const filteredSkins = currentSkins.filter(skin => !defaultSkinNames.has(skin.name)); const removedCount = currentSkins.length - filteredSkins.length; if (removedCount === 0) { if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('No duplicate default skins found in configuration'); } else { alert('No duplicate default skins found in configuration'); } return; } // Update textarea skinsTextarea.value = JSON.stringify(filteredSkins, null, 2); // Show confirmation if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(`Removed ${removedCount} custom skin${removedCount > 1 ? 's' : ''} that were overriding defaults`); } else { alert(`Removed ${removedCount} custom skin${removedCount > 1 ? 's' : ''} that were overriding defaults`); } } catch (error) { console.error('[KefinTweaks Configuration] Error removing duplicate defaults:', error); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Error processing skins JSON', '5'); } else { alert('Error processing skins JSON. Please check the format.'); } } } function toastMsg(message, timeout) { if (window.KefinTweaksToaster?.toast) { window.KefinTweaksToaster.toast(message, timeout); } else { alert(message); } } function getExportClipboardFlags() { const isHttps = (typeof ApiClient !== 'undefined' && ApiClient._serverAddress && ApiClient._serverAddress.startsWith('https://')) || (typeof location !== 'undefined' && location.protocol === 'https:'); const supportsClipboard = !!(navigator.clipboard && navigator.clipboard.writeText); return { canUseClipboard: isHttps && supportsClipboard }; } function buildManageExportPanelHtml(canUseClipboard) { return `
Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration. ${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'}
${canUseClipboard ? `
` : ''}
`; } function buildManageImportPanelHtml() { return `
Paste your configuration JSON below. This will completely replace your current configuration.
`; } function backupsSettingsDescription(scheduledEnabled, keepCount) { const x = Math.max(1, Math.floor(Number(keepCount) || 5)); if (scheduledEnabled) { return `KefinTweaks will backup your configuration once per day and keep up to ${x} previous scheduled backups. Backups are only created from an admin account that is logged in.`; } return 'KefinTweaks will never automatically backup your configuration unless necessary during a migration task or other operations.'; } function buildManageBackupsPanelHtml(settings) { const scheduledEnabled = settings.scheduledEnabled === true; const keepCount = Math.max(1, Math.floor(Number(settings.keepCount) || 5)); const toggleHtml = window.KefinTweaksUI?.buildToggleCard ? window.KefinTweaksUI.buildToggleCard( 'manageScheduledBackups', scheduledEnabled, 'Scheduled Backups', backupsSettingsDescription(scheduledEnabled, keepCount) ) : `
${backupsSettingsDescription(scheduledEnabled, keepCount)}
`; return `
${toggleHtml}
KefinTweaks will overwrite the oldest scheduled Backup once you get to this limit.

Manage Backups

Loading backups…
`; } function renderBackupsTableRows(backups) { if (!backups.length) { return `
No backups found.
`; } const rows = backups.map((b) => `
${escapeHtmlAttr(b.name)}
${escapeHtmlAttr(b.kindLabel || 'Manual')} · ${escapeHtmlAttr(b.dateLabel || 'Unknown date')}
`).join(''); return `
${rows}
Backup Actions
`; } function escapeHtmlAttr(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } async function refreshManageBackupsTable(host) { if (!host) return; host.innerHTML = `
Loading backups…
`; const listFn = window.KefinTweaksUtils?.listKefinConfigBackups; if (!listFn) { host.innerHTML = `
Backup helpers unavailable.
`; return; } const backups = await listFn(); host.innerHTML = renderBackupsTableRows(backups); } function confirmDeleteBackup(name) { return confirmDeleteBackups([name]); } function confirmCreateBackup() { return new Promise((resolve) => { if (!window.ModalSystem) { const label = window.prompt('Optional backup name (leave blank for default):', '') || ''; resolve(window.confirm('Create a configuration backup now?') ? { create: true, label: String(label).trim() } : { create: false }); return; } window.ModalSystem.create({ id: 'manageCreateBackupConfirm', title: 'Create Backup', content: `
Create a backup of your current KefinTweaks configuration?
When set, the backup is named KefinTweaks-Config-Backup-[Name]-Timestamp. Leave blank for the default name.
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (mi) => { const input = mi.dialogContent?.querySelector('#manageCreateBackupName'); setTimeout(() => input?.focus(), 100); mi.dialogFooter?.querySelector('#manageCreateBackupConfirmBtn')?.addEventListener('click', () => { const label = (input?.value || '').trim(); mi.close(); resolve({ create: true, label }); }); mi.dialogFooter?.querySelector('#manageCreateBackupCancelBtn')?.addEventListener('click', () => { mi.close(); resolve({ create: false }); }); } }); }); } function confirmDeleteBackups(names) { const list = (Array.isArray(names) ? names : []).filter(Boolean); return new Promise((resolve) => { if (!list.length) { resolve(false); return; } const title = list.length === 1 ? 'Delete Backup' : `Delete ${list.length} Backups`; const body = list.length === 1 ? `
Remove backup ${escapeHtmlAttr(list[0])}? This cannot be undone.
` : `
Remove ${list.length} selected backups? This cannot be undone.
`; if (!window.ModalSystem) { resolve(window.confirm(list.length === 1 ? `Delete backup "${list[0]}"?` : `Delete ${list.length} selected backups?`)); return; } window.ModalSystem.create({ id: 'manageDeleteBackupConfirm', title, content: body, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (mi) => { mi.dialogFooter?.querySelector('#manageDeleteBackupConfirmBtn')?.addEventListener('click', () => { mi.close(); resolve(true); }); mi.dialogFooter?.querySelector('#manageDeleteBackupCancelBtn')?.addEventListener('click', () => { mi.close(); resolve(false); }); } }); }); } function confirmRestoreBackup(name) { return new Promise((resolve) => { if (!window.ModalSystem) { const backupFirst = window.confirm(`Backup current config before restoring "${name}"?`); resolve(window.confirm(`Restore "${name}"?`) ? { restore: true, backupCurrentFirst: backupFirst } : { restore: false }); return; } window.ModalSystem.create({ id: 'manageRestoreBackupConfirm', title: 'Restore Backup', content: `
Restore configuration from ${escapeHtmlAttr(name)}? This will overwrite your current active configuration.
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (mi) => { mi.dialogFooter?.querySelector('#manageRestoreWithBackupBtn')?.addEventListener('click', () => { mi.close(); resolve({ restore: true, backupCurrentFirst: true }); }); mi.dialogFooter?.querySelector('#manageRestoreOnlyBtn')?.addEventListener('click', () => { mi.close(); resolve({ restore: true, backupCurrentFirst: false }); }); mi.dialogFooter?.querySelector('#manageRestoreCancelBtn')?.addEventListener('click', () => { mi.close(); resolve({ restore: false }); }); } }); }); } async function persistBackupsSettings(config, settings) { config.backups = { scheduledEnabled: settings.scheduledEnabled === true, keepCount: Math.max(1, Math.floor(Number(settings.keepCount) || 5)), lastScheduledAt: settings.lastScheduledAt || config.backups?.lastScheduledAt || null }; if (window.KefinTweaksConfig) { window.KefinTweaksConfig.backups = { ...config.backups }; } await KefinTweaksUtils.saveConfigToJavaScriptInjector(config); if (config.backups.scheduledEnabled) { await window.KefinTweaksUtils.maybeRunScheduledBackup?.(config); } } function bindManageBackupsPanel(root, config) { const settings = window.KefinTweaksUtils?.normalizeBackupsSettings ? window.KefinTweaksUtils.normalizeBackupsSettings(config) : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null }; const scheduledCb = root.querySelector('#manageScheduledBackups'); const keepInput = root.querySelector('#manageBackupKeepCount'); const descEl = root.querySelector('#manageScheduledBackupsDesc') || root.querySelector('.kefin-toggle-card-desc'); const tableHost = root.querySelector('#manageBackupsTableHost'); if (window.KefinTweaksUI?.bindToggleCards) { window.KefinTweaksUI.bindToggleCards(root); } const syncDesc = () => { if (!descEl) return; const enabled = scheduledCb?.checked === true; const keep = Math.max(1, Math.floor(Number(keepInput?.value) || 5)); descEl.textContent = backupsSettingsDescription(enabled, keep); }; scheduledCb?.addEventListener('change', async () => { syncDesc(); try { await persistBackupsSettings(config, { ...settings, scheduledEnabled: scheduledCb.checked === true, keepCount: Math.max(1, Math.floor(Number(keepInput?.value) || 5)) }); toastMsg(`Scheduled backups ${scheduledCb.checked ? 'enabled' : 'disabled'}`); await refreshManageBackupsTable(tableHost); } catch (e) { console.error('[KefinTweaks Configuration] Error saving backup settings:', e); toastMsg('Failed to save backup settings', '5'); } }); keepInput?.addEventListener('change', async () => { const keep = Math.max(1, Math.floor(Number(keepInput.value) || 5)); keepInput.value = String(keep); syncDesc(); try { await persistBackupsSettings(config, { ...settings, scheduledEnabled: scheduledCb?.checked === true, keepCount: keep }); await window.KefinTweaksUtils.pruneKefinConfigBackups?.(keep); await refreshManageBackupsTable(tableHost); toastMsg(`Keeping up to ${keep} backups`); } catch (e) { console.error('[KefinTweaks Configuration] Error saving keep count:', e); toastMsg('Failed to save backup keep count', '5'); } }); tableHost?.addEventListener('click', async (e) => { const createBtn = e.target.closest?.('#manageBackupsCreateBtn'); const selectAllBtn = e.target.closest?.('#manageBackupsSelectAllBtn'); const deselectAllBtn = e.target.closest?.('#manageBackupsDeselectAllBtn'); const deleteSelectedBtn = e.target.closest?.('#manageBackupsDeleteSelectedBtn'); const syncSelectionUi = () => { const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked'); const all = tableHost.querySelectorAll('.manage-backup-row-check'); const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn'); if (btn) btn.disabled = selected.length === 0; const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); if (header && all.length) { header.checked = selected.length === all.length; header.indeterminate = selected.length > 0 && selected.length < all.length; } }; const setAllChecked = (checked) => { tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => { cb.checked = checked; }); const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); if (header) { header.checked = checked; header.indeterminate = false; } syncSelectionUi(); }; if (createBtn) { const choice = await confirmCreateBackup(); if (!choice?.create) return; const result = await window.KefinTweaksUtils?.createKefinConfigBackup?.(config, { reason: 'manual', kind: 'manual', label: choice.label || '' }); if (result?.ok) { toastMsg(`Backup created: ${result.name}`); await refreshManageBackupsTable(tableHost); } else { toastMsg('Failed to create backup', '5'); } return; } if (selectAllBtn) { setAllChecked(true); return; } if (deselectAllBtn) { setAllChecked(false); return; } if (deleteSelectedBtn) { const names = Array.from(tableHost.querySelectorAll('.manage-backup-row-check:checked')) .map((cb) => cb.getAttribute('data-backup-name')) .filter(Boolean); if (!names.length) return; const confirmed = await confirmDeleteBackups(names); if (!confirmed) return; const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.(names); if (result?.ok) { toastMsg(`Deleted ${result.removed} backup${result.removed === 1 ? '' : 's'}`); await refreshManageBackupsTable(tableHost); } else { toastMsg('Failed to delete selected backups', '5'); } return; } // Ignore row-checkbox clicks here (handled by change); don't treat as row actions if (e.target?.classList?.contains('manage-backup-row-check') || e.target?.id === 'manageBackupsSelectAllHeader') { return; } const restoreBtn = e.target.closest?.('.manage-backup-restore-btn'); const deleteBtn = e.target.closest?.('.manage-backup-delete-btn'); const row = e.target.closest?.('tr[data-backup-name]'); const name = row?.getAttribute('data-backup-name'); if (!name) return; if (restoreBtn) { const choice = await confirmRestoreBackup(name); if (!choice.restore) return; const ok = await window.KefinTweaksUtils.restoreKefinConfigBackup?.(name, { backupCurrentFirst: choice.backupCurrentFirst === true }); if (ok) { toastMsg('Configuration restored. Reloading…'); setTimeout(() => window.location.reload(), 600); } else { toastMsg('Failed to restore backup', '5'); } return; } if (deleteBtn) { const confirmed = await confirmDeleteBackup(name); if (!confirmed) return; const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.([name]); if (result?.ok) { toastMsg('Backup deleted'); await refreshManageBackupsTable(tableHost); } else { toastMsg('Failed to delete backup', '5'); } } }); tableHost?.addEventListener('change', (e) => { const target = e.target; if (!target) return; if (target.id === 'manageBackupsSelectAllHeader') { const checked = target.checked === true; tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => { cb.checked = checked; }); target.indeterminate = false; } else if (!target.classList?.contains('manage-backup-row-check')) { return; } const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked'); const all = tableHost.querySelectorAll('.manage-backup-row-check'); const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn'); if (btn) btn.disabled = selected.length === 0; const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); if (header && all.length && target.id !== 'manageBackupsSelectAllHeader') { header.checked = selected.length === all.length; header.indeterminate = selected.length > 0 && selected.length < all.length; } }); refreshManageBackupsTable(tableHost); } function openManageConfigModal(config, parentModalInstance) { if (!window.ModalSystem) { alert('Modal system not available'); return; } const { canUseClipboard } = getExportClipboardFlags(); const settings = window.KefinTweaksUtils?.normalizeBackupsSettings ? window.KefinTweaksUtils.normalizeBackupsSettings(config) : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null }; const configJson = JSON.stringify(config, null, 2); window.ModalSystem.create({ id: 'kefinTweaksManageModal', title: 'Manage', dialogStyle: { width: 'min(720px, 95vw)', height: 'min(780px, 90vh)', maxHeight: '90vh' }, content: `
${buildManageBackupsPanelHtml(settings)}
${buildManageExportPanelHtml(canUseClipboard)}
${buildManageImportPanelHtml()}
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (manageModal) => { const content = manageModal.dialogContent; manageModal.dialogFooter?.querySelector('#closeManageConfigBtn')?.addEventListener('click', () => manageModal.close()); const setTab = (tab) => { content.querySelectorAll('.manage-tab-btn').forEach((btn) => { btn.classList.toggle('active', btn.dataset.manageTab === tab); }); content.querySelectorAll('.manage-tab-panel').forEach((panel) => { panel.classList.toggle('is-active', panel.dataset.managePanel === tab); }); }; content.querySelectorAll('.manage-tab-btn').forEach((btn) => { btn.addEventListener('click', () => setTab(btn.dataset.manageTab)); }); content.querySelector('#manageRestoreDefaultsBtn')?.addEventListener('click', async () => { const didReset = await handleResetConfig(parentModalInstance || manageModal); if (didReset) { manageModal.close(); } }); content.querySelector('#manageResetAllUsersBtn')?.addEventListener('click', () => { handleResetAllUsersConfig(); }); // Export panel const exportTa = content.querySelector('#manageExportConfigTextarea'); if (exportTa) { exportTa.value = configJson; setTimeout(() => { exportTa.focus(); exportTa.select(); }, 100); } content.querySelector('#manageCopyExportConfigBtn')?.addEventListener('click', async () => { try { await navigator.clipboard.writeText(configJson); toastMsg('Configuration copied to clipboard!'); } catch (copyError) { console.error('[KefinTweaks Configuration] Error copying config:', copyError); toastMsg('Error copying configuration to clipboard', '5'); } }); // Import panel content.querySelector('#manageConfirmImportBtn')?.addEventListener('click', async () => { const jsonText = content.querySelector('#manageImportConfigTextarea')?.value.trim() || ''; if (!jsonText) { toastMsg('Please paste configuration JSON', '3'); return; } let importedConfig; try { importedConfig = JSON.parse(jsonText); } catch (parseError) { toastMsg('Invalid JSON format', '5'); return; } if (!importedConfig || typeof importedConfig !== 'object' || Array.isArray(importedConfig)) { toastMsg('Configuration must be a JSON object', '5'); return; } const confirmed = await showImportConfirmation(); if (!confirmed) return; manageModal.close(); await applyImportedConfig(importedConfig, parentModalInstance || manageModal); }); bindManageBackupsPanel(content.querySelector('[data-manage-panel="backups"]') || content, config); } }); } // Export configuration via modal (with optional clipboard copy on HTTPS) async function handleExportConfig(config) { try { const configJson = JSON.stringify(config, null, 2); // Prefer modal-based export so it works even on unsecured servers if (window.ModalSystem) { const { canUseClipboard } = getExportClipboardFlags(); window.ModalSystem.create({ id: 'exportConfigModal', title: 'Export Configuration', content: `
Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration. ${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'}
`, footer: ` ${ canUseClipboard ? ` ` : '' } `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (modalInstance) => { const textarea = modalInstance.dialogContent.querySelector('#exportConfigTextarea'); if (textarea) { textarea.value = configJson; // Select text to make manual copying easier setTimeout(() => { textarea.focus(); textarea.select(); }, 100); } const closeBtn = modalInstance.dialogFooter?.querySelector('#closeExportConfigBtn'); if (closeBtn) { closeBtn.addEventListener('click', () => modalInstance.close()); } if (canUseClipboard) { const copyBtn = modalInstance.dialogFooter?.querySelector('#copyExportConfigBtn'); if (copyBtn) { copyBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(configJson); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Configuration copied to clipboard!'); } else { alert('Configuration copied to clipboard!'); } } catch (copyError) { console.error( '[KefinTweaks Configuration] Error copying config to clipboard:', copyError ); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast( 'Error copying configuration to clipboard', '5' ); } else { alert('Error copying configuration to clipboard'); } } }); } } } }); } else if (navigator.clipboard && navigator.clipboard.writeText) { // Fallback: no modal system, but clipboard is available await navigator.clipboard.writeText(configJson); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Configuration exported to clipboard!'); } else { alert('Configuration exported to clipboard!'); } } else { // Last-resort fallback: log to console console.log('[KefinTweaks Configuration] Exported configuration:', configJson); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast( 'Unable to open export modal or access clipboard; configuration logged to console', '3' ); } else { alert( 'Unable to open export modal or access clipboard. Check the browser console for the exported configuration.' ); } } } catch (error) { console.error('[KefinTweaks Configuration] Error exporting config:', error); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Error exporting configuration', '5'); } else { alert('Error exporting configuration'); } } } // Import configuration from clipboard async function handleImportConfig(modalInstance) { if (!window.ModalSystem) { alert('Modal system not available'); return; } // Show import modal with textarea const importModal = window.ModalSystem.create({ id: 'importConfigModal', title: 'Import Configuration', content: `
Paste your configuration JSON below. This will completely replace your current configuration.
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (importModalInstance) => { const confirmBtn = importModalInstance.dialogFooter?.querySelector('#confirmImportBtn'); const cancelBtn = importModalInstance.dialogFooter?.querySelector('#cancelImportBtn'); const textarea = importModalInstance.dialogContent.querySelector('#importConfigTextarea'); // Focus textarea if (textarea) { setTimeout(() => textarea.focus(), 100); } if (confirmBtn) { confirmBtn.addEventListener('click', async () => { const jsonText = textarea?.value.trim() || ''; if (!jsonText) { if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Please paste configuration JSON', '3'); } else { alert('Please paste configuration JSON'); } return; } // Validate JSON let importedConfig; try { importedConfig = JSON.parse(jsonText); } catch (parseError) { console.error('[KefinTweaks Configuration] Invalid JSON:', parseError); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(`Invalid JSON: ${parseError.message}`, '5'); } else { alert(`Invalid JSON: ${parseError.message}`); } return; } // Validate config structure (basic check) if (typeof importedConfig !== 'object' || importedConfig === null) { if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Invalid configuration format', '5'); } else { alert('Invalid configuration format'); } return; } // Show confirmation dialog const confirmed = await showImportConfirmation(); if (!confirmed) { return; } // Close import modal importModalInstance.close(); // Apply imported config try { await applyImportedConfig(importedConfig, modalInstance); } catch (error) { console.error('[KefinTweaks Configuration] Error applying imported config:', error); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(`Error applying configuration: ${error.message}`, '5'); } else { alert(`Error applying configuration: ${error.message}`); } } }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { importModalInstance.close(); }); } } }); } // Show confirmation dialog for import function showImportConfirmation() { return new Promise((resolve) => { if (!window.ModalSystem) { resolve(false); return; } const modal = window.ModalSystem.create({ id: 'importConfirmation', title: 'Confirm Import', content: `
This will completely overwrite your current configuration. All existing settings will be replaced with the imported configuration. This action cannot be undone.
`, footer: ` `, closeOnBackdrop: true, closeOnEscape: true, onOpen: (modalInstance) => { const confirmBtn = modalInstance.dialogFooter?.querySelector('#confirmImportConfirmBtn'); const cancelBtn = modalInstance.dialogFooter?.querySelector('#cancelImportConfirmBtn'); if (confirmBtn) { confirmBtn.addEventListener('click', () => { modalInstance.close(); resolve(true); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { modalInstance.close(); resolve(false); }); } } }); }); } // Apply imported configuration async function applyImportedConfig(importedConfig, modalInstance) { // Update in-memory config before reopen (getKefinTweaksConfig reads window.KefinTweaksConfig) window.KefinTweaksConfig = importedConfig; window.KefinTweaksCurrentConfig = importedConfig; currentLoadedConfig = importedConfig; window.KefinTweaksConfigEnabled = importedConfig.enabled !== false; await KefinTweaksUtils.saveConfigToJavaScriptInjector(importedConfig); modalInstance.close(); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Configuration imported successfully! Reloading...'); } setTimeout(() => { openConfigurationModal(); }, 500); } const JS_INJECTOR_ALIASES = ['JavaScript Injector', 'JS Injector']; // Find JavaScript Injector plugin (delegates to shared utils cache) async function findJavaScriptInjectorPlugin() { try { if (!window.KefinTweaksUtils?.resolvePluginId) { throw new Error('KefinTweaksUtils.resolvePluginId is not available'); } const pluginId = await window.KefinTweaksUtils.resolvePluginId(JS_INJECTOR_ALIASES); if (!pluginId) { throw new Error('JavaScript Injector plugin not found. Please ensure it is installed.'); } console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', pluginId); return pluginId; } catch (error) { console.error('[KefinTweaks Configuration] Error finding JavaScript Injector plugin:', error); throw error; } } // Get current JavaScript Injector configuration (delegates to shared utils) async function getJavaScriptInjectorConfig(pluginId) { try { if (!window.KefinTweaksUtils?.getPluginConfiguration) { throw new Error('KefinTweaksUtils.getPluginConfiguration is not available'); } const config = await window.KefinTweaksUtils.getPluginConfiguration(pluginId || JS_INJECTOR_ALIASES); console.log('[KefinTweaks Configuration] Current JS Injector config:', config); return config; } catch (error) { console.error('[KefinTweaks Configuration] Error getting JS Injector config:', error); throw error; } } // Save configuration to JavaScript Injector plugin (delegates to shared utils) async function saveConfigToJavaScriptInjector(config) { try { if (!window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) { throw new Error('KefinTweaksUtils.saveConfigToJavaScriptInjector is not available'); } const success = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(config); if (!success) { throw new Error('Failed to save configuration to JavaScript Injector'); } console.log('[KefinTweaks Configuration] Configuration saved successfully'); return true; } catch (error) { console.error('[KefinTweaks Configuration] Error saving configuration:', error); throw error; } } // Expose injector helpers for feature-config fallbacks window.KefinTweaksConfiguration = Object.assign(window.KefinTweaksConfiguration || {}, { findJavaScriptInjectorPlugin, getJavaScriptInjectorConfig, saveConfigToJavaScriptInjector, openConfigurationModal }); function getSectionConfigFromUI(rootElement, prefix, sectionIndex) { const scope = rootElement || document; const sectionItem = scope.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`); if (!sectionItem) return null; const sanitizedIndex = sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_'); const sectionIdAttr = sectionItem.getAttribute('data-section-id') || `${prefix}-section-${sanitizedIndex}`; const enabledCheckbox = sectionItem.querySelector(`#${prefix}_section_enabled_${sanitizedIndex}`) || sectionItem.querySelector(`.${prefix}_section_enabled[data-section-index="${sectionIndex}"]`); const enabled = enabledCheckbox?.checked !== false; const name = sectionItem.querySelector(`.${prefix}_section_name[data-section-index="${sectionIndex}"]`)?.value || ''; const type = sectionItem.querySelector(`.${prefix}_section_type[data-section-index="${sectionIndex}"]`)?.value || 'Genre'; let source = ''; const sourceContainer = sectionItem.querySelector(`.${prefix}_section_source_container[data-section-index="${sectionIndex}"]`); const badgeContainer = sourceContainer?.querySelector(`.tag-badge-container[data-section-index="${sectionIndex}"][data-type]`) || null; if (badgeContainer) { const badges = badgeContainer.querySelectorAll('.tag-badge'); source = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean).join(', '); } else { const sourceInput = sectionItem.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`); source = sourceInput ? sourceInput.value : ''; } const itemLimit = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_itemLimit`)?.value || '16', 10); const sortOrder = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrder`)?.value || 'Random'; const sortOrderDirection = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrderDirection`)?.value || 'Ascending'; const cardFormat = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_cardFormat`)?.value || 'Poster'; const order = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_order`)?.value || '100', 10); const renderModeSelect = sectionItem.querySelector(`.${prefix}_section_renderMode[data-section-index="${sectionIndex}"]`); const renderMode = renderModeSelect ? renderModeSelect.value : 'Normal'; const spotlight = renderMode === 'Spotlight'; // Legacy compatibility const discoveryCheckbox = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_discovery`) || sectionItem.querySelector(`.${prefix}_section_discovery[data-section-index="${sectionIndex}"]`); const discoveryEnabled = discoveryCheckbox?.checked === true; const searchTerm = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_searchTerm`)?.value?.trim() || ''; let includeItemTypes = []; const includeItemTypesContainer = sectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`); if (includeItemTypesContainer) { const badges = includeItemTypesContainer.querySelectorAll('.tag-badge'); includeItemTypes = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean); } if (includeItemTypes.length === 0 && type !== 'Parent') { includeItemTypes = ['Movie']; } const additionalQueryOptions = []; const additionalOptionsContainer = sectionItem.querySelector(`.${prefix}_section_additionalOptions_list_container[data-section-index="${sectionIndex}"]`); if (additionalOptionsContainer) { const optionRows = additionalOptionsContainer.querySelectorAll('.additional-options-list .additional-option-row'); optionRows.forEach(row => { const input = row.querySelector('.additional-option-value'); if (input) { const key = input.getAttribute('data-key'); const type = input.getAttribute('data-type'); let value; if (type === 'boolean') { value = input.checked; } else { value = input.value; } if (key) { additionalQueryOptions.push({ key, value }); } } }); } return { id: sectionIdAttr, enabled: enabled, name: name, type: type, source: source, itemLimit: itemLimit, sortOrder: sortOrder, sortOrderDirection: sortOrderDirection, cardFormat: cardFormat, order: order, renderMode: renderMode, spotlight: spotlight, discoveryEnabled: discoveryEnabled, searchTerm: searchTerm, includeItemTypes: includeItemTypes, additionalQueryOptions: additionalQueryOptions }; } async function fetchItemsForSectionPreview(sectionConfig) { const apiClient = window.ApiClient; if (!apiClient) { throw new Error('ApiClient is not available'); } const userId = apiClient.getCurrentUserId(); const type = sectionConfig.type || 'Genre'; const searchTerm = sectionConfig.searchTerm?.trim() || ''; const includeItemTypes = Array.isArray(sectionConfig.includeItemTypes) && sectionConfig.includeItemTypes.length > 0 ? sectionConfig.includeItemTypes.join(',') : (sectionConfig.includeItemTypes || 'Movie'); const sortOrder = sectionConfig.sortOrder || 'Random'; const sortOrderDirection = sectionConfig.sortOrderDirection || 'Ascending'; const itemLimit = parseInt(sectionConfig.itemLimit, 10) || 16; const sources = (sectionConfig.source || '').split(',').map(s => s.trim()).filter(Boolean); const fields = 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear,ImageTags,BackdropImageTags,ParentBackdropImageTags,ParentThumbImageTag,SeriesPrimaryImageTag'; // Parse additional query options const additionalOptions = {}; if (Array.isArray(sectionConfig.additionalQueryOptions)) { sectionConfig.additionalQueryOptions.forEach(option => { if (option && option.key) { let value = option.value; if (value === 'true' || value === true) value = true; else if (value === 'false' || value === false) value = false; additionalOptions[option.key] = value; } }); } const dedupeAndLimit = (items = []) => { let working = Array.isArray(items) ? [...items] : []; if (sortOrder === 'Random') { working.sort(() => Math.random() - 0.5); } else if (window.cardBuilder?.sortItems) { working = window.cardBuilder.sortItems(working, sortOrder, sortOrderDirection); } const seen = new Set(); const limited = []; for (const item of working) { if (!item || !item.Id || seen.has(item.Id)) continue; seen.add(item.Id); limited.push(item); if (limited.length >= itemLimit) break; } return limited; }; try { const queryBase = { IncludeItemTypes: includeItemTypes, Recursive: true, Fields: fields, SortBy: sortOrder, SortOrder: sortOrderDirection, ...additionalOptions }; if (type === 'Genre' || type === 'Tag') { if (sources.length === 0) { return []; } const paramName = type === 'Genre' ? 'Genres' : 'Tags'; // Genres use pipe delimiter usually, tags comma? // homeScreen.js uses pipe for Genres, comma for Tags. // apiClient handles array/string? usually string. const delimiter = type === 'Genre' ? '|' : ','; const valueParam = sources.join(delimiter); const params = { ...queryBase, [paramName]: valueParam, Limit: itemLimit }; if (searchTerm) params.SearchTerm = searchTerm; const response = await apiClient.getItems(userId, params); return dedupeAndLimit(response.Items || []); } if (type === 'Collection' || type === 'Playlist' || type === 'Parent') { const existingIds = new Set(); let collected = []; const runQuery = async (params) => { const response = await apiClient.getItems(userId, params); if (response?.Items?.length) { response.Items.forEach(item => { if (item && item.Id && !existingIds.has(item.Id)) { existingIds.add(item.Id); collected.push(item); } }); } }; if (type === 'Parent' && sources.length === 0) { const params = { ...queryBase, Limit: itemLimit * 2 }; if (searchTerm) params.SearchTerm = searchTerm; await runQuery(params); } else { const multiplier = Math.max(1, sources.length); for (const parentId of sources) { const params = { ...queryBase, ParentId: parentId, Limit: itemLimit * multiplier }; if (searchTerm) params.SearchTerm = searchTerm; await runQuery(params); } } return dedupeAndLimit(collected); } return []; } catch (error) { console.warn('[KefinTweaks Configuration] Preview fetch failed:', error); throw error; } } // Handle save configuration async function handleSaveConfig(modalInstance, overrideOptionalIncludes = null) { // Verify admin status before saving const userIsAdmin = await isAdmin(); if (!userIsAdmin) { alert('You must be an administrator to save configuration.'); return; } console.log('[KefinTweaks Configuration] Saving configuration...'); // Collect all form values // Preserve existing kefinTweaksRoot from current config (no longer editable in UI) 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) if (config.scriptRoot) { delete config.scriptRoot; } // Collect script toggles document.querySelectorAll('[data-script-key]').forEach(checkbox => { const key = checkbox.getAttribute('data-script-key'); config.scripts[key] = checkbox.checked; }); console.log('[KefinTweaks Configuration] Configuration collected:', config); // Save to JavaScript Injector plugin try { await KefinTweaksUtils.saveConfigToJavaScriptInjector(config); // Update global config objects so dropdown changes reflect saved values window.KefinTweaksConfig = config; window.KefinTweaksCurrentConfig = config; currentLoadedConfig = config; // Show success toast if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('KefinTweaks saved!'); } } catch (error) { console.error('[KefinTweaks Configuration] Error saving configuration:', error); // Show error toast if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(`Error saving configuration: ${error.message}. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.`, '5'); } else { // Fallback to alert if toaster is not available alert(`Error saving configuration: ${error.message}\n\nPlease ensure the JavaScript Injector plugin is installed and you have administrator permissions.`); } } } /** * @returns {Promise} true if defaults were restored */ async function handleResetConfig(modalInstance) { const userIsAdmin = await isAdmin(); if (!userIsAdmin) { alert('You must be an administrator to reset configuration.'); return false; } const confirmed = confirm('This will restore the KefinTweaks configuration to its defaults. It cannot be undone. Are you sure you want to continue?'); if (!confirmed) { return false; } try { const defaultConfig = await loadDefaultConfig(); // Retain kefinTweaksRoot from existing config const existingConfig = await getKefinTweaksConfig(); defaultConfig.kefinTweaksRoot = existingConfig.kefinTweaksRoot; // Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/' window.KefinTweaksConfig = defaultConfig; window.KefinTweaksCurrentConfig = defaultConfig; currentLoadedConfig = defaultConfig; window.KefinTweaksConfigEnabled = defaultConfig.enabled !== false; await KefinTweaksUtils.saveConfigToJavaScriptInjector(defaultConfig); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Configuration reset to defaults.'); } else { alert('Configuration reset to defaults.'); } if (modalInstance && typeof modalInstance.close === 'function') { modalInstance.close(); } openConfigurationModal(); return true; } catch (error) { console.error('[KefinTweaks Configuration] Error resetting config:', error); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(`Error resetting configuration: ${error.message}`, '5'); } else { alert(`Error resetting configuration: ${error.message}`); } return false; } } /** * Bump configVersion so clients clear kefinTweaks* localStorage on next load (see injector.js). */ async function handleResetAllUsersConfig() { const userIsAdmin = await isAdmin(); if (!userIsAdmin) { alert('You must be an administrator to reset configuration for all users.'); return; } const confirmed = confirm('WARNING: This will force ALL users to reset their KefinTweaks local settings on their next visit. This action cannot be undone. Are you sure you want to continue?'); if (!confirmed) { return; } try { const config = await getKefinTweaksConfig(); config.configVersion = Date.now(); await KefinTweaksUtils.saveConfigToJavaScriptInjector(config); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('Reset signal sent. All users will be reset on next load.'); } else { alert('Reset signal sent. All users will be reset on next load.'); } } catch (error) { console.error('[KefinTweaks Configuration] Error resetting all users config:', error); if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast(`Error resetting configuration: ${error.message}`, '5'); } else { alert(`Error resetting configuration: ${error.message}`); } } } // Helper to parse JSON fields safely function parseJSONField(fieldId, defaultValue) { try { const field = document.getElementById(fieldId); if (!field || !field.value.trim()) { return defaultValue; } return JSON.parse(field.value); } catch (error) { console.error(`[KefinTweaks Configuration] Error parsing JSON field ${fieldId}:`, error); alert(`Error parsing JSON in ${fieldId}: ${error.message}`); return defaultValue; } } function getKefinTweaksRoot() { const raw = window.KefinTweaksConfig?.kefinTweaksRoot || ''; return raw.endsWith('/') ? raw : `${raw}/`; } function getKefinTweaksLogoCssValue() { const root = getKefinTweaksRoot(); if (!root) return 'none'; return `url('${root}logo.png')`; } function ensureKefinTweaksLogoVar() { document.documentElement.style.setProperty( '--kefinTweaksLogo', getKefinTweaksLogoCssValue() ); } function closeAppUserMenu() { const menu = document.getElementById('app-user-menu'); if (!menu) return; const backdrop = menu.querySelector('.MuiBackdrop-root, .MuiModal-backdrop'); if (backdrop) { backdrop.click(); return; } document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true })); } /** * Create KefinTweaks menu item for Jellyfin v12 #app-user-menu * @returns {HTMLAnchorElement} */ function createAppUserMenuConfigButton() { ensureKefinTweaksLogoVar(); const link = document.createElement('a'); link.className = 'MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root MuiMenuItem-gutters css-mbeig7'; link.setAttribute('tabindex', '-1'); link.setAttribute('role', 'menuitem'); link.href = '#'; link.setAttribute('data-kefintweaks-user-menu-config-button', 'true'); const icon = document.createElement('div'); icon.className = 'MuiListItemIcon-root'; const iconSpan = document.createElement('span'); iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium'; iconSpan.setAttribute('aria-hidden', 'true'); icon.appendChild(iconSpan); const text = document.createElement('div'); text.className = 'MuiListItemText-root'; const span = document.createElement('span'); span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary'; span.textContent = 'KefinTweaks'; text.appendChild(span); const ripple = document.createElement('span'); ripple.className = 'MuiTouchRipple-root'; link.appendChild(icon); link.appendChild(text); link.appendChild(ripple); link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); closeAppUserMenu(); openConfigurationModal(); }); return link; } /** * Insert KefinTweaks into #app-user-menu before the first HR after Dashboard. * @returns {boolean} true if button is present after this call */ function addConfigButtonToAppUserMenu() { if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) { return true; } const appUserMenu = document.getElementById('app-user-menu'); if (!appUserMenu) { return false; } const menuList = appUserMenu.querySelector('ul[role="menu"]'); if (!menuList) { return false; } const dashboardLink = menuList.querySelector('a[href="#/dashboard"]') || menuList.querySelector('a[href*="#/dashboard"]'); if (!dashboardLink) { console.log('[KefinTweaks Configuration] #app-user-menu Dashboard link not found'); return false; } let divider = dashboardLink.nextElementSibling; while (divider && !(divider.matches('hr') || divider.classList.contains('MuiDivider-root'))) { divider = divider.nextElementSibling; } if (!divider) { console.log('[KefinTweaks Configuration] #app-user-menu divider after Dashboard not found'); return false; } const button = createAppUserMenuConfigButton(); divider.parentNode.insertBefore(button, divider); console.log('[KefinTweaks Configuration] Configuration button added to #app-user-menu'); return true; } let appUserMenuConfigObserver = null; /** * Permanent observer: keep re-injecting if #app-user-menu remounts and our button is lost. */ function setupAppUserMenuConfigButtonObserver() { addConfigButtonToAppUserMenu(); if (appUserMenuConfigObserver) { return; } const utils = window.KefinTweaksUtils; let scheduled = false; const onMutations = () => { if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) { return; } if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; addConfigButtonToAppUserMenu(); }); }; if (utils?.onBodyMutation) { appUserMenuConfigObserver = utils.onBodyMutation('config-appUserMenu', onMutations); return; } if (typeof MutationObserver === 'undefined' || !document.body) { console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)'); return; } appUserMenuConfigObserver = new MutationObserver(onMutations); appUserMenuConfigObserver.observe(document.body, { childList: true, subtree: true }); } // Create configuration button for Administration section function createConfigButton() { ensureKefinTweaksLogoVar(); const button = document.createElement('div'); button.style.display = 'block'; button.style.padding = '0'; button.style.margin = '0'; button.className = 'listItem-border emby-button kefin-config-button'; button.setAttribute('data-kefintweaks-config-button', 'true'); button.onclick = (e) => { e.preventDefault(); openConfigurationModal(); }; button.innerHTML = `
KefinTweaks
`; return button; } // Create dashboard side menu button function createDashboardConfigButton() { ensureKefinTweaksLogoVar(); // Check if button already exists if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) { return null; } // Get an existing native button const nativeButton = document.querySelector('.MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters.MuiListItemButton-root.MuiListItemButton-gutters'); const div = document.createElement('div'); div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp'; div.setAttribute('tabindex', '0'); div.setAttribute('data-kefintweaks-dashboard-config-button', 'true'); div.onclick = () => { openConfigurationModal(); }; // Create icon with Material icon const icon = document.createElement('div'); icon.className = 'MuiListItemIcon-root css-37qkju css-5pks8q'; const iconSpan = document.createElement('span'); iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium'; iconSpan.style.marginRight = '12px'; iconSpan.setAttribute('aria-hidden', 'true'); icon.appendChild(iconSpan); // Create text const text = document.createElement('div'); text.className = 'MuiListItemText-root css-f696uz css-t3p1pa'; const span = document.createElement('span'); span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz css-pl8nxc'; span.textContent = 'KefinTweaks'; text.appendChild(span); // Create ripple effect span const ripple = document.createElement('span'); ripple.className = 'MuiTouchRipple-root css-w0pj6f css-4mb1j7'; div.appendChild(icon); div.appendChild(text); div.appendChild(ripple); return div; } // Add configuration button to dashboard side menu function addConfigButtonToDashboard() { // Check if button already exists if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) { return; } // Find the plugins section const pluginsSection = document.querySelector('ul[aria-labelledby="plugins-subheader"]'); if (!pluginsSection) { console.log('[KefinTweaks Configuration] Plugins section not found, retrying...'); //setTimeout(addConfigButtonToDashboard, 500); return; } // Create the button const button = createDashboardConfigButton(); if (!button) { return; } // Add button to the plugins section pluginsSection.appendChild(button); console.log('[KefinTweaks Configuration] Configuration button added to dashboard side menu'); } // Add configuration button to Administration section function addConfigButtonToAdminSection() { return; // Check if button already exists (check for admin section button specifically) if (document.querySelector('.adminSection [data-kefintweaks-config-button]')) { return; } // Find the Administration section const adminSection = document.querySelector('.adminSection.verticalSection'); if (!adminSection) { console.log('[KefinTweaks Configuration] Administration section not found, retrying...'); //setTimeout(addConfigButtonToAdminSection, 500); return; } // Create and add the button const button = createConfigButton(); adminSection.appendChild(button); console.log('[KefinTweaks Configuration] Configuration button added to Administration section'); } // Register onViewPage handler for mypreferencesmenu page and dashboard function registerViewPageHandler() { if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { console.log('[KefinTweaks Configuration] KefinTweaksUtils.onViewPage not available, retrying...'); setTimeout(registerViewPageHandler, 1000); return; } // Handler for dashboard and preferences pages window.KefinTweaksUtils.onViewPage(async (view, element, hash) => { // Check if user is admin before adding buttons const userIsAdmin = await isAdmin(); if (!userIsAdmin) { console.log('[KefinTweaks Configuration] User is not admin, not adding buttons'); return; } // Check if we're on the mypreferencesmenu page if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) { console.log('[KefinTweaks Configuration] Preferences page detected'); // Wait a bit for the page to fully load setTimeout(() => { addConfigButtonToAdminSection(); }, 500); } // Check if we're on the dashboard page if (view === 'dashboard' || view === 'configurationpage' || hash.includes('dashboard') || hash.includes('configurationpage')) { console.log('[KefinTweaks Configuration] Dashboard page detected'); // Wait a bit for the page to fully load setTimeout(() => { addConfigButtonToDashboard(); }, 500); } }); console.log('[KefinTweaks Configuration] Registered onViewPage handler'); } // Add configuration link to custom menu async function addConfigLinkToCustomMenu() { // Check if user is admin const userIsAdmin = await isAdmin(); if (!userIsAdmin) { console.log('[KefinTweaks Configuration] User is not admin, not adding menu link'); return; } // Check if utils is available if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) { console.log('[KefinTweaks Configuration] KefinTweaksUtils.addCustomMenuLink not available, retrying...'); setTimeout(addConfigLinkToCustomMenu, 1000); return; } // Admin-only: user menu admin section + default side menu; open via JS action const success = await window.KefinTweaksUtils.addCustomMenuLink( 'KefinTweaks', 'build', '', false, { userMenu: true, isAdminLink: true, action: 'KefinTweaksConfiguration.openConfigurationModal' } ); if (success) { console.log('[KefinTweaks Configuration] Configuration link added to custom menu'); } } // Handle direct navigation to config URL function handleDirectConfigNavigation() { if (window.location.hash === '#kefintweaks-config') { console.log('[KefinTweaks Configuration] Direct navigation to config URL detected'); // Remove the hash to prevent navigation window.history.replaceState(null, '', window.location.pathname + window.location.search); // Open modal openConfigurationModal(); } } // Set up hashchange listener for navigation after page load function setupHashChangeListener() { window.addEventListener('hashchange', () => { if (window.location.hash === '#kefintweaks-config') { console.log('[KefinTweaks Configuration] Hash change to config URL detected'); // Remove the hash to prevent navigation window.history.replaceState(null, '', window.location.pathname + window.location.search); // Open modal openConfigurationModal(); } }); } // Initialize when DOM is ready async function initialize() { if (!window.ApiClient || !window.ApiClient._loggedIn) { setTimeout(initialize, 1000); 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(); // Add configuration link to custom menu addConfigLinkToCustomMenu(); // Set up hashchange listener setupHashChangeListener(); // Handle direct navigation to config URL handleDirectConfigNavigation(); // Also check current page on initial load const hash = window.location.hash; const userIsAdmin = await isAdmin(); if (userIsAdmin) { // User-menu KefinTweaks entry is owned by addCustomMenuLink(userMenu + isAdminLink + action) if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) { setTimeout(() => { addConfigButtonToAdminSection(); }, 1000); } if (hash && hash.includes('dashboard')) { setTimeout(() => { addConfigButtonToDashboard(); }, 1000); } } } // Start initialization if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } console.log('[KefinTweaks Configuration] Script loaded'); })();