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