`;
}).join('');
}
// Build script toggle switches
function buildScriptToggles(scripts) {
// Features that have configuration options (not just toggles)
const featuresWithConfig = new Set(['homeScreen', 'search', 'flattenSingleSeasonShows', 'skinManager', 'customMenuLinks', 'seriesInfo', 'versionPreferences', 'thumbnailScrubber', 'watchTogether', 'watchlist', 'userManager']);
const scriptNames = [
{ key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' },
{ key: 'homeScreen', label: 'Enhanced Home Screen', desc: 'Add custom home screen sections and a "discovery engine" to your Home Page. Create sections based on Genre, Tags, Playlists, Collections or Search Terms for specific Item Types. Use the "Spotlight" feature to highlight specific sections with an image carousel/slideshow.' },
{ key: 'search', label: 'Enhanced Search', desc: 'Search speed is improved by searching less content types by default. Provides options to specify which content type to search. Fully compatible with both the Jellysearch Search from Jellyfin Enhanced plugin and the Meilisearch plugin.' },
{ key: 'infiniteScroll', label: 'Infinite Scroll', desc: 'Adds infinite scrolling to the library pages for Movies and TV' },
{ key: 'removeContinue', label: 'Remove Continue', desc: 'Adds the ability to remove items from the Continue Watching sections' },
{ key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' },
{ key: 'headerTabs', label: 'Header Tabs', desc: 'Allows direct navigation to specific Tab sections of a given library page. Full support for default Jellyfin landing pages.' },
{ key: 'customMenuLinks', label: 'Custom Menu Links', desc: 'Add custom menu links to the left side navigation menu' },
{ key: 'hamburgerMenu', label: 'Desktop Hamburger Menu', desc: 'Shows an Open Menu button on desktop (900px+) that opens a left navigation drawer like the mobile layout, including libraries and custom menu links.' },
{ key: 'games', label: 'Games', desc: 'Add a Games link to the side menu for Movie LUT and other library games' },
{ key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' },
{ key: 'playlist', label: 'Playlist UX', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' },
{ key: 'itemDetailsCollections', label: 'Collections on Details Page', desc: 'Displays collections on item details pages (Included In section)' },
{ key: 'flattenSingleSeasonShows', label: 'Episodes On Series Page', desc: 'Displays episodes directly on series page. For single-season shows, shows all episodes. For multi-season shows, shows episodes from the season with Next Up episode (or Season 1 if no Next Up).' },
{ key: 'seriesInfo', label: 'Series Info+', desc: 'Adds Season and Episode counts to the Series and Season pages. Also adds an "Ends at" time similar to the Episode page.' },
{ key: 'versionPreferences', label: 'Item Version Preferences', desc: 'Manage how different versions and editions of your items are displayed. Reorders and sanitizes the media source dropdown on item details pages by prioritized version and edition terms.' },
{ key: 'collections', label: 'Collection Sorting', desc: 'Collection sorting functionality on the Collection page' },
{ key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' },
{ key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' },
{ key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' },
{ key: 'thumbnailScrubber', label: 'Thumbnail Scrubber', desc: 'Shows trickplay thumbnail preview when hovering in the bottom 20px of a video card similar to Youtube' },
{ key: 'watchTogether', label: 'Watch Together', desc: 'Link additional Jellyfin accounts to a Watch Together group, then sync watched / in-progress playstate to selected accounts on this device.' },
{ key: 'userManager', label: 'User Manager', desc: 'Create reusable user policy templates and apply them when adding users or bulk-editing existing accounts. Import/export comes later.' }
];
const defsByName = new Map(
(window.KefinTweaks?.getScripts?.() || []).map((def) => [def.name, def])
);
// Prefer cache warmed by await getJellyfinMajorVersion() before opening the modal
const major = window.KefinTweaks?._jellyfinMajorVersion ?? null;
const visibleScripts = scriptNames.filter((script) => {
const def = defsByName.get(script.key);
if (!def?.versions?.length) return true;
if (major == null) return false;
return def.versions.includes(major);
});
return visibleScripts.map(script => {
// Match injector defaults: most scripts on unless set false; opt-in scripts require true
const defaultDisabled = script.key === 'thumbnailScrubber' || script.key === 'watchTogether' || script.key === 'userManager';
const isEnabled = defaultDisabled
? scripts[script.key] === true
: scripts[script.key] !== false;
const hasConfig = featuresWithConfig.has(script.key);
const configKey = script.key === 'flattenSingleSeasonShows' ? 'seriesEpisodes' : script.key;
return `
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")
`,
footer: `
`,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (pickerModalInstance) => {
const searchInput = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionSearch');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const list = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionList');
const items = list.querySelectorAll('label');
const term = e.target.value.toLowerCase();
items.forEach(item => {
const name = item.querySelector('span').textContent.toLowerCase();
item.style.display = name.includes(term) ? '' : 'none';
});
});
}
const confirmBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionConfirm');
const clearBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionClear');
const cancelBtn = pickerModalInstance.dialogFooter?.querySelector('#playlistPickerForSectionCancel');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
const selected = pickerModalInstance.dialogContent.querySelector('#playlistPickerForSectionList input[type="radio"]:checked');
const selectedId = selected ? selected.value : '';
if (sourceInput) {
sourceInput.value = selectedId;
const button = sectionItem?.querySelector(`button[onclick*="openPlaylistPickerForSection"]`);
const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_playlist_selected[data-section-index="${sectionIndex}"]`);
if (button) {
button.querySelector('span').textContent = `Select Playlist ${selectedId ? '(1)' : ''}`;
}
if (selectedDiv) {
selectedDiv.textContent = selectedId ? `Selected: 1 playlist` : 'No playlist selected';
}
}
pickerModalInstance.close();
});
}
if (clearBtn) {
clearBtn.addEventListener('click', () => {
const radios = pickerModalInstance.dialogContent.querySelectorAll('#playlistPickerForSectionList input[type="radio"]');
radios.forEach(radio => radio.checked = false);
if (sourceInput) {
sourceInput.value = '';
const button = sectionItem?.querySelector(`button[onclick*="openPlaylistPickerForSection"]`);
const selectedDiv = sectionItem?.querySelector(`.${prefix}_section_playlist_selected[data-section-index="${sectionIndex}"]`);
if (button) {
button.querySelector('span').textContent = 'Select Playlist';
}
if (selectedDiv) {
selectedDiv.textContent = 'No playlist selected';
}
}
pickerModalInstance.close();
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
pickerModalInstance.close();
});
}
}
});
} catch (error) {
console.error('[KefinTweaks Configuration] Error opening playlist picker for section:', error);
alert('Error loading playlists. Please try again.');
}
};
window.editSeasonalSeason = function(index) {
// For now, editing is done inline - this could open a modal in the future
console.log('Edit seasonal season', index);
};
window.deleteSeasonalSeason = function(index) {
const seasonsList = modalInstance.dialogContent.querySelector('#homeScreen_seasonal_seasons_list');
if (!seasonsList) return;
const seasonItem = seasonsList.querySelector(`.seasonal-season-item[data-season-index="${index}"]`);
if (seasonItem && confirm('Are you sure you want to delete this season?')) {
seasonItem.remove();
// Re-index remaining items
const remainingItems = seasonsList.querySelectorAll('.seasonal-season-item');
remainingItems.forEach((item, newIndex) => {
item.setAttribute('data-season-index', newIndex);
item.querySelectorAll('[data-index]').forEach(el => {
el.setAttribute('data-index', newIndex);
});
const editBtn = item.querySelector('button[onclick*="editSeasonalSeason"]');
const deleteBtn = item.querySelector('button[onclick*="deleteSeasonalSeason"]');
if (editBtn) editBtn.setAttribute('onclick', `editSeasonalSeason(${newIndex})`);
if (deleteBtn) deleteBtn.setAttribute('onclick', `deleteSeasonalSeason(${newIndex})`);
});
}
};
// Toggle visibility of configuration sections based on script enable state
const scriptCheckboxes = {
search: modalInstance.dialogContent.querySelector('#script_search'),
exclusiveElsewhere: modalInstance.dialogContent.querySelector('#script_exclusiveElsewhere'),
skinManager: modalInstance.dialogContent.querySelector('#script_skinManager'),
customMenuLinks: modalInstance.dialogContent.querySelector('#script_customMenuLinks'),
flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#script_flattenSingleSeasonShows')
};
const configSections = {
search: modalInstance.dialogContent.querySelector('#configSection_search'),
exclusiveElsewhere: modalInstance.dialogContent.querySelector('#configSection_exclusiveElsewhere'),
skin: modalInstance.dialogContent.querySelector('#configSection_skin'),
theme: modalInstance.dialogContent.querySelector('#configSection_theme'),
customMenuLinks: modalInstance.dialogContent.querySelector('#configSection_customMenuLinks'),
flattenSingleSeasonShows: modalInstance.dialogContent.querySelector('#configSection_flattenSingleSeasonShows')
};
// Function to toggle section visibility
const toggleSectionVisibility = (scriptKey, sectionKey) => {
const checkbox = scriptCheckboxes[scriptKey];
const section = configSections[sectionKey];
if (checkbox && section) {
const isEnabled = checkbox.checked;
section.style.display = isEnabled ? '' : 'none';
}
};
// Set up event listeners for each script checkbox
if (scriptCheckboxes.search) {
scriptCheckboxes.search.addEventListener('change', () => toggleSectionVisibility('search', 'search'));
}
if (scriptCheckboxes.exclusiveElsewhere) {
scriptCheckboxes.exclusiveElsewhere.addEventListener('change', () => toggleSectionVisibility('exclusiveElsewhere', 'exclusiveElsewhere'));
}
if (scriptCheckboxes.skinManager) {
scriptCheckboxes.skinManager.addEventListener('change', () => {
toggleSectionVisibility('skinManager', 'skin');
toggleSectionVisibility('skinManager', 'theme');
});
}
// Initialize pending state for optional includes to track changes across category switches
// Deep copy initial config to avoid mutating reference before save
const pendingOptionalIncludes = JSON.parse(JSON.stringify(config.optionalIncludes || []));
let currentOptionalIncludesCategory = 'global';
// Optional includes category dropdown handler
const optionalIncludesCategory = modalInstance.dialogContent.querySelector('#optionalIncludesCategory');
if (optionalIncludesCategory) {
optionalIncludesCategory.addEventListener('change', (e) => {
const newCategory = e.target.value;
// 1. Capture changes from the PREVIOUS category before switching
const currentCheckboxes = modalInstance.dialogContent.querySelectorAll(`.optionalIncludeCheckbox[data-category="${currentOptionalIncludesCategory}"]`);
currentCheckboxes.forEach(checkbox => {
const key = checkbox.getAttribute('data-key');
const enabled = checkbox.checked;
const existingIndex = pendingOptionalIncludes.findIndex(item => item.key === key);
if (existingIndex >= 0) {
pendingOptionalIncludes[existingIndex].enabled = enabled;
} else {
pendingOptionalIncludes.push({ key, enabled });
}
});
// Update current category tracker
currentOptionalIncludesCategory = newCategory;
const editor = modalInstance.dialogContent.querySelector('#optionalIncludesEditor');
if (editor) {
// Create a temporary config object that uses our pending state
// This ensures buildOptionalIncludesEditor renders checkboxes with the correct pending state
const currentConfig = {
...(window.KefinTweaksConfig || config),
optionalIncludes: pendingOptionalIncludes
};
editor.innerHTML = buildOptionalIncludesEditor(newCategory, currentConfig, window.KefinTweaksSkinConfig || []);
// Re-attach checkbox handlers
attachOptionalIncludesCheckboxHandlers(modalInstance, newCategory, currentConfig);
}
});
}
// Attach initial checkbox handlers
attachOptionalIncludesCheckboxHandlers(modalInstance, 'global', config);
if (scriptCheckboxes.customMenuLinks) {
scriptCheckboxes.customMenuLinks.addEventListener('change', () => toggleSectionVisibility('customMenuLinks', 'customMenuLinks'));
}
if (scriptCheckboxes.flattenSingleSeasonShows) {
scriptCheckboxes.flattenSingleSeasonShows.addEventListener('change', () => toggleSectionVisibility('flattenSingleSeasonShows', 'flattenSingleSeasonShows'));
}
// Save button handler
const saveBtn = modalInstance.dialogFooter?.querySelector('#saveConfigBtn');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
// Capture changes from the CURRENT category one last time
const currentCheckboxes = modalInstance.dialogContent.querySelectorAll(`.optionalIncludeCheckbox[data-category="${currentOptionalIncludesCategory}"]`);
currentCheckboxes.forEach(checkbox => {
const key = checkbox.getAttribute('data-key');
const enabled = checkbox.checked;
const existingIndex = pendingOptionalIncludes.findIndex(item => item.key === key);
if (existingIndex >= 0) {
pendingOptionalIncludes[existingIndex].enabled = enabled;
} else {
pendingOptionalIncludes.push({ key, enabled });
}
});
handleSaveConfig(modalInstance, pendingOptionalIncludes);
});
}
const resetBtn = modalInstance.dialogFooter?.querySelector('#resetConfigBtn');
if (resetBtn) {
resetBtn.addEventListener('click', () => handleResetConfig(modalInstance));
}
// Export button handler
const exportBtn = modalInstance.dialog?.querySelector('#exportConfigBtn');
if (exportBtn) {
exportBtn.addEventListener('click', () => handleExportConfig(config));
}
// Import button handler
const importBtn = modalInstance.dialog?.querySelector('#importConfigBtn');
if (importBtn) {
importBtn.addEventListener('click', () => handleImportConfig(modalInstance));
}
// Remove duplicate defaults button handler
const removeDuplicateDefaultsBtn = modalInstance.dialogContent.querySelector('#removeDuplicateDefaultsBtn');
if (removeDuplicateDefaultsBtn) {
removeDuplicateDefaultsBtn.addEventListener('click', () => handleRemoveDuplicateDefaults(modalInstance));
}
}
// Handle removing custom skins that override defaults
function handleRemoveDuplicateDefaults(modalInstance) {
const skinsTextarea = modalInstance.dialogContent.querySelector('#skinsJson');
if (!skinsTextarea) return;
try {
const currentSkins = JSON.parse(skinsTextarea.value || '[]');
const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name));
// Filter out skins that match default names
const filteredSkins = currentSkins.filter(skin => !defaultSkinNames.has(skin.name));
const removedCount = currentSkins.length - filteredSkins.length;
if (removedCount === 0) {
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('No duplicate default skins found in configuration');
} else {
alert('No duplicate default skins found in configuration');
}
return;
}
// Update textarea
skinsTextarea.value = JSON.stringify(filteredSkins, null, 2);
// Show confirmation
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(`Removed ${removedCount} custom skin${removedCount > 1 ? 's' : ''} that were overriding defaults`);
} else {
alert(`Removed ${removedCount} custom skin${removedCount > 1 ? 's' : ''} that were overriding defaults`);
}
} catch (error) {
console.error('[KefinTweaks Configuration] Error removing duplicate defaults:', error);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Error processing skins JSON', '5');
} else {
alert('Error processing skins JSON. Please check the format.');
}
}
}
// Export configuration via modal (with optional clipboard copy on HTTPS)
async function handleExportConfig(config) {
try {
const configJson = JSON.stringify(config, null, 2);
// Prefer modal-based export so it works even on unsecured servers
if (window.ModalSystem) {
const isHttps =
(typeof ApiClient !== 'undefined' &&
ApiClient._serverAddress &&
ApiClient._serverAddress.startsWith('https://')) ||
(typeof location !== 'undefined' && location.protocol === 'https:');
const supportsClipboard = !!(navigator.clipboard && navigator.clipboard.writeText);
const canUseClipboard = isHttps && supportsClipboard;
window.ModalSystem.create({
id: 'exportConfigModal',
title: 'Export Configuration',
content: `
Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration.
${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'}
`,
footer: `
${
canUseClipboard
? `
`
: ''
}
`,
closeOnBackdrop: true,
closeOnEscape: true,
onOpen: (modalInstance) => {
const textarea = modalInstance.dialogContent.querySelector('#exportConfigTextarea');
if (textarea) {
textarea.value = configJson;
// Select text to make manual copying easier
setTimeout(() => {
textarea.focus();
textarea.select();
}, 100);
}
const closeBtn = modalInstance.dialogFooter?.querySelector('#closeExportConfigBtn');
if (closeBtn) {
closeBtn.addEventListener('click', () => modalInstance.close());
}
if (canUseClipboard) {
const copyBtn = modalInstance.dialogFooter?.querySelector('#copyExportConfigBtn');
if (copyBtn) {
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(configJson);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration copied to clipboard!');
} else {
alert('Configuration copied to clipboard!');
}
} catch (copyError) {
console.error(
'[KefinTweaks Configuration] Error copying config to clipboard:',
copyError
);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(
'Error copying configuration to clipboard',
'5'
);
} else {
alert('Error copying configuration to clipboard');
}
}
});
}
}
}
});
} else if (navigator.clipboard && navigator.clipboard.writeText) {
// Fallback: no modal system, but clipboard is available
await navigator.clipboard.writeText(configJson);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration exported to clipboard!');
} else {
alert('Configuration exported to clipboard!');
}
} else {
// Last-resort fallback: log to console
console.log('[KefinTweaks Configuration] Exported configuration:', configJson);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(
'Unable to open export modal or access clipboard; configuration logged to console',
'3'
);
} else {
alert(
'Unable to open export modal or access clipboard. Check the browser console for the exported configuration.'
);
}
}
} catch (error) {
console.error('[KefinTweaks Configuration] Error exporting config:', error);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Error exporting configuration', '5');
} else {
alert('Error exporting configuration');
}
}
}
// Import configuration from clipboard
async function handleImportConfig(modalInstance) {
if (!window.ModalSystem) {
alert('Modal system not available');
return;
}
// Show import modal with textarea
const importModal = window.ModalSystem.create({
id: 'importConfigModal',
title: 'Import Configuration',
content: `
Paste your configuration JSON below. This will completely replace your current configuration.
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 KefinTweaksUtils.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);
}
const JS_INJECTOR_ALIASES = ['JavaScript Injector', 'JS Injector'];
// Find JavaScript Injector plugin (delegates to shared utils cache)
async function findJavaScriptInjectorPlugin() {
try {
if (!window.KefinTweaksUtils?.resolvePluginId) {
throw new Error('KefinTweaksUtils.resolvePluginId is not available');
}
const pluginId = await window.KefinTweaksUtils.resolvePluginId(JS_INJECTOR_ALIASES);
if (!pluginId) {
throw new Error('JavaScript Injector plugin not found. Please ensure it is installed.');
}
console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', pluginId);
return pluginId;
} catch (error) {
console.error('[KefinTweaks Configuration] Error finding JavaScript Injector plugin:', error);
throw error;
}
}
// Get current JavaScript Injector configuration (delegates to shared utils)
async function getJavaScriptInjectorConfig(pluginId) {
try {
if (!window.KefinTweaksUtils?.getPluginConfiguration) {
throw new Error('KefinTweaksUtils.getPluginConfiguration is not available');
}
const config = await window.KefinTweaksUtils.getPluginConfiguration(pluginId || JS_INJECTOR_ALIASES);
console.log('[KefinTweaks Configuration] Current JS Injector config:', config);
return config;
} catch (error) {
console.error('[KefinTweaks Configuration] Error getting JS Injector config:', error);
throw error;
}
}
// Save configuration to JavaScript Injector plugin (delegates to shared utils)
async function saveConfigToJavaScriptInjector(config) {
try {
if (!window.KefinTweaksUtils?.saveConfigToJavaScriptInjector) {
throw new Error('KefinTweaksUtils.saveConfigToJavaScriptInjector is not available');
}
const success = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(config);
if (!success) {
throw new Error('Failed to save configuration to JavaScript Injector');
}
console.log('[KefinTweaks Configuration] Configuration saved successfully');
return true;
} catch (error) {
console.error('[KefinTweaks Configuration] Error saving configuration:', error);
throw error;
}
}
// Expose injector helpers for feature-config fallbacks
window.KefinTweaksConfiguration = Object.assign(window.KefinTweaksConfiguration || {}, {
findJavaScriptInjectorPlugin,
getJavaScriptInjectorConfig,
saveConfigToJavaScriptInjector,
openConfigurationModal
});
function getSectionConfigFromUI(rootElement, prefix, sectionIndex) {
const scope = rootElement || document;
const sectionItem = scope.querySelector(`.${prefix}_section_item[data-section-index="${sectionIndex}"]`);
if (!sectionItem) return null;
const sanitizedIndex = sectionIndex.toString().replace(/[^a-zA-Z0-9]/g, '_');
const sectionIdAttr = sectionItem.getAttribute('data-section-id') || `${prefix}-section-${sanitizedIndex}`;
const enabledCheckbox = sectionItem.querySelector(`#${prefix}_section_enabled_${sanitizedIndex}`) || sectionItem.querySelector(`.${prefix}_section_enabled[data-section-index="${sectionIndex}"]`);
const enabled = enabledCheckbox?.checked !== false;
const name = sectionItem.querySelector(`.${prefix}_section_name[data-section-index="${sectionIndex}"]`)?.value || '';
const type = sectionItem.querySelector(`.${prefix}_section_type[data-section-index="${sectionIndex}"]`)?.value || 'Genre';
let source = '';
const sourceContainer = sectionItem.querySelector(`.${prefix}_section_source_container[data-section-index="${sectionIndex}"]`);
const badgeContainer = sourceContainer?.querySelector(`.tag-badge-container[data-section-index="${sectionIndex}"][data-type]`) || null;
if (badgeContainer) {
const badges = badgeContainer.querySelectorAll('.tag-badge');
source = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean).join(', ');
} else {
const sourceInput = sectionItem.querySelector(`.${prefix}_section_source[data-section-index="${sectionIndex}"]`);
source = sourceInput ? sourceInput.value : '';
}
const itemLimit = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_itemLimit`)?.value || '16', 10);
const sortOrder = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrder`)?.value || 'Random';
const sortOrderDirection = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_sortOrderDirection`)?.value || 'Ascending';
const cardFormat = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_cardFormat`)?.value || 'Poster';
const order = parseInt(sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_order`)?.value || '100', 10);
const renderModeSelect = sectionItem.querySelector(`.${prefix}_section_renderMode[data-section-index="${sectionIndex}"]`);
const renderMode = renderModeSelect ? renderModeSelect.value : 'Normal';
const spotlight = renderMode === 'Spotlight'; // Legacy compatibility
const discoveryCheckbox = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_discovery`) || sectionItem.querySelector(`.${prefix}_section_discovery[data-section-index="${sectionIndex}"]`);
const discoveryEnabled = discoveryCheckbox?.checked === true;
const searchTerm = sectionItem.querySelector(`#${prefix}_section_${sanitizedIndex}_searchTerm`)?.value?.trim() || '';
let includeItemTypes = [];
const includeItemTypesContainer = sectionItem.querySelector(`.includeItemTypes-badge-container[data-section-index="${sectionIndex}"]`);
if (includeItemTypesContainer) {
const badges = includeItemTypesContainer.querySelectorAll('.tag-badge');
includeItemTypes = Array.from(badges).map(badge => badge.getAttribute('data-value')).filter(Boolean);
}
if (includeItemTypes.length === 0 && type !== 'Parent') {
includeItemTypes = ['Movie'];
}
const additionalQueryOptions = [];
const additionalOptionsContainer = sectionItem.querySelector(`.${prefix}_section_additionalOptions_list_container[data-section-index="${sectionIndex}"]`);
if (additionalOptionsContainer) {
const optionRows = additionalOptionsContainer.querySelectorAll('.additional-options-list .additional-option-row');
optionRows.forEach(row => {
const input = row.querySelector('.additional-option-value');
if (input) {
const key = input.getAttribute('data-key');
const type = input.getAttribute('data-type');
let value;
if (type === 'boolean') {
value = input.checked;
} else {
value = input.value;
}
if (key) {
additionalQueryOptions.push({ key, value });
}
}
});
}
return {
id: sectionIdAttr,
enabled: enabled,
name: name,
type: type,
source: source,
itemLimit: itemLimit,
sortOrder: sortOrder,
sortOrderDirection: sortOrderDirection,
cardFormat: cardFormat,
order: order,
renderMode: renderMode,
spotlight: spotlight,
discoveryEnabled: discoveryEnabled,
searchTerm: searchTerm,
includeItemTypes: includeItemTypes,
additionalQueryOptions: additionalQueryOptions
};
}
async function fetchItemsForSectionPreview(sectionConfig) {
const apiClient = window.ApiClient;
if (!apiClient) {
throw new Error('ApiClient is not available');
}
const userId = apiClient.getCurrentUserId();
const type = sectionConfig.type || 'Genre';
const searchTerm = sectionConfig.searchTerm?.trim() || '';
const includeItemTypes = Array.isArray(sectionConfig.includeItemTypes) && sectionConfig.includeItemTypes.length > 0
? sectionConfig.includeItemTypes.join(',')
: (sectionConfig.includeItemTypes || 'Movie');
const sortOrder = sectionConfig.sortOrder || 'Random';
const sortOrderDirection = sectionConfig.sortOrderDirection || 'Ascending';
const itemLimit = parseInt(sectionConfig.itemLimit, 10) || 16;
const sources = (sectionConfig.source || '').split(',').map(s => s.trim()).filter(Boolean);
const fields = 'PrimaryImageAspectRatio,DateCreated,Overview,ProductionYear,ImageTags,BackdropImageTags,ParentBackdropImageTags,ParentThumbImageTag,SeriesPrimaryImageTag';
// Parse additional query options
const additionalOptions = {};
if (Array.isArray(sectionConfig.additionalQueryOptions)) {
sectionConfig.additionalQueryOptions.forEach(option => {
if (option && option.key) {
let value = option.value;
if (value === 'true' || value === true) value = true;
else if (value === 'false' || value === false) value = false;
additionalOptions[option.key] = value;
}
});
}
const dedupeAndLimit = (items = []) => {
let working = Array.isArray(items) ? [...items] : [];
if (sortOrder === 'Random') {
working.sort(() => Math.random() - 0.5);
} else if (window.cardBuilder?.sortItems) {
working = window.cardBuilder.sortItems(working, sortOrder, sortOrderDirection);
}
const seen = new Set();
const limited = [];
for (const item of working) {
if (!item || !item.Id || seen.has(item.Id)) continue;
seen.add(item.Id);
limited.push(item);
if (limited.length >= itemLimit) break;
}
return limited;
};
try {
const queryBase = {
IncludeItemTypes: includeItemTypes,
Recursive: true,
Fields: fields,
SortBy: sortOrder,
SortOrder: sortOrderDirection,
...additionalOptions
};
if (type === 'Genre' || type === 'Tag') {
if (sources.length === 0) {
return [];
}
const paramName = type === 'Genre' ? 'Genres' : 'Tags';
// Genres use pipe delimiter usually, tags comma?
// homeScreen.js uses pipe for Genres, comma for Tags.
// apiClient handles array/string? usually string.
const delimiter = type === 'Genre' ? '|' : ',';
const valueParam = sources.join(delimiter);
const params = {
...queryBase,
[paramName]: valueParam,
Limit: itemLimit
};
if (searchTerm) params.SearchTerm = searchTerm;
const response = await apiClient.getItems(userId, params);
return dedupeAndLimit(response.Items || []);
}
if (type === 'Collection' || type === 'Playlist' || type === 'Parent') {
const existingIds = new Set();
let collected = [];
const runQuery = async (params) => {
const response = await apiClient.getItems(userId, params);
if (response?.Items?.length) {
response.Items.forEach(item => {
if (item && item.Id && !existingIds.has(item.Id)) {
existingIds.add(item.Id);
collected.push(item);
}
});
}
};
if (type === 'Parent' && sources.length === 0) {
const params = {
...queryBase,
Limit: itemLimit * 2
};
if (searchTerm) params.SearchTerm = searchTerm;
await runQuery(params);
} else {
const multiplier = Math.max(1, sources.length);
for (const parentId of sources) {
const params = {
...queryBase,
ParentId: parentId,
Limit: itemLimit * multiplier
};
if (searchTerm) params.SearchTerm = searchTerm;
await runQuery(params);
}
}
return dedupeAndLimit(collected);
}
return [];
} catch (error) {
console.warn('[KefinTweaks Configuration] Preview fetch failed:', error);
throw error;
}
}
// Handle save configuration
async function handleSaveConfig(modalInstance, overrideOptionalIncludes = null) {
// Verify admin status before saving
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
alert('You must be an administrator to save configuration.');
return;
}
console.log('[KefinTweaks Configuration] Saving configuration...');
// Collect all form values
// Preserve existing kefinTweaksRoot from current config (no longer editable in UI)
const currentConfig = window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {};
const config = currentConfig;
config.enabled = modalInstance.dialogContent.querySelector('#kefinTweaksEnabled')?.checked !== false;
// Remove scriptRoot if it exists (legacy field, no longer used)
if (config.scriptRoot) {
delete config.scriptRoot;
}
// Collect script toggles
document.querySelectorAll('[data-script-key]').forEach(checkbox => {
const key = checkbox.getAttribute('data-script-key');
config.scripts[key] = checkbox.checked;
});
console.log('[KefinTweaks Configuration] Configuration collected:', config);
// Save to JavaScript Injector plugin
try {
await KefinTweaksUtils.saveConfigToJavaScriptInjector(config);
// Update global config objects so dropdown changes reflect saved values
window.KefinTweaksConfig = config;
window.KefinTweaksCurrentConfig = config;
currentLoadedConfig = config;
// Show success toast
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('KefinTweaks saved!');
}
} catch (error) {
console.error('[KefinTweaks Configuration] Error saving configuration:', error);
// Show error toast
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast(`Error saving configuration: ${error.message}. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.`, '5');
} else {
// Fallback to alert if toaster is not available
alert(`Error saving configuration: ${error.message}\n\nPlease ensure the JavaScript Injector plugin is installed and you have administrator permissions.`);
}
}
}
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 KefinTweaksUtils.saveConfigToJavaScriptInjector(defaultConfig);
if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) {
window.KefinTweaksToaster.toast('Configuration reset to defaults.');
} else {
alert('Configuration reset to defaults.');
}
if (modalInstance && typeof modalInstance.close === 'function') {
modalInstance.close();
}
openConfigurationModal();
} 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;
}
}
function getKefinTweaksRoot() {
const raw = window.KefinTweaksConfig?.kefinTweaksRoot || '';
return raw.endsWith('/') ? raw : `${raw}/`;
}
function getKefinTweaksLogoCssValue() {
const root = getKefinTweaksRoot();
if (!root) return 'none';
return `url('${root}logo.png')`;
}
function ensureKefinTweaksLogoVar() {
document.documentElement.style.setProperty(
'--kefinTweaksLogo',
getKefinTweaksLogoCssValue()
);
}
function closeAppUserMenu() {
const menu = document.getElementById('app-user-menu');
if (!menu) return;
const backdrop = menu.querySelector('.MuiBackdrop-root, .MuiModal-backdrop');
if (backdrop) {
backdrop.click();
return;
}
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true
}));
}
/**
* Create KefinTweaks menu item for Jellyfin v12 #app-user-menu
* @returns {HTMLAnchorElement}
*/
function createAppUserMenuConfigButton() {
ensureKefinTweaksLogoVar();
const link = document.createElement('a');
link.className = 'MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters MuiMenuItem-root MuiMenuItem-gutters css-mbeig7';
link.setAttribute('tabindex', '-1');
link.setAttribute('role', 'menuitem');
link.href = '#';
link.setAttribute('data-kefintweaks-user-menu-config-button', 'true');
const icon = document.createElement('div');
icon.className = 'MuiListItemIcon-root';
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
iconSpan.setAttribute('aria-hidden', 'true');
icon.appendChild(iconSpan);
const text = document.createElement('div');
text.className = 'MuiListItemText-root';
const span = document.createElement('span');
span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary';
span.textContent = 'KefinTweaks';
text.appendChild(span);
const ripple = document.createElement('span');
ripple.className = 'MuiTouchRipple-root';
link.appendChild(icon);
link.appendChild(text);
link.appendChild(ripple);
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
closeAppUserMenu();
openConfigurationModal();
});
return link;
}
/**
* Insert KefinTweaks into #app-user-menu before the first HR after Dashboard.
* @returns {boolean} true if button is present after this call
*/
function addConfigButtonToAppUserMenu() {
if (document.querySelector('[data-kefintweaks-user-menu-config-button]')) {
return true;
}
const appUserMenu = document.getElementById('app-user-menu');
if (!appUserMenu) {
return false;
}
const menuList = appUserMenu.querySelector('ul[role="menu"]');
if (!menuList) {
return false;
}
const dashboardLink = menuList.querySelector('a[href="#/dashboard"]')
|| menuList.querySelector('a[href*="#/dashboard"]');
if (!dashboardLink) {
console.log('[KefinTweaks Configuration] #app-user-menu Dashboard link not found');
return false;
}
let divider = dashboardLink.nextElementSibling;
while (divider && !(divider.matches('hr') || divider.classList.contains('MuiDivider-root'))) {
divider = divider.nextElementSibling;
}
if (!divider) {
console.log('[KefinTweaks Configuration] #app-user-menu divider after Dashboard not found');
return false;
}
const button = createAppUserMenuConfigButton();
divider.parentNode.insertBefore(button, divider);
console.log('[KefinTweaks Configuration] Configuration button added to #app-user-menu');
return true;
}
let appUserMenuConfigObserver = null;
/**
* Permanent observer: keep re-injecting if #app-user-menu remounts and our button is lost.
*/
function setupAppUserMenuConfigButtonObserver() {
addConfigButtonToAppUserMenu();
if (appUserMenuConfigObserver) {
return;
}
if (typeof MutationObserver === 'undefined' || !document.body) {
console.log('[KefinTweaks Configuration] Cannot observe #app-user-menu (MutationObserver/body unavailable)');
return;
}
appUserMenuConfigObserver = new MutationObserver(() => {
addConfigButtonToAppUserMenu();
});
appUserMenuConfigObserver.observe(document.body, {
childList: true,
subtree: true
});
}
// Create configuration button for Administration section
function createConfigButton() {
ensureKefinTweaksLogoVar();
const button = document.createElement('div');
button.style.display = 'block';
button.style.padding = '0';
button.style.margin = '0';
button.className = 'listItem-border emby-button kefin-config-button';
button.setAttribute('data-kefintweaks-config-button', 'true');
button.onclick = (e) => {
e.preventDefault();
openConfigurationModal();
};
button.innerHTML = `
KefinTweaks
`;
return button;
}
// Create dashboard side menu button
function createDashboardConfigButton() {
ensureKefinTweaksLogoVar();
// Check if button already exists
if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) {
return null;
}
// Get an existing native button
const nativeButton = document.querySelector('.MuiButtonBase-root.MuiListItemButton-root.MuiListItemButton-gutters.MuiListItemButton-root.MuiListItemButton-gutters');
const div = document.createElement('div');
div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters css-yknuxp';
div.setAttribute('tabindex', '0');
div.setAttribute('data-kefintweaks-dashboard-config-button', 'true');
div.onclick = () => {
openConfigurationModal();
};
// Create icon with Material icon
const icon = document.createElement('div');
icon.className = 'MuiListItemIcon-root css-37qkju css-5pks8q';
const iconSpan = document.createElement('span');
iconSpan.className = 'material-icons MuiSvgIcon-root MuiSvgIcon-fontSizeMedium';
iconSpan.style.marginRight = '12px';
iconSpan.setAttribute('aria-hidden', 'true');
icon.appendChild(iconSpan);
// Create text
const text = document.createElement('div');
text.className = 'MuiListItemText-root css-f696uz css-t3p1pa';
const span = document.createElement('span');
span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz css-pl8nxc';
span.textContent = 'KefinTweaks';
text.appendChild(span);
// Create ripple effect span
const ripple = document.createElement('span');
ripple.className = 'MuiTouchRipple-root css-w0pj6f css-4mb1j7';
div.appendChild(icon);
div.appendChild(text);
div.appendChild(ripple);
return div;
}
// Add configuration button to dashboard side menu
function addConfigButtonToDashboard() {
// Check if button already exists
if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) {
return;
}
// Find the plugins section
const pluginsSection = document.querySelector('ul[aria-labelledby="plugins-subheader"]');
if (!pluginsSection) {
console.log('[KefinTweaks Configuration] Plugins section not found, retrying...');
//setTimeout(addConfigButtonToDashboard, 500);
return;
}
// Create the button
const button = createDashboardConfigButton();
if (!button) {
return;
}
// Add button to the plugins section
pluginsSection.appendChild(button);
console.log('[KefinTweaks Configuration] Configuration button added to dashboard side menu');
}
// Add configuration button to Administration section
function addConfigButtonToAdminSection() {
return;
// Check if button already exists (check for admin section button specifically)
if (document.querySelector('.adminSection [data-kefintweaks-config-button]')) {
return;
}
// Find the Administration section
const adminSection = document.querySelector('.adminSection.verticalSection');
if (!adminSection) {
console.log('[KefinTweaks Configuration] Administration section not found, retrying...');
//setTimeout(addConfigButtonToAdminSection, 500);
return;
}
// Create and add the button
const button = createConfigButton();
adminSection.appendChild(button);
console.log('[KefinTweaks Configuration] Configuration button added to Administration section');
}
// Register onViewPage handler for mypreferencesmenu page and dashboard
function registerViewPageHandler() {
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) {
console.log('[KefinTweaks Configuration] KefinTweaksUtils.onViewPage not available, retrying...');
setTimeout(registerViewPageHandler, 1000);
return;
}
// Handler for dashboard and preferences pages
window.KefinTweaksUtils.onViewPage(async (view, element, hash) => {
// Check if user is admin before adding buttons
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
console.log('[KefinTweaks Configuration] User is not admin, not adding buttons');
return;
}
// Check if we're on the mypreferencesmenu page
if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) {
console.log('[KefinTweaks Configuration] Preferences page detected');
// Wait a bit for the page to fully load
setTimeout(() => {
addConfigButtonToAdminSection();
}, 500);
}
// Check if we're on the dashboard page
if (view === 'dashboard' || view === 'configurationpage' || hash.includes('dashboard') || hash.includes('configurationpage')) {
console.log('[KefinTweaks Configuration] Dashboard page detected');
// Wait a bit for the page to fully load
setTimeout(() => {
addConfigButtonToDashboard();
}, 500);
}
});
console.log('[KefinTweaks Configuration] Registered onViewPage handler');
}
// Add configuration link to custom menu
async function addConfigLinkToCustomMenu() {
// Check if user is admin
const userIsAdmin = await isAdmin();
if (!userIsAdmin) {
console.log('[KefinTweaks Configuration] User is not admin, not adding menu link');
return;
}
// Check if utils is available
if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) {
console.log('[KefinTweaks Configuration] KefinTweaksUtils.addCustomMenuLink not available, retrying...');
setTimeout(addConfigLinkToCustomMenu, 1000);
return;
}
// Admin-only: user menu admin section + default side menu; open via JS action
const success = await window.KefinTweaksUtils.addCustomMenuLink(
'KefinTweaks',
'build',
'',
false,
{
userMenu: true,
isAdminLink: true,
action: 'KefinTweaksConfiguration.openConfigurationModal'
}
);
if (success) {
console.log('[KefinTweaks Configuration] Configuration link added to custom menu');
}
}
// Handle direct navigation to config URL
function handleDirectConfigNavigation() {
if (window.location.hash === '#kefintweaks-config') {
console.log('[KefinTweaks Configuration] Direct navigation to config URL detected');
// Remove the hash to prevent navigation
window.history.replaceState(null, '', window.location.pathname + window.location.search);
// Open modal
openConfigurationModal();
}
}
// Set up hashchange listener for navigation after page load
function setupHashChangeListener() {
window.addEventListener('hashchange', () => {
if (window.location.hash === '#kefintweaks-config') {
console.log('[KefinTweaks Configuration] Hash change to config URL detected');
// Remove the hash to prevent navigation
window.history.replaceState(null, '', window.location.pathname + window.location.search);
// Open modal
openConfigurationModal();
}
});
}
// Initialize when DOM is ready
async function initialize() {
if (!window.ApiClient || !window.ApiClient._loggedIn) {
setTimeout(initialize, 1000);
return;
}
// Run migration check during initialization
try {
const config = await getKefinTweaksConfig();
// Migration is handled inside getKefinTweaksConfig
} catch (error) {
console.warn('[KefinTweaks Configuration] Error during initialization migration check:', error);
}
// Register view page handler
registerViewPageHandler();
// Add configuration link to custom menu
addConfigLinkToCustomMenu();
// Set up hashchange listener
setupHashChangeListener();
// Handle direct navigation to config URL
handleDirectConfigNavigation();
// Also check current page on initial load
const hash = window.location.hash;
const userIsAdmin = await isAdmin();
if (userIsAdmin) {
// User-menu KefinTweaks entry is owned by addCustomMenuLink(userMenu + isAdminLink + action)
if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) {
setTimeout(() => {
addConfigButtonToAdminSection();
}, 1000);
}
if (hash && hash.includes('dashboard')) {
setTimeout(() => {
addConfigButtonToDashboard();
}, 1000);
}
}
}
// Start initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
console.log('[KefinTweaks Configuration] Script loaded');
})();