diff --git a/README.md b/README.md index 944d630..65e44fe 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t - [Installation](#-installation) - [Prerequisites](#prerequisites) - [Setup Instructions](#setup-instructions) +- [Configuration](#configuration) - [Features Overview](#features-overview) - [Data Caching](#data-caching) - [Feature Requests Completed](#-feature-requests-completed) @@ -62,27 +63,70 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t 0a. **Install Prerequisites listed above** 0b. **If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings** 1. **Add a new script to your JS Injector Plugin** -2. **Copy the entire contents of kefinTweaks.js into the new script** +2. **Copy the the code below and paste it into the new script:** -**Optionally update the "scriptRoot" in the KefinTweaks script if you wish to customize and host these scripts yourself.** +```javascript +const script = document.createElement("script"); +script.src = `https://ranaldsgift.github.io/KefinTweaks/injector.js?v=${new Date().getTime()}`; +script.async = true; +document.head.appendChild(script); +``` -3. **Ensure the script is enabled** -4. **Save your changes** +3. **Save your changes**
injector

+
+ +## Configuration + +KefinTweaks includes a comprehensive configuration interface accessible to administrators. The configuration UI allows you to customize all aspects of KefinTweaks without editing code files. + +### Accessing Configuration + +The configuration interface can be accessed in multiple ways: + +1. **From User Preferences**: Navigate to your User Preferences page and find the "Administration" section. Click the "Configure KefinTweaks" button. +2. **From Dashboard**: In the Dashboard side menu, find the "KefinTweaks" button under the Plugins section. +3. **From Left Navigation Menu**: A "Configure" link will appear in the left navigation menu beneath a KefinTweaks heading. + +### Configuration Features + +All configuration options for KefinTweaks can be managed directly from the UI. + +- **Feature Toggle**: Toggle individual KefinTweaks features on or off +- **Home Screen Configuration**: Configure custom sections, discovery features, and seasonal content +- **Search Configuration**: Enable/disable Jellyseerr integration +- **Skin Configuration**: Manage available skins, set default skins, and enable/disable specific skins +- **Theme Configuration**: Add custom themes available to all users +- **Exclusive Elsewhere Configuration**: Configure branding behavior +- **Custom Menu Links**: Add custom links to the navigation menu +- **Script Root URLs**: Configure custom script hosting locations (advanced) + +### Export and Import + +- **Export**: Export your entire configuration to clipboard as JSON for backup or sharing +- **Import**: Import a previously exported configuration to restore settings or apply configurations from other servers -5. **Required for Watchlist:** -Add a new tab to your Custom Tabs plugin with the following content: - ```html -
- ```
- customtab -
-
+ Configuration Interface + +
+ +
+ Configuration Interface +
+
+ +### Automatic Setup + +On first load, KefinTweaks automatically: +- Creates the `KefinTweaks-Config` script in the JavaScript Injector plugin with default settings +- Ensures the Watchlist tab exists in the CustomTabs plugin with the correct HTML structure + +These tasks only run for administrator users and happen automatically in the background.
## Features Overview @@ -373,6 +417,7 @@ Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-upd | `modal.js` | Generic modal system for dialogs | | `toaster.js` | Toast notification system | | `userConfig.js` | User-specific configuration for skins and settings | +| `apiHelper.js` | API helper functions for common operations | ### Feature Scripts @@ -382,7 +427,7 @@ Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-upd | `homeScreen.js` | Custom home screen sections | `cardBuilder`, `localStorageCache`, `utils` | | `search.js` | Enhanced search functionality | `cardBuilder`, `utils` | | `headerTabs.js` | Header tab improvements | None | -| `customMenu.js` | Custom menu link handling | `utils` | +| `customMenuLinks.js` | Custom menu link handling | `utils` | | `exclusiveElsewhere.js` | Custom branding for unavailable content | None | | `updoot.js` | Upvote functionality integration | None | | `backdropLeakFix.js` | Memory leak fixes | None | @@ -395,6 +440,9 @@ Coming soon...requires the [Updoot](https://github.com/BobHasNoSoul/jellyfin-upd | `itemDetailsCollections.js` | Collections display on item details pages | `indexedDBCache`, `utils`, `cardBuilder` | | `flattenSingleSeasonShows.js` | Flatten single-season shows to show episodes directly | `cardBuilder`, `utils` | | `skinManager.js` | Skin selection and management | `utils`, `userConfig` | +| `collections.js` | Collection sorting functionality | `utils`, `modal` | +| `sonarrRequests.js` | Sonarr request functionality for missing episodes and series | None | +| `deviceManager.js` | Device management with remove device functionality | `utils` |
## Auto-Inject Dependencies @@ -432,12 +480,10 @@ This README and the inline comments in kefinTweaks.js and the other scripts is t ### Planned Features - **Jellyfin 10.11 Support**: -- **UI Configuration**: -Allow admins to customize the configuration through the UI instead of the JS script -Allow users to also customize various configuration options through the UI
### Version History +- **v0.3.0**: KefinTweaks configuration for admins in the Jellyfin UI! - **v0.2.4**: New Feature: Watchlist Export/Import - **v0.2.3**: New Feature: Collection Sorting, general fixes - **v0.2.2**: Add support for skin server versions, fixes for HeaderTabs diff --git a/injector.js b/injector.js index a96b9a2..8230905 100644 --- a/injector.js +++ b/injector.js @@ -6,7 +6,7 @@ 'use strict'; console.log('[KefinTweaks Injector] Initializing...'); - const versionNumber = "0.2.4"; + const versionNumber = "0.3.0"; // Configuration: Start with defaults, then merge user config // This allows new scripts to work out of the box without requiring config updates @@ -497,6 +497,16 @@ console.log('[KefinTweaks Injector] All scripts loaded successfully!'); + // Always load configuration.js for admin UI (loads after other scripts) + console.log('[KefinTweaks Injector] Loading configuration script...'); + try { + await loadScript('configuration.js'); + await loadCSS('configuration.css'); + console.log('[KefinTweaks Injector] Configuration script loaded successfully'); + } catch (error) { + console.warn('[KefinTweaks Injector] Failed to load configuration script:', error); + } + // Dispatch custom event when all scripts are loaded const event = new CustomEvent('kefinTweaksLoaded', { detail: { @@ -549,15 +559,335 @@ } }; + // Check if user is admin (with timeout for login) + async function checkAdminWithTimeout(maxWaitMs = 5000) { + const startTime = Date.now(); + const checkInterval = 100; // Check every 100ms + + return new Promise((resolve) => { + const checkAdmin = async () => { + try { + if (window.ApiClient && window.ApiClient.getCurrentUser) { + const user = await window.ApiClient.getCurrentUser(); + if (user && user.Policy && user.Policy.IsAdministrator === true) { + console.log('[KefinTweaks Startup] User is admin'); + resolve(true); + return; + } + // User is logged in but not admin + if (user) { + console.log('[KefinTweaks Startup] User is not admin'); + resolve(false); + return; + } + } + } catch (error) { + // User might not be logged in yet, continue waiting + } + + // Check if we've exceeded max wait time + if (Date.now() - startTime >= maxWaitMs) { + console.log('[KefinTweaks Startup] Timeout waiting for user login'); + resolve(false); + return; + } + + // Continue checking + setTimeout(checkAdmin, checkInterval); + }; + + checkAdmin(); + }); + } + + // Find plugin by name + async function findPlugin(pluginName) { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + throw new Error('ApiClient not available'); + } + + const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); + + const response = await fetch(`${server}/Plugins`, { + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const plugins = await response.json(); + const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []); + + const plugin = pluginsList.find(p => p.Name === pluginName); + return plugin ? plugin.Id : null; + } catch (error) { + console.error(`[KefinTweaks Startup] Error finding plugin ${pluginName}:`, error); + return null; + } + } + + // Get plugin configuration + async function getPluginConfig(pluginId) { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + throw new Error('ApiClient not available'); + } + + const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); + + const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error('[KefinTweaks Startup] Error getting plugin config:', error); + throw error; + } + } + + // Save plugin configuration + async function savePluginConfig(pluginId, config) { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + throw new Error('ApiClient not available'); + } + + const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); + + const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { + method: 'POST', + headers: { + 'X-Emby-Token': token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return true; + } catch (error) { + console.error('[KefinTweaks Startup] Error saving plugin config:', error); + throw error; + } + } + + // Ensure KefinTweaks-Config exists in JS Injector + async function ensureKefinTweaksConfig() { + try { + console.log('[KefinTweaks Startup] Ensuring KefinTweaks-Config exists in JS Injector...'); + + const pluginId = await findPlugin('JavaScript Injector') || await findPlugin('JS Injector'); + if (!pluginId) { + console.warn('[KefinTweaks Startup] JavaScript Injector plugin not found'); + return false; + } + + const injectorConfig = await getPluginConfig(pluginId); + + // Ensure CustomJavaScripts array exists + if (!injectorConfig.CustomJavaScripts) { + injectorConfig.CustomJavaScripts = []; + } + + // Check if KefinTweaks-Config already exists + const existingScript = injectorConfig.CustomJavaScripts.find( + script => script.Name === 'KefinTweaks-Config' + ); + + if (existingScript) { + console.log('[KefinTweaks Startup] KefinTweaks-Config already exists in JS Injector'); + return true; + } + + // Load default config + let defaultConfig; + if (window.KefinTweaksDefaultConfig) { + defaultConfig = window.KefinTweaksDefaultConfig; + } else { + // Try to load it + try { + const defaultConfigUrl = window.KefinTweaksConfig?.kefinTweaksRoot + ? `${window.KefinTweaksConfig.kefinTweaksRoot}kefinTweaks-default-config.js` + : 'https://ranaldsgift.github.io/KefinTweaks/kefinTweaks-default-config.js'; + + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = defaultConfigUrl; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load default config')); + document.head.appendChild(script); + }); + + defaultConfig = window.KefinTweaksDefaultConfig; + } catch (error) { + console.error('[KefinTweaks Startup] Error loading default config:', error); + // Create minimal config + defaultConfig = { + kefinTweaksRoot: 'https://ranaldsgift.github.io/KefinTweaks/', + scriptRoot: 'https://ranaldsgift.github.io/KefinTweaks/scripts/', + scripts: {}, + homeScreen: {}, + exclusiveElsewhere: {}, + search: {}, + skins: [], + themes: [], + customMenuLinks: [] + }; + } + } + + // Create the script content + const scriptContent = `// KefinTweaks Configuration +// This file is automatically generated by KefinTweaks Configuration UI +// Do not edit manually unless you know what you're doing + +window.KefinTweaksConfig = ${JSON.stringify(defaultConfig, null, 2)};`; + + // Add new script + injectorConfig.CustomJavaScripts.push({ + Name: 'KefinTweaks-Config', + Script: scriptContent, + Enabled: true, + RequiresAuthentication: false + }); + + // Save the configuration + await savePluginConfig(pluginId, injectorConfig); + console.log('[KefinTweaks Startup] KefinTweaks-Config added to JS Injector'); + return true; + } catch (error) { + console.error('[KefinTweaks Startup] Error ensuring KefinTweaks-Config:', error); + return false; + } + } + + // Ensure Watchlist tab exists in CustomTabs + async function ensureWatchlistTab() { + try { + // Check if Watchlist is enabled in the configuration + if (!window.KefinTweaksConfig.scripts.watchlist) { + console.log('[KefinTweaks Startup] Watchlist is disabled in configuration, skipping Watchlist tab check'); + return true; + } + + console.log('[KefinTweaks Startup] Ensuring Watchlist tab exists in CustomTabs...'); + + const pluginId = await findPlugin('Custom Tabs'); + if (!pluginId) { + console.warn('[KefinTweaks Startup] CustomTabs plugin not found'); + return false; + } + + // Get current CustomTabs config + const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); + + const response = await fetch(`${server}/CustomTabs/Config`, { + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + let tabs = await response.json(); + if (!Array.isArray(tabs)) { + tabs = []; + } + + // Check if Watchlist tab exists + const watchlistTab = tabs.find(tab => tab.Title === 'Watchlist'); + + if (watchlistTab) { + // Ensure ContentHtml is correct + if (watchlistTab.ContentHtml !== '
') { + watchlistTab.ContentHtml = '
'; + // Update the tab in the array + const index = tabs.findIndex(tab => tab.Title === 'Watchlist'); + tabs[index] = watchlistTab; + + // Save updated config + await savePluginConfig(pluginId, { Tabs: tabs }); + console.log('[KefinTweaks Startup] Updated Watchlist tab ContentHtml'); + } else { + console.log('[KefinTweaks Startup] Watchlist tab already exists with correct ContentHtml'); + } + return true; + } + + // Add Watchlist tab + tabs.push({ + Title: 'Watchlist', + ContentHtml: '
' + }); + + // Save updated config + await savePluginConfig(pluginId, { Tabs: tabs }); + console.log('[KefinTweaks Startup] Added Watchlist tab to CustomTabs'); + return true; + } catch (error) { + console.error('[KefinTweaks Startup] Error ensuring Watchlist tab:', error); + return false; + } + } + + // Startup task - runs only for admin users + async function startupTask() { + console.log('[KefinTweaks Startup] Starting startup task...'); + + // Check if user is admin (wait up to 5s for login) + const isAdmin = await checkAdminWithTimeout(5000); + + if (!isAdmin) { + console.log('[KefinTweaks Startup] User is not admin or not logged in, skipping startup task'); + return; + } + + try { + // Task 1: Ensure KefinTweaks-Config exists in JS Injector + await ensureKefinTweaksConfig(); + + // Task 2: Ensure Watchlist tab exists in CustomTabs + await ensureWatchlistTab(); + + console.log('[KefinTweaks Startup] Startup task completed successfully'); + } catch (error) { + console.error('[KefinTweaks Startup] Error in startup task:', error); + } + } + // Start initialization when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { injectVersionBadgeCSS(); initialize(); + // Run startup task after a short delay to ensure ApiClient is ready + setTimeout(startupTask, 1000); }); } else { injectVersionBadgeCSS(); initialize(); + // Run startup task after a short delay to ensure ApiClient is ready + setTimeout(startupTask, 1000); } console.log('[KefinTweaks Injector] Injector script loaded. Available at window.KefinTweaks'); diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js new file mode 100644 index 0000000..4f868ae --- /dev/null +++ b/kefinTweaks-default-config.js @@ -0,0 +1,447 @@ +window.KefinTweaksDefaultConfig = { + "kefinTweaksRoot": "https://ranaldsgift.github.io/KefinTweaks/", + "scriptRoot": "https://ranaldsgift.github.io/KefinTweaks/scripts/", + "scripts": { + "homeScreen": true, + "search": true, + "watchlist": true, + "headerTabs": true, + "customMenuLinks": true, + "breadcrumbs": true, + "playlist": true, + "itemDetailsCollections": true, + "flattenSingleSeasonShows": true, + "collections": true, + "exclusiveElsewhere": false, + "backdropLeakFix": true, + "dashboardButtonFix": true, + "infiniteScroll": true, + "removeContinue": true, + "subtitleSearch": true, + "skinManager": true + }, + "homeScreen": { + "enableNewAndTrending": true, + "enableNewMovies": true, + "enableNewEpisodes": true, + "enableTrending": false, + "enableDiscovery": true, + "enableInfiniteScroll": true, + "minPeopleAppearances": 10, + "minGenreMovieCount": 50, + "minimumShowsForNetwork": 5, + "enableWatchlist": true, + "enableSeasonal": true, + "seasonalItemLimit": 16, + "customSections": [] + }, + "exclusiveElsewhere": { + "hideServerName": false + }, + "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 + } + ], + "defaultSkin": null, + "themes": [], + "customMenuLinks": [] +}; diff --git a/pages/images/config-1.png b/pages/images/config-1.png new file mode 100644 index 0000000..feb7be1 Binary files /dev/null and b/pages/images/config-1.png differ diff --git a/pages/images/config-2.png b/pages/images/config-2.png new file mode 100644 index 0000000..1f263ab Binary files /dev/null and b/pages/images/config-2.png differ diff --git a/pages/images/injector.png b/pages/images/injector.png index eb0d291..05871a2 100644 Binary files a/pages/images/injector.png and b/pages/images/injector.png differ diff --git a/scripts/configuration.css b/scripts/configuration.css new file mode 100644 index 0000000..f6426ac --- /dev/null +++ b/scripts/configuration.css @@ -0,0 +1,64 @@ +.kefin-config-button { + -webkit-tap-highlight-color: transparent; + background-color: transparent; + cursor: pointer; + user-select: none; + vertical-align: middle; + appearance: none; + color: inherit; + display: flex; + -webkit-box-flex: 1; + flex-grow: 1; + -webkit-box-pack: start; + justify-content: flex-start; + -webkit-box-align: center; + align-items: center; + position: relative; + min-width: 0px; + box-sizing: border-box; + text-align: left; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 16px; + padding-right: 16px; + outline: 0px; + border-width: 0px; + border-style: initial; + border-color: initial; + border-image: initial; + margin: 0px; + border-radius: 0px; + text-decoration: none; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.customMenuOptions { + display: flex; + flex-direction: column; +} + +.customMenuOptions > * { + order: 1; +} + +a[href="#"].navMenuOption:has(.build) { + order: 9999; + margin-top: 2em !important; + position: relative; +} + +a[href="#"].navMenuOption:has(.build)::before { + content: 'KefinTweaks'; + position: absolute; + top: -1.5em; + left: 0; +} + +.listItemContent:has([data-skin-name]) { + justify-content: space-between; + width: 100%; +} + +#kefinTweaksConfigModal .listItem { + align-items: start; +} \ No newline at end of file diff --git a/scripts/configuration.js b/scripts/configuration.js new file mode 100644 index 0000000..455ce4d --- /dev/null +++ b/scripts/configuration.js @@ -0,0 +1,1700 @@ +// KefinTweaks Configuration UI +// Adds a configuration button to the Administration section in user preferences +// Opens configuration in a modal window + +(function() { + 'use strict'; + + console.log('[KefinTweaks Configuration] Initializing...'); + + const MODAL_ID = 'kefinTweaksConfigModal'; + + // Check if user is admin + async function isAdmin() { + try { + if (window.ApiClient && window.ApiClient.getCurrentUser) { + const user = await window.ApiClient.getCurrentUser(); + return user && user.Policy && user.Policy.IsAdministrator === true; + } + } catch (error) { + console.warn('[KefinTweaks Configuration] Could not check admin status:', error); + } + return false; + } + + // Load default configuration from JS file + async function loadDefaultConfig() { + return new Promise((resolve, reject) => { + try { + const defaultConfigUrl = window.KefinTweaksConfig?.kefinTweaksRoot + ? `${window.KefinTweaksConfig.kefinTweaksRoot}kefinTweaks-default-config.js` + : 'https://ranaldsgift.github.io/KefinTweaks/kefinTweaks-default-config.js'; + + // Check if already loaded + if (window.KefinTweaksDefaultConfig) { + console.log('[KefinTweaks Configuration] Using cached default config'); + resolve(window.KefinTweaksDefaultConfig); + return; + } + + // Create script element to load the JS file + const script = document.createElement('script'); + script.src = defaultConfigUrl; + script.async = true; + + script.onload = () => { + if (window.KefinTweaksDefaultConfig) { + console.log('[KefinTweaks Configuration] Loaded default config:', window.KefinTweaksDefaultConfig); + resolve(window.KefinTweaksDefaultConfig); + } else { + reject(new Error('Default config file loaded but window.KefinTweaksDefaultConfig is not defined')); + } + }; + + script.onerror = (error) => { + reject(new Error(`Failed to load default config file: ${defaultConfigUrl}`)); + }; + + document.head.appendChild(script); + } catch (error) { + console.error('[KefinTweaks Configuration] Error loading default config:', error); + reject(error); + } + }); + } + + // Get KefinTweaks configuration from JS Injector or load defaults + async function getKefinTweaksConfig() { + try { + // First, try to get config from JS Injector + const pluginId = await findJavaScriptInjectorPlugin(); + const injectorConfig = await getJavaScriptInjectorConfig(pluginId); + + // Find KefinTweaks-Config script + const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( + script => script.Name === 'KefinTweaks-Config' + ); + + if (kefinTweaksScript && kefinTweaksScript.Script) { + // Extract config from script content + // The script should contain: window.KefinTweaksConfig = {...}; + const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/); + if (scriptMatch && scriptMatch[1]) { + try { + const config = JSON.parse(scriptMatch[1]); + console.log('[KefinTweaks Configuration] Loaded config from JS Injector:', config); + return config; + } catch (parseError) { + console.error('[KefinTweaks Configuration] Error parsing config from script:', parseError); + } + } + } + + // If no config found in JS Injector, load defaults + console.log('[KefinTweaks Configuration] No config found in JS Injector, loading defaults'); + const defaultConfig = await loadDefaultConfig(); + + // Save defaults to JS Injector for future use + await saveConfigToJavaScriptInjector(defaultConfig); + + return defaultConfig; + } catch (error) { + console.error('[KefinTweaks Configuration] Error getting config, falling back to defaults:', error); + // Fallback to defaults if anything fails + try { + return await loadDefaultConfig(); + } catch (fallbackError) { + console.error('[KefinTweaks Configuration] Error loading default config as fallback:', fallbackError); + // Return empty config structure as last resort + return { + kefinTweaksRoot: 'https://ranaldsgift.github.io/KefinTweaks/', + scriptRoot: 'https://ranaldsgift.github.io/KefinTweaks/scripts/', + scripts: {}, + homeScreen: {}, + exclusiveElsewhere: {}, + search: {}, + skins: [], + defaultSkin: null, + themes: [], + customMenuLinks: [] + }; + } + } + } + + // Inject responsive CSS for the configuration modal + function injectConfigModalCSS() { + const styleId = 'kefintweaks-config-modal-css'; + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + #${MODAL_ID} .modal-content-wrapper { + max-width: 90vw; + width: 1400px; + max-height: 90vh; + overflow-y: auto; + background: var(--background-color, #1a1a1a); + border-radius: 8px; + } + + #${MODAL_ID} .content-primary { + max-width: 1400px; + margin: 0 auto; + padding: 1.5em; + } + + #${MODAL_ID} .paperList { + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + padding: 1em; + } + + #${MODAL_ID} .listItem { + transition: background-color 0.2s ease; + } + + #${MODAL_ID} .listItem:hover { + background-color: rgba(255, 255, 255, 0.02); + } + + #${MODAL_ID} textarea.fld { + resize: vertical; + min-height: 200px; + } + + #${MODAL_ID} details summary { + user-select: none; + } + + #${MODAL_ID} details summary:hover { + opacity: 0.8; + } + + #${MODAL_ID} pre { + white-space: pre-wrap; + word-wrap: break-word; + } + + /* Responsive design */ + @media (max-width: 768px) { + #${MODAL_ID} .modal-content-wrapper { + width: 95vw; + max-height: 95vh; + } + + #${MODAL_ID} .content-primary { + padding: 1em; + } + + #${MODAL_ID} .paperList { + padding: 0.75em; + } + + #${MODAL_ID} [style*="grid-template-columns"] { + grid-template-columns: 1fr !important; + } + } + + @media (max-width: 480px) { + #${MODAL_ID} .content-primary { + padding: 0.75em; + } + + #${MODAL_ID} .pageTitle { + font-size: 1.5em; + } + + #${MODAL_ID} input.fld, + #${MODAL_ID} textarea.fld { + font-size: 16px; /* Prevents zoom on iOS */ + } + } + `; + document.head.appendChild(style); + } + + // Show confirmation modal for editing script root configuration + function showScriptRootEditConfirmation() { + return new Promise((resolve) => { + if (!window.ModalSystem) { + resolve(false); + return; + } + + const modal = window.ModalSystem.create({ + id: 'scriptRootEditConfirmation', + title: 'Edit Script Root URLs', + content: ` +
+ Editing the Root URLs is useful if you are hosting the scripts yourself or if you want to use a specific KefinTweaks version. There is no validation done for the URLs provided for these settings so please ensure they are valid. In any case, you can easily revert these settings. +
+ `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + const confirmBtn = modalInstance.dialogFooter?.querySelector('#confirmEditScriptRoot'); + const cancelBtn = modalInstance.dialogFooter?.querySelector('#cancelEditScriptRoot'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + modalInstance.close(); + resolve(true); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + modalInstance.close(); + resolve(false); + }); + } + } + }); + }); + } + + // Build the configuration page content + function buildConfigPageContent(config) { + const scripts = config.scripts || {}; + const homeScreen = config.homeScreen || {}; + const exclusiveElsewhere = config.exclusiveElsewhere || {}; + const search = config.search || {}; + const customMenuLinks = config.customMenuLinks || []; + const skins = config.skins || []; + const themes = config.themes || []; + + // Get all skin names for autocomplete + const allSkinNames = skins.map(skin => skin.name).filter(Boolean); + + return ` +
+
+
+
+

Script Root Configuration

+
Configure where KefinTweaks scripts are loaded from
+
+ +
+
+ +
+ +
+
+
+

Feature Configuration

+
Toggle individual KefinTweaks features on or off. Existing configurations are maintained when disabling features.
+
+
+
+ ${buildScriptToggles(scripts)} +
+
+ +
+
+
+

Home Screen Configuration

+
Configure custom home screen sections and discovery features
+
+
+
+ ${buildHomeScreenConfig(homeScreen)} +
+
+ +
+
+
+

Exclusive Elsewhere Configuration

+
Configure exclusive elsewhere branding behavior
+
+
+ ${buildExclusiveElsewhereConfig(exclusiveElsewhere)} +
+ + + +
+
+
+

Skin Configuration

+
Configure default skin and available skins for all users
+
+
+
+
+
Default Skin
+ +
Select a default skin for all users
+
+
+
+
+

Enable/Disable Skins

+
Disabled skins will not appear in the appearance dropdowns for users
+
+ ${buildSkinToggles(skins)} +
+
+
+
+
+
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 +
[
+  {
+    "name": "Custom Skin",
+    "author": "username",
+    "url": [
+      {
+        "majorServerVersions": [10, 11],
+        "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme.css"]
+      }
+    ],
+    "colorSchemes": []
+  },
+  {
+    "name": "Custom Skin 2",
+    "author": "username 2",
+    "url": [
+      {
+        "majorServerVersions": [10],
+        "urls": ["https://cdn.jsdelivr.net/gh/username/custom-theme2.css"]
+      }
+    ],
+    "colorSchemes": []
+  }
+]
+
+
+
+
+ +
+
+
+

Theme Configuration

+
Configure additional themes available to all users
+
+
+
+
+
Themes JSON
+
Add additional themes that will be available to all users. Each theme should have a name and URL pointing to a CSS file.
+ +
+ View Example Format +
[
+  {
+    "name": "Custom Dark Theme",
+    "url": "https://cdn.jsdelivr.net/gh/username/custom-dark-theme.css"
+  },
+  {
+    "name": "Custom Light Theme",
+    "url": "https://cdn.jsdelivr.net/gh/username/custom-light-theme.css"
+  }
+]
+
+
+
+
+ + + + `; + } + + // Extract GitHub repository URL from skin configuration + function getSkinGitHubUrl(skin) { + // Check if skin has explicit github field + if (skin.github) { + return skin.github; + } + + // Try to extract from jsdelivr GitHub CDN URLs + if (skin.url && Array.isArray(skin.url)) { + for (const urlObj of skin.url) { + if (urlObj.urls && Array.isArray(urlObj.urls)) { + for (const url of urlObj.urls) { + // Match pattern: https://cdn.jsdelivr.net/gh/{owner}/{repo}@... + const match = url.match(/https:\/\/cdn\.jsdelivr\.net\/gh\/([^\/]+)\/([^\/@]+)/); + if (match) { + const owner = match[1]; + const repo = match[2]; + return `https://github.com/${owner}/${repo}`; + } + } + } + } + } + + return null; + } + + // Build skin toggle switches + function buildSkinToggles(skins) { + return skins.map(skin => { + const isEnabled = skin.enabled !== false; // Default to true if not set + const isHidden = skin.hidden === true; + const githubUrl = getSkinGitHubUrl(skin); + + // Determine tooltip text + let tooltipText = ''; + if (isHidden) { + tooltipText = 'This skin is included automatically by KefinTweaks'; + } else if (githubUrl) { + tooltipText = 'View on GitHub'; + } + + return ` +
+
+
+
${skin.name}
+ ${githubUrl ? `info` : ''} +
+ +
+
+ `; + }).join(''); + } + + // Build script toggle switches + function buildScriptToggles(scripts) { + const scriptNames = [ + { key: 'homeScreen', label: 'Home Screen', desc: 'Custom home screen sections and discovery engine. These can be configured below.' }, + { key: 'search', label: 'Search', desc: 'Search speed is improved by searching less content types by default. Provides options to specify which content type to search. Fully compatible with Jellysearch Search from Jellyfin Enhanced plugin.' }, + { key: 'watchlist', label: 'Watchlist', desc: 'Allows your users to add items to their Watchlist. The Watchlist page shows an overview of all items on a user\'s Watchlist, as well as their Series Progress and Movie History. It also includes a Statistics page with an overview of your user watched stats.' }, + { key: 'headerTabs', label: 'Header Tabs', desc: 'Allows direct navigation to specific Tab sections of a given library page. Full support for default Jellyfin landing pages.' }, + { key: 'customMenuLinks', label: 'Custom Menu Links', desc: 'Add custom menu links to the left side navigation menu' }, + { key: 'breadcrumbs', label: 'Breadcrumbs', desc: 'Breadcrumb navigation for Movies, TV and Music. Allows quick navigation between Seasons/Episodes and Albums/Songs.' }, + { key: 'playlist', label: 'Playlist', desc: 'Improved Playlist page navigation: Items only play when the Play button is pressed. Clicking an item navigates to the item\'s page.' }, + { key: 'itemDetailsCollections', label: 'Item Details Collections', desc: 'Displays collections on item details pages (Included In section)' }, + { key: 'flattenSingleSeasonShows', label: 'Flatten Single Season Shows', desc: 'Flattens single-season shows to display episodes directly on series page' }, + { key: 'collections', label: 'Collections', desc: 'Collection sorting functionality on the Collection page' }, + { key: 'exclusiveElsewhere', label: 'Exclusive Elsewhere', desc: 'Custom branding when items aren\'t available on streaming services' }, + { key: 'backdropLeakFix', label: 'Backdrop Leak Fix', desc: 'Fixes memory leaks from backdrop images when tab isn\'t focused' }, + { key: 'dashboardButtonFix', label: 'Dashboard Button Fix', desc: 'Improved back button behavior on dashboard - prevents navigating back to the new tab browser page' }, + { key: 'infiniteScroll', label: 'Infinite Scroll', desc: 'Adds infinite scrolling to the library pages for Movies and TV' }, + { key: 'removeContinue', label: 'Remove Continue', desc: 'Adds the ability to remove items from the Continue Watching sections' }, + { key: 'subtitleSearch', label: 'Subtitle Search', desc: 'Search and download subtitles directly from the video OSD' }, + { key: 'skinManager', label: 'Skin Manager', desc: 'Skin selection and management - adds skin dropdown to header and display preferences' } + ]; + + return scriptNames.map(script => { + const isEnabled = scripts[script.key] !== false; // Default to true if not set + return ` +
+
+
+
${script.label}
+ +
+
${script.desc}
+
+
+ `; + }).join(''); + } + + // Build home screen configuration + function buildHomeScreenConfig(homeScreen) { + const exampleCustomSections = `[ + { + "name": "My Favorite Movies", + "id": "acc84f03475eb51602b13cde158d21ad", + "maxItems": 20, + "order": 5 + }, + { + "name": "Action Collection", + "id": "bdd95f14586eb51703c24def269e32be", + "maxItems": 15, + "order": 10 + } +]`; + + return ` +
+
+
Enable New and Trending
+ +
+
+
+
+
Enable New Movies
+ +
+
+
+
+
Enable New Episodes
+ +
+
+
+
+
Enable Trending
+ +
This feature is not yet implemented
+
+
+
+
+
Enable Discovery
+ +
+
+
+
+
Enable Infinite Scroll
+ +
+
+
+
+
Minimum People Appearances
+ +
Minimum movie appearances for people to be featured
+
+
+
+
+
Minimum Genre Movie Count
+ +
Minimum movie count for genres to be included
+
+
+
+
+
Minimum Shows For Network
+ +
Minimum show count for TV networks to be featured
+
+
+
+
+
Enable Watchlist
+ +
+
+
+
+
Enable Seasonal
+ +
+
+
+
+
Seasonal Item Limit
+ +
Maximum items to show in seasonal sections
+
+
+
+
+
Custom Sections JSON
+
Add custom playlist/collection-based sections. Get collection/playlist IDs from Jellyfin URLs.
+ +
+ View Example Format +
${exampleCustomSections}
+
+
+
+ `; + } + + // Build exclusive elsewhere configuration + function buildExclusiveElsewhereConfig(exclusiveElsewhere) { + return ` +
+
+
Hide Server Name
+
Set to true to hide server name, useful when you want to only show a logo or icon via CSS
+ +
+
+ `; + } + + // Build search configuration + function buildSearchConfig(search) { + return ` +
+
+
Enable Jellyseerr
+
Enable Jellyseerr integration for request functionality
+ +
+
+ `; + } + + // Open configuration modal + async function openConfigurationModal() { + console.log('[KefinTweaks Configuration] Opening configuration modal...'); + + // Inject CSS if not already injected + injectConfigModalCSS(); + + // Load configuration first + let config; + try { + config = await getKefinTweaksConfig(); + } catch (error) { + console.error('[KefinTweaks Configuration] Error loading config:', error); + alert('Error loading configuration. Please try again.'); + return; + } + + // Build the content (without title, as ModalSystem adds it) + const content = document.createElement('div'); + content.className = 'modal-content-wrapper'; + content.id = MODAL_ID; + content.innerHTML = ` +
+ ${buildConfigPageContent(config)} +
+ `; + + // Create footer with save, export, and import buttons + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.gap = '0.75em'; + footer.style.alignItems = 'center'; + footer.innerHTML = ` + + + + `; + + // Create modal using ModalSystem + if (!window.ModalSystem) { + console.error('[KefinTweaks Configuration] ModalSystem not available'); + alert('Modal system not available. Please ensure modal.js is loaded.'); + return; + } + + const modal = window.ModalSystem.create({ + id: MODAL_ID, + title: 'KefinTweaks Configuration', + content: content, + footer: footer, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + // Update modal dialog styling for large size + if (modalInstance && modalInstance.dialog) { + modalInstance.dialog.style.maxWidth = '90vw'; + modalInstance.dialog.style.width = '1400px'; + modalInstance.dialog.style.maxHeight = '90vh'; + } + + // Add event handlers + setupConfigModalHandlers(modalInstance, config); + } + }); + } + + // Set up event handlers for configuration modal + function setupConfigModalHandlers(modalInstance, config) { + // Edit script root button handler + const editBtn = modalInstance.dialogContent.querySelector('#editScriptRootBtn'); + if (editBtn) { + editBtn.addEventListener('click', async () => { + const confirmed = await showScriptRootEditConfirmation(); + if (confirmed) { + const contentDiv = modalInstance.dialogContent.querySelector('#scriptRootConfigContent'); + if (contentDiv) { + contentDiv.style.display = 'block'; + } + } + }); + } + + // No validation needed for default skin dropdown - it only allows valid options + + // Home Screen: Toggle visibility of New Movies/Episodes/Trending based on New and Trending + const enableNewAndTrendingCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_enableNewAndTrending'); + if (enableNewAndTrendingCheckbox) { + const newMoviesContainer = modalInstance.dialogContent.querySelector('#homeScreen_newMoviesContainer'); + const newEpisodesContainer = modalInstance.dialogContent.querySelector('#homeScreen_newEpisodesContainer'); + const trendingContainer = modalInstance.dialogContent.querySelector('#homeScreen_trendingContainer'); + + const toggleNewMoviesEpisodesTrendingVisibility = () => { + const isEnabled = enableNewAndTrendingCheckbox.checked; + if (newMoviesContainer) { + newMoviesContainer.style.display = isEnabled ? '' : 'none'; + } + if (newEpisodesContainer) { + newEpisodesContainer.style.display = isEnabled ? '' : 'none'; + } + if (trendingContainer) { + trendingContainer.style.display = isEnabled ? '' : 'none'; + } + }; + + enableNewAndTrendingCheckbox.addEventListener('change', toggleNewMoviesEpisodesTrendingVisibility); + } + + // Home Screen: Toggle visibility of Infinite Scroll based on Discovery + const enableDiscoveryCheckbox = modalInstance.dialogContent.querySelector('#homeScreen_enableDiscovery'); + if (enableDiscoveryCheckbox) { + const infiniteScrollContainer = modalInstance.dialogContent.querySelector('#homeScreen_infiniteScrollContainer'); + + const toggleInfiniteScrollVisibility = () => { + const isEnabled = enableDiscoveryCheckbox.checked; + if (infiniteScrollContainer) { + infiniteScrollContainer.style.display = isEnabled ? '' : 'none'; + } + }; + + enableDiscoveryCheckbox.addEventListener('change', toggleInfiniteScrollVisibility); + } + + // Toggle visibility of configuration sections based on script enable state + const scriptCheckboxes = { + search: modalInstance.dialogContent.querySelector('#script_search'), + exclusiveElsewhere: modalInstance.dialogContent.querySelector('#script_exclusiveElsewhere'), + skinManager: modalInstance.dialogContent.querySelector('#script_skinManager'), + customMenuLinks: modalInstance.dialogContent.querySelector('#script_customMenuLinks') + }; + + const configSections = { + search: modalInstance.dialogContent.querySelector('#configSection_search'), + exclusiveElsewhere: modalInstance.dialogContent.querySelector('#configSection_exclusiveElsewhere'), + skin: modalInstance.dialogContent.querySelector('#configSection_skin'), + theme: modalInstance.dialogContent.querySelector('#configSection_theme'), + customMenuLinks: modalInstance.dialogContent.querySelector('#configSection_customMenuLinks') + }; + + // Function to toggle section visibility + const toggleSectionVisibility = (scriptKey, sectionKey) => { + const checkbox = scriptCheckboxes[scriptKey]; + const section = configSections[sectionKey]; + + if (checkbox && section) { + const isEnabled = checkbox.checked; + section.style.display = isEnabled ? '' : 'none'; + } + }; + + // Set up event listeners for each script checkbox + if (scriptCheckboxes.search) { + scriptCheckboxes.search.addEventListener('change', () => toggleSectionVisibility('search', 'search')); + } + + if (scriptCheckboxes.exclusiveElsewhere) { + scriptCheckboxes.exclusiveElsewhere.addEventListener('change', () => toggleSectionVisibility('exclusiveElsewhere', 'exclusiveElsewhere')); + } + + if (scriptCheckboxes.skinManager) { + scriptCheckboxes.skinManager.addEventListener('change', () => { + toggleSectionVisibility('skinManager', 'skin'); + toggleSectionVisibility('skinManager', 'theme'); + }); + } + + if (scriptCheckboxes.customMenuLinks) { + scriptCheckboxes.customMenuLinks.addEventListener('change', () => toggleSectionVisibility('customMenuLinks', 'customMenuLinks')); + } + + // Save button handler + const saveBtn = modalInstance.dialogFooter?.querySelector('#saveConfigBtn'); + if (saveBtn) { + saveBtn.addEventListener('click', () => handleSaveConfig(modalInstance)); + } + + // Export button handler + const exportBtn = modalInstance.dialogFooter?.querySelector('#exportConfigBtn'); + if (exportBtn) { + exportBtn.addEventListener('click', () => handleExportConfig(config)); + } + + // Import button handler + const importBtn = modalInstance.dialogFooter?.querySelector('#importConfigBtn'); + if (importBtn) { + importBtn.addEventListener('click', () => handleImportConfig(modalInstance)); + } + } + + // Export configuration to clipboard + async function handleExportConfig(config) { + try { + const configJson = JSON.stringify(config, null, 2); + await navigator.clipboard.writeText(configJson); + + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration exported to clipboard!'); + } else { + alert('Configuration exported to clipboard!'); + } + } catch (error) { + console.error('[KefinTweaks Configuration] Error exporting config:', error); + + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Error exporting configuration to clipboard', '5'); + } else { + alert('Error exporting configuration to clipboard'); + } + } + } + + // Import configuration from clipboard + async function handleImportConfig(modalInstance) { + if (!window.ModalSystem) { + alert('Modal system not available'); + return; + } + + // Show import modal with textarea + const importModal = window.ModalSystem.create({ + id: 'importConfigModal', + title: 'Import Configuration', + content: ` +
+ Paste your configuration JSON below. This will completely replace your current configuration. +
+ + `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (importModalInstance) => { + const confirmBtn = importModalInstance.dialogFooter?.querySelector('#confirmImportBtn'); + const cancelBtn = importModalInstance.dialogFooter?.querySelector('#cancelImportBtn'); + const textarea = importModalInstance.dialogContent.querySelector('#importConfigTextarea'); + + // Focus textarea + if (textarea) { + setTimeout(() => textarea.focus(), 100); + } + + if (confirmBtn) { + confirmBtn.addEventListener('click', async () => { + const jsonText = textarea?.value.trim() || ''; + + if (!jsonText) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Please paste configuration JSON', '3'); + } else { + alert('Please paste configuration JSON'); + } + return; + } + + // Validate JSON + let importedConfig; + try { + importedConfig = JSON.parse(jsonText); + } catch (parseError) { + console.error('[KefinTweaks Configuration] Invalid JSON:', parseError); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast(`Invalid JSON: ${parseError.message}`, '5'); + } else { + alert(`Invalid JSON: ${parseError.message}`); + } + return; + } + + // Validate config structure (basic check) + if (typeof importedConfig !== 'object' || importedConfig === null) { + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Invalid configuration format', '5'); + } else { + alert('Invalid configuration format'); + } + return; + } + + // Show confirmation dialog + const confirmed = await showImportConfirmation(); + if (!confirmed) { + return; + } + + // Close import modal + importModalInstance.close(); + + // Apply imported config + try { + await applyImportedConfig(importedConfig, modalInstance); + } catch (error) { + console.error('[KefinTweaks Configuration] Error applying imported config:', error); + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast(`Error applying configuration: ${error.message}`, '5'); + } else { + alert(`Error applying configuration: ${error.message}`); + } + } + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + importModalInstance.close(); + }); + } + } + }); + } + + // Show confirmation dialog for import + function showImportConfirmation() { + return new Promise((resolve) => { + if (!window.ModalSystem) { + resolve(false); + return; + } + + const modal = window.ModalSystem.create({ + id: 'importConfirmation', + title: 'Confirm Import', + content: ` +
+ This will completely overwrite your current configuration. All existing settings will be replaced with the imported configuration. This action cannot be undone. +
+ `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (modalInstance) => { + const confirmBtn = modalInstance.dialogFooter?.querySelector('#confirmImportConfirmBtn'); + const cancelBtn = modalInstance.dialogFooter?.querySelector('#cancelImportConfirmBtn'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', () => { + modalInstance.close(); + resolve(true); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + modalInstance.close(); + resolve(false); + }); + } + } + }); + }); + } + + // Apply imported configuration + async function applyImportedConfig(importedConfig, modalInstance) { + // Save to JavaScript Injector + await saveConfigToJavaScriptInjector(importedConfig); + + // Reload the modal with new config + modalInstance.close(); + + // Show success message + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('Configuration imported successfully! Reloading...'); + } + + // Reopen modal with new config after a short delay + setTimeout(() => { + openConfigurationModal(); + }, 500); + } + + // Find JavaScript Injector plugin + async function findJavaScriptInjectorPlugin() { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + throw new Error('ApiClient not available'); + } + + const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); + + const response = await fetch(`${server}/Plugins`, { + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const plugins = await response.json(); + console.log('[KefinTweaks Configuration] Active Plugins:', plugins); + + // Handle both array and object with Items property + const pluginsList = Array.isArray(plugins) ? plugins : (plugins.Items || []); + + // Find JavaScript Injector plugin + const injectorPlugin = pluginsList.find(plugin => + plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector' + ); + + if (!injectorPlugin) { + throw new Error('JavaScript Injector plugin not found. Please ensure it is installed.'); + } + + console.log('[KefinTweaks Configuration] Found JavaScript Injector plugin:', injectorPlugin); + return injectorPlugin.Id; + } catch (error) { + console.error('[KefinTweaks Configuration] Error finding JavaScript Injector plugin:', error); + throw error; + } + } + + // Get current JavaScript Injector configuration + async function getJavaScriptInjectorConfig(pluginId) { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + throw new Error('ApiClient not available'); + } + + const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); + + const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const config = await response.json(); + console.log('[KefinTweaks Configuration] Current JS Injector config:', config); + return config; + } catch (error) { + console.error('[KefinTweaks Configuration] Error getting JS Injector config:', error); + throw error; + } + } + + // Save configuration to JavaScript Injector plugin + async function saveConfigToJavaScriptInjector(config) { + try { + // Find the plugin + const pluginId = await findJavaScriptInjectorPlugin(); + + // Get fresh configuration data + const injectorConfig = await getJavaScriptInjectorConfig(pluginId); + + // Ensure CustomJavaScripts array exists + if (!injectorConfig.CustomJavaScripts) { + injectorConfig.CustomJavaScripts = []; + } + + // Create the script content + const scriptContent = `// KefinTweaks Configuration +// This file is automatically generated by KefinTweaks Configuration UI +// Do not edit manually unless you know what you're doing + +window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; + + // Check if KefinTweaks-Config already exists + const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( + script => script.Name === 'KefinTweaks-Config' + ); + + if (existingScriptIndex !== -1) { + // Update existing script + console.log('[KefinTweaks Configuration] Updating existing KefinTweaks-Config script'); + injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; + // Keep existing Enabled and RequiresAuthentication settings + } else { + // Add new script + console.log('[KefinTweaks Configuration] Adding new KefinTweaks-Config script'); + injectorConfig.CustomJavaScripts.push({ + Name: 'KefinTweaks-Config', + Script: scriptContent, + Enabled: true, + RequiresAuthentication: false + }); + } + + // POST the updated configuration back + const server = ApiClient._serverAddress; + const token = ApiClient.accessToken(); + + const response = await fetch(`${server}/Plugins/${pluginId}/Configuration`, { + method: 'POST', + headers: { + 'X-Emby-Token': token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(injectorConfig) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + console.log('[KefinTweaks Configuration] Configuration saved successfully'); + return true; + } catch (error) { + console.error('[KefinTweaks Configuration] Error saving configuration:', error); + throw error; + } + } + + // Handle save configuration + async function handleSaveConfig(modalInstance) { + // Verify admin status before saving + const userIsAdmin = await isAdmin(); + if (!userIsAdmin) { + alert('You must be an administrator to save configuration.'); + return; + } + + console.log('[KefinTweaks Configuration] Saving configuration...'); + + // Collect all form values + const config = { + kefinTweaksRoot: document.getElementById('kefinTweaksRoot')?.value || '', + scriptRoot: document.getElementById('scriptRoot')?.value || '', + scripts: {}, + homeScreen: {}, + exclusiveElsewhere: {}, + search: {}, + defaultSkin: document.getElementById('defaultSkin')?.value || null, + skins: [], + themes: [], + customMenuLinks: [] + }; + + // Collect script toggles + document.querySelectorAll('[data-script-key]').forEach(checkbox => { + const key = checkbox.getAttribute('data-script-key'); + config.scripts[key] = checkbox.checked; + }); + + // Collect home screen config + config.homeScreen = { + enableNewAndTrending: document.getElementById('homeScreen_enableNewAndTrending')?.checked !== false, + enableNewMovies: document.getElementById('homeScreen_enableNewMovies')?.checked !== false, + enableNewEpisodes: document.getElementById('homeScreen_enableNewEpisodes')?.checked !== false, + enableTrending: document.getElementById('homeScreen_enableTrending')?.checked === true, + enableDiscovery: document.getElementById('homeScreen_enableDiscovery')?.checked !== false, + enableInfiniteScroll: document.getElementById('homeScreen_enableInfiniteScroll')?.checked !== false, + minPeopleAppearances: parseInt(document.getElementById('homeScreen_minPeopleAppearances')?.value || '10'), + minGenreMovieCount: parseInt(document.getElementById('homeScreen_minGenreMovieCount')?.value || '50'), + minimumShowsForNetwork: parseInt(document.getElementById('homeScreen_minimumShowsForNetwork')?.value || '5'), + enableWatchlist: document.getElementById('homeScreen_enableWatchlist')?.checked !== false, + enableSeasonal: document.getElementById('homeScreen_enableSeasonal')?.checked !== false, + seasonalItemLimit: parseInt(document.getElementById('homeScreen_seasonalItemLimit')?.value || '16'), + customSections: parseJSONField('homeScreen_customSections', []) + }; + + // Collect exclusive elsewhere config + config.exclusiveElsewhere = { + hideServerName: document.getElementById('exclusiveElsewhere_hideServerName')?.checked === true + }; + + // Collect search config + config.search = { + enableJellyseerr: document.getElementById('search_enableJellyseerr')?.checked === true + }; + + // Parse JSON fields + const skinsFromJson = parseJSONField('skinsJson', []); + config.themes = parseJSONField('themesJson', []); + config.customMenuLinks = parseJSONField('customMenuLinksJson', []); + + // 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); + + // Collect enabled states from toggles + 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]; + + // 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]; + } + }); + + // Handle defaultSkin + if (config.defaultSkin === '') { + config.defaultSkin = null; + } + + console.log('[KefinTweaks Configuration] Configuration collected:', config); + + // Save to JavaScript Injector plugin + try { + await saveConfigToJavaScriptInjector(config); + + // Show success toast + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast('KefinTweaks saved!'); + } + } catch (error) { + console.error('[KefinTweaks Configuration] Error saving configuration:', error); + + // Show error toast + if (window.KefinTweaksToaster && window.KefinTweaksToaster.toast) { + window.KefinTweaksToaster.toast(`Error saving configuration: ${error.message}. Please ensure the JavaScript Injector plugin is installed and you have administrator permissions.`, '5'); + } else { + // Fallback to alert if toaster is not available + alert(`Error saving configuration: ${error.message}\n\nPlease ensure the JavaScript Injector plugin is installed and you have administrator permissions.`); + } + } + } + + // Helper to parse JSON fields safely + function parseJSONField(fieldId, defaultValue) { + try { + const field = document.getElementById(fieldId); + if (!field || !field.value.trim()) { + return defaultValue; + } + return JSON.parse(field.value); + } catch (error) { + console.error(`[KefinTweaks Configuration] Error parsing JSON field ${fieldId}:`, error); + alert(`Error parsing JSON in ${fieldId}: ${error.message}`); + return defaultValue; + } + } + + // Create configuration button for Administration section + function createConfigButton() { + const button = document.createElement('a'); + button.setAttribute('is', 'emby-linkbutton'); + button.style.display = 'block'; + button.style.padding = '0'; + button.style.margin = '0'; + button.className = 'listItem-border emby-button'; + button.setAttribute('data-kefintweaks-config-button', 'true'); + button.href = '#'; + button.onclick = (e) => { + e.preventDefault(); + openConfigurationModal(); + }; + + button.innerHTML = ` +
+ +
+
Configure KefinTweaks
+
+
+ `; + + return button; + } + + // Create dashboard side menu button + function createDashboardConfigButton() { + // Check if button already exists + if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) { + return null; + } + + const div = document.createElement('div'); + div.className = 'MuiButtonBase-root MuiListItemButton-root MuiListItemButton-gutters MuiListItemButton-root MuiListItemButton-gutters kefin-config-button'; + div.setAttribute('tabindex', '0'); + div.setAttribute('data-kefintweaks-dashboard-config-button', 'true'); + div.onclick = () => { + openConfigurationModal(); + }; + + // Create icon with Material icon + const icon = document.createElement('div'); + icon.className = 'MuiListItemIcon-root css-37qkju'; + const iconSpan = document.createElement('span'); + iconSpan.className = 'material-icons build MuiSvgIcon-root MuiSvgIcon-fontSizeMedium'; + iconSpan.style.marginRight = '12px'; + iconSpan.setAttribute('aria-hidden', 'true'); + icon.appendChild(iconSpan); + + // Create text + const text = document.createElement('div'); + text.className = 'MuiListItemText-root css-f696uz'; + const span = document.createElement('span'); + span.className = 'MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-hlxkyz'; + span.textContent = 'KefinTweaks'; + text.appendChild(span); + + // Create ripple effect span + const ripple = document.createElement('span'); + ripple.className = 'MuiTouchRipple-root css-w0pj6f'; + + div.appendChild(icon); + div.appendChild(text); + div.appendChild(ripple); + + return div; + } + + // Add configuration button to dashboard side menu + function addConfigButtonToDashboard() { + // Check if button already exists + if (document.querySelector('[data-kefintweaks-dashboard-config-button]')) { + return; + } + + // Find the plugins section + const pluginsSection = document.querySelector('ul[aria-labelledby="plugins-subheader"]'); + if (!pluginsSection) { + console.log('[KefinTweaks Configuration] Plugins section not found, retrying...'); + setTimeout(addConfigButtonToDashboard, 500); + return; + } + + // Create the button + const button = createDashboardConfigButton(); + if (!button) { + return; + } + + // Add button to the plugins section + pluginsSection.appendChild(button); + + console.log('[KefinTweaks Configuration] Configuration button added to dashboard side menu'); + } + + // Add configuration button to Administration section + function addConfigButtonToAdminSection() { + // Check if button already exists (check for admin section button specifically) + if (document.querySelector('.adminSection [data-kefintweaks-config-button]')) { + return; + } + + // Find the Administration section + const adminSection = document.querySelector('.adminSection.verticalSection'); + if (!adminSection) { + console.log('[KefinTweaks Configuration] Administration section not found, retrying...'); + setTimeout(addConfigButtonToAdminSection, 500); + return; + } + + // Create and add the button + const button = createConfigButton(); + adminSection.appendChild(button); + + console.log('[KefinTweaks Configuration] Configuration button added to Administration section'); + } + + // Register onViewPage handler for mypreferencesmenu page and dashboard + function registerViewPageHandler() { + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { + console.log('[KefinTweaks Configuration] KefinTweaksUtils.onViewPage not available, retrying...'); + setTimeout(registerViewPageHandler, 1000); + return; + } + + // Handler for dashboard and preferences pages + window.KefinTweaksUtils.onViewPage(async (view, element, hash) => { + // Check if user is admin before adding buttons + const userIsAdmin = await isAdmin(); + if (!userIsAdmin) { + console.log('[KefinTweaks Configuration] User is not admin, not adding buttons'); + return; + } + + // Check if we're on the mypreferencesmenu page + if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) { + console.log('[KefinTweaks Configuration] Preferences page detected'); + // Wait a bit for the page to fully load + setTimeout(() => { + addConfigButtonToAdminSection(); + }, 500); + } + + // Check if we're on the dashboard page + if (view === 'dashboard' || view === 'configurationpage' || hash.includes('dashboard') || hash.includes('configurationpage')) { + console.log('[KefinTweaks Configuration] Dashboard page detected'); + // Wait a bit for the page to fully load + setTimeout(() => { + addConfigButtonToDashboard(); + }, 500); + } + }); + + console.log('[KefinTweaks Configuration] Registered onViewPage handler'); + } + + // Add configuration link to custom menu + async function addConfigLinkToCustomMenu() { + // Check if user is admin + const userIsAdmin = await isAdmin(); + if (!userIsAdmin) { + console.log('[KefinTweaks Configuration] User is not admin, not adding menu link'); + return; + } + + // Check if utils is available + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.addCustomMenuLink) { + console.log('[KefinTweaks Configuration] KefinTweaksUtils.addCustomMenuLink not available, retrying...'); + setTimeout(addConfigLinkToCustomMenu, 1000); + return; + } + + // Use a special URL that we can intercept + const configUrl = '#'; + + // Add the menu link + const success = await window.KefinTweaksUtils.addCustomMenuLink( + 'Configure', + 'build', + configUrl, + false + ); + + if (success) { + console.log('[KefinTweaks Configuration] Configuration link added to custom menu'); + + // Set up click handler to intercept navigation and open modal instead + setupConfigLinkClickHandler(); + } + } + + // Set up click handler for configuration menu link + function setupConfigLinkClickHandler() { + // Find the config button + const configButton = document.querySelector('.navMenuOption:has(.build)'); + if (!configButton) { + console.log('[KefinTweaks Configuration] Configuration button not found, retrying...'); + setTimeout(setupConfigLinkClickHandler, 1000); + return; + } + + // Add click handler to the config button + configButton.addEventListener('click', (e) => { + e.preventDefault(); + openConfigurationModal(); + }); + } + + // Handle direct navigation to config URL + function handleDirectConfigNavigation() { + if (window.location.hash === '#kefintweaks-config') { + console.log('[KefinTweaks Configuration] Direct navigation to config URL detected'); + // Remove the hash to prevent navigation + window.history.replaceState(null, '', window.location.pathname + window.location.search); + // Open modal + openConfigurationModal(); + } + } + + // Set up hashchange listener for navigation after page load + function setupHashChangeListener() { + window.addEventListener('hashchange', () => { + if (window.location.hash === '#kefintweaks-config') { + console.log('[KefinTweaks Configuration] Hash change to config URL detected'); + // Remove the hash to prevent navigation + window.history.replaceState(null, '', window.location.pathname + window.location.search); + // Open modal + openConfigurationModal(); + } + }); + } + + // Initialize when DOM is ready + async function initialize() { + if (!window.ApiClient || !window.ApiClient._loggedIn) { + setTimeout(initialize, 1000); + return; + } + + // Register view page handler + registerViewPageHandler(); + + // Add configuration link to custom menu + addConfigLinkToCustomMenu(); + + // Set up hashchange listener + setupHashChangeListener(); + + // Handle direct navigation to config URL + handleDirectConfigNavigation(); + + // Also check current page on initial load + const hash = window.location.hash; + const userIsAdmin = await isAdmin(); + + if (userIsAdmin) { + if (hash && (hash.includes('mypreferencesmenu') || hash.includes('userpreferences') || hash.includes('preferences'))) { + setTimeout(() => { + addConfigButtonToAdminSection(); + }, 1000); + } + + if (hash && hash.includes('dashboard')) { + setTimeout(() => { + addConfigButtonToDashboard(); + }, 1000); + } + } + } + + // Start initialization + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } + + console.log('[KefinTweaks Configuration] Script loaded'); +})(); diff --git a/scripts/modal.js b/scripts/modal.js index 26f7f99..9bcd13d 100644 --- a/scripts/modal.js +++ b/scripts/modal.js @@ -14,11 +14,13 @@ window.ModalSystem = (function() { * @param {Object} options - Modal configuration * @param {string} options.id - Unique modal ID * @param {string} options.title - Modal title - * @param {string} options.content - HTML content for the modal body + * @param {string|HTMLElement} options.content - HTML content for the modal body + * @param {string|HTMLElement} options.footer - Optional footer HTML content * @param {Function} options.onClose - Callback when modal closes * @param {Function} options.onOpen - Callback when modal opens * @param {boolean} options.closeOnBackdrop - Whether to close when clicking backdrop (default: true) * @param {boolean} options.closeOnEscape - Whether to close on Escape key (default: true) + * @param {boolean} options.showCloseButton - Whether to show close button in header (default: true if title exists) * @returns {Object} Modal instance */ function createModal(options = {}) { @@ -26,10 +28,12 @@ window.ModalSystem = (function() { id, title, content, + footer, onClose, onOpen, closeOnBackdrop = true, - closeOnEscape = true + closeOnEscape = true, + showCloseButton = true } = options; if (!id) { @@ -54,30 +58,86 @@ window.ModalSystem = (function() { dialog.setAttribute('data-autofocus', 'true'); dialog.setAttribute('data-removeonclose', 'true'); dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; + dialog.style.display = 'flex'; + dialog.style.flexDirection = 'column'; + dialog.style.maxHeight = '90vh'; - const dialogContent = document.createElement('div'); - dialogContent.style.margin = '0'; - dialogContent.style.padding = '1.25em 1.5em 1.5em'; - - // Add title if provided + // Create header if title is provided + let dialogHeader = null; if (title) { + dialogHeader = document.createElement('div'); + dialogHeader.className = 'formDialogHeader'; + dialogHeader.style.display = 'flex'; + dialogHeader.style.justifyContent = 'space-between'; + dialogHeader.style.alignItems = 'center'; + dialogHeader.style.padding = '1.25em 1.5em'; + dialogHeader.style.borderBottom = '1px solid rgba(255,255,255,0.1)'; + dialogHeader.style.flexShrink = '0'; + const titleElement = document.createElement('h2'); - titleElement.style.margin = '0 0 .5em'; + titleElement.style.margin = '0'; + titleElement.style.textAlign = 'left'; titleElement.textContent = title; - dialogContent.appendChild(titleElement); + dialogHeader.appendChild(titleElement); + + // Add close button if enabled + if (showCloseButton) { + const closeButton = document.createElement('button'); + closeButton.setAttribute('is', 'paper-icon-button-light'); + closeButton.className = 'btnCancel btnClose autoSize paper-icon-button-light'; + closeButton.setAttribute('tabindex', '-1'); + closeButton.title = 'Close'; + closeButton.onclick = () => closeModal(id); + + const closeIcon = document.createElement('span'); + closeIcon.className = 'material-icons close'; + closeIcon.setAttribute('aria-hidden', 'true'); + closeButton.appendChild(closeIcon); + + dialogHeader.appendChild(closeButton); + } + + dialog.appendChild(dialogHeader); } + // Create scrollable content area + const dialogContent = document.createElement('div'); + dialogContent.style.marginBottom = '5em'; + dialogContent.style.padding = title || footer ? '1.25em 1.5em' : '1.25em 1.5em 1.5em'; + dialogContent.style.overflowY = 'auto'; + dialogContent.style.flex = '1'; + dialogContent.style.minHeight = '0'; + // Add content if (content) { if (typeof content === 'string') { - dialogContent.innerHTML += content; + dialogContent.innerHTML = content; } else if (content instanceof HTMLElement) { dialogContent.appendChild(content); } } + // Create footer if provided + let dialogFooter = null; + if (footer) { + dialogFooter = document.createElement('div'); + dialogFooter.className = 'formDialogFooter'; + dialogFooter.style.padding = '1.25em 1.5em'; + dialogFooter.style.borderTop = '1px solid rgba(255,255,255,0.1)'; + dialogFooter.style.flexShrink = '0'; + + if (typeof footer === 'string') { + dialogFooter.innerHTML = footer; + } else if (footer instanceof HTMLElement) { + dialogFooter.appendChild(footer); + } + } + // Assemble modal dialog.appendChild(dialogContent); + if (dialogFooter) { + dialog.appendChild(dialogFooter); + } dialogContainer.appendChild(dialog); // Add to DOM @@ -91,6 +151,8 @@ window.ModalSystem = (function() { dialogContainer, dialog, dialogContent, + dialogHeader, + dialogFooter, isOpen: true, close: () => closeModal(id), updateContent: (newContent) => updateModalContent(id, newContent), diff --git a/scripts/search.css b/scripts/search.css index 432ac63..0d774b2 100644 --- a/scripts/search.css +++ b/scripts/search.css @@ -59,4 +59,11 @@ label[for="searchTextInput"] { .input-wrapper { position: relative; +} + +span.searchfields-icon { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); } \ No newline at end of file diff --git a/scripts/search.js b/scripts/search.js index cb683fd..f9cbaef 100644 --- a/scripts/search.js +++ b/scripts/search.js @@ -36,12 +36,30 @@ xhr.send = function() { // Simulate a successful response setTimeout(() => { - xhr.readyState = 4; - xhr.status = 200; - xhr.responseText = '{"Items":[],"TotalRecordCount":0}'; + // Use Object.defineProperty to override read-only properties + Object.defineProperty(xhr, 'readyState', { + value: 4, + writable: true, + configurable: true + }); + Object.defineProperty(xhr, 'status', { + value: 200, + writable: true, + configurable: true + }); + Object.defineProperty(xhr, 'responseText', { + value: '{"Items":[],"TotalRecordCount":0}', + writable: true, + configurable: true + }); + + // Trigger the event handlers if (xhr.onreadystatechange) { xhr.onreadystatechange(); } + if (xhr.onload) { + xhr.onload(); + } }, 100); }; } else { @@ -581,7 +599,9 @@ smartMusicBtn.addEventListener('click', ()=>setSearchType('music')); smartBooksBtn.addEventListener('click', ()=>setSearchType('books')); smartAllBtn.addEventListener('click', ()=>setSearchType('all')); - smartRequestBtn.addEventListener('click', ()=>setSearchType('request')); + if (smartRequestBtn) { + smartRequestBtn.addEventListener('click', ()=>setSearchType('request')); + } // Parse type parameter from URL let typeFromUrl = 'videos'; // default diff --git a/scripts/skinManager.js b/scripts/skinManager.js index 99d9149..4721d3f 100644 --- a/scripts/skinManager.js +++ b/scripts/skinManager.js @@ -208,18 +208,15 @@ // Load and merge skin configurations function loadSkinConfig() { - const userSkins = window.KefinTweaksUserConfig?.skins || []; - const additionalSkins = window.KefinTweaksConfig?.skins || []; + // Only load from window.KefinTweaksConfig (from JS Injector) + const configSkins = window.KefinTweaksConfig?.skins || []; - // Start with user skins (these take priority) - SKINS_CONFIG = [...userSkins]; - - // Add additional skins from main config, avoiding duplicates by name - additionalSkins.forEach(skin => { - const exists = SKINS_CONFIG.some(existingSkin => existingSkin.name === skin.name); - if (!exists) { - SKINS_CONFIG.push(skin); - } + // Filter to only include enabled skins (enabled !== false) + // Default to enabled if the property is not set + SKINS_CONFIG = configSkins.filter(skin => { + // Include skin if enabled is not explicitly false + // If enabled is undefined/null, treat as enabled (true) + return skin.enabled !== false; }); // Ensure we always have at least a default skin @@ -241,7 +238,9 @@ LOG(`Filtered skins by server version: ${skinsBeforeFilter} -> ${skinsAfterFilter} (removed ${skinsBeforeFilter - skinsAfterFilter} incompatible skins)`); } - LOG(`Loaded ${SKINS_CONFIG.length} skins (${userSkins.length} user + ${additionalSkins.length} additional)`); + const enabledCount = configSkins.filter(s => s.enabled !== false).length; + const disabledCount = configSkins.length - enabledCount; + LOG(`Loaded ${SKINS_CONFIG.length} enabled skins from KefinTweaksConfig (${enabledCount} enabled, ${disabledCount} disabled)`); } // Load and merge theme configurations @@ -374,8 +373,13 @@ const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); if (!selectedSkin) { - ERR('Selected skin not found during verification'); - return false; + ERR('Selected skin not found, reverting to default skin'); + const defaultSkinName = getDefaultSkinName(); + const defaultSkin = SKINS_CONFIG.find(skin => skin.name === defaultSkinName); + if (defaultSkin) { + loadSkin(defaultSkin); + } + return true; } // Check if skin CSS is properly loaded diff --git a/scripts/subtitleSearch.js b/scripts/subtitleSearch.js index 7567d78..91ec116 100644 --- a/scripts/subtitleSearch.js +++ b/scripts/subtitleSearch.js @@ -16,7 +16,8 @@ // State management let isInitialized = false; let isAddingCCButton = false; - let subtitleDialogObserver = null; + let onViewPageUnregister = null; + let dialogCheckInterval = null; // Configuration const CONFIG = { @@ -1020,6 +1021,66 @@ }); } + /** + * Start checking for subtitle dialogs periodically (only when on video page) + * This is much more efficient than a MutationObserver watching the entire document + */ + function startDialogCheck() { + // Clear any existing interval + if (dialogCheckInterval) { + clearInterval(dialogCheckInterval); + } + + // Check for dialogs periodically while on video page + // The interval is automatically stopped when navigating away via onViewPage + dialogCheckInterval = setInterval(() => { + addSearchButtonToExistingDialogs(); + }, 500); // Check every 500ms + } + + /** + * Stop checking for subtitle dialogs + */ + function stopDialogCheck() { + if (dialogCheckInterval) { + clearInterval(dialogCheckInterval); + dialogCheckInterval = null; + } + } + + + /** + * Initialize subtitle search functionality for video page + */ + async function initializeForVideoPage() { + try { + // Check if ApiClient is available first + if (!isApiClientAvailable()) { + LOG('ApiClient not available, skipping initialization'); + return; + } + + // Check permissions first + const hasPermission = await checkSubtitlePermissions(); + if (!hasPermission) { + LOG('User does not have subtitle management permission, skipping initialization'); + return; + } + + LOG('User has subtitle management permission, initializing for video page...'); + + // Add CC button if it doesn't exist + addCCButton(); + + // Start checking for subtitle dialogs + startDialogCheck(); + + LOG('Subtitle search functionality initialized for video page'); + + } catch (error) { + ERR('Error initializing subtitle search:', error); + } + } /** * Initialize subtitle search functionality @@ -1046,37 +1107,36 @@ LOG('User has subtitle management permission, initializing...'); - // Add CC button if it doesn't exist + // Wait for KefinTweaksUtils to be available + if (!window.KefinTweaksUtils || !window.KefinTweaksUtils.onViewPage) { + LOG('KefinTweaksUtils.onViewPage not available, retrying in 1 second'); + setTimeout(initialize, 1000); + return; + } + + // Register onViewPage handler for video page + onViewPageUnregister = window.KefinTweaksUtils.onViewPage(async (view, element, hash) => { + if (hash && hash.includes('#/video')) { + LOG('Video page detected via onViewPage, initializing...'); + // Small delay to ensure OSD is ready + setTimeout(() => { + initializeForVideoPage(); + }, 500); + } else { + // Stop dialog checking when not on video page + stopDialogCheck(); + } + }, { + pages: ['video'] + }); + + // Also check current page on initial load if (window.location.hash.includes('#/video')) { - LOG('Video page detected, adding CC button'); - addCCButton(); + setTimeout(() => { + initializeForVideoPage(); + }, 500); } - // Set up observer for subtitle dialogs - if (subtitleDialogObserver) { - subtitleDialogObserver.disconnect(); - } - - subtitleDialogObserver = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'childList') { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - // Check if a subtitle dialog was added - if (node.classList && node.classList.contains('dialogContainer')) { - setTimeout(() => addSearchButtonToExistingDialogs(), 100); - } - } - }); - } - }); - }); - - subtitleDialogObserver.observe(document.body, { - childList: true, - subtree: true - }); - isInitialized = true; LOG('Subtitle search functionality initialized successfully'); @@ -1089,10 +1149,13 @@ * Clean up resources */ function cleanup() { - if (subtitleDialogObserver) { - subtitleDialogObserver.disconnect(); - subtitleDialogObserver = null; + stopDialogCheck(); + + if (onViewPageUnregister) { + onViewPageUnregister(); + onViewPageUnregister = null; } + isInitialized = false; LOG('Subtitle search functionality cleaned up'); } @@ -1104,19 +1167,6 @@ initialize(); } - // Re-initialize when navigating (for SPA behavior) - let lastUrl = location.href; - new MutationObserver(() => { - const url = location.href; - if (url !== lastUrl) { - lastUrl = url; - setTimeout(() => { - cleanup(); - initialize(); - }, 1000); - } - }).observe(document, { subtree: true, childList: true }); - // Expose functions globally for debugging window.subtitleSearchCleanup = cleanup; window.getSession = getSession; diff --git a/scripts/watchlist.js b/scripts/watchlist.js index edc2cd9..9ea2a94 100644 --- a/scripts/watchlist.js +++ b/scripts/watchlist.js @@ -4123,6 +4123,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: return; } + const maxRetries = 10; + let retries = 0; // Use WebSocket to listen for UserDataChanged messages instead of intercepting fetch // This is more reliable and doesn't interfere with fetch requests function setupWebSocketMonitoring() { @@ -4131,6 +4133,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const socket = (window.ApiClient && (window.ApiClient.webSocket || window.ApiClient._webSocket)) || null; if (!socket) { + if (retries >= maxRetries) { + ERR('Max retries reached, giving up on WebSocket monitoring'); + return false; + } + retries++; setTimeout(() => { if (!playbackMonitorInitialized) { setupWebSocketMonitoring(); @@ -4190,7 +4197,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: //setupWebSocketMonitoring(); // Try to set up immediately -/* if (!setupWebSocketMonitoring()) { + if (!setupWebSocketMonitoring()) { // If WebSocket isn't available yet, retry after a short delay // This can happen if the page loads before the WebSocket connection is established setTimeout(() => { @@ -4198,7 +4205,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: setupWebSocketMonitoring(); } }, 1000); - } */ + } } let watchedItems = []; diff --git a/skins/jamfin-kefin.css b/skins/jamfin-kefin.css index e9b8398..9657966 100644 --- a/skins/jamfin-kefin.css +++ b/skins/jamfin-kefin.css @@ -10,10 +10,13 @@ -o-backdrop-filter: blur(var(--theme-blur)) !important; -ms-backdrop-filter: blur(var(--theme-blur)) !important; height: 100%; - display: flex !important; align-items: center; } +.kefinTweaks-breadcrumbs-wrapper[style="display: block;"] { + display: flex !important; +} + .watchlist-icon .detailButton-content:before { content: 'Watchlist'; } diff --git a/skins/neutralfin-kefin.css b/skins/neutralfin-kefin.css index a96c657..dc83d4d 100644 --- a/skins/neutralfin-kefin.css +++ b/skins/neutralfin-kefin.css @@ -43,6 +43,9 @@ .material-icons.favorite_border:before { content: '\e0f6'; } +.material-icons.build:before { + content: '\e1b1'; +} /* Lazy workaround for the pause button on the media bar plugin */ .pause-button .material-icons {