Files
KefinTweaks/configuration/homeScreen-configuration.js
T

5693 lines
283 KiB
JavaScript

// KefinTweaks Home Screen Configuration UI
// Modular configuration modal for managing home screen sections
// Uses the new HomeScreenSection format with queries array
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks HomeScreen Config]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HomeScreen Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks HomeScreen Config]', ...args);
// Modal IDs
const MAIN_MODAL_ID = 'kefin-homescreen-config-main';
const SECTION_EDITOR_MODAL_ID = 'kefin-homescreen-section-editor';
const DISCOVERY_EDITOR_MODAL_ID = 'kefin-homescreen-discovery-editor';
// Supported additional query options (matching configuration.js)
const SUPPORTED_QUERY_OPTIONS = {
ParentId: { label: 'Parent ID', type: 'string', hint: 'GUID of parent item' },
ExcludeItemIds: { label: 'Exclude Item IDs', type: 'array', hint: 'Comma-separated GUIDs' },
GenreIds: { label: 'Genre IDs', type: 'array', hint: 'Comma-separated GUIDs' },
PersonIds: { label: 'Person IDs', type: 'array', hint: 'Comma-separated GUIDs' },
StudioIds: { label: 'Studio IDs', type: 'array', hint: 'Comma-separated GUIDs' },
Fields: { label: 'Fields', type: 'array', hint: 'Comma-separated field names' },
MinPremiereDate: { label: 'Min Premiere Date', type: 'date', hint: 'YYYY-MM-DD' },
MaxPremiereDate: { label: 'Max Premiere Date', type: 'date', hint: 'YYYY-MM-DD' },
MinDateLastSaved: { label: 'Min Date Last Saved', type: 'date', hint: 'YYYY-MM-DD' },
MinDateLastSavedForUser: { label: 'Min Date Last Saved For User', type: 'date', hint: 'YYYY-MM-DD' },
IndexNumber: { label: 'Index Number', type: 'number', hint: 'Episode/Season index' },
ParentIndexNumber: { label: 'Parent Index Number', type: 'number', hint: 'Season index' },
MinCommunityRating: { label: 'Min Community Rating', type: 'number', hint: '0-10' },
MinCriticRating: { label: 'Min Critic Rating', type: 'number', hint: '0-10' },
IsFavorite: { label: 'Is Favorite', type: 'boolean' },
IsPlayed: { label: 'Is Played', type: 'boolean' },
IsUnplayed: { label: 'Is Unplayed', type: 'boolean' },
IsMissing: { label: 'Is Missing', type: 'boolean' },
IsUnaired: { label: 'Is Unaired', type: 'boolean' },
HasThemeSong: { label: 'Has Theme Song', type: 'boolean' },
HasThemeVideo: { label: 'Has Theme Video', type: 'boolean' },
HasSubtitles: { label: 'Has Subtitles', type: 'boolean' },
HasSpecialFeature: { label: 'Has Special Feature', type: 'boolean' },
HasTrailer: { label: 'Has Trailer', type: 'boolean' },
HasParentalRating: { label: 'Has Parental Rating', type: 'boolean' },
IsHd: { label: 'Is HD', type: 'boolean' },
Is4K: { label: 'Is 4K', type: 'boolean' },
HasOverview: { label: 'Has Overview', type: 'boolean' },
HasOfficialRating: { label: 'Has Official Rating', type: 'boolean' },
Recursive: { label: 'Recursive', type: 'boolean', default: true },
MaxOfficialRating: { label: 'Max Official Rating', type: 'string', hint: 'e.g., PG-13, TV-MA' },
MinOfficialRating: { label: 'Min Official Rating', type: 'string', hint: 'e.g., PG, TV-14' },
NameStartsWith: { label: 'Name Starts With', type: 'string' },
NameStartsWithOrGreater: { label: 'Name Starts With Or Greater', type: 'string' },
NameLessThan: { label: 'Name Less Than', type: 'string' },
LocationTypes: { label: 'Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' },
ExcludeLocationTypes: { label: 'Exclude Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' },
ExcludeItemTypes: { label: 'Exclude Item Types', type: 'array', hint: 'Movie, Series, Episode, etc.' },
ImageTypes: { label: 'Image Types', type: 'array', hint: 'Primary, Backdrop, Thumb, Logo, etc.' },
OfficialRatings: { label: 'Official Ratings', type: 'array', hint: 'PG, PG-13, R, etc.' },
Studios: { label: 'Studios', type: 'array', hint: 'Pipe-delimited names' },
Artists: { label: 'Artists', type: 'array', hint: 'Pipe-delimited names' },
Albums: { label: 'Albums', type: 'array', hint: 'Pipe-delimited names' },
SeriesStatus: { label: 'Series Status', type: 'array', hint: 'Continuing, Ended, Unreleased' },
Years: { label: 'Years', type: 'array', hint: 'Comma-separated years' },
EnableResumable: { label: 'Enable Resumable', type: 'boolean' },
EnableUserData: { label: 'Enable User Data', type: 'boolean' }
};
const CARD_FORMATS = ['Poster', 'Thumb', 'Backdrop', 'Square', 'Random'];
const SORT_ORDERS = ['Random', 'Name', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber'];
const SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending'];
// Current config state
let currentConfig = null;
let mainModalInstance = null;
let currentActiveTab = 'settings'; // Track the currently active tab
/**
* Show toast notification
*/
function showToast(message, duration = '3') {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(message, duration);
} else {
// Fallback to alert if toaster not available
alert(message);
}
}
/**
* Flatten section groups into a flat array of sections
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @returns {Array} Flat array of all sections
*/
function flattenSectionGroups(groups) {
if (!Array.isArray(groups)) return [];
const flattened = [];
groups.forEach(group => {
if (group && Array.isArray(group.sections)) {
flattened.push(...group.sections);
}
});
return flattened;
}
/**
* Get all sections from groups with metadata about which group they belong to
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @returns {Array} Array of objects with section and groupName
*/
function getAllSectionsFromGroups(groups) {
if (!Array.isArray(groups)) return [];
const result = [];
groups.forEach(group => {
if (group && Array.isArray(group.sections)) {
group.sections.forEach(section => {
result.push({
section: section,
groupName: group.name || 'Unnamed Group',
groupAuthor: group.author,
groupDescription: group.description
});
});
}
});
return result;
}
/**
* Render section groups with group-level and section-level checkboxes
* Used by import and export modals to display sections within their groups
* @param {HTMLElement} container - Container element to render into
* @param {Array} sectionGroups - Array of section group objects { name, author?, description?, sections: [] }
* @param {Object} options - Options object
* @param {string} options.checkboxClass - CSS class for section checkboxes (e.g., 'export-custom-check', 'import-custom-check')
* @param {string} options.groupCheckboxClass - CSS class for group checkboxes (optional, defaults to checkboxClass + '-group')
*/
function renderSectionGroupsCheckboxList(container, sectionGroups, options = {}) {
if (!Array.isArray(sectionGroups) || sectionGroups.length === 0) {
container.innerHTML = '<div class="listItemBodyText secondary">No section groups found.</div>';
return;
}
const checkboxClass = options.checkboxClass || 'section-check';
const groupCheckboxClass = options.groupCheckboxClass || checkboxClass + '-group';
let html = '<div style="background: rgba(0,0,0,0.2); border-radius: 4px; padding: 0.5em;">';
sectionGroups.forEach((group, groupIndex) => {
if (!group || !Array.isArray(group.sections) || group.sections.length === 0) {
return;
}
const groupName = group.name || 'Unnamed Group';
// Group checkbox row
html += `
<div style="margin-bottom: 0.5em; padding: 0.5em; background: rgba(255,255,255,0.05); border-radius: 4px;">
<label class="checkboxContainer" style="display: flex; align-items: center; padding: 0.25em 0;">
<input type="checkbox" class="${groupCheckboxClass}" data-group-index="${groupIndex}" checked>
<span class="listItemBodyText" style="margin-left: 0.5em; font-weight: 500;">${groupName}</span>
</label>
<div style="margin-left: 1.5em; margin-top: 0.25em;">
`;
// Section checkboxes (indented)
group.sections.forEach((section, sectionIndex) => {
html += `
<label class="checkboxContainer" style="display: flex; align-items: center; padding: 0.25em 0;">
<input type="checkbox" class="${checkboxClass}" data-group-index="${groupIndex}" data-section-index="${sectionIndex}" checked>
<span class="listItemBodyText" style="margin-left: 0.5em;">${section.name || 'Unnamed Section'}</span>
</label>
`;
});
html += `
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
// Wire up group checkboxes to toggle all section checkboxes in that group
container.querySelectorAll(`.${groupCheckboxClass}`).forEach(groupCheckbox => {
groupCheckbox.addEventListener('change', (e) => {
const groupIndex = parseInt(e.target.dataset.groupIndex);
const isChecked = e.target.checked;
container.querySelectorAll(`.${checkboxClass}[data-group-index="${groupIndex}"]`).forEach(sectionCheckbox => {
sectionCheckbox.checked = isChecked;
});
});
});
}
/**
* Find a section by ID in groups
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} sectionId - Section ID to find
* @returns {Object|null} Object with section, group, and groupIndex, or null if not found
*/
function findSectionInGroups(groups, sectionId) {
if (!Array.isArray(groups)) return null;
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const group = groups[groupIndex];
if (group && Array.isArray(group.sections)) {
const sectionIndex = group.sections.findIndex(s => s.id === sectionId);
if (sectionIndex !== -1) {
return {
section: group.sections[sectionIndex],
group: group,
groupIndex: groupIndex,
sectionIndex: sectionIndex
};
}
}
}
return null;
}
/**
* Find a section by ID across all section groups (HOME, SEASONAL, DISCOVERY, CUSTOM)
* @param {Object} config - Configuration object
* @param {string} sectionId - Section ID to find
* @returns {Object|null} Object with section, group, groupIndex, sectionIndex, and groupType, or null if not found
*/
function findSectionInAllGroups(config, sectionId) {
if (!config) return null;
// Check HOME_SECTION_GROUPS
let found = findSectionInGroups(config.HOME_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'HOME_SECTION_GROUPS';
return found;
}
// Check SEASONAL_SECTION_GROUPS
found = findSectionInGroups(config.SEASONAL_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'SEASONAL_SECTION_GROUPS';
return found;
}
// Check DISCOVERY_SECTION_GROUPS
found = findSectionInGroups(config.DISCOVERY_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'DISCOVERY_SECTION_GROUPS';
return found;
}
// Check CUSTOM_SECTION_GROUPS
found = findSectionInGroups(config.CUSTOM_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'CUSTOM_SECTION_GROUPS';
return found;
}
return null;
}
/**
* Update a section in groups
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} sectionId - Section ID to update
* @param {Object} updatedSection - Updated section data
* @returns {boolean} True if section was found and updated
*/
function updateSectionInGroups(groups, sectionId, updatedSection) {
const found = findSectionInGroups(groups, sectionId);
if (found) {
found.group.sections[found.sectionIndex] = { ...found.section, ...updatedSection };
return true;
}
return false;
}
/**
* Add a section to a group
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} groupName - Name of the group to add to
* @param {Object} section - Section to add
* @param {string} author - Optional author for custom groups
*/
function addSectionToGroup(groups, groupName, section, author) {
if (!Array.isArray(groups)) return;
// Find existing group
let targetGroup = groups.find(g => {
if (author) {
return g.name === groupName && g.author === author;
}
return g.name === groupName && !g.author;
});
// Create group if it doesn't exist
if (!targetGroup) {
targetGroup = {
name: groupName,
sections: []
};
if (author) {
targetGroup.author = author;
}
groups.push(targetGroup);
}
// Add section
if (!targetGroup.sections) {
targetGroup.sections = [];
}
targetGroup.sections.push(section);
// Sort sections by order
targetGroup.sections.sort((a, b) => (a.order || 0) - (b.order || 0));
}
/**
* Remove a section from groups
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} sectionId - Section ID to remove
* @returns {boolean} True if section was found and removed
*/
function removeSectionFromGroups(groups, sectionId) {
if (!Array.isArray(groups)) return false;
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const group = groups[groupIndex];
if (group && Array.isArray(group.sections)) {
const sectionIndex = group.sections.findIndex(s => s.id === sectionId);
if (sectionIndex !== -1) {
group.sections.splice(sectionIndex, 1);
// Remove empty groups (except default groups)
if (group.sections.length === 0 && !group.author) {
groups.splice(groupIndex, 1);
}
return true;
}
}
}
return false;
}
/**
* Verify and sync recently-added library sections with actual Jellyfin libraries
* @param {Object} config - Current configuration object
* @returns {Object} Updated configuration with synced recently-added sections
*/
async function verifyRecentlyAddedInLibraryConfig(config) {
try {
if (!window.dataHelper || !window.dataHelper.getLibraries) {
WARN('dataHelper.getLibraries not available, skipping recently-added verification');
return config;
}
// Get current libraries
const libraries = await window.dataHelper.getLibraries();
if (!libraries || libraries.length === 0) {
LOG('No libraries found, skipping recently-added verification');
return config;
}
// Flatten groups to get all sections
const allSections = flattenSectionGroups(config.HOME_SECTION_GROUPS || []);
// Extract existing recently-added sections
const recentlyAddedSections = allSections.filter(s => s.id && s.id.startsWith('recently-added-'));
const existingLibraryIds = new Set(recentlyAddedSections.map(s => s.id.replace('recently-added-', '')));
// Get current library IDs
const currentLibraryIds = new Set(libraries.map(l => l.Id));
// Find sections to remove (libraries that no longer exist)
const sectionsToRemove = recentlyAddedSections.filter(s => {
const libId = s.id.replace('recently-added-', '');
return !currentLibraryIds.has(libId);
});
// Find libraries to add (new libraries not in config)
const librariesToAdd = libraries.filter(l => !existingLibraryIds.has(l.Id) && l.CollectionType && l.CollectionType !== 'boxsets' && l.CollectionType !== 'playlists');
if (sectionsToRemove.length === 0 && librariesToAdd.length === 0) {
LOG('Recently-added sections are in sync with libraries');
return config;
}
LOG(`Syncing recently-added sections: Removing ${sectionsToRemove.length}, Adding ${librariesToAdd.length}`);
// Remove sections for non-existent libraries
const sectionsToRemoveIds = new Set(sectionsToRemove.map(s => s.id));
// Update groups by removing sections
const updatedGroups = (config.HOME_SECTION_GROUPS || []).map(group => {
if (!group.sections) return group;
const filteredSections = group.sections.filter(s => !sectionsToRemoveIds.has(s.id));
return {
...group,
sections: filteredSections
};
});
// Add sections for new libraries
librariesToAdd.forEach(library => {
let viewMoreUrl = null;
if (library.CollectionType === 'movies') {
viewMoreUrl = `#/movies.html?topParentId=${library.Id}&collectionType=movies&tab=1`;
} else if (library.CollectionType === 'tvshows') {
viewMoreUrl = `#/tv.html?topParentId=${library.Id}&collectionType=tvshows&tab=1`;
}
const newSection = {
id: `recently-added-${library.Id}`,
name: `Recently Added ${library.Name}`,
enabled: false, // Default to disabled
order: 61,
cardFormat: 'Poster',
viewMoreUrl: viewMoreUrl,
queries: [{
path: '/Items/Latest',
queryOptions: {
ParentId: library.Id,
SortBy: 'DateCreated',
SortOrder: 'Descending',
Limit: 16
}
}]
};
// Find or create "Recently Added" group
let recentlyAddedGroup = updatedGroups.find(g => g.name === 'Recently Added');
if (!recentlyAddedGroup) {
recentlyAddedGroup = {
name: 'Recently Added',
sections: []
};
updatedGroups.push(recentlyAddedGroup);
}
recentlyAddedGroup.sections.push(newSection);
});
// Update config
config.HOME_SECTION_GROUPS = updatedGroups;
LOG('Recently-added sections synced successfully');
return config;
} catch (error) {
ERR('Error verifying recently-added library config:', error);
return config;
}
}
/**
* Load configuration from JS Injector and merge with defaults
*/
async function loadConfig() {
try {
// Get existing config from window.KefinTweaksConfig
const existingConfig = (window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig) || {};
// Get defaults from homeScreenConfig2.js
const defaults = window.KefinHomeConfig2;
if (!defaults) {
throw new Error('KefinHomeConfig2 not found. Ensure homeScreenConfig2.js is loaded.');
}
// Merge configs - now using 4 groups
const mergedConfig = {
HOME_SECTION_GROUPS: mergeGroupDefaults(
defaults.HOME_SECTION_GROUPS || [],
existingConfig.HOME_SECTION_GROUPS || [],
'home'
),
SEASONAL_SECTION_GROUPS: mergeGroupDefaults(
defaults.SEASONAL_SECTION_GROUPS || [],
existingConfig.SEASONAL_SECTION_GROUPS || [],
'seasonal'
),
DISCOVERY_SECTION_GROUPS: mergeGroupDefaults(
defaults.DISCOVERY_SECTION_GROUPS || [],
existingConfig.DISCOVERY_SECTION_GROUPS || [],
'discovery'
),
CUSTOM_SECTION_GROUPS: mergeGroupDefaults(
defaults.CUSTOM_SECTION_GROUPS || [],
existingConfig.CUSTOM_SECTION_GROUPS || [],
'custom'
),
DISCOVERY_SETTINGS: { ...defaults.DISCOVERY_SETTINGS, ...(existingConfig.DISCOVERY_SETTINGS || {}) },
SEASONAL_THEME_SETTINGS: { ...defaults.SEASONAL_THEME_SETTINGS, ...(existingConfig.SEASONAL_THEME_SETTINGS || {}) },
CACHE: { ...defaults.CACHE, ...(existingConfig.CACHE || {}) },
MERGE_NEXT_UP: existingConfig.MERGE_NEXT_UP ?? defaults.MERGE_NEXT_UP ?? false
};
// Verify and sync recently-added library sections
await verifyRecentlyAddedInLibraryConfig(mergedConfig);
currentConfig = mergedConfig;
return mergedConfig;
} catch (error) {
ERR('Error loading config:', error);
throw error;
}
}
/**
* Get merged home screen config (exported for use by other scripts)
* @returns {Promise<Object>} Merged configuration object
*/
async function getConfig() {
return await loadConfig();
}
/**
* Merge default groups with admin overrides
* @param {Array} defaultGroups - Array of default HomeScreenSectionGroup objects
* @param {Array} overrideGroups - Array of override HomeScreenSectionGroup objects
* @param {string} sectionType - Type of section to merge (home, seasonal, discovery, custom)
* @returns {Array} Merged groups
*/
function mergeGroupDefaults(defaultGroups, overrideGroups, sectionType = 'home') {
const merged = [];
const overrideGroupMapById = new Map();
const overrideGroupMapByName = new Map();
const overrideGroupByOriginalName = new Map();
// Create maps of override groups by id, name/author, and _originalName
overrideGroups.forEach(overrideGroup => {
// Map by id (preferred method)
if (overrideGroup.id) {
overrideGroupMapById.set(overrideGroup.id, overrideGroup);
}
// Map by name/author (fallback for backward compatibility)
const nameKey = overrideGroup.author ? `${overrideGroup.name}::${overrideGroup.author}` : overrideGroup.name || 'Unnamed';
overrideGroupMapByName.set(nameKey, overrideGroup);
// Also create a map by _originalName if it exists (for renamed default groups)
if (overrideGroup._originalName) {
const originalKey = overrideGroup.author ? `${overrideGroup._originalName}::${overrideGroup.author}` : overrideGroup._originalName || 'Unnamed';
overrideGroupByOriginalName.set(originalKey, overrideGroup);
}
});
// Merge default groups with overrides
defaultGroups.forEach(defaultGroup => {
let overrideGroup = null;
let matchedBy = null;
// Try to match by id first (preferred)
if (defaultGroup.id) {
overrideGroup = overrideGroupMapById.get(defaultGroup.id);
if (overrideGroup) {
matchedBy = 'id';
}
}
// If no match by id, try matching by name/author (backward compatibility)
if (!overrideGroup) {
const nameKey = defaultGroup.author ? `${defaultGroup.name}::${defaultGroup.author}` : defaultGroup.name || 'Unnamed';
overrideGroup = overrideGroupMapByName.get(nameKey);
if (overrideGroup) {
matchedBy = 'name';
}
}
// If still no match, try matching by _originalName
if (!overrideGroup) {
const nameKey = defaultGroup.author ? `${defaultGroup.name}::${defaultGroup.author}` : defaultGroup.name || 'Unnamed';
overrideGroup = overrideGroupByOriginalName.get(nameKey);
if (overrideGroup) {
matchedBy = 'originalName';
}
}
if (overrideGroup) {
// Merge sections within the group
const mergedSections = mergeSectionsInGroup(
defaultGroup.sections || [],
overrideGroup.sections || []
);
merged.push({
...defaultGroup,
...overrideGroup,
sections: mergedSections
});
// Remove from maps using the keys that were used to store them
if (overrideGroup.id && matchedBy === 'id') {
overrideGroupMapById.delete(overrideGroup.id);
}
const currentNameKey = overrideGroup.author ? `${overrideGroup.name}::${overrideGroup.author}` : overrideGroup.name || 'Unnamed';
overrideGroupMapByName.delete(currentNameKey);
if (overrideGroup._originalName) {
const originalNameKey = overrideGroup.author ? `${overrideGroup._originalName}::${overrideGroup.author}` : overrideGroup._originalName || 'Unnamed';
overrideGroupByOriginalName.delete(originalNameKey);
}
} else {
merged.push(defaultGroup);
}
});
// Add any custom groups (not in defaults)
overrideGroupMapById.forEach(overrideGroup => {
merged.push(overrideGroup);
});
overrideGroupMapByName.forEach(overrideGroup => {
// Only add if not already added via id map
if (!overrideGroup.id || !overrideGroupMapById.has(overrideGroup.id)) {
merged.push(overrideGroup);
}
});
// Add the type to each section in the merged sections
merged.forEach(group => {
group.sections.forEach(section => {
section.type = sectionType;
});
});
return merged;
}
/**
* Merge sections within a group
* @param {Array} defaultSections - Default sections
* @param {Array} overrideSections - Override sections
* @returns {Array} Merged sections
*/
function mergeSectionsInGroup(defaultSections, overrideSections) {
const merged = [];
const overrideMap = new Map();
// Create map of overrides by id
overrideSections.forEach(override => {
overrideMap.set(override.id, override);
});
// Merge defaults with overrides
defaultSections.forEach(defaultSection => {
const override = overrideMap.get(defaultSection.id);
if (override) {
// Deep merge: override takes precedence but preserve queries structure
merged.push({
...defaultSection,
...override,
queries: override.queries || defaultSection.queries || []
});
overrideMap.delete(defaultSection.id);
} else {
merged.push(defaultSection);
}
});
// Add any custom sections (not in defaults)
overrideMap.forEach(override => {
merged.push(override);
});
// Sort by order
return merged.sort((a, b) => (a.order || 0) - (b.order || 0));
}
/**
* Get saved config from JS Injector
* @returns {Promise<Object>} Saved full KefinTweaksConfig object
*/
async function getSavedConfig() {
try {
if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
WARN('ApiClient not available, using window.KefinTweaksConfig as fallback');
return window.KefinTweaksConfig || {};
}
const server = window.ApiClient._serverAddress;
const token = window.ApiClient.accessToken();
// Get plugins list
const pluginsResponse = await fetch(`${server}/Plugins`, {
headers: {
'X-Emby-Token': token
}
});
if (!pluginsResponse.ok) {
throw new Error(`Failed to get plugins: ${pluginsResponse.status} ${pluginsResponse.statusText}`);
}
const pluginsData = await pluginsResponse.json();
const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []);
// Find JavaScript Injector plugin
const injectorPlugin = pluginsList.find(plugin =>
plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector'
);
if (!injectorPlugin) {
WARN('JavaScript Injector plugin not found, using window.KefinTweaksConfig as fallback');
return window.KefinTweaksConfig || {};
}
// Get injector config
const configResponse = await fetch(`${server}/Plugins/${injectorPlugin.Id}/Configuration`, {
headers: {
'X-Emby-Token': token
}
});
if (!configResponse.ok) {
throw new Error(`Failed to get plugin config: ${configResponse.status} ${configResponse.statusText}`);
}
const injectorConfig = await configResponse.json();
// Find KefinTweaks-Config script
const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find(
script => script.Name === 'KefinTweaks-Config'
);
if (kefinTweaksScript && kefinTweaksScript.Script) {
// Extract config from script content
const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/);
if (scriptMatch && scriptMatch[1]) {
try {
const config = JSON.parse(scriptMatch[1]);
return config || {};
} catch (parseError) {
ERR('Error parsing config from script:', parseError);
}
}
}
// Fallback: use window.KefinTweaksConfig if available
return window.KefinTweaksConfig || {};
} catch (error) {
ERR('Error getting saved config:', error);
// Fallback: use window.KefinTweaksConfig if available
return window.KefinTweaksConfig || {};
}
}
/**
* Deep merge queryOptions objects, preserving properties from savedQuery
* @param {Object} savedQuery - Query from saved config
* @param {Object} currentQuery - Query from current config
* @returns {Object} Merged query object
*/
function mergeQueryOptions(savedQuery, currentQuery) {
if (!savedQuery && !currentQuery) return {};
if (!savedQuery) return JSON.parse(JSON.stringify(currentQuery));
if (!currentQuery) return JSON.parse(JSON.stringify(savedQuery));
const merged = JSON.parse(JSON.stringify(savedQuery));
// Deep merge queryOptions
if (currentQuery.queryOptions) {
merged.queryOptions = {
...(savedQuery.queryOptions || {}),
...currentQuery.queryOptions
};
} else if (savedQuery.queryOptions) {
merged.queryOptions = JSON.parse(JSON.stringify(savedQuery.queryOptions));
}
// Merge other query-level properties (current takes precedence)
Object.keys(currentQuery).forEach(key => {
if (key !== 'queryOptions') {
merged[key] = currentQuery[key];
}
});
return merged;
}
/**
* Merge queries arrays by index, preserving queryOptions from saved queries
* @param {Array} savedQueries - Queries from saved config
* @param {Array} currentQueries - Queries from current config
* @returns {Array} Merged queries array
*/
function mergeQueries(savedQueries, currentQueries) {
if (!savedQueries || savedQueries.length === 0) {
return currentQueries ? JSON.parse(JSON.stringify(currentQueries)) : [];
}
if (!currentQueries || currentQueries.length === 0) {
return JSON.parse(JSON.stringify(savedQueries));
}
const merged = [];
const maxLength = Math.max(savedQueries.length, currentQueries.length);
for (let i = 0; i < maxLength; i++) {
const savedQuery = savedQueries[i];
const currentQuery = currentQueries[i];
if (savedQuery && currentQuery) {
// Both exist - merge them
merged.push(mergeQueryOptions(savedQuery, currentQuery));
} else if (currentQuery) {
// Only current exists - use it
merged.push(JSON.parse(JSON.stringify(currentQuery)));
} else if (savedQuery) {
// Only saved exists - preserve it
merged.push(JSON.parse(JSON.stringify(savedQuery)));
}
}
return merged;
}
/**
* Merge section objects, preserving properties from savedSection
* @param {Object} savedSection - Section from saved config
* @param {Object} currentSection - Section from current config
* @returns {Object} Merged section object
*/
function mergeSection(savedSection, currentSection) {
if (!savedSection && !currentSection) return null;
if (!savedSection) return JSON.parse(JSON.stringify(currentSection));
if (!currentSection) return JSON.parse(JSON.stringify(savedSection));
const merged = JSON.parse(JSON.stringify(savedSection));
// Merge section properties (current takes precedence for editable fields)
Object.keys(currentSection).forEach(key => {
if (key === 'queries') {
// Special handling for queries array
merged.queries = mergeQueries(savedSection.queries, currentSection.queries);
} else {
// For other properties, current config takes precedence
merged[key] = currentSection[key];
}
});
// Ensure id is preserved from currentSection
if (currentSection.id) {
merged.id = currentSection.id;
}
return merged;
}
/**
* Merge group objects, matching sections by id
* @param {Object} savedGroup - Group from saved config
* @param {Object} currentGroup - Group from current config
* @returns {Object} Merged group object
*/
function mergeGroup(savedGroup, currentGroup) {
if (!savedGroup && !currentGroup) return null;
if (!savedGroup) return JSON.parse(JSON.stringify(currentGroup));
if (!currentGroup) return JSON.parse(JSON.stringify(savedGroup));
const merged = JSON.parse(JSON.stringify(savedGroup));
// Update group properties from current (name, author, etc.)
Object.keys(currentGroup).forEach(key => {
if (key !== 'sections') {
merged[key] = currentGroup[key];
}
});
// Merge sections by id
const mergedSections = [];
const savedSections = savedGroup.sections || [];
const currentSections = currentGroup.sections || [];
const savedSectionMap = new Map();
// Create map of saved sections by id
savedSections.forEach(section => {
if (section.id && !section.deleted) {
savedSectionMap.set(section.id, section);
}
});
// Process current sections (merge or add)
currentSections.forEach(currentSection => {
if (currentSection.deleted) {
// Skip deleted sections from current config
return;
}
const savedSection = savedSectionMap.get(currentSection.id);
if (savedSection) {
// Section exists in both - merge them
mergedSections.push(mergeSection(savedSection, currentSection));
savedSectionMap.delete(currentSection.id);
} else {
// New section - add it
mergedSections.push(JSON.parse(JSON.stringify(currentSection)));
}
});
// Add any remaining saved sections that weren't in current (preserve them)
savedSectionMap.forEach(savedSection => {
if (!savedSection.deleted) {
mergedSections.push(JSON.parse(JSON.stringify(savedSection)));
}
});
merged.sections = mergedSections;
return merged;
}
/**
* Merge home screen group configs, matching groups by id
* @param {Array} currentGroups - Groups from current config
* @param {Array} savedGroups - Groups from saved config
* @returns {Array} Merged groups array
*/
function mergeHomeScreenGroupConfig(currentGroups, savedGroups) {
if (!currentGroups || currentGroups.length === 0) {
// Filter out deleted groups from saved config
return (savedGroups || []).filter(g => !g.deleted).map(g => JSON.parse(JSON.stringify(g)));
}
if (!savedGroups || savedGroups.length === 0) {
return JSON.parse(JSON.stringify(currentGroups));
}
const merged = [];
const savedGroupMap = new Map();
// Create map of saved groups by id (with fallback to name for backward compatibility)
savedGroups.forEach(group => {
if (group.deleted) return;
const key = group.id || group.name;
if (key) {
savedGroupMap.set(key, group);
}
});
// Process current groups (merge or add)
currentGroups.forEach(currentGroup => {
if (currentGroup.deleted) {
// Skip deleted groups from current config
return;
}
const key = currentGroup.id || currentGroup.name;
const savedGroup = key ? savedGroupMap.get(key) : null;
if (savedGroup) {
// Group exists in both - merge them
merged.push(mergeGroup(savedGroup, currentGroup));
savedGroupMap.delete(key);
} else {
// New group - add it
merged.push(JSON.parse(JSON.stringify(currentGroup)));
}
});
// Add any remaining saved groups that weren't in current (preserve them)
savedGroupMap.forEach(savedGroup => {
if (!savedGroup.deleted) {
merged.push(JSON.parse(JSON.stringify(savedGroup)));
}
});
return merged;
}
/**
* Save configuration to JS Injector
* Merges partial configs with saved config to preserve existing data
*/
async function saveConfig(config) {
try {
// Load saved config from JS Injector
const savedConfig = await getSavedConfig();
const savedHomeScreenConfig = savedConfig.homeScreenConfig || {};
// Create merged config
const mergedHomeScreenConfig = {
// Merge default groups (HOME, SEASONAL, DISCOVERY) using deep merge
HOME_SECTION_GROUPS: mergeHomeScreenGroupConfig(
config.HOME_SECTION_GROUPS || [],
savedHomeScreenConfig.HOME_SECTION_GROUPS || []
),
SEASONAL_SECTION_GROUPS: mergeHomeScreenGroupConfig(
config.SEASONAL_SECTION_GROUPS || [],
savedHomeScreenConfig.SEASONAL_SECTION_GROUPS || []
),
DISCOVERY_SECTION_GROUPS: mergeHomeScreenGroupConfig(
config.DISCOVERY_SECTION_GROUPS || [],
savedHomeScreenConfig.DISCOVERY_SECTION_GROUPS || []
),
// Custom groups are replaced entirely (UI is source of truth)
CUSTOM_SECTION_GROUPS: config.CUSTOM_SECTION_GROUPS || [],
// Merge other top-level properties (shallow merge)
DISCOVERY_SETTINGS: {
...(savedHomeScreenConfig.DISCOVERY_SETTINGS || {}),
...(config.DISCOVERY_SETTINGS || {})
},
SEASONAL_THEME_SETTINGS: {
...(savedHomeScreenConfig.SEASONAL_THEME_SETTINGS || {}),
...(config.SEASONAL_THEME_SETTINGS || {})
},
CACHE: {
...(savedHomeScreenConfig.CACHE || {}),
...(config.CACHE || {})
},
MERGE_NEXT_UP: config.MERGE_NEXT_UP !== undefined ? config.MERGE_NEXT_UP : (savedHomeScreenConfig.MERGE_NEXT_UP !== undefined ? savedHomeScreenConfig.MERGE_NEXT_UP : false)
};
// Ensure window.KefinTweaksConfig exists
if (!window.KefinTweaksConfig) {
window.KefinTweaksConfig = {};
}
// Update homeScreenConfig with merged result
window.KefinTweaksConfig.homeScreenConfig = mergedHomeScreenConfig;
currentConfig = mergedHomeScreenConfig;
// Merge other top-level config properties (preserve existing)
Object.keys(savedConfig).forEach(key => {
if (key !== 'homeScreenConfig' && !window.KefinTweaksConfig.hasOwnProperty(key)) {
window.KefinTweaksConfig[key] = savedConfig[key];
}
});
// Save to JS Injector using utils
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
await window.KefinTweaksUtils.saveConfigToJavaScriptInjector();
LOG('Configuration saved to JS Injector');
return true;
} else {
// Fallback: try to use configuration.js functions
if (typeof window.KefinTweaksConfiguration !== 'undefined' && window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector) {
await window.KefinTweaksConfiguration.saveConfigToJavaScriptInjector(window.KefinTweaksConfig);
LOG('Configuration saved to JS Injector (fallback)');
return true;
}
WARN('saveConfigToJavaScriptInjector not available');
return false;
}
} catch (error) {
ERR('Error saving config:', error);
return false;
}
}
/**
* Build Jellyfin checkbox HTML
*/
function buildCheckbox(id, checked, label) {
return `
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="${id}" ${checked ? 'checked' : ''}>
<span class="listItemBodyText">${label}</span>
</label>
`;
}
/**
* Build Jellyfin select/dropdown HTML
*/
function buildSelect(id, options, selectedValue, label) {
const optionsHTML = options.map(opt => {
const value = typeof opt === 'string' ? opt : opt.value;
const label = typeof opt === 'string' ? opt : opt.label;
const selected = value === selectedValue ? 'selected' : '';
return `<option value="${value}" ${selected}>${label}</option>`;
}).join('');
return `
<div style="margin-bottom: 1em;">
<label class="listItemBodyText" for="${id}" style="display: block; margin-bottom: 0.25em;">${label}</label>
<select id="${id}" class="fld emby-select emby-select-withcolor">
${optionsHTML}
</select>
</div>
`;
}
/**
* Build Jellyfin text input HTML
*/
function buildTextInput(id, value, label, type = 'text', placeholder = '') {
return `
<div style="margin-bottom: 1em;">
<label class="listItemBodyText" for="${id}" style="display: block; margin-bottom: 0.25em;">${label}</label>
<input type="${type}" id="${id}" class="fld emby-input" value="${value || ''}" placeholder="${placeholder || ''}">
</div>
`;
}
/**
* Build enabled toggle buttons (ENABLED/DISABLED)
*/
function buildEnabledToggleButtons(id, enabled) {
const isEnabled = enabled !== false;
return `
<div style="margin-bottom: 1.5em; display: flex; gap: 0.5em;">
<button type="button" class="enabled-toggle-btn ${isEnabled ? 'active' : ''}" data-enabled="true" style="
flex: 1;
padding: 0.75em 1.5em;
border: 2px solid ${isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(255,255,255,0.2)'};
border-radius: 4px;
background: ${isEnabled ? 'rgba(0, 164, 220, 0.2)' : 'transparent'};
color: ${isEnabled ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'};
font-weight: ${isEnabled ? '600' : '400'};
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
">
Enabled
</button>
<button type="button" class="enabled-toggle-btn ${!isEnabled ? 'active' : ''}" data-enabled="false" style="
flex: 1;
padding: 0.75em 1.5em;
border: 2px solid ${!isEnabled ? 'rgba(158, 158, 158, 0.8)' : 'rgba(255,255,255,0.2)'};
border-radius: 4px;
background: ${!isEnabled ? 'rgba(158, 158, 158, 0.2)' : 'transparent'};
color: ${!isEnabled ? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.7)'};
font-weight: ${!isEnabled ? '600' : '400'};
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
">
Disabled
</button>
</div>
`;
}
/**
* Build toggle slider (ON/OFF switch) - generic function for both settings and sections
* @param {string} id - ID for the toggle (used for hidden checkbox if includeHiddenCheckbox is true)
* @param {boolean} checked - Initial checked/enabled state
* @param {string} label - Label text (only used if wrapInLabel is true)
* @param {object} options - Configuration options
* @param {boolean} options.includeHiddenCheckbox - Whether to include hidden checkbox (for settings)
* @param {object} options.dataAttributes - Data attributes object (e.g., {sectionId: '...', sectionType: '...'})
* @param {string} options.cssClass - CSS class for toggle button (default: 'toggle-slider')
* @param {boolean} options.wrapInLabel - Whether to wrap in label container (default: true)
*/
function buildToggleSlider(id, checked, label, options = {}) {
const {
includeHiddenCheckbox = false,
dataAttributes = {},
cssClass = 'toggle-slider',
wrapInLabel = true
} = options;
const isEnabled = checked !== false;
const dataAttrsString = Object.entries(dataAttributes)
.map(([key, value]) => `data-${key}="${value}"`)
.join(' ');
const toggleButton = `
<button type="button" class="${cssClass}" ${dataAttrsString} ${includeHiddenCheckbox ? `data-checkbox-id="${id}"` : ''} data-enabled="${isEnabled}" style="
position: relative;
width: 60px;
height: 28px;
border-radius: 14px;
border: none;
background: ${isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'};
cursor: pointer;
transition: background-color 0.3s ease;
flex-shrink: 0;
padding: 0;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
">
<span style="
position: absolute;
left: ${isEnabled ? '8px' : '6px'};
font-size: 10px;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
opacity: ${isEnabled ? '1' : '0'};
z-index: 1;
pointer-events: none;
">ON</span>
<span style="
position: absolute;
right: ${isEnabled ? '6px' : '8px'};
font-size: 10px;
font-weight: 600;
color: rgba(255,255,255,0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
opacity: ${isEnabled ? '0' : '1'};
z-index: 1;
pointer-events: none;
">OFF</span>
<span style="
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
left: ${isEnabled ? 'calc(100% - 26px)' : '3px'};
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
z-index: 2;
pointer-events: none;
"></span>
</button>
`;
const hiddenCheckbox = includeHiddenCheckbox ? `
<input type="checkbox" id="${id}" ${isEnabled ? 'checked' : ''} style="display: none;">
` : '';
if (wrapInLabel) {
return `
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
${hiddenCheckbox}
${toggleButton}
<span class="listItemBodyText">${label}</span>
</label>
`;
} else {
return hiddenCheckbox + toggleButton;
}
}
/**
* Update toggle slider UI programmatically
* @param {HTMLElement} toggleButton - The toggle button element
* @param {boolean} isEnabled - New enabled state
*/
function updateToggleSliderUI(toggleButton, isEnabled) {
if (!toggleButton) return;
toggleButton.dataset.enabled = isEnabled;
toggleButton.style.background = isEnabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)';
// Update ON text
const onText = toggleButton.querySelector('span:first-of-type');
if (onText) {
onText.style.opacity = isEnabled ? '1' : '0';
onText.style.left = isEnabled ? '8px' : '6px';
}
// Update OFF text
const offText = toggleButton.querySelector('span:nth-of-type(2)');
if (offText) {
offText.style.opacity = isEnabled ? '0' : '1';
offText.style.right = isEnabled ? '6px' : '8px';
}
// Update knob position
const knob = toggleButton.querySelector('span:last-of-type');
if (knob) {
knob.style.left = isEnabled ? 'calc(100% - 26px)' : '3px';
}
// Update hidden checkbox if present
const checkboxId = toggleButton.dataset.checkboxId;
if (checkboxId) {
const checkbox = document.getElementById(checkboxId);
if (checkbox) {
checkbox.checked = isEnabled;
}
}
}
/**
* Build filter by played status toggle button
*/
function buildFilterByPlayedStatusToggle(id, checked) {
const isChecked = checked === true;
return `
<div style="margin-bottom: 1em;">
<label class="listItemBodyText" style="display: block; margin-bottom: 0.5em;">Hide Watched Items</label>
<button type="button" class="filter-played-toggle" data-checked="${isChecked}" style="
position: relative;
width: 60px;
height: 28px;
border-radius: 14px;
border: none;
background: ${isChecked ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)'};
cursor: pointer;
transition: background-color 0.3s ease;
padding: 0;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
">
<span style="
position: absolute;
left: ${isChecked ? '8px' : '6px'};
font-size: 10px;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
opacity: ${isChecked ? '1' : '0'};
z-index: 1;
">ON</span>
<span style="
position: absolute;
right: ${isChecked ? '6px' : '8px'};
font-size: 10px;
font-weight: 600;
color: rgba(255,255,255,0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
opacity: ${isChecked ? '0' : '1'};
z-index: 1;
">OFF</span>
<span style="
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: white;
left: ${isChecked ? 'calc(100% - 26px)' : '3px'};
transition: left 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
z-index: 2;
"></span>
</button>
</div>
`;
}
/**
* Build compact section row HTML
*/
function buildSectionRowHTML(section, type = 'home', groupName = null) {
const isEnabled = section.enabled !== false;
const isSeasonal = section.startDate && section.endDate;
const isSpotlight = section.renderMode === 'Spotlight';
const isDiscovery = section.discoveryEnabled === true;
const isCustom = type.toLowerCase() === 'custom' || section.id.startsWith('custom');
return `
<div class="listItem section-row" data-section-id="${section.id}" data-section-type="${type}" draggable="true" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 0.5em; display: flex; align-items: center; justify-content: space-between; cursor: pointer; transition: background-color 0.2s;" onmouseover="this.style.backgroundColor='rgba(255,255,255,0.05)'" onmouseout="this.style.backgroundColor=''">
<div style="flex: 1; display: flex; align-items: center; gap: 1em;">
<!-- Toggle Switch -->
${buildToggleSlider('', isEnabled, '', {
dataAttributes: { 'section-id': section.id, 'section-type': type },
cssClass: 'toggle-slider section-toggle-switch',
wrapInLabel: false
})}
<!-- Section Info -->
<div style="flex: 1;">
<input type="text" class="listItemBodyText editable-section-name" data-section-id="${section.id}" value="${(section.name || 'Unnamed Section').replace(/"/g, '&quot;')}" size="${Math.max((section.name || 'Unnamed Section').length, 15)}" style="font-weight: 500; margin-bottom: 0.25em; cursor: text; padding: 0.25em 0.5em; border-radius: 3px; transition: background-color 0.2s; display: inline-block; border: none; outline: none; background: transparent; font-family: inherit; font-size: inherit; color: inherit;" onmouseover="this.style.backgroundColor='rgba(255,255,255,0.05)'" onmouseout="this.style.backgroundColor='transparent'" onfocus="this.style.backgroundColor='rgba(255,255,255,0.1)'" onblur="this.style.backgroundColor='transparent'" onclick="event.stopPropagation();" oninput="this.size=Math.max(this.value.length,15)" />
<div style="display: flex; gap: 1em; align-items: center; flex-wrap: wrap;">
${groupName ? `<span class="listItemBodyText secondary" style="font-size: 0.8em; background: rgba(255,255,255,0.1); padding: 0.2em 0.5em; border-radius: 3px;">${groupName}</span>` : ''}
<span class="listItemBodyText secondary" style="font-size: 0.85em;">Order: ${section.order || 0}</span>
${section.cardFormat && !isSpotlight ? `<span class="listItemBodyText secondary" style="font-size: 0.85em;">${section.cardFormat}</span>` : ''}
${isSpotlight ? `<span class="listItemBodyText secondary spotlight-tag" style="font-size: 0.85em;">Spotlight</span>` : ''}
${isSeasonal ? `<span class="listItemBodyText secondary seasonal-tag" style="font-size: 0.85em;">Active from: ${section.startDate} - ${section.endDate}</span>` : ''}
${isDiscovery ? `<span class="listItemBodyText secondary discovery-tag" style="font-size: 0.85em;">Discovery</span>` : ''}
</div>
</div>
</div>
<div style="display: flex; gap: 0.5em; align-items: center;">
<button type="button" class="emby-button section-preview-btn raised" data-section-id="${section.id}" data-section-type="${type}" style="padding: 0.5em 1em; background: rgba(0, 164, 220, 0.2); opacity: 0; transition: opacity 0.2s; pointer-events: none;">
<span>Preview</span>
</button>
<button type="button" class="emby-button section-edit-btn raised" data-section-id="${section.id}" data-section-type="${type}" style="padding: 0.5em 1em; opacity: 0; transition: opacity 0.2s; pointer-events: none;">
<span>Edit</span>
</button>
${isCustom ? `
<button type="button" class="emby-button section-delete-btn raised" data-section-id="${section.id}" style="padding: 0.5em 1em; background: rgba(244, 67, 54, 0.2); opacity: 0; transition: opacity 0.2s; pointer-events: none;">
<span>Delete</span>
</button>
` : ''}
</div>
</div>
`;
}
/**
* Check if device is mobile
*/
function isMobile() {
return window.innerWidth <= 768;
}
function toggleSectionType(e) {
const sectionType = e.target.dataset.sectionType;
const groupContainer = e.target.closest('.section-group');
if (!groupContainer) return;
// Get data-section-type-sectionType attribute from the group container
const sectionTypeAttribute = groupContainer.getAttribute(`data-section-type-${sectionType}`);
const newState = sectionTypeAttribute === 'false' ? 'true' : 'false';
if (newState === 'true') {
e.target.classList.add('button-submit');
} else {
e.target.classList.remove('button-submit');
}
groupContainer.setAttribute(`data-section-type-${sectionType}`, newState);
}
function renderSectionTypeToggles(sectionTypes) {
const sectionTypeTogglesContainer = document.createElement('div');
sectionTypeTogglesContainer.style.display = 'flex';
sectionTypeTogglesContainer.style.gap = '0.5em';
for (const type of sectionTypes) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = type;
button.classList.add('emby-button');
button.classList.add('section-type-toggle-btn');
button.classList.add('raised');
button.classList.add('block');
button.classList.add('button-submit');
button.dataset.sectionType = type;
button.style.padding = '0.5em 1em';
button.style.fontSize = '0.9em';
// Event handler is attached via event delegation in attachMainModalListeners
sectionTypeTogglesContainer.appendChild(button);
}
return sectionTypeTogglesContainer.outerHTML;
}
/**
* Build section navigation HTML (left column)
*/
function buildSectionNavigationHTML(activeType = 'home') {
const types = [
{ id: 'home', label: 'Home Sections' },
{ id: 'seasonal', label: 'Seasonal Sections' },
{ id: 'discovery', label: 'Discovery Sections' },
{ id: 'custom', label: 'Custom Sections' },
{ id: 'create', label: 'Create New Section' }
];
return `
<div style="display: flex; flex-direction: column; gap: 0.5em;">
${types.map(type => `
<button type="button"
class="section-type-nav-btn ${activeType === type.id ? 'active' : ''}"
data-section-type="${type.id}"
style="padding: 0.75em 1em; text-align: left; background: ${activeType === type.id ? 'rgba(0, 164, 220, 0.2)' : 'rgba(255,255,255,0.05)'}; border: 1px solid ${activeType === type.id ? 'rgba(0, 164, 220, 0.5)' : 'rgba(255,255,255,0.1)'}; border-radius: 4px; color: ${activeType === type.id ? 'var(--theme-primary-color, #00a4dc)' : 'inherit'}; cursor: pointer; transition: all 0.2s;">
<span class="listItemBodyText" style="font-weight: ${activeType === type.id ? '500' : '400'};">${type.label}</span>
</button>
`).join('')}
</div>
`;
}
/**
* Build group HTML with sections and toggle-all button
*/
function buildGroupHTML(group, sectionType) {
let sections = group.sections || [];
if (sections.length === 0) return '';
sections = sections.filter(s => s.hidden !== true);
const allEnabled = sections.every(s => s.enabled !== false);
const groupId = `group-${group.name || 'unnamed'}-${group.author || ''}`.replace(/[^a-zA-Z0-9-]/g, '-');
// Get unique section types
const sectionTypes = [...new Set(sections.map(s => s.sectionType))];
return `
<div class="section-group" data-group-id="${groupId}" data-group-name="${group.name || 'Unnamed Group'}" style="margin-bottom: 1.5em; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 1em;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75em;">
<div>
<input type="text" class="listItemBodyText editable-group-name" data-group-id="${groupId}" value="${(group.name || 'Unnamed Group').replace(/"/g, '&quot;')}" size="${Math.max((group.name || 'Unnamed Group').length, 15)}" style="font-weight: 500; font-size: 1.1em; cursor: text; padding: 0.25em 0.5em; border-radius: 3px; transition: background-color 0.2s; border: none; outline: none; background: transparent; font-family: inherit; color: inherit;" onmouseover="this.style.backgroundColor='rgba(255,255,255,0.05)'" onmouseout="this.style.backgroundColor='transparent'" onfocus="this.style.backgroundColor='rgba(255,255,255,0.1)'" onblur="this.style.backgroundColor='transparent'" oninput="this.size=Math.max(this.value.length,15)" />
${group.description ? `<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-top: 0.25em;">${group.description}</div>` : ''}
${group.author ? `<div class="listItemBodyText secondary" style="font-size: 0.8em; margin-top: 0.25em; color: rgba(255,255,255,0.5);">by ${group.author}</div>` : ''}
</div>
${sectionTypes.length > 1 ? `<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-top: 0.25em;">${renderSectionTypeToggles(sectionTypes)}</div>` : ''}
<button type="button" class="emby-button group-toggle-all-btn raised" data-group-id="${groupId}" data-enabled="${allEnabled}" style="padding: 0.5em 1em; font-size: 0.9em;">
<span>${allEnabled ? 'Disable All' : 'Enable All'}</span>
</button>
</div>
<div class="group-sections-list" data-group-id="${groupId}">
${sections.map(s => buildSectionRowHTML(s, s.sectionType ?? sectionType, s.sectionType)).join('')}
</div>
</div>
`;
}
/**
* Build section content HTML (right column)
*/
function buildSectionContentHTML(sectionType) {
// Always use currentConfig as source of truth
if (!currentConfig) return '<div class="listItemBodyText secondary">No configuration loaded.</div>';
const mergeNextUp = currentConfig.MERGE_NEXT_UP || false;
// Filter groups based on mergeNextUp setting
const getFilteredGroups = (groups) => {
if (mergeNextUp) {
// Filter out groups containing nextUp or continueWatching sections when merged
return groups.map(group => {
const filteredSections = (group.sections || []).filter(s =>
s.id !== 'nextUp' && s.id !== 'continueWatching'
);
return { ...group, sections: filteredSections };
}).filter(group => group.sections.length > 0);
} else {
// Filter out continueWatchingAndNextUp section
return groups.map(group => {
const filteredSections = (group.sections || []).filter(s =>
s.id !== 'continueWatchingAndNextUp'
);
return { ...group, sections: filteredSections };
}).filter(group => group.sections.length > 0);
}
};
switch (sectionType) {
case 'home': {
//const homeGroups = getFilteredGroups(currentConfig.HOME_SECTION_GROUPS || []);
const homeGroups = currentConfig.HOME_SECTION_GROUPS || [];
return `
<div>
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 1em;">Home Sections</div>
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.75em;">Customize the default Home Sections provided by KefinTweaks.</div>
<div id="home-sections-list">
${homeGroups.map(g => buildGroupHTML(g, 'home')).join('')}
</div>
</div>
`;
}
case 'seasonal': {
const seasonalGroups = currentConfig.SEASONAL_SECTION_GROUPS || [];
return `
<div>
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 1em;">Seasonal Sections</div>
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.75em;">Customize the default Seasonal Home Sections provided by KefinTweaks. These sections only appear between the specified start and end dates.</div>
<div id="seasonal-sections-list">
${seasonalGroups.map(g => buildGroupHTML(g, 'home')).join('')}
</div>
</div>
`;
}
case 'discovery': {
const discoveryGroups = currentConfig.DISCOVERY_SECTION_GROUPS || [];
return `
<div>
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 1em;">Discovery Sections</div>
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.75em;">Discovery sections will appear dynamically on the home screen as the user scrolls down. They are pre-fetched and cached in the background to ensure a smooth experience. You can customize their settings here.</div>
<div id="discovery-sections-list">
${discoveryGroups.map(g => buildGroupHTML(g, 'discovery')).join('')}
</div>
</div>
`;
}
case 'custom': {
const customGroups = currentConfig.CUSTOM_SECTION_GROUPS || [];
return `
<div>
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 1em;">Custom Sections</div>
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.75em;">Customize your Custom Home Sections here. You can add, edit, and delete your own sections. You can also import sections from the community from the "Browse Community Sections" tab or import from a file you exported from the "Import / Export Sections" tab.</div>
<div id="custom-sections-list">
${customGroups.map(g => buildGroupHTML(g, 'custom')).join('')}
</div>
<button type="button" class="emby-button button-submit raised add-custom-section-btn" style="padding: 0.75em 1.5em; margin-top: 0.75em;">
<span>Add Custom Section</span>
</button>
</div>
`;
}
default:
return '<div class="listItemBodyText secondary">Unknown section type</div>';
}
}
/**
* Build main configuration modal content
*/
function buildMainConfigHTML() {
// Always use currentConfig as source of truth
if (!currentConfig) return '<div class="listItemBodyText secondary">No configuration loaded.</div>';
// Determine if settings should be expanded (expanded by default unless mobile)
const settingsExpanded = !isMobile();
// Use currentActiveTab to determine which tab/content should be displayed
const activeTab = currentActiveTab || 'settings';
return `
<div style="max-width: 100%; width: calc(100vw - 4em); overflow: hidden;">
<!-- Tab Navigation -->
<div style="display: flex; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 1em; flex-wrap: wrap; gap: 0.5em;">
<button class="config-tab-btn ${activeTab === 'settings' ? 'active' : ''}" data-tab="settings" style="padding: 0.75em 1.5em; background: none; border: none; border-bottom: 2px solid ${activeTab === 'settings' ? 'var(--theme-primary-color, #00a4dc)' : 'transparent'}; color: ${activeTab === 'settings' ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'}; cursor: pointer; font-weight: 500;">
Global Settings
</button>
<button class="config-tab-btn ${activeTab === 'sections' ? 'active' : ''}" data-tab="sections" style="padding: 0.75em 1.5em; background: none; border: none; border-bottom: 2px solid ${activeTab === 'sections' ? 'var(--theme-primary-color, #00a4dc)' : 'transparent'}; color: ${activeTab === 'sections' ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'}; cursor: pointer; font-weight: 500;">
Edit Sections
</button>
<button class="config-tab-btn ${activeTab === 'order' ? 'active' : ''}" data-tab="order" style="padding: 0.75em 1.5em; background: none; border: none; border-bottom: 2px solid ${activeTab === 'order' ? 'var(--theme-primary-color, #00a4dc)' : 'transparent'}; color: ${activeTab === 'order' ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'}; cursor: pointer; font-weight: 500;">
Order Sections
</button>
<button class="config-tab-btn ${activeTab === 'community' ? 'active' : ''}" data-tab="community" style="padding: 0.75em 1.5em; background: none; border: none; border-bottom: 2px solid ${activeTab === 'community' ? 'var(--theme-primary-color, #00a4dc)' : 'transparent'}; color: ${activeTab === 'community' ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'}; cursor: pointer; font-weight: 500;">
Browse Community Sections
</button>
<button class="config-tab-btn ${activeTab === 'import-export' ? 'active' : ''}" data-tab="import-export" style="padding: 0.75em 1.5em; background: none; border: none; border-bottom: 2px solid ${activeTab === 'import-export' ? 'var(--theme-primary-color, #00a4dc)' : 'transparent'}; color: ${activeTab === 'import-export' ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.7)'}; cursor: pointer; font-weight: 500;">
Import / Export Sections
</button>
</div>
<!-- Global Settings Tab -->
<div id="tab-settings" class="config-tab-content" style="display: ${activeTab === 'settings' ? 'block' : 'none'};">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1em;">
<!-- General Settings -->
<details ${settingsExpanded ? 'open' : ''} style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; margin-bottom: 0.5em;">General Settings</summary>
<div style="padding: 0.75em 0 0 0;">
${buildGeneralSettingsHTML()}
</div>
</details>
<!-- Discovery Settings -->
<details ${settingsExpanded ? 'open' : ''} style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; margin-bottom: 0.5em;">Discovery Settings</summary>
<div style="padding: 0.75em 0 0 0;">
${buildDiscoverySettingsHTML()}
</div>
</details>
<!-- Cache Settings -->
<details ${settingsExpanded ? 'open' : ''} style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; margin-bottom: 0.5em;">Cache Settings</summary>
<div style="padding: 0.75em 0 0 0;">
${buildCacheSettingsHTML()}
</div>
</details>
</div>
</div>
<!-- Edit Sections Tab -->
<div id="tab-sections" class="config-tab-content" style="display: ${activeTab === 'sections' ? 'block' : 'none'};">
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 1.5em;">
<!-- Left Column: Navigation -->
<div id="section-navigation">
${buildSectionNavigationHTML('home')}
</div>
<!-- Right Column: Content -->
<div id="section-content">
${buildSectionContentHTML('home')}
</div>
</div>
</div>
<!-- Order Sections Tab -->
<div id="tab-order" class="config-tab-content" style="display: ${activeTab === 'order' ? 'block' : 'none'};">
<div id="order-sections-container">
</div>
</div>
<!-- Browse Community Sections Tab -->
<div id="tab-community" class="config-tab-content" style="display: ${activeTab === 'community' ? 'block' : 'none'};">
<div class="listItemBodyText" style="margin-bottom: 1em;">Browse and import curated home screen sections from the community.</div>
<div class="listItemBodyText secondary" style="margin-bottom: 1.5em;">Submit your own Home Screen Sections <a href="https://github.com/ranaldsgift/KefinTweaks/discussions/64" target="_blank" class="button-link">here.</a></div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5em; min-height: 400px;">
<!-- Left Column: Collection Cards Grid -->
<div>
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Collections</div>
<div id="communityCollectionsGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1em; max-height: 600px; overflow-y: auto; padding-right: 0.5em;">
<!-- Collection cards will be populated here -->
</div>
</div>
<!-- Right Column: Sections List -->
<div>
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Sections</div>
<div id="communitySectionsContainer" style="display: none;">
<div id="communitySectionsList" style="max-height: 500px; overflow-y: auto; padding-right: 0.5em;">
<!-- Sections list will be populated here -->
</div>
<button type="button" class="emby-button raised block button-submit" id="confirmCommunityImportBtn" style="padding: 0.75em 2em; margin-top: 1em; display: none;">Import Selected</button>
</div>
<div id="communitySectionsEmpty" class="listItemBodyText secondary" style="padding: 2em; text-align: center; color: rgba(255,255,255,0.5);">
Select a collection to view its sections
</div>
</div>
</div>
</div>
<!-- Import / Export Sections Tab -->
<div id="tab-import-export" class="config-tab-content" style="display: ${activeTab === 'import-export' ? 'block' : 'none'};">
<div class="listItemBodyText" style="margin-bottom: 1em;">Share your custom home screen sections or import configurations from others.</div>
<div style="display: flex; gap: 1em; flex-wrap: wrap; margin-top: 1.5em;">
<button type="button" class="emby-button button-submit raised export-sections-btn" style="padding: 0.75em 1.5em;">
<span>Export Sections</span>
</button>
<button type="button" class="emby-button button-submit raised import-sections-btn" style="padding: 0.75em 1.5em;">
<span>Import Sections</span>
</button>
</div>
</div>
</div>
<style>
.section-row:hover .section-preview-btn,
.section-row:hover .section-edit-btn,
.section-row:hover .section-delete-btn {
opacity: 1 !important;
pointer-events: auto !important;
}
.toggle-slider:hover {
opacity: 0.9;
transform: scale(1.02);
}
.toggle-slider[data-enabled="true"]:hover {
background: rgba(0, 164, 220, 1) !important;
}
.toggle-slider[data-enabled="false"]:hover {
background: rgba(158, 158, 158, 0.7) !important;
}
</style>
`;
}
/**
* Build general settings HTML
*/
function buildGeneralSettingsHTML() {
if (!currentConfig) return '';
const seasonal = currentConfig.SEASONAL_THEME_SETTINGS || {};
return `
<div style="display: grid; gap: 1em;">
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('merge-next-up', currentConfig.MERGE_NEXT_UP === true, 'Merge Next Up with Continue Watching', { includeHiddenCheckbox: true })}
</div>
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('seasonal-enableSeasonalAnimations', seasonal.enableSeasonalAnimations !== false, 'Enable Seasonal Animations', { includeHiddenCheckbox: true })}
</div>
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('seasonal-enableSeasonalBackground', seasonal.enableSeasonalBackground !== false, 'Enable Seasonal Backgrounds', { includeHiddenCheckbox: true })}
</div>
</div>
`;
}
/**
* Build discovery settings HTML
*/
function buildDiscoverySettingsHTML() {
if (!currentConfig) return '';
const discovery = currentConfig.DISCOVERY_SETTINGS || {};
return `
<div style="display: grid; gap: 1em;">
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
${buildToggleSlider('discovery-enabled', discovery.enabled !== false, 'Enabled', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-infiniteScroll', discovery.infiniteScroll !== false, 'Infinite Scroll', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-renderSpotlightAboveMatching', discovery.renderSpotlightAboveMatching === true, 'Group Top Rated and Normal Sections', { includeHiddenCheckbox: true })}
${buildToggleSlider('discovery-randomizeOrder', discovery.randomizeOrder === true, 'Randomize Order', { includeHiddenCheckbox: true })}
${buildTextInput('discovery-minPeopleAppearances', discovery.minPeopleAppearances || 10, 'Min People Appearances', 'number')}
${buildTextInput('discovery-minGenreMovieCount', discovery.minGenreMovieCount || 50, 'Min Genre Movie Count', 'number')}
${buildTextInput('discovery-defaultItemLimit', discovery.defaultItemLimit || 16, 'Default Item Limit', 'number')}
${buildSelect('discovery-defaultSortOrder', SORT_ORDERS, discovery.defaultSortOrder || 'Random', 'Default Sort Order')}
${buildSelect('discovery-defaultCardFormat', CARD_FORMATS, discovery.defaultCardFormat || 'Poster', 'Default Card Format')}
${buildTextInput('discovery-spotlightDiscoveryChance', discovery.spotlightDiscoveryChance || 0.5, 'Spotlight Discovery Chance (0-1)', 'number')}
</div>
</div>
`;
}
/**
* Build seasonal settings HTML (deprecated - moved to General Settings)
*/
function buildSeasonalSettingsHTML() {
return '';
}
/**
* Convert milliseconds to minutes
*/
function msToMinutes(ms) {
return Math.round(ms / 60000);
}
/**
* Convert minutes to milliseconds
*/
function minutesToMs(minutes) {
return minutes * 60000;
}
/**
* Build cache settings HTML
*/
function buildCacheSettingsHTML() {
if (!currentConfig) return '';
const cache = currentConfig.CACHE || {};
return `
<div style="display: grid; gap: 1em;">
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em;">
<div class="listItemBodyText secondary" style="font-size: 0.85em; margin-bottom: 0.75em;">TTL values in minutes</div>
${buildTextInput('cache-DEFAULT_TTL', msToMinutes(cache.DEFAULT_TTL || 1800000), 'Default TTL (minutes)', 'number')}
${buildTextInput('cache-VERY_SHORT_TTL', msToMinutes(cache.VERY_SHORT_TTL || 60000), 'Very Short TTL (minutes)', 'number')}
${buildTextInput('cache-SHORT_TTL', msToMinutes(cache.SHORT_TTL || 300000), 'Short TTL (minutes)', 'number')}
${buildTextInput('cache-LONG_TTL', msToMinutes(cache.LONG_TTL || 86400000), 'Long TTL (minutes)', 'number')}
${buildTextInput('cache-STATIC_TTL', msToMinutes(cache.STATIC_TTL || 604800000), 'Static TTL (minutes)', 'number')}
${buildTextInput('cache-DISCOVERY_TTL', msToMinutes(cache.DISCOVERY_TTL || 3600000), 'Discovery TTL (minutes)', 'number')}
</div>
</div>
`;
}
/**
* Get field configuration for default sections
*/
function getDefaultSectionFields(sectionId) {
// Check all default groups (HOME, SEASONAL, DISCOVERY)
const defaultHome = flattenSectionGroups(window.KefinHomeConfig2?.HOME_SECTION_GROUPS || []);
const defaultSeasonal = flattenSectionGroups(window.KefinHomeConfig2?.SEASONAL_SECTION_GROUPS || []);
const defaultDiscovery = flattenSectionGroups(window.KefinHomeConfig2?.DISCOVERY_SECTION_GROUPS || []);
const allDefaultSections = [...defaultHome, ...defaultSeasonal, ...defaultDiscovery];
const defaultIds = new Set(allDefaultSections.map(s => s.id));
if (!defaultIds.has(sectionId) && !sectionId.startsWith('recently-added-')) {
return null; // Not a default section
}
if (sectionId.startsWith('recently-added-')) {
return {
includeName: true,
includeCardFormat: true,
includeOrder: true,
includeItemLimit: true,
includeSortOrder: false,
includeFilterByPlayedStatus: true,
};
}
// Simplified sections (Next Up, Continue Watching, Upcoming)
const simplifiedSections = ['nextUp', 'continueWatching', 'upcoming', 'continueWatchingAndNextUp'];
if (simplifiedSections.includes(sectionId)) {
return {
includeName: true,
includeCardFormat: true,
includeOrder: true,
includeItemLimit: true,
includeSortOrder: false,
includeFilterByPlayedStatus: sectionId === 'upcoming',
includePremiereDays: false,
includeMinimumShows: false
};
}
// Recently Released sections
if (sectionId.startsWith('recentlyReleased.')) {
return {
includeName: true,
includeCardFormat: true,
includeOrder: true,
includeItemLimit: true,
includeSortOrder: true,
includeFilterByPlayedStatus: true,
includePremiereDays: true,
includeMinimumShows: false
};
}
// Popular TV Networks
if (sectionId === 'popularTVNetworks') {
return {
includeName: true,
includeCardFormat: true,
includeOrder: true,
includeItemLimit: true,
includeSortOrder: true,
includeFilterByPlayedStatus: false,
includePremiereDays: false,
includeMinimumShows: true
};
}
// Other default sections
return {
includeName: true,
includeCardFormat: true,
includeOrder: true,
includeItemLimit: true,
includeSortOrder: true,
includeFilterByPlayedStatus: false,
includePremiereDays: false,
includeMinimumShows: false
};
}
/**
* Build section editor modal content
*/
function buildSectionEditorHTML(section, isDiscovery = false) {
if (isDiscovery) {
return buildDiscoveryEditorHTML(section);
}
const queries = section.queries || [{}];
const hasMultipleQueries = queries.length > 1;
const defaultFields = getDefaultSectionFields(section.id);
const isDefaultSection = defaultFields !== null;
// Get current group name for this section (if editing existing)
let currentGroupName = '';
if (section.id) {
// Check all groups to find where this section exists
let found = findSectionInGroups(currentConfig.HOME_SECTION_GROUPS || [], section.id);
if (!found) found = findSectionInGroups(currentConfig.SEASONAL_SECTION_GROUPS || [], section.id);
if (!found) found = findSectionInGroups(currentConfig.DISCOVERY_SECTION_GROUPS || [], section.id);
if (!found) found = findSectionInGroups(currentConfig.CUSTOM_SECTION_GROUPS || [], section.id);
if (found) {
currentGroupName = found.group.name || '';
}
}
// Get all existing group names for dropdown (only for custom sections)
let groupSelectHTML = '';
if (!isDefaultSection) {
// For custom sections, only show groups from CUSTOM_SECTION_GROUPS
const allGroups = currentConfig.CUSTOM_SECTION_GROUPS || [];
const existingGroupNames = [...new Set(allGroups.map(g => g.name).filter(Boolean))];
const groupOptions = ['New...', ...existingGroupNames];
const selectedGroupValue = currentGroupName || (existingGroupNames.length > 0 ? existingGroupNames[0] : 'New...');
groupSelectHTML = `
${buildSelect('section-group', groupOptions, selectedGroupValue, 'Section Group')}
<div id="section-group-new-container" style="display: ${selectedGroupValue === 'New...' ? 'block' : 'none'}; margin-bottom: 1em;">
${buildTextInput('section-group-new', '', 'New Group Name')}
</div>
`;
}
// Build Basic Properties based on section type
let basicPropertiesHTML = `
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Basic Properties</div>
${groupSelectHTML}
${buildTextInput('section-name', section.name || '', 'Section Name')}
`;
// For default sections, show simplified fields
if (isDefaultSection) {
if (defaultFields.includeCardFormat) {
basicPropertiesHTML += buildSelect('section-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format');
}
if (defaultFields.includeOrder) {
basicPropertiesHTML += buildTextInput('section-order', section.order || 0, 'Order', 'number');
}
if (defaultFields.includeItemLimit) {
const itemLimit = section.queries?.[0]?.queryOptions?.Limit || 20;
basicPropertiesHTML += buildTextInput('section-itemLimit', itemLimit, 'Item Limit', 'number');
}
if (defaultFields.includeFilterByPlayedStatus) {
basicPropertiesHTML += buildFilterByPlayedStatusToggle('section-filterByPlayedStatus', section.queries?.[0]?.queryOptions?.IsUnplayed === true);
}
} else {
// Custom sections - show full editor in flexbox layout
basicPropertiesHTML += `
<div style="display: flex; gap: 0.75em; flex-wrap: wrap; align-items: flex-end;">
<div style="min-width: 150px; align-self: start;">
${buildSelect('section-renderMode', [
{ value: 'Normal', label: 'Normal' },
{ value: 'Spotlight', label: 'Spotlight' },
{ value: 'Random', label: 'Random' }
], section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal'), 'Render Mode')}
</div>
<div style="min-width: 150px; align-self: start;">
${buildTextInput('section-order', section.order || 0, 'Order', 'number')}
</div>
<div id="section-cardFormat-container" style="min-width: 150px; display: ${(section.renderMode === 'Spotlight' || section.spotlight) ? 'none' : 'block'}; align-self: start;">
${buildSelect('section-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format')}
</div>
<div id="section-discoveryEnabled-container" style="min-width: 150px; display: block; align-self: start;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Discovery Enabled</div>
${buildToggleSlider('section-discoveryEnabled', section.discoveryEnabled === true, '', {
dataAttributes: { 'section-id': section.id, 'section-type': 'custom' },
cssClass: 'toggle-slider section-toggle-switch section-discoveryEnabled-toggle',
wrapInLabel: false
})}
</div>
<div style="min-width: 150px; align-self: start;">
${buildFilterByPlayedStatusToggle('section-filterByPlayedStatus', section.queries?.[0]?.queryOptions?.IsUnplayed === true)}
</div>
</div>
`;
}
// Additional fields for default sections
let additionalFieldsHTML = '';
if (isDefaultSection) {
if (defaultFields.includeSortOrder) {
const sortBy = section.queries?.[0]?.queryOptions?.SortBy || 'Random';
const sortOrder = section.queries?.[0]?.queryOptions?.SortOrder || 'Ascending';
additionalFieldsHTML += `
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Sorting</div>
${buildSelect('section-sortBy', SORT_ORDERS, sortBy, 'Sort By')}
${buildSelect('section-sortOrder', SORT_ORDER_DIRECTIONS, sortOrder, 'Sort Order')}
</div>
`;
}
if (defaultFields.includePremiereDays) {
// Extract min/max age from query object (not queryOptions)
const query = section.queries?.[0];
let minAgeInDays = query?.minAge !== undefined ? query.minAge : 0;
let maxAgeInDays = query?.maxAge !== undefined ? query.maxAge : 30;
// Fallback: try to calculate from MinPremiereDate/MaxPremiereDate if minAge/maxAge not set
if (query?.minAge === undefined && query?.maxAge === undefined) {
const minPremiereDate = query?.queryOptions?.MinPremiereDate;
const maxPremiereDate = query?.queryOptions?.MaxPremiereDate;
if (minPremiereDate || maxPremiereDate) {
const now = Date.now();
if (minPremiereDate) {
const minDate = new Date(minPremiereDate);
maxAgeInDays = Math.floor((now - minDate.getTime()) / (1000 * 60 * 60 * 24));
}
if (maxPremiereDate) {
const maxDate = new Date(maxPremiereDate);
minAgeInDays = Math.floor((now - maxDate.getTime()) / (1000 * 60 * 60 * 24));
}
}
}
additionalFieldsHTML += `
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Premiere Date Range</div>
${buildTextInput('section-minAgeInDays', minAgeInDays, 'Minimum Age (Days)', 'number')}
${buildTextInput('section-maxAgeInDays', maxAgeInDays, 'Maximum Age (Days)', 'number')}
</div>
`;
}
if (defaultFields.includeMinimumShows) {
// For popularTVNetworks, we need to get this from somewhere - it's not in the section config directly
// We'll store it in a custom property or query option
const minimumShows = section.minimumShowsForNetwork || section.queries?.[0]?.queryOptions?.minimumShowsForNetwork || 5;
additionalFieldsHTML += `
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Network Settings</div>
${buildTextInput('section-minimumShowsForNetwork', minimumShows, 'Minimum Shows to Appear', 'number')}
</div>
`;
}
}
return `
<div style="max-width: 1000px; margin: 0 auto;">
<!-- Enabled Toggle (at the very top) -->
<div>
${buildEnabledToggleButtons('section-enabled', section.enabled)}
</div>
<!-- Basic Properties -->
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
${basicPropertiesHTML}
</div>
${additionalFieldsHTML}
${!isDefaultSection ? `
<!-- Queries Array Editor -->
<div style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<div style="display: flex; align-items: center; margin-bottom: 0.75em; gap: 0.5em;">
<div class="listItemBodyText" style="font-weight: 500;">Queries</div>
<a href="https://api.jellyfin.org/" target="_blank" style="color: inherit;" title="Use the Jellyfin API documentation for additional details.">
<span class="material-icons info"></span>
</a>
</div>
<div id="queries-list">
${queries.map((query, index) => buildQueryEditor(query, index, queries.length > 1)).join('')}
</div>
<button type="button" class="emby-button add-query-btn raised" style="padding: 0.5em 1em; margin-top: 0.75em;">
<span>Add Query</span>
</button>
<div data-merge-sorting style="margin-top: 1em; padding-top: 1em; border-top: 1px solid rgba(255,255,255,0.1); display: ${hasMultipleQueries ? 'block' : 'none'};">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.75em;">Merge Sorting (for multiple queries)</div>
${buildSelect('section-sortBy', SORT_ORDERS, section.sortBy, 'Sort By')}
${buildSelect('section-sortOrder', SORT_ORDER_DIRECTIONS, section.sortOrder || 'Descending', 'Sort Order')}
</div>
</div>
<!-- Advanced Options -->
<details style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 1em;">
<summary class="listItemBodyText" style="font-weight: 500; cursor: pointer; margin-bottom: 0.5em;">Advanced Options</summary>
<div style="padding: 0.75em 0 0 0;">
${buildTextInput('section-ttl', section.ttl !== undefined ? section.ttl : '', 'TTL (ms, leave empty for default)', 'number')}
${buildCheckbox('section-flattenSeries', section.flattenSeries === true, 'Flatten Series')}
${buildTextInput('section-minimumItems', section.minimumItems || '', 'Minimum Items', 'number')}
${buildTextInput('section-startDate', section.startDate || '', 'Start Date (MM-DD)', 'text')}
${buildTextInput('section-endDate', section.endDate || '', 'End Date (MM-DD)', 'text')}
</div>
</details>
` : ''}
</div>
`;
}
/**
* Build query editor HTML
*/
function buildQueryEditor(query, index, canDelete) {
const queryType = query.path ? 'custom' : (query.dataSource ? 'datasource' : 'standard');
const pathDisplay = queryType === 'custom' ? 'block' : 'none';
const dataSourceDisplay = queryType === 'datasource' ? 'block' : 'none';
return `
<div class="query-editor" data-query-index="${index}" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-bottom: 0.75em;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75em;">
<div class="listItemBodyText" style="font-weight: 500;">Query ${index + 1}</div>
${canDelete ? `
<button type="button" class="emby-button delete-query-btn raised" data-query-index="${index}" style="padding: 0.5em 1em; background: rgba(244, 67, 54, 0.2);">
<span>Delete</span>
</button>
` : ''}
</div>
${buildSelect(`query-${index}-type`, [
{ value: 'standard', label: 'Standard Items Query' },
{ value: 'custom', label: 'Custom Endpoint' },
{ value: 'datasource', label: 'Data Source' }
], queryType, 'Query Type')}
<div id="query-${index}-path-container" style="display: ${pathDisplay};">
${buildTextInput(`query-${index}-path`, query.path || '', 'Endpoint Path', 'text', '/Genres, /Persons, /Shows/NextUp, /Items/Latest, etc')}
</div>
<div id="query-${index}-dataSource-container" style="display: ${dataSourceDisplay};">
${buildTextInput(`query-${index}-dataSource`, query.dataSource || '', 'Data Source (e.g., MoviesCache.getImdbTop250Movies)')}
</div>
<div style="margin-top: 0.75em;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.5em;">Parent Options</div>
${buildParentOptionsEditor(query, index)}
</div>
<div style="margin-top: 0.75em;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.5em;">Query Options</div>
${buildQueryOptionsEditor(query.queryOptions || {}, index)}
</div>
</div>
`;
}
/**
* Build parent options editor
*/
function buildParentOptionsEditor(query, queryIndex) {
const queryOptions = query.queryOptions || {};
// Determine parent item type from query level first, then fall back to queryOptions for backwards compatibility
let parentItemType = query._parentItemType || '';
// Backwards compatibility: check queryOptions._parentItemType
if (!parentItemType && (queryOptions._parentItemType === 'Collection' || queryOptions._parentItemType === 'Playlist')) {
parentItemType = queryOptions._parentItemType;
} else if (!parentItemType && queryOptions.CollectionIds && (Array.isArray(queryOptions.CollectionIds) ? queryOptions.CollectionIds.length > 0 : queryOptions.CollectionIds)) {
// Backwards compatibility: check old CollectionIds field
parentItemType = 'Collection';
} else if (!parentItemType && queryOptions.PlaylistIds && (Array.isArray(queryOptions.PlaylistIds) ? queryOptions.PlaylistIds.length > 0 : queryOptions.PlaylistIds)) {
// Backwards compatibility: check old PlaylistIds field
parentItemType = 'Playlist';
} else if (!parentItemType && queryOptions.ParentId) {
parentItemType = 'Generic Parent';
}
return `
<div style="display: grid; gap: 0.75em;">
<!-- Parent Item Dropdown -->
${buildSelect(`query-${queryIndex}-parentItemType`, [
{ value: '', label: 'Select a Parent...' },
{ value: 'Collection', label: 'Collection' },
{ value: 'Playlist', label: 'Playlist' },
{ value: 'Generic Parent', label: 'Generic Parent' }
], parentItemType, 'Parent Item')}
<!-- Conditional Parent Item Fields -->
<div id="query-${queryIndex}-Collections-container" data-query-index="${queryIndex}" data-type="Collections" style="display: ${parentItemType === 'Collection' ? 'block' : 'none'};"></div>
<div id="query-${queryIndex}-Playlists-container" data-query-index="${queryIndex}" data-type="Playlists" style="display: ${parentItemType === 'Playlist' ? 'block' : 'none'};"></div>
<div id="query-${queryIndex}-GenericParent-container" style="display: ${parentItemType === 'Generic Parent' ? 'block' : 'none'};">
${buildTextInput(`query-${queryIndex}-ParentId`, queryOptions.ParentId || '', 'Parent ID (GUID)', 'text')}
</div>
<!-- Hidden field to store parent item type -->
<input type="hidden" id="query-${queryIndex}-ParentItemType" value="${parentItemType}">
</div>
`;
}
/**
* Build query options editor
*/
function buildQueryOptionsEditor(queryOptions, queryIndex) {
// Regular text/number fields
const commonFields = [
{ key: 'SortBy', label: 'Sort By', value: queryOptions.SortBy || '', options: SORT_ORDERS },
{ key: 'SortOrder', label: 'Sort Order', value: queryOptions.SortOrder || '', options: SORT_ORDER_DIRECTIONS },
{ key: 'Limit', label: 'Limit', value: queryOptions.Limit || '', type: 'number' },
{ key: 'Filters', label: 'Filters', value: queryOptions.Filters || '' },
{ key: 'SearchTerm', label: 'Search Term', value: queryOptions.SearchTerm || '' }
];
// Extract additional options (not in common fields or badge fields)
const additionalOptions = [];
const badgeFieldKeys = ['IncludeItemTypes', 'Genres', 'Tags', 'CollectionIds', 'PlaylistIds', 'ParentId'];
Object.entries(queryOptions || {}).forEach(([key, value]) => {
if (!commonFields.find(f => f.key === key) && !badgeFieldKeys.includes(key)) {
// If the key doesn't start with an underscore, add it to additionalOptions
if (!key.startsWith('_')) {
additionalOptions.push({ key, value });
}
}
});
return `
<div style="display: grid; gap: 0.75em;">
<!-- Badge fields grouped in flexbox: IncludeItemTypes, Genres, Tags -->
<div style="display: flex; gap: 0.75em; flex-wrap: wrap;">
<div id="query-${queryIndex}-IncludeItemTypes-container" data-query-index="${queryIndex}" data-type="IncludeItemTypes" style="flex: 1; min-width: 250px;"></div>
<div id="query-${queryIndex}-Genres-container" data-query-index="${queryIndex}" data-type="Genres" style="flex: 1; min-width: 250px;"></div>
<div id="query-${queryIndex}-Tags-container" data-query-index="${queryIndex}" data-type="Tags" style="flex: 1; min-width: 250px;"></div>
</div>
<!-- Regular Fields in flexbox -->
<div style="display: flex; gap: 0.75em; flex-wrap: wrap;">
${commonFields.map(field => `
<div style="flex: 1; min-width: 150px;">
${field.options ? buildSelect(`query-${queryIndex}-${field.key}`, field.options, field.value, field.label) : buildTextInput(`query-${queryIndex}-${field.key}`, field.value, field.label, field.type || 'text')}
</div>
`).join('')}
</div>
<details style="margin-top: 0.5em;">
<summary class="listItemBodyText" style="cursor: pointer; font-size: 0.9em;">Additional Options</summary>
<div style="padding: 0.5em 0;">
${buildAdditionalOptionsControls(queryIndex)}
${buildAdditionalOptionsList(queryIndex, additionalOptions)}
</div>
</details>
</div>
`;
}
/**
* Setup badge autocomplete for a query field
*/
async function setupQueryBadgeAutocomplete(modalInstance, queryIndex, type, queryOptions = {}, includeItemTypes = null, parentItemType = null) {
if (!window.KefinTweaksUI || !window.KefinTweaksUI.createAutoCompleteInput) {
WARN('KefinTweaksUI.createAutoCompleteInput not available');
return;
}
const container = modalInstance.dialogContent.querySelector(`#query-${queryIndex}-${type}-container`);
if (!container) return;
// Calculate itemTypesForFilters early (used for Genres/Tags)
const rawItemTypes = includeItemTypes || queryOptions.IncludeItemTypes;
const itemTypesForFilters = (type === 'Genres' || type === 'Tags')
? (Array.isArray(rawItemTypes) ? rawItemTypes : [rawItemTypes])
: null;
// Extract current values from query options
let currentValues = [];
if (type === 'IncludeItemTypes') {
currentValues = Array.isArray(queryOptions.IncludeItemTypes) ? queryOptions.IncludeItemTypes : (queryOptions.IncludeItemTypes ? [queryOptions.IncludeItemTypes] : []);
} else if (type === 'Genres') {
// Get selected genre names from queryOptions
const selectedGenres = queryOptions.Genres || '';
const selectedGenreNames = selectedGenres ? (Array.isArray(selectedGenres) ? selectedGenres : selectedGenres.split(',').map(s => s.trim()).filter(s => s)) : [];
// Fetch available genres from dataHelper.getFilters() to validate/match selected values
if (window.dataHelper && window.dataHelper.getFilters && itemTypesForFilters) {
try {
const filters = await window.dataHelper.getFilters(itemTypesForFilters, true, false);
const availableGenres = filters?.Genres || [];
// Match selected genre names against available genres (case-insensitive)
const genreMap = new Map(availableGenres.map(g => [g.toLowerCase(), g]));
currentValues = selectedGenreNames
.map(name => {
// Try exact match first, then case-insensitive
const exactMatch = availableGenres.find(g => g === name);
if (exactMatch) return exactMatch;
const caseInsensitiveMatch = genreMap.get(name.toLowerCase());
return caseInsensitiveMatch || name; // Fallback to original name if not found
})
.filter(Boolean);
} catch (err) {
WARN('Error fetching genres from dataHelper:', err);
// Fallback to using selected values as-is
currentValues = selectedGenreNames;
}
} else {
// Fallback to using selected values as-is
currentValues = selectedGenreNames;
}
} else if (type === 'Tags') {
// Get selected tag names from queryOptions
const selectedTags = queryOptions.Tags || '';
const selectedTagNames = selectedTags ? (Array.isArray(selectedTags) ? selectedTags : selectedTags.split(',').map(s => s.trim()).filter(s => s)) : [];
// Fetch available tags from dataHelper.getFilters() to validate/match selected values
if (window.dataHelper && window.dataHelper.getFilters && itemTypesForFilters) {
try {
const filters = await window.dataHelper.getFilters(itemTypesForFilters, true, false);
const availableTags = filters?.Tags || [];
// Match selected tag names against available tags (case-insensitive)
const tagMap = new Map(availableTags.map(t => [t.toLowerCase(), t]));
currentValues = selectedTagNames
.map(name => {
// Try exact match first, then case-insensitive
const exactMatch = availableTags.find(t => t === name);
if (exactMatch) return exactMatch;
const caseInsensitiveMatch = tagMap.get(name.toLowerCase());
return caseInsensitiveMatch || name; // Fallback to original name if not found
})
.filter(Boolean);
} catch (err) {
WARN('Error fetching tags from dataHelper:', err);
// Fallback to using selected values as-is
currentValues = selectedTagNames;
}
} else {
// Fallback to using selected values as-is
currentValues = selectedTagNames;
}
} else if (type === 'Collections') {
// Check if ParentId contains a collection ID (when _parentItemType is 'Collection')
let collectionIds = [];
// Check query level first, then queryOptions for backwards compatibility
const effectiveParentItemType = parentItemType || queryOptions._parentItemType;
if (effectiveParentItemType === 'Collection' && queryOptions.ParentId) {
collectionIds = [queryOptions.ParentId];
} else {
// Backwards compatibility: check old CollectionIds field
collectionIds = Array.isArray(queryOptions.CollectionIds) ? queryOptions.CollectionIds : (queryOptions.CollectionIds ? [queryOptions.CollectionIds] : []);
}
if (collectionIds.length ===0) {
currentValues = [];
} else {
// For Collections, we need to fetch names from IDs
if (window.apiHelper && window.ApiClient) {
try {
const userId = window.ApiClient.getCurrentUserId();
const serverAddress = window.ApiClient.serverAddress();
if (userId && serverAddress) {
const data = await window.apiHelper.getItems(
{
IncludeItemTypes: 'BoxSet,CollectionFolder',
Recursive: true,
Fields: 'ItemCounts',
Ids: collectionIds.join(',')
},
true,
300000
);
// Convert to objects with id and name
currentValues = (data?.Items || []).map(item => ({
id: item.Id,
name: item.Name
}));
// Add any IDs that weren't found (shouldn't happen, but just in case)
const foundIds = new Set(currentValues.map(v => v.id));
collectionIds.forEach(id => {
if (!foundIds.has(id)) {
currentValues.push({ id: id, name: id }); // Fallback to ID if not found
}
});
} else {
// Fallback: convert IDs to objects
currentValues = collectionIds.map(id => ({ id: id, name: id }));
}
} catch (err) {
WARN('Error fetching collection names:', err);
// Fallback: convert IDs to objects
currentValues = collectionIds.map(id => ({ id: id, name: id }));
}
} else {
// Convert IDs to objects for consistency
currentValues = collectionIds.map(id => ({ id: id, name: id }));
}
}
} else if (type === 'Playlists') {
// Check if ParentId contains a playlist ID (when _parentItemType is 'Playlist')
let playlistIds = [];
// Check query level first, then queryOptions for backwards compatibility
const effectiveParentItemType = parentItemType || queryOptions._parentItemType;
if (effectiveParentItemType === 'Playlist' && queryOptions.ParentId) {
playlistIds = [queryOptions.ParentId];
} else {
// Backwards compatibility: check old PlaylistIds field
playlistIds = Array.isArray(queryOptions.PlaylistIds) ? queryOptions.PlaylistIds : (queryOptions.PlaylistIds ? [queryOptions.PlaylistIds] : []);
}
if (playlistIds.length ===0) {
currentValues = [];
} else {
// For Playlists, we need to fetch names from IDs
if (window.apiHelper && window.ApiClient) {
try {
const userId = window.ApiClient.getCurrentUserId();
const serverAddress = window.ApiClient.serverAddress();
if (userId && serverAddress) {
const data = await window.apiHelper.getItems(
{
IncludeItemTypes: 'Playlist',
Recursive: true,
Fields: 'ItemCounts',
Ids: playlistIds.join(',')
},
true,
300000
);
// Convert to objects with id and name
currentValues = (data?.Items || []).map(item => ({
id: item.Id,
name: item.Name
}));
// Add any IDs that weren't found
const foundIds = new Set(currentValues.map(v => v.id));
playlistIds.forEach(id => {
if (!foundIds.has(id)) {
currentValues.push({ id: id, name: id }); // Fallback to ID if not found
}
});
} else {
// Fallback: convert IDs to objects
currentValues = playlistIds.map(id => ({ id: id, name: id }));
}
} catch (err) {
WARN('Error fetching playlist names:', err);
// Fallback: convert IDs to objects
currentValues = playlistIds.map(id => ({ id: id, name: id }));
}
} else {
// Convert IDs to objects for consistency
currentValues = playlistIds.map(id => ({ id: id, name: id }));
}
}
}
// Get available item types for IncludeItemTypes
const allAvailableItemTypes = [
'AggregateFolder', 'Audio', 'AudioBook', 'BasePluginFolder', 'Book', 'BoxSet', 'Channel',
'ChannelFolderItem', 'CollectionFolder', 'Episode', 'Folder', 'Genre', 'ManualPlaylistsFolder',
'Movie', 'LiveTvChannel', 'LiveTvProgram', 'MusicAlbum', 'MusicArtist', 'MusicGenre', 'MusicVideo',
'Person', 'Photo', 'PhotoAlbum', 'Playlist', 'PlaylistsFolder', 'Program', 'Recording', 'Season',
'Series', 'Studio', 'Trailer', 'TvChannel', 'TvProgram', 'UserRootFolder', 'UserView', 'Video', 'Year'
];
// Normalize type: Collections->Collection, Playlists->Playlist, Genres->Genre, Tags->Tag
const normalizedType = type === 'IncludeItemTypes' ? 'IncludeItemTypes'
: (type === 'Collections' ? 'Collection'
: (type === 'Playlists' ? 'Playlist'
: (type === 'Genres' ? 'Genre'
: (type === 'Tags' ? 'Tag' : type))));
const autocomplete = window.KefinTweaksUI.createAutoCompleteInput({
type: normalizedType,
prefix: 'query',
sectionIndex: queryIndex,
container: container,
label: type === 'IncludeItemTypes' ? 'Include Item Types' : (type === 'Collections' ? 'Collections' : (type === 'Playlists' ? 'Playlists' : type)),
currentValuesArray: currentValues, // Fixed: use currentValuesArray parameter name
availableTypes: type === 'IncludeItemTypes' ? allAvailableItemTypes : null,
includeItemTypes: itemTypesForFilters, // Pass for filter fetching (Genres/Tags only)
onChange: (values) => {
// Values are updated automatically via hidden inputs
}
});
if (autocomplete && autocomplete.element) {
container.innerHTML = '';
container.appendChild(autocomplete.element);
await autocomplete.setup();
}
}
/**
* Build additional options controls (dropdown + add button)
*/
function buildAdditionalOptionsControls(queryIndex) {
const dropdownOptions = Object.entries(SUPPORTED_QUERY_OPTIONS)
.map(([key, meta]) => `<option value="${key}">${meta.label}</option>`)
.join('');
return `
<div class="query-${queryIndex}-additional-options-controls" data-query-index="${queryIndex}" style="border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 0.75em; margin-top: 0.5em;">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Additional Options</div>
<div style="display: flex; gap: 0.5em; align-items: center;">
<select class="fld emby-select emby-select-withcolor additional-option-select" data-query-index="${queryIndex}" style="flex: 1; margin: 0; min-width: 0;">
<option value="">Select an option to add...</option>
${dropdownOptions}
</select>
<button type="button" class="raised emby-button add-additional-option" data-query-index="${queryIndex}" style="background: rgba(0, 164, 220, 0.2); min-width: auto; padding: 0.5em 1em;">
<span>Add</span>
</button>
</div>
</div>
`;
}
/**
* Build additional options list
*/
function buildAdditionalOptionsList(queryIndex, currentOptions) {
const optionsHtml = (currentOptions || []).map((option, index) => {
const meta = SUPPORTED_QUERY_OPTIONS[option.key] || { label: option.key, type: 'string' };
const inputId = `query-${queryIndex}-additionalOption-${index}`;
let inputField = '';
if (meta.type === 'boolean') {
const isChecked = option.value === true || option.value === 'true';
inputField = `
<div style="flex: 1;">
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="${inputId}" class="additional-option-value" data-key="${option.key}" data-type="boolean" ${isChecked ? 'checked' : ''}>
<span class="listItemBodyText">${meta.label}</span>
</label>
</div>
`;
} else {
const displayValue = Array.isArray(option.value) ? option.value.join(',') : (option.value || '');
inputField = `
<div style="flex: 1;">
<div class="listItemBodyText" style="margin-bottom: 0.25em;">${meta.label}</div>
<input type="${meta.type === 'number' ? 'number' : (meta.type === 'date' ? 'date' : 'text')}"
id="${inputId}"
class="fld emby-input additional-option-value"
value="${displayValue}"
data-key="${option.key}"
data-type="${meta.type}"
placeholder="${meta.hint || ''}"
style="width: 100%;">
</div>
`;
}
return `
<div class="additional-option-row" style="display: flex; align-items: flex-end; gap: 0.5em; margin-top: 0.5em; padding: 0.5em; background: rgba(255,255,255,0.05); border-radius: 4px;">
${inputField}
<button type="button" class="remove-additional-option" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0.25em; display: flex; align-items: center;" title="Remove Option">
<span class="material-icons">close</span>
</button>
</div>
`;
}).join('');
return `
<div class="query-${queryIndex}-additional-options-list-container" data-query-index="${queryIndex}" style="margin-top: 0.75em;">
<div class="additional-options-list" data-query-index="${queryIndex}">
${optionsHtml}
</div>
</div>
`;
}
/**
* Build discovery section editor HTML (simplified)
*/
function buildDiscoveryEditorHTML(section) {
return `
<div style="max-width: 100%;">
<div class="listItemBodyText secondary" style="margin-bottom: 1em; padding: 0.75em; background: rgba(255,255,255,0.05); border-radius: 4px;">
<strong>Note:</strong> Discovery sections use dynamic sources that are determined at runtime. You can only modify basic settings here.
</div>
${buildTextInput('discovery-name', section.name || '', 'Name Template (use placeholders like {Genre}, {Person}, {Title})')}
${buildCheckbox('discovery-enabled', section.enabled !== false, 'Enabled')}
${buildTextInput('discovery-itemLimit', section.itemLimit || 20, 'Item Limit', 'number')}
${buildSelect('discovery-sortOrder', SORT_ORDERS, section.sortOrder || 'Random', 'Sort Order')}
${section.sortOrderDirection ? buildSelect('discovery-sortOrderDirection', SORT_ORDER_DIRECTIONS, section.sortOrderDirection || 'Ascending', 'Sort Order Direction') : ''}
${buildSelect('discovery-cardFormat', CARD_FORMATS, section.cardFormat || 'Poster', 'Card Format')}
${buildTextInput('discovery-ttl', section.ttl !== undefined ? section.ttl : '', 'TTL (ms)', 'number')}
${buildCheckbox('discovery-spotlight', section.spotlight === true, 'Spotlight')}
</div>
`;
}
/**
* Collect form data from section editor
* @param {boolean} isDiscovery - Whether this is a discovery section
* @param {Object} originalSection - Original section data (for preserving hidden fields in default sections)
*/
function collectSectionEditorData(isDiscovery = false, originalSection = null) {
const dialog = document.querySelector(`.dialogContainer[data-modal-id="${SECTION_EDITOR_MODAL_ID}"]`);
if (!dialog) return null;
if (isDiscovery) {
const renderMode = dialog.querySelector('#discovery-renderMode')?.value || 'Normal';
const filterByPlayedStatus = dialog.querySelector('#discovery-filterByPlayedStatus')?.checked === true;
const isCollections = dialog.querySelector('#discovery-minimumItems') !== null;
const discovery = {
name: dialog.querySelector('#discovery-name')?.value || '',
enabled: dialog.querySelector('#discovery-enabled')?.checked !== false,
renderMode: renderMode,
order: parseInt(dialog.querySelector('#discovery-order')?.value || '0', 10),
itemLimit: parseInt(dialog.querySelector('#discovery-itemLimit')?.value || '20', 10),
sortOrder: dialog.querySelector('#discovery-sortOrder')?.value || 'Random',
sortOrderDirection: dialog.querySelector('#discovery-sortOrderDirection')?.value || undefined,
ttl: dialog.querySelector('#discovery-ttl')?.value ? parseInt(dialog.querySelector('#discovery-ttl').value, 10) : undefined
};
// Only set cardFormat if renderMode is not Spotlight
if (renderMode !== 'Spotlight') {
discovery.cardFormat = dialog.querySelector('#discovery-cardFormat')?.value || 'Poster';
}
// Add IsUnplayed filter if enabled
if (filterByPlayedStatus) {
discovery.queryOptions = { IsUnplayed: true };
}
// Add minimumItems for Collections
if (isCollections) {
const minimumItems = dialog.querySelector('#discovery-minimumItems')?.value;
if (minimumItems) {
discovery.minimumItems = parseInt(minimumItems, 10);
}
}
return discovery;
}
// Check if this is a default section
const sectionId = dialog.querySelector('.section-save-btn')?.dataset.sectionId;
const defaultFields = sectionId ? getDefaultSectionFields(sectionId) : null;
const isDefaultSection = defaultFields !== null;
// For default sections, start with original data to preserve hidden fields
let section;
if (isDefaultSection && originalSection) {
// Start with a deep copy of the original section
section = JSON.parse(JSON.stringify(originalSection));
// Now update only the editable fields from the UI
section.name = dialog.querySelector('#section-name')?.value || section.name || '';
// Get enabled state from toggle buttons
const enabledBtn = dialog.querySelector('.enabled-toggle-btn.active[data-enabled="true"]');
section.enabled = enabledBtn !== null;
section.order = parseInt(dialog.querySelector('#section-order')?.value || '0', 10);
if (defaultFields.includeCardFormat) {
section.cardFormat = dialog.querySelector('#section-cardFormat')?.value || 'Poster';
}
// Ensure queries array exists
if (!section.queries || section.queries.length === 0) {
section.queries = [{ queryOptions: {} }];
}
// Update only the editable queryOptions fields
if (defaultFields.includeItemLimit) {
const itemLimit = parseInt(dialog.querySelector('#section-itemLimit')?.value || '20', 10);
if (!section.queries[0].queryOptions) section.queries[0].queryOptions = {};
section.queries[0].queryOptions.Limit = itemLimit;
}
if (defaultFields.includeSortOrder) {
const sortBy = dialog.querySelector('#section-sortBy')?.value;
const sortOrder = dialog.querySelector('#section-sortOrder')?.value;
if (sortBy || sortOrder) {
if (!section.queries[0].queryOptions) section.queries[0].queryOptions = {};
if (sortBy) section.queries[0].queryOptions.SortBy = sortBy;
if (sortOrder) section.queries[0].queryOptions.SortOrder = sortOrder;
}
}
if (defaultFields.includePremiereDays) {
const minAgeInDays = parseInt(dialog.querySelector('#section-minAgeInDays')?.value || '0', 10);
const maxAgeInDays = parseInt(dialog.querySelector('#section-maxAgeInDays')?.value || '30', 10);
// Store minAge/maxAge at query level (not in queryOptions)
section.queries[0].minAge = minAgeInDays;
section.queries[0].maxAge = maxAgeInDays;
}
if (defaultFields.includeMinimumShows) {
const minimumShows = parseInt(dialog.querySelector('#section-minimumShowsForNetwork')?.value || '5', 10);
section.minimumShowsForNetwork = minimumShows;
}
// Handle IsUnplayed filter (from toggle button)
// Apply to all queries to ensure toggle is source of truth
const filterToggle = dialog.querySelector('.filter-played-toggle');
const filterByPlayedStatus = filterToggle?.dataset.checked === 'true';
if (defaultFields.includeFilterByPlayedStatus) {
section.queries.forEach(query => {
if (!query.queryOptions) query.queryOptions = {};
if (filterByPlayedStatus) {
query.queryOptions.IsUnplayed = true;
} else {
// Remove IsUnplayed if unchecked (toggle is source of truth)
delete query.queryOptions.IsUnplayed;
}
});
}
const discoveryEnabledToggle = dialog.querySelector('.section-discoveryEnabled-toggle');
if (discoveryEnabledToggle) {
section.discoveryEnabled = discoveryEnabledToggle.dataset.enabled === 'true';
}
return section;
}
// For custom sections, build from scratch (existing logic)
// Get enabled state from toggle buttons
const enabledBtn = dialog.querySelector('.enabled-toggle-btn.active[data-enabled="true"]');
section = {
name: dialog.querySelector('#section-name')?.value || '',
enabled: enabledBtn !== null,
order: parseInt(dialog.querySelector('#section-order')?.value || '0', 10),
};
// Custom sections - collect full editor data
const renderMode = dialog.querySelector('#section-renderMode')?.value || 'Normal';
// Get filter by played status from toggle button
const filterToggle = dialog.querySelector('.filter-played-toggle');
const filterByPlayedStatus = filterToggle?.dataset.checked === 'true';
const discoveryEnabledToggle = dialog.querySelector('.section-discoveryEnabled-toggle');
section.discoveryEnabled = discoveryEnabledToggle?.dataset.enabled === 'true';
section.renderMode = renderMode;
// Only set cardFormat if renderMode is not Spotlight
if (renderMode !== 'Spotlight') {
section.cardFormat = dialog.querySelector('#section-cardFormat')?.value || 'Poster';
}
// Collect queries
const queryEditors = dialog.querySelectorAll('.query-editor');
section.queries = Array.from(queryEditors).map((editor) => {
const queryIndex = parseInt(editor.dataset.queryIndex, 10);
const queryType = editor.querySelector(`#query-${queryIndex}-type`)?.value || 'standard';
const query = {};
if (queryType === 'custom') {
const path = editor.querySelector(`#query-${queryIndex}-path`)?.value;
if (path) query.path = path;
} else if (queryType === 'datasource') {
const dataSource = editor.querySelector(`#query-${queryIndex}-dataSource`)?.value;
if (dataSource) query.dataSource = dataSource;
}
// Collect query options
query.queryOptions = {};
// Add IsUnplayed filter if Filter by Played Status is enabled
if (filterByPlayedStatus) {
query.queryOptions.IsUnplayed = true;
}
// Collect badge autocomplete values from hidden inputs
const includeItemTypesHidden = editor.querySelector(`.query_includeItemTypes_hidden[data-section-index="${queryIndex}"]`) ||
editor.closest('.query-editor')?.querySelector(`.query_includeItemTypes_hidden[data-section-index="${queryIndex}"]`);
if (includeItemTypesHidden && includeItemTypesHidden.value) {
query.queryOptions.IncludeItemTypes = includeItemTypesHidden.value.split(',').map(s => s.trim()).filter(s => s);
}
const genresHidden = editor.querySelector(`.query_genres_hidden[data-section-index="${queryIndex}"]`) ||
editor.closest('.query-editor')?.querySelector(`.query_genres_hidden[data-section-index="${queryIndex}"]`);
if (genresHidden && genresHidden.value) {
query.queryOptions.Genres = genresHidden.value.split(',').map(s => s.trim()).filter(s => s).join(',');
}
const tagsHidden = editor.querySelector(`.query_tags_hidden[data-section-index="${queryIndex}"]`) ||
editor.closest('.query-editor')?.querySelector(`.query_tags_hidden[data-section-index="${queryIndex}"]`);
if (tagsHidden && tagsHidden.value) {
query.queryOptions.Tags = tagsHidden.value.split(',').map(s => s.trim()).filter(s => s).join(',');
}
// Handle parent item type - only one can be set per query
const parentItemTypeSelect = editor.querySelector(`#query-${queryIndex}-parentItemType`);
const parentItemType = parentItemTypeSelect?.value || '';
if (parentItemType === 'Collection') {
const collectionsHidden = editor.querySelector(`.query_collection_hidden[data-section-index="${queryIndex}"]`) ||
editor.closest('.query-editor')?.querySelector(`.query_collection_hidden[data-section-index="${queryIndex}"]`);
if (collectionsHidden && collectionsHidden.value) {
// Extract collection IDs and use the first one for ParentId
const collectionIds = collectionsHidden.value.split(',').map(s => s.trim()).filter(s => s);
if (collectionIds.length > 0) {
query.queryOptions.ParentId = collectionIds[0]; // Use first collection ID as ParentId
// Store at query level (not in queryOptions)
query._parentItemType = 'Collection';
}
}
// Clear other parent types
delete query.queryOptions.PlaylistIds;
delete query.queryOptions.CollectionIds;
delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility)
} else if (parentItemType === 'Playlist') {
const playlistsHidden = editor.querySelector(`.query_playlist_hidden[data-section-index="${queryIndex}"]`) ||
editor.closest('.query-editor')?.querySelector(`.query_playlist_hidden[data-section-index="${queryIndex}"]`);
if (playlistsHidden && playlistsHidden.value) {
// Extract playlist IDs and use the first one for ParentId
const playlistIds = playlistsHidden.value.split(',').map(s => s.trim()).filter(s => s);
if (playlistIds.length > 0) {
query.queryOptions.ParentId = playlistIds[0]; // Use first playlist ID as ParentId
// Store at query level (not in queryOptions)
query._parentItemType = 'Playlist';
}
}
// Clear other parent types
delete query.queryOptions.CollectionIds;
delete query.queryOptions.PlaylistIds;
delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility)
} else if (parentItemType === 'Generic Parent') {
const parentIdInput = editor.querySelector(`#query-${queryIndex}-ParentId`);
if (parentIdInput && parentIdInput.value) {
query.queryOptions.ParentId = parentIdInput.value.trim();
}
// Clear other parent types and metadata
delete query.queryOptions.CollectionIds;
delete query.queryOptions.PlaylistIds;
delete query._parentItemType;
delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility)
} else {
// No parent item selected - clear all parent types and metadata
delete query.queryOptions.CollectionIds;
delete query.queryOptions.PlaylistIds;
delete query.queryOptions.ParentId;
delete query._parentItemType;
delete query.queryOptions._parentItemType; // Remove from queryOptions if it exists (backwards compatibility)
}
// Fallback: try to get from badge containers directly
if (!includeItemTypesHidden) {
const badgeContainer = editor.querySelector(`#query-${queryIndex}-IncludeItemTypes-container`);
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
if (badges.length > 0) {
query.queryOptions.IncludeItemTypes = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean);
}
}
}
if (!genresHidden) {
const badgeContainer = editor.querySelector(`#query-${queryIndex}-Genres-container`);
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
if (badges.length > 0) {
query.queryOptions.Genres = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean).join(',');
}
}
}
if (!tagsHidden) {
const badgeContainer = editor.querySelector(`#query-${queryIndex}-Tags-container`);
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
if (badges.length > 0) {
query.queryOptions.Tags = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean).join(',');
}
}
}
/* if (!collectionsHidden) {
const badgeContainer = editor.querySelector(`#query-${queryIndex}-Collections-container`);
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
if (badges.length > 0) {
query.queryOptions.CollectionIds = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean);
}
}
}
if (!playlistsHidden) {
const badgeContainer = editor.querySelector(`#query-${queryIndex}-Playlists-container`);
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
if (badges.length > 0) {
query.queryOptions.PlaylistIds = Array.from(badges).map(b => b.getAttribute('data-value')).filter(Boolean);
}
}
} */
['SortBy', 'SortOrder', 'Filters', 'SearchTerm'].forEach(key => {
const value = editor.querySelector(`#query-${queryIndex}-${key}`)?.value;
if (value) query.queryOptions[key] = value;
});
const limit = editor.querySelector(`#query-${queryIndex}-Limit`)?.value;
if (limit) query.queryOptions.Limit = parseInt(limit, 10);
// Collect additional options from list
const additionalOptionsList = editor.querySelector(`.additional-options-list[data-query-index="${queryIndex}"]`);
if (additionalOptionsList) {
const optionRows = additionalOptionsList.querySelectorAll('.additional-option-row');
optionRows.forEach(row => {
const keyInput = row.querySelector('.additional-option-key');
const typeInput = row.querySelector('.additional-option-type');
const valueInput = row.querySelector('.additional-option-value');
if (valueInput) {
const key = valueInput.dataset.key;
const type = valueInput.dataset.type || 'string';
if (key) {
let value;
if (type === 'boolean') {
value = valueInput.checked === true;
} else if (type === 'number') {
value = valueInput.value ? parseFloat(valueInput.value) : undefined;
} else if (type === 'array') {
value = valueInput.value ? valueInput.value.split(',').map(s => s.trim()).filter(s => s) : [];
} else {
value = valueInput.value || undefined;
}
if (value !== undefined && value !== null && value !== '') {
query.queryOptions[key] = value;
}
}
}
});
}
// Ensure IsUnplayed is removed if toggle is OFF (toggle is source of truth)
// This must happen after collecting all options to override any IsUnplayed from additional options
if (!filterByPlayedStatus) {
delete query.queryOptions.IsUnplayed;
}
// Ensure queryOptions exists even if empty
if (Object.keys(query.queryOptions).length === 0 && !query.path && !query.dataSource) {
query.queryOptions = {};
}
return query;
});
// Collect advanced options
const ttl = dialog.querySelector('#section-ttl')?.value;
if (ttl) section.ttl = parseInt(ttl, 10);
section.flattenSeries = dialog.querySelector('#section-flattenSeries')?.checked === true;
const minimumItems = dialog.querySelector('#section-minimumItems')?.value;
if (minimumItems) section.minimumItems = parseInt(minimumItems, 10);
const startDate = dialog.querySelector('#section-startDate')?.value;
const endDate = dialog.querySelector('#section-endDate')?.value;
if (startDate) section.startDate = startDate;
if (endDate) section.endDate = endDate;
// Multi-query sorting
if (section.queries.length > 1) {
const sortBy = dialog.querySelector('#section-sortBy')?.value;
const sortOrder = dialog.querySelector('#section-sortOrder')?.value;
if (sortBy) section.sortBy = sortBy;
if (sortOrder) section.sortOrder = sortOrder;
}
// Populate the _targetGroupName with the group name specified in the section editor
const targetGroupName = dialog.querySelector('select#section-group')?.value;
if (targetGroupName) section._targetGroupName = targetGroupName;
return section;
}
/**
* Collect all settings from form
*/
function collectGlobalSettings() {
const dialog = document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"]`);
if (!dialog) return {};
const discovery = {
enabled: dialog.querySelector('#discovery-enabled')?.checked !== false,
infiniteScroll: dialog.querySelector('#discovery-infiniteScroll')?.checked !== false,
minPeopleAppearances: parseInt(dialog.querySelector('#discovery-minPeopleAppearances')?.value || '10', 10),
minGenreMovieCount: parseInt(dialog.querySelector('#discovery-minGenreMovieCount')?.value || '50', 10),
defaultItemLimit: parseInt(dialog.querySelector('#discovery-defaultItemLimit')?.value || '16', 10),
defaultSortOrder: dialog.querySelector('#discovery-defaultSortOrder')?.value || 'Random',
defaultCardFormat: dialog.querySelector('#discovery-defaultCardFormat')?.value || 'Poster',
spotlightDiscoveryChance: parseFloat(dialog.querySelector('#discovery-spotlightDiscoveryChance')?.value || '0.5'),
renderSpotlightAboveMatching: dialog.querySelector('#discovery-renderSpotlightAboveMatching')?.checked === true,
randomizeOrder: dialog.querySelector('#discovery-randomizeOrder')?.checked === true
};
const seasonal = {
enabled: dialog.querySelector('#seasonal-enabled')?.checked !== false,
enableSeasonalAnimations: dialog.querySelector('#seasonal-enableSeasonalAnimations')?.checked !== false,
enableSeasonalBackground: dialog.querySelector('#seasonal-enableSeasonalBackground')?.checked !== false,
seasonToggles: (window.KefinHomeConfig2?.SEASONAL_THEME_SETTINGS?.seasonToggles || []).map(season => ({
...season,
enabled: dialog.querySelector(`#seasonal-${season.id}`)?.checked === true
}))
};
const cache = {
DEFAULT_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-DEFAULT_TTL')?.value || '30', 10)),
VERY_SHORT_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-VERY_SHORT_TTL')?.value || '1', 10)),
SHORT_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-SHORT_TTL')?.value || '5', 10)),
LONG_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-LONG_TTL')?.value || '1440', 10)),
STATIC_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-STATIC_TTL')?.value || '10080', 10)),
DISCOVERY_TTL: minutesToMs(parseInt(dialog.querySelector('#cache-DISCOVERY_TTL')?.value || '60', 10)),
FORCE_REFRESH_TTL: 0
};
const mergeNextUp = dialog.querySelector('#merge-next-up')?.checked === true;
return {
DISCOVERY_SETTINGS: discovery,
SEASONAL_THEME_SETTINGS: seasonal,
CACHE: cache,
MERGE_NEXT_UP: mergeNextUp
};
}
/**
* Open section editor modal
*/
async function openSectionEditor(section, type = 'home') {
const isDiscovery = type === 'discovery';
const modalId = isDiscovery ? DISCOVERY_EDITOR_MODAL_ID : SECTION_EDITOR_MODAL_ID;
// Close if already open
if (window.ModalSystem.isOpen(modalId)) {
window.ModalSystem.close(modalId);
}
// Store original section for default sections (to preserve hidden fields)
const originalSection = JSON.parse(JSON.stringify(section));
const content = document.createElement('div');
content.innerHTML = buildSectionEditorHTML(section, isDiscovery);
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.style.gap = '0.5em';
footer.style.display = 'flex';
footer.innerHTML = `
<button class="emby-button raised section-preview-btn" data-section-id="${section.id}" data-section-type="${type}" style="background: rgba(0, 164, 220, 0.2);">Preview</button>
<button class="emby-button raised" onclick="window.ModalSystem.close('${modalId}')">Cancel</button>
<button class="emby-button raised block button-submit section-save-btn" data-section-id="${section.id}" data-section-type="${type}">Save</button>
`;
window.ModalSystem.create({
id: modalId,
title: `Edit Section: ${section.name || 'Unnamed'}`,
content: content,
footer: footer,
closeOnBackdrop: false,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modalInstance) => {
// Attach event listeners
const saveBtn = modalInstance.dialogFooter.querySelector('.section-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const sectionData = collectSectionEditorData(isDiscovery, originalSection);
if (!sectionData) {
showToast('Error collecting section data');
return;
}
// Preserve section ID
sectionData.id = section.id;
updateSectionInConfig(sectionData);
// Save immediately
await saveConfig(currentConfig);
// Close editor first
window.ModalSystem.close(modalId);
// Refresh main modal and restore tab functionality
refreshMainModal();
});
}
const updateSectionInConfig = (sectionData) => {
const found = findSectionInAllGroups(currentConfig, sectionData.id);
// Handle group assignment
const targetGroupName = sectionData._targetGroupName;
delete sectionData._targetGroupName; // Remove metadata
if (found) {
// Check if the section is in the target group
if (targetGroupName && targetGroupName !== found.group.name) {
// Remove the section from the current group
found.group.sections.splice(found.sectionIndex, 1);
// Add the section to the target group
addSectionToGroup(currentConfig[found.groupType], targetGroupName, sectionData);
return;
}
// Update the current config with the updated section data
updateSectionInGroups(currentConfig[found.groupType], sectionData.id, sectionData);
return;
}
// Add the section to the config
addSectionToGroup(currentConfig.CUSTOM_SECTION_GROUPS, 'Custom Sections', sectionData);
}
// Preview button handler
const previewBtn = modalInstance.dialogFooter.querySelector('.section-preview-btn');
if (previewBtn) {
previewBtn.addEventListener('click', async () => {
// Collect current section data from editor
const sectionData = collectSectionEditorData(isDiscovery, originalSection);
if (!sectionData) {
showToast('Error collecting section data for preview');
return;
}
// Merge with original section to preserve ID and all queryOptions fields
const previewSectionConfig = { ...originalSection, ...sectionData };
await previewSection(previewSectionConfig, type, previewBtn);
});
}
// Add query button handler
const addQueryBtn = modalInstance.dialogContent.querySelector('.add-query-btn');
if (addQueryBtn) {
addQueryBtn.addEventListener('click', () => {
const queriesList = modalInstance.dialogContent.querySelector('#queries-list');
const newIndex = queriesList.querySelectorAll('.query-editor').length;
const newQueryHTML = buildQueryEditor({}, newIndex, true);
queriesList.insertAdjacentHTML('beforeend', newQueryHTML);
// Setup additional options listeners for new query
setupAdditionalOptionsListeners(modalInstance, newIndex);
// Setup badge autocomplete for new query
(async () => {
const emptyQueryOptions = {};
await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'IncludeItemTypes', emptyQueryOptions);
await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'Genres', emptyQueryOptions, ['Movie']);
await setupQueryBadgeAutocomplete(modalInstance, newIndex, 'Tags', emptyQueryOptions, ['Movie']);
// Collections/Playlists will be set up when parent item type is selected
})();
// Setup query type change handler for new query
const newQueryEditor = queriesList.querySelector(`.query-editor[data-query-index="${newIndex}"]`);
if (newQueryEditor) {
const typeSelect = newQueryEditor.querySelector(`#query-${newIndex}-type`);
if (typeSelect) {
const pathContainer = newQueryEditor.querySelector(`#query-${newIndex}-path-container`);
const dataSourceContainer = newQueryEditor.querySelector(`#query-${newIndex}-dataSource-container`);
typeSelect.addEventListener('change', () => {
const newQueryType = typeSelect.value;
if (pathContainer) pathContainer.style.display = newQueryType === 'custom' ? 'block' : 'none';
if (dataSourceContainer) dataSourceContainer.style.display = newQueryType === 'datasource' ? 'block' : 'none';
});
}
}
// Re-attach delete handlers for all queries
modalInstance.dialogContent.querySelectorAll('.delete-query-btn').forEach(btn => {
if (!btn.dataset.listenerAttached) {
btn.dataset.listenerAttached = 'true';
btn.addEventListener('click', () => {
const index = parseInt(btn.dataset.queryIndex, 10);
const queryEditor = modalInstance.dialogContent.querySelector(`.query-editor[data-query-index="${index}"]`);
if (queryEditor) {
queryEditor.remove();
// Re-index remaining queries
modalInstance.dialogContent.querySelectorAll('.query-editor').forEach((editor, newIndex) => {
editor.dataset.queryIndex = newIndex;
const titleEl = editor.querySelector('.listItemBodyText');
if (titleEl) titleEl.textContent = `Query ${newIndex + 1}`;
});
// Show/hide merge sorting based on query count
const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length;
const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]');
if (mergeSorting) {
mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none';
}
}
});
}
});
// Show merge sorting if multiple queries
const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length;
const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]');
if (mergeSorting) {
mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none';
}
});
}
// Delete query button handlers
modalInstance.dialogContent.querySelectorAll('.delete-query-btn').forEach(btn => {
btn.addEventListener('click', () => {
const index = parseInt(btn.dataset.queryIndex, 10);
const queryEditor = modalInstance.dialogContent.querySelector(`.query-editor[data-query-index="${index}"]`);
if (queryEditor) {
queryEditor.remove();
// Re-index remaining queries
modalInstance.dialogContent.querySelectorAll('.query-editor').forEach((editor, newIndex) => {
editor.dataset.queryIndex = newIndex;
const titleEl = editor.querySelector('.listItemBodyText');
if (titleEl) titleEl.textContent = `Query ${newIndex + 1}`;
// Update all input IDs
editor.querySelectorAll('input, select, textarea').forEach(input => {
const oldId = input.id;
if (oldId && oldId.includes(`query-${index}-`)) {
input.id = oldId.replace(`query-${index}-`, `query-${newIndex}-`);
const label = editor.querySelector(`label[for="${oldId}"]`);
if (label) label.setAttribute('for', input.id);
}
});
});
// Show/hide merge sorting based on query count
const queriesCount = modalInstance.dialogContent.querySelectorAll('.query-editor').length;
const mergeSorting = modalInstance.dialogContent.querySelector('[data-merge-sorting]');
if (mergeSorting) {
mergeSorting.style.display = queriesCount > 1 ? 'block' : 'none';
}
}
});
});
// Handle query type changes to show/hide relevant fields
modalInstance.dialogContent.querySelectorAll('[id^="query-"][id$="-type"]').forEach(select => {
const queryEditor = select.closest('.query-editor');
const queryIndex = queryEditor.dataset.queryIndex;
// Set initial state
const queryType = select.value;
const pathContainer = queryEditor.querySelector(`#query-${queryIndex}-path-container`);
const dataSourceContainer = queryEditor.querySelector(`#query-${queryIndex}-dataSource-container`);
if (pathContainer) pathContainer.style.display = queryType === 'custom' ? 'block' : 'none';
if (dataSourceContainer) dataSourceContainer.style.display = queryType === 'datasource' ? 'block' : 'none';
// Handle changes
select.addEventListener('change', () => {
const newQueryType = select.value;
if (pathContainer) pathContainer.style.display = newQueryType === 'custom' ? 'block' : 'none';
if (dataSourceContainer) dataSourceContainer.style.display = newQueryType === 'datasource' ? 'block' : 'none';
});
});
// Handle parent item type dropdown changes
modalInstance.dialogContent.querySelectorAll('[id^="query-"][id$="-parentItemType"]').forEach(select => {
const queryEditor = select.closest('.query-editor');
const queryIndex = parseInt(queryEditor.dataset.queryIndex, 10);
const collectionsContainer = queryEditor.querySelector(`#query-${queryIndex}-Collections-container`);
const playlistsContainer = queryEditor.querySelector(`#query-${queryIndex}-Playlists-container`);
const genericParentContainer = queryEditor.querySelector(`#query-${queryIndex}-GenericParent-container`);
const hiddenParentItemType = queryEditor.querySelector(`#query-${queryIndex}-ParentItemType`);
// Helper function to setup autocomplete for a container
const setupAutocompleteForType = async (containerType, container) => {
if (!container) {
WARN(`Container not found for ${containerType} at query index ${queryIndex}`);
return;
}
// Check if autocomplete is already set up by looking for the autocomplete input
const existingInput = container.querySelector('.autocomplete-input');
if (existingInput) {
LOG(`Autocomplete already set up for ${containerType} at query index ${queryIndex}`);
return; // Already set up
}
LOG(`Setting up autocomplete for ${containerType} at query index ${queryIndex}`);
// Get query and queryOptions from the section's queries array (passed to openSectionEditor)
// Fallback to empty object if not available
const query = (section.queries && section.queries[queryIndex]) ? section.queries[queryIndex] : {};
const queryOptions = query.queryOptions || {};
const queryParentItemType = query._parentItemType || null;
await setupQueryBadgeAutocomplete(modalInstance, queryIndex, containerType, queryOptions, null, queryParentItemType);
};
// Set initial state and setup autocomplete if needed
const parentItemType = select.value;
// Set parent item type data attribute
const parentElement = select.parentElement?.parentElement;
if (parentElement) {
parentElement.dataset.parentItemType = parentItemType;
}
// Set initial display states
if (collectionsContainer) {
collectionsContainer.style.display = parentItemType === 'Collection' ? 'block' : 'none';
}
if (playlistsContainer) {
playlistsContainer.style.display = parentItemType === 'Playlist' ? 'block' : 'none';
}
if (genericParentContainer) {
genericParentContainer.style.display = parentItemType === 'Generic Parent' ? 'block' : 'none';
}
if (hiddenParentItemType) hiddenParentItemType.value = parentItemType;
// Setup autocomplete for initially visible containers
if (collectionsContainer && parentItemType === 'Collection') {
setupAutocompleteForType('Collections', collectionsContainer).catch(err => {
ERR('Error setting up Collections autocomplete:', err);
});
}
if (playlistsContainer && parentItemType === 'Playlist') {
setupAutocompleteForType('Playlists', playlistsContainer).catch(err => {
ERR('Error setting up Playlists autocomplete:', err);
});
}
// Handle changes
select.addEventListener('change', () => {
const newParentItemType = select.value;
// Select list immediate parent element
const parentElement = select.parentElement?.parentElement;
if (parentElement) {
parentElement.dataset.parentItemType = newParentItemType;
}
// Update display styles synchronously
if (collectionsContainer) {
collectionsContainer.style.display = newParentItemType === 'Collection' ? 'block' : 'none';
}
if (playlistsContainer) {
playlistsContainer.style.display = newParentItemType === 'Playlist' ? 'block' : 'none';
}
if (genericParentContainer) {
genericParentContainer.style.display = newParentItemType === 'Generic Parent' ? 'block' : 'none';
}
if (hiddenParentItemType) hiddenParentItemType.value = newParentItemType;
// Force synchronous repaint by accessing layout property
if (collectionsContainer) void collectionsContainer.offsetHeight;
if (playlistsContainer) void playlistsContainer.offsetHeight;
if (genericParentContainer) void genericParentContainer.offsetHeight;
// Defer async autocomplete setup to allow browser to paint first
requestAnimationFrame(async () => {
if (collectionsContainer && newParentItemType === 'Collection') {
await setupAutocompleteForType('Collections', collectionsContainer);
}
if (playlistsContainer && newParentItemType === 'Playlist') {
await setupAutocompleteForType('Playlists', playlistsContainer);
}
});
});
});
// Setup additional options listeners for all queries
modalInstance.dialogContent.querySelectorAll('.query-editor').forEach(queryEditor => {
const queryIndex = parseInt(queryEditor.dataset.queryIndex, 10);
setupAdditionalOptionsListeners(modalInstance, queryIndex);
});
// Setup badge autocomplete for all queries (only for custom sections, not default sections)
const queriesList = modalInstance.dialogContent.querySelector('#queries-list');
if (queriesList && !isDiscovery) {
// This is a custom section with queries editor
const queries = section.queries || [{}];
queries.forEach(async (query, queryIndex) => {
const queryOptions = query.queryOptions || {};
const includeItemTypes = queryOptions.IncludeItemTypes;
// Always setup: IncludeItemTypes, Genres, Tags
await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'IncludeItemTypes', queryOptions, null, query._parentItemType);
await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Genres', queryOptions, includeItemTypes, query._parentItemType);
await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Tags', queryOptions, includeItemTypes, query._parentItemType);
// Conditionally setup Collections/Playlists based on parent item type
const queryEditor = queriesList.querySelector(`.query-editor[data-query-index="${queryIndex}"]`);
// Check query level first, then queryOptions for backwards compatibility
let parentItemType = query._parentItemType || queryOptions._parentItemType || '';
if (parentItemType === 'Collection') {
await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Collections', queryOptions, null, query._parentItemType);
} else if (parentItemType === 'Playlist') {
await setupQueryBadgeAutocomplete(modalInstance, queryIndex, 'Playlists', queryOptions, null, query._parentItemType);
}
});
}
// Render Mode dropdown - show/hide Card Format
const renderModeSelect = modalInstance.dialogContent.querySelector('#section-renderMode');
const cardFormatContainer = modalInstance.dialogContent.querySelector('#section-cardFormat-container');
if (renderModeSelect && cardFormatContainer) {
const updateCardFormatVisibility = () => {
cardFormatContainer.style.display = renderModeSelect.value === 'Spotlight' ? 'none' : 'block';
};
renderModeSelect.addEventListener('change', updateCardFormatVisibility);
updateCardFormatVisibility(); // Set initial state
}
// Section Group dropdown - show/hide new group input
const groupSelect = modalInstance.dialogContent.querySelector('#section-group');
const newGroupContainer = modalInstance.dialogContent.querySelector('#section-group-new-container');
if (groupSelect && newGroupContainer) {
// Set initial state
if (groupSelect.value === 'New...') {
newGroupContainer.style.display = 'block';
}
groupSelect.addEventListener('change', () => {
newGroupContainer.style.display = groupSelect.value === 'New...' ? 'block' : 'none';
});
}
// Enabled toggle buttons handler
modalInstance.dialogContent.querySelectorAll('.enabled-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const enabled = btn.dataset.enabled === 'true';
// Update all buttons
modalInstance.dialogContent.querySelectorAll('.enabled-toggle-btn').forEach(b => {
const isActive = b.dataset.enabled === String(enabled);
b.classList.toggle('active', isActive);
if (isActive) {
b.style.borderColor = enabled ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.8)';
b.style.background = enabled ? 'rgba(0, 164, 220, 0.2)' : 'rgba(158, 158, 158, 0.2)';
b.style.color = enabled ? 'var(--theme-primary-color, #00a4dc)' : 'rgba(255,255,255,0.9)';
b.style.fontWeight = '600';
} else {
b.style.borderColor = 'rgba(255,255,255,0.2)';
b.style.background = 'transparent';
b.style.color = 'rgba(255,255,255,0.7)';
b.style.fontWeight = '400';
}
});
});
});
// Filter by played status toggle handler
const filterToggle = modalInstance.dialogContent.querySelector('.filter-played-toggle');
if (filterToggle) {
filterToggle.addEventListener('click', () => {
const currentChecked = filterToggle.dataset.checked === 'true';
const newChecked = !currentChecked;
filterToggle.dataset.checked = newChecked;
// Update toggle visual state
filterToggle.style.background = newChecked ? 'rgba(0, 164, 220, 0.8)' : 'rgba(158, 158, 158, 0.5)';
const onText = filterToggle.querySelector('span:first-of-type');
if (onText) {
onText.style.opacity = newChecked ? '1' : '0';
onText.style.left = newChecked ? '8px' : '6px';
}
const offText = filterToggle.querySelector('span:nth-of-type(2)');
if (offText) {
offText.style.opacity = newChecked ? '0' : '1';
offText.style.right = newChecked ? '6px' : '8px';
}
const knob = filterToggle.querySelector('span:last-of-type');
if (knob) {
knob.style.left = newChecked ? 'calc(100% - 26px)' : '3px';
}
});
}
const discoveryEnabledToggle = modalInstance.dialogContent.querySelector('.section-discoveryEnabled-toggle');
if (discoveryEnabledToggle) {
discoveryEnabledToggle.addEventListener('click', () => {
const currentChecked = discoveryEnabledToggle.dataset.enabled === 'true';
const newChecked = !currentChecked;
discoveryEnabledToggle.dataset.enabled = newChecked;
updateToggleSliderUI(discoveryEnabledToggle, newChecked);
});
}
}
});
}
/**
* Refresh main modal content
*/
function refreshMainModal() {
if (!mainModalInstance) return;
// Store current section type if on sections tab
let currentSectionType = 'home';
if (currentActiveTab === 'sections') {
const activeNavBtn = mainModalInstance.dialogContent.querySelector('.section-type-nav-btn.active');
if (activeNavBtn) {
currentSectionType = activeNavBtn.dataset.sectionType;
}
}
// Store current active tab before refresh
const savedActiveTab = currentActiveTab || 'settings';
const content = document.createElement('div');
content.innerHTML = buildMainConfigHTML();
mainModalInstance.updateContent(content);
// Reset the listeners flag so they can be re-attached to the new content
/* if (mainModalInstance.dialog) {
delete mainModalInstance.dialog.dataset.mainModalListenersAttached;
delete mainModalInstance.dialog.dataset.sectionListenersAttached;
}
// Re-attach event listeners (including tab listeners)
attachMainModalListeners();
attachTabListeners(mainModalInstance); */
// Restore active tab
if (savedActiveTab) {
const dialog = mainModalInstance.dialogContainer;
if (dialog) {
const tabBtn = dialog.querySelector(`.config-tab-btn[data-tab="${savedActiveTab}"]`);
if (tabBtn) {
tabBtn.click();
}
}
}
// If we were on sections tab, restore the section type navigation
if (currentActiveTab === 'sections') {
const dialog = mainModalInstance.dialogContainer;
const navBtns = dialog.querySelectorAll('.section-type-nav-btn');
navBtns.forEach(btn => {
if (btn.dataset.sectionType === currentSectionType) {
btn.click();
}
});
}
}
/**
* Setup additional options add/remove listeners for a query
*/
function setupAdditionalOptionsListeners(modalInstance, queryIndex) {
const dialog = modalInstance.dialogContainer;
const controls = dialog.querySelector(`.query-${queryIndex}-additional-options-controls[data-query-index="${queryIndex}"]`);
const listContainer = dialog.querySelector(`.query-${queryIndex}-additional-options-list-container[data-query-index="${queryIndex}"]`);
if (controls) {
const addBtn = controls.querySelector('.add-additional-option');
const select = controls.querySelector('.additional-option-select');
const list = listContainer ? listContainer.querySelector('.additional-options-list') : null;
if (addBtn && select && list) {
addBtn.addEventListener('click', () => {
const key = select.value;
if (!key) return;
const meta = SUPPORTED_QUERY_OPTIONS[key];
if (!meta) return;
const index = list.children.length;
const inputId = `query-${queryIndex}-additionalOption-${Date.now()}_${index}`;
let inputField = '';
if (meta.type === 'boolean') {
inputField = `
<div style="flex: 1;">
<label class="checkboxContainer" style="display: flex; align-items: center; gap: 0.5em;">
<input type="checkbox" id="${inputId}" class="additional-option-value" data-key="${key}" data-type="boolean" ${meta.default === true ? 'checked' : ''}>
<span class="listItemBodyText">${meta.label}</span>
</label>
</div>
`;
} else {
inputField = `
<div style="flex: 1;">
<div class="listItemBodyText" style="margin-bottom: 0.25em;">${meta.label}</div>
<input type="${meta.type === 'number' ? 'number' : (meta.type === 'date' ? 'date' : 'text')}"
id="${inputId}"
class="fld emby-input additional-option-value"
value=""
data-key="${key}"
data-type="${meta.type}"
placeholder="${meta.hint || ''}"
style="width: 100%;">
</div>
`;
}
const row = document.createElement('div');
row.className = 'additional-option-row';
row.style.cssText = 'display: flex; align-items: flex-end; gap: 0.5em; margin-top: 0.5em; padding: 0.5em; background: rgba(255,255,255,0.05); border-radius: 4px;';
row.innerHTML = `
${inputField}
<button type="button" class="remove-additional-option" style="background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0.25em; display: flex; align-items: center;" title="Remove Option">
<span class="material-icons">close</span>
</button>
`;
// Add delete listener for new row
row.querySelector('.remove-additional-option').addEventListener('click', () => {
row.remove();
});
list.appendChild(row);
// Reset select
select.value = '';
});
}
}
// Add listeners for existing delete buttons
if (listContainer) {
listContainer.querySelectorAll('.remove-additional-option').forEach(btn => {
btn.addEventListener('click', (e) => {
const row = e.target.closest('.additional-option-row');
if (row) row.remove();
});
});
}
}
/**
* Render home sections order editor
* @param {Array} sections - Flattened array of sections from HOME_SECTIONS, SEASONAL_SECTIONS, CUSTOM_SECTIONS
*/
function renderHomeSectionsOrderEditor(sections) {
if (!sections || !Array.isArray(sections)) {
return '<div class="listItemBodyText secondary">No sections found.</div>';
}
const filteredSections = sections.filter(s => !s.hidden);
// Verify sections are ordered by .order value and sort if needed
const sortedSections = [...filteredSections].sort((a, b) => {
const orderA = a.order || 0;
const orderB = b.order || 0;
return orderA - orderB;
});
if (sortedSections.length === 0) {
return '<div class="listItemBodyText secondary">No sections to order.</div>';
}
// Create a dummy group with all sections to reuse buildGroupHTML
const dummyGroup = {
name: 'All Home Sections',
sections: sortedSections,
author: ''
};
return `
${buildGroupHTML(dummyGroup, 'all')}
`;
}
/**
* Attach tab switching listeners
*/
function attachTabListeners(modalInstance) {
const dialog = modalInstance.dialogContainer;
if (!dialog) return;
// Use event delegation to prevent stacking listeners when switching tabs
dialog.addEventListener('click', (e) => {
const btn = e.target.closest('.config-tab-btn');
if (!btn) return;
const targetTab = btn.dataset.tab;
// Update the current active tab tracker
currentActiveTab = targetTab;
const tabButtons = dialog.querySelectorAll('.config-tab-btn');
const tabContents = dialog.querySelectorAll('.config-tab-content');
// Update button states
tabButtons.forEach(b => {
b.classList.remove('active');
b.style.borderBottomColor = 'transparent';
b.style.color = 'rgba(255,255,255,0.7)';
});
btn.classList.add('active');
btn.style.borderBottomColor = 'var(--theme-primary-color, #00a4dc)';
btn.style.color = 'var(--theme-primary-color, #00a4dc)';
// Update tab content visibility
tabContents.forEach(content => {
content.style.display = 'none';
});
const targetContent = dialog.querySelector(`#tab-${targetTab}`);
if (targetContent) {
targetContent.style.display = 'block';
// Refresh tab content based on currentConfig when switching tabs
if (targetTab === 'order') {
// Order Sections tab - refresh with current config
const container = targetContent.querySelector('#order-sections-container');
if (container) {
const allSections = [
...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })),
...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })),
...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' }))
];
container.innerHTML = renderHomeSectionsOrderEditor(allSections);
}
} else if (targetTab === 'sections') {
// Edit Sections tab - refresh section content based on current active section type
const contentContainer = dialog.querySelector('#section-content');
if (contentContainer) {
// Get the currently active section type navigation button
const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active');
const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home';
// Re-render the section content with current config
contentContainer.innerHTML = buildSectionContentHTML(activeType);
}
} else if (targetTab === 'community') {
// Community Sections tab - refresh community sections content
setupCommunityCollectionsTab(dialog);
}
}
});
/* const tabButtons = dialog.querySelectorAll('.config-tab-btn');
const tabContents = dialog.querySelectorAll('.config-tab-content');
tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
const targetTab = btn.dataset.tab;
// Update the current active tab tracker
currentActiveTab = targetTab;
// Update button states
tabButtons.forEach(b => {
b.classList.remove('active');
b.style.borderBottomColor = 'transparent';
b.style.color = 'rgba(255,255,255,0.7)';
});
btn.classList.add('active');
btn.style.borderBottomColor = 'var(--theme-primary-color, #00a4dc)';
btn.style.color = 'var(--theme-primary-color, #00a4dc)';
// Update tab content visibility
tabContents.forEach(content => {
content.style.display = 'none';
});
const targetContent = dialog.querySelector(`#tab-${targetTab}`);
if (targetContent) {
targetContent.style.display = 'block';
// Refresh tab content based on currentConfig when switching tabs
if (targetTab === 'order') {
// Order Sections tab - refresh with current config
const container = targetContent.querySelector('#order-sections-container');
if (container) {
const allSections = [
...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })),
...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })),
...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' }))
];
container.innerHTML = renderHomeSectionsOrderEditor(allSections);
}
} else if (targetTab === 'sections') {
// Edit Sections tab - refresh section content based on current active section type
const contentContainer = dialog.querySelector('#section-content');
if (contentContainer) {
// Get the currently active section type navigation button
const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active');
const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home';
// Re-render the section content with current config
contentContainer.innerHTML = buildSectionContentHTML(activeType);
}
}
}
});
}); */
}
/**
* Attach event listeners to main modal
*/
function attachMainModalListeners(modalInstance) {
const dialog = modalInstance?.dialogContainer ?? document.querySelector(`.dialogContainer[data-modal-id="${MAIN_MODAL_ID}"]`);
if (!dialog) return;
if (dialog.dataset.mainModalListenersAttached === 'true') {
return; // Listeners already attached, skip
}
dialog.dataset.mainModalListenersAttached = 'true';
// Attach initial section row listeners
attachSectionRowListeners(dialog);
// Merge Next Up toggle - update currentConfig immediately and refresh sections tab
// Use event delegation to prevent stacking listeners when switching tabs
/* dialog.addEventListener('click', async (e) => {
const btn = e.target.closest('#merge-next-up');
if (!btn) return;
currentConfig.MERGE_NEXT_UP = btn.checked;
// Hide continue watching and next up sections if merge next up is enabled
const continueWatchingGroup = currentConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Continue Watching');
if (continueWatchingGroup) {
continueWatchingGroup.sections = continueWatchingGroup.sections.map(s => {
if (s.id === 'continueWatchingAndNextUp') {
s.enabled = currentConfig.MERGE_NEXT_UP;
s.hidden = !currentConfig.MERGE_NEXT_UP;
} else {
s.enabled = !currentConfig.MERGE_NEXT_UP;
s.hidden = currentConfig.MERGE_NEXT_UP;
}
return s;
});
// Update continueWatchingGroup in currentConfig
currentConfig.HOME_SECTION_GROUPS = currentConfig.HOME_SECTION_GROUPS.map(g => {
if (g.name === 'Continue Watching') {
return continueWatchingGroup;
}
return g;
});
}
await saveConfig(currentConfig);
refreshMainModal();
}); */
/* const mergeNextUpCheckbox = dialog.querySelector('#merge-next-up');
if (mergeNextUpCheckbox) {
mergeNextUpCheckbox.addEventListener('change', async () => {
currentConfig.MERGE_NEXT_UP = mergeNextUpCheckbox.checked;
// Hide continue watching and next up sections if merge next up is enabled
const continueWatchingGroup = currentConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Continue Watching');
if (continueWatchingGroup) {
continueWatchingGroup.sections = continueWatchingGroup.sections.map(s => {
if (s.id === 'continueWatchingAndNextUp') {
s.enabled = currentConfig.MERGE_NEXT_UP;
s.hidden = !currentConfig.MERGE_NEXT_UP;
} else {
s.enabled = !currentConfig.MERGE_NEXT_UP;
s.hidden = currentConfig.MERGE_NEXT_UP;
}
return s;
});
// Update continueWatchingGroup in currentConfig
currentConfig.HOME_SECTION_GROUPS = currentConfig.HOME_SECTION_GROUPS.map(g => {
if (g.name === 'Continue Watching') {
return continueWatchingGroup;
}
return g;
});
}
await saveConfig(currentConfig);
// Refresh sections tab if it's currently visible
const sectionsTab = dialog.querySelector('#tab-sections');
if (sectionsTab && sectionsTab.style.display !== 'none') {
const contentContainer = dialog.querySelector('#section-content');
if (contentContainer) {
const activeNavBtn = dialog.querySelector('.section-type-nav-btn.active');
const activeType = activeNavBtn ? activeNavBtn.dataset.sectionType : 'home';
contentContainer.innerHTML = buildSectionContentHTML(activeType);
}
}
});
} */
// Group toggle-all buttons
// Use event delegation to prevent stacking listeners when switching tabs
dialog.addEventListener('click', async (e) => {
const btn = e.target.closest('.group-toggle-all-btn');
if (!btn) return;
e.stopPropagation();
const groupId = btn.dataset.groupId;
const currentEnabled = btn.dataset.enabled === 'true';
const newEnabled = !currentEnabled;
const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`);
if (!groupElement) return;
const sectionsList = groupElement.querySelector('.group-sections-list');
if (!sectionsList) return;
// Find all sections in this group and update them
const sectionRows = sectionsList.querySelectorAll('.section-row');
const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId);
// Update all sections in the group
sectionIds.forEach(sectionId => {
const found = findSectionInAllGroups(currentConfig, sectionId);
if (found && found.section) {
found.section.enabled = newEnabled;
}
});
await saveConfig(currentConfig);
// Update UI
btn.dataset.enabled = newEnabled;
btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All';
// Update all toggle switches in the group
sectionRows.forEach(row => {
const toggleBtn = row.querySelector('.toggle-slider');
if (toggleBtn) {
updateToggleSliderUI(toggleBtn, newEnabled);
}
});
});
/* dialog.querySelectorAll('.group-toggle-all-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const groupId = btn.dataset.groupId;
const currentEnabled = btn.dataset.enabled === 'true';
const newEnabled = !currentEnabled;
const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`);
if (!groupElement) return;
const sectionsList = groupElement.querySelector('.group-sections-list');
if (!sectionsList) return;
// Find all sections in this group and update them
const sectionRows = sectionsList.querySelectorAll('.section-row');
const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId);
// Update all sections in the group
sectionIds.forEach(sectionId => {
const found = findSectionInAllGroups(currentConfig, sectionId);
if (found && found.section) {
found.section.enabled = newEnabled;
}
});
await saveConfig(currentConfig);
// Update UI
btn.dataset.enabled = newEnabled;
btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All';
// Update all toggle switches in the group
sectionRows.forEach(row => {
const toggleBtn = row.querySelector('.toggle-slider');
if (toggleBtn) {
updateToggleSliderUI(toggleBtn, newEnabled);
}
});
});
}); */
// Add custom section button - use event delegation
dialog.addEventListener('click', (e) => {
const btn = e.target.closest('.add-custom-section-btn') || e.target.closest('.section-type-nav-btn[data-section-type="create"]');
if (!btn) return;
const newSection = {
id: `custom-${Date.now()}`,
name: 'New Custom Section',
enabled: true,
order: 100,
cardFormat: 'Poster',
queries: [{
queryOptions: {
SortBy: 'Random',
Limit: 20
}
}]
};
openSectionEditor(newSection, 'custom');
});
// Section type toggle buttons - use event delegation
dialog.addEventListener('click', (e) => {
const btn = e.target.closest('.section-type-toggle-btn');
if (!btn) return;
toggleSectionType(e);
});
// Section type navigation buttons
// Use event delegation to prevent stacking listeners when switching tabs
dialog.addEventListener('click', (e) => {
const btn = e.target.closest('.section-type-nav-btn:not([data-section-type="create"])');
if (!btn) return;
const sectionType = btn.dataset.sectionType;
// Update active button
dialog.querySelectorAll('.section-type-nav-btn').forEach(b => {
b.classList.remove('active');
b.style.background = 'rgba(255,255,255,0.05)';
b.style.borderColor = 'rgba(255,255,255,0.1)';
b.style.color = 'inherit';
const label = b.querySelector('.listItemBodyText');
if (label) label.style.fontWeight = '400';
});
btn.classList.add('active');
btn.style.background = 'rgba(0, 164, 220, 0.2)';
btn.style.borderColor = 'rgba(0, 164, 220, 0.5)';
btn.style.color = 'var(--theme-primary-color, #00a4dc)';
const label = btn.querySelector('.listItemBodyText');
if (label) label.style.fontWeight = '500';
// Update content
const contentContainer = dialog.querySelector('#section-content');
if (contentContainer) {
contentContainer.innerHTML = buildSectionContentHTML(sectionType);
}
});
/* dialog.querySelectorAll('.section-type-nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
const sectionType = btn.dataset.sectionType;
// Update active button
dialog.querySelectorAll('.section-type-nav-btn').forEach(b => {
b.classList.remove('active');
b.style.background = 'rgba(255,255,255,0.05)';
b.style.borderColor = 'rgba(255,255,255,0.1)';
b.style.color = 'inherit';
const label = b.querySelector('.listItemBodyText');
if (label) label.style.fontWeight = '400';
});
btn.classList.add('active');
btn.style.background = 'rgba(0, 164, 220, 0.2)';
btn.style.borderColor = 'rgba(0, 164, 220, 0.5)';
btn.style.color = 'var(--theme-primary-color, #00a4dc)';
const label = btn.querySelector('.listItemBodyText');
if (label) label.style.fontWeight = '500';
// Update content
const contentContainer = dialog.querySelector('#section-content');
if (contentContainer) {
contentContainer.innerHTML = buildSectionContentHTML(sectionType);
}
});
}); */
// Community Collections tab setup
setupCommunityCollectionsTab(dialog);
// Export Sections button
const exportSectionsBtn = dialog.querySelector('.export-sections-btn');
if (exportSectionsBtn) {
exportSectionsBtn.addEventListener('click', () => {
showExportDialog();
});
}
// Import Sections button
const importSectionsBtn = dialog.querySelector('.import-sections-btn');
if (importSectionsBtn) {
importSectionsBtn.addEventListener('click', () => {
showImportDialog();
});
}
}
/**
* Get section order value from config by section ID
*/
function getSectionOrder(sectionId) {
const found = findSectionInAllGroups(currentConfig, sectionId);
return found?.section?.order || 0;
}
/**
* Update section position in place without refreshing the entire modal
* This preserves scroll position and provides a smoother UX
*/
function updateSectionPositionInPlace(draggedElement, container) {
if (!draggedElement || !container) return;
// Get all section rows in the container
const allRows = Array.from(container.querySelectorAll('.section-row'));
if (allRows.length === 0) return;
// Store scroll position - try to find the scrollable parent
const scrollableParent = container.closest('.dialogContent') || container.closest('.dialog') || container.parentElement;
const scrollTop = scrollableParent?.scrollTop || 0;
// Sort rows by their order value from config
allRows.sort((a, b) => {
const aId = a.dataset.sectionId;
const bId = b.dataset.sectionId;
const aOrder = getSectionOrder(aId);
const bOrder = getSectionOrder(bId);
return aOrder - bOrder;
});
// Re-append rows in sorted order and update order display
allRows.forEach(row => {
container.appendChild(row);
// Update the order display in the row
const sectionId = row.dataset.sectionId;
const order = getSectionOrder(sectionId);
// Find all secondary text spans and update the one that shows Order
const secondarySpans = row.querySelectorAll('.listItemBodyText.secondary');
secondarySpans.forEach(span => {
const text = span.textContent.trim();
if (text.startsWith('Order:')) {
span.textContent = `Order: ${order}`;
}
});
});
// Restore scroll position
if (scrollableParent && scrollableParent.scrollTop !== undefined) {
scrollableParent.scrollTop = scrollTop;
}
}
/**
* Calculate new order value based on drag position using DOM elements
* New logic: if section after dropped position exists, use its order - 1; if not, use preceding section order + 1
*/
function calculateNewOrder(draggedElement, targetElement, dropPosition) {
const container = targetElement.parentElement;
const allRows = Array.from(container.querySelectorAll('.section-row'));
// Find the target's position in the DOM
const targetIndex = allRows.indexOf(targetElement);
if (dropPosition === 'above') {
// Dropped above target element
if (targetIndex === 0) {
// Dropped at the beginning - use first section order - 1
const firstSectionId = targetElement.dataset.sectionId;
const firstOrder = getSectionOrder(firstSectionId);
return Math.max(0, firstOrder - 1);
} else {
// Dropped between elements - use target's order - 1 (section after dropped position)
const targetSectionId = targetElement.dataset.sectionId;
const targetOrder = getSectionOrder(targetSectionId);
return Math.max(0, targetOrder - 1);
}
} else {
// dropPosition === 'below'
if (targetIndex === allRows.length - 1) {
// Dropped at the end - use last section order + 1 (preceding section + 1)
const lastSectionId = targetElement.dataset.sectionId;
const lastOrder = getSectionOrder(lastSectionId);
return lastOrder + 1;
} else {
// Dropped between elements - check if section after exists
const nextElement = allRows[targetIndex + 1];
if (nextElement && nextElement !== draggedElement) {
// Section after exists - use its order - 1
const nextSectionId = nextElement.dataset.sectionId;
const nextOrder = getSectionOrder(nextSectionId);
return Math.max(0, nextOrder - 1);
} else {
// No section after - use preceding section (target) order + 1
const targetSectionId = targetElement.dataset.sectionId;
const targetOrder = getSectionOrder(targetSectionId);
return targetOrder + 1;
}
}
}
}
/**
* Attach listeners to section rows (helper for dynamic content)
* Uses event delegation to prevent stacking listeners when switching tabs
*/
function attachSectionRowListeners(dialog) {
// Check if listeners have already been attached using a data attribute
if (dialog.dataset.sectionListenersAttached === 'true') {
return; // Listeners already attached, skip
}
// Mark that listeners have been attached
dialog.dataset.sectionListenersAttached = 'true';
const defaultSections = flattenSectionGroups(window.KefinHomeConfig2?.HOME_SECTION_GROUPS || []);
const defaultIds = new Set(defaultSections.map(s => s.id));
// Consolidated handler for editable section and group names - use event delegation
dialog.addEventListener('blur', async (e) => {
// Handle section names
const sectionNameEl = e.target.closest('.editable-section-name');
if (sectionNameEl) {
const sectionId = sectionNameEl.dataset.sectionId;
const newName = sectionNameEl.value.trim();
if (!newName) {
showToast('Section name cannot be empty');
// Restore old name
const found = findSectionInAllGroups(currentConfig, sectionId);
if (found?.section) {
sectionNameEl.value = found.section.name || 'Unnamed Section';
}
return;
}
// Update section name in config
const found = findSectionInAllGroups(currentConfig, sectionId);
if (found?.section && found.section.name !== newName) {
found.section.name = newName;
await saveConfig(currentConfig);
showToast(`Section renamed to "${newName}"`);
}
return;
}
// Handle group names
const groupNameEl = e.target.closest('.editable-group-name');
if (groupNameEl) {
const groupId = groupNameEl.dataset.groupId;
const newName = groupNameEl.value.trim();
const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`);
const oldName = groupElement?.dataset.groupName;
if (!newName) {
showToast('Group name cannot be empty');
// Restore old name
groupNameEl.value = oldName || 'Unnamed Group';
return;
}
if (oldName === newName) {
return; // No change
}
// Find and update group in config
let groupFound = false;
let foundGroup = null;
let groupArrayType = null;
const allGroupArrays = [
{ groups: currentConfig.HOME_SECTION_GROUPS, type: 'HOME_SECTION_GROUPS' },
{ groups: currentConfig.SEASONAL_SECTION_GROUPS, type: 'SEASONAL_SECTION_GROUPS' },
{ groups: currentConfig.DISCOVERY_SECTION_GROUPS, type: 'DISCOVERY_SECTION_GROUPS' },
{ groups: currentConfig.CUSTOM_SECTION_GROUPS, type: 'CUSTOM_SECTION_GROUPS' }
];
for (const { groups, type } of allGroupArrays) {
if (!groups) continue;
const group = groups.find(g => {
const gId = `group-${g.name || 'unnamed'}-${g.author || ''}`.replace(/[^a-zA-Z0-9-]/g, '-');
return gId === groupId;
});
if (group) {
foundGroup = group;
groupArrayType = type;
groupFound = true;
break;
}
}
if (groupFound && foundGroup) {
// Check if this is a default group (not CUSTOM) and handle _originalName
if (groupArrayType !== 'CUSTOM_SECTION_GROUPS') {
// If _originalName is not set, check if this is a default group
if (!foundGroup._originalName) {
const defaults = window.KefinHomeConfig2;
if (defaults) {
const defaultGroups = defaults[groupArrayType] || [];
const isDefaultGroup = defaultGroups.some(dg => {
const dgKey = dg.author ? `${dg.name}::${dg.author}` : dg.name || 'Unnamed';
const currentKey = foundGroup.author ? `${oldName}::${foundGroup.author}` : oldName || 'Unnamed';
return dgKey === currentKey;
});
// If it's a default group, store the original name
if (isDefaultGroup) {
foundGroup._originalName = oldName;
}
}
}
// If _originalName is already set, preserve it (don't overwrite)
}
// Update the group name
foundGroup.name = newName;
// Update data attribute
groupElement.dataset.groupName = newName;
await saveConfig(currentConfig);
showToast(`Group renamed to "${newName}"`);
} else {
showToast('Could not find group to rename');
groupNameEl.value = oldName || 'Unnamed Group';
}
}
}, true); // Use capture phase
// Consolidated handler for Enter key - prevents default behavior and blurs input
dialog.addEventListener('keydown', (e) => {
const nameEl = e.target.closest('.editable-section-name, .editable-group-name');
if (!nameEl) return;
if (e.key === 'Enter') {
e.preventDefault();
nameEl.blur();
}
}, true);
// Drag and drop handlers - use event delegation
let draggedElement = null;
let dropPosition = 'above'; // Store drop position for drop handler
dialog.addEventListener('dragstart', (e) => {
const row = e.target.closest('.section-row');
if (!row) return;
draggedElement = row;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', row.innerHTML);
row.style.opacity = '0.5';
});
dialog.addEventListener('dragend', (e) => {
const row = e.target.closest('.section-row');
if (!row) return;
row.style.opacity = '1';
// Remove drag-over styling from all rows
dialog.querySelectorAll('.section-row').forEach(r => {
r.style.borderTop = '';
});
});
dialog.addEventListener('dragover', (e) => {
const row = e.target.closest('.section-row');
if (!row) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Calculate drop position based on cursor Y position relative to element center
const rect = row.getBoundingClientRect();
const elementCenterY = rect.top + rect.height / 2;
dropPosition = e.clientY < elementCenterY ? 'above' : 'below';
// Visual feedback: show drop indicator
dialog.querySelectorAll('.section-row').forEach(r => {
r.style.borderTop = '';
});
// Show indicator on the target row based on drop position
row.style.borderTop = '2px solid var(--theme-primary-color, #00a4dc)';
});
dialog.addEventListener('drop', async (e) => {
const row = e.target.closest('.section-row');
if (!row) return;
e.preventDefault();
if (!draggedElement || draggedElement === row) return;
const sectionType = row.dataset.sectionType;
const draggedSectionId = draggedElement.dataset.sectionId;
const draggedSectionType = draggedElement.dataset.sectionType;
// Check if we're on the Order Sections tab
const isOrderTab = currentActiveTab === 'order';
// If on sections tab, restrict dragging within same group
if (!isOrderTab) {
// Find which group the dragged section belongs to
const draggedFound = findSectionInAllGroups(currentConfig, draggedSectionId);
const targetFound = findSectionInAllGroups(currentConfig, row.dataset.sectionId);
// Only allow drop if both sections are in the same group
if (!draggedFound || !targetFound || draggedFound.group !== targetFound.group) {
return;
}
}
// Calculate new order using DOM elements
const newOrder = calculateNewOrder(draggedElement, row, dropPosition);
// Update order in config - find section across all groups
const found = findSectionInAllGroups(currentConfig, draggedSectionId);
if (found) {
found.section.order = newOrder;
// Sort sections within the group by order
found.group.sections.sort((a, b) => (a.order || 0) - (b.order || 0));
}
await saveConfig(currentConfig);
// Update section position in place instead of refreshing entire modal
// This preserves scroll position
let container = e.target.closest('.group-sections-list');
if (container) {
updateSectionPositionInPlace(draggedElement, container);
} else {
// Fallback to refresh if container not found
refreshMainModal();
}
});
// Unified toggle slider click handler - handles both section toggles and settings toggles
dialog.addEventListener('click', async (e) => {
// Check if click is on toggle slider button or its children
const btn = e.target.closest('.toggle-slider');
if (!btn) return;
// Stop propagation to prevent section row click handler from firing
e.stopPropagation();
// Check if this is a section toggle (has data-section-id)
const sectionId = btn.dataset.sectionId;
if (sectionId) {
// Section toggle: update section config and save
const sectionType = btn.dataset.sectionType;
const found = findSectionInAllGroups(currentConfig, sectionId);
const section = found?.section;
if (section) {
section.enabled = !section.enabled;
await saveConfig(currentConfig);
// Update the toggle switch UI
updateToggleSliderUI(btn, section.enabled);
}
} else {
// Settings toggle: toggle hidden checkbox and update UI
const checkboxId = btn.dataset.checkboxId;
if (checkboxId) {
const checkbox = document.getElementById(checkboxId);
if (checkbox) {
checkbox.checked = !checkbox.checked;
// Trigger change event for any listeners (like merge-next-up)
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
updateToggleSliderUI(btn, checkbox.checked);
if (checkboxId === 'merge-next-up') {
currentConfig.MERGE_NEXT_UP = checkbox.checked;
// Hide continue watching and next up sections if merge next up is enabled
const continueWatchingGroup = currentConfig.HOME_SECTION_GROUPS.find(g => g.name === 'Continue Watching');
if (continueWatchingGroup) {
continueWatchingGroup.sections = continueWatchingGroup.sections.map(s => {
if (s.id === 'continueWatchingAndNextUp') {
s.enabled = currentConfig.MERGE_NEXT_UP;
s.hidden = !currentConfig.MERGE_NEXT_UP;
} else {
s.enabled = !currentConfig.MERGE_NEXT_UP;
s.hidden = currentConfig.MERGE_NEXT_UP;
}
return s;
});
// Update continueWatchingGroup in currentConfig
currentConfig.HOME_SECTION_GROUPS = currentConfig.HOME_SECTION_GROUPS.map(g => {
if (g.name === 'Continue Watching') {
return continueWatchingGroup;
}
return g;
});
}
await saveConfig(currentConfig);
refreshMainModal();
}
}
} else {
// Fallback: just toggle the visual state
const currentState = btn.dataset.enabled === 'true';
updateToggleSliderUI(btn, !currentState);
}
}
});
// Group toggle-all buttons - use event delegation
dialog.addEventListener('click', async (e) => {
const btn = e.target.closest('.group-toggle-all-btn');
if (!btn) return;
e.stopPropagation();
const groupId = btn.dataset.groupId;
const currentEnabled = btn.dataset.enabled === 'true';
const newEnabled = !currentEnabled;
const groupElement = dialog.querySelector(`.section-group[data-group-id="${groupId}"]`);
if (!groupElement) return;
const sectionsList = groupElement.querySelector('.group-sections-list');
if (!sectionsList) return;
// Find all sections in this group and update them
const sectionRows = sectionsList.querySelectorAll('.section-row');
const sectionIds = Array.from(sectionRows).map(row => row.dataset.sectionId);
// Update all sections in the group
sectionIds.forEach(sectionId => {
const found = findSectionInAllGroups(currentConfig, sectionId);
if (found && found.section) {
found.section.enabled = newEnabled;
}
});
await saveConfig(currentConfig);
// Update UI
btn.dataset.enabled = newEnabled;
btn.querySelector('span').textContent = newEnabled ? 'Disable All' : 'Enable All';
// Update all toggle switches in the group
sectionRows.forEach(row => {
const toggleBtn = row.querySelector('.toggle-slider');
if (toggleBtn) {
updateToggleSliderUI(toggleBtn, newEnabled);
}
});
});
// Preview section buttons - use event delegation
dialog.addEventListener('click', async (e) => {
const btn = e.target.closest('.section-preview-btn');
if (!btn) return;
e.stopPropagation();
const sectionId = btn.dataset.sectionId;
const sectionType = btn.dataset.sectionType;
// Find section across all groups
const found = findSectionInAllGroups(currentConfig, sectionId);
const section = found?.section;
if (section) {
await previewSection(section, sectionType, btn);
}
});
// Click on section row to edit - use event delegation
dialog.addEventListener('click', (e) => {
const row = e.target.closest('.section-row');
if (!row) return;
// Don't trigger if clicking on delete button, toggle switch, preview button, or editable name
if (e.target.closest('.section-delete-btn') ||
e.target.closest('.toggle-slider') ||
e.target.closest('.section-preview-btn') ||
e.target.closest('.editable-section-name')) {
return;
}
const sectionId = row.dataset.sectionId;
const sectionType = row.dataset.sectionType;
// Find section across all groups
const found = findSectionInAllGroups(currentConfig, sectionId);
const section = found?.section;
if (section) {
openSectionEditor(section, sectionType);
}
});
// Delete custom section buttons - use event delegation
dialog.addEventListener('click', async (e) => {
const btn = e.target.closest('.section-delete-btn');
if (!btn) return;
e.stopPropagation();
const sectionId = btn.dataset.sectionId;
const groups = currentConfig.CUSTOM_SECTION_GROUPS || [];
const found = findSectionInGroups(groups, sectionId);
const sectionName = found?.section?.name || sectionId;
if (confirm(`Delete section "${sectionName}"?`)) {
removeSectionFromGroups(groups, sectionId);
await saveConfig(currentConfig);
showToast(`Section "${sectionName}" deleted`);
refreshMainModal();
}
});
}
/**
* Setup community collections tab with card grid layout
*/
function setupCommunityCollectionsTab(dialog) {
// Ensure community config is available
if (!window.KefinCommunityConfig) {
const communityTab = dialog.querySelector('#tab-community');
if (communityTab) {
communityTab.innerHTML = '<div class="listItemBodyText" style="color: #f44336;">Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.</div>';
}
return;
}
const communityCollections = window.KefinCommunityConfig.collections || [];
if (communityCollections.length === 0) {
const communityTab = dialog.querySelector('#tab-community');
if (communityTab) {
communityTab.innerHTML = '<div class="listItemBodyText">No community collections available.</div>';
}
return;
}
const collectionsGrid = dialog.querySelector('#communityCollectionsGrid');
const sectionsContainer = dialog.querySelector('#communitySectionsContainer');
const sectionsList = dialog.querySelector('#communitySectionsList');
const sectionsEmpty = dialog.querySelector('#communitySectionsEmpty');
const confirmBtn = dialog.querySelector('#confirmCommunityImportBtn');
if (!collectionsGrid || !sectionsContainer || !sectionsList || !sectionsEmpty || !confirmBtn) {
return;
}
let selectedCollection = null;
let selectedCollectionIndex = null;
// Build collection cards
collectionsGrid.innerHTML = communityCollections.map((collection, index) => {
const description = collection.description || 'No description available.';
return `
<div class="community-collection-card" data-collection-index="${index}" style="
background: rgba(255,255,255,0.05);
border: 2px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 1em;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5em;">
<div style="flex: 1;">
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.25em;">${collection.name || 'Unnamed Collection'}</div>
<div class="listItemBodyText secondary" style="font-size: 0.85em; color: rgba(255,255,255,0.6);">by ${collection.author || 'Anonymous'}</div>
</div>
<button type="button" class="community-collection-info-btn" data-collection-index="${index}" style="
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
padding: 0.25em 0.5em;
color: rgba(255,255,255,0.87);
cursor: pointer;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 0.25em;
" title="View description">
<span class="material-icons" style="font-size: 1.1em;">info</span>
</button>
</div>
<div class="listItemBodyText secondary" style="font-size: 0.8em; color: rgba(255,255,255,0.5);">
${collection.sections ? collection.sections.length : 0} section${collection.sections && collection.sections.length !== 1 ? 's' : ''}
</div>
</div>
`;
}).join('');
// Add click handlers to collection cards
collectionsGrid.querySelectorAll('.community-collection-card').forEach((card, index) => {
card.addEventListener('click', (e) => {
// Don't trigger if clicking the info button
if (e.target.closest('.community-collection-info-btn')) {
return;
}
// Remove previous selection
collectionsGrid.querySelectorAll('.community-collection-card').forEach(c => {
c.style.borderColor = 'rgba(255,255,255,0.1)';
c.style.background = 'rgba(255,255,255,0.05)';
});
// Highlight selected card
card.style.borderColor = 'rgba(0, 164, 220, 0.8)';
card.style.background = 'rgba(0, 164, 220, 0.15)';
selectedCollection = communityCollections[index];
selectedCollectionIndex = index;
// Render sections list
renderSectionsList(selectedCollection, sectionsList, sectionsContainer, sectionsEmpty, confirmBtn);
});
});
// Add info button handlers for popover
collectionsGrid.querySelectorAll('.community-collection-info-btn').forEach((btn, index) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const collection = communityCollections[index];
const description = collection.description || 'No description available.';
// Create and show popover
showInfoPopover(btn, description, collection.name || 'Collection');
});
});
// Import button handler
confirmBtn.addEventListener('click', async () => {
if (!selectedCollection) return;
const selectedIndices = Array.from(dialog.querySelectorAll('.import-community-check:checked')).map(c => parseInt(c.dataset.index));
if (selectedIndices.length === 0) {
showToast('Please select at least one section to import.');
return;
}
// Import logic - import entire groups to CUSTOM_SECTION_GROUPS
if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = [];
const selectedSections = selectedIndices.map(i => {
const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); // Deep copy
return convertToHomeScreenSection(section);
});
// Create a group from the collection
const importedGroup = {
name: selectedCollection.name || 'Imported Collection',
author: selectedCollection.author,
description: selectedCollection.description,
sections: selectedSections.map(section => {
// Check for ID collision - only regenerate if needed
if (section.id && sectionIdExists(currentConfig, section.id)) {
section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random()*1000)}`;
}
return section;
})
};
currentConfig.CUSTOM_SECTION_GROUPS.push(importedGroup);
const importedCount = selectedSections.length;
if (importedCount > 0) {
await saveConfig(currentConfig);
showToast(`Successfully imported ${importedCount} sections. Refreshing...`);
refreshMainModal();
}
});
}
/**
* Render sections list for selected collection
*/
function renderSectionsList(collection, sectionsList, sectionsContainer, sectionsEmpty, confirmBtn) {
if (!collection || !collection.sections || collection.sections.length === 0) {
sectionsList.innerHTML = '<div class="listItemBodyText secondary">No sections found in this collection.</div>';
sectionsContainer.style.display = 'block';
sectionsEmpty.style.display = 'none';
confirmBtn.style.display = 'none';
return;
}
sectionsList.innerHTML = `
<div style="background: rgba(0,0,0,0.2); border-radius: 4px; padding: 0.5em;">
${collection.sections.map((s, i) => `
<label class="checkboxContainer" style="display: flex; align-items: center; padding: 0.5em; margin-bottom: 0.25em;">
<input type="checkbox" class="import-community-check" data-index="${i}" checked>
<span class="listItemBodyText" style="margin-left: 0.5em;">${s.name || 'Unnamed'}</span>
</label>
`).join('')}
</div>
`;
sectionsContainer.style.display = 'block';
sectionsEmpty.style.display = 'none';
confirmBtn.style.display = 'inline-block';
}
/**
* Show info popover for collection description
*/
function showInfoPopover(triggerElement, description, title) {
// Remove any existing popover
const existingPopover = document.querySelector('.community-info-popover');
if (existingPopover) {
existingPopover.remove();
}
// Create popover element
const popover = document.createElement('div');
popover.className = 'community-info-popover';
popover.style.cssText = `
position: absolute;
background: rgba(26,26,26,0.98);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
padding: 1em;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
z-index: 10000;
max-width: 400px;
min-width: 250px;
`;
popover.innerHTML = `
<div class="listItemBodyText" style="font-weight: 500; margin-bottom: 0.5em;">${title}</div>
<div class="listItemBodyText secondary" style="line-height: 1.5;">${description}</div>
<button type="button" class="community-popover-close" style="
position: absolute;
top: 0.5em;
right: 0.5em;
background: none;
border: none;
color: rgba(255,255,255,0.7);
cursor: pointer;
padding: 0.25em;
display: flex;
align-items: center;
" title="Close">
<span class="material-icons" style="font-size: 1.2em;">close</span>
</button>
`;
// Position popover relative to trigger
const rect = triggerElement.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
popover.style.top = `${rect.bottom + scrollTop + 8}px`;
popover.style.left = `${rect.left + scrollLeft}px`;
// Add close button handler
popover.querySelector('.community-popover-close').addEventListener('click', () => {
popover.remove();
});
// Close on outside click
const closeOnOutsideClick = (e) => {
if (!popover.contains(e.target) && e.target !== triggerElement) {
popover.remove();
document.removeEventListener('click', closeOnOutsideClick);
}
};
setTimeout(() => {
document.addEventListener('click', closeOnOutsideClick);
}, 0);
document.body.appendChild(popover);
}
/**
* Convert legacy section format to HomeScreenSection format
*/
function convertToHomeScreenSection(section) {
// If already in new format, return as-is
if (section.queries && Array.isArray(section.queries)) {
return section;
}
// Convert legacy format
const newSection = {
id: section.id,
name: section.name,
enabled: section.enabled !== false,
order: section.order || 100,
cardFormat: section.cardFormat || 'Poster',
itemLimit: section.itemLimit || 20,
queries: []
};
// Build query from legacy fields
const query = {
queryOptions: {}
};
if (section.path) {
query.path = section.path;
}
if (section.dataSource) {
query.dataSource = section.dataSource;
}
// Copy all other fields to queryOptions
Object.keys(section).forEach(key => {
if (!['id', 'name', 'enabled', 'order', 'cardFormat', 'itemLimit', 'path', 'dataSource', 'queries', 'type'].includes(key)) {
query.queryOptions[key] = section[key];
}
});
newSection.queries.push(query);
return newSection;
}
/**
* Check if section ID exists in config
*/
function sectionIdExists(config, sectionId) {
const allHomeSections = flattenSectionGroups(config.HOME_SECTION_GROUPS || []);
const allSeasonalSections = flattenSectionGroups(config.SEASONAL_SECTION_GROUPS || []);
const allDiscoverySections = flattenSectionGroups(config.DISCOVERY_SECTION_GROUPS || []);
const allCustomSections = flattenSectionGroups(config.CUSTOM_SECTION_GROUPS || []);
const allIds = [
...allHomeSections.map(s => s.id),
...allSeasonalSections.map(s => s.id),
...allDiscoverySections.map(s => s.id),
...allCustomSections.map(s => s.id)
];
return allIds.includes(sectionId);
}
/**
* Show community import dialog
*/
async function showCommunityImportDialog() {
// Ensure community config is available
if (!window.KefinCommunityConfig) {
showToast('Community configuration not loaded. Please ensure homeScreenConfig-community.js is loaded.');
return;
}
const communityCollections = window.KefinCommunityConfig.collections || [];
if (communityCollections.length === 0) {
showToast('No community collections available.');
return;
}
const content = document.createElement('div');
content.innerHTML = `
<div class="content-primary">
<div class="listItemBodyText" style="margin-bottom: 1em;">Select Home Screen Sections to import:</div>
<div class="listItemBodyText" style="margin-bottom: 1em;">These sections are created and submitted by community members like you!</div>
<div class="listItemBodyText secondary" style="margin-bottom: 1.5em;">Submit your own Home Screen Sections <a href="https://github.com/ranaldsgift/KefinTweaks/discussions/64" target="_blank" class="button-link">here.</a></div>
<div style="margin-bottom: 1.5em;">
<select id="communityCollectionSelect" class="emby-select-withcolor emby-select" style="width: 100%; padding: 0.5em; background: rgba(255,255,255,0.1); color: inherit; border: 1px solid rgba(255,255,255,0.1); border-radius: 4px;">
<option value="">Select a collection...</option>
${communityCollections.map((c, i) => `<option value="${i}">${c.name} by ${c.author}</option>`).join('')}
</select>
</div>
<div id="collectionDescription" style="margin-bottom: 1.5em; font-style: italic; color: #ccc; display: none; padding: 1em; background: rgba(0,0,0,0.2); border-radius: 4px;"></div>
<div id="importSelectionContainer" style="display: none; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1em;">
<div class="listItemBodyText" style="margin-bottom: 1em; font-weight: bold;">Sections in this collection:</div>
<div id="importSelectionList"></div>
</div>
</div>
`;
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.innerHTML = `
<button class="emby-button raised block button-submit" id="confirmCommunityImportBtn" style="padding: 0.75em 2em; display: none;">Import Selected</button>
<button class="emby-button raised" onclick="window.ModalSystem.close('kefinCommunityImportModal')">Cancel</button>
`;
let selectedCollection = null;
window.ModalSystem.create({
id: 'kefinCommunityImportModal',
title: 'Import Community Sections',
content: content,
footer: footer,
closeOnBackdrop: false,
closeOnEscape: true,
onOpen: (modalInstance) => {
const select = content.querySelector('#communityCollectionSelect');
const descContainer = content.querySelector('#collectionDescription');
const selectionContainer = content.querySelector('#importSelectionContainer');
const selectionList = content.querySelector('#importSelectionList');
const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmCommunityImportBtn');
select.addEventListener('change', () => {
const index = select.value;
if (index === '') {
selectedCollection = null;
descContainer.style.display = 'none';
selectionContainer.style.display = 'none';
confirmBtn.style.display = 'none';
return;
}
selectedCollection = communityCollections[parseInt(index)];
// Show description
descContainer.textContent = selectedCollection.description || 'No description.';
descContainer.style.display = 'block';
// Render sections list
let html = '';
if (selectedCollection.sections && selectedCollection.sections.length > 0) {
html += `
<div style="background: rgba(0,0,0,0.2); border-radius: 4px; padding: 0.5em;">
${selectedCollection.sections.map((s, i) => `
<label class="checkboxContainer" style="display: flex; align-items: center; padding: 0.5em;">
<input type="checkbox" class="import-community-check" data-index="${i}" checked>
<span class="listItemBodyText" style="margin-left: 0.5em;">${s.name || 'Unnamed'}</span>
</label>
`).join('')}
</div>`;
} else {
html = '<div class="listItemBodyText secondary">No sections found in this collection.</div>';
}
selectionList.innerHTML = html;
selectionContainer.style.display = 'block';
confirmBtn.style.display = 'inline-block';
});
confirmBtn.addEventListener('click', async () => {
if (!selectedCollection) return;
const selectedIndices = Array.from(content.querySelectorAll('.import-community-check:checked')).map(c => parseInt(c.dataset.index));
if (selectedIndices.length === 0) {
showToast('Please select at least one section to import.');
return;
}
// Import logic - import entire groups to CUSTOM_SECTION_GROUPS
if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = [];
const selectedSections = selectedIndices.map(i => {
const section = JSON.parse(JSON.stringify(selectedCollection.sections[i])); // Deep copy
return convertToHomeScreenSection(section);
});
// Create a group from the collection
const importedGroup = {
name: selectedCollection.name || 'Imported Collection',
author: selectedCollection.author,
description: selectedCollection.description,
sections: selectedSections.map(section => {
// Check for ID collision - only regenerate if needed
if (section.id && sectionIdExists(currentConfig, section.id)) {
section.id = `${section.id}_community_${Date.now()}_${Math.floor(Math.random()*1000)}`;
}
return section;
})
};
currentConfig.CUSTOM_SECTION_GROUPS.push(importedGroup);
const importedCount = selectedSections.length;
if (importedCount > 0) {
await saveConfig(currentConfig);
showToast(`Successfully imported ${importedCount} sections. Refreshing...`);
refreshMainModal();
window.ModalSystem.close('kefinCommunityImportModal');
} else {
window.ModalSystem.close('kefinCommunityImportModal');
}
});
}
});
}
/**
* Show export dialog
*/
async function showExportDialog() {
const sectionGroups = currentConfig.CUSTOM_SECTION_GROUPS || [];
if (sectionGroups.length === 0 || !sectionGroups.some(g => g.sections && g.sections.length > 0)) {
alert('No custom sections found to export.');
return;
}
const content = document.createElement('div');
content.innerHTML = `
<div class="content-primary">
<div style="margin-bottom: 1.5em;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5em;">
<button type="button" class="emby-button raised" onclick="this.closest('.content-primary').querySelectorAll('.export-custom-check').forEach(c => c.checked = true)" style="font-size: 0.9em;">Select All</button>
<button type="button" class="emby-button raised" onclick="this.closest('.content-primary').querySelectorAll('.export-custom-check').forEach(c => c.checked = false)" style="font-size: 0.9em;">Deselect All</button>
</div>
<div id="exportSectionGroupsList"></div>
</div>
<div id="exportOutputContainer" style="display: none; margin-top: 1.5em;">
<div class="listItemBodyText" style="margin-bottom: 0.5em;">Export JSON:</div>
<textarea id="exportOutput" class="fld emby-textarea" readonly style="width: 100%; height: 200px; font-family: monospace;"></textarea>
<div style="display: flex; gap: 1em; margin-top: 0.5em;">
<button type="button" class="emby-button raised" id="copyExportBtn">Copy to Clipboard</button>
<button type="button" class="emby-button raised" id="downloadExportBtn">Download .json</button>
</div>
</div>
</div>
`;
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.innerHTML = `
<button class="emby-button raised hide" onclick="window.ModalSystem.close('kefinExportModal')">Close</button>
<button class="emby-button raised block button-submit" id="generateExportBtn" style="padding: 0.75em 2em;">Generate JSON</button>
`;
window.ModalSystem.create({
id: 'kefinExportModal',
title: 'Export Custom Sections',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modalInstance) => {
const sectionGroupsList = content.querySelector('#exportSectionGroupsList');
renderSectionGroupsCheckboxList(sectionGroupsList, sectionGroups, {
checkboxClass: 'export-custom-check',
groupCheckboxClass: 'export-custom-group-check'
});
const generateBtn = modalInstance.dialogFooter.querySelector('#generateExportBtn');
generateBtn.addEventListener('click', () => {
// Collect selected sections by group
const selectedGroups = [];
sectionGroups.forEach((group, groupIndex) => {
if (!group || !Array.isArray(group.sections)) return;
const selectedSections = [];
group.sections.forEach((section, sectionIndex) => {
const checkbox = content.querySelector(`.export-custom-check[data-group-index="${groupIndex}"][data-section-index="${sectionIndex}"]`);
if (checkbox && checkbox.checked) {
selectedSections.push(section);
}
});
// Only include groups that have at least one section selected
if (selectedSections.length > 0) {
selectedGroups.push({
name: group.name,
author: group.author,
description: group.description,
sections: selectedSections
});
}
});
if (selectedGroups.length === 0) {
alert('Please select at least one section to export.');
return;
}
// Export as array of section groups
const json = JSON.stringify(selectedGroups, null, 2);
const outputContainer = content.querySelector('#exportOutputContainer');
const output = content.querySelector('#exportOutput');
output.value = json;
outputContainer.style.display = 'block';
generateBtn.style.display = 'none';
content.querySelector('#copyExportBtn').addEventListener('click', () => {
output.select();
document.execCommand('copy');
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Copied to clipboard');
} else {
alert('Copied to clipboard');
}
});
content.querySelector('#downloadExportBtn').addEventListener('click', () => {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kefin-tweaks-export-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
});
}
});
}
/**
* Show import dialog
*/
async function showImportDialog() {
const content = document.createElement('div');
content.innerHTML = `
<div class="content-primary">
<div class="listItemBodyText" style="margin-bottom: 1em;">Paste JSON content or a URL (Raw GitHub, Gist, Pastebin):</div>
<textarea id="importInput" class="fld emby-textarea" style="width: 100%; height: 150px; font-family: monospace;" placeholder='[{ "name": "...", "sections": [...] }, ...] or { "sectionGroups": [...] } or https://...'></textarea>
<div id="importSelectionContainer" style="display: none; margin-top: 1.5em; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1em;">
<div class="listItemBodyText" style="margin-bottom: 1em; font-weight: bold;">Found items to import:</div>
<div id="importSelectionList"></div>
</div>
</div>
`;
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('kefinImportModal')">Close</button>
<button class="emby-button raised block button-submit" id="loadImportBtn" style="padding: 0.75em 2em;">Load</button>
<button class="emby-button raised block button-submit" id="confirmImportBtn" style="padding: 0.75em 2em; display: none;">Import Selected</button>
`;
let parsedData = null;
let sectionGroups = null;
window.ModalSystem.create({
id: 'kefinImportModal',
title: 'Import Custom Sections',
content: content,
footer: footer,
closeOnBackdrop: false,
closeOnEscape: true,
onOpen: (modalInstance) => {
const loadBtn = modalInstance.dialogFooter.querySelector('#loadImportBtn');
const confirmBtn = modalInstance.dialogFooter.querySelector('#confirmImportBtn');
const input = content.querySelector('#importInput');
const selectionContainer = content.querySelector('#importSelectionContainer');
const selectionList = content.querySelector('#importSelectionList');
loadBtn.addEventListener('click', async () => {
const rawInput = input.value.trim();
if (!rawInput) return;
loadBtn.disabled = true;
loadBtn.textContent = 'Loading...';
try {
let jsonStr = rawInput;
// Check if URL
if (rawInput.startsWith('http://') || rawInput.startsWith('https://')) {
const res = await fetch(rawInput);
if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`);
jsonStr = await res.text();
}
parsedData = JSON.parse(jsonStr);
// Accept array of section groups or object with sectionGroups property
if (Array.isArray(parsedData)) {
sectionGroups = parsedData;
} else if (parsedData.sectionGroups && Array.isArray(parsedData.sectionGroups)) {
sectionGroups = parsedData.sectionGroups;
} else {
throw new Error('Invalid format: Expected array of section groups or object with sectionGroups property.');
}
if (!sectionGroups || sectionGroups.length === 0) {
throw new Error('No section groups found.');
}
// Render selection using shared helper
renderSectionGroupsCheckboxList(selectionList, sectionGroups, {
checkboxClass: 'import-custom-check',
groupCheckboxClass: 'import-custom-group-check'
});
selectionContainer.style.display = 'block';
confirmBtn.style.display = 'inline-block';
} catch (error) {
showToast(`Error loading data: ${error.message}`);
} finally {
loadBtn.disabled = false;
loadBtn.textContent = 'Load';
}
});
confirmBtn.addEventListener('click', async () => {
if (!sectionGroups || sectionGroups.length === 0) return;
// Collect selected sections by group
const groupsToImport = [];
sectionGroups.forEach((group, groupIndex) => {
if (!group || !Array.isArray(group.sections)) return;
const selectedSections = [];
group.sections.forEach((section, sectionIndex) => {
const checkbox = content.querySelector(`.import-custom-check[data-group-index="${groupIndex}"][data-section-index="${sectionIndex}"]`);
if (checkbox && checkbox.checked) {
const sectionCopy = JSON.parse(JSON.stringify(section)); // Deep copy
const convertedSection = convertToHomeScreenSection(sectionCopy);
// Check for ID collision
if (convertedSection.id && sectionIdExists(currentConfig, convertedSection.id)) {
convertedSection.id = `${convertedSection.id}_imported_${Date.now()}_${Math.floor(Math.random()*1000)}`;
}
selectedSections.push(convertedSection);
}
});
// Only include groups that have at least one section selected
if (selectedSections.length > 0) {
groupsToImport.push({
name: group.name,
author: group.author,
description: group.description,
sections: selectedSections
});
}
});
if (groupsToImport.length === 0) {
showToast('Please select at least one section to import.');
return;
}
if (!currentConfig.CUSTOM_SECTION_GROUPS) currentConfig.CUSTOM_SECTION_GROUPS = [];
// Append groups to CUSTOM_SECTION_GROUPS
groupsToImport.forEach(group => {
currentConfig.CUSTOM_SECTION_GROUPS.push(group);
});
const importedCount = groupsToImport.reduce((sum, g) => sum + g.sections.length, 0);
if (importedCount > 0) {
await saveConfig(currentConfig);
showToast(`Successfully imported ${importedCount} sections in ${groupsToImport.length} group${groupsToImport.length !== 1 ? 's' : ''}. Refreshing...`);
refreshMainModal();
window.ModalSystem.close('kefinImportModal');
} else {
window.ModalSystem.close('kefinImportModal');
}
});
}
});
// Community Collections tab setup
setupCommunityCollectionsTab(dialog);
// Export Sections button
const exportSectionsBtn = dialog.querySelector('.export-sections-btn');
if (exportSectionsBtn) {
exportSectionsBtn.addEventListener('click', () => {
showExportDialog();
});
}
// Import Sections button
const importSectionsBtn = dialog.querySelector('.import-sections-btn');
if (importSectionsBtn) {
importSectionsBtn.addEventListener('click', () => {
showImportDialog();
});
}
}
/**
* Open main configuration modal
*/
async function openConfigModal() {
try {
// Load config
const config = await loadConfig();
// Build content
const content = document.createElement('div');
content.innerHTML = buildMainConfigHTML();
// Build footer
const footer = document.createElement('div');
footer.style.display = 'flex';
footer.style.gap = '0.75em';
footer.style.justifyContent = 'flex-end';
footer.innerHTML = `
<button class="emby-button raised" onclick="window.ModalSystem.close('${MAIN_MODAL_ID}')">Close</button>
<button class="emby-button raised block button-submit" id="save-all-btn">Save</button>
`;
// Create modal
mainModalInstance = window.ModalSystem.create({
id: MAIN_MODAL_ID,
title: 'Home Screen Configuration',
content: content,
footer: footer,
closeOnBackdrop: true,
closeOnEscape: true,
showCloseButton: true,
onOpen: (modalInstance) => {
// Attach tab switching
attachTabListeners(modalInstance);
// Attach event listeners
attachMainModalListeners(modalInstance);
// Save All button
const saveAllBtn = modalInstance.dialogFooter.querySelector('#save-all-btn');
if (saveAllBtn) {
saveAllBtn.addEventListener('click', async () => {
const globalSettings = collectGlobalSettings();
Object.assign(currentConfig, globalSettings);
// If Order Sections tab was used, ensure order values are up to date
const orderList = modalInstance.dialogContent.querySelector('#order-sections-list');
if (orderList) {
const allRows = Array.from(orderList.querySelectorAll('.section-order-row'));
allRows.forEach((r, index) => {
const sectionId = r.dataset.sectionId;
if (sectionId) {
const found = findSectionInAllGroups(currentConfig, sectionId);
if (found?.section) {
found.section.order = (index + 1) * 10;
}
}
});
}
await saveConfig(currentConfig);
showToast('Configuration saved!');
});
}
// Populate Order Sections tab if it's the active tab
if (currentActiveTab === 'order') {
const orderContainer = modalInstance.dialogContent.querySelector('#order-sections-container');
if (orderContainer && orderContainer.children.length === 0) {
const allSections = [
...flattenSectionGroups(currentConfig.HOME_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Default' })),
...flattenSectionGroups(currentConfig.SEASONAL_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Seasonal' })),
...flattenSectionGroups(currentConfig.CUSTOM_SECTION_GROUPS || []).map(s => ({ ...s, sectionType: 'Custom' }))
];
orderContainer.innerHTML = renderHomeSectionsOrderEditor(allSections);
}
}
}
});
mainModalInstance.dialog.style.maxWidth = '90vw';
mainModalInstance.dialog.style.width = '1400px';
mainModalInstance.dialog.style.height = '90vh';
LOG('Main configuration modal opened');
} catch (error) {
ERR('Error opening config modal:', error);
showToast('Error opening configuration: ' + error.message);
}
}
/**
* Preview section by fetching items and rendering them
*/
async function previewSection(section, sectionType, btn) {
if (!window.ApiClient || !window.apiHelper || !window.cardBuilder) {
showToast('Required dependencies not available for preview');
return;
}
Dashboard.showLoadingMsg();
try {
const userId = ApiClient.getCurrentUserId();
const serverUrl = ApiClient.serverAddress();
if (!userId || !serverUrl) {
showToast('Unable to get user ID or server URL');
return;
}
// Build queries from section configuration
const queries = section.queries || [];
if (queries.length === 0) {
showToast('Section has no queries to preview');
return;
}
// Fetch items from all queries
let allItems = [];
for (const query of queries) {
let queryUrl = null;
// Build query URL using shared builder (handles minAge/maxAge conversion)
if (query.dataSource) {
// Data source - skip for preview (would need special handling)
showToast('Data source queries cannot be previewed');
continue;
} else if (window.apiHelper && window.apiHelper.buildQueryFromSection) {
// Use shared builder for consistency
queryUrl = window.apiHelper.buildQueryFromSection(query, userId, serverUrl);
} else {
// Fallback to individual builders
if (query.path) {
queryUrl = buildCustomEndpointForPreview(query.path, query.queryOptions || {}, userId, serverUrl);
} else {
queryUrl = buildStandardQueryForPreview(query.queryOptions || {}, userId, serverUrl);
}
}
if (queryUrl && typeof queryUrl === 'string') {
try {
const result = await apiHelper.getQuery(queryUrl, { useCache: false });
const items = result.Items || result || [];
allItems = allItems.concat(items);
} catch (error) {
console.error('[Preview] Error fetching query:', error);
showToast(`Error fetching query: ${error.message}`);
}
}
}
if (allItems.length === 0) {
showToast('No items found for preview');
// Additionally temporarily change the visual style of the Preview button
// Change the text to "No Items" and give it a distinct red border or make it clear that it's not working
if (btn) {
btn.textContent = 'No Items';
btn.style.border = '1px solid #ff6b6b';
btn.style.color = '#ff6b6b';
}
// After a short period remove this style and restore the original text and color
setTimeout(() => {
if (btn) {
btn.textContent = 'Preview';
btn.style.border = '1px solid #ccc';
btn.style.color = '#ccc';
}
}, 3000);
return;
}
// Apply limit if specified
const limit = section.queries?.[0]?.queryOptions?.Limit || section.itemLimit || 20;
allItems = window.cardBuilder.postProcessItems(section, allItems);
const limitedItems = allItems.slice(0, parseInt(limit, 10));
// Determine render mode
const renderMode = section.renderMode || (section.spotlight ? 'Spotlight' : 'Normal');
const useSpotlight = renderMode === 'Spotlight' || (renderMode === 'Random' && Math.random() < 0.5);
// Determine card format
let cardFormat = section.cardFormat || 'Poster';
if (cardFormat === 'Random') {
const formats = ['Poster', 'Thumb', 'Backdrop'];
cardFormat = formats[Math.floor(Math.random() * formats.length)];
}
// Create preview modal
const previewModalId = 'kefin-preview-section';
const content = document.createElement('div');
content.style.cssText = 'padding: 1em; max-width: 1400px; width: 100%;';
content.innerHTML = `
<div class="listItemBodyText" style="margin-bottom: 1em;">
Preview: ${section.name || 'Unnamed Section'} (${limitedItems.length} item${limitedItems.length !== 1 ? 's' : ''})
</div>
<div id="preview-container" style="min-height: 400px;"></div>
`;
const footer = document.createElement('div');
footer.innerHTML = `<button class="emby-button raised" onclick="window.ModalSystem.close('${previewModalId}')">Close</button>`;
window.ModalSystem.create({
id: previewModalId,
title: 'Section Preview',
content: content,
footer: footer,
size: 'large',
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modalInstance) => {
const container = modalInstance.dialogContent.querySelector('#preview-container');
modalInstance.dialog.style.width = '1400px';
let previewElement = null;
if (useSpotlight && typeof window.cardBuilder.renderSpotlightSection === 'function') {
previewElement = window.cardBuilder.renderSpotlightSection(limitedItems, section.name || 'Preview', {
autoPlay: false,
showDots: true,
showNavButtons: true,
viewMoreUrl: null
});
} else if (typeof window.cardBuilder.renderCards === 'function') {
previewElement = window.cardBuilder.renderCards(
limitedItems,
section.name || 'Preview',
null,
true,
cardFormat,
section.sortOrder || null,
section.sortOrderDirection || null
);
}
if (previewElement) {
container.innerHTML = '';
container.appendChild(previewElement);
} else {
container.innerHTML = '<div style="opacity: 0.75; text-align: center;">Unable to render preview with the current configuration.</div>';
}
}
});
} catch (error) {
console.error('[Preview] Error:', error);
showToast(`Preview error: ${error.message}`);
} finally {
Dashboard.hideLoadingMsg();
}
}
/**
* Build standard query URL for preview
* Uses shared apiHelper.buildQueryFromSection for consistency
*/
function buildStandardQueryForPreview(queryOptions, userId, serverUrl) {
if (window.apiHelper && window.apiHelper.buildStandardQuery) {
return window.apiHelper.buildStandardQuery(queryOptions, userId, serverUrl);
}
// Fallback if apiHelper not available
// Fields that require pipe delimiter instead of comma
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
const params = new URLSearchParams({
Recursive: 'true',
Fields: 'PrimaryImageAspectRatio,DateCreated,Overview,Taglines,ProductionYear,RecursiveItemCount,ChildCount,UserData',
ImageTypeLimit: '1',
UserId: userId
});
Object.entries(queryOptions || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
// Use pipe delimiter for specific fields, comma for others
const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ',';
params.set(key, value.join(delimiter));
} else {
params.set(key, value);
}
}
});
return `${serverUrl}/Items?${params.toString()}`;
}
/**
* Build custom endpoint URL for preview
* Uses shared apiHelper.buildQueryFromSection for consistency
*/
function buildCustomEndpointForPreview(path, queryOptions, userId, serverUrl) {
if (window.apiHelper && window.apiHelper.buildCustomEndpoint) {
return window.apiHelper.buildCustomEndpoint(path, queryOptions, userId, serverUrl);
}
// Fallback if apiHelper not available
// Fields that require pipe delimiter instead of comma
const PIPE_DELIMITED_FIELDS = ['Genres', 'Tags', 'OfficialRatings', 'Studios', 'Artists', 'ExcludeArtistsIds', 'Albums', 'AlbumIds', 'StudioIds', 'GenreIds'];
const params = new URLSearchParams({
UserId: userId
});
Object.entries(queryOptions || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
// Use pipe delimiter for specific fields, comma for others
const delimiter = PIPE_DELIMITED_FIELDS.includes(key) ? '|' : ',';
params.set(key, value.join(delimiter));
} else {
params.set(key, value);
}
}
});
if (!params.has('Fields')) {
params.set('Fields', 'PrimaryImageAspectRatio,DateCreated,MediaSourceCount,UserData');
}
if (!params.has('ImageTypeLimit')) {
params.set('ImageTypeLimit', '1');
}
if (!params.has('EnableTotalRecordCount')) {
params.set('EnableTotalRecordCount', 'false');
}
return `${serverUrl}${path}?${params.toString()}`;
}
window.KefinTweaksFeatureConfigs = window.KefinTweaksFeatureConfigs || {};
window.KefinTweaksFeatureConfigs.homeScreen = { openConfigModal };
// Export getConfig for use by other scripts
window.KefinHomeScreen = window.KefinHomeScreen || {};
window.KefinHomeScreen.getConfig = getConfig;
LOG('Home Screen Configuration UI loaded');
// Load user configuration script after this script is initialized
function loadUserConfigurationScript() {
try {
const root = window.KefinTweaksConfig?.kefinTweaksRoot;
if (!root) {
WARN('kefinTweaksRoot not configured, cannot load user configuration script');
return;
}
// Check if script is already loaded
if (document.querySelector('script[data-kefin-user-homescreen-config]')) {
LOG('User home screen configuration script already loaded');
return;
}
const scriptUrl = `${root}configuration/homeScreen-user-configuration.js`;
const script = document.createElement('script');
script.src = scriptUrl;
script.async = true;
script.setAttribute('data-kefin-user-homescreen-config', 'true');
script.onload = () => {
LOG('User home screen configuration script loaded successfully');
};
script.onerror = (error) => {
ERR('Failed to load user home screen configuration script:', scriptUrl, error);
};
document.head.appendChild(script);
} catch (error) {
ERR('Error loading user configuration script:', error);
}
}
// Load user config script after a short delay to ensure this script is fully initialized
setTimeout(loadUserConfigurationScript, 100);
})();