// KefinTweaks Home Screen Configuration UI
// Modular configuration modal for managing home screen sections
// Uses the new HomeScreenSection format with queries array
(function() {
'use strict';
const LOG = (...args) => console.log('[KefinTweaks HomeScreen Config]', ...args);
const WARN = (...args) => console.warn('[KefinTweaks HomeScreen Config]', ...args);
const ERR = (...args) => console.error('[KefinTweaks HomeScreen Config]', ...args);
// Modal IDs
const MAIN_MODAL_ID = 'kefin-homescreen-config-main';
const SECTION_EDITOR_MODAL_ID = 'kefin-homescreen-section-editor';
const DISCOVERY_EDITOR_MODAL_ID = 'kefin-homescreen-discovery-editor';
// Supported additional query options (matching configuration.js)
const SUPPORTED_QUERY_OPTIONS = {
ParentId: { label: 'Parent ID', type: 'string', hint: 'GUID of parent item' },
ExcludeItemIds: { label: 'Exclude Item IDs', type: 'array', hint: 'Comma-separated GUIDs' },
GenreIds: { label: 'Genre IDs', type: 'array', hint: 'Comma-separated GUIDs' },
PersonIds: { label: 'Person IDs', type: 'array', hint: 'Comma-separated GUIDs' },
StudioIds: { label: 'Studio IDs', type: 'array', hint: 'Comma-separated GUIDs' },
Fields: { label: 'Fields', type: 'array', hint: 'Comma-separated field names' },
MinPremiereDate: { label: 'Min Premiere Date', type: 'date', hint: 'YYYY-MM-DD' },
MaxPremiereDate: { label: 'Max Premiere Date', type: 'date', hint: 'YYYY-MM-DD' },
MinDateLastSaved: { label: 'Min Date Last Saved', type: 'date', hint: 'YYYY-MM-DD' },
MinDateLastSavedForUser: { label: 'Min Date Last Saved For User', type: 'date', hint: 'YYYY-MM-DD' },
IndexNumber: { label: 'Index Number', type: 'number', hint: 'Episode/Season index' },
ParentIndexNumber: { label: 'Parent Index Number', type: 'number', hint: 'Season index' },
MinCommunityRating: { label: 'Min Community Rating', type: 'number', hint: '0-10' },
MinCriticRating: { label: 'Min Critic Rating', type: 'number', hint: '0-10' },
IsFavorite: { label: 'Is Favorite', type: 'boolean' },
IsPlayed: { label: 'Is Played', type: 'boolean' },
IsUnplayed: { label: 'Is Unplayed', type: 'boolean' },
IsMissing: { label: 'Is Missing', type: 'boolean' },
IsUnaired: { label: 'Is Unaired', type: 'boolean' },
HasThemeSong: { label: 'Has Theme Song', type: 'boolean' },
HasThemeVideo: { label: 'Has Theme Video', type: 'boolean' },
HasSubtitles: { label: 'Has Subtitles', type: 'boolean' },
HasSpecialFeature: { label: 'Has Special Feature', type: 'boolean' },
HasTrailer: { label: 'Has Trailer', type: 'boolean' },
HasParentalRating: { label: 'Has Parental Rating', type: 'boolean' },
IsHd: { label: 'Is HD', type: 'boolean' },
Is4K: { label: 'Is 4K', type: 'boolean' },
HasOverview: { label: 'Has Overview', type: 'boolean' },
HasOfficialRating: { label: 'Has Official Rating', type: 'boolean' },
Recursive: { label: 'Recursive', type: 'boolean', default: true },
MaxOfficialRating: { label: 'Max Official Rating', type: 'string', hint: 'e.g., PG-13, TV-MA' },
MinOfficialRating: { label: 'Min Official Rating', type: 'string', hint: 'e.g., PG, TV-14' },
NameStartsWith: { label: 'Name Starts With', type: 'string' },
NameStartsWithOrGreater: { label: 'Name Starts With Or Greater', type: 'string' },
NameLessThan: { label: 'Name Less Than', type: 'string' },
LocationTypes: { label: 'Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' },
ExcludeLocationTypes: { label: 'Exclude Location Types', type: 'array', hint: 'FileSystem, Remote, Virtual, Offline' },
ExcludeItemTypes: { label: 'Exclude Item Types', type: 'array', hint: 'Movie, Series, Episode, etc.' },
ImageTypes: { label: 'Image Types', type: 'array', hint: 'Primary, Backdrop, Thumb, Logo, etc.' },
OfficialRatings: { label: 'Official Ratings', type: 'array', hint: 'PG, PG-13, R, etc.' },
Studios: { label: 'Studios', type: 'array', hint: 'Pipe-delimited names' },
Artists: { label: 'Artists', type: 'array', hint: 'Pipe-delimited names' },
Albums: { label: 'Albums', type: 'array', hint: 'Pipe-delimited names' },
SeriesStatus: { label: 'Series Status', type: 'array', hint: 'Continuing, Ended, Unreleased' },
Years: { label: 'Years', type: 'array', hint: 'Comma-separated years' },
EnableResumable: { label: 'Enable Resumable', type: 'boolean' },
EnableUserData: { label: 'Enable User Data', type: 'boolean' }
};
const CARD_FORMATS = ['Poster', 'Thumb', 'Backdrop', 'Square', 'Random'];
const SORT_ORDERS = ['Random', 'Name', 'DateCreated', 'PremiereDate', 'CommunityRating', 'CriticRating', 'DatePlayed', 'SortName', 'PlayCount', 'PlayedPercentage', 'StartDate', 'Runtime', 'ProductionYear', 'IsPlayed', 'IsUnplayed', 'ParentIndexNumber', 'IndexNumber'];
const SORT_ORDER_DIRECTIONS = ['Ascending', 'Descending'];
// Current config state
let currentConfig = null;
let mainModalInstance = null;
let currentActiveTab = 'settings'; // Track the currently active tab
/**
* Show toast notification
*/
function showToast(message, duration = '3') {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(message, duration);
} else {
// Fallback to alert if toaster not available
alert(message);
}
}
/**
* Flatten section groups into a flat array of sections
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @returns {Array} Flat array of all sections
*/
function flattenSectionGroups(groups) {
if (!Array.isArray(groups)) return [];
const flattened = [];
groups.forEach(group => {
if (group && Array.isArray(group.sections)) {
flattened.push(...group.sections);
}
});
return flattened;
}
/**
* Get all sections from groups with metadata about which group they belong to
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @returns {Array} Array of objects with section and groupName
*/
function getAllSectionsFromGroups(groups) {
if (!Array.isArray(groups)) return [];
const result = [];
groups.forEach(group => {
if (group && Array.isArray(group.sections)) {
group.sections.forEach(section => {
result.push({
section: section,
groupName: group.name || 'Unnamed Group',
groupAuthor: group.author,
groupDescription: group.description
});
});
}
});
return result;
}
/**
* Render section groups with group-level and section-level checkboxes
* Used by import and export modals to display sections within their groups
* @param {HTMLElement} container - Container element to render into
* @param {Array} sectionGroups - Array of section group objects { name, author?, description?, sections: [] }
* @param {Object} options - Options object
* @param {string} options.checkboxClass - CSS class for section checkboxes (e.g., 'export-custom-check', 'import-custom-check')
* @param {string} options.groupCheckboxClass - CSS class for group checkboxes (optional, defaults to checkboxClass + '-group')
*/
function renderSectionGroupsCheckboxList(container, sectionGroups, options = {}) {
if (!Array.isArray(sectionGroups) || sectionGroups.length === 0) {
container.innerHTML = '
No section groups found.
';
return;
}
const checkboxClass = options.checkboxClass || 'section-check';
const groupCheckboxClass = options.groupCheckboxClass || checkboxClass + '-group';
let html = '';
sectionGroups.forEach((group, groupIndex) => {
if (!group || !Array.isArray(group.sections) || group.sections.length === 0) {
return;
}
const groupName = group.name || 'Unnamed Group';
// Group checkbox row
html += `
`;
});
html += '
';
container.innerHTML = html;
// Wire up group checkboxes to toggle all section checkboxes in that group
container.querySelectorAll(`.${groupCheckboxClass}`).forEach(groupCheckbox => {
groupCheckbox.addEventListener('change', (e) => {
const groupIndex = parseInt(e.target.dataset.groupIndex);
const isChecked = e.target.checked;
container.querySelectorAll(`.${checkboxClass}[data-group-index="${groupIndex}"]`).forEach(sectionCheckbox => {
sectionCheckbox.checked = isChecked;
});
});
});
}
/**
* Find a section by ID in groups
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} sectionId - Section ID to find
* @returns {Object|null} Object with section, group, and groupIndex, or null if not found
*/
function findSectionInGroups(groups, sectionId) {
if (!Array.isArray(groups)) return null;
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const group = groups[groupIndex];
if (group && Array.isArray(group.sections)) {
const sectionIndex = group.sections.findIndex(s => s.id === sectionId);
if (sectionIndex !== -1) {
return {
section: group.sections[sectionIndex],
group: group,
groupIndex: groupIndex,
sectionIndex: sectionIndex
};
}
}
}
return null;
}
/**
* Find a section by ID across all section groups (HOME, SEASONAL, DISCOVERY, CUSTOM)
* @param {Object} config - Configuration object
* @param {string} sectionId - Section ID to find
* @returns {Object|null} Object with section, group, groupIndex, sectionIndex, and groupType, or null if not found
*/
function findSectionInAllGroups(config, sectionId) {
if (!config) return null;
// Check HOME_SECTION_GROUPS
let found = findSectionInGroups(config.HOME_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'HOME_SECTION_GROUPS';
return found;
}
// Check SEASONAL_SECTION_GROUPS
found = findSectionInGroups(config.SEASONAL_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'SEASONAL_SECTION_GROUPS';
return found;
}
// Check DISCOVERY_SECTION_GROUPS
found = findSectionInGroups(config.DISCOVERY_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'DISCOVERY_SECTION_GROUPS';
return found;
}
// Check CUSTOM_SECTION_GROUPS
found = findSectionInGroups(config.CUSTOM_SECTION_GROUPS || [], sectionId);
if (found) {
found.groupType = 'CUSTOM_SECTION_GROUPS';
return found;
}
return null;
}
/**
* Update a section in groups
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} sectionId - Section ID to update
* @param {Object} updatedSection - Updated section data
* @returns {boolean} True if section was found and updated
*/
function updateSectionInGroups(groups, sectionId, updatedSection) {
const found = findSectionInGroups(groups, sectionId);
if (found) {
found.group.sections[found.sectionIndex] = { ...found.section, ...updatedSection };
return true;
}
return false;
}
/**
* Add a section to a group
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} groupName - Name of the group to add to
* @param {Object} section - Section to add
* @param {string} author - Optional author for custom groups
*/
function addSectionToGroup(groups, groupName, section, author) {
if (!Array.isArray(groups)) return;
// Find existing group
let targetGroup = groups.find(g => {
if (author) {
return g.name === groupName && g.author === author;
}
return g.name === groupName && !g.author;
});
// Create group if it doesn't exist
if (!targetGroup) {
targetGroup = {
name: groupName,
sections: []
};
if (author) {
targetGroup.author = author;
}
groups.push(targetGroup);
}
// Add section
if (!targetGroup.sections) {
targetGroup.sections = [];
}
targetGroup.sections.push(section);
// Sort sections by order
targetGroup.sections.sort((a, b) => (a.order || 0) - (b.order || 0));
}
/**
* Remove a section from groups
* @param {Array} groups - Array of HomeScreenSectionGroup objects
* @param {string} sectionId - Section ID to remove
* @returns {boolean} True if section was found and removed
*/
function removeSectionFromGroups(groups, sectionId) {
if (!Array.isArray(groups)) return false;
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const group = groups[groupIndex];
if (group && Array.isArray(group.sections)) {
const sectionIndex = group.sections.findIndex(s => s.id === sectionId);
if (sectionIndex !== -1) {
group.sections.splice(sectionIndex, 1);
// Remove empty groups (except default groups)
if (group.sections.length === 0 && !group.author) {
groups.splice(groupIndex, 1);
}
return true;
}
}
}
return false;
}
/**
* Verify and sync recently-added library sections with actual Jellyfin libraries
* @param {Object} config - Current configuration object
* @returns {Object} Updated configuration with synced recently-added sections
*/
async function verifyRecentlyAddedInLibraryConfig(config) {
try {
if (!window.dataHelper || !window.dataHelper.getLibraries) {
WARN('dataHelper.getLibraries not available, skipping recently-added verification');
return config;
}
// Get current libraries
const libraries = await window.dataHelper.getLibraries();
if (!libraries || libraries.length === 0) {
LOG('No libraries found, skipping recently-added verification');
return config;
}
// Flatten groups to get all sections
const allSections = flattenSectionGroups(config.HOME_SECTION_GROUPS || []);
// Extract existing recently-added sections
const recentlyAddedSections = allSections.filter(s => s.id && s.id.startsWith('recently-added-'));
const existingLibraryIds = new Set(recentlyAddedSections.map(s => s.id.replace('recently-added-', '')));
// Get current library IDs
const currentLibraryIds = new Set(libraries.map(l => l.Id));
// Find sections to remove (libraries that no longer exist)
const sectionsToRemove = recentlyAddedSections.filter(s => {
const libId = s.id.replace('recently-added-', '');
return !currentLibraryIds.has(libId);
});
// Find libraries to add (new libraries not in config)
const librariesToAdd = libraries.filter(l => !existingLibraryIds.has(l.Id) && l.CollectionType && l.CollectionType !== 'boxsets' && l.CollectionType !== 'playlists');
if (sectionsToRemove.length === 0 && librariesToAdd.length === 0) {
LOG('Recently-added sections are in sync with libraries');
return config;
}
LOG(`Syncing recently-added sections: Removing ${sectionsToRemove.length}, Adding ${librariesToAdd.length}`);
// Remove sections for non-existent libraries
const sectionsToRemoveIds = new Set(sectionsToRemove.map(s => s.id));
// Update groups by removing sections
const updatedGroups = (config.HOME_SECTION_GROUPS || []).map(group => {
if (!group.sections) return group;
const filteredSections = group.sections.filter(s => !sectionsToRemoveIds.has(s.id));
return {
...group,
sections: filteredSections
};
});
// Add sections for new libraries
librariesToAdd.forEach(library => {
let viewMoreUrl = null;
if (library.CollectionType === 'movies') {
viewMoreUrl = `#/movies.html?topParentId=${library.Id}&collectionType=movies&tab=1`;
} else if (library.CollectionType === 'tvshows') {
viewMoreUrl = `#/tv.html?topParentId=${library.Id}&collectionType=tvshows&tab=1`;
}
const newSection = {
id: `recently-added-${library.Id}`,
name: `Recently Added ${library.Name}`,
enabled: false, // Default to disabled
order: 61,
cardFormat: 'Poster',
viewMoreUrl: viewMoreUrl,
queries: [{
path: '/Items/Latest',
queryOptions: {
ParentId: library.Id,
SortBy: 'DateCreated',
SortOrder: 'Descending',
Limit: 16
}
}]
};
// Find or create "Recently Added" group
let recentlyAddedGroup = updatedGroups.find(g => g.name === 'Recently Added');
if (!recentlyAddedGroup) {
recentlyAddedGroup = {
name: 'Recently Added',
sections: []
};
updatedGroups.push(recentlyAddedGroup);
}
recentlyAddedGroup.sections.push(newSection);
});
// Update config
config.HOME_SECTION_GROUPS = updatedGroups;
LOG('Recently-added sections synced successfully');
return config;
} catch (error) {
ERR('Error verifying recently-added library config:', error);
return config;
}
}
/**
* Load configuration from JS Injector and merge with defaults
*/
async function loadConfig() {
try {
// Get existing config from window.KefinTweaksConfig
const existingConfig = (window.KefinTweaksConfig && window.KefinTweaksConfig.homeScreenConfig) || {};
// Get defaults from homeScreenConfig2.js
const defaults = window.KefinHomeConfig2;
if (!defaults) {
throw new Error('KefinHomeConfig2 not found. Ensure homeScreenConfig2.js is loaded.');
}
// Merge configs - now using 4 groups
const mergedConfig = {
HOME_SECTION_GROUPS: mergeGroupDefaults(
defaults.HOME_SECTION_GROUPS || [],
existingConfig.HOME_SECTION_GROUPS || [],
'home'
),
SEASONAL_SECTION_GROUPS: mergeGroupDefaults(
defaults.SEASONAL_SECTION_GROUPS || [],
existingConfig.SEASONAL_SECTION_GROUPS || [],
'seasonal'
),
DISCOVERY_SECTION_GROUPS: mergeGroupDefaults(
defaults.DISCOVERY_SECTION_GROUPS || [],
existingConfig.DISCOVERY_SECTION_GROUPS || [],
'discovery'
),
CUSTOM_SECTION_GROUPS: mergeGroupDefaults(
defaults.CUSTOM_SECTION_GROUPS || [],
existingConfig.CUSTOM_SECTION_GROUPS || [],
'custom'
),
DISCOVERY_SETTINGS: { ...defaults.DISCOVERY_SETTINGS, ...(existingConfig.DISCOVERY_SETTINGS || {}) },
SEASONAL_THEME_SETTINGS: { ...defaults.SEASONAL_THEME_SETTINGS, ...(existingConfig.SEASONAL_THEME_SETTINGS || {}) },
CACHE: { ...defaults.CACHE, ...(existingConfig.CACHE || {}) },
MERGE_NEXT_UP: existingConfig.MERGE_NEXT_UP ?? defaults.MERGE_NEXT_UP ?? false
};
// Verify and sync recently-added library sections
await verifyRecentlyAddedInLibraryConfig(mergedConfig);
currentConfig = mergedConfig;
return mergedConfig;
} catch (error) {
ERR('Error loading config:', error);
throw error;
}
}
/**
* Get merged home screen config (exported for use by other scripts)
* @returns {Promise