mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
2911 lines
118 KiB
JavaScript
2911 lines
118 KiB
JavaScript
// 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, skinConfig.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 — prefer window.KefinTweaks
|
|
let cachedServerVersion = null;
|
|
|
|
async function getCurrentMajorServerVersion() {
|
|
try {
|
|
if (window.KefinTweaks?.getJellyfinMajorVersion) {
|
|
cachedServerVersion = await window.KefinTweaks.getJellyfinMajorVersion();
|
|
return cachedServerVersion;
|
|
}
|
|
return cachedServerVersion;
|
|
} 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<string>|null} Array of URLs to load, or null if no URLs
|
|
*/
|
|
async function getSkinUrlsForCurrentVersion(skin) {
|
|
if (!skin.url) {
|
|
return null;
|
|
}
|
|
|
|
const currentMajorVersion = await 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.
|
|
// majorServerVersions including 12 means any major version >= 12.
|
|
const matchingUrlObjects = skin.url.filter(urlObj => {
|
|
if (!urlObj.majorServerVersions || !Array.isArray(urlObj.majorServerVersions)) {
|
|
return false;
|
|
}
|
|
if (currentMajorVersion === null) return false;
|
|
if (urlObj.majorServerVersions.includes(currentMajorVersion)) return true;
|
|
return urlObj.majorServerVersions.includes(12) && currentMajorVersion >= 12;
|
|
});
|
|
|
|
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
|
|
*/
|
|
async function filterSkinsByServerVersion(skins) {
|
|
const currentMajorVersion = await 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 => {
|
|
const matchesMajorVersions = (versions) => {
|
|
if (!versions || !Array.isArray(versions)) return false;
|
|
if (versions.includes(currentMajorVersion)) return true;
|
|
// 12 is a floor: configs that include 12 apply to any major >= 12
|
|
return versions.includes(12) && currentMajorVersion >= 12;
|
|
};
|
|
|
|
// 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 = matchesMajorVersions(skin.majorServerVersions);
|
|
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 =>
|
|
matchesMajorVersions(urlObj.majorServerVersions)
|
|
);
|
|
|
|
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 = matchesMajorVersions(skin.majorServerVersions);
|
|
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;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalize a single URL string for comparison
|
|
* URLs pointing to /KefinTweaks/skins/{filename}.css are normalized to just the filename
|
|
* This allows matching across different root domains (self-hosted, GitHub, jsdelivr, etc.)
|
|
* @param {string} urlString - URL string to normalize
|
|
* @returns {string} Normalized URL (filename if it's a KefinTweaks skin, otherwise original URL)
|
|
*/
|
|
function normalizeSingleUrl(urlString) {
|
|
if (!urlString || typeof urlString !== 'string') {
|
|
return urlString;
|
|
}
|
|
|
|
// Remove query parameters and fragments for comparison
|
|
let normalized = urlString.split('?')[0].split('#')[0];
|
|
|
|
// Match pattern: .../KefinTweaks/skins/{filename}.css
|
|
// This works for various formats:
|
|
// - https://ranaldsgift.github.io/KefinTweaks/skins/elegantKefin.css
|
|
// - https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@main/skins/elegantKefin.css
|
|
// - https://selfhosted.com/KefinTweaks/skins/elegantKefin.css
|
|
const kefinTweaksSkinPattern = /\/KefinTweaks\/skins\/([^\/\?]+\.css)(?:\?.*)?$/i;
|
|
const match = normalized.match(kefinTweaksSkinPattern);
|
|
|
|
if (match) {
|
|
// Extract just the filename (e.g., "elegantKefin.css")
|
|
return match[1].toLowerCase();
|
|
}
|
|
|
|
// For other URLs, normalize by removing trailing slashes and normalizing the URL
|
|
// Remove trailing slash
|
|
normalized = normalized.replace(/\/$/, '');
|
|
|
|
// Return normalized URL (lowercase for case-insensitive comparison)
|
|
return normalized.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Deep compare two skin configurations to check if they are materially identical
|
|
* Compares URLs and colorSchemes, ignoring other properties like enabled/hidden
|
|
* URLs pointing to /KefinTweaks/skins/{filename}.css are normalized to just the filename
|
|
* @param {Object} skin1 - First skin configuration
|
|
* @param {Object} skin2 - Second skin configuration
|
|
* @returns {boolean} True if skins match exactly in URLs and colorSchemes
|
|
*/
|
|
function skinsMatchExactly(skin1, skin2) {
|
|
if (!skin1 || !skin2 || skin1.name !== skin2.name) {
|
|
return false;
|
|
}
|
|
|
|
// Normalize URLs for comparison
|
|
const normalizeUrls = (url) => {
|
|
if (!url) return null;
|
|
// New format: array of objects with majorServerVersions and urls
|
|
if (Array.isArray(url) && url.length > 0 && typeof url[0] === 'object' && url[0].majorServerVersions) {
|
|
// Sort by majorServerVersions for consistent comparison
|
|
const sorted = [...url].sort((a, b) => {
|
|
const aVersions = Array.isArray(a.majorServerVersions) ? a.majorServerVersions.sort().join(',') : '';
|
|
const bVersions = Array.isArray(b.majorServerVersions) ? b.majorServerVersions.sort().join(',') : '';
|
|
return aVersions.localeCompare(bVersions);
|
|
});
|
|
return JSON.stringify(sorted.map(u => ({
|
|
majorServerVersions: Array.isArray(u.majorServerVersions) ? [...u.majorServerVersions].sort() : u.majorServerVersions,
|
|
urls: Array.isArray(u.urls)
|
|
? [...u.urls].map(normalizeSingleUrl).sort()
|
|
: normalizeSingleUrl(u.urls)
|
|
})));
|
|
}
|
|
// Old format: string or array of strings
|
|
if (typeof url === 'string') {
|
|
return JSON.stringify([normalizeSingleUrl(url)]);
|
|
}
|
|
if (Array.isArray(url)) {
|
|
return JSON.stringify([...url].map(normalizeSingleUrl).sort());
|
|
}
|
|
return JSON.stringify(url);
|
|
};
|
|
|
|
// Normalize colorSchemes for comparison
|
|
const normalizeColorSchemes = (colorSchemes) => {
|
|
if (!colorSchemes || !Array.isArray(colorSchemes)) return '[]';
|
|
const sorted = [...colorSchemes].sort((a, b) => {
|
|
const aName = (a.name || '').toLowerCase();
|
|
const bName = (b.name || '').toLowerCase();
|
|
return aName.localeCompare(bName);
|
|
});
|
|
return JSON.stringify(sorted.map(cs => {
|
|
let normalizedUrl = cs.url;
|
|
if (normalizedUrl) {
|
|
// Handle @import url(...) format - extract the URL
|
|
const importMatch = normalizedUrl.match(/@import\s+url\(["']?([^"']+)["']?\)/i);
|
|
if (importMatch) {
|
|
normalizedUrl = importMatch[1];
|
|
}
|
|
normalizedUrl = normalizeSingleUrl(normalizedUrl);
|
|
}
|
|
return {
|
|
name: cs.name,
|
|
url: normalizedUrl
|
|
};
|
|
}));
|
|
};
|
|
|
|
const urls1 = normalizeUrls(skin1.url);
|
|
const urls2 = normalizeUrls(skin2.url);
|
|
const schemes1 = normalizeColorSchemes(skin1.colorSchemes);
|
|
const schemes2 = normalizeColorSchemes(skin2.colorSchemes);
|
|
|
|
return urls1 === urls2 && schemes1 === schemes2;
|
|
}
|
|
|
|
/**
|
|
* Check if a Jellypane skin contains the old URL from the default config
|
|
* @param {Object} skin - Skin configuration to check
|
|
* @returns {boolean} True if it's Jellypane with the old URL
|
|
*/
|
|
function isJellypaneWithOldUrlFormat(skin) {
|
|
if (!skin || skin.name !== 'Jellypane') {
|
|
return false;
|
|
}
|
|
|
|
if (!skin.url) return false;
|
|
|
|
const oldJellypaneUrl = 'https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/Jellypane.css';
|
|
|
|
// Check if the old URL exists in the skin config
|
|
// Handle new format: 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) {
|
|
// Check all url objects for the old URL
|
|
for (const urlObj of skin.url) {
|
|
if (Array.isArray(urlObj.urls)) {
|
|
if (urlObj.urls.includes(oldJellypaneUrl)) {
|
|
return true;
|
|
}
|
|
} else if (urlObj.urls === oldJellypaneUrl) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Handle old format: string or array of strings
|
|
if (typeof skin.url === 'string') {
|
|
return skin.url === oldJellypaneUrl;
|
|
}
|
|
|
|
if (Array.isArray(skin.url)) {
|
|
return skin.url.includes(oldJellypaneUrl);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Create a backup of the current KefinTweaksConfig in JS Injector
|
|
* @param {Object} configToBackup - The config object to backup
|
|
* @returns {Promise<boolean>} True if backup was successful
|
|
*/
|
|
async function createConfigBackup(configToBackup) {
|
|
try {
|
|
if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) {
|
|
WARN('ApiClient not available, cannot create backup');
|
|
return false;
|
|
}
|
|
|
|
if (!window.KefinTweaksUtils?.resolvePluginId || !window.KefinTweaksUtils?.getPluginConfiguration) {
|
|
WARN('KefinTweaksUtils plugin helpers not available, cannot create backup');
|
|
return false;
|
|
}
|
|
|
|
const server = window.ApiClient._serverAddress;
|
|
const injectorAliases = ['JavaScript Injector', 'JS Injector'];
|
|
|
|
const pluginId = await window.KefinTweaksUtils.resolvePluginId(injectorAliases);
|
|
if (!pluginId) {
|
|
WARN('JavaScript Injector plugin not found, cannot create backup');
|
|
return false;
|
|
}
|
|
|
|
const injectorConfig = await window.KefinTweaksUtils.getPluginConfiguration(injectorAliases);
|
|
const configUrl = `${server}/Plugins/${pluginId}/Configuration`;
|
|
|
|
// Ensure CustomJavaScripts array exists
|
|
if (!injectorConfig.CustomJavaScripts) {
|
|
injectorConfig.CustomJavaScripts = [];
|
|
}
|
|
|
|
// Create backup script name with timestamp
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); // Format: 2024-01-01T12-30-45
|
|
const backupName = `KefinTweaks-Config-[SkinConflictsRemoved-${timestamp}]`;
|
|
|
|
// Create the backup script content
|
|
const backupScriptContent = `// KefinTweaks Configuration Backup
|
|
// This backup was automatically created when skin conflicts were removed
|
|
// Created: ${new Date().toISOString()}
|
|
// Do not edit manually
|
|
|
|
window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
|
|
|
|
// Add backup script (disabled by default so it doesn't interfere)
|
|
injectorConfig.CustomJavaScripts.push({
|
|
Name: backupName,
|
|
Script: backupScriptContent,
|
|
Enabled: false, // Disabled so it doesn't interfere with the main config
|
|
RequiresAuthentication: false
|
|
});
|
|
|
|
// Save the updated configuration with backup
|
|
const saveResponse = await fetch(configUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': window.apiHelper.getAuthHeader(),
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(injectorConfig)
|
|
});
|
|
|
|
if (!saveResponse.ok) {
|
|
WARN(`Failed to save backup: ${saveResponse.statusText}`);
|
|
return false;
|
|
}
|
|
|
|
LOG(`Created backup: ${backupName}`);
|
|
return true;
|
|
} catch (error) {
|
|
ERR('Error creating config backup:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Load and merge skin configurations
|
|
async function loadSkinConfig() {
|
|
// Load default skins from skinConfig.js (for merging)
|
|
const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || [];
|
|
|
|
// Load legacy defaults for duplicate detection (canonical v0.3.5 snapshot)
|
|
const legacyDefaults = window.KefinTweaksLegacySkinDefaults?.skins || [];
|
|
|
|
// Load admin-configured skins from window.KefinTweaksConfig (from JS Injector)
|
|
let adminSkins = window.KefinTweaksConfig?.skins || [];
|
|
|
|
// Clean up admin skins: remove exact duplicates of legacy defaults and old Jellypane format
|
|
const cleanedAdminSkins = [];
|
|
const skinsToRemove = new Set();
|
|
|
|
adminSkins.forEach(adminSkin => {
|
|
// Check for exact match with any legacy default skin (canonical comparison)
|
|
const matchingLegacyDefault = legacyDefaults.find(legacySkin => {
|
|
if (adminSkin.name !== legacySkin.name) {
|
|
return false;
|
|
}
|
|
const matches = skinsMatchExactly(adminSkin, legacySkin);
|
|
if (!matches) {
|
|
// Debug: Log why skins with same name don't match
|
|
LOG(`[Skin Match Debug] Skin '${adminSkin.name}' found in both configs but URLs/colorSchemes differ. Admin: ${JSON.stringify({url: adminSkin.url, colorSchemes: adminSkin.colorSchemes})}, Legacy Default: ${JSON.stringify({url: legacySkin.url, colorSchemes: legacySkin.colorSchemes})}`);
|
|
}
|
|
return matches;
|
|
});
|
|
|
|
if (matchingLegacyDefault) {
|
|
// Exact match found - remove from admin config
|
|
LOG(`Removing exact duplicate of legacy default skin '${adminSkin.name}' from admin config`);
|
|
skinsToRemove.add(adminSkin.name);
|
|
return; // Skip adding to cleanedAdminSkins
|
|
}
|
|
|
|
// Special case: Jellypane with old URL format
|
|
if (isJellypaneWithOldUrlFormat(adminSkin)) {
|
|
LOG(`Removing Jellypane with old URL format from admin config`);
|
|
skinsToRemove.add(adminSkin.name);
|
|
return; // Skip adding to cleanedAdminSkins
|
|
}
|
|
|
|
// Keep this admin skin
|
|
cleanedAdminSkins.push(adminSkin);
|
|
});
|
|
|
|
// If we removed any skins, create backup and save cleaned config
|
|
if (skinsToRemove.size > 0 && window.KefinTweaksConfig) {
|
|
// Create a deep copy of the original config for backup
|
|
const originalConfig = JSON.parse(JSON.stringify(window.KefinTweaksConfig));
|
|
|
|
// Create backup before modifying
|
|
const backupCreated = await createConfigBackup(originalConfig);
|
|
if (!backupCreated) {
|
|
WARN('Failed to create backup, but continuing with cleanup');
|
|
}
|
|
|
|
// Update KefinTweaksConfig with cleaned skins
|
|
window.KefinTweaksConfig.skins = cleanedAdminSkins;
|
|
|
|
// Save the cleaned config back to JS Injector
|
|
if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) {
|
|
const saveSuccess = await window.KefinTweaksUtils.saveConfigToJavaScriptInjector(window.KefinTweaksConfig, { waitForLogin: false });
|
|
if (saveSuccess) {
|
|
LOG(`Successfully saved cleaned config (removed ${skinsToRemove.size} duplicate/old-format skin(s): ${Array.from(skinsToRemove).join(', ')})`);
|
|
} else {
|
|
WARN('Failed to save cleaned config to JS Injector');
|
|
}
|
|
} else {
|
|
WARN('KefinTweaksUtils.saveConfigToJavaScriptInjector not available, cannot save cleaned config');
|
|
}
|
|
|
|
LOG(`Cleaned up ${skinsToRemove.size} duplicate/old-format skin(s) from admin config: ${Array.from(skinsToRemove).join(', ')}`);
|
|
}
|
|
|
|
adminSkins = cleanedAdminSkins;
|
|
|
|
// Merge: Start with defaults, then override/add from admin config
|
|
// Admin config takes precedence for duplicate names
|
|
const mergedSkins = [];
|
|
const adminSkinNames = new Set(adminSkins.map(s => s.name));
|
|
|
|
// First, add all default skins
|
|
defaultSkins.forEach(skin => {
|
|
mergedSkins.push({ ...skin });
|
|
});
|
|
|
|
// Then, add/override with admin skins
|
|
adminSkins.forEach(adminSkin => {
|
|
const existingIndex = mergedSkins.findIndex(s => s.name === adminSkin.name);
|
|
if (existingIndex >= 0) {
|
|
// Override existing default skin with admin version
|
|
mergedSkins[existingIndex] = { ...adminSkin };
|
|
} else {
|
|
// Add new admin skin
|
|
mergedSkins.push({ ...adminSkin });
|
|
}
|
|
});
|
|
|
|
// Filter to only include enabled skins (enabled !== false)
|
|
// Default to enabled if the property is not set
|
|
SKINS_CONFIG = mergedSkins.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 = await filterSkinsByServerVersion(SKINS_CONFIG);
|
|
const skinsAfterFilter = SKINS_CONFIG.length;
|
|
|
|
if (skinsBeforeFilter !== skinsAfterFilter) {
|
|
LOG(`Filtered skins by server version: ${skinsBeforeFilter} -> ${skinsAfterFilter} (removed ${skinsBeforeFilter - skinsAfterFilter} incompatible skins)`);
|
|
}
|
|
|
|
// Expose merged config globally for use by other scripts
|
|
window.KefinTweaksSkinConfig = SKINS_CONFIG;
|
|
|
|
const enabledCount = mergedSkins.filter(s => s.enabled !== false).length;
|
|
const disabledCount = mergedSkins.length - enabledCount;
|
|
LOG(`Loaded ${SKINS_CONFIG.length} enabled skins (${enabledCount} enabled, ${disabledCount} disabled). Merged config available at window.KefinTweaksSkinConfig`);
|
|
}
|
|
|
|
/**
|
|
* Get the source information for a skin (default, custom, or overridden)
|
|
* @param {string} skinName - Name of the skin to check
|
|
* @returns {string} 'default', 'custom', or 'overridden'
|
|
*/
|
|
function getSkinSourceInfo(skinName) {
|
|
if (!skinName) return 'default';
|
|
|
|
const defaultSkins = window.KefinTweaksDefaultSkinsConfig?.skins || [];
|
|
const adminSkins = window.KefinTweaksConfig?.skins || [];
|
|
|
|
const isDefault = defaultSkins.some(s => s.name === skinName);
|
|
const isAdmin = adminSkins.some(s => s.name === skinName);
|
|
|
|
if (isDefault && isAdmin) {
|
|
return 'overridden';
|
|
} else if (isAdmin) {
|
|
return 'custom';
|
|
} else {
|
|
return 'default';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get source information for all skins in the merged config
|
|
* @returns {Object} Map of skin names to their source ('default', 'custom', or 'overridden')
|
|
*/
|
|
function getAllSkinSources() {
|
|
const sourceMap = {};
|
|
const mergedSkins = window.KefinTweaksSkinConfig || SKINS_CONFIG || [];
|
|
|
|
mergedSkins.forEach(skin => {
|
|
if (skin.name) {
|
|
sourceMap[skin.name] = getSkinSourceInfo(skin.name);
|
|
}
|
|
});
|
|
|
|
return sourceMap;
|
|
}
|
|
|
|
// Expose helper functions globally
|
|
window.KefinTweaksSkinManager = window.KefinTweaksSkinManager || {};
|
|
window.KefinTweaksSkinManager.getSkinSourceInfo = getSkinSourceInfo;
|
|
window.KefinTweaksSkinManager.getAllSkinSources = getAllSkinSources;
|
|
window.KefinTweaksSkinManager.toggleAppearancePopover = toggleAppearancePopover;
|
|
|
|
// Load and merge theme configurations
|
|
async function loadThemeConfig() {
|
|
// Load themes from admin config only (no default themes support for now)
|
|
const adminThemes = window.KefinTweaksConfig?.themes || [];
|
|
|
|
// Start with admin themes
|
|
THEMES_CONFIG = [...adminThemes];
|
|
|
|
LOG(`Loaded ${THEMES_CONFIG.length} themes from admin config`);
|
|
}
|
|
|
|
|
|
// Track loaded skin CSS URLs
|
|
let loadedSkinUrls = new Set();
|
|
|
|
// Track loaded optional includes CSS URLs
|
|
let loadedOptionalIncludesUrls = new Set();
|
|
|
|
// Storage key for user config
|
|
const USER_CONFIG_STORAGE_KEY = 'kefinTweaksUserConfig';
|
|
|
|
/**
|
|
* Extract filename from a URL
|
|
* @param {string} url - The URL to extract filename from
|
|
* @returns {string} The filename (e.g., "custom-media-covers-latest-min.css")
|
|
*/
|
|
function extractFilenameFromUrl(url) {
|
|
if (!url) return '';
|
|
|
|
try {
|
|
// Remove query parameters and fragments
|
|
const urlWithoutParams = url.split('?')[0].split('#')[0];
|
|
|
|
// Extract the last path segment
|
|
const pathParts = urlWithoutParams.split('/');
|
|
const filename = pathParts[pathParts.length - 1];
|
|
|
|
return filename || '';
|
|
} catch (e) {
|
|
ERR('Error extracting filename from URL:', e);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize skin name for use in keys (replace spaces with underscores)
|
|
* @param {string} skinName - The skin name
|
|
* @returns {string} Normalized skin name
|
|
*/
|
|
function normalizeSkinNameForKey(skinName) {
|
|
if (!skinName) return '';
|
|
return skinName.replace(/\s+/g, '_');
|
|
}
|
|
|
|
/**
|
|
* Generate a key for an optional include
|
|
* Format: [skin_name]-[author]-[css-filename]
|
|
* @param {string} skinName - The skin name (or "global" for global includes)
|
|
* @param {string} author - The author name
|
|
* @param {string} url - The URL to extract filename from
|
|
* @returns {string} The generated key
|
|
*/
|
|
function generateOptionalIncludeKey(skinName, author, url) {
|
|
const normalizedSkinName = skinName === 'global' ? 'global' : normalizeSkinNameForKey(skinName);
|
|
const filename = extractFilenameFromUrl(url);
|
|
const normalizedAuthor = (author || '').replace(/\s+/g, '_');
|
|
|
|
return `${normalizedSkinName}-${normalizedAuthor}-${filename}`;
|
|
}
|
|
|
|
/**
|
|
* Get the user config from localStorage
|
|
* @returns {Object} User config object with optionalIncludes array of { key, enabled } objects
|
|
*/
|
|
function getUserConfig() {
|
|
try {
|
|
const saved = localStorage.getItem(USER_CONFIG_STORAGE_KEY);
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved);
|
|
// Migrate old format (array of keys) to new format (array of objects)
|
|
if (parsed.optionalIncludes && parsed.optionalIncludes.length > 0) {
|
|
const firstItem = parsed.optionalIncludes[0];
|
|
if (typeof firstItem === 'string') {
|
|
// Old format: array of keys (all enabled)
|
|
parsed.optionalIncludes = parsed.optionalIncludes.map(key => ({ key, enabled: true }));
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
} catch (e) {
|
|
ERR('Error parsing user config:', e);
|
|
}
|
|
return { optionalIncludes: [] };
|
|
}
|
|
|
|
/**
|
|
* Save the user config to localStorage
|
|
* @param {Object} userConfig - User config object
|
|
*/
|
|
function saveUserConfig(userConfig) {
|
|
try {
|
|
localStorage.setItem(USER_CONFIG_STORAGE_KEY, JSON.stringify(userConfig));
|
|
} catch (e) {
|
|
ERR('Error saving user config:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an optional include is enabled based on precedence:
|
|
* 1. User localStorage config (highest priority) - objects with { key, enabled }
|
|
* 2. Admin config (KefinTweaksConfig.optionalIncludes) - objects with { key, enabled }
|
|
* 3. Default config (lowest priority) - defaultEnabled parameter
|
|
* @param {string} key - The optional include key
|
|
* @param {boolean} defaultEnabled - Default enabled state from config
|
|
* @returns {boolean} Whether the include is enabled
|
|
*/
|
|
function isOptionalIncludeEnabled(key, defaultEnabled = false, userConfig = null) {
|
|
// Check user localStorage first (highest priority)
|
|
if (!userConfig) {
|
|
userConfig = getUserConfig();
|
|
}
|
|
const userOptionalIncludes = userConfig.optionalIncludes || [];
|
|
|
|
// Check if user has explicitly set this key
|
|
const userEntry = userOptionalIncludes.find(entry => {
|
|
if (typeof entry === 'string') {
|
|
// Legacy format: just a key (enabled)
|
|
return entry === key;
|
|
}
|
|
if (typeof entry === 'object' && entry.key) {
|
|
return entry.key === key;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (userEntry !== undefined) {
|
|
// User has explicitly configured this key
|
|
if (typeof userEntry === 'string') {
|
|
return true; // Legacy format: key string means enabled
|
|
}
|
|
return userEntry.enabled === true;
|
|
}
|
|
|
|
// User hasn't configured this key, check admin config
|
|
const adminOptionalIncludes = window.KefinTweaksConfig?.optionalIncludes || [];
|
|
|
|
if (Array.isArray(adminOptionalIncludes) && adminOptionalIncludes.length > 0) {
|
|
// Check if admin has configured this key
|
|
const adminEntry = adminOptionalIncludes.find(entry => {
|
|
if (typeof entry === 'string') {
|
|
// Legacy format: just a key (enabled)
|
|
return entry === key;
|
|
}
|
|
if (typeof entry === 'object' && entry.key) {
|
|
return entry.key === key;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (adminEntry !== undefined) {
|
|
// Admin has configured this key
|
|
if (typeof adminEntry === 'string') {
|
|
return true; // Legacy format: key string means enabled
|
|
}
|
|
return adminEntry.enabled === true;
|
|
}
|
|
}
|
|
|
|
// Neither user nor admin has configured this key, fall back to default
|
|
return defaultEnabled;
|
|
}
|
|
|
|
/**
|
|
* Get enabled optional includes for a skin from user config
|
|
* @param {string} skinName - The skin name (or "global" for global includes)
|
|
* @param {Array} defaultOptionalIncludes - Default optional includes from skin config
|
|
* @returns {Array} Array of enabled optional includes with their enabled state
|
|
*/
|
|
function getOptionalIncludesEnabledState(skinName, defaultOptionalIncludes = []) {
|
|
if (!skinName || !defaultOptionalIncludes || defaultOptionalIncludes.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Map default optional includes to enabled state based on key-based checks
|
|
return defaultOptionalIncludes.map(include => {
|
|
const author = extractAuthorFromUrl(include.url);
|
|
const key = generateOptionalIncludeKey(skinName, author, include.url);
|
|
const enabled = isOptionalIncludeEnabled(key, include.enabled || false);
|
|
|
|
return {
|
|
...include,
|
|
enabled: enabled,
|
|
key: key // Store key for later use
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save a single optional include state to user config
|
|
* Only saves the specific key that was toggled, maintaining existing entries
|
|
* @param {string} key - The optional include key
|
|
* @param {boolean} enabled - Whether the include is enabled
|
|
*/
|
|
function saveOptionalIncludeState(key, enabled) {
|
|
if (!key) {
|
|
return;
|
|
}
|
|
|
|
const userConfig = getUserConfig();
|
|
const currentEntries = userConfig.optionalIncludes || [];
|
|
|
|
// Find existing entry for this key
|
|
const existingIndex = currentEntries.findIndex(entry => {
|
|
const entryKey = typeof entry === 'string' ? entry : entry.key;
|
|
return entryKey === key;
|
|
});
|
|
|
|
if (existingIndex >= 0) {
|
|
// Update existing entry
|
|
if (typeof currentEntries[existingIndex] === 'string') {
|
|
// Legacy format: replace with object
|
|
currentEntries[existingIndex] = { key: key, enabled: enabled };
|
|
} else {
|
|
// Update enabled state
|
|
currentEntries[existingIndex].enabled = enabled;
|
|
}
|
|
} else {
|
|
// Add new entry
|
|
currentEntries.push({ key: key, enabled: enabled });
|
|
}
|
|
|
|
// Update user config
|
|
userConfig.optionalIncludes = currentEntries;
|
|
|
|
// Save updated config
|
|
saveUserConfig(userConfig);
|
|
}
|
|
|
|
/**
|
|
* Save enabled optional includes state to user config (legacy function for bulk updates)
|
|
* @param {string} skinName - The skin name (or "global" for global includes)
|
|
* @param {Array} optionalIncludes - Array of optional includes with enabled state
|
|
* @deprecated Use saveOptionalIncludeState for individual updates
|
|
*/
|
|
function saveOptionalIncludesEnabledState(skinName, optionalIncludes) {
|
|
if (!skinName || !optionalIncludes) {
|
|
return;
|
|
}
|
|
|
|
const userConfig = getUserConfig();
|
|
|
|
// Get array of optional include entries with { key, enabled }
|
|
const entries = optionalIncludes.map(include => {
|
|
// Use stored key if available, otherwise generate it
|
|
const key = include.key || (() => {
|
|
const author = extractAuthorFromUrl(include.url);
|
|
return generateOptionalIncludeKey(skinName, author, include.url);
|
|
})();
|
|
|
|
return {
|
|
key: key,
|
|
enabled: include.enabled === true
|
|
};
|
|
});
|
|
|
|
// Get all current entries (from other skins/global)
|
|
const currentEntries = userConfig.optionalIncludes || [];
|
|
|
|
// Remove entries for this skin/global (to replace with new ones)
|
|
const prefix = skinName === 'global' ? 'global-' : `${normalizeSkinNameForKey(skinName)}-`;
|
|
const otherEntries = currentEntries.filter(entry => {
|
|
const entryKey = typeof entry === 'string' ? entry : entry.key;
|
|
return !entryKey.startsWith(prefix);
|
|
});
|
|
|
|
// Combine other entries with new entries
|
|
const allEntries = [...otherEntries, ...entries];
|
|
|
|
// Update user config
|
|
userConfig.optionalIncludes = allEntries;
|
|
|
|
// Save updated config
|
|
saveUserConfig(userConfig);
|
|
}
|
|
|
|
/**
|
|
* Get merged global optional includes
|
|
* Merges default global includes with admin configuration
|
|
* @returns {Array} Array of optional include objects
|
|
*/
|
|
function getMergedGlobalOptionalIncludes() {
|
|
// Get default global optional includes from skinConfig.js
|
|
// We only support toggling existing defaults defined in skinConfig.js
|
|
// User/Admin enabled states are applied via isOptionalIncludeEnabled()
|
|
return (window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || []).map(include => ({ ...include }));
|
|
}
|
|
|
|
/**
|
|
* Load optional includes CSS for a skin based on userConfig
|
|
* Includes both global and skin-specific optional includes
|
|
* @param {Object} skin - The skin configuration object
|
|
*/
|
|
function loadOptionalIncludes(skin) {
|
|
if (!skin) {
|
|
return;
|
|
}
|
|
|
|
// Get merged global optional includes
|
|
const globalOptionalIncludes = getMergedGlobalOptionalIncludes();
|
|
|
|
const userConfig = getUserConfig();
|
|
|
|
// Load enabled global optional includes
|
|
// This explicitly checks user localStorage config first via isOptionalIncludeEnabled
|
|
globalOptionalIncludes.forEach(include => {
|
|
const author = extractAuthorFromUrl(include.url);
|
|
const key = generateOptionalIncludeKey('global', author, include.url);
|
|
|
|
// Check enabled state (User Config > Admin Config > Default)
|
|
const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false, userConfig);
|
|
|
|
if (isEnabled && include.url) {
|
|
loadOptionalIncludeCSS(include.url, 'global');
|
|
}
|
|
});
|
|
|
|
// Load enabled skin-specific optional includes
|
|
if (skin.optionalIncludes && skin.optionalIncludes.length > 0) {
|
|
skin.optionalIncludes.forEach(include => {
|
|
const author = extractAuthorFromUrl(include.url);
|
|
const key = generateOptionalIncludeKey(skin.name, author, include.url);
|
|
|
|
// Check enabled state (User Config > Admin Config > Default)
|
|
const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false, userConfig);
|
|
|
|
if (isEnabled && include.url) {
|
|
loadOptionalIncludeCSS(include.url, skin.name);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unload all optional includes CSS for a specific skin
|
|
* @param {string} skinName - The skin name to unload includes for (or 'global' for global includes)
|
|
*/
|
|
function unloadOptionalIncludesForSkin(skinName) {
|
|
const optionalIncludeLinks = document.querySelectorAll('link[data-kefin-optional-include="true"]');
|
|
optionalIncludeLinks.forEach(link => {
|
|
if (link.getAttribute('data-skin') === skinName) {
|
|
if (link.parentNode) {
|
|
link.parentNode.removeChild(link);
|
|
}
|
|
const url = link.href;
|
|
loadedOptionalIncludesUrls.delete(url);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unload all optional includes CSS (both global and skin-specific)
|
|
*/
|
|
function unloadAllOptionalIncludes() {
|
|
const optionalIncludeLinks = document.querySelectorAll('link[data-kefin-optional-include="true"]');
|
|
optionalIncludeLinks.forEach(link => {
|
|
if (link.parentNode) {
|
|
link.parentNode.removeChild(link);
|
|
}
|
|
const url = link.href;
|
|
loadedOptionalIncludesUrls.delete(url);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load CSS for an optional include
|
|
* @param {string} url - The CSS URL
|
|
* @param {string} skinName - The skin name
|
|
*/
|
|
function loadOptionalIncludeCSS(url, skinName) {
|
|
// Check if this CSS is already loaded in the DOM
|
|
const existingLink = document.querySelector(`link[href="${url}"]`);
|
|
if (existingLink) {
|
|
LOG(`Optional include CSS already loaded: ${url}`);
|
|
return;
|
|
}
|
|
|
|
if (!url || url === '') {
|
|
LOG(`Invalid optional include CSS URL: ${url}, skipping`);
|
|
return;
|
|
}
|
|
|
|
const link = document.createElement('link');
|
|
link.id = `cssOptionalInclude-${Date.now()}-${Math.random()}`;
|
|
link.rel = 'stylesheet';
|
|
link.type = 'text/css';
|
|
link.href = url;
|
|
link.setAttribute('data-kefin-optional-include', 'true');
|
|
link.setAttribute('data-skin', skinName);
|
|
|
|
// Insert after the last skin link
|
|
const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]');
|
|
let insertAfter = null;
|
|
|
|
if (skinLinks.length > 0) {
|
|
insertAfter = skinLinks[skinLinks.length - 1];
|
|
} else {
|
|
insertAfter = document.getElementById('cssTheme');
|
|
}
|
|
|
|
if (insertAfter && insertAfter.parentNode) {
|
|
insertAfter.parentNode.insertBefore(link, insertAfter.nextSibling);
|
|
} else {
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
loadedOptionalIncludesUrls.add(url);
|
|
LOG(`Loaded optional include CSS: ${url}`);
|
|
}
|
|
|
|
// Tooltip management
|
|
let currentTooltip = null;
|
|
|
|
// Store unregister functions for handlers
|
|
let unregisterDisplayPreferencesHandler = 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;
|
|
}
|
|
|
|
/**
|
|
* Inject one-off skin manager UI styles.
|
|
*/
|
|
function ensureSkinManagerStyles() {
|
|
if (document.getElementById('kefin-skin-manager-styles')) return;
|
|
const style = document.createElement('style');
|
|
style.id = 'kefin-skin-manager-styles';
|
|
style.textContent = `
|
|
#displayPreferencesPage:has(.MuiStack-root) .fldSkin {
|
|
margin-top: 2em;
|
|
margin-bottom: -1.8em;
|
|
}
|
|
`;
|
|
(document.head || document.documentElement).appendChild(style);
|
|
}
|
|
|
|
/**
|
|
* Initialize the skin manager with verification and retry logic
|
|
*/
|
|
async function initialize(retryCount = 0) {
|
|
const MAX_RETRIES = 10;
|
|
|
|
if (!window.KefinTweaksUtils) {
|
|
ERR('KefinTweaksUtils not available - skin manager cannot initialize');
|
|
return;
|
|
}
|
|
|
|
ensureSkinManagerStyles();
|
|
|
|
await window.KefinTweaksUtils.waitForApiClient();
|
|
|
|
// 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 and Appearance header control
|
|
*/
|
|
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']
|
|
});
|
|
|
|
registerAppearanceMenuLink();
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Register Appearance in header-right via custom menu links
|
|
*/
|
|
async function registerAppearanceMenuLink() {
|
|
if (!window.KefinTweaksUtils?.addCustomMenuLink) {
|
|
WARN('addCustomMenuLink not available; Appearance button not registered');
|
|
return;
|
|
}
|
|
|
|
window.KefinTweaksSkinManager = window.KefinTweaksSkinManager || {};
|
|
window.KefinTweaksSkinManager.toggleAppearancePopover = toggleAppearancePopover;
|
|
|
|
try {
|
|
await window.KefinTweaksUtils.addCustomMenuLink(
|
|
'Appearance',
|
|
'palette',
|
|
'',
|
|
false,
|
|
{
|
|
sideMenu: false,
|
|
topNavigation: 'right',
|
|
userMenu: false,
|
|
action: 'KefinTweaksSkinManager.toggleAppearancePopover',
|
|
order: 1
|
|
}
|
|
);
|
|
LOG('Appearance menu link registered (topNavigation: right)');
|
|
} catch (err) {
|
|
ERR('Failed to register Appearance menu link:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle the appearance popover
|
|
*/
|
|
function toggleAppearancePopover(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
// Remove existing popover if it exists
|
|
const existingPopover = document.querySelector('.kefin-appearance-popover');
|
|
if (existingPopover) {
|
|
existingPopover.remove();
|
|
return;
|
|
}
|
|
|
|
const anchor = (event && (
|
|
event.currentTarget
|
|
|| event.target?.closest?.('[data-kefin-custom-menu-top-right], .headerAppearanceButton')
|
|
|| event.target
|
|
)) || document.querySelector('[data-kefin-custom-menu-top-right], .headerAppearanceButton');
|
|
|
|
// Create the popover
|
|
createAppearancePopover(anchor);
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
if (typeof window.ModalSystem?.applyOpenAnimation === 'function') {
|
|
window.ModalSystem.applyOpenAnimation(dialog);
|
|
} else {
|
|
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();
|
|
updateSkinAuthorLabel();
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Update author display in popover if it exists
|
|
updateSkinAuthorLabel();
|
|
|
|
// Update author display in display preferences if it exists
|
|
updateDisplayPreferencesSkinLabel();
|
|
}
|
|
|
|
/**
|
|
* Update the author info section in the display preferences page
|
|
*/
|
|
function updateDisplayPreferencesSkinLabel() {
|
|
const labelElement = document.querySelector('label[for="selectSkin"]');
|
|
if (!labelElement) {
|
|
return;
|
|
}
|
|
|
|
const skinSelect = document.getElementById('selectSkin');
|
|
const selectedSkinName = skinSelect?.value || localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
|
|
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
|
|
|
|
// Find existing gear icon and remove it
|
|
const existingGear = labelElement.querySelector('.material-icons[data-optional-includes-gear]');
|
|
if (existingGear) {
|
|
existingGear.remove();
|
|
}
|
|
|
|
// Add gear icon for all skins (global optional includes apply to all)
|
|
if (selectedSkin) {
|
|
const gearIcon = document.createElement('span');
|
|
gearIcon.className = 'material-icons';
|
|
gearIcon.setAttribute('data-optional-includes-gear', 'true');
|
|
gearIcon.textContent = 'settings';
|
|
gearIcon.style.cssText = 'font-size: 1em; margin-left: 0.5em; cursor: pointer; opacity: 0.7; transition: opacity 0.2s; vertical-align: middle;';
|
|
gearIcon.title = 'Configure additional skin options';
|
|
gearIcon.addEventListener('mouseenter', () => {
|
|
gearIcon.style.opacity = '1';
|
|
});
|
|
gearIcon.addEventListener('mouseleave', () => {
|
|
gearIcon.style.opacity = '0.7';
|
|
});
|
|
gearIcon.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
openOptionalIncludesModal(selectedSkin);
|
|
});
|
|
labelElement.appendChild(gearIcon);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to determine the GitHub repository URL for a skin
|
|
* @param {Object} skin
|
|
* @returns {string|null}
|
|
*/
|
|
function getSkinRepositoryUrl(skin) {
|
|
if (!skin) return null;
|
|
|
|
if (skin.github) return skin.github;
|
|
if (skin.repo) return skin.repo;
|
|
if (skin.repository) return skin.repository;
|
|
|
|
if (skin.url) {
|
|
const candidateUrls = Array.isArray(skin.url) ? skin.url : [skin.url];
|
|
for (const entry of candidateUrls) {
|
|
if (typeof entry === 'string') {
|
|
const repoUrl = extractRepoFromUrl(entry);
|
|
if (repoUrl) return repoUrl;
|
|
} else if (entry && Array.isArray(entry.urls)) {
|
|
for (const nestedUrl of entry.urls) {
|
|
const repoUrl = extractRepoFromUrl(nestedUrl);
|
|
if (repoUrl) return repoUrl;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractRepoFromUrl(url) {
|
|
if (typeof url !== 'string') return null;
|
|
const match = url.match(/https:\/\/cdn\.jsdelivr\.net\/gh\/([^\/]+)\/([^\/@]+)/i);
|
|
if (match) {
|
|
const owner = match[1];
|
|
const repo = match[2];
|
|
return `https://github.com/${owner}/${repo}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract author name from GitHub URL
|
|
* @param {string} url - The URL to extract author from
|
|
* @returns {string|null} Author name or null
|
|
*/
|
|
function extractAuthorFromUrl(url) {
|
|
if (!url || typeof url !== 'string') return null;
|
|
const match = url.match(/https:\/\/cdn\.jsdelivr\.net\/gh\/([^\/]+)\//i);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Open modal for configuring optional includes for a skin
|
|
* Shows both global and skin-specific optional includes
|
|
* @param {Object} skin - The skin configuration object
|
|
*/
|
|
function openOptionalIncludesModal(skin) {
|
|
if (!skin) {
|
|
return;
|
|
}
|
|
|
|
if (!window.ModalSystem) {
|
|
ERR('ModalSystem not available');
|
|
return;
|
|
}
|
|
|
|
// Get merged global optional includes (defaults + admin config)
|
|
const mergedGlobalOptionalIncludes = getMergedGlobalOptionalIncludes();
|
|
|
|
// Get enabled state for global includes (using key-based checks)
|
|
const enabledGlobalIncludes = mergedGlobalOptionalIncludes.map(include => {
|
|
const author = extractAuthorFromUrl(include.url);
|
|
const key = generateOptionalIncludeKey('global', author, include.url);
|
|
const enabled = isOptionalIncludeEnabled(key, include.enabled || false);
|
|
|
|
return {
|
|
...include,
|
|
enabled: enabled,
|
|
key: key
|
|
};
|
|
});
|
|
|
|
// Get skin-specific optional includes
|
|
const skinOptionalIncludes = skin.optionalIncludes || [];
|
|
|
|
// Get current enabled state for skin-specific includes
|
|
const enabledSkinIncludes = getOptionalIncludesEnabledState(skin.name, skinOptionalIncludes);
|
|
|
|
const modalId = `optional-includes-${skin.name.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
|
|
|
// Create content
|
|
const content = document.createElement('div');
|
|
|
|
// Description
|
|
const description = document.createElement('div');
|
|
description.className = 'listItemBodyText secondary';
|
|
description.textContent = 'Enable or disable additional CSS modules. Global options apply to all skins:';
|
|
description.style.cssText = 'margin-bottom: 1em; font-size: 0.9em; opacity: 0.8;';
|
|
content.appendChild(description);
|
|
|
|
// Create checkboxes container
|
|
const checkboxesContainer = document.createElement('div');
|
|
checkboxesContainer.style.cssText = 'display: flex; flex-direction: column; gap: 0.75em;';
|
|
|
|
// Check if there are any optional includes at all
|
|
if (enabledGlobalIncludes.length === 0 && enabledSkinIncludes.length === 0) {
|
|
const noOptionsMessage = document.createElement('div');
|
|
noOptionsMessage.className = 'listItemBodyText secondary';
|
|
noOptionsMessage.textContent = 'No additional options available for this skin.';
|
|
noOptionsMessage.style.cssText = 'text-align: center; padding: 1em; opacity: 0.6;';
|
|
checkboxesContainer.appendChild(noOptionsMessage);
|
|
}
|
|
|
|
// Add global optional includes first
|
|
if (enabledGlobalIncludes.length > 0) {
|
|
const globalSection = document.createElement('div');
|
|
globalSection.style.cssText = 'margin-bottom: 1em;';
|
|
|
|
const globalHeader = document.createElement('div');
|
|
globalHeader.className = 'listItemBodyText';
|
|
globalHeader.textContent = 'Global Options';
|
|
globalHeader.style.cssText = 'font-weight: 500; margin-bottom: 0.5em; font-size: 0.95em;';
|
|
globalSection.appendChild(globalHeader);
|
|
|
|
enabledGlobalIncludes.forEach((include, index) => {
|
|
const checkboxItem = document.createElement('div');
|
|
checkboxItem.style.cssText = 'display: flex; flex-direction: column; gap: 0.25em; padding: 0.5em; border-radius: 4px; background: rgba(255, 255, 255, 0.05);';
|
|
|
|
const checkboxRow = document.createElement('div');
|
|
checkboxRow.style.cssText = 'display: flex; align-items: center; gap: 0.75em;';
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.id = `globalOptionalInclude_${index}`;
|
|
checkbox.checked = include.enabled;
|
|
checkbox.setAttribute('data-include-name', include.name);
|
|
checkbox.setAttribute('data-include-url', include.url);
|
|
checkbox.setAttribute('data-is-global', 'true');
|
|
checkbox.style.cssText = 'width: 18px; height: 18px; cursor: pointer;';
|
|
|
|
const label = document.createElement('label');
|
|
label.htmlFor = checkbox.id;
|
|
label.style.cssText = 'flex: 1; cursor: pointer; user-select: none;';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.textContent = include.name;
|
|
nameSpan.style.cssText = 'font-weight: 500;';
|
|
label.appendChild(nameSpan);
|
|
|
|
// Add author name if available
|
|
const author = extractAuthorFromUrl(include.url);
|
|
if (author) {
|
|
const authorSpan = document.createElement('span');
|
|
authorSpan.textContent = ' by ';
|
|
authorSpan.style.cssText = 'font-size: 0.85em; opacity: 0.7; margin-left: 0.25em;';
|
|
|
|
const authorLink = document.createElement('a');
|
|
authorLink.href = `https://github.com/${author}`;
|
|
authorLink.target = '_blank';
|
|
authorLink.rel = 'noopener noreferrer';
|
|
authorLink.textContent = author;
|
|
authorLink.style.cssText = 'color: inherit; text-decoration: underline; opacity: 0.8; transition: opacity 0.2s;';
|
|
authorLink.addEventListener('mouseenter', () => {
|
|
authorLink.style.opacity = '1';
|
|
});
|
|
authorLink.addEventListener('mouseleave', () => {
|
|
authorLink.style.opacity = '0.8';
|
|
});
|
|
|
|
authorSpan.appendChild(authorLink);
|
|
label.appendChild(authorSpan);
|
|
}
|
|
|
|
checkboxRow.appendChild(checkbox);
|
|
checkboxRow.appendChild(label);
|
|
checkboxItem.appendChild(checkboxRow);
|
|
globalSection.appendChild(checkboxItem);
|
|
});
|
|
|
|
checkboxesContainer.appendChild(globalSection);
|
|
}
|
|
|
|
// Add skin-specific optional includes
|
|
if (enabledSkinIncludes.length > 0) {
|
|
const skinSection = document.createElement('div');
|
|
|
|
const skinHeader = document.createElement('div');
|
|
skinHeader.className = 'listItemBodyText';
|
|
skinHeader.textContent = `${skin.name} Skin Options`;
|
|
skinHeader.style.cssText = 'font-weight: 500; margin-bottom: 0.5em; font-size: 0.95em;';
|
|
skinSection.appendChild(skinHeader);
|
|
|
|
enabledSkinIncludes.forEach((include, index) => {
|
|
const checkboxItem = document.createElement('div');
|
|
checkboxItem.style.cssText = 'display: flex; flex-direction: column; gap: 0.25em; padding: 0.5em; border-radius: 4px; background: rgba(255, 255, 255, 0.05);';
|
|
|
|
const checkboxRow = document.createElement('div');
|
|
checkboxRow.style.cssText = 'display: flex; align-items: center; gap: 0.75em;';
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.id = `skinOptionalInclude_${skin.name}_${index}`;
|
|
checkbox.checked = include.enabled;
|
|
checkbox.setAttribute('data-include-name', include.name);
|
|
checkbox.setAttribute('data-include-url', include.url);
|
|
checkbox.setAttribute('data-is-global', 'false');
|
|
checkbox.style.cssText = 'width: 18px; height: 18px; cursor: pointer;';
|
|
|
|
const label = document.createElement('label');
|
|
label.htmlFor = checkbox.id;
|
|
label.style.cssText = 'flex: 1; cursor: pointer; user-select: none;';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.textContent = include.name;
|
|
nameSpan.style.cssText = 'font-weight: 500;';
|
|
label.appendChild(nameSpan);
|
|
|
|
// Add author name if available
|
|
const author = extractAuthorFromUrl(include.url);
|
|
if (author) {
|
|
const authorSpan = document.createElement('span');
|
|
authorSpan.textContent = ' by ';
|
|
authorSpan.style.cssText = 'font-size: 0.85em; opacity: 0.7; margin-left: 0.25em;';
|
|
|
|
const authorLink = document.createElement('a');
|
|
authorLink.href = `https://github.com/${author}`;
|
|
authorLink.target = '_blank';
|
|
authorLink.rel = 'noopener noreferrer';
|
|
authorLink.textContent = author;
|
|
authorLink.style.cssText = 'color: inherit; text-decoration: underline; opacity: 0.8; transition: opacity 0.2s;';
|
|
authorLink.addEventListener('mouseenter', () => {
|
|
authorLink.style.opacity = '1';
|
|
});
|
|
authorLink.addEventListener('mouseleave', () => {
|
|
authorLink.style.opacity = '0.8';
|
|
});
|
|
|
|
authorSpan.appendChild(authorLink);
|
|
label.appendChild(authorSpan);
|
|
}
|
|
|
|
checkboxRow.appendChild(checkbox);
|
|
checkboxRow.appendChild(label);
|
|
checkboxItem.appendChild(checkboxRow);
|
|
skinSection.appendChild(checkboxItem);
|
|
});
|
|
|
|
// Prepend child instead so that it appears first
|
|
checkboxesContainer.prepend(skinSection);
|
|
}
|
|
|
|
content.appendChild(checkboxesContainer);
|
|
|
|
// Create modal using ModalSystem (no footer - auto-save on change)
|
|
const modal = window.ModalSystem.create({
|
|
id: modalId,
|
|
title: `Optional CSS Modules`,
|
|
content: content,
|
|
footer: null,
|
|
closeOnBackdrop: true,
|
|
closeOnEscape: true,
|
|
onOpen: (modalInstance) => {
|
|
// Focus first checkbox
|
|
const firstCheckbox = modalInstance.dialog.querySelector('input[type="checkbox"]');
|
|
if (firstCheckbox) {
|
|
setTimeout(() => firstCheckbox.focus(), 100);
|
|
}
|
|
|
|
// Add change event listeners to all checkboxes for auto-save and real-time updates
|
|
const allCheckboxes = modalInstance.dialog.querySelectorAll('input[type="checkbox"]');
|
|
allCheckboxes.forEach(checkbox => {
|
|
checkbox.addEventListener('change', () => {
|
|
const isGlobal = checkbox.getAttribute('data-is-global') === 'true';
|
|
|
|
if (isGlobal) {
|
|
// Handle global optional includes - save to user localStorage (highest priority)
|
|
const includeUrl = checkbox.getAttribute('data-include-url');
|
|
const author = extractAuthorFromUrl(includeUrl);
|
|
const key = generateOptionalIncludeKey('global', author, includeUrl);
|
|
const enabled = checkbox.checked;
|
|
|
|
// Save only this specific key to user config
|
|
saveOptionalIncludeState(key, enabled);
|
|
|
|
// Apply changes immediately
|
|
const currentSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
|
|
const currentSkin = SKINS_CONFIG.find(s => s.name === currentSkinName);
|
|
|
|
if (currentSkin) {
|
|
// Unload and reload global optional includes
|
|
unloadOptionalIncludesForSkin('global');
|
|
// Reload based on updated config (will reload both global and skin-specific)
|
|
loadOptionalIncludes(currentSkin);
|
|
}
|
|
|
|
LOG(`Updated global optional include: ${key}`);
|
|
} else {
|
|
// Handle skin-specific optional includes
|
|
const includeUrl = checkbox.getAttribute('data-include-url');
|
|
const author = extractAuthorFromUrl(includeUrl);
|
|
const key = generateOptionalIncludeKey(skin.name, author, includeUrl);
|
|
const enabled = checkbox.checked;
|
|
|
|
// Save only this specific key (Option 1: only save what user toggled)
|
|
saveOptionalIncludeState(key, enabled);
|
|
|
|
// Check if this is the currently active skin
|
|
const currentSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
|
|
if (currentSkinName === skin.name) {
|
|
// Reload skin-specific optional includes based on updated userConfig
|
|
// First, unload current skin-specific optional includes
|
|
unloadOptionalIncludesForSkin(skin.name);
|
|
|
|
// Then reload based on userConfig (will reload both global and skin-specific)
|
|
loadOptionalIncludes(skin);
|
|
|
|
LOG(`Updated optional includes for ${skin.name} in real-time`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update the author info section within the popover
|
|
*/
|
|
function updateSkinAuthorLabel() {
|
|
const labelElement = document.querySelector('label[for="selectSkinPopover"]');
|
|
if (!labelElement) {
|
|
return;
|
|
}
|
|
|
|
const skinSelect = document.getElementById('selectSkinPopover');
|
|
const selectedSkinName = skinSelect?.value || localStorage.getItem(STORAGE_KEY) || getDefaultSkinName();
|
|
const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName);
|
|
|
|
labelElement.innerHTML = '';
|
|
if (!selectedSkin || (!selectedSkin.author && !getSkinRepositoryUrl(selectedSkin))) {
|
|
labelElement.textContent = 'Skin';
|
|
return;
|
|
}
|
|
|
|
const prefix = document.createElement('span');
|
|
prefix.textContent = 'Skin by ';
|
|
labelElement.appendChild(prefix);
|
|
|
|
const authorName = selectedSkin.author || 'View Repository';
|
|
const repoUrl = getSkinRepositoryUrl(selectedSkin);
|
|
|
|
if (repoUrl) {
|
|
const link = document.createElement('a');
|
|
link.href = repoUrl;
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
link.textContent = authorName;
|
|
link.style.color = 'var(--accent, #4a9eff)';
|
|
link.style.textDecoration = 'none';
|
|
link.style.marginLeft = '2px';
|
|
labelElement.appendChild(link);
|
|
} else {
|
|
const authorSpan = document.createElement('span');
|
|
authorSpan.textContent = authorName;
|
|
labelElement.appendChild(authorSpan);
|
|
}
|
|
|
|
// Add gear icon for all skins (global optional includes apply to all)
|
|
if (selectedSkin) {
|
|
const gearIcon = document.createElement('span');
|
|
gearIcon.className = 'material-icons';
|
|
gearIcon.textContent = 'settings';
|
|
gearIcon.style.fontSize = '1em';
|
|
gearIcon.style.marginLeft = '0.5em';
|
|
gearIcon.style.cursor = 'pointer';
|
|
gearIcon.style.opacity = '0.7';
|
|
gearIcon.style.transition = 'opacity 0.2s';
|
|
gearIcon.title = 'Configure additional skin options';
|
|
gearIcon.addEventListener('mouseenter', () => {
|
|
gearIcon.style.opacity = '1';
|
|
});
|
|
gearIcon.addEventListener('mouseleave', () => {
|
|
gearIcon.style.opacity = '0.7';
|
|
});
|
|
gearIcon.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
openOptionalIncludesModal(selectedSkin);
|
|
});
|
|
labelElement.appendChild(gearIcon);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, [data-kefin-custom-menu-top-right]')) {
|
|
|
|
// 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;
|
|
}
|
|
|
|
// .fldDisplayMode or #display-settings-layout-description
|
|
const displayModeField = form.querySelector('.fldDisplayMode') || form.querySelector('#display-settings-layout-description');
|
|
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;
|
|
}
|
|
|
|
// Update label with gear icon
|
|
updateDisplayPreferencesSkinLabel();
|
|
|
|
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;
|
|
}
|
|
|
|
requestAnimationFrame(async () => {
|
|
removeAllSkinCSS();
|
|
// Unload skin-specific optional includes for the current skin (if switching)
|
|
if (currentSkinName) {
|
|
unloadOptionalIncludesForSkin(currentSkinName);
|
|
}
|
|
|
|
const cssUrls = await getSkinUrlsForCurrentVersion(skin);
|
|
|
|
if (cssUrls && cssUrls.length > 0) {
|
|
LOG(`Loading skin with ${cssUrls.length} CSS files: ${skin.name}`);
|
|
cssUrls.forEach(url => {
|
|
if (url) { // Skip null/empty URLs
|
|
loadSkinCSS(url, skin.name);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Load optional includes for this skin
|
|
loadOptionalIncludes(skin);
|
|
|
|
window.cardBuilder?.invalidateCardRadiusCache?.();
|
|
requestAnimationFrame(() => window.cardBuilder?.ensureCardRadiusCssVar?.());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
})();
|