// KefinTweaks Skin Manager // Manages skin selection and CSS loading for the display settings page // Adds a skin dropdown to mypreferencesdisplay.html and handles skin switching // Requires: utils.js, userConfig.js modules to be loaded before this script (function() { 'use strict'; // Common logging function const LOG = (...args) => console.log('[KefinTweaks SkinManager]', ...args); const WARN = (...args) => console.warn('[KefinTweaks SkinManager]', ...args); const ERR = (...args) => console.error('[KefinTweaks SkinManager]', ...args); LOG('Initializing...'); // Configuration from user config let SKINS_CONFIG = []; let THEMES_CONFIG = []; const STORAGE_KEY = 'kefinTweaks_selectedSkin'; const THEME_STORAGE_KEY = 'kefinTweaks_selectedTheme'; const COLOR_SCHEMES_STORAGE_KEY = 'kefinTweaks_selectedColorScheme'; // Cached server version for synchronous access let cachedServerVersion = null; let versionPollingStarted = false; /** * Get the current major server version from ApiClient * Polls every 500ms for up to 5 seconds in the background if not immediately available * @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable */ function getCurrentMajorServerVersion() { try { // Check if ApiClient and serverVersion are available immediately if (typeof window.ApiClient !== 'undefined' && window.ApiClient._appVersion) { const versionParts = window.ApiClient._appVersion.split('.'); if (versionParts.length >= 2) { const majorVersion = parseInt(versionParts[1], 10); if (!isNaN(majorVersion)) { cachedServerVersion = majorVersion; // Cache it return majorVersion; } } } // If we have a cached value, return it if (cachedServerVersion !== null) { return cachedServerVersion; } // Start background polling if not already started if (!versionPollingStarted) { versionPollingStarted = true; const pollInterval = 500; // 500ms const maxDuration = 5000; // 5 seconds const maxAttempts = maxDuration / pollInterval; // 10 attempts let attempts = 0; const poll = setInterval(() => { attempts++; try { if (typeof window.ApiClient !== 'undefined' && window.ApiClient._appVersion) { const versionParts = window.ApiClient._appVersion.split('.'); if (versionParts.length >= 2) { const majorVersion = parseInt(versionParts[1], 10); if (!isNaN(majorVersion)) { clearInterval(poll); cachedServerVersion = majorVersion; return; } } } } catch (error) { WARN('Error getting server version during poll:', error); } // Timeout after max attempts if (attempts >= maxAttempts) { clearInterval(poll); cachedServerVersion = null; // Cache null to indicate we tried } }, pollInterval); } // Return null immediately (callers can check again later or use cached value) return null; } catch (error) { WARN('Error getting server version:', error); return null; } } /** * Extract URLs from skin configuration based on current server version * Supports both new structure (array of objects) and old structure (string/array of strings) * @param {Object} skin - The skin configuration object * @returns {Array|null} Array of URLs to load, or null if no URLs */ function getSkinUrlsForCurrentVersion(skin) { if (!skin.url) { return null; } const currentMajorVersion = getCurrentMajorServerVersion(); // New structure: array of objects with majorServerVersions and urls if (Array.isArray(skin.url) && skin.url.length > 0 && typeof skin.url[0] === 'object' && skin.url[0].majorServerVersions) { // Find all URL objects that match the current server version const matchingUrlObjects = skin.url.filter(urlObj => { if (!urlObj.majorServerVersions || !Array.isArray(urlObj.majorServerVersions)) { return false; } return currentMajorVersion !== null && urlObj.majorServerVersions.includes(currentMajorVersion); }); if (matchingUrlObjects.length === 0) { if (currentMajorVersion !== null) { LOG(`No URL configuration found for skin '${skin.name}' on server version ${currentMajorVersion}`); } return null; } // Merge all URLs from matching objects const allUrls = []; matchingUrlObjects.forEach(urlObj => { if (Array.isArray(urlObj.urls)) { allUrls.push(...urlObj.urls); } else if (urlObj.urls) { allUrls.push(urlObj.urls); } }); return allUrls.length > 0 ? allUrls : null; } // Old structure: string or array of strings (backward compatibility) if (typeof skin.url === 'string') { return [skin.url]; } if (Array.isArray(skin.url)) { return skin.url; } return null; } /** * Filter skins based on the current server version * Checks majorServerVersions in the URL structure * @param {Array} skins - Array of skin configuration objects * @returns {Array} Filtered array of skins compatible with current server version */ function filterSkinsByServerVersion(skins) { const currentMajorVersion = getCurrentMajorServerVersion(); // If we can't determine the server version, include all skins (backward compatibility) if (currentMajorVersion === null) { WARN('Could not determine server version, showing all skins'); return skins; } return skins.filter(skin => { // Check if skin has URL configuration if (!skin.url) { // Skin with no URL (like Default) - check old majorServerVersions field for backward compatibility if (skin.majorServerVersions && Array.isArray(skin.majorServerVersions)) { const isSupported = skin.majorServerVersions.includes(currentMajorVersion); if (!isSupported) { LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion} (supports: ${skin.majorServerVersions.join(', ')})`); } return isSupported; } // No version info, include for backward compatibility return true; } // New structure: check majorServerVersions in URL objects if (Array.isArray(skin.url) && skin.url.length > 0 && typeof skin.url[0] === 'object' && skin.url[0].majorServerVersions) { // Check if any URL object supports the current version const hasMatchingVersion = skin.url.some(urlObj => { if (!urlObj.majorServerVersions || !Array.isArray(urlObj.majorServerVersions)) { return false; } return urlObj.majorServerVersions.includes(currentMajorVersion); }); if (!hasMatchingVersion) { LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion}`); } return hasMatchingVersion; } // Old structure: check top-level majorServerVersions field (backward compatibility) if (skin.majorServerVersions && Array.isArray(skin.majorServerVersions)) { const isSupported = skin.majorServerVersions.includes(currentMajorVersion); if (!isSupported) { LOG(`Skin '${skin.name}' is not supported for server version ${currentMajorVersion} (supports: ${skin.majorServerVersions.join(', ')})`); } return isSupported; } // No version info specified, include for backward compatibility LOG(`Skin '${skin.name}' has no version information, including it for backward compatibility`); return true; }); } // Load and merge skin configurations function loadSkinConfig() { // Only load from window.KefinTweaksConfig (from JS Injector) const configSkins = window.KefinTweaksConfig?.skins || []; // Filter to only include enabled skins (enabled !== false) // Default to enabled if the property is not set SKINS_CONFIG = configSkins.filter(skin => { // Include skin if enabled is not explicitly false // If enabled is undefined/null, treat as enabled (true) return skin.enabled !== false; }); // Ensure we always have at least a default skin if (SKINS_CONFIG.length === 0) { SKINS_CONFIG = [ { name: 'Default', url: null } ]; } // Filter skins based on current server version const skinsBeforeFilter = SKINS_CONFIG.length; SKINS_CONFIG = filterSkinsByServerVersion(SKINS_CONFIG); const skinsAfterFilter = SKINS_CONFIG.length; if (skinsBeforeFilter !== skinsAfterFilter) { LOG(`Filtered skins by server version: ${skinsBeforeFilter} -> ${skinsAfterFilter} (removed ${skinsBeforeFilter - skinsAfterFilter} incompatible skins)`); } const enabledCount = configSkins.filter(s => s.enabled !== false).length; const disabledCount = configSkins.length - enabledCount; LOG(`Loaded ${SKINS_CONFIG.length} enabled skins from KefinTweaksConfig (${enabledCount} enabled, ${disabledCount} disabled)`); } // Load and merge theme configurations function loadThemeConfig() { const userThemes = window.KefinTweaksUserConfig?.themes || []; const additionalThemes = window.KefinTweaksConfig?.themes || []; // Start with user themes (these take priority) THEMES_CONFIG = [...userThemes]; // Add additional themes from main config, avoiding duplicates by name additionalThemes.forEach(theme => { const exists = THEMES_CONFIG.some(existingTheme => existingTheme.name === theme.name); if (!exists) { THEMES_CONFIG.push(theme); } }); LOG(`Loaded ${THEMES_CONFIG.length} themes (${userThemes.length} user + ${additionalThemes.length} additional)`); } // Track loaded skin CSS URLs let loadedSkinUrls = new Set(); // Tooltip management let currentTooltip = null; // Store unregister functions for handlers let unregisterDisplayPreferencesHandler = null; let unregisterAnyPageHandler = null; /** * Create and show a tooltip * @param {string} text - The tooltip text * @param {Element} target - The target element to position the tooltip near */ function showTooltip(text, target) { // Remove existing tooltip hideTooltip(); // Create tooltip element currentTooltip = document.createElement('div'); currentTooltip.className = 'kefin-tooltip'; currentTooltip.textContent = text; // Add tooltip styles currentTooltip.style.cssText = ` position: absolute; background: rgba(0, 0, 0, 0.9); color: white; padding: 8px 12px; border-radius: 6px; font-size: 12px; font-family: inherit; z-index: 10000; pointer-events: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); max-width: 200px; word-wrap: break-word; opacity: 0; transition: opacity 0.2s ease; `; // Add to document document.body.appendChild(currentTooltip); // Position tooltip positionTooltip(target); // Fade in setTimeout(() => { if (currentTooltip) { currentTooltip.style.opacity = '1'; } }, 10); } /** * Position the tooltip relative to the target element * @param {Element} target - The target element */ function positionTooltip(target) { if (!currentTooltip || !target) return; const rect = target.getBoundingClientRect(); const tooltipRect = currentTooltip.getBoundingClientRect(); // Position above the target element let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); let top = rect.top - tooltipRect.height - 8; // Adjust if tooltip would go off screen if (left < 8) { left = 8; } else if (left + tooltipRect.width > window.innerWidth - 8) { left = window.innerWidth - tooltipRect.width - 8; } if (top < 8) { // Position below if no room above top = rect.bottom + 8; } currentTooltip.style.left = `${left}px`; currentTooltip.style.top = `${top}px`; } /** * Hide and remove the current tooltip */ function hideTooltip() { if (currentTooltip) { currentTooltip.style.opacity = '0'; setTimeout(() => { if (currentTooltip && currentTooltip.parentNode) { currentTooltip.parentNode.removeChild(currentTooltip); } currentTooltip = null; }, 200); } } /** * Verify that the current skin/scheme/theme is properly applied * @returns {boolean} True if verification passes, false otherwise */ function verifySkinApplication() { const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (!selectedSkin) { ERR('Selected skin not found, reverting to default skin'); const defaultSkinName = getDefaultSkinName(); const defaultSkin = SKINS_CONFIG.find(skin => skin.name === defaultSkinName); if (defaultSkin) { loadSkin(defaultSkin); } return true; } // Check if skin CSS is properly loaded if (selectedSkin.url) { const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); const hasCorrectSkin = Array.from(skinLinks).some(link => link.getAttribute('data-skin') === selectedSkinName ); if (!hasCorrectSkin) { WARN(`Skin verification failed: Expected skin '${selectedSkinName}' not found in DOM`); return false; } } // Check if theme is properly applied const selectedThemeValue = localStorage.getItem(THEME_STORAGE_KEY); if (selectedThemeValue) { const selectedTheme = THEMES_CONFIG.find(theme => theme.name === selectedThemeValue); if (selectedTheme) { const cssThemeLink = document.getElementById('cssTheme'); if (!cssThemeLink || !cssThemeLink.hasAttribute('data-kefin-custom-theme')) { WARN(`Theme verification failed: Custom theme '${selectedThemeValue}' not properly applied`); return false; } } } // Check if color scheme is properly applied const selectedColorSchemeName = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); if (selectedColorSchemeName && selectedSkin.colorSchemes) { const selectedColorScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === selectedColorSchemeName); if (selectedColorScheme && selectedColorScheme.url) { const cssColorSchemesLink = document.getElementById('cssColorSchemes'); if (!cssColorSchemesLink || !cssColorSchemesLink.hasAttribute('data-kefin-color-schemes')) { WARN(`Color scheme verification failed: Color scheme '${selectedColorSchemeName}' not properly applied`); return false; } } } LOG('Skin application verification passed'); return true; } /** * Initialize the skin manager with verification and retry logic */ function initialize(retryCount = 0) { const MAX_RETRIES = 10; if (!window.KefinTweaksUtils) { ERR('KefinTweaksUtils not available - skin manager cannot initialize'); return; } // Load and merge skin configurations loadSkinConfig(); // Load and merge theme configurations loadThemeConfig(); loadSelectedSkin(); loadSelectedTheme(); loadSelectedColorScheme(); LOG('SkinManager initializing...'); // Verify skin application after a short delay to allow CSS to load setTimeout(() => { if (!verifySkinApplication()) { if (retryCount < MAX_RETRIES) { WARN(`Skin application verification failed, retrying... (attempt ${retryCount + 1}/${MAX_RETRIES})`); setTimeout(() => { initialize(retryCount + 1); }, 500); // Wait 500ms before retry } else { ERR(`Skin application verification failed after ${MAX_RETRIES} attempts - giving up`); // Register handlers even if verification failed (give up case) registerHandlers(); } } else { LOG('Skin application verified successfully'); // Register handlers after successful verification registerHandlers(); } }, 200); // Wait 200ms for CSS to load before verification } /** * Register the onViewPage handlers */ function registerHandlers() { // Only register if not already registered if (unregisterDisplayPreferencesHandler) { LOG('Handlers already registered, skipping'); return; } // Register for the display preferences page unregisterDisplayPreferencesHandler = window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, { pages: ['mypreferencesdisplay'] }); // Register for all pages to add the appearance button unregisterAnyPageHandler = window.KefinTweaksUtils.onViewPage(handleAnyPage, { pages: [] }); LOG('SkinManager handlers registered'); } /** * Handle the display preferences page * @param {string} view - The view name * @param {Element} element - The view element */ function handleDisplayPreferencesPage(view, element) { LOG('Display preferences page detected'); // Wait for the page to be fully loaded setTimeout(() => { addSkinDropdown(); loadSelectedSkin(); addColorSchemesDropdown(); loadSelectedColorScheme(); addThemeOptions(); loadSelectedTheme(); }, 100); } /** * Handle any page to add the appearance button * @param {string} view - The view name * @param {Element} element - The view element */ function handleAnyPage(view, element) { // Skip the display preferences page as it's handled separately if (view === 'mypreferencesdisplay') { return; } // Wait for the page to be fully loaded setTimeout(() => { addAppearanceButton(); }, 100); } /** * Add the appearance button to the header */ function addAppearanceButton() { const headerRight = document.querySelector('.headerRight'); if (!headerRight) { WARN('Header right section not found'); return; } // Check if appearance button already exists if (headerRight.querySelector('.headerAppearanceButton')) { LOG('Appearance button already exists'); return; } // Create the appearance button const appearanceButton = document.createElement('button'); appearanceButton.type = 'button'; appearanceButton.setAttribute('is', 'paper-icon-button-light'); appearanceButton.className = 'headerButton headerButtonRight headerAppearanceButton paper-icon-button-light'; appearanceButton.title = 'Appearance'; // Create the icon span const iconSpan = document.createElement('span'); iconSpan.className = 'material-icons palette'; iconSpan.setAttribute('aria-hidden', 'true'); appearanceButton.appendChild(iconSpan); // Add click event listener appearanceButton.addEventListener('click', toggleAppearancePopover); // Insert before the search button (or at the end if no search button) const searchButton = headerRight.querySelector('.headerSearchButton'); if (searchButton) { headerRight.insertBefore(appearanceButton, searchButton); } else { headerRight.appendChild(appearanceButton); } LOG('Appearance button added successfully'); // Unregister the handleAnyPage handler since button is now added and persistent if (unregisterAnyPageHandler) { unregisterAnyPageHandler(); unregisterAnyPageHandler = null; LOG('Unregistered handleAnyPage handler - button is now persistent'); } } /** * Toggle the appearance popover */ function toggleAppearancePopover(event) { event.preventDefault(); event.stopPropagation(); // Remove existing popover if it exists const existingPopover = document.querySelector('.kefin-appearance-popover'); if (existingPopover) { existingPopover.remove(); return; } // Create the popover createAppearancePopover(event.target); } /** * Create the appearance popover with skin and color scheme dropdowns * @param {Element} button - The button that triggered the popover */ function createAppearancePopover(button) { // Create backdrop const backdrop = document.createElement('div'); backdrop.className = 'dialogBackdrop dialogBackdropOpened'; backdrop.setAttribute('data-modal-id', 'appearance-popover'); backdrop.style.position = 'fixed'; backdrop.style.top = '0'; backdrop.style.left = '0'; backdrop.style.width = '100%'; backdrop.style.height = '100%'; backdrop.style.zIndex = '9999'; backdrop.style.backgroundColor = 'transparent'; // Create dialog container const dialogContainer = document.createElement('div'); dialogContainer.className = 'dialogContainer'; dialogContainer.setAttribute('data-modal-id', 'appearance-popover'); dialogContainer.style.position = 'fixed'; dialogContainer.style.top = '0'; dialogContainer.style.left = '0'; dialogContainer.style.width = '100%'; dialogContainer.style.height = '100%'; dialogContainer.style.zIndex = '10000'; dialogContainer.style.pointerEvents = 'none'; // Create dialog (popover) const dialog = document.createElement('div'); dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog'; dialog.style.position = 'absolute'; dialog.style.minWidth = '280px'; dialog.style.pointerEvents = 'auto'; dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; // Create dialog content const dialogContent = document.createElement('div'); dialogContent.style.margin = '0'; dialogContent.style.padding = '1.25em 1.5em 1.5em'; dialogContent.style.display = 'flex'; dialogContent.style.flexDirection = 'column'; dialogContent.style.gap = '0.8em'; // Create skin dropdown const skinContainer = createDropdown({ label: 'Skin', id: 'selectSkinPopover', options: SKINS_CONFIG.map(skin => ({ value: skin.name, text: skin.name, author: skin.author })), changeHandler: handleSkinChange, isPopover: true }); dialogContent.appendChild(skinContainer); // Create color schemes dropdown const colorSchemesContainer = createDropdown({ label: 'Color Schemes', id: 'selectColorSchemesPopover', options: [], // Will be populated by updatePopoverColorSchemes changeHandler: handleColorSchemeChange, isPopover: true }); dialogContent.appendChild(colorSchemesContainer); // Assemble dialog dialog.appendChild(dialogContent); dialogContainer.appendChild(dialog); // Position the popover positionPopover(dialog, button); // Add to document document.body.appendChild(backdrop); document.body.appendChild(dialogContainer); // Set current values from localStorage const skinSelect = document.getElementById('selectSkinPopover'); const colorSchemesSelect = document.getElementById('selectColorSchemesPopover'); if (skinSelect) { const currentSkin = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); skinSelect.value = currentSkin; } // Update color schemes dropdown based on current skin updatePopoverColorSchemes(); // Add click outside to close setTimeout(() => { document.addEventListener('click', handlePopoverClickOutside); }, 10); LOG('Appearance popover created with Jellyfin styling'); } /** * Create a generic dropdown with Jellyfin styling * @param {Object} config - Configuration object * @param {string} config.label - The dropdown label * @param {string} config.id - The dropdown ID * @param {Array} config.options - The dropdown options * @param {Function} config.changeHandler - The change event handler * @param {boolean} config.isPopover - Whether this is for a popover (affects styling) * @param {Function} config.hoverHandler - Optional hover handler for tooltips * @param {Function} config.focusHandler - Optional focus handler for tooltips * @param {Function} config.blurHandler - Optional blur handler for tooltips * @returns {Element} The dropdown container */ function createDropdown(config) { const { label, id, options = [], changeHandler, isPopover = false, hoverHandler, focusHandler, blurHandler } = config; const container = document.createElement('div'); // Create label const labelElement = document.createElement('label'); if (isPopover) { labelElement.style.cssText = ` display: block; margin-bottom: 8px; font-size: 14px; color: var(--main-color, var(--accent, #fff)); `; } else { labelElement.className = 'selectLabel'; } labelElement.textContent = label; labelElement.setAttribute('for', id); container.appendChild(labelElement); // Create select container with Jellyfin styling const selectContainer = document.createElement('div'); selectContainer.className = 'selectContainer'; if (isPopover) { selectContainer.style.marginBottom = '0px'; } // Create select const select = document.createElement('select'); select.id = id; select.setAttribute('is', 'emby-select'); select.setAttribute('label', label); select.className = 'emby-select-withcolor emby-select'; // Add options options.forEach(option => { const optionElement = document.createElement('option'); optionElement.value = option.value; optionElement.textContent = option.text; if (option.author) { optionElement.setAttribute('data-author', option.author); } if (option.url) { optionElement.setAttribute('data-scheme-url', option.url); } select.appendChild(optionElement); }); // Create the arrow container const arrowContainer = document.createElement('div'); arrowContainer.className = 'selectArrowContainer'; const hiddenDiv = document.createElement('div'); hiddenDiv.style.visibility = 'hidden'; hiddenDiv.style.display = 'none'; hiddenDiv.textContent = '0'; const arrow = document.createElement('span'); arrow.className = 'selectArrow material-icons keyboard_arrow_down'; arrow.setAttribute('aria-hidden', 'true'); arrow.style.marginTop = '.2em'; arrowContainer.appendChild(hiddenDiv); arrowContainer.appendChild(arrow); // Assemble the select container selectContainer.appendChild(select); selectContainer.appendChild(arrowContainer); // Add event handlers if (changeHandler) { select.addEventListener('change', changeHandler); } if (hoverHandler) { select.addEventListener('mouseover', hoverHandler); select.addEventListener('mouseout', hideTooltip); } if (focusHandler) { select.addEventListener('focus', focusHandler); select.addEventListener('blur', blurHandler || hideTooltip); } container.appendChild(selectContainer); return container; } /** * Create a dropdown for the popover (wrapper for createDropdown) * @param {string} label - The dropdown label * @param {string} id - The dropdown ID * @param {Array} options - The dropdown options * @param {Function} changeHandler - The change event handler * @returns {Element} The dropdown container */ function createPopoverDropdown(label, id, options, changeHandler) { return createDropdown({ label, id, options, changeHandler, isPopover: true }); } /** * Handle skin change (unified for both popover and display page) * @param {Event} event - The change event */ function handleSkinChange(event) { const selectedSkinName = event.target.value; const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (!selectedSkin) { ERR('Selected skin not found in configuration'); return; } LOG(`Skin changed to: ${selectedSkin.name}`); // Save skin selection to localStorage localStorage.setItem(STORAGE_KEY, selectedSkinName); // Load the new skin loadSkin(selectedSkin); // Set default color scheme for the new skin const defaultColorScheme = getDefaultColorScheme(selectedSkin); if (defaultColorScheme) { localStorage.setItem(COLOR_SCHEMES_STORAGE_KEY, defaultColorScheme); syncAllColorSchemeDropdowns(); // Load the default color scheme const defaultScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === defaultColorScheme); if (defaultScheme) { updateColorSchemesCSS(defaultScheme.url); } } else { localStorage.removeItem(COLOR_SCHEMES_STORAGE_KEY); syncAllColorSchemeDropdowns(); updateColorSchemesCSS(null); } } /** * Handle color scheme change (unified for both popover and display page) * @param {Event} event - The change event */ function handleColorSchemeChange(event) { const selectedColorSchemeName = event.target.value; const selectedOption = event.target.selectedOptions[0]; LOG(`Color scheme changed to: ${selectedColorSchemeName}`); // Save color scheme selection to localStorage localStorage.setItem(COLOR_SCHEMES_STORAGE_KEY, selectedColorSchemeName); const schemeUrl = selectedOption.getAttribute('data-scheme-url'); updateColorSchemesCSS(schemeUrl); } /** * Update the color schemes dropdown in the popover */ function updatePopoverColorSchemes() { const colorSchemesSelect = document.getElementById('selectColorSchemesPopover'); if (!colorSchemesSelect) return; const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); // Get the color schemes container to show/hide it // We need to go up to the container that holds both label and select const selectContainer = colorSchemesSelect.closest('.selectContainer'); const colorSchemesContainer = selectContainer ? selectContainer.parentNode : colorSchemesSelect.closest('div'); // Clear existing options const options = colorSchemesSelect.querySelectorAll('option'); options.forEach(option => { option.remove(); }); // Show/hide color schemes dropdown based on whether skin has color schemes if (selectedSkin && selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { // Show the container colorSchemesContainer.style.display = ''; // Add color scheme options selectedSkin.colorSchemes.forEach(scheme => { const option = document.createElement('option'); option.value = scheme.name; option.textContent = scheme.name; option.setAttribute('data-scheme-url', scheme.url); colorSchemesSelect.appendChild(option); }); // Set the current selection const storedColorScheme = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); if (storedColorScheme) { colorSchemesSelect.value = storedColorScheme; } else { // Use the default color scheme for this skin const defaultColorScheme = getDefaultColorScheme(selectedSkin); if (defaultColorScheme) { colorSchemesSelect.value = defaultColorScheme; } } } else { // Hide the container if no color schemes colorSchemesContainer.style.display = 'none'; } } /** * Position the popover relative to the button * @param {Element} popover - The popover element * @param {Element} button - The button element */ function positionPopover(popover, button) { const rect = button.getBoundingClientRect(); const popoverRect = popover.getBoundingClientRect(); // Position below the button, right-aligned using right property let currentPos = rect.right - popoverRect.width; let top = rect.bottom + 16; // Increased from 8 to 16 for more spacing // Adjust if popover would go off screen if (currentPos < 8) { currentPos = 8; } else if (currentPos + popoverRect.width > window.innerWidth - 8) { currentPos = window.innerWidth - popoverRect.width - 8; } if (top + popoverRect.height > window.innerHeight - 8) { // Position above if no room below top = rect.top - popoverRect.height - 8; } popover.style.right = `calc(100% - ${currentPos}px - 50px)`; popover.style.top = `${top}px`; } /** * Handle clicks outside the popover to close it * @param {Event} event - The click event */ function handlePopoverClickOutside(event) { const backdrop = document.querySelector('.dialogBackdrop[data-modal-id]'); const dialogContainer = document.querySelector('.dialogContainer[data-modal-id]'); if (backdrop && dialogContainer && !dialogContainer.contains(event.target) && !event.target.closest('.headerAppearanceButton')) { // Remove backdrop and dialog container if (backdrop.parentNode) { backdrop.parentNode.removeChild(backdrop); } if (dialogContainer.parentNode) { dialogContainer.parentNode.removeChild(dialogContainer); } document.removeEventListener('click', handlePopoverClickOutside); } } /** * Add the skin dropdown to the display preferences page */ function addSkinDropdown() { const settingsContainer = document.querySelector('.settingsContainer'); if (!settingsContainer) { WARN('Settings container not found'); return; } const form = settingsContainer.querySelector('form'); if (!form) { WARN('Settings form not found'); return; } const displayModeField = form.querySelector('.fldDisplayMode'); if (!displayModeField) { WARN('Display mode field not found'); return; } // Check if skin dropdown already exists if (form.querySelector('.fldSkin')) { LOG('Skin dropdown already exists'); return; } // Create the skin dropdown container const skinContainer = document.createElement('div'); skinContainer.className = 'fldSkin'; // Create the dropdown using the generic function const dropdown = createDropdown({ label: 'Skin', id: 'selectSkin', options: SKINS_CONFIG.map(skin => ({ value: skin.name, text: skin.name, author: skin.author })), changeHandler: handleSkinChange, hoverHandler: handleSkinSelectHover, focusHandler: handleSkinSelectFocus }); // Add to skin container skinContainer.appendChild(dropdown); // Insert after the display mode field displayModeField.parentNode.insertBefore(skinContainer, displayModeField.nextSibling); // Set the current selection const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const select = document.getElementById('selectSkin'); if (select) { select.value = selectedSkinName; } LOG('Skin dropdown added successfully'); } /** * Handle hover events on the skin select dropdown * @param {Event} event - The mouseover event */ function handleSkinSelectHover(event) { const select = event.target; const selectedSkinName = select.value; const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (selectedSkin && selectedSkin.author) { let tooltipText = `Author: ${selectedSkin.author}`; if (selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { tooltipText += `\nColor Schemes: ${selectedSkin.colorSchemes.length}`; } showTooltip(tooltipText, select); } } /** * Handle focus events on the skin select dropdown * @param {Event} event - The focus event */ function handleSkinSelectFocus(event) { const select = event.target; const selectedSkinName = select.value; const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (selectedSkin && selectedSkin.author) { let tooltipText = `Author: ${selectedSkin.author}`; if (selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { tooltipText += `\nColor Schemes: ${selectedSkin.colorSchemes.length}`; } showTooltip(tooltipText, select); } } /** * Add the color schemes dropdown to the display preferences page */ function addColorSchemesDropdown() { const settingsContainer = document.querySelector('.settingsContainer'); if (!settingsContainer) { WARN('Settings container not found'); return; } const form = settingsContainer.querySelector('form'); if (!form) { WARN('Settings form not found'); return; } const skinField = form.querySelector('.fldSkin'); if (!skinField) { WARN('Skin field not found - color schemes dropdown requires skin field'); return; } // Check if color schemes dropdown already exists if (form.querySelector('.fldColorSchemes')) { LOG('Color schemes dropdown already exists'); return; } // Create the color schemes dropdown container const colorSchemesContainer = document.createElement('div'); colorSchemesContainer.className = 'fldColorSchemes'; // Create the dropdown using the generic function const dropdown = createDropdown({ label: 'Color Schemes', id: 'selectColorSchemes', options: [], // Will be populated by updateColorSchemesDropdown changeHandler: handleColorSchemeChange }); // Add to color schemes container colorSchemesContainer.appendChild(dropdown); // Insert after the skin field skinField.parentNode.insertBefore(colorSchemesContainer, skinField.nextSibling); // Update color schemes based on current skin selection updateColorSchemesDropdown(); LOG('Color schemes dropdown added successfully'); } /** * Synchronize all color scheme dropdowns with the current skin and localStorage * This ensures both popover and display page dropdowns stay in sync */ function syncAllColorSchemeDropdowns() { const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); // Get all color scheme dropdowns const displayDropdown = document.getElementById('selectColorSchemes'); const popoverDropdown = document.getElementById('selectColorSchemesPopover'); // Update display page dropdown if (displayDropdown) { updateColorSchemesDropdown(); } // Update popover dropdown if (popoverDropdown) { updatePopoverColorSchemes(); } // Ensure localStorage has a valid color scheme for the current skin const storedColorScheme = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); if (selectedSkin && selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { // Check if stored color scheme is valid for current skin const isValidScheme = selectedSkin.colorSchemes.some(scheme => scheme.name === storedColorScheme); if (!isValidScheme) { // Remove invalid color scheme from localStorage localStorage.removeItem(COLOR_SCHEMES_STORAGE_KEY); LOG('Removed invalid color scheme from localStorage'); } } else { // No color schemes available, remove from localStorage localStorage.removeItem(COLOR_SCHEMES_STORAGE_KEY); } } /** * Update the color schemes dropdown based on the selected skin */ function updateColorSchemesDropdown() { const select = document.getElementById('selectColorSchemes'); if (!select) { return; } // Get the currently selected skin const skinSelect = document.getElementById('selectSkin'); if (!skinSelect) { return; } const selectedSkinName = skinSelect.value; const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); // Clear existing options const options = select.querySelectorAll('option'); options.forEach(option => { option.remove(); }); // Show/hide the color schemes dropdown based on whether the skin has color schemes const colorSchemesContainer = select.closest('.fldColorSchemes'); if (colorSchemesContainer) { if (selectedSkin && selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { // Add color scheme options selectedSkin.colorSchemes.forEach(scheme => { const option = document.createElement('option'); option.value = scheme.name; option.textContent = scheme.name; option.setAttribute('data-scheme-url', scheme.url); select.appendChild(option); }); // Show the dropdown colorSchemesContainer.style.display = ''; LOG(`Updated color schemes dropdown with ${selectedSkin.colorSchemes.length} options for skin: ${selectedSkinName}`); } else { // Hide the dropdown if no color schemes colorSchemesContainer.style.display = 'none'; LOG(`Hiding color schemes dropdown - skin '${selectedSkinName}' has no color schemes`); } } // Set the selected color scheme const selectedColorSchemeName = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); if (selectedColorSchemeName) { select.value = selectedColorSchemeName; } else { // Use the default color scheme for this skin const defaultColorScheme = getDefaultColorScheme(selectedSkin); if (defaultColorScheme) { select.value = defaultColorScheme; } } } /** * Add custom theme options to the existing select#selectTheme dropdown */ function addThemeOptions() { const themeSelect = document.getElementById('selectTheme'); if (!themeSelect) { WARN('Theme select dropdown not found'); return; } // Check if custom themes have already been added if (themeSelect.hasAttribute('data-kefin-themes-added')) { LOG('Custom theme options already added'); return; } // Check if Jellyfin has already added its options if (themeSelect.options.length > 0) { addCustomThemeOptions(themeSelect); } else { // Wait for Jellyfin to add its options using MutationObserver LOG('Waiting for Jellyfin theme options to load...'); const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList' && themeSelect.options.length > 0) { LOG('Jellyfin theme options detected, adding custom options'); addCustomThemeOptions(themeSelect); observer.disconnect(); // Stop observing once we've added our options } }); }); observer.observe(themeSelect, { childList: true }); // Fallback timeout in case MutationObserver doesn't trigger setTimeout(() => { if (!themeSelect.hasAttribute('data-kefin-themes-added') && themeSelect.options.length > 0) { LOG('Fallback: Adding custom theme options after timeout'); addCustomThemeOptions(themeSelect); } observer.disconnect(); }, 2000); } } /** * Add the actual custom theme options to the select element * @param {HTMLSelectElement} themeSelect - The theme select element */ function addCustomThemeOptions(themeSelect) { // Add custom theme options THEMES_CONFIG.forEach(theme => { const option = document.createElement('option'); option.value = theme.name; option.textContent = theme.name; option.setAttribute('data-theme-url', theme.url); option.setAttribute('data-custom-theme', 'true'); themeSelect.appendChild(option); }); // Mark as processed themeSelect.setAttribute('data-kefin-themes-added', 'true'); // Add event listener for theme changes themeSelect.addEventListener('change', handleThemeChange); // Set the selected value from localStorage cache const cachedTheme = localStorage.getItem(THEME_STORAGE_KEY); if (cachedTheme) { themeSelect.value = cachedTheme; LOG(`Restored theme selection from cache: ${cachedTheme}`); } LOG(`Added ${THEMES_CONFIG.length} custom theme options`); } /** * Handle theme selection change * @param {Event} event - The change event */ function handleThemeChange(event) { const selectedOption = event.target.selectedOptions[0]; const selectedThemeValue = event.target.value; if (selectedOption) { LOG(`Custom theme changed to: ${selectedThemeValue}`); // Save selection to localStorage localStorage.setItem(THEME_STORAGE_KEY, selectedThemeValue); // Custom theme selected const themeUrl = selectedOption.getAttribute('data-theme-url') ?? `themes/${selectedThemeValue}/theme.css`; if (themeUrl) { // Update the cssTheme link href updateThemeCSS(themeUrl); } } else { // Default Jellyfin theme selected LOG(`Default theme changed to: ${selectedThemeValue}`); // Save selection to localStorage localStorage.setItem(THEME_STORAGE_KEY, selectedThemeValue); // Reset to default Jellyfin theme behavior resetThemeCSS(); } } /** * Update the cssTheme link href to a custom theme URL * @param {string} url - The custom theme URL */ function updateThemeCSS(url) { let cssThemeLink = document.getElementById('cssTheme'); if (!cssThemeLink) { // Create the cssTheme link if it doesn't exist cssThemeLink = document.createElement('link'); cssThemeLink.id = 'cssTheme'; cssThemeLink.rel = 'stylesheet'; cssThemeLink.type = 'text/css'; cssThemeLink.setAttribute('data-kefin-custom-theme', 'true'); document.head.appendChild(cssThemeLink); LOG('Created cssTheme link'); } cssThemeLink.href = url; LOG(`Updated cssTheme link to: ${url}`); } /** * Reset the cssTheme link to default Jellyfin behavior */ function resetThemeCSS() { const cssThemeLink = document.getElementById('cssTheme'); if (cssThemeLink && cssThemeLink.hasAttribute('data-kefin-custom-theme')) { // Only remove if we created it cssThemeLink.remove(); LOG('Removed custom cssTheme link'); } } /** * Load the currently selected theme */ function loadSelectedTheme() { const selectedThemeValue = localStorage.getItem(THEME_STORAGE_KEY); if (selectedThemeValue) { const selectedTheme = THEMES_CONFIG.find(theme => theme.name === selectedThemeValue); if (selectedTheme) { LOG(`Loading selected custom theme: ${selectedTheme.name}`); updateThemeCSS(selectedTheme.url); // Set the dropdown selection const themeSelect = document.getElementById('selectTheme'); if (themeSelect) { themeSelect.value = selectedThemeValue; } } else { // Check if it's a default Jellyfin theme const themeSelect = document.getElementById('selectTheme'); if (themeSelect) { const option = themeSelect.querySelector(`option[value="${selectedThemeValue}"]`); if (option && !option.hasAttribute('data-custom-theme')) { LOG(`Loading selected default theme: ${selectedThemeValue}`); themeSelect.value = selectedThemeValue; resetThemeCSS(); } else { WARN(`Selected theme '${selectedThemeValue}' not found`); } } } } } function removeColorSchemesCSS() { const cssColorSchemesLink = document.getElementById('cssColorSchemes'); if (cssColorSchemesLink && cssColorSchemesLink.hasAttribute('data-kefin-color-schemes')) { cssColorSchemesLink.remove(); LOG('Removed cssColorSchemes link'); } } /** * Update the cssColorSchemes link href to a custom color scheme URL * @param {string|null} url - The color scheme URL, or null/empty to remove CSS */ function updateColorSchemesCSS(url) { // Handle reset case (null or empty URL) if (!url) { removeColorSchemesCSS(); return; } let cssColorSchemesLink = document.getElementById('cssColorSchemes'); if (!cssColorSchemesLink) { // Create the cssColorSchemes link if it doesn't exist cssColorSchemesLink = document.createElement('link'); cssColorSchemesLink.id = 'cssColorSchemes'; cssColorSchemesLink.rel = 'stylesheet'; cssColorSchemesLink.type = 'text/css'; cssColorSchemesLink.setAttribute('data-kefin-color-schemes', 'true'); // Find the best insertion point to maintain CSS cascade order: // cssTheme -> cssSkin(s) -> cssColorSchemes const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); let insertAfter = null; if (skinLinks.length > 0) { // Insert after the last skin CSS link insertAfter = skinLinks[skinLinks.length - 1]; } else { // No skin CSS, insert after cssTheme insertAfter = document.getElementById('cssTheme'); } if (insertAfter && insertAfter.parentNode) { insertAfter.parentNode.insertBefore(cssColorSchemesLink, insertAfter.nextSibling); } else { // Fallback to appending to head if no reference point found document.head.appendChild(cssColorSchemesLink); } LOG('Created cssColorSchemes link'); } // Find the scheme by URL and update localStorage const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (selectedSkin && selectedSkin.colorSchemes) { const matchingScheme = selectedSkin.colorSchemes.find(scheme => scheme.url === url); if (matchingScheme) { LOG(`Updated cssColorSchemes link to: ${url} for skin: ${selectedSkinName}`); // Update all color scheme dropdowns to match the applied scheme const displayDropdown = document.getElementById('selectColorSchemes'); const popoverDropdown = document.getElementById('selectColorSchemesPopover'); if (displayDropdown && matchingScheme) { displayDropdown.value = matchingScheme.name; LOG(`Updated display color scheme dropdown to: ${matchingScheme.name}`); } if (popoverDropdown && matchingScheme) { popoverDropdown.value = matchingScheme.name; LOG(`Updated popover color scheme dropdown to: ${matchingScheme.name}`); } if (cssColorSchemesLink) { cssColorSchemesLink.href = url; LOG(`Updated cssColorSchemes link to: ${url}`); } return; } } // If no skin or no color schemes, reset to default LOG('No skin or color schemes available, removing cssColorSchemes link'); removeColorSchemesCSS(); } /** * Load the currently selected color scheme */ function loadSelectedColorScheme() { const selectedColorSchemeName = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); if (selectedColorSchemeName) { // Get the currently selected skin const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (selectedSkin && selectedSkin.colorSchemes) { const selectedColorScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === selectedColorSchemeName); if (selectedColorScheme) { LOG(`Loading selected color scheme: ${selectedColorScheme.name}`); updateColorSchemesCSS(selectedColorScheme.url); } else { LOG(`Selected color scheme '${selectedColorSchemeName}' not found for skin '${selectedSkinName}', using default`); // Use the default color scheme for this skin const defaultColorScheme = getDefaultColorScheme(selectedSkin); if (defaultColorScheme) { const defaultScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === defaultColorScheme); if (defaultScheme) { updateColorSchemesCSS(defaultScheme.url); } } else { updateColorSchemesCSS(null); } } } else { WARN(`No color schemes available for skin '${selectedSkinName}'`); } } else { // No color scheme stored, use default for current skin const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (selectedSkin) { const defaultColorScheme = getDefaultColorScheme(selectedSkin); if (defaultColorScheme) { const defaultScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === defaultColorScheme); if (defaultScheme) { LOG(`Loading default color scheme for skin '${selectedSkinName}': ${defaultScheme.name}`); updateColorSchemesCSS(defaultScheme.url); } } else { LOG(`No default color scheme for skin '${selectedSkinName}', removing CSS`); updateColorSchemesCSS(null); } } } } /** * Get the default color scheme for a skin * @param {Object} skin - The skin configuration object * @returns {string|null} The default color scheme name, or null if no schemes */ function getDefaultColorScheme(skin) { if (!skin || !skin.colorSchemes || skin.colorSchemes.length === 0) { return null; } return skin.colorSchemes[0].name; } /** * Get the default skin name from configuration * @returns {string} The default skin name */ function getDefaultSkinName() { // Check if a default skin is specified in the main configuration const configDefaultSkin = window.KefinTweaksConfig?.defaultSkin; if (configDefaultSkin) { LOG(`Using configured default skin: ${configDefaultSkin}`); return configDefaultSkin; } // Fall back to 'Default' if no configuration is specified return 'Default'; } /** * Load the currently selected skin */ function loadSelectedSkin() { const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (selectedSkin) { LOG(`Loading selected skin: ${selectedSkin.name}`); loadSkin(selectedSkin); // Update color schemes dropdown after skin is loaded setTimeout(() => { syncAllColorSchemeDropdowns(); }, 100); } else { const defaultSkinName = getDefaultSkinName(); WARN(`Selected skin '${selectedSkinName}' not found, falling back to ${defaultSkinName}`); const defaultSkin = SKINS_CONFIG.find(skin => skin.name === defaultSkinName); if (defaultSkin) { loadSkin(defaultSkin); // Update color schemes dropdown after default skin is loaded setTimeout(() => { syncAllColorSchemeDropdowns(); }, 100); } } } /** * Load a specific skin * @param {Object} skin - The skin configuration object */ function loadSkin(skin) { // Check if this skin is already loaded by looking at the DOM const currentSkinLink = document.querySelector('link[data-kefin-skin="true"]'); const currentSkinName = currentSkinLink ? currentSkinLink.getAttribute('data-skin') : null; if (currentSkinName === skin.name) { LOG(`Skin '${skin.name}' is already loaded - skipping`); return; } // Use requestAnimationFrame to avoid blocking requestAnimationFrame(() => { removeAllSkinCSS(); }); // Performance optimization: Load new CSS first, then remove old CSS // This prevents the double-reflow/recalc that causes UI freezing // Step 1: Load the new CSS first (gets cached and starts loading) requestAnimationFrame(() => { const cssUrls = getSkinUrlsForCurrentVersion(skin); if (!cssUrls || cssUrls.length === 0) { LOG(`No CSS URLs found for skin: ${skin.name}`); return; } LOG(`Loading skin with ${cssUrls.length} CSS files: ${skin.name}`); cssUrls.forEach(url => { if (url) { // Skip null/empty URLs loadSkinCSS(url, skin.name); } }); }); } /** * Load CSS for a specific skin using link tags * @param {string} url - The CSS URL * @param {string} skinName - The skin name */ function loadSkinCSS(url, skinName) { // Check if this CSS is already loaded if (loadedSkinUrls.has(url)) { LOG(`Skin CSS already loaded: ${url}`); return; } if (!url || url === '') { LOG(`Invalid skin CSS URL: ${url}, skipping`); return; } const link = document.createElement('link'); link.id = `cssSkin-${Date.now()}-${Math.random()}`; // Unique ID to prevent conflicts link.rel = 'stylesheet'; link.type = 'text/css'; link.href = url; link.setAttribute('data-kefin-skin', 'true'); link.setAttribute('data-skin', skinName); // Find the insertion point: after the last skin link, or after cssTheme if no skin links exist // This ensures CSS files are loaded in the order they appear in the config array const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); let insertAfter = null; if (skinLinks.length > 0) { // Insert after the last skin link to maintain order insertAfter = skinLinks[skinLinks.length - 1]; } else { // No skin links yet, insert after cssTheme insertAfter = document.getElementById('cssTheme'); } if (insertAfter && insertAfter.parentNode) { insertAfter.parentNode.insertBefore(link, insertAfter.nextSibling); } else { // Fallback to appending to head if reference point not found document.getElementById('reactRoot').after(link); } loadedSkinUrls.add(url); LOG(`Skin CSS loaded via link tag: ${url}`); // Defer expensive DOM operations (cssColorSchemes repositioning) // to avoid blocking during the initial CSS load requestAnimationFrame(() => { const cssColorSchemesLink = document.getElementById('cssColorSchemes'); if (cssColorSchemesLink && cssColorSchemesLink.parentNode) { // Only reposition if it's not already in the right position const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); const lastSkinLink = skinLinks[skinLinks.length - 1]; if (lastSkinLink && lastSkinLink.nextSibling !== cssColorSchemesLink) { // Remove it from its current position cssColorSchemesLink.remove(); // Insert it after the last skin CSS link lastSkinLink.parentNode.insertBefore(cssColorSchemesLink, lastSkinLink.nextSibling); LOG('Repositioned cssColorSchemes after skin CSS'); } } }); } /** * Remove all previously loaded skin CSS */ function removeAllSkinCSS() { const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); skinLinks.forEach(link => { const url = link.href; loadedSkinUrls.delete(url); link.remove(); LOG(`Removed skin CSS link: ${url}`); }); // Clear the tracking set loadedSkinUrls.clear(); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } LOG('Initialized successfully'); })();