Configure default skin and available skins for all users
Default Skin
Select a default skin for all users
Enable/Disable Skins
Disabled skins will not appear in the appearance dropdowns for users
All themes are created with love by community members like you. Don't forget to thank them for their work and time if you appreciate their creations.
KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility.
${buildSkinToggles(skins, skinSources)}
${(() => {
// Check if there are any overridden default skins
const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden');
if (hasOverriddenSkins) {
return `
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
`;
}
return '';
})()}
Optional CSS Modules
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
`;
}).join('');
}
// Build script toggle switches
function buildScriptToggles(scripts) {
const scriptNames = [
{ key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' },
{ key: 'homeScreen', label: 'Enhanced Home Screen', desc: 'Add custom home screen sections and a "discovery engine" to your Home Page. Create sections based on Genre, Tags, Playlists, Collections or Search Terms for specific Item Types. Use the "Spotlight" feature to highlight specific sections with an image carousel/slideshow.' },
{ key: 'search', label: 'Enhanced Search', desc: 'Search speed is improved by searching less content types by default. Provides options to specify which content type to search. Fully compatible with both the Jellysearch Search from Jellyfin Enhanced plugin and the Meilisearch plugin.' },
{ key: 'infiniteScroll', label: 'Infinite Scroll', desc: 'Adds infinite scrolling to the library pages for Movies and TV' },
{ key: 'removeContinue', label: 'Remove Continue', desc: 'Adds the ability to remove items from the Continue Watching sections' },
{ key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' },
{ key: 'headerTabs', label: 'Header Tabs', desc: 'Allows direct navigation to specific Tab sections of a given library page. Full support for default Jellyfin landing pages.' },
{ key: 'customMenuLinks', label: 'Custom Menu Links', desc: 'Add custom menu links to the left side navigation menu' },
{ key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' },
{ key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' },
{ key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' },
{ key: 'flattenSingleSeasonShows', label: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' },
{ key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' },
{ key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' },
{ key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' },
{ key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' },
{ key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' },
{ key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' }
];
return scriptNames.map(script => {
const isEnabled = scripts[script.key] !== false; // Default to true if not set
return `
Failed to load preview: ${error?.message || error}
`;
}
}
});
previewModal.open();
}
async function updateSectionSourceField(prefix, sectionIndex, isSeasonalNested) {
const sectionItem = modalInstance.dialogContent.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`);
if (!sectionItem) {
console.warn(`[KefinTweaks Configuration] Section item not found for prefix: ${prefix}, index: ${sectionIndex}`);
return;
}
const typeSelect = sectionItem.querySelector(`.${prefix}_section_type[data-section-index="${sectionIndex}"]`);
if (!typeSelect) {
console.warn(`[KefinTweaks Configuration] Type select not found for prefix: ${prefix}, index: ${sectionIndex}`);
return;
}
const type = typeSelect.value || 'Genre';
// Find the source field container using the specific class
const sourceContainer = sectionItem.querySelector(`.${prefix}_section_source_container[data-section-index="${sectionIndex}"]`);
if (!sourceContainer) {
console.warn(`[KefinTweaks Configuration] Source container not found for prefix: ${prefix}, index: ${sectionIndex}`);
return;
}
// Get current source value
const currentSourceInput = sectionItem.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`);
const badgeContainer = sourceContainer?.querySelector(`.tag-badge-container[data-section-index="${sectionIndex}"][data-type]`) || null;
let currentSource = '';
if (badgeContainer) {
// Extract from existing badges
const badges = badgeContainer.querySelectorAll('.tag-badge');
currentSource = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean).join(', ');
} else if (currentSourceInput) {
// Extract from dropdown/input
currentSource = currentSourceInput.value || '';
}
// Clear badges whenever the source type changes
// This ensures a clean state when switching between any types
const currentType = currentSourceInput?.dataset?.type || badgeContainer?.dataset?.type;
if (currentType && currentType !== type) {
currentSource = '';
}
let newSourceHtml = '';
if (type === 'Parent') {
// Parent type uses simple text input
newSourceHtml = `
Parent IDs (comma-separated, leave empty for "Any")
This will completely overwrite your current configuration. All existing settings will be replaced with the imported configuration. This action cannot be undone.
`,
footer: `
`,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modalInstance) => {
const confirmBtn = modalInstance.dialogFooter?.querySelector('#confirmImportConfirmBtn');
const cancelBtn = modalInstance.dialogFooter?.querySelector('#cancelImportConfirmBtn');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
modalInstance.close();
resolve(true);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
modalInstance.close();
resolve(false);
});
}
}
});
});
}
// Apply imported configuration
async function applyImportedConfig(importedConfig, modalInstance) {
// Save to JavaScript Injector
await saveConfigToJavaScriptInjector(importedConfig);
// Reload the modal with new config
modalInstance.close();
// Show success message
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration imported successfully! Reloading...');
}
// Reopen modal with new config after a short delay
setTimeout(() => {
openConfigurationModal();
}, 500);
}
// Find JavaScript Injector plugin
async function findJavaScriptInjectorPlugin() {
try {
if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
throw new Error('ApiClient not available');
}
const server = ApiClient._serverAddress;
const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins`, {
headers: {
'X-Emby-Token': token
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const plugins = await response.json();
console.log('[KefinTweaks Configuration] Active Plugins:', plugins);
// Handle both array and object with Items property
const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []);
// Find JavaScript Injector plugin
const injectorPlugin = pluginsList.find(plugin =>
plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector'
);
if (!injectorPlugin) {
throw new Error('JavaScript Injector plugin not found. Please ensure it is installed.');
}
console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', injectorPlugin);
return injectorPlugin.Id;
} catch (error) {
console.error('[KefinTweaks Configuration] Error finding JavaScript Injector plugin:', error);
throw error;
}
}
// Get current JavaScript Injector configuration
async function getJavaScriptInjectorConfig(pluginId) {
try {
if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
throw new Error('ApiClient not available');
}
const server = ApiClient._serverAddress;
const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
headers: {
'X-Emby-Token': token
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const config = await response.json();
console.log('[KefinTweaks Configuration] Current JS Injector config:', config);
return config;
} catch (error) {
console.error('[KefinTweaks Configuration] Error getting JS Injector config:', error);
throw error;
}
}
// Save configuration to JavaScript Injector plugin
async function saveConfigToJavaScriptInjector(config) {
try {
// Find the plugin
const pluginId = await findJavaScriptInjectorPlugin();
// Get fresh configuration data
const injectorConfig = await getJavaScriptInjectorConfig(pluginId);
// Ensure CustomJavaScripts array exists
if (!injectorConfig.CustomJavaScripts) {
injectorConfig.CustomJavaScripts = [];
}
// Create the script content
const scriptContent = `// KefinTweaks Configuration
// This file is automatically generated by KefinTweaks Configuration UI
// Do not edit manually unless you know what you're doing
window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`;
// Check if KefinTweaks-Config already exists
const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex(
script => script.Name === 'KefinTweaks-Config'
);
if (existingScriptIndex !== -1) {
// Update existing script
console.log('[KefinTweaks Configuration] Updating existing KefinTweaks-Config script');
injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent;
// Keep existing Enabled and RequiresAuthentication settings
} else {
// Add new script
console.log('[KefinTweaks Configuration] Adding new KefinTweaks-Config script');
injectorConfig.CustomJavaScripts.push({
Name: 'KefinTweaks-Config',
Script: scriptContent,
Enabled: true,
RequiresAuthentication: false
});
}
// POST the updated configuration back
const server = ApiClient._serverAddress;
const token = ApiClient.accessToken();
const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, {
method: 'POST',
headers: {
'X-Emby-Token': token,
'Content-Type': 'application/json'
},
body: JSON.stringify(injectorConfig)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
console.log('[KefinTweaks Configuration] Configuration saved successfully');
return true;
} catch (error) {
console.error('[KefinTweaks Configuration] Error saving configuration:', error);
throw error;
}
}
function getSectionConfigFromUI(rootElement, prefix, sectionIndex) {
const scope = rootElement || document;
const sectionItem = scope.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`);
if (!sectionItem) return null;
const sanitizedIndex = sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_');
const sectionIdAttr = sectionItem.getAttribute('data-section-id') || `${prefix}-section-${sanitizedIndex}`;
const enabledCheckbox = sectionItem.querySelector(`#${prefix}_section_enabled_${sanitizedIndex}`) || sectionItem.querySelector(`.${prefix}_section_enabled[data-section-index="${sectionIndex}"]`);
const enabled = enabledCheckbox?.checked !== false;
const name = sectionItem.querySelector(`.${prefix}_section_name[data-section-index="${sectionIndex}"]`)?.value || '';
const type = sectionItem.querySelector(`.${prefix}_section_type[data-section-index="${sectionIndex}"]`)?.value || 'Genre';
let source = '';
const sourceContainer = sectionItem.querySelector(`.${prefix}_section_source_container[data-section-index="${sectionIndex}"]`);
const badgeContainer = sourceContainer?.querySelector(`.tag-badge-container[data-section-index="${sectionIndex}"][data-type]`) || null;
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
source = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean).join(', ');
} else {
const sourceInput = sectionItem.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`);
source = sourceInput ? sourceInput.value : '';
}
const itemLimit = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_itemLimit`)?.value || '16', 10);
const sortOrder = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrder`)?.value || 'Random';
const sortOrderDirection = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrderDirection`)?.value || 'Ascending';
const cardFormat = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_cardFormat`)?.value || 'Poster';
const order = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_order`)?.value || '100', 10);
const spotlightCheckbox = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_spotlight`) || sectionItem.querySelector(`.${prefix}_section_spotlight[data-section-index="${sectionIndex}"]`);
const discoveryCheckbox = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_discovery`) || sectionItem.querySelector(`.${prefix}_section_discovery[data-section-index="${sectionIndex}"]`);
const spotlight = spotlightCheckbox?.checked === true;
const discoveryEnabled = discoveryCheckbox?.checked === true;
const searchTerm = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_searchTerm`)?.value?.trim() || '';
let includeItemTypes = [];
const includeItemTypesContainer = sectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`);
if (includeItemTypesContainer) {
const badges = includeItemTypesContainer.querySelectorAll('.tag-badge');
includeItemTypes = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean);
}
if (includeItemTypes.length === 0 && type !== 'Parent') {
includeItemTypes = ['Movie'];
}
const additionalQueryOptions = [];
const additionalOptionsContainer = sectionItem.querySelector(`.${prefix}_section_additionalOptions_list_container[data-section-index="${sectionIndex}"]`);
if (additionalOptionsContainer) {
const optionRows = additionalOptionsContainer.querySelectorAll('.additional-options-list .additional-option-row');
optionRows.forEach(row => {
const input = row.querySelector('.additional-option-value');
if (input) {
const key = input.getAttribute('data-key');
const type = input.getAttribute('data-type');
let value;
if (type === 'boolean') {
value = input.checked;
} else {
value = input.value;
}
if (key) {
additionalQueryOptions.push({ key, value });
}
}
});
}
return {
id: sectionIdAttr,
enabled: enabled,
name: name,
type: type,
source: source,
itemLimit: itemLimit,
sortOrder: sortOrder,
sortOrderDirection: sortOrderDirection,
cardFormat: cardFormat,
order: order,
spotlight: spotlight,
discoveryEnabled: discoveryEnabled,
searchTerm: searchTerm,
includeItemTypes: includeItemTypes,
additionalQueryOptions: additionalQueryOptions
};
}
async function fetchItemsForSectionPreview(sectionConfig) {
const apiClient = window.ApiClient;
if (!apiClient) {
throw new Error('ApiClient is not available');
}
const userId = apiClient.getCurrentUserId();
const type = sectionConfig.type || 'Genre';
const searchTerm = sectionConfig.searchTerm?.trim() || '';
const includeItemTypes = Array.isArray(sectionConfig.includeItemTypes) && sectionConfig.includeItemTypes.length > 0
? sectionConfig.includeItemTypes.join(',')
: (sectionConfig.includeItemTypes || 'Movie');
const sortOrder = sectionConfig.sortOrder || 'Random';
const sortOrderDirection = sectionConfig.sortOrderDirection || 'Ascending';
const itemLimit = parseInt(sectionConfig.itemLimit, 10) || 16;
const sources = (sectionConfig.source || '').split(',').map(s => s.trim()).filter(Boolean);
const fields = 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear,ImageTags,BackdropImageTags,ParentBackdropImageTags,ParentThumbImageTag,SeriesPrimaryImageTag';
// Parse additional query options
const additionalOptions = {};
if (Array.isArray(sectionConfig.additionalQueryOptions)) {
sectionConfig.additionalQueryOptions.forEach(option => {
if (option && option.key) {
let value = option.value;
if (value === 'true' || value === true) value = true;
else if (value === 'false' || value === false) value = false;
additionalOptions[option.key] = value;
}
});
}
const dedupeAndLimit = (items = []) => {
let working = Array.isArray(items) ? [...items] : [];
if (sortOrder === 'Random') {
working.sort(() => Math.random() - 0.5);
} else if (window.cardBuilder?.sortItems) {
working = window.cardBuilder.sortItems(working, sortOrder, sortOrderDirection);
}
const seen = new Set();
const limited = [];
for (const item of working) {
if (!item || !item.Id || seen.has(item.Id)) continue;
seen.add(item.Id);
limited.push(item);
if (limited.length >= itemLimit) break;
}
return limited;
};
try {
const queryBase = {
IncludeItemTypes: includeItemTypes,
Recursive: true,
Fields: fields,
SortBy: sortOrder,
SortOrder: sortOrderDirection,
...additionalOptions
};
if (type === 'Genre' || type === 'Tag') {
if (sources.length === 0) {
return [];
}
const paramName = type === 'Genre' ? 'Genres' : 'Tags';
// Genres use pipe delimiter usually, tags comma?
// homeScreen.js uses pipe for Genres, comma for Tags.
// apiClient handles array/string? usually string.
const delimiter = type === 'Genre' ? '|' : ',';
const valueParam = sources.join(delimiter);
const params = {
...queryBase,
[paramName]: valueParam,
Limit: itemLimit
};
if (searchTerm) params.SearchTerm = searchTerm;
const response = await apiClient.getItems(userId, params);
return dedupeAndLimit(response.Items || []);
}
if (type === 'Collection' || type === 'Playlist' || type === 'Parent') {
const existingIds = new Set();
let collected = [];
const runQuery = async (params) => {
const response = await apiClient.getItems(userId, params);
if (response?.Items?.length) {
response.Items.forEach(item => {
if (item && item.Id && !existingIds.has(item.Id)) {
existingIds.add(item.Id);
collected.push(item);
}
});
}
};
if (type === 'Parent' && sources.length === 0) {
const params = {
...queryBase,
Limit: itemLimit * 2
};
if (searchTerm) params.SearchTerm = searchTerm;
await runQuery(params);
} else {
const multiplier = Math.max(1, sources.length);
for (const parentId of sources) {
const params = {
...queryBase,
ParentId: parentId,
Limit: itemLimit * multiplier
};
if (searchTerm) params.SearchTerm = searchTerm;
await runQuery(params);
}
}
return dedupeAndLimit(collected);
}
return [];
} catch (error) {
console.warn('[KefinTweaks Configuration] Preview fetch failed:', error);
throw error;
}
}
// Handle save configuration
async function handleSaveConfig(modalInstance) {
// Verify admin status before saving
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
alert('You must be an administrator to save configuration.');
return;
}
console.log('[KefinTweaks Configuration] Saving configuration...');
// Collect all form values
// Preserve existing kefinTweaksRoot from current config (no longer editable in UI)
const currentConfig = window.KefinTweaksCurrentConfig || {};
const config = {
kefinTweaksRoot: currentConfig.kefinTweaksRoot || '',
scripts: {},
homeScreen: {},
exclusiveElsewhere: {},
search: {},
defaultSkin: document.getElementById('defaultSkin')?.value || null,
skins: [],
themes: [],
customMenuLinks: []
};
config.enabled = modalInstance.dialogContent.querySelector('#kefinTweaksEnabled')?.checked !== false;
// Remove scriptRoot if it exists (legacy field, no longer used)
if (config.scriptRoot) {
delete config.scriptRoot;
}
// Collect script toggles
document.querySelectorAll('[data-script-key]').forEach(checkbox => {
const key = checkbox.getAttribute('data-script-key');
config.scripts[key] = checkbox.checked;
});
// Helper function to get section config
function getSectionConfig(prefix, includeSortOrderOrOptions = true, includeOrderFlag = true) {
let includeSortOrder = true;
let includeOrder = true;
let includeName = false;
let defaultName = '';
if (typeof includeSortOrderOrOptions === 'object') {
const opts = includeSortOrderOrOptions || {};
includeSortOrder = opts.includeSortOrder !== undefined ? opts.includeSortOrder : true;
includeOrder = opts.includeOrder !== undefined ? opts.includeOrder : true;
includeName = opts.includeName === true;
defaultName = opts.defaultName || '';
} else {
includeSortOrder = includeSortOrderOrOptions !== false;
includeOrder = includeOrderFlag !== false;
}
const config = {
itemLimit: parseInt(document.getElementById(`${prefix}_itemLimit`)?.value || '16', 10)
};
if (includeName) {
config.name = document.getElementById(`${prefix}_name`)?.value || defaultName || '';
}
if (includeSortOrder) {
config.sortOrder = document.getElementById(`${prefix}_sortOrder`)?.value || 'Random';
config.sortOrderDirection = document.getElementById(`${prefix}_sortOrderDirection`)?.value || 'Ascending';
}
config.cardFormat = document.getElementById(`${prefix}_cardFormat`)?.value || 'Poster';
if (includeOrder) {
config.order = parseInt(document.getElementById(`${prefix}_order`)?.value || '100', 10);
}
// Check for IsPlayed configuration
const isPlayedEnabledCheckbox = document.getElementById(`${prefix}_isPlayed_enabled`);
if (isPlayedEnabledCheckbox) {
if (isPlayedEnabledCheckbox.checked) {
const isPlayedValueSelect = document.getElementById(`${prefix}_isPlayed_value`);
config.isPlayed = isPlayedValueSelect?.value === 'true';
} else {
config.isPlayed = null;
}
}
// Handle Min/Max Age In Days for Recently Released
if (prefix === 'homeScreen_recentlyReleased_movies') {
const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value;
const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value;
config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null;
config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null;
}
if (prefix === 'homeScreen_recentlyReleased_episodes') {
const minAge = document.getElementById(`${prefix}_minAgeInDays`)?.value;
const maxAge = document.getElementById(`${prefix}_maxAgeInDays`)?.value;
config.minAgeInDays = minAge !== '' ? parseInt(minAge, 10) : null;
config.maxAgeInDays = maxAge !== '' ? parseInt(maxAge, 10) : null;
}
return config;
}
// Collect seasonal seasons
function getSeasonalSeasons() {
const seasons = [];
const seasonItems = modalInstance.dialogContent.querySelectorAll('.seasonal-season-item');
seasonItems.forEach((item, seasonIndex) => {
// Try to find checkbox by ID first, then fall back to class selector
const enabledCheckbox = item.querySelector(`#seasonal-season-enabled-${seasonIndex}`) || item.querySelector('.seasonal-season-enabled[data-index="${seasonIndex}"]');
const enabled = enabledCheckbox?.checked !== false;
const name = item.querySelector('.seasonal-season-name')?.value || '';
const startDate = item.querySelector('.seasonal-season-startDate')?.value || '';
const endDate = item.querySelector('.seasonal-season-endDate')?.value || '';
const orderInput = item.querySelector(`.seasonal-season-order-input[data-index="${seasonIndex}"]`);
const order = orderInput ? parseInt(orderInput.value || '100', 10) : 100;
// Collect nested sections
const sectionsList = item.querySelector(`.seasonal_season_sections_list[data-season-index="${seasonIndex}"]`);
const sections = [];
if (sectionsList) {
const sectionItems = sectionsList.querySelectorAll('.seasonal_season_section_item');
sectionItems.forEach((sectionItem, sectionItemIndex) => {
const fullIndex = `${seasonIndex}_${sectionItemIndex}`;
const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'seasonal_season', fullIndex);
if (sectionConfig) {
sections.push(sectionConfig);
}
});
}
const seasonConfig = {
id: name.toLowerCase().replace(/\s+/g, '-') || `season-${seasonIndex}`,
name: name,
enabled: enabled,
startDate: startDate,
endDate: endDate,
order: order,
sections: sections
};
seasons.push(seasonConfig);
});
return seasons;
}
// Collect custom sections
function getCustomSections() {
const sectionsList = modalInstance.dialogContent.querySelector('#customSections_list');
const sections = [];
if (!sectionsList) {
return sections;
}
const sectionItems = sectionsList.querySelectorAll('.customSection_section_item');
sectionItems.forEach((sectionItem, index) => {
const sectionIndex = sectionItem.getAttribute('data-section-index') ?? index;
const sectionConfig = getSectionConfigFromUI(modalInstance.dialogContent, 'customSection', sectionIndex);
if (sectionConfig) {
sections.push(sectionConfig);
}
});
return sections;
}
// Collect home screen config
config.homeScreen = {
defaultItemLimit: 16,
defaultSortOrder: 'Random',
defaultCardFormat: 'Poster',
recentlyReleased: {
enabled: document.getElementById('homeScreen_recentlyReleased_enabled')?.checked !== false,
movies: {
enabled: document.getElementById('homeScreen_recentlyReleased_movies_enabled')?.checked !== false,
...getSectionConfig('homeScreen_recentlyReleased_movies', { includeName: true, defaultName: 'Recently Released Movies', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true })
},
episodes: {
enabled: document.getElementById('homeScreen_recentlyReleased_episodes_enabled')?.checked !== false,
...getSectionConfig('homeScreen_recentlyReleased_episodes', { includeName: true, defaultName: 'Recently Aired Episodes', includeSortOrder: true, includeOrder: true, includeIsPlayed: true, includePremiereDays: true })
}
},
trending: {
enabled: document.getElementById('homeScreen_trending_enabled')?.checked === true,
...getSectionConfig('homeScreen_trending', { includeName: true, defaultName: 'Trending' })
},
popularTVNetworks: {
enabled: document.getElementById('homeScreen_popularTVNetworks_enabled')?.checked === true,
minimumShowsForNetwork: parseInt(document.getElementById('homeScreen_popularTVNetworks_minimumShowsForNetwork')?.value || '5'),
...getSectionConfig('homeScreen_popularTVNetworks', { includeName: true, defaultName: 'Popular TV Networks' })
},
watchlist: {
enabled: document.getElementById('homeScreen_watchlist_enabled')?.checked === true,
...getSectionConfig('homeScreen_watchlist', { includeName: true, defaultName: 'Watchlist' })
},
watchAgain: {
enabled: document.getElementById('homeScreen_watchAgain_enabled')?.checked === true,
...getSectionConfig('homeScreen_watchAgain', { includeName: true, defaultName: 'Watch Again' })
},
upcoming: {
enabled: document.getElementById('homeScreen_upcoming_enabled')?.checked !== false,
...getSectionConfig('homeScreen_upcoming', { includeSortOrder: false, includeName: true, defaultName: 'Upcoming' })
},
imdbTop250: {
enabled: document.getElementById('homeScreen_imdbTop250_enabled')?.checked !== false,
...getSectionConfig('homeScreen_imdbTop250', { includeName: true, defaultName: 'IMDb Top 250' })
},
seasonal: {
enabled: document.getElementById('homeScreen_seasonal_enabled')?.checked !== false,
enableSeasonalAnimations: document.getElementById('homeScreen_seasonal_enableSeasonalAnimations')?.checked !== false,
defaultItemLimit: 16,
defaultSortOrder: 'Random',
defaultCardFormat: 'Poster',
seasons: getSeasonalSeasons()
},
discovery: {
enabled: document.getElementById('homeScreen_discovery_enabled')?.checked !== false,
infiniteScroll: document.getElementById('homeScreen_discovery_infiniteScroll')?.checked !== false,
randomizeOrder: document.getElementById('homeScreen_discovery_randomizeOrder')?.checked === true,
minPeopleAppearances: parseInt(document.getElementById('homeScreen_discovery_minPeopleAppearances')?.value || '10'),
minGenreMovieCount: parseInt(document.getElementById('homeScreen_discovery_minGenreMovieCount')?.value || '50'),
spotlightDiscoveryChance: parseFloat(document.getElementById('homeScreen_discovery_spotlightDiscoveryChance')?.value || '0.5'),
renderSpotlightAboveMatching: document.getElementById('homeScreen_discovery_renderSpotlightAboveMatching')?.checked === true,
defaultItemLimit: parseInt(document.getElementById('homeScreen_discovery_itemLimit')?.value || '16'),
defaultSortOrder: document.getElementById('homeScreen_discovery_sortOrder')?.value || 'Random',
defaultCardFormat: document.getElementById('homeScreen_discovery_cardFormat')?.value || 'Poster',
sectionTypes: (() => {
const sectionConfigs = {};
DISCOVERY_SECTION_DEFINITIONS.forEach(section => {
const prefix = `homeScreen_discovery_sectionTypes_${section.key}`;
const collectedConfig = getSectionConfig(prefix, { includeName: true, includeOrder: true, includeSortOrder: false, defaultName: section.defaultName });
collectedConfig.enabled = document.getElementById(`${prefix}_enabled`)?.checked !== false;
if (section.extras?.minimumItems !== undefined) {
const minimumValue = parseInt(document.getElementById(`${prefix}_minimumItems`)?.value || `${section.extras.minimumItems}`, 10);
collectedConfig.minimumItems = Number.isFinite(minimumValue) ? minimumValue : section.extras.minimumItems;
}
sectionConfigs[section.key] = collectedConfig;
});
return sectionConfigs;
})()
},
customSections: getCustomSections()
};
// Collect exclusive elsewhere config
config.exclusiveElsewhere = {
hideServerName: document.getElementById('exclusiveElsewhere_hideServerName')?.checked === true
};
// Collect search config
config.search = {
enableJellyseerr: document.getElementById('search_enableJellyseerr')?.checked === true
};
// Collect flattenSingleSeasonShows config
config.flattenSingleSeasonShows = {
hideSingleSeasonContainer: document.getElementById('flattenSingleSeasonShows_hideSingleSeasonContainer')?.checked === true
};
// Parse JSON fields
const skinsFromJson = parseJSONField('skinsJson', []);
config.themes = parseJSONField('themesJson', []);
config.customMenuLinks = parseJSONField('customMenuLinksJson', []);
// Collect optional includes configuration
config.optionalIncludes = collectOptionalIncludesConfig(modalInstance);
// Collect skin enabled toggles and merge with JSON skins
// Only save admin-configured skins (not defaults)
// Get default skin names to filter them out
const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name));
// Collect enabled states from toggles (for all skins, including defaults)
const skinEnabledStates = {};
modalInstance.dialogContent.querySelectorAll('[data-skin-name]').forEach(checkbox => {
const skinName = checkbox.getAttribute('data-skin-name');
skinEnabledStates[skinName] = checkbox.checked;
});
// Only save admin skins (from JSON textarea) - filter out any that match default names
// This ensures we don't accidentally save default skins
config.skins = skinsFromJson
.filter(skin => !defaultSkinNames.has(skin.name)) // Exclude defaults
.map(skin => ({
...skin,
enabled: skinEnabledStates[skin.name] !== false, // Default to true if not set
hidden: false // Explicitly set for admin skins
}));
// Note: Enabled states for default skins are collected but not saved in config.skins
// Default skins will use their default enabled state from skinConfig.js
// If we need to save enabled states for defaults, we'd need a separate field (future enhancement)
// Handle defaultSkin
if (config.defaultSkin === '') {
config.defaultSkin = null;
}
console.log('[KefinTweaks Configuration] Configuration collected:', config);
// Save to JavaScript Injector plugin
try {
await saveConfigToJavaScriptInjector(config);
// Update global config objects so dropdown changes reflect saved values
window.KefinTweaksConfig = config;
window.KefinTweaksCurrentConfig = config;
currentLoadedConfig = config;
// Show success toast
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('KefinTweaks saved!');
}
} catch (error) {
console.error('[KefinTweaks Configuration] Error saving configuration:', error);
// Show error toast
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(`Error saving configuration: ${error.message}. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.`, '5');
} else {
// Fallback to alert if toaster is not available
alert(`Error saving configuration: ${error.message}\n\nPlease ensure the JavaScript Injector plugin is installed and you have administrator permissions.`);
}
}
}
async function handleResetConfig(modalInstance) {
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
alert('You must be an administrator to reset configuration.');
return;
}
const confirmed = confirm('This will restore the KefinTweaks configuration to its defaults. It cannot be undone. Are you sure you want to continue?');
if (!confirmed) {
return;
}
try {
const defaultConfig = await loadDefaultConfig();
// Retain kefinTweaksRoot from existing config
const existingConfig = await getKefinTweaksConfig();
defaultConfig.kefinTweaksRoot = existingConfig.kefinTweaksRoot;
// Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/'
await saveConfigToJavaScriptInjector(defaultConfig);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration reset to defaults.');
} else {
alert('Configuration reset to defaults.');
}
if (modalInstance && typeof modalInstance.close === 'function') {
modalInstance.close();
}
openConfigurationModal();
} catch (error) {
console.error('[KefinTweaks Configuration] Error resetting config:', error);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(`Error resetting configuration: ${error.message}`, '5');
} else {
alert(`Error resetting configuration: ${error.message}`);
}
}
}
// Helper to parse JSON fields safely
function parseJSONField(fieldId, defaultValue) {
try {
const field = document.getElementById(fieldId);
if (!field || !field.value.trim()) {
return defaultValue;
}
return JSON.parse(field.value);
} catch (error) {
console.error(`[KefinTweaks Configuration] Error parsing JSON field ${fieldId}:`, error);
alert(`Error parsing JSON in ${fieldId}: ${error.message}`);
return defaultValue;
}
}
// Create configuration button for Administration section
function createConfigButton() {
const button = document.createElement('div');
button.style.display = 'block';
button.style.padding = '0';
button.style.margin = '0';
button.className = 'listItem-border emby-button kefin-config-button';
button.setAttribute('data-kefintweaks-config-button', 'true');
button.onclick = (e) => {
e.preventDefault();
openConfigurationModal();
};
button.innerHTML = `
Configure KefinTweaks
`;
return button;
}
// Create dashboard side menu button
function createDashboardConfigButton() {
// Check if button already exists
if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) {
return null;
}
const div = document.createElement('div');
div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters kefin-config-button';
div.setAttribute('tabindex', '0');
div.setAttribute('data-kefintweaks-dashboard-config-button', 'true');
div.onclick = () => {
openConfigurationModal();
};
// Create icon with Material icon
const icon = document.createElement('div');
icon.className = 'MuiListItemIcon-root css-37qkju';
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons build MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
iconSpan.style.marginRight = '12px';
iconSpan.setAttribute('aria-hidden', 'true');
icon.appendChild(iconSpan);
// Create text
const text = document.createElement('div');
text.className = 'MuiListItemText-root css-f696uz';
const span = document.createElement('span');
span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz';
span.textContent = 'KefinTweaks';
text.appendChild(span);
// Create ripple effect span
const ripple = document.createElement('span');
ripple.className = 'MuiTouchRipple-root css-w0pj6f';
div.appendChild(icon);
div.appendChild(text);
div.appendChild(ripple);
return div;
}
// Add configuration button to dashboard side menu
function addConfigButtonToDashboard() {
// Check if button already exists
if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) {
return;
}
// Find the plugins section
const pluginsSection = document.querySelector('ul[aria-labelledby="plugins-subheader"]');
if (!pluginsSection) {
console.log('[KefinTweaks Configuration] Plugins section not found, retrying...');
setTimeout(addConfigButtonToDashboard, 500);
return;
}
// Create the button
const button = createDashboardConfigButton();
if (!button) {
return;
}
// Add button to the plugins section
pluginsSection.appendChild(button);
console.log('[KefinTweaks Configuration] Configuration button added to dashboard side menu');
}
// Add configuration button to Administration section
function addConfigButtonToAdminSection() {
// Check if button already exists (check for admin section button specifically)
if (document.querySelector('.adminSection [data-kefintweaks-config-button]')) {
return;
}
// Find the Administration section
const adminSection = document.querySelector('.adminSection.verticalSection');
if (!adminSection) {
console.log('[KefinTweaks Configuration] Administration section not found, retrying...');
setTimeout(addConfigButtonToAdminSection, 500);
return;
}
// Create and add the button
const button = createConfigButton();
adminSection.appendChild(button);
console.log('[KefinTweaks Configuration] Configuration button added to Administration section');
}
// Register onViewPage handler for mypreferencesmenu page and dashboard
function registerViewPageHandler() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
console.log('[KefinTweaks Configuration] KefinTweaksUtils.onViewPage not available, retrying...');
setTimeout(registerViewPageHandler, 1000);
return;
}
// Handler for dashboard and preferences pages
window.KefinTweaksUtils.onViewPage(async (view, element, hash) => {
// Check if user is admin before adding buttons
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
console.log('[KefinTweaks Configuration] User is not admin, not adding buttons');
return;
}
// Check if we're on the mypreferencesmenu page
if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) {
console.log('[KefinTweaks Configuration] Preferences page detected');
// Wait a bit for the page to fully load
setTimeout(() => {
addConfigButtonToAdminSection();
}, 500);
}
// Check if we're on the dashboard page
if (view === 'dashboard' || view === 'configurationpage' || hash.includes('dashboard') || hash.includes('configurationpage')) {
console.log('[KefinTweaks Configuration] Dashboard page detected');
// Wait a bit for the page to fully load
setTimeout(() => {
addConfigButtonToDashboard();
}, 500);
}
});
console.log('[KefinTweaks Configuration] Registered onViewPage handler');
}
// Add configuration link to custom menu
async function addConfigLinkToCustomMenu() {
// Check if user is admin
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
console.log('[KefinTweaks Configuration] User is not admin, not adding menu link');
return;
}
// Check if utils is available
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
console.log('[KefinTweaks Configuration] KefinTweaksUtils.addCustomMenuLink not available, retrying...');
setTimeout(addConfigLinkToCustomMenu, 1000);
return;
}
// Use a special URL that we can intercept
const configUrl = '#';
// Add the menu link
const success = await window.KefinTweaksUtils.addCustomMenuLink(
'Configure',
'build',
configUrl,
false,
'.adminMenuOptions'
);
if (success) {
console.log('[KefinTweaks Configuration] Configuration link added to custom menu');
// Set up click handler to intercept navigation and open modal instead
setupConfigLinkClickHandler();
}
}
// Set up click handler for configuration menu link
function setupConfigLinkClickHandler() {
// Find the config button
const configButton = document.querySelector('.navMenuOption[data-name="configure"]');
if (!configButton) {
console.log('[KefinTweaks Configuration] Configuration button not found, retrying...');
setTimeout(setupConfigLinkClickHandler, 1000);
return;
}
// Add click handler to the config button
configButton.addEventListener('click', (e) => {
e.preventDefault();
openConfigurationModal();
});
}
// Handle direct navigation to config URL
function handleDirectConfigNavigation() {
if (window.location.hash === '#kefintweaks-config') {
console.log('[KefinTweaks Configuration] Direct navigation to config URL detected');
// Remove the hash to prevent navigation
window.history.replaceState(null, '', window.location.pathname + window.location.search);
// Open modal
openConfigurationModal();
}
}
// Set up hashchange listener for navigation after page load
function setupHashChangeListener() {
window.addEventListener('hashchange', () => {
if (window.location.hash === '#kefintweaks-config') {
console.log('[KefinTweaks Configuration] Hash change to config URL detected');
// Remove the hash to prevent navigation
window.history.replaceState(null, '', window.location.pathname + window.location.search);
// Open modal
openConfigurationModal();
}
});
}
// Initialize when DOM is ready
async function initialize() {
if (!window.ApiClient || !window.ApiClient._loggedIn) {
setTimeout(initialize, 1000);
return;
}
// Register view page handler
registerViewPageHandler();
// Add configuration link to custom menu
addConfigLinkToCustomMenu();
// Set up hashchange listener
setupHashChangeListener();
// Handle direct navigation to config URL
handleDirectConfigNavigation();
// Also check current page on initial load
const hash = window.location.hash;
const userIsAdmin = await isAdmin();
if (userIsAdmin) {
if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) {
setTimeout(() => {
addConfigButtonToAdminSection();
}, 1000);
}
if (hash && hash.includes('dashboard')) {
setTimeout(() => {
addConfigButtonToDashboard();
}, 1000);
}
}
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
console.log('[KefinTweaks Configuration] Script loaded');
})();