From 38e68f1840988b2418a93ed2cec29702f3f92def Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Mon, 1 Dec 2025 04:26:59 -0500 Subject: [PATCH] Update Skin support: Improve handling for custom skins overriding default skins. Improve default skin handling. --- README.md | 4 +- injector.js | 15 +- kefinTweaks-default-config.js | 404 +---- scripts/configuration.js | 448 +++++- ...Config.js => skinConfig-0.3.5-defaults.js} | 45 +- scripts/skinConfig.js | 798 ++++++++++ scripts/skinManager.js | 1327 +++++++++++++++-- skins/optional/ElegantFin/cardHoverEffect.css | 3 + .../optional/ElegantFin/centerPlayButton.css | 3 + .../ElegantFin/extraOverlayButtons.css | 3 + .../ElegantFin/libraryLabelVisibility.css | 3 + skins/optional/ElegantFin/solidAppBar.css | 3 + 12 files changed, 2476 insertions(+), 580 deletions(-) rename scripts/{userConfig.js => skinConfig-0.3.5-defaults.js} (89%) create mode 100644 scripts/skinConfig.js create mode 100644 skins/optional/ElegantFin/cardHoverEffect.css create mode 100644 skins/optional/ElegantFin/centerPlayButton.css create mode 100644 skins/optional/ElegantFin/extraOverlayButtons.css create mode 100644 skins/optional/ElegantFin/libraryLabelVisibility.css create mode 100644 skins/optional/ElegantFin/solidAppBar.css diff --git a/README.md b/README.md index c2c9059..e74edc0 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,8 @@ The features in KefinTweaks which use local data caching are listed below, along - **Series Progress and Movie History** [24h]: These are both more expensive operations, especially if you have watched a very high number of Movies or Shows. It's not practical to fetch this data on demand so we cache it to improve UX. - Collections on Details Page - - **Collections** [24h]: - Sadly I know no way to retreive a list of Collections that an Item is a child of from the API directly. The Ancestors endpoint only returns the physical ancestors of an item. In order to be able to populate the "Included In" section, we fetch the children from every Collection in your library and add the Item ID of each child to the cache. + - **Collections** [1h]: + These items are only cached after they are fetched on first page load. Sadly I know no way to retreive a list of Collections that an Item is a child of from the API directly. The Ancestors endpoint only returns the physical ancestors of an item. In order to be able to populate the "Included In" section, we fetch the children from every Collection in your library and add the Item ID of each child to the cache.
## Community Request Progress diff --git a/injector.js b/injector.js index b4cfe19..bf2cccc 100644 --- a/injector.js +++ b/injector.js @@ -50,17 +50,24 @@ description: 'Common utilities for page view management and MutationObserver conversion' }, { - name: 'userConfig', - script: 'userConfig.js', + name: 'skinConfigLegacyDefaults', + script: 'skinConfig-0.3.5-defaults.js', css: null, dependencies: [], - description: 'User-specific configuration for skins and other settings' + description: 'Legacy skin defaults (v0.3.5) for duplicate detection' + }, + { + name: 'skinConfig', + script: 'skinConfig.js', + css: null, + dependencies: [], + description: 'Default skin configuration for KefinTweaks' }, { name: 'skinManager', script: 'skinManager.js', css: 'defaultSkin.css', - dependencies: ['utils', 'userConfig'], + dependencies: ['utils', 'skinConfigLegacyDefaults', 'skinConfig'], priority: true, // Load immediately after dependencies to reduce UI disruption description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading' }, diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js index cf344a4..0aef899 100644 --- a/kefinTweaks-default-config.js +++ b/kefinTweaks-default-config.js @@ -371,407 +371,9 @@ window.KefinTweaksDefaultConfig = { "search": { "enableJellyseerr": false }, - "skins": [ - { - "name": "Default", - "author": "Jellyfin", - "url": null, - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "ElegantFin", - "author": "lscambo13", - "url": [ - { - "majorServerVersions": [10, 11], - "urls": [ - "https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css", - "https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/assets/add-ons/media-bar-plugin-support-nightly.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/elegantKefin.css" - ] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "NeutralFin", - "author": "KartoffelChipss", - "url": [ - { - "majorServerVersions": [10, 11], - "urls": [ - "https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@latest/theme/neutralfin-minified.css", - "https://cdn.jsdelivr.net/gh/KartoffelChipss/Jellyfin-Lucide@main/theme/jellyfin-lucide.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/neutralfin-kefin.css" - ] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "Jellypane", - "author": "tedhinklater", - "url": [ - { - "majorServerVersions": [10], - "urls": ["https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/Jellypane.css"] - }, - { - "majorServerVersions": [11], - "urls": ["https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/Jellypane10.11.css"] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "Jellyfish", - "author": "n00bcodr", - "url": [ - { - "majorServerVersions": [10], - "urls": ["https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css"] - }, - { - "majorServerVersions": [11], - "urls": [ - "https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css", - "https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/10.11_fixes.css" - ] - } - ], - "colorSchemes": [ - { - "name": "Default", - "url": "" - }, - { - "name": "Banana", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/banana.css" - }, - { - "name": "Coal", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coal.css" - }, - { - "name": "Coral", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coral.css" - }, - { - "name": "Grass", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/grass.css" - }, - { - "name": "Jellyblue", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyblue.css" - }, - { - "name": "Jellyflix", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyflix.css" - }, - { - "name": "Lavender", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/lavender.css" - }, - { - "name": "Mint", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/mint.css" - }, - { - "name": "Ocean", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/ocean.css" - }, - { - "name": "Peach", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/peach.css" - }, - { - "name": "Watermelon", - "url": "https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/watermelon.css" - } - ], - "enabled": true, - "hidden": true - }, - { - "name": "Glassmorphism", - "author": "alexyle", - "url": [ - { - "majorServerVersions": [10, 11], - "urls": ["https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css"] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "Zombie", - "author": "MakD", - "url": [ - { - "majorServerVersions": [10], - "urls": ["https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/zombie_revived.css"] - } - ], - "colorSchemes": [ - { - "name": "Default", - "url": "" - }, - { - "name": "Amazon Prime", - "url": "https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/amazon-prime.css" - }, - { - "name": "Apple TV", - "url": "https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/apple-tv.css" - }, - { - "name": "Disney", - "url": "https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/disney.css" - }, - { - "name": "HBO Max", - "url": "https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hbo-max.css" - }, - { - "name": "Hulu", - "url": "https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hulu.css" - }, - { - "name": "Netflix", - "url": "https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/netflix.css" - }, - { - "name": "YouTube", - "url": "https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/youtube.css" - } - ], - "enabled": true, - "hidden": true - }, - { - "name": "Jamfin", - "author": "JamsRepos", - "url": [ - { - "majorServerVersions": [10], - "urls": [ - "https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/jamfin-kefin.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/jamfin-kefin-10.css" - ] - }, - { - "majorServerVersions": [11], - "urls": [ - "https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/jamfin-kefin.css" - ] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "Scyfin", - "author": "loof2736", - "url": [ - { - "majorServerVersions": [10], - "urls": [ - "https://cdn.jsdelivr.net/gh/loof2736/scyfin@v1.4.17/CSS/scyfin-theme.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/scyfin-kefin.css" - ] - }, - { - "majorServerVersions": [11], - "urls": [ - "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/scyfin-kefin.css" - ] - } - ], - "colorSchemes": [ - { - "name": "Default", - "url": "" - }, - { - "name": "Seafoam", - "url": "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css" - }, - { - "name": "Coral", - "url": "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css" - }, - { - "name": "Snow", - "url": "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css" - }, - { - "name": "OLED", - "url": "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-oled.css" - } - ], - "enabled": true, - "hidden": true - }, - { - "name": "Catppuccin", - "author": "Catppuccin", - "url": [ - { - "majorServerVersions": [10, 11], - "urls": ["https://jellyfin.catppuccin.com/theme.css"] - } - ], - "colorSchemes": [ - { - "name": "Latte", - "url": "https://jellyfin.catppuccin.com/catppuccin-latte.css" - }, - { - "name": "Frappé", - "url": "https://jellyfin.catppuccin.com/catppuccin-frappe.css" - }, - { - "name": "Macchiato", - "url": "https://jellyfin.catppuccin.com/catppuccin-macchiato.css" - }, - { - "name": "Mocha", - "url": "https://jellyfin.catppuccin.com/catppuccin-mocha.css" - } - ], - "enabled": true, - "hidden": true - }, - { - "name": "Flow", - "author": "LitCastVlog", - "url": [ - { - "majorServerVersions": [10], - "urls": [ - "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/flow-kefin.css" - ] - }, - { - "majorServerVersions": [11], - "urls": [ - "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css", - "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Compatibility.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/flow-kefin.css" - ] - } - ], - "colorSchemes": [ - { - "name": "Default", - "url": "" - }, - { - "name": "Orange", - "url": "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Orange.css" - }, - { - "name": "Pink", - "url": "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Pink.css" - }, - { - "name": "Rainbow", - "url": "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Rainbow.css" - }, - { - "name": "Red", - "url": "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Red.css" - }, - { - "name": "White", - "url": "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-White.css" - }, - { - "name": "Blue", - "url": "https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Blue.css" - }, - { - "name": "Seafoam", - "url": "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css" - }, - { - "name": "Coral", - "url": "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css" - }, - { - "name": "Snow", - "url": "https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css" - } - ], - "enabled": true, - "hidden": true - }, - { - "name": "Monochromic", - "author": "CTalvio", - "url": [ - { - "majorServerVersions": [10, 11], - "urls": [ - "https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/monochromic_preset.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/chromic-kefin.css" - ] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "Kaleidochromic", - "author": "CTalvio", - "url": [ - { - "majorServerVersions": [10, 11], - "urls": [ - "https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/kaleidochromic_preset.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/chromic-kefin.css" - ] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - }, - { - "name": "Novachromic", - "author": "CTalvio", - "url": [ - { - "majorServerVersions": [10, 11], - "urls": [ - "https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/novachromic_preset.css", - "https://ranaldsgift.github.io/KefinTweaks/skins/chromic-kefin.css" - ] - } - ], - "colorSchemes": [], - "enabled": true, - "hidden": true - } - ], + "skins": [], "defaultSkin": null, "themes": [], - "customMenuLinks": [] + "customMenuLinks": [], + "optionalIncludes": [] }; diff --git a/scripts/configuration.js b/scripts/configuration.js index 8fd6584..26c52b1 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -323,6 +323,15 @@ transition: transform 0.2s ease; } + /* Skins JSON collapsible styling */ + #${MODAL_ID} details.listItem[open] summary .material-icons { + transform: rotate(90deg); + } + + #${MODAL_ID} details.listItem summary .material-icons { + transition: transform 0.2s ease; + } + /* Responsive design */ @media (max-width: 768px) { #${MODAL_ID} .modal-content-wrapper { @@ -369,11 +378,15 @@ const exclusiveElsewhere = config.exclusiveElsewhere || {}; const search = config.search || {}; const customMenuLinks = config.customMenuLinks || []; - const skins = config.skins || []; + // Use merged skin config from skinManager (includes both defaults and admin skins) + const skins = window.KefinTweaksSkinConfig || []; const themes = config.themes || []; // Get all skin names for autocomplete const allSkinNames = skins.map(skin => skin.name).filter(Boolean); + + // Get skin source info for visual distinctions + const skinSources = window.KefinTweaksSkinManager?.getAllSkinSources?.() || {}; const isEnabled = window.KefinTweaksConfigEnabled !== false; @@ -448,6 +461,7 @@
Default Skin
Select a default skin for all users
@@ -461,18 +475,53 @@ KefinTweaks has not contributed to the creation of any of the skins below and is only attempting to facilitate their accessibility.
- ${buildSkinToggles(skins)} + ${buildSkinToggles(skins, skinSources)}
-
+ ${(() => { + // Check if there are any overridden default skins + const hasOverriddenSkins = Object.values(skinSources).some(source => source === 'overridden'); + if (hasOverriddenSkins) { + return ` +
+
+
Some default skins have been overridden by custom configurations. Click the button below to remove custom skins that override defaults.
+ +
+
+ `; + } + return ''; + })()} +
-
Skins JSON
-
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. Hidden skins (included automatically by KefinTweaks) are not shown here.
- -
- View Example Format -
[
+                        

Optional CSS Modules

+
Configure default enabled/disabled state for optional CSS modules. These settings will be used when users haven't specified their own preferences.
+
Select Skin
+ +
+ ${buildOptionalIncludesEditor('global', config, skins)} +
+
+
+
+ + chevron_right + Skins JSON + +
+
+
Add additional skins that will be available to all users. Each skin can have multiple CSS files for different server versions. You can override the Default skins from KefinTweaks by specifying a custom configuration with that Skin Name in the configuration JSON below. Any skins that appear in this JSON and are named the same as the KefinTweaks default skins will override the default functionality. Default skins are not shown here as they are managed separately.
+ +
+ View Example Format +
[
   {
     "name": "Custom Skin",
     "author": "username",
@@ -496,9 +545,10 @@
     "colorSchemes": []
   }
 ]
-
+
+
- +
@@ -594,22 +644,221 @@ } // Build skin toggle switches - function buildSkinToggles(skins) { + /** + * Build dropdown options for skins that have optional includes + * @param {Array} skins - Array of skin configurations + * @returns {string} HTML string for dropdown options + */ + function buildOptionalIncludesSkinOptions(skins) { + const skinsWithOptionalIncludes = skins.filter(skin => + skin.optionalIncludes && skin.optionalIncludes.length > 0 + ); + + return skinsWithOptionalIncludes.map(skin => + `` + ).join(''); + } + + /** + * Extract author from URL (for display purposes) + * @param {string} url - The URL + * @returns {string} Author name or empty string + */ + function extractAuthorFromUrl(url) { + if (!url) return ''; + try { + // Try to extract GitHub username from jsdelivr URLs + const jsdelivrMatch = url.match(/cdn\.jsdelivr\.net\/gh\/([^\/]+)\//); + if (jsdelivrMatch) { + return jsdelivrMatch[1]; + } + } catch (e) { + // Ignore errors + } + return ''; + } + + /** + * Generate optional include key (same format as skinManager) + * @param {string} skinName - The skin name or "global" + * @param {string} author - The author name + * @param {string} url - The URL + * @returns {string} The generated key + */ + function generateOptionalIncludeKey(skinName, author, url) { + const normalizedSkinName = skinName === 'global' ? 'global' : skinName.replace(/\s+/g, '_'); + const filename = url ? url.split('?')[0].split('#')[0].split('/').pop() : ''; + const normalizedAuthor = (author || '').replace(/\s+/g, '_'); + return `${normalizedSkinName}-${normalizedAuthor}-${filename}`; + } + + /** + * Extract filename from URL + * @param {string} url - The URL + * @returns {string} The filename + */ + function extractFilenameFromUrl(url) { + if (!url) return ''; + try { + const urlWithoutParams = url.split('?')[0].split('#')[0]; + const pathParts = urlWithoutParams.split('/'); + return pathParts[pathParts.length - 1] || ''; + } catch (e) { + return ''; + } + } + + /** + * Build the optional includes editor UI + * @param {string} category - "global" or skin name + * @param {Object} config - The KefinTweaksConfig object + * @param {Array} skins - Array of all skins + * @returns {string} HTML string for the editor + */ + function buildOptionalIncludesEditor(category, config, skins) { + let optionalIncludes = []; + + if (category === 'global') { + // Get global optional includes from defaults + admin config + const defaultGlobalIncludes = window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || []; + optionalIncludes = defaultGlobalIncludes.map(include => ({ ...include })); + + // Merge with admin config (admin can override enabled state) + const adminOptionalIncludes = config.optionalIncludes || []; + adminOptionalIncludes.forEach(adminEntry => { + if (typeof adminEntry === 'object' && adminEntry.key) { + // Extract info from key: global-author-filename + const keyParts = adminEntry.key.split('-'); + if (keyParts[0] === 'global' && keyParts.length >= 3) { + const author = keyParts[1]; + const filename = keyParts.slice(2).join('-'); + + // Find matching include by URL + const matchingInclude = optionalIncludes.find(inc => { + const incAuthor = extractAuthorFromUrl(inc.url); + const incFilename = extractFilenameFromUrl(inc.url); + return incAuthor === author && incFilename === filename; + }); + + if (matchingInclude) { + matchingInclude.enabled = adminEntry.enabled; + } else { + // Admin added a new include (we'd need URL, but for now just show it) + optionalIncludes.push({ + name: filename.replace('.css', '').replace(/-/g, ' '), + url: '', // We don't have the URL stored, would need to enhance + enabled: adminEntry.enabled, + key: adminEntry.key + }); + } + } + } + }); + } else { + // Get skin-specific optional includes + const skin = skins.find(s => s.name === category); + if (skin && skin.optionalIncludes) { + optionalIncludes = skin.optionalIncludes.map(include => { + const author = extractAuthorFromUrl(include.url); + const key = generateOptionalIncludeKey(category, author, include.url); + + // Check admin config for enabled state + const adminOptionalIncludes = config.optionalIncludes || []; + const adminEntry = adminOptionalIncludes.find(entry => + typeof entry === 'object' && entry.key === key + ); + + return { + ...include, + key: key, + enabled: adminEntry ? adminEntry.enabled : (include.enabled || false) + }; + }); + } + } + + if (optionalIncludes.length === 0) { + return ` +
+ No optional CSS modules available for ${category === 'global' ? 'global options' : category}. +
+ `; + } + + return ` +
+ Configure which optional CSS modules are enabled by default. Users can override these settings individually. +
+
+ ${optionalIncludes.map((include, index) => { + const author = extractAuthorFromUrl(include.url); + const key = include.key || generateOptionalIncludeKey(category, author, include.url); + const filename = include.url ? extractFilenameFromUrl(include.url) : ''; + + return ` +
+
+
+
+
${include.name}
+ ${author ? `` : ''} +
+ +
+ ${filename ? `
${filename}
` : ''} +
+
+ `; + }).join('')} +
+ `; + } + + function buildSkinToggles(skins, skinSources = {}) { return skins.map(skin => { const isEnabled = skin.enabled !== false; // Default to true if not set - const isHidden = skin.hidden === true; const githubUrl = getSkinGitHubUrl(skin); + const source = skinSources[skin.name] || 'default'; + + // Determine styling based on source + let borderStyle = '1px solid rgba(255,255,255,0.1)'; + let backgroundStyle = 'rgba(255,255,255,0.02)'; + let badgeText = ''; + let badgeStyle = ''; + let badgeIcon = ''; + + if (source === 'default') { + backgroundStyle = 'rgba(255,255,255,0.01)'; + badgeText = 'D'; + badgeStyle = 'background: rgba(0,122,255,0.2); color: rgba(0,122,255,0.9);'; + } else if (source === 'overridden') { + borderStyle = '2px solid rgba(0, 122, 255, 0.5)'; + backgroundStyle = 'rgba(0,122,255,0.05)'; + badgeIcon = 'warning'; + badgeStyle = 'color: rgba(255,165,0,0.9);'; + } else if (source === 'custom') { + // Custom skins use default styling + } // Determine tooltip text let tooltipText = ''; - if (isHidden) { + if (source === 'default') { tooltipText = 'This skin is included automatically by KefinTweaks'; + } else if (source === 'overridden') { + tooltipText = 'This default skin has been overridden by a custom configuration'; } else if (githubUrl) { tooltipText = 'View on GitHub'; } return ` -
+
${githubUrl ? ` @@ -620,6 +869,8 @@ ` : `
${skin.name}
`} + ${badgeIcon ? `${badgeIcon}` : ''} + ${badgeText ? `${badgeText}` : ''}
${buildJellyfinCheckbox(`skin_${skin.name.replace(/[^a-zA-Z0-9]/g, '_')}`, isEnabled, '', { 'data-skin-name': skin.name })}
@@ -1580,6 +1831,50 @@ }); } + /** + * Collect optional includes configuration from the UI + * @param {Object} modalInstance - The modal instance + * @returns {Array} Array of { key, enabled } objects + */ + function collectOptionalIncludesConfig(modalInstance) { + const optionalIncludes = []; + const allCheckboxes = modalInstance.dialogContent.querySelectorAll('.optionalIncludeCheckbox'); + + allCheckboxes.forEach(checkbox => { + const key = checkbox.getAttribute('data-key'); + const enabled = checkbox.checked; + + if (key) { + optionalIncludes.push({ + key: key, + enabled: enabled + }); + } + }); + + return optionalIncludes; + } + + /** + * Attach event handlers for optional includes checkboxes + * @param {Object} modalInstance - The modal instance + * @param {string} category - "global" or skin name + * @param {Object} config - The current config + */ + function attachOptionalIncludesCheckboxHandlers(modalInstance, category, config) { + const checkboxes = modalInstance.dialogContent.querySelectorAll(`.optionalIncludeCheckbox[data-category="${category}"]`); + checkboxes.forEach(checkbox => { + // Remove existing listeners (if any) and add new one + const newCheckbox = checkbox.cloneNode(true); + checkbox.parentNode.replaceChild(newCheckbox, checkbox); + + newCheckbox.addEventListener('change', () => { + // Update will be saved when user clicks Save button + // No need to save immediately + }); + }); + } + // Set up event handlers for configuration modal function setupConfigModalHandlers(modalInstance, config) { // Enabled checkbox handler @@ -3584,6 +3879,25 @@ }); } + // Optional includes category dropdown handler + const optionalIncludesCategory = modalInstance.dialogContent.querySelector('#optionalIncludesCategory'); + if (optionalIncludesCategory) { + optionalIncludesCategory.addEventListener('change', (e) => { + const category = e.target.value; + const editor = modalInstance.dialogContent.querySelector('#optionalIncludesEditor'); + if (editor) { + // Use current config from window (which may have been updated after save) + const currentConfig = window.KefinTweaksConfig || config; + editor.innerHTML = buildOptionalIncludesEditor(category, currentConfig, window.KefinTweaksSkinConfig || []); + // Re-attach checkbox handlers + attachOptionalIncludesCheckboxHandlers(modalInstance, category, currentConfig); + } + }); + } + + // Attach initial checkbox handlers + attachOptionalIncludesCheckboxHandlers(modalInstance, 'global', config); + if (scriptCheckboxes.customMenuLinks) { scriptCheckboxes.customMenuLinks.addEventListener('change', () => toggleSectionVisibility('customMenuLinks', 'customMenuLinks')); } @@ -3610,6 +3924,53 @@ if (importBtn) { importBtn.addEventListener('click', () => handleImportConfig(modalInstance)); } + + // Remove duplicate defaults button handler + const removeDuplicateDefaultsBtn = modalInstance.dialogContent.querySelector('#removeDuplicateDefaultsBtn'); + if (removeDuplicateDefaultsBtn) { + removeDuplicateDefaultsBtn.addEventListener('click', () => handleRemoveDuplicateDefaults(modalInstance)); + } + } + + // Handle removing custom skins that override defaults + function handleRemoveDuplicateDefaults(modalInstance) { + const skinsTextarea = modalInstance.dialogContent.querySelector('#skinsJson'); + if (!skinsTextarea) return; + + try { + const currentSkins = JSON.parse(skinsTextarea.value || '[]'); + const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name)); + + // Filter out skins that match default names + const filteredSkins = currentSkins.filter(skin => !defaultSkinNames.has(skin.name)); + const removedCount = currentSkins.length - filteredSkins.length; + + if (removedCount === 0) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('No duplicate default skins found in configuration'); + } else { + alert('No duplicate default skins found in configuration'); + } + return; + } + + // Update textarea + skinsTextarea.value = JSON.stringify(filteredSkins, null, 2); + + // Show confirmation + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast(`Removed ${removedCount} custom skin${removedCount > 1 ? 's' : ''} that were overriding defaults`); + } else { + alert(`Removed ${removedCount} custom skin${removedCount > 1 ? 's' : ''} that were overriding defaults`); + } + } catch (error) { + console.error('[KefinTweaks Configuration] Error removing duplicate defaults:', error); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Error processing skins JSON', '5'); + } else { + alert('Error processing skins JSON. Please check the format.'); + } + } } // Export configuration to clipboard @@ -4357,49 +4718,35 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const skinsFromJson = parseJSONField('skinsJson', []); config.themes = parseJSONField('themesJson', []); config.customMenuLinks = parseJSONField('customMenuLinksJson', []); + + // Collect optional includes configuration + config.optionalIncludes = collectOptionalIncludesConfig(modalInstance); // Collect skin enabled toggles and merge with JSON skins - // First, get the original config to preserve hidden skins - const originalConfig = await getKefinTweaksConfig(); - const originalSkins = originalConfig.skins || []; - const hiddenSkins = originalSkins.filter(skin => skin.hidden === true); + // Only save admin-configured skins (not defaults) + // Get default skin names to filter them out + const defaultSkinNames = new Set((window.KefinTweaksDefaultSkinsConfig?.skins || []).map(s => s.name)); - // Collect enabled states from toggles + // Collect enabled states from toggles (for all skins, including defaults) const skinEnabledStates = {}; modalInstance.dialogContent.querySelectorAll('[data-skin-name]').forEach(checkbox => { const skinName = checkbox.getAttribute('data-skin-name'); skinEnabledStates[skinName] = checkbox.checked; }); - // Merge: start with hidden skins (preserve them), then add/update from JSON - config.skins = [...hiddenSkins]; + // Only save admin skins (from JSON textarea) - filter out any that match default names + // This ensures we don't accidentally save default skins + config.skins = skinsFromJson + .filter(skin => !defaultSkinNames.has(skin.name)) // Exclude defaults + .map(skin => ({ + ...skin, + enabled: skinEnabledStates[skin.name] !== false, // Default to true if not set + hidden: false // Explicitly set for admin skins + })); - // Add/update skins from JSON textarea - skinsFromJson.forEach(skin => { - const existingIndex = config.skins.findIndex(s => s.name === skin.name); - if (existingIndex !== -1) { - // Update existing skin (shouldn't happen for hidden, but just in case) - config.skins[existingIndex] = { - ...config.skins[existingIndex], - ...skin, - enabled: skinEnabledStates[skin.name] !== false // Default to true if not set - }; - } else { - // Add new skin - config.skins.push({ - ...skin, - enabled: skinEnabledStates[skin.name] !== false, - hidden: false // Explicitly set for new skins - }); - } - }); - - // Update enabled states for all skins (including hidden ones) - config.skins.forEach(skin => { - if (skinEnabledStates.hasOwnProperty(skin.name)) { - skin.enabled = skinEnabledStates[skin.name]; - } - }); + // Note: Enabled states for default skins are collected but not saved in config.skins + // Default skins will use their default enabled state from skinConfig.js + // If we need to save enabled states for defaults, we'd need a separate field (future enhancement) // Handle defaultSkin if (config.defaultSkin === '') { @@ -4412,6 +4759,11 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; try { await saveConfigToJavaScriptInjector(config); + // Update global config objects so dropdown changes reflect saved values + window.KefinTweaksConfig = config; + window.KefinTweaksCurrentConfig = config; + currentLoadedConfig = config; + // Show success toast if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { window.KefinTweaksToaster.toast('KefinTweaks saved!'); diff --git a/scripts/userConfig.js b/scripts/skinConfig-0.3.5-defaults.js similarity index 89% rename from scripts/userConfig.js rename to scripts/skinConfig-0.3.5-defaults.js index 5b419de..2948635 100644 --- a/scripts/userConfig.js +++ b/scripts/skinConfig-0.3.5-defaults.js @@ -1,29 +1,20 @@ -// KefinTweaks User Configuration -// User-specific settings and configurations for KefinTweaks features -// This file contains settings that users can customize for their specific needs +// KefinTweaks Legacy Skin Defaults (v0.3.5) +// Canonical snapshot of default skins for duplicate detection +// This file is used to identify and remove duplicate skins from admin configs +// DO NOT modify this file - it represents the canonical state at version 0.3.5 (function() { 'use strict'; // ============================================================================ - // USER CONFIGURATION + // LEGACY SKIN DEFAULTS (v0.3.5) // ============================================================================ const getKefinTweaksRoot = () => { return window.KefinTweaksConfig?.kefinTweaksRoot || 'https://ranaldsgift.github.io/KefinTweaks/'; } - const USER_CONFIG = { - // ============================================================================ - // SKIN CONFIGURATION - // ============================================================================ - // Configure available skins for the display settings page - // These skins will be merged with skins from the main KefinTweaks config - // User-defined skins take priority over main config skins (no duplicates by name) - // Each skin's url field should be an array of objects with majorServerVersions and urls arrays - // Example: url: [{ majorServerVersions: [10, 11], urls: ['url1.css', 'url2.css'] }] - // Each skin can also have colorSchemes array for color variations specific to that skin - // Each skin should have an author field indicating who created the skin + const KEFIN_TWEAKS_LEGACY_SKIN_DEFAULTS = { skins: [ { name: 'Default', @@ -95,11 +86,7 @@ { majorServerVersions: [10], urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane.css'] - }, - /* { - majorServerVersions: [11], - urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane10.11.css'] - } */ + } ], colorSchemes: [] }, @@ -437,26 +424,16 @@ ], colorSchemes: [] } - // Add more skins here as needed - ], - - // ============================================================================ - // THEME CONFIGURATION - // ============================================================================ - // Configure available themes for color variations - // These themes will be added as options to the existing select#selectTheme dropdown - // When a custom theme is selected, the cssTheme link href will be updated to the custom URL - // Each theme should have a name and URL pointing to a CSS file - themes: [ ] }; // ============================================================================ // CONFIGURATION EXPOSURE // ============================================================================ - // Make user configuration available to scripts - window.KefinTweaksUserConfig = USER_CONFIG; + // Make legacy skin defaults available for duplicate detection + window.KefinTweaksLegacySkinDefaults = KEFIN_TWEAKS_LEGACY_SKIN_DEFAULTS; - console.log('[KefinTweaks UserConfig] User configuration loaded. Available at window.KefinTweaksUserConfig'); + console.log('[KefinTweaks Legacy Skin Defaults] Canonical defaults (v0.3.5) loaded. Available at window.KefinTweaksLegacySkinDefaults'); })(); + diff --git a/scripts/skinConfig.js b/scripts/skinConfig.js new file mode 100644 index 0000000..84de061 --- /dev/null +++ b/scripts/skinConfig.js @@ -0,0 +1,798 @@ +// KefinTweaks Default Skin Configuration +// Default skins provided by KefinTweaks +// These skins will be merged with admin-configured skins from KefinTweaksConfig +// Admin-configured skins take priority over default skins (no duplicates by name) + +(function() { + 'use strict'; + + // ============================================================================ + // DEFAULT SKIN CONFIGURATION + // ============================================================================ + + const getKefinTweaksRoot = () => { + return window.KefinTweaksConfig?.kefinTweaksRoot || 'https://ranaldsgift.github.io/KefinTweaks/'; + } + + const KEFIN_TWEAKS_DEFAULT_SKINS_CONFIG = { + // ============================================================================ + // GLOBAL OPTIONAL INCLUDES + // ============================================================================ + // Global optional includes that apply to all skins + // These will be merged with admin-configured global optional includes from KefinTweaksConfig + globalOptionalIncludes: [ + { + name: 'Custom Media Covers', + url: 'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/assets/add-ons/custom-media-covers-latest-min.css', + enabled: false + }, + { + name: 'Center My Media', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/central-libraries-small.css', + enabled: false + }, + { + name: 'Hide My Media Label', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/hide-my-media.css', + enabled: false + }, + { + name: 'Smaller Cast Images', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/smaller-cast.css', + enabled: false + }, + { + name: 'Episode Grid', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-EpisodeGrid.css', + enabled: false + }, + { + name: 'Round Cast', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFLow-RoundCastCrew.css', + enabled: false + }, + { + name: 'Smaller Cast', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/smallercast.css', + enabled: false + }, + { + name: 'Compact Episode List', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/episodelist/episodes_compactlist.css', + enabled: false + }, + { + name: 'Episode Grid', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/episodelist/episodes_grid.css', + enabled: false + }, + { + name: 'Pan Animation', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/effects/pan-animation.css', + enabled: false + }, + { + name: 'Animated Overlay', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-AnimatedOverlay.css', + enabled: false + } + ], + + // ============================================================================ + // SKIN CONFIGURATION + // ============================================================================ + // Configure available skins for the display settings page + // These skins will be merged with skins from the main KefinTweaks config + // Admin-configured skins take priority over default skins (no duplicates by name) + // Each skin's url field should be an array of objects with majorServerVersions and urls arrays + // Example: url: [{ majorServerVersions: [10, 11], urls: ['url1.css', 'url2.css'] }] + // Each skin can also have colorSchemes array for color variations specific to that skin + // Each skin should have an author field indicating who created the skin + skins: [ + { + name: 'Default', + author: 'Jellyfin', + url: null, // null means no additional CSS (default Jellyfin theme) + colorSchemes: [] + }, + { + name: 'ElegantFin', + author: 'lscambo13', + url: [ + { + majorServerVersions: [10, 11], + urls: [ + 'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css', + `${getKefinTweaksRoot()}skins/elegantKefin.css` + ] + } + ], + colorSchemes: [], + optionalIncludes: [ + { + name: 'Media Bar Support', + url: 'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/assets/add-ons/media-bar-plugin-support-latest-min.css', + enabled: true + }, + { + name: 'Media Bar Support (Nightly Version)', + url: 'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/assets/add-ons/media-bar-plugin-support-nightly.css', + enabled: false + }, + { + name: 'Card Hover Effect', + url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/cardHoverEffect.css`, + enabled: false + }, + { + name: 'Center Overlay Play Button', + url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/centerPlayButton.css`, + enabled: false + }, + { + name: 'Extra Overlay Buttons', + url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/extraOverlayButtons.css`, + enabled: false + }, + { + name: 'Library Label Visibility', + url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/libraryLabelVisibility.css`, + enabled: false + }, + { + name: 'Solid App Bar', + url: `${getKefinTweaksRoot()}skins/optional/ElegantFin/solidAppBar.css`, + enabled: false + } + ] + }, + { + name: 'NeutralFin', + author: 'KartoffelChipss', + url: [ + { + majorServerVersions: [10, 11], + urls: [ + 'https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@latest/theme/neutralfin-minified.css', + 'https://cdn.jsdelivr.net/gh/KartoffelChipss/Jellyfin-Lucide@main/theme/jellyfin-lucide.css', + `${getKefinTweaksRoot()}skins/neutralfin-kefin.css` + ] + } + ], + colorSchemes: [] + }, + { + name: 'GlassFin', + author: 'KBH-Reeper', + url: [ + { + majorServerVersions: [10, 11], + urls: [ + 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@latest/Theme/GlassFin-Theme-latest-stable.css', + 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@latest/Theme/PluginStyling/MediaBar-Plugin-latest.css' + ] + } + ], + colorSchemes: [ + { + name: 'Glass', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/BadgeStyling/Glass.css' + }, + { + name: 'Faded', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/BadgeStyling/Faded.css' + }, + { + name: 'Neon', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/BadgeStyling/Neon.css' + }, + { + name: 'PurpleGlass', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/BadgeStyling/PurpleGlass.css' + }, + { + name: 'PurpleStainedGlass', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/BadgeStyling/PurpleStainedGlass.css' + }, + { + name: 'RedGlass', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/BadgeStyling/RedGlass.css' + }, + { + name: 'BlueGlass', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/BadgeStyling/BlueGlass.css' + } + ], + optionalIncludes: [ + { + name: 'Media Bar Support', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/PluginStyling/MediaBar-Plugin-latest.css', + enabled: false + }, + { + name: 'Additional Poster Buttons', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Stylings/CardButtons.css', + enabled: false + }, + { + name: 'Watched Status Badges', + url: 'https://cdn.jsdelivr.net/gh/KBH-Reeper/GlassFin@main/Theme/Styling/ActivateBadges.css', + enabled: false + } + ] + }, + { + name: 'Jellypane', + author: 'tedhinklater', + url: [ // TODO: Update this when we get the specific 10.10.X / 10.11.X versions + { + majorServerVersions: [10], + urls: ['https://cdn.jsdelivr.net/gh/ranaldsgift/Jellypane@main/jellypane.css'] + }, + { + majorServerVersions: [11], + urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/jellypane.css'] + }, + ], + colorSchemes: [] + }, + { + name: 'Finimalism', + author: 'tedhinklater', + url: [ + { + majorServerVersions: [10], + urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism7.css'] + }, + { + majorServerVersions: [11], + urls: ['https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/finimalism10.11.css'] + } + ], + colorSchemes: [], + optionalIncludes: [ + { + name: 'No Animations', + url: 'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/no-animation.css', + enabled: false + }, + { + name: 'Horizontal Scrolling', + url: 'https://cdn.jsdelivr.net/gh/tedhinklater/finimalism@main/horizontal-scrolling.css', + enabled: false + } + ] + }, + { + name: 'Jellyfish', + author: 'n00bcodr', + url: [ + { + majorServerVersions: [10], + urls: ['https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css'] + }, + { + majorServerVersions: [11], + urls: [ + 'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css', + 'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/10.11_fixes.css' + ] + } + ], + optionalIncludes: [ + { + name: 'Floating Progress Bar', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/progress_bar.css', + enabled: false + }, + { + name: 'Watched Indicators', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/indicators.css', + enabled: false + }, + { + name: 'Text instead of icons for buttons on item page', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/icontext.css', + enabled: false + }, + { + name: 'Colored Parental Ratings', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/ratings.css', + enabled: false + }, + { + name: 'Streamberry logo', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/streamberry_logo.css', + enabled: false + } + ], + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Banana', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/banana.css' + }, + { + name: 'Coal', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coal.css' + }, + { + name: 'Coral', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coral.css' + }, + { + name: 'Grass', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/grass.css' + }, + { + name: 'Jellyblue', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyblue.css' + }, + { + name: 'Jellyflix', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyflix.css' + }, + { + name: 'Lavender', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/lavender.css' + }, + { + name: 'Mint', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/mint.css' + }, + { + name: 'Ocean', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/ocean.css' + }, + { + name: 'Peach', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/peach.css' + }, + { + name: 'Watermelon', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/watermelon.css' + } + ] + }, + { + name: 'Glassmorphism', + author: 'alexyle', + url: [ + { + majorServerVersions: [10, 11], + urls: ['https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css'] + } + ], + colorSchemes: [] + }, + { + name: 'Zombie', + author: 'MakD', + url: [ + { + majorServerVersions: [10], + urls: ['https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/zombie_revived.css'] + } + ], + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Amazon Prime', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/amazon-prime.css' + }, + { + name: 'Apple TV', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/apple-tv.css' + }, + { + name: 'Disney', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/disney.css' + }, + { + name: 'HBO Max', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hbo-max.css' + }, + { + name: 'Hulu', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hulu.css' + }, + { + name: 'Netflix', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/netflix.css' + }, + { + name: 'YouTube', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/youtube.css' + } + ] + }, + { + name: 'Jamfin', + author: 'JamsRepos', + url: [ + { + majorServerVersions: [10], + urls: [ + 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/base.css', + `${getKefinTweaksRoot()}skins/jamfin-kefin.css`, + `${getKefinTweaksRoot()}skins/jamfin-kefin-10.css` + ] + }, + { + majorServerVersions: [11], + urls: [ + 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css', + `${getKefinTweaksRoot()}skins/jamfin-kefin.css` + ] + } + ], + colorSchemes: [], + optionalIncludes: [ + { + name: 'Watched Indicators', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/count-indicators.css', + enabled: false + }, + { + name: 'Floating Progress Bar', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/floating-progress.css', + enabled: false + }, + { + name: 'Hide Forgot Password', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/forgot-password.css', + enabled: false + }, + { + name: 'Hover Animation', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/moving-cards.css', + enabled: false + }, + { + name: 'Static Sidebar', + url: 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/modules/static-sidebar.css', + enabled: false + } + ] + }, + { + name: 'Scyfin', + author: 'loof2736', + url: [ + { + majorServerVersions: [10], + urls: [ + 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@v1.4.17/CSS/scyfin-theme.css', + `${getKefinTweaksRoot()}skins/scyfin-kefin.css` + ] + }, + { + majorServerVersions: [11], + urls: [ + 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css', + `${getKefinTweaksRoot()}skins/scyfin-kefin.css` + ] + } + ], + optionalIncludes: [ + { + name: 'Disable Static Drawer', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/disable-static-drawer.css', + enabled: false + } + ], + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Seafoam', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css' + }, + { + name: 'Coral', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css' + }, + { + name: 'Snow', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css' + }, + { + name: 'OLED', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-oled.css' + } + ] + }, + { + name: 'Catppuccin', + author: 'Catppuccin', + url: [ + { + majorServerVersions: [10, 11], + urls: ['https://jellyfin.catppuccin.com/theme.css'] + } + ], + colorSchemes: [ + { + name: 'Latte', + url: 'https://jellyfin.catppuccin.com/catppuccin-latte.css' + }, + { + name: 'Frappé', + url: 'https://jellyfin.catppuccin.com/catppuccin-frappe.css' + }, + { + name: 'Macchiato', + url: 'https://jellyfin.catppuccin.com/catppuccin-macchiato.css' + }, + { + name: 'Mocha', + url: 'https://jellyfin.catppuccin.com/catppuccin-mocha.css' + } + ] + }, + { + name: 'Flow', + author: 'LitCastVlog', + url: [ + { + majorServerVersions: [10], + urls: [ + 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css', + `${getKefinTweaksRoot()}skins/flow-kefin.css` + ] + }, + { + majorServerVersions: [11], + urls: [ + 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-oneliner.css', + 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Compatibility.css', + `${getKefinTweaksRoot()}skins/flow-kefin.css` + ] + } + ], + optionalIncludes: [ + { + name: 'B/W Logo, Smaller Cast & Crew, Hide Upcoming on TV + Next Up in Season View', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Extras.css', + enabled: false + }, + { + name: 'Collapsible Side Drawer', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Drawer-Toggle.css', + enabled: false + }, + { + name: 'Bigger Logo', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-HighDPIExtras.css', + enabled: false + }, + { + name: 'Darker Icons/Accents', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Dark.css', + enabled: false + }, + { + name: 'Episode Grid Outline/Dim on Hover', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-EpisodeGrid-outline.css', + enabled: false + }, + { + name: 'Horizontal Scroll Episodes', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Scrollable-Episodes-WIP.css', + enabled: false + }, + { + name: 'Horizontal Scroll Episodes with Controls Overlayed', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-Scrollable-Episodes-ALT.css', + enabled: false + }, + { + name: 'Nyan Progress Bar', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/NyanProgress.css', + enabled: false + }, + { + name: 'Left Aligned Font / Poster Spacing / Watched Indicators', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-FontTweaks.css', + enabled: false + } + ], + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Orange', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Orange.css' + }, + { + name: 'Pink', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Pink.css' + }, + { + name: 'Rainbow', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Rainbow.css' + }, + { + name: 'Red', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Red.css' + }, + { + name: 'White', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-White.css' + }, + { + name: 'Blue', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Blue.css' + }, + { + name: 'Seafoam', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css' + }, + { + name: 'Coral', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css' + }, + { + name: 'Snow', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css' + } + ] + }, + { + name: 'Ultrachromic', + author: 'CTalvio', + url: [ + { + majorServerVersions: [10, 11], + urls: [ + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/base.css', + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/accentlist.css', + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/fixes.css', + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/jf_font.css', + `${getKefinTweaksRoot()}skins/chromic-kefin.css` + ] + } + ], + optionalIncludes: [ + { + name: 'Round UI Corners', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/rounding.css', + enabled: false + }, + { + name: 'Round UI with circle accent on hover', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/rounding_circlehover.css', + enabled: false + }, + { + name: 'Login Screen Minimal', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/login/login_minimalistic.css', + enabled: false + }, + { + name: 'Login Screen Full', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/login/login_frame.css', + enabled: false + }, + { + name: 'Input Fields with Border', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/fields/fields_border.css', + enabled: false + }, + { + name: 'Input Fields without Border', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/fields/fields_noborder.css', + enabled: false + }, + { + name: 'Floating Watched Indicators', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/cornerindicator/indicator_floating.css', + enabled: false + }, + { + name: 'Corner Watched Indicators', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/cornerindicator/indicator_corner.css', + enabled: false + }, + { + name: 'Simple Titles', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_simple.css', + enabled: false + }, + { + name: 'Logo Titles', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_simple-logo.css', + enabled: false + }, + { + name: 'Banner Titles', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_banner.css', + enabled: false + }, + { + name: 'Banner Logo Titles', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/titlepage/title_banner-logo.css', + enabled: false + }, + { + name: 'Progress Bar', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/overlayprogress.css', + enabled: false + }, + { + name: 'Bottom Progress Bar', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/bottombarprogress.css', + enabled: false + }, + { + name: 'Floating Progress Bar', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/progress/floating.css', + enabled: false + }, + { + name: 'Hoverglow Effect', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/effects/hoverglow.css', + enabled: false + }, + { + name: 'Glassy Effect', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/effects/glassy.css', + enabled: false + }, + { + name: 'Mobile Backdrop "Hack"', + url: 'https://ctalvio.github.io/Monochromic/backdrop-hack_style.css', + enabled: false + } + ], + colorSchemes: [ + { + name: 'Dark', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/type/dark.css' + }, + { + name: 'Light', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/type/light.css' + }, + { + name: 'Colorful', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/type/colorful.css' + }, + { + name: 'Dark with Accent', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/type/dark_withaccent.css' + }, + { + name: 'Light with Accent', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/type/light_withaccent.css' + }, + { + name: 'Colorful with Accent', + url: 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/type/colorful_withaccent.css' + } + ] + } + // Add more skins here as needed + ] + }; + + // ============================================================================ + // CONFIGURATION EXPOSURE + // ============================================================================ + // Make default skin configuration available to scripts + window.KefinTweaksDefaultSkinsConfig = KEFIN_TWEAKS_DEFAULT_SKINS_CONFIG; + + console.log('[KefinTweaks SkinConfig] Default skin configuration loaded. Available at window.KefinTweaksDefaultSkinsConfig'); + +})(); + diff --git a/scripts/skinManager.js b/scripts/skinManager.js index 7cf6cd2..867beb1 100644 --- a/scripts/skinManager.js +++ b/scripts/skinManager.js @@ -1,7 +1,7 @@ // KefinTweaks Skin Manager // Manages skin selection and CSS loading for the display settings page // Adds a skin dropdown to mypreferencesdisplay.html and handles skin switching -// Requires: utils.js, userConfig.js modules to be loaded before this script +// Requires: utils.js, skinConfig.js modules to be loaded before this script (function() { 'use strict'; @@ -41,69 +41,35 @@ * @returns {number|null} The major version number (e.g., 10 for "10.10.X", 11 for "10.11.X"), or null if unavailable */ async function getCurrentMajorServerVersion() { - try { - if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) { - return null; - } - - if (window.ApiClient._appName === 'Jellyfin Media Player') { - // Check the server version instead of app version - if (!window.ApiClient._serverVersion) { - // Wait 10s to see if it becomes ready, check every 500ms - const startTime = Date.now(); - while (Date.now() - startTime < 10000) { - if (window.ApiClient._serverVersion) { - break; - } - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion); - return cachedServerVersion; - } - - // Check if ApiClient and serverVersion are available immediately - if (window.ApiClient._appVersion) { - cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion); - return cachedServerVersion; - } - + try { // If we have a cached value, return it if (cachedServerVersion !== null) { return cachedServerVersion; } - - // Start background polling if not already started - if (!versionPollingStarted) { - versionPollingStarted = true; - const pollInterval = 500; // 500ms - const maxDuration = 5000; // 5 seconds - const maxAttempts = maxDuration / pollInterval; // 10 attempts - let attempts = 0; - - const poll = setInterval(() => { - attempts++; - - try { - if (typeof window.ApiClient !== 'undefined' && window.ApiClient._appVersion) { - cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion); - return; - } - } catch (error) { - WARN('Error getting server version during poll:', error); - } - - // Timeout after max attempts - if (attempts >= maxAttempts) { - clearInterval(poll); - cachedServerVersion = null; // Cache null to indicate we tried - } - }, pollInterval); + + if (!window.ApiClient || !window.ApiClient._appName || !window.ApiClient._appVersion) { + return null; } - - // Return null immediately (callers can check again later or use cached value) - return null; + + if (window.ApiClient._appName === 'Jellyfin Web' && window.ApiClient._appVersion) { + cachedServerVersion = getMajorServerVersion(window.ApiClient._appVersion); + return cachedServerVersion; + } + + // Check the server version instead of app version + if (!window.ApiClient._serverVersion) { + // Wait 10s to see if it becomes ready, check every 500ms + const startTime = Date.now(); + while (Date.now() - startTime < 10000) { + if (window.ApiClient._serverVersion) { + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + cachedServerVersion = getMajorServerVersion(window.ApiClient._serverVersion); + return cachedServerVersion; } catch (error) { WARN('Error getting server version:', error); return null; @@ -225,22 +191,358 @@ }); } + /** + * 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} 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; + } + + const server = window.ApiClient._serverAddress; + const token = window.ApiClient.accessToken(); + + // Find JavaScript Injector plugin + const pluginsResponse = await fetch(`${server}/Plugins`, { + headers: { 'X-Emby-Token': token } + }); + + if (!pluginsResponse.ok) { + WARN(`Failed to get plugins for backup: ${pluginsResponse.statusText}`); + return false; + } + + const pluginsData = await pluginsResponse.json(); + const pluginsList = Array.isArray(pluginsData) ? pluginsData : (pluginsData.Items || []); + const plugin = pluginsList.find(p => p.Name === 'JavaScript Injector' || p.Name === 'JS Injector'); + + if (!plugin) { + WARN('JavaScript Injector plugin not found, cannot create backup'); + return false; + } + + const pluginId = plugin.Id; + + // Get current injector config + const configUrl = `${server}/Plugins/${pluginId}/Configuration`; + const configResponse = await fetch(configUrl, { + headers: { 'X-Emby-Token': token } + }); + + if (!configResponse.ok) { + WARN(`Failed to get plugin config for backup: ${configResponse.statusText}`); + return false; + } + + const injectorConfig = await configResponse.json(); + + // 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: { + 'X-Emby-Token': token, + '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() { - // Only load from window.KefinTweaksConfig (from JS Injector) - const configSkins = window.KefinTweaksConfig?.skins || []; + // 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; - // Add any default skins from userConfig.js that are not already present - const defaultSkins = window.KefinTweaksUserConfig?.skins || []; + // 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 => { - if (!configSkins.some(s => s.name === skin.name)) { - configSkins.push(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 = configSkins.filter(skin => { + 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; @@ -265,42 +567,485 @@ LOG(`Filtered skins by server version: ${skinsBeforeFilter} -> ${skinsAfterFilter} (removed ${skinsBeforeFilter - skinsAfterFilter} incompatible skins)`); } - const enabledCount = configSkins.filter(s => s.enabled !== false).length; - const disabledCount = configSkins.length - enabledCount; - LOG(`Loaded ${SKINS_CONFIG.length} enabled skins from KefinTweaksConfig (${enabledCount} enabled, ${disabledCount} disabled)`); + // 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; + // Load and merge theme configurations async function loadThemeConfig() { - const userThemes = window.KefinTweaksUserConfig?.themes || []; - const additionalThemes = window.KefinTweaksConfig?.themes || []; + // Load themes from admin config only (no default themes support for now) + const adminThemes = window.KefinTweaksConfig?.themes || []; - // Start with user themes (these take priority) - THEMES_CONFIG = [...userThemes]; - - // Add any default themes from userConfig.js that are not already present - const defaultThemes = window.KefinTweaksUserConfig?.themes || []; - defaultThemes.forEach(theme => { - if (!THEMES_CONFIG.some(t => t.name === theme.name)) { - THEMES_CONFIG.push(theme); - } - }); + // Start with admin themes + THEMES_CONFIG = [...adminThemes]; - // Add additional themes from main config, avoiding duplicates by name - additionalThemes.forEach(theme => { - const exists = THEMES_CONFIG.some(existingTheme => existingTheme.name === theme.name); - if (!exists) { - THEMES_CONFIG.push(theme); - } - }); - - LOG(`Loaded ${THEMES_CONFIG.length} themes (${userThemes.length} user + ${additionalThemes.length} additional)`); + 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) { + // Check user localStorage first (highest priority) + const 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 (defaults + admin config) + * Admin config should contain array of keys (strings), not objects + * @returns {Array} Merged list of global optional includes + */ + function getMergedGlobalOptionalIncludes() { + // Get default global optional includes from skinConfig.js + const defaultGlobalIncludes = window.KefinTweaksDefaultSkinsConfig?.globalOptionalIncludes || []; + + // Get admin-configured global optional includes from KefinTweaksConfig + // Admin config should be an array of keys (strings) + const adminGlobalIncludeKeys = window.KefinTweaksConfig?.optionalIncludes || []; + + // Create a set of admin keys for quick lookup + const adminKeysSet = new Set(adminGlobalIncludeKeys.filter(k => typeof k === 'string')); + + // Start with defaults, then check if admin has added any new ones + // For now, we only support defaults + admin keys + // Admin can't override defaults, only add new ones via keys + const merged = defaultGlobalIncludes.map(include => ({ ...include })); + + // Note: Admin config now stores keys, not full objects + // If admin wants to add new optional includes, they would need to add them to defaults + // or we'd need to support a different structure. For now, we'll just use defaults + // and check enabled state via keys + + return merged; + } + + /** + * 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(); + + // Load enabled global optional includes (check enabled state via keys) + globalOptionalIncludes.forEach(include => { + const author = extractAuthorFromUrl(include.url); + const key = generateOptionalIncludeKey('global', author, include.url); + const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false); + + 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); + const isEnabled = isOptionalIncludeEnabled(key, include.enabled || false); + + 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 + if (loadedOptionalIncludesUrls.has(url)) { + 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; @@ -900,6 +1645,50 @@ // 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); + } } /** @@ -1011,6 +1800,328 @@ 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 (Option 1: only save what user toggled) + saveOptionalIncludeState(key, enabled); + + // Also update admin config with { key, enabled } (for initial enabled state) + // Only update the specific key that was changed + if (!window.KefinTweaksConfig) { + window.KefinTweaksConfig = {}; + } + if (!window.KefinTweaksConfig.optionalIncludes) { + window.KefinTweaksConfig.optionalIncludes = []; + } + + // Find or create entry for this key in admin config + const currentAdminEntries = window.KefinTweaksConfig.optionalIncludes || []; + const adminEntryIndex = currentAdminEntries.findIndex(entry => { + const entryKey = typeof entry === 'string' ? entry : (entry.key || ''); + return entryKey === key; + }); + + if (adminEntryIndex >= 0) { + // Update existing entry + if (typeof currentAdminEntries[adminEntryIndex] === 'string') { + currentAdminEntries[adminEntryIndex] = { key: key, enabled: enabled }; + } else { + currentAdminEntries[adminEntryIndex].enabled = enabled; + } + } else { + // Add new entry + currentAdminEntries.push({ key: key, enabled: enabled }); + } + + window.KefinTweaksConfig.optionalIncludes = currentAdminEntries; + + // Save to JS Injector + if (window.KefinTweaksUtils && window.KefinTweaksUtils.saveConfigToJavaScriptInjector) { + window.KefinTweaksUtils.saveConfigToJavaScriptInjector(window.KefinTweaksConfig, { waitForLogin: false }); + } + + // 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 */ @@ -1052,6 +2163,30 @@ 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); + } } /** @@ -1163,9 +2298,12 @@ const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); const select = document.getElementById('selectSkin'); if (select) { - select.value = selectedSkinName; + select.value = selectedSkinName; } + // Update label with gear icon + updateDisplayPreferencesSkinLabel(); + LOG('Skin dropdown added successfully'); } @@ -1753,6 +2891,10 @@ // Use requestAnimationFrame to avoid blocking requestAnimationFrame(() => { removeAllSkinCSS(); + // Unload skin-specific optional includes for the current skin (if switching) + if (currentSkinName) { + unloadOptionalIncludesForSkin(currentSkinName); + } }); // Performance optimization: Load new CSS first, then remove old CSS @@ -1773,6 +2915,9 @@ loadSkinCSS(url, skin.name); } }); + + // Load optional includes for this skin + loadOptionalIncludes(skin); }); } diff --git a/skins/optional/ElegantFin/cardHoverEffect.css b/skins/optional/ElegantFin/cardHoverEffect.css new file mode 100644 index 0000000..d217fbd --- /dev/null +++ b/skins/optional/ElegantFin/cardHoverEffect.css @@ -0,0 +1,3 @@ +:root{ + --cardHoverEffect: none; + } \ No newline at end of file diff --git a/skins/optional/ElegantFin/centerPlayButton.css b/skins/optional/ElegantFin/centerPlayButton.css new file mode 100644 index 0000000..30bd2d2 --- /dev/null +++ b/skins/optional/ElegantFin/centerPlayButton.css @@ -0,0 +1,3 @@ +:root{ + --overlayPlayButtonPosition: 50%; +} \ No newline at end of file diff --git a/skins/optional/ElegantFin/extraOverlayButtons.css b/skins/optional/ElegantFin/extraOverlayButtons.css new file mode 100644 index 0000000..4557cb2 --- /dev/null +++ b/skins/optional/ElegantFin/extraOverlayButtons.css @@ -0,0 +1,3 @@ +:root{ + --extraCardButtonsVisibility: block; + } \ No newline at end of file diff --git a/skins/optional/ElegantFin/libraryLabelVisibility.css b/skins/optional/ElegantFin/libraryLabelVisibility.css new file mode 100644 index 0000000..e84baf7 --- /dev/null +++ b/skins/optional/ElegantFin/libraryLabelVisibility.css @@ -0,0 +1,3 @@ +:root{ + --libraryLabelVisibility: block; + } \ No newline at end of file diff --git a/skins/optional/ElegantFin/solidAppBar.css b/skins/optional/ElegantFin/solidAppBar.css new file mode 100644 index 0000000..3b34036 --- /dev/null +++ b/skins/optional/ElegantFin/solidAppBar.css @@ -0,0 +1,3 @@ +:root{ + --appBarHeight: 4.6em; + } \ No newline at end of file