diff --git a/README.md b/README.md index 3f49851..95a263a 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,13 @@ I think it's also worth mentioning that before the 1.0 release, you can expect t Note: The Slideshow at the top of the Home Screen in the video below is from the [MediaBar](https://github.com/IAmParadox27/jellyfin-plugin-media-bar) plugin by [IAmParadox27](https://github.com/IAmParadox27) and [MakD](https://github.com/MakD). -[![Home Screen Example](https://i.imgur.com/rCboFCB.jpeg)](https://streamable.com/ypnn6n) +[![Home Screen Example](https://i.imgur.com/rCboFCB.jpeg)](https://youtu.be/4ikW4CFSn3I) ## Table of Contents - [Installation](#installation) - [Prerequisites](#prerequisites) - [Setup Instructions](#setup-instructions) - - [Alternate Installation Options](#alternate-installation-options) - [Configuration](#configuration) - [Features Overview](#features-overview) - [Data Caching](#data-caching) @@ -76,44 +75,29 @@ Note: The Slideshow at the top of the Home Screen in the video below is from the ### Setup Instructions -0a. **Install Prerequisites listed above** -0b. **If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings** +0. **Install the Prerequisites listed above** 1. **Add a new script to your JS Injector Plugin** -2. **Copy the the code below and paste it into the new script:** +2. **Copy the the code below, paste it into the new script, save your changes and refresh the page** ```javascript const script = document.createElement("script"); -script.src = `https://ranaldsgift.github.io/KefinTweaks/injector.js?v=${new Date().getTime()}`; +script.src = `https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/kefinTweaks-plugin.js`; script.async = true; document.head.appendChild(script); ``` -3. **Save your changes** +If you are hosting the script yourself, replace the `https://cdn.jsdelivr.net/gh/ranaldsgift` with your hosted location. You should be able to include the scripts in your jelly-web folder and access it from `/web/KefinTweaks` if you like. + +3. **Navigate to the Plugins page and click on KefinTweaks** +4. **Choose your preferred plugin version, or specify a custom source if you are self hosting the scripts** +5. **Press Install! KefinTweaks will automatically load without refreshing the page**
- injector -
+ injector +

-### Alternate Installation Options - -Some people may prefer to not include these scripts from a remote source and rather host them locally or at some other location and pull them from there. This is supported and easily achievable just follow these steps: - -1. Download your prefered release, extract it and move the files to your preferred hosting location. -2. Modify the line below in your JS Injector script for KefinTweaks: -```script.src = `https://ranaldsgift.github.io/KefinTweaks/injector.js?v=${new Date().getTime()}`;``` - - Replace the `https://ranaldsgift.github.io/KefinTweaks/injector.js` with the location you've chosen to host the scripts. - - Optionally remove the `?v=${new Date().getTime()}` if you prefer the script to not auto-refresh when you make local changes. -![KefinTweaks script include](https://i.imgur.com/n2h0eux.png) -3. Copy the contents of [kefintweaks-default-config.js](https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/main/kefinTweaks-default-config.js) into JS Injector by adding a new script or updating the existing one. - ![KefinTweaks-Config](https://i.imgur.com/OkoD2UJ.png) - - Ensure the name is "KefinTweaks-Config" -4. Update the "kefinTweaksRoot" and the "scriptRoot" values in the KefinTweaks-Config in JS Injector so that they reflect the locations you've chosen to host the scripts. -5. Save your changes! - -Also, it's probably worth noting that you should be able to include the scripts in your jelly-web folder and access it from `/web/KefinTweaks` if you like. - ## 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. @@ -122,9 +106,9 @@ KefinTweaks includes a comprehensive configuration interface accessible to admin 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. +1. **From Dashboard**: In the Dashboard side menu, find the "KefinTweaks" button under the Plugins section. +2. **From Left Navigation Menu**: A "Configure" link will appear in the left navigation menu beneath a KefinTweaks heading. +3. **From User Preferences**: Navigate to your User Preferences page and find the "Administration" section. Click the "Configure KefinTweaks" button. ### Configuration Features @@ -163,6 +147,19 @@ On first load, KefinTweaks automatically: These tasks only run for administrator users and happen automatically in the background.
+## Uninstallation + +1. **Navigate to the Plugins page and click on KefinTweaks** +2. **Press Uninstall and click to confirm** + +
+ injector +
+
+
+ + + ## Features Overview KefinTweaks provides a modular system of enhancements that can be individually enabled or disabled based on your needs. Each script is designed to work independently while sharing common utilities and dependencies. @@ -612,6 +609,7 @@ This README and the inline comments in kefinTweaks.js and the other scripts is t
### Version History +- **v0.3.2**: Add proper installation and uninstallation support from the Plugins page. New feature: Playlist sorting. - **v0.3.1**: New Home Screen Section feature: Spotlights! Improved Configuration UI. - **v0.3.0**: KefinTweaks configuration for admins in the Jellyfin UI! - **v0.2.4**: New Feature: Watchlist Export/Import diff --git a/injector.js b/injector.js index 848ea84..c44873f 100644 --- a/injector.js +++ b/injector.js @@ -6,7 +6,7 @@ 'use strict'; console.log('[KefinTweaks Injector] Initializing...'); - const versionNumber = "0.3.1"; + const versionNumber = "0.3.2"; // Configuration: Start with defaults, then merge user config // This allows new scripts to work out of the box without requiring config updates @@ -198,8 +198,8 @@ name: 'playlist', script: 'playlist.js', css: null, - dependencies: ['cardBuilder', 'utils'], - description: 'Modifies playlist view page behavior to navigate to item details instead of playing, and adds play button to playlist items' + dependencies: ['cardBuilder', 'utils', 'modal'], + description: 'Modifies playlist view page behavior to navigate to item details instead of playing, adds play button to playlist items, and adds sorting functionality' }, { name: 'itemDetailsCollections', @@ -227,25 +227,6 @@ // Use this for development //const urlSuffix = ''; const urlSuffix = `?v=${new Date().getTime()}`; - - // Fetch version date from GitHub releases API - async function fetchVersionDate(version) { - try { - const response = await fetch(`https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/tags/v${version}`); - if (!response.ok) { - // Try latest release if tag doesn't exist - const latestResponse = await fetch(`https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest`); - if (!latestResponse.ok) throw new Error('Failed to fetch release data'); - const data = await latestResponse.json(); - return new Date(data.published_at); - } - const data = await response.json(); - return new Date(data.published_at); - } catch (error) { - console.warn('[KefinTweaks Injector] Failed to fetch version date from GitHub:', error); - return null; - } - } // Inject dashboard drawer version badge CSS using dynamic version number function injectVersionBadgeCSS() { @@ -336,21 +317,32 @@ // Get the root path for scripts from configuration function getScriptRoot() { - const configScriptRoot = window.KefinTweaksConfig?.scriptRoot; - - // Handle legacy configuration pointing to the root directory - if (configScriptRoot === 'https://ranaldsgift.github.io/KefinTweaks/') { - return 'https://ranaldsgift.github.io/KefinTweaks/scripts/'; + const kefinTweaksRoot = window.KefinTweaksConfig?.kefinTweaksRoot; + + // If kefinTweaksRoot is not set or empty, return null (scripts should not load) + if (!kefinTweaksRoot || kefinTweaksRoot === '') { + console.warn('[KefinTweaks Injector] kefinTweaksRoot is not configured'); + return null; } - - return window.KefinTweaksConfig?.scriptRoot || 'https://ranaldsgift.github.io/KefinTweaks/scripts/'; + + // Ensure root ends with / + const root = kefinTweaksRoot.endsWith('/') ? kefinTweaksRoot : kefinTweaksRoot + '/'; + + // Return scripts path + return root + 'scripts/'; } // Load a CSS file function loadCSS(filename) { return new Promise((resolve, reject) => { + const scriptRoot = getScriptRoot(); + if (!scriptRoot) { + reject(new Error('kefinTweaksRoot is not configured')); + return; + } + // Check if CSS is already loaded (match by root and filename, ignore suffix) - const existingLink = document.querySelector(`link[href*="${getScriptRoot()}${filename}"]`); + const existingLink = document.querySelector(`link[href*="${scriptRoot}${filename}"]`); if (existingLink) { console.log(`[KefinTweaks Injector] CSS already loaded: ${filename}`); resolve(); @@ -360,7 +352,7 @@ const link = document.createElement('link'); link.rel = 'stylesheet'; link.type = 'text/css'; - link.href = `${getScriptRoot()}${filename}${urlSuffix}`; + link.href = `${scriptRoot}${filename}${urlSuffix}`; link.onload = () => { console.log(`[KefinTweaks Injector] CSS loaded: ${filename}`); @@ -379,8 +371,14 @@ // Load a JavaScript file function loadScript(filename) { return new Promise((resolve, reject) => { + const scriptRoot = getScriptRoot(); + if (!scriptRoot) { + reject(new Error('kefinTweaksRoot is not configured')); + return; + } + // Check if script is already loaded - const existingScript = document.querySelector(`script[src*="${getScriptRoot()}${filename}"]`); + const existingScript = document.querySelector(`script[src*="${scriptRoot}${filename}"]`); if (existingScript) { console.log(`[KefinTweaks Injector] Script already loaded: ${filename}`); resolve(); @@ -388,7 +386,7 @@ } const script = document.createElement('script'); - script.src = `${getScriptRoot()}${filename}${urlSuffix}`; + script.src = `${scriptRoot}${filename}${urlSuffix}`; script.async = true; script.onload = () => { @@ -438,8 +436,13 @@ // Load a single script (assumes dependencies are already loaded) async function loadScriptSync(scriptDef) { + const scriptRoot = getScriptRoot(); + if (!scriptRoot) { + throw new Error('kefinTweaksRoot is not configured'); + } + // Check if already loaded (match by root and filename, ignore suffix) - const isAlreadyLoaded = document.querySelector(`script[src*="${getScriptRoot()}${scriptDef.script}"]`); + const isAlreadyLoaded = document.querySelector(`script[src*="${scriptRoot}${scriptDef.script}"]`); if (isAlreadyLoaded) { console.log(`[KefinTweaks Injector] Script already loaded: ${scriptDef.name}`); return; @@ -460,6 +463,13 @@ async function initialize() { console.log(`[KefinTweaks Injector] Starting KefinTweaks v${versionNumber} initialization...`); + // Check if kefinTweaksRoot is configured + const scriptRoot = getScriptRoot(); + if (!scriptRoot) { + console.log('[KefinTweaks Injector] kefinTweaksRoot is not configured. Please configure KefinTweaks using the installer before scripts can be loaded.'); + return; + } + // Validate configuration if (!validateConfiguration()) { console.error('[KefinTweaks Injector] Configuration validation failed. Aborting.'); @@ -713,96 +723,6 @@ } } - // 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); - window.KefinTweaksConfig = defaultConfig; - 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() { @@ -885,6 +805,12 @@ window.KefinTweaksConfig = ${JSON.stringify(defaultConfig, null, 2)};`; async function startupTask() { console.log('[KefinTweaks Startup] Starting startup task...'); + // Check if kefinTweaksRoot is configured - startup tasks only run after installation + if (!window.KefinTweaksConfig?.kefinTweaksRoot || window.KefinTweaksConfig.kefinTweaksRoot === '') { + console.log('[KefinTweaks Startup] kefinTweaksRoot is not configured, skipping startup tasks'); + return; + } + // Check if user is admin (wait up to 5s for login) const isAdmin = await checkAdminWithTimeout(5000); @@ -894,10 +820,7 @@ window.KefinTweaksConfig = ${JSON.stringify(defaultConfig, null, 2)};`; } try { - // Task 1: Ensure KefinTweaks-Config exists in JS Injector - await ensureKefinTweaksConfig(); - - // Task 2: Ensure Watchlist tab exists in CustomTabs + // Task: Ensure Watchlist tab exists in CustomTabs await ensureWatchlistTab(); console.log('[KefinTweaks Startup] Startup task completed successfully'); diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js index 2c8903a..cf344a4 100644 --- a/kefinTweaks-default-config.js +++ b/kefinTweaks-default-config.js @@ -1,6 +1,5 @@ window.KefinTweaksDefaultConfig = { - "kefinTweaksRoot": "https://ranaldsgift.github.io/KefinTweaks/", - "scriptRoot": "https://ranaldsgift.github.io/KefinTweaks/scripts/", + "kefinTweaksRoot": "", "scripts": { "homeScreen": true, "search": true, diff --git a/kefinTweaks-plugin.js b/kefinTweaks-plugin.js new file mode 100644 index 0000000..439bc59 --- /dev/null +++ b/kefinTweaks-plugin.js @@ -0,0 +1,1413 @@ +// KefinTweaks Installer +// Entry point script for KefinTweaks installation and configuration +// Users paste this script into JS Injector instead of injector.js + +(function() { + 'use strict'; + + console.log('[KefinTweaks Installer] Initializing...'); + + const MODAL_ID = 'kefinTweaksSourceModal'; + let versionsCache = null; + + // jsDelivr URL patterns + const JSDELIVR_BASE = 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks'; + const GITHUB_REPO = 'ranaldsgift/KefinTweaks'; + const GITHUB_API_RELEASES = `https://api.github.com/repos/${GITHUB_REPO}/releases`; + + // 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 Installer] Could not check admin status:', error); + } + return false; + } + + // Find JavaScript Injector plugin + async function findJavaScriptInjectorPlugin() { + try { + if (!window.ApiClient || !window.ApiClient._serverAddress || !window.ApiClient.accessToken) { + return null; + } + + 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 injectorPlugin = pluginsList.find(plugin => + plugin.Name === 'JavaScript Injector' || plugin.Name === 'JS Injector' + ); + + if (!injectorPlugin) { + throw new Error('JavaScript Injector plugin not found'); + } + + return injectorPlugin.Id; + } catch (error) { + console.error('[KefinTweaks Installer] 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) { + return null; + } + + 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 Installer] Error getting JS Injector config:', error); + throw error; + } + } + + // Get KefinTweaks config from JS Injector + async function getKefinTweaksConfig() { + try { + const pluginId = await findJavaScriptInjectorPlugin(); + + if (!pluginId) { + return null; + } + + const injectorConfig = await getJavaScriptInjectorConfig(pluginId); + + if (!injectorConfig) { + return null; + } + + const kefinTweaksScript = injectorConfig.CustomJavaScripts?.find( + script => script.Name === 'KefinTweaks-Config' + ); + + if (kefinTweaksScript && kefinTweaksScript.Script) { + const scriptMatch = kefinTweaksScript.Script.match(/window\.KefinTweaksConfig\s*=\s*({[\s\S]*});/); + if (scriptMatch && scriptMatch[1]) { + try { + return JSON.parse(scriptMatch[1]); + } catch (parseError) { + console.error('[KefinTweaks Installer] Error parsing config:', parseError); + } + } + } + } catch (error) { + console.warn('[KefinTweaks Installer] Error getting config:', error); + } + return null; + } + + // Load default config from URL + async function loadDefaultConfig(rootUrl) { + return new Promise((resolve, reject) => { + try { + const defaultConfigUrl = `${rootUrl}kefinTweaks-default-config.js`; + + if (window.KefinTweaksDefaultConfig) { + resolve(window.KefinTweaksDefaultConfig); + return; + } + + const script = document.createElement('script'); + script.src = defaultConfigUrl; + script.async = true; + + script.onload = () => { + if (window.KefinTweaksDefaultConfig) { + resolve(window.KefinTweaksDefaultConfig); + } else { + reject(new Error('Default config file loaded but window.KefinTweaksDefaultConfig is not defined')); + } + }; + + script.onerror = () => { + reject(new Error(`Failed to load default config file: ${defaultConfigUrl}`)); + }; + + document.head.appendChild(script); + } catch (error) { + reject(error); + } + }); + } + + // Save configuration to JavaScript Injector + async function saveConfigToJavaScriptInjector(config) { + try { + const pluginId = await findJavaScriptInjectorPlugin(); + const injectorConfig = await getJavaScriptInjectorConfig(pluginId); + + if (!injectorConfig.CustomJavaScripts) { + injectorConfig.CustomJavaScripts = []; + } + + const scriptContent = `// KefinTweaks Configuration +// This file is automatically generated by KefinTweaks Installer +// Do not edit manually unless you know what you're doing + +window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; + + const existingScriptIndex = injectorConfig.CustomJavaScripts.findIndex( + script => script.Name === 'KefinTweaks-Config' + ); + + if (existingScriptIndex !== -1) { + injectorConfig.CustomJavaScripts[existingScriptIndex].Script = scriptContent; + } else { + injectorConfig.CustomJavaScripts.push({ + Name: 'KefinTweaks-Config', + Script: scriptContent, + Enabled: true, + RequiresAuthentication: false + }); + } + + 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}`); + } + + return true; + } catch (error) { + console.error('[KefinTweaks Installer] Error saving config:', error); + throw error; + } + } + + // Parse source URL to determine source type and version + function parseKefinTweaksSource(url) { + if (!url || url === '') { + return { sourceType: 'github', version: 'Latest' }; + } + + const match = url.match(/cdn\.jsdelivr\.net\/gh\/ranaldsgift\/KefinTweaks@([^\/]+)\//); + + if (!match) { + return { sourceType: 'custom', version: null }; + } + + const version = match[1]; + if (version === 'latest') { + return { sourceType: 'github', version: 'Latest' }; + } else if (version === 'main' || version === 'master') { + return { sourceType: 'github', version: 'Bleeding Edge' }; + } else { + return { sourceType: 'github', version: version.replace(/^v/, '') }; + } + } + + // Fetch available versions from GitHub + async function fetchVersions() { + if (versionsCache) { + return versionsCache; + } + + try { + const response = await fetch(GITHUB_API_RELEASES); + if (!response.ok) { + throw new Error('Failed to fetch releases'); + } + + const releases = await response.json(); + const versions = releases + .map(release => release.tag_name.replace(/^v/, '')) + .filter(tag => tag.match(/^\d+\.\d+\.\d+/)); // Only semantic versions + + versionsCache = ['Latest', 'Bleeding Edge', ...versions]; + return versionsCache; + } catch (error) { + console.warn('[KefinTweaks Installer] Error fetching versions:', error); + return ['Latest', 'Bleeding Edge']; + } + } + + // Build kefinTweaksRoot URL from selections + function buildKefinTweaksRootUrl(sourceType, source) { + if (sourceType === 'custom') { + // Ensure URL ends with / + return source.endsWith('/') ? source : source + '/'; + } + + // GitHub source + let versionTag = 'latest'; + if (source === 'Bleeding Edge') { + versionTag = 'main'; + } else if (source !== 'Latest') { + versionTag = source.startsWith('v') ? source : 'v' + source; + } + + return `${JSDELIVR_BASE}@${versionTag}/`; + } + + // Simple modal creation (doesn't depend on modal.js) + function createSimpleModal(id, title, content, footer) { + // Remove existing modal + const existing = document.getElementById(id); + if (existing) { + existing.remove(); + } + + const backdrop = document.createElement('div'); + backdrop.className = 'dialogBackdrop dialogBackdropOpened'; + backdrop.id = id + '-backdrop'; + + const dialogContainer = document.createElement('div'); + dialogContainer.className = 'dialogContainer'; + dialogContainer.id = id; + + const dialog = document.createElement('div'); + dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog centeredDialog opened'; + dialog.style.display = 'flex'; + dialog.style.flexDirection = 'column'; + dialog.style.maxHeight = '90vh'; + dialog.style.maxWidth = '600px'; + dialog.style.width = '90vw'; + + if (title) { + const header = document.createElement('div'); + header.className = 'formDialogHeader'; + header.style.display = 'flex'; + header.style.justifyContent = 'space-between'; + header.style.alignItems = 'center'; + header.style.padding = '1.25em 1.5em'; + header.style.borderBottom = '1px solid rgba(255,255,255,0.1)'; + + const titleEl = document.createElement('h2'); + titleEl.style.margin = '0'; + titleEl.textContent = title; + header.appendChild(titleEl); + + const closeBtn = document.createElement('button'); + closeBtn.setAttribute('is', 'paper-icon-button-light'); + closeBtn.className = 'btnCancel btnClose autoSize paper-icon-button-light'; + closeBtn.onclick = () => closeSimpleModal(id); + const closeIcon = document.createElement('span'); + closeIcon.className = 'material-icons close'; + closeBtn.appendChild(closeIcon); + header.appendChild(closeBtn); + + dialog.appendChild(header); + } + + const contentDiv = document.createElement('div'); + contentDiv.style.padding = '1.25em 1.5em 8em'; + contentDiv.style.overflowY = 'auto'; + contentDiv.style.flex = '1'; + if (typeof content === 'string') { + contentDiv.innerHTML = content; + } else { + contentDiv.appendChild(content); + } + dialog.appendChild(contentDiv); + + if (footer) { + const footerDiv = document.createElement('div'); + footerDiv.className = 'formDialogFooter'; + footerDiv.style.padding = '1.25em 1.5em'; + footerDiv.style.borderTop = '1px solid rgba(255,255,255,0.1)'; + if (typeof footer === 'string') { + footerDiv.innerHTML = footer; + } else { + footerDiv.appendChild(footer); + } + dialog.appendChild(footerDiv); + } + + dialogContainer.appendChild(dialog); + document.body.appendChild(backdrop); + document.body.appendChild(dialogContainer); + + backdrop.onclick = (e) => { + if (e.target === backdrop) { + closeSimpleModal(id); + } + }; + + // Close modal when clicking outside (on dialogContainer) + dialogContainer.onclick = (e) => { + if (e.target === dialogContainer) { + closeSimpleModal(id); + } + }; + + return { id, close: () => closeSimpleModal(id) }; + } + + function closeSimpleModal(id) { + const backdrop = document.getElementById(id + '-backdrop'); + const dialog = document.getElementById(id); + if (backdrop) backdrop.remove(); + if (dialog) dialog.remove(); + } + + /** + * Show a confirmation modal + * @param {string} message - Confirmation message + * @param {Function} onConfirm - Callback when user confirms + * @param {Function} onCancel - Optional callback when user cancels + */ + function showConfirmationModal(message, onConfirm, onCancel = null) { + const CONFIRM_MODAL_ID = 'kefinTweaksConfirmModal'; + + const content = document.createElement('div'); + content.style.display = 'flex'; + content.style.flexDirection = 'column'; + content.style.gap = '1em'; + content.style.lineHeight = '1.6'; + content.innerHTML = `

${message}

`; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.justifyContent = 'flex-end'; + footer.style.gap = '1em'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btnCancel raised emby-button button-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => { + closeSimpleModal(CONFIRM_MODAL_ID); + if (onCancel) onCancel(); + }; + + const confirmBtn = document.createElement('button'); + confirmBtn.className = 'btnSubmit raised emby-button button-submit'; + confirmBtn.textContent = 'Confirm'; + confirmBtn.onclick = () => { + closeSimpleModal(CONFIRM_MODAL_ID); + if (onConfirm) onConfirm(); + }; + + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + + createSimpleModal(CONFIRM_MODAL_ID, 'Confirm', content, footer); + } + + /** + * Show an alert/info modal + * @param {string} title - Modal title + * @param {string} message - Message content (can include HTML) + */ + function showAlertModal(title, message) { + const ALERT_MODAL_ID = 'kefinTweaksAlertModal'; + + const content = document.createElement('div'); + content.style.display = 'flex'; + content.style.flexDirection = 'column'; + content.style.gap = '1em'; + content.style.lineHeight = '1.6'; + content.innerHTML = message; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.justifyContent = 'flex-end'; + footer.style.gap = '1em'; + + const okBtn = document.createElement('button'); + okBtn.className = 'btnSubmit raised emby-button button-submit'; + okBtn.textContent = 'OK'; + okBtn.onclick = () => { + closeSimpleModal(ALERT_MODAL_ID); + }; + + footer.appendChild(okBtn); + + createSimpleModal(ALERT_MODAL_ID, title, content, footer); + } + + /** + * Show success modal after saving configuration + * @param {boolean} isInitialInstall - Whether this is the initial installation + * @param {string} version - Version number or source type + */ + async function showSuccessModal(isInitialInstall, version) { + const SUCCESS_MODAL_ID = 'kefinTweaksSuccessModal'; + + const content = document.createElement('div'); + content.style.display = 'flex'; + content.style.flexDirection = 'column'; + content.style.gap = '1em'; + content.style.lineHeight = '1.6'; + + if (isInitialInstall) { + // Initial installation message with support information + content.innerHTML = ` +

Thanks for installing KefinTweaks.

+

KefinTweaks is not maintained by the Jellyfin team, and as a result you are encouraged to seek support from me directly. Sadly, there is no place suitable for discussion of plugins built by community members or fan-made projects in the official Jellyfin Discord, so please visit the Jellyfin Community Discord to find me (username: HighImKevin) and other users who would be happy to help you out. Please also feel free to report bugs and request features from the Issues page. The strength of this plugin relies on awesome community members like you, so thanks for using KefinTweaks!

+ `; + } else { + // Update message with refresh instruction + content.innerHTML = ` +

KefinTweaks ${version} has been installed. Please refresh your page for the changes to take effect!

+ `; + } + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.justifyContent = 'flex-end'; + footer.style.gap = '1em'; + + const okBtn = document.createElement('button'); + okBtn.className = 'btnSubmit raised emby-button button-submit'; + okBtn.textContent = 'OK'; + okBtn.onclick = () => { + closeSimpleModal(SUCCESS_MODAL_ID); + if (!isInitialInstall) { + // Optionally auto-refresh on update (or let user do it manually) + // window.location.reload(); + } + }; + + footer.appendChild(okBtn); + + createSimpleModal(SUCCESS_MODAL_ID, 'KefinTweaks', content, footer); + } + + // Open source configuration modal + async function openKefinTweaksSourceModal() { + const userIsAdmin = await isAdmin(); + if (!userIsAdmin) { + showAlertModal('Access Denied', '

You must be an administrator to configure KefinTweaks.

'); + return; + } + + const currentConfig = await getKefinTweaksConfig(); + const currentRoot = currentConfig?.kefinTweaksRoot || ''; + const sourceInfo = parseKefinTweaksSource(currentRoot); + const versions = await fetchVersions(); + + const content = document.createElement('div'); + content.style.display = 'flex'; + content.style.flexDirection = 'column'; + content.style.gap = '1.5em'; + + // Source Type dropdown + const sourceTypeLabel = document.createElement('label'); + sourceTypeLabel.textContent = 'Source Type:'; + sourceTypeLabel.style.display = 'block'; + sourceTypeLabel.style.marginBottom = '0.5em'; + + const sourceTypeSelect = document.createElement('select'); + sourceTypeSelect.className = 'fld emby-select emby-select-with-color'; + sourceTypeSelect.id = 'kefinTweaksSourceType'; + sourceTypeSelect.innerHTML = ` + + + `; + + // Source dropdown (for GitHub) + const sourceLabel = document.createElement('label'); + sourceLabel.textContent = 'Version:'; + sourceLabel.style.display = 'block'; + sourceLabel.style.marginBottom = '0.5em'; + + const sourceSelect = document.createElement('select'); + sourceSelect.className = 'fld emby-select emby-select-with-color'; + sourceSelect.id = 'kefinTweaksSource'; + sourceSelect.innerHTML = versions.map(v => + `` + ).join(''); + + // Custom URL input + const customUrlInput = document.createElement('input'); + customUrlInput.type = 'text'; + customUrlInput.className = 'fld emby-input'; + customUrlInput.id = 'kefinTweaksCustomUrl'; + customUrlInput.placeholder = 'https://example.com/KefinTweaks/'; + customUrlInput.value = sourceInfo.sourceType === 'custom' ? currentRoot : ''; + customUrlInput.style.display = sourceInfo.sourceType === 'custom' ? 'block' : 'none'; + + // Set initial visibility based on source type + if (sourceInfo.sourceType === 'custom') { + sourceSelect.style.display = 'none'; + sourceLabel.style.display = 'none'; + customUrlInput.style.display = 'block'; + } else { + sourceSelect.style.display = 'block'; + sourceLabel.style.display = 'block'; + customUrlInput.style.display = 'none'; + } + + // Toggle visibility based on source type + sourceTypeSelect.onchange = () => { + if (sourceTypeSelect.value === 'custom') { + sourceSelect.style.display = 'none'; + sourceLabel.style.display = 'none'; + customUrlInput.style.display = 'block'; + } else { + sourceSelect.style.display = 'block'; + sourceLabel.style.display = 'block'; + customUrlInput.style.display = 'none'; + } + }; + + content.appendChild(sourceTypeLabel); + content.appendChild(sourceTypeSelect); + content.appendChild(sourceLabel); + content.appendChild(sourceSelect); + content.appendChild(customUrlInput); + + // Footer with buttons + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.justifyContent = 'flex-end'; + footer.style.gap = '1em'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btnCancel raised emby-button button-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => closeSimpleModal(MODAL_ID); + + const saveBtn = document.createElement('button'); + saveBtn.className = 'btnSubmit raised emby-button button-submit'; + saveBtn.textContent = (!currentRoot || currentRoot.trim() === '') ? 'Install' : 'Save'; + saveBtn.onclick = async () => { + try { + const sourceType = sourceTypeSelect.value; + const source = sourceType === 'custom' ? customUrlInput.value : sourceSelect.value; + + if (sourceType === 'custom' && (!source || source.trim() === '')) { + showAlertModal('Invalid URL', '

Please enter a valid URL for your self-hosted location.

'); + return; + } + + const kefinTweaksRoot = buildKefinTweaksRootUrl(sourceType, source); + + // Load default config from the selected root + let defaultConfig; + try { + defaultConfig = await loadDefaultConfig(kefinTweaksRoot); + } catch (error) { + console.error('[KefinTweaks Installer] Error loading default config:', error); + // Create minimal config if loading fails + defaultConfig = { + kefinTweaksRoot: kefinTweaksRoot, + scripts: {}, + homeScreen: {}, + exclusiveElsewhere: {}, + search: {}, + skins: [], + defaultSkin: null, + themes: [], + customMenuLinks: [] + }; + } + + // Merge with existing config to preserve user settings + const mergedConfig = { + ...defaultConfig, + kefinTweaksRoot: kefinTweaksRoot, + ...(currentConfig || {}), + kefinTweaksRoot: kefinTweaksRoot // Ensure root is updated + }; + + // Remove scriptRoot if it exists (legacy field) + if (mergedConfig.scriptRoot) { + delete mergedConfig.scriptRoot; + } + + await saveConfigToJavaScriptInjector(mergedConfig); + + // Update window.KefinTweaksConfig immediately so injector can use it + window.KefinTweaksConfig = mergedConfig; + + closeSimpleModal(MODAL_ID); + + // Update the plugin card with new version/status + await updateKefinTweaksPluginCard(); + + // Determine if this is initial installation + const isInitialInstall = !currentConfig || !currentConfig.kefinTweaksRoot || currentConfig.kefinTweaksRoot === ''; + + // Get version for display + const sourceInfo = parseKefinTweaksSource(kefinTweaksRoot); + let versionDisplay = ''; + if (sourceInfo.sourceType === 'custom') { + versionDisplay = 'Self Hosted'; + } else if (sourceInfo.version === 'Bleeding Edge') { + versionDisplay = 'Bleeding Edge'; + } else if (sourceInfo.version === 'Latest') { + // Fetch the actual latest version number + try { + const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); + if (response.ok) { + const data = await response.json(); + const versionNumber = data.tag_name.replace(/^v/, ''); + versionDisplay = `${versionNumber} (Latest)`; + } else { + versionDisplay = 'Latest'; + } + } catch (error) { + console.warn('[KefinTweaks Installer] Error fetching latest version:', error); + versionDisplay = 'Latest'; + } + } else { + versionDisplay = sourceInfo.version; + } + + // Load injector.js from the configured root + await loadInjectorFromRoot(kefinTweaksRoot); + + // Show success modal + await showSuccessModal(isInitialInstall, versionDisplay); + } catch (error) { + console.error('[KefinTweaks Installer] Error saving configuration:', error); + showAlertModal('Configuration Error', `

Error saving configuration: ${error.message}

`); + } + }; + + const uninstallBtn = document.createElement('button'); + uninstallBtn.className = 'btnCancel raised emby-button button-cancel'; + uninstallBtn.textContent = 'Uninstall'; + uninstallBtn.style.marginRight = 'auto'; + // Hide uninstall button if KefinTweaks isn't installed yet (no root URL) + if (!currentRoot || currentRoot.trim() === '') { + uninstallBtn.style.display = 'none'; + } + uninstallBtn.onclick = () => { + closeSimpleModal(MODAL_ID); + showConfirmationModal( + 'Are you sure you want to uninstall KefinTweaks?

This will disable the installer and backup your configuration.', + async () => { + try { + await uninstallKefinTweaks(); + // Remove the plugin card + removeKefinTweaksPluginCard(); + showAlertModal( + 'KefinTweaks Uninstalled', + '

KefinTweaks has been uninstalled. The installer has been disabled and your configuration has been backed up.

Please refresh your page.

You can re-enable KefinTweaks at any time from the JS Injector Plugin.' + ); + } catch (error) { + console.error('[KefinTweaks Installer] Error uninstalling:', error); + showAlertModal( + 'Uninstall Error', + `

Error uninstalling KefinTweaks: ${error.message}

` + ); + } + }, + () => { + // User cancelled, reopen the source modal + openKefinTweaksSourceModal(); + } + ); + }; + + footer.appendChild(saveBtn); + footer.appendChild(cancelBtn); + footer.appendChild(uninstallBtn); + + createSimpleModal(MODAL_ID, 'Configure KefinTweaks Source', content, footer); + } + + /** + * Uninstall KefinTweaks + * 1. Renames KefinTweaks-Config to KefinTweaks-Config-Backup-Timestamp + * 2. Disables the installer script (script containing kefinTweaks-plugin.js) + */ + async function uninstallKefinTweaks() { + try { + const pluginId = await findJavaScriptInjectorPlugin(); + const injectorConfig = await getJavaScriptInjectorConfig(pluginId); + + if (!injectorConfig.CustomJavaScripts) { + throw new Error('No custom scripts found in JS Injector configuration'); + } + + // Step 1: Rename KefinTweaks-Config to backup + const configScriptIndex = injectorConfig.CustomJavaScripts.findIndex( + script => script.Name === 'KefinTweaks-Config' + ); + + if (configScriptIndex !== -1) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + injectorConfig.CustomJavaScripts[configScriptIndex].Name = `KefinTweaks-Config-Backup-${timestamp}`; + injectorConfig.CustomJavaScripts[configScriptIndex].Enabled = false; + console.log('[KefinTweaks Installer] Renamed KefinTweaks-Config to backup and disabled it'); + } + + // Step 2: Find and disable installer script + // Look for script containing kefinTweaks-plugin.js (flexible pattern to match script.src*kefinTweaks-plugin.js) + const installerScriptIndex = injectorConfig.CustomJavaScripts.findIndex( + script => script.Script && /script\.src.*kefinTweaks-plugin\.js/i.test(script.Script) + ); + + if (installerScriptIndex !== -1) { + injectorConfig.CustomJavaScripts[installerScriptIndex].Enabled = false; + console.log('[KefinTweaks Installer] Disabled installer script'); + } else { + console.warn('[KefinTweaks Installer] Installer script not found in CustomJavaScripts'); + } + + // Save the updated configuration + 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 Installer] Uninstall completed successfully'); + return true; + } catch (error) { + console.error('[KefinTweaks Installer] Error uninstalling:', error); + throw error; + } + } + + /** + * Update the plugin card with new version and status information + */ + async function updateKefinTweaksPluginCard() { + const card = document.querySelector('[data-id="kefinTweaksPlugin"]'); + if (!card) { + // Card doesn't exist yet, try to add it + const pluginsPage = document.querySelector('#pluginsPage:not(.hide)'); + if (pluginsPage) { + let installedPlugins = pluginsPage.querySelector('.installedPlugins'); + if (!installedPlugins) { + installedPlugins = document.querySelector('#pluginsPage:not(.hide)>div>div>div:last-child>div'); + } + if (installedPlugins) { + addKefinTweaksPluginCard(installedPlugins); + } + } + return; + } + + try { + const config = await getKefinTweaksConfig(); + const root = config?.kefinTweaksRoot || ''; + const sourceInfo = parseKefinTweaksSource(root); + + // Determine version display text + let versionDisplay = 'Installation Pending'; + if (root) { + if (sourceInfo.sourceType === 'custom') { + versionDisplay = 'Self Hosted'; + } else if (sourceInfo.version === 'Bleeding Edge') { + versionDisplay = 'Bleeding Edge'; + } else if (sourceInfo.version === 'Latest') { + // Fetch the actual latest version number + try { + const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); + if (response.ok) { + const data = await response.json(); + const versionNumber = data.tag_name.replace(/^v/, ''); + versionDisplay = `${versionNumber} (Latest)`; + } else { + versionDisplay = 'Latest'; + } + } catch (error) { + console.warn('[KefinTweaks Installer] Error fetching latest version:', error); + versionDisplay = 'Latest'; + } + } else { + // Specific version + versionDisplay = sourceInfo.version; + } + } + + // Update data attributes + card.setAttribute('data-version', versionDisplay); + card.setAttribute('data-status', root ? 'Active' : 'Pending'); + + // Check if this is a Material-UI card + const isMuiCard = card.classList.contains('MuiGrid-root'); + + if (isMuiCard) { + // Handle Material-UI card structure + const versionElement = card.querySelector('.MuiTypography-root.MuiTypography-body2'); + if (versionElement) { + versionElement.textContent = versionDisplay; + } + } else { + // Handle standard card structure + const cardTextElements = card.querySelectorAll('.cardText'); + if (cardTextElements.length > 0) { + // Update first cardText (plugin name with version) + const nameElement = cardTextElements[0]; + const versionSpan = nameElement.querySelector('.cardText-secondary'); + if (versionSpan) { + versionSpan.textContent = versionDisplay; + } else { + // If no secondary span exists, create one + nameElement.innerHTML = `KefinTweaks${versionDisplay}`; + } + } + + // Determine status text + const statusText = root ? 'Status Active' : 'Click to Install'; + + // Update status text (usually last cardText) + if (cardTextElements.length > 0) { + cardTextElements[cardTextElements.length - 1].textContent = statusText; + } + } + } catch (error) { + console.error('[KefinTweaks Installer] Error updating plugin card:', error); + } + } + + /** + * Remove the plugin card from the DOM + */ + function removeKefinTweaksPluginCard() { + const card = document.querySelector('[data-id="kefinTweaksPlugin"]'); + if (card) { + card.remove(); + console.log('[KefinTweaks Installer] Plugin card removed'); + } + } + + // Make function globally accessible + window.openKefinTweaksSourceModal = openKefinTweaksSourceModal; + + // Add plugin card to plugins page + function addKefinTweaksPluginCard(installedPlugins) { + if (!installedPlugins) { + return; + } + + // Check if card already exists in either container + if (document.querySelector('[data-id="kefinTweaksPlugin"]')) { + return; + } + + const pluginsPage = document.querySelector('#pluginsPage:not(.hide)'); + if (!pluginsPage) { + return; + } + + // Find or create frontEndPlugins container + let frontEndPlugins = pluginsPage.querySelector('.frontEndPlugins'); + + if (!frontEndPlugins) { + // Create the heading + const heading = document.createElement('div'); + heading.className = 'sectionTitleContainer flex align-items-center'; + heading.innerHTML = '

Front End Plugins

'; + heading.style.marginTop = '2em'; + + // Create the frontEndPlugins container + frontEndPlugins = document.createElement('div'); + frontEndPlugins.className = 'frontEndPlugins itemsContainer vertical-wrap'; + + // Insert heading and container after installedPlugins + if (installedPlugins.nextSibling) { + installedPlugins.parentNode.insertBefore(heading, installedPlugins.nextSibling); + installedPlugins.parentNode.insertBefore(frontEndPlugins, heading.nextSibling); + } else { + installedPlugins.parentNode.appendChild(heading); + installedPlugins.parentNode.appendChild(frontEndPlugins); + } + } + + // Find an existing plugin card to clone + // Try multiple selectors to find a plugin card + const existingCard = installedPlugins.querySelector('.card[data-id]') || + installedPlugins.querySelector(':first-child') || + installedPlugins.firstElementChild; + + if (!existingCard) { + // Set up MutationObserver to watch for plugin cards being added + console.log('[KefinTweaks Installer] No existing plugin card found, setting up MutationObserver'); + + const observer = new MutationObserver((mutations) => { + // Check if any child was added + for (const mutation of mutations) { + if (mutation.addedNodes.length > 0) { + // Check if we now have a card to clone + const cardToClone = installedPlugins.querySelector('.card[data-id]') || + installedPlugins.querySelector(':first-child') || + installedPlugins.firstElementChild; + + if (cardToClone) { + console.log('[KefinTweaks Installer] Plugin card detected, disconnecting observer'); + observer.disconnect(); + // Try adding our card again + addKefinTweaksPluginCard(installedPlugins); + return; + } + } + } + }); + + // Observe child additions to the installedPlugins container + observer.observe(installedPlugins, { + childList: true, + subtree: false + }); + + // Disconnect after 10 seconds if no cards appear + setTimeout(() => { + observer.disconnect(); + console.warn('[KefinTweaks Installer] MutationObserver timeout: No plugin cards appeared after 10 seconds'); + }, 10000); + + return; + } + + // Get current config to determine version/status + getKefinTweaksConfig().then(async config => { + const root = config?.kefinTweaksRoot || ''; + const sourceInfo = parseKefinTweaksSource(root); + + // Determine version display text + let versionDisplay = 'Installation Pending'; + if (root) { + if (sourceInfo.sourceType === 'custom') { + versionDisplay = 'Self Hosted'; + } else if (sourceInfo.version === 'Bleeding Edge') { + versionDisplay = 'Bleeding Edge'; + } else if (sourceInfo.version === 'Latest') { + // Fetch the actual latest version number + try { + const response = await fetch('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); + if (response.ok) { + const data = await response.json(); + const versionNumber = data.tag_name.replace(/^v/, ''); + versionDisplay = `${versionNumber} (Latest)`; + } else { + versionDisplay = 'Latest'; + } + } catch (error) { + console.warn('[KefinTweaks Installer] Error fetching latest version:', error); + versionDisplay = 'Latest'; + } + } else { + // Specific version + versionDisplay = sourceInfo.version; + } + } + + // Clone the existing card + const card = existingCard.cloneNode(true); + + // Update data attributes + card.setAttribute('data-id', 'kefinTweaksPlugin'); + card.setAttribute('data-version', versionDisplay); + card.setAttribute('data-name', 'KefinTweaks'); + card.setAttribute('data-removable', 'true'); + card.setAttribute('data-status', root ? 'Active' : 'Pending'); + + // Check if this is a Material-UI card (MuiGrid-root) + const isMuiCard = card.classList.contains('MuiGrid-root'); + + if (isMuiCard) { + // Handle Material-UI card structure + const nameElement = card.querySelector('.MuiTypography-root.MuiTypography-body1'); + const versionElement = card.querySelector('.MuiTypography-root.MuiTypography-body2'); + + if (nameElement) { + nameElement.textContent = 'KefinTweaks'; + } + + if (versionElement) { + versionElement.textContent = versionDisplay; + } + } else { + // Handle standard card structure + const cardTextElements = card.querySelectorAll('.cardText'); + if (cardTextElements.length > 0) { + // Update first cardText (plugin name with version) + const nameElement = cardTextElements[0]; + const versionSpan = nameElement.querySelector('.cardText-secondary'); + if (versionSpan) { + // Update existing version span + versionSpan.textContent = versionDisplay; + // Update the name part (text before the span) + if (nameElement.firstChild && nameElement.firstChild.nodeType === Node.TEXT_NODE) { + nameElement.firstChild.textContent = 'KefinTweaks'; + } else { + // Insert name text before the version span + nameElement.insertBefore(document.createTextNode('KefinTweaks'), versionSpan); + } + } else { + // If no secondary span exists, create one + nameElement.innerHTML = `KefinTweaks${versionDisplay}`; + } + } + + // Determine status text + const statusText = root ? 'Status Active' : 'Click to Install'; + + // Update status text (usually last cardText) + if (cardTextElements.length > 0) { + cardTextElements[cardTextElements.length - 1].textContent = statusText; + } else { + // Add status if it doesn't exist + const cardFooter = card.querySelector('.cardFooter'); + if (cardFooter) { + const statusElement = document.createElement('div'); + statusElement.className = 'cardText'; + statusElement.textContent = statusText; + cardFooter.appendChild(statusElement); + } + } + } + +/* if (img) { + const iconDiv = document.createElement('div'); + iconDiv.className = 'cardImage flex align-items-center justify-content-center defaultCardBackground defaultCardBackground5'; + + const iconSpan = document.createElement('span'); + iconSpan.className = 'cardImageIcon material-icons extension'; + iconSpan.setAttribute('aria-hidden', 'true'); + + iconDiv.appendChild(iconSpan); + + // Replace the img with the icon div + img.parentNode.replaceChild(iconDiv, img); + } */ + + // Find and replace the anchor/link with a button that opens the modal + // For Material-UI cards, the anchor might be structured differently + let anchorElement = null; + + if (isMuiCard) { + // For Material-UI cards, look for anchor in various possible locations + anchorElement = card.querySelector('a[href]') || + card.querySelector('.MuiCardActionArea') || + card.querySelector('a'); + } else { + // For standard cards, look for .cardImageContainer + anchorElement = card.querySelector('.cardImageContainer'); + } + + if (anchorElement) { + // Remove existing link attributes + anchorElement.removeAttribute('href'); + anchorElement.removeAttribute('data-url'); + anchorElement.removeAttribute('target'); + + // Convert to button if it's not already + if (anchorElement.tagName.toLowerCase() !== 'button') { + const button = document.createElement('button'); + // Copy all attributes (except link-specific ones) + Array.from(anchorElement.attributes).forEach(attr => { + if (attr.name !== 'href' && attr.name !== 'data-url' && attr.name !== 'target') { + button.setAttribute(attr.name, attr.value); + } + }); + button.className = anchorElement.className; + + // For Material-UI, preserve Mui classes but add button behavior + if (isMuiCard) { + button.setAttribute('type', 'button'); + // Preserve MuiCardActionArea if it exists + if (anchorElement.classList.contains('MuiCardActionArea')) { + button.classList.add('MuiCardActionArea'); + } + } else { + button.setAttribute('is', 'emby-linkbutton'); + button.style.margin = '0'; + button.style.padding = '0'; + } + + // Copy all child nodes + while (anchorElement.firstChild) { + button.appendChild(anchorElement.firstChild); + } + + // Replace the element + anchorElement.parentNode.replaceChild(button, anchorElement); + button.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + openKefinTweaksSourceModal(); + }; + } else { + // It's already a button, just update onclick + anchorElement.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + openKefinTweaksSourceModal(); + }; + } + + // Replace any existing img tag with icon div + let img = card.querySelector('img'); + + if (img) { + img.src = 'https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/main/logo.png'; + img.style.width = 'auto'; + img.style.height = '100%'; + img.parentNode.style.background = 'linear-gradient(to bottom, #202020, #101010)'; + } else { + img = document.createElement('div'); + img.className = 'defaultCardBackground'; + // give the gradient some color + img.style.background = 'url(https://raw.githubusercontent.com/ranaldsgift/KefinTweaks/refs/heads/main/KefinTweaks/pages/images/logo.png), linear-gradient(to bottom, #202020, #101010)'; + img.style.backgroundSize = 'contain'; + img.style.backgroundPosition = 'center'; + img.style.backgroundRepeat = 'no-repeat'; + img.style.width = '100%'; + img.style.height = '100%'; + img.style.overflow = 'hidden'; + img.style.display = 'flex'; + } + + const defaultCardBackground = card.querySelector('.defaultCardBackground'); + + if (img && defaultCardBackground) { + // Replace the defaultCardBackground with the img + defaultCardBackground.parentNode.replaceChild(img, defaultCardBackground); + } + } + + // Find and handle menu button (btnCardMenu) if it exists + const menuButton = card.querySelector('.btnCardMenu'); + if (menuButton) { + // Remove existing click handlers by cloning + const clonedMenuButton = menuButton.cloneNode(true); + menuButton.parentNode.replaceChild(clonedMenuButton, menuButton); + + // Add click handler to open modal + clonedMenuButton.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + openKefinTweaksSourceModal(); + }; + } + + // Append to frontEndPlugins container instead of installedPlugins + frontEndPlugins.appendChild(card); + }).catch(err => { + console.error('[KefinTweaks Installer] Error getting config for card:', err); + }); + } + + // Check for plugins page and add card + function checkForPluginsPage(view, element, hash) { + + if (hash && hash.includes('dashboard/plugins')) { + const pluginsPage = document.querySelector('#pluginsPage:not(.hide)'); + if (!pluginsPage) { + return; + } + + let installedPlugins = pluginsPage.querySelector('.installedPlugins'); + + if (!installedPlugins) { + // Support for Jellyfin 10.11.X + installedPlugins = document.querySelector('#pluginsPage:not(.hide)>div>div>div:last-child>div'); + } + + if (pluginsPage && installedPlugins) { + // Small delay to ensure DOM is ready + setTimeout(() => { + addKefinTweaksPluginCard(installedPlugins); + }, 100); + } + } + } + + // Check if user is logged in and admin + async function checkUserAndSetup(retryCount = 0) { + const maxRetries = 100; // 10 seconds (100 * 100ms) + + try { + // Check if ApiClient is available and user is logged in + if (!window.ApiClient || !window.ApiClient._loggedIn) { + if (retryCount < maxRetries) { + setTimeout(() => { + checkUserAndSetup(retryCount + 1); + }, 100); + return; + } else { + console.log('[KefinTweaks Installer] User not logged in after polling, skipping setup'); + return; + } + } + + // Check if user is admin + const userIsAdmin = await isAdmin(); + if (!userIsAdmin) { + console.log('[KefinTweaks Installer] User is not admin, skipping setup'); + return; + } + + // User is logged in and is admin, proceed with setup + console.log('[KefinTweaks Installer] User is admin, setting up functionality'); + + // Add CSS + const css = document.createElement('style'); + css.textContent = ` + [data-id="kefinTweaksPlugin"] .cardImageContainer::after, + [data-id="kefinTweaksPlugin"] .MuiButtonBase-root.MuiCardActionArea-root::after { + content: 'KefinTweaks'; + position: absolute; + font-size: 2em; + top: 50%; + transform: translateY(-50%); + } + `; + document.head.appendChild(css); + + // Hook into Emby.Page.onViewShow + setupOnViewShow(); + + // Check and load injector if config is ready + checkAndLoadInjector(); + } catch (error) { + console.error('[KefinTweaks Installer] Error checking user:', error); + // Retry on error + if (retryCount < maxRetries) { + setTimeout(() => { + checkUserAndSetup(retryCount + 1); + }, 100); + } + } + } + + // Hook into Emby.Page.onViewShow + function setupOnViewShow() { + const originalOnViewShow = window.Emby?.Page?.onViewShow; + + if (window.Emby && window.Emby.Page) { + window.Emby.Page.onViewShow = function(...args) { + // Call original handler if it exists + if (originalOnViewShow) { + try { + originalOnViewShow.apply(this, args); + } catch (err) { + console.warn('[KefinTweaks Installer] Error in original onViewShow:', err); + } + } + + // Check for plugins page + const hash = window.location.hash; + checkForPluginsPage(args[0], args[1], hash); + }; + + console.log('[KefinTweaks Installer] Hooked into Emby.Page.onViewShow'); + } else { + // Retry if Emby.Page not ready yet + setTimeout(setupOnViewShow, 100); + } + } + + // Initialize the installer + function initialize() { + // Start checking for logged in admin user + checkUserAndSetup(); + } + + // Load injector.js from a specific root URL + async function loadInjectorFromRoot(root) { + if (!root || root === '') { + console.warn('[KefinTweaks Installer] Cannot load injector: kefinTweaksRoot is empty'); + return false; + } + + // Ensure root ends with / + const rootUrl = root.endsWith('/') ? root : root + '/'; + const injectorUrl = `${rootUrl}injector.js`; + + // Check if injector is already loaded from this URL + const existingScript = document.querySelector(`script[src="${injectorUrl}"]`); + if (existingScript) { + console.log('[KefinTweaks Installer] Injector already loaded from:', injectorUrl); + return true; + } + + console.log('[KefinTweaks Installer] Loading injector.js from:', injectorUrl); + + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = injectorUrl; + script.async = true; + + script.onload = () => { + console.log('[KefinTweaks Installer] Injector loaded successfully'); + resolve(true); + }; + + script.onerror = (error) => { + console.error('[KefinTweaks Installer] Failed to load injector:', error); + reject(new Error(`Failed to load injector.js from ${injectorUrl}`)); + }; + + document.head.appendChild(script); + }); + } + + // Check if config exists and load injector.js if ready + async function checkAndLoadInjector() { + try { + const config = await getKefinTweaksConfig(); + const root = config?.kefinTweaksRoot || ''; + + if (root && root !== '') { + await loadInjectorFromRoot(root); + } else { + console.log('[KefinTweaks Installer] kefinTweaksRoot not configured. Please configure via the Plugins page.'); + } + } catch (error) { + console.error('[KefinTweaks Installer] Error checking config:', error); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + initialize(); + }); + } else { + initialize(); + } + + console.log('[KefinTweaks Installer] Installer script loaded'); +})(); + diff --git a/kefinTweaks.js b/kefinTweaks.js deleted file mode 100644 index eccc7da..0000000 --- a/kefinTweaks.js +++ /dev/null @@ -1,309 +0,0 @@ -// KefinTweaks - Central Configuration and Script Loader -// Copy this entire file into your Jellyfin JS injector plugin -// Modify the configuration below to enable/disable features and customize settings -// -// Full compatibility with Jellyfin 10.10.7 -// Untested compatibility with Jellyfin 10.11+ -// -// FEATURES OVERVIEW: -// ================= -// For a detailed feature overview see README.md at https://github.com/ranaldsgift/KefinTweaks -// -// PREREQUISITES: -// ============ -// - JS Injector plugin https://github.com/n00bcodr/Jellyfin-JavaScript-Injector -// - Custom Tabs plugin for watchlist functionality https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs -// - JellyfinEnhanced plugin for Jellyseerr search functionality and ExclusiveElsewhere https://github.com/n00bcodr/Jellyfin-Enhanced -// -// INSTALLATION: -// ============ -// 0a. Install required 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 this entire file into the new script -// (Optional) Enable/disable features, or customize their configuration by editing the script below -// 3. Ensure the script is enabled -// 4. Save your changes -// -// CONFIGURATION GUIDE: -// =================== -// - Scripts: Enable/disable individual features -// - Home Screen: Configure custom sections and discovery settings -// - Exclusive Elsewhere: Only useful if you want to display an icon or logo instead of text -// - Search: Enable Jellyseerr integration adds an option to search for Jellyseerr results only -// -// Considerations for the home screen sections: -// - Default Jellyfin sections (order 1-10) -// - New releases sections (order 30-49) - Newest movies and episodes -// - Seasonal sections (order 50-59) - Holiday-themed content -// - One-off sections: Watchlist, TV Networks, section (order 60-69) - Your watchlist -// - Discovery sections (order 1000+) - Intelligent recommendations based on your library -// - Custom sections can be placed anywhere in the order by using the order field in the configuration - -(function() { - 'use strict'; - - // ============================================================================ - // CENTRAL CONFIGURATION - // ============================================================================ - - const KEFIN_TWEAKS_CONFIG = { - // ============================================================================ - // SCRIPT ROOT CONFIGURATION - // ============================================================================ - // Configure where scripts are loaded from - kefinTweaksRoot: 'https://ranaldsgift.github.io/KefinTweaks/', - scriptRoot: 'https://ranaldsgift.github.io/KefinTweaks/scripts/', - - // ============================================================================ - // SCRIPT ENABLE/DISABLE FLAGS - // ============================================================================ - // Toggle individual features on/off. Dependencies are automatically managed. - scripts: { - // Major feature enhancements - homeScreen: true, // Custom home screen sections and discovery engine - search: true, // Enhanced search with better UI and Jellyseerr integration - // REQUIREMENTS: Watchlist requires Custom Tabs plugin https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs - // If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings - watchlist: true, // Complete watchlist management with progress tracking - - // UI and navigation improvements - headerTabs: true, // Improved header tab functionality and behavior - customMenuLinks: true, // Load and add custom menu links from configuration - breadcrumbs: true, // Breadcrumb navigation for Movies, Series, Seasons, Episodes, Music - playlist: true, // Enhanced playlist view with better navigation - itemDetailsCollections: true, // Shows collections on item details pages (Included In section) - flattenSingleSeasonShows: true, // Flattens single-season shows to display episodes directly on series page - collections: true, // Collection sorting functionality - - // Reviews and recommendations - // updoot: false, // This is unsupported for now but will be worked on in the future - - // Branding and external integrations - // REQUIRES: ExclusiveElsewhere requires JellyfinEnhanced plugin https://github.com/n00bcodr/Jellyfin-Enhanced - exclusiveElsewhere: false, // Custom branding when items aren't available on streaming services - - // Performance and stability improvements - backdropLeakFix: true, // Fixes memory leaks from backdrop images when tab isn't focused - dashboardButtonFix: true, // Proper back button behavior on dashboard - infiniteScroll: true, // Smooth infinite scrolling. Supports Movie and TV libraries. Music to be added in the future. - - // Quality of life improvements - removeContinue: true, // Add "Remove from Continue Watching" functionality - subtitleSearch: true, // Search and download subtitles directly from video OSD - - // UI customization - skinManager: true // Skin selection and management - adds skin dropdown to header and display preferences - }, - - // ============================================================================ - // HOME SCREEN CONFIGURATION - // ============================================================================ - // Configure custom home screen sections and discovery features - homeScreen: { - // NEW AND TRENDING SECTIONS - // Automatically curated sections showing latest content - enableNewAndTrending: true, // Master toggle for new and trending sections - enableNewMovies: true, // Show newest movies section - enableNewEpisodes: true, // Show newest episodes from last 7 days - enableTrending: false, // Show trending sections (currently stubs, order 32-33) - - // DISCOVERY SECTIONS - // Intelligent recommendations based on your library content - // Includes: - // - [Genre] Movies (random genres with sufficient movie count) - // - Directed by [Director] (top directors from your library) - // - Written by [Writer] (top writers from your library) - // - Starring [Actor] (top actors from your library) - // - Because you watched [Movie] (similar content based on watch history) - // - Because you liked [Movie] (similar content based on favorites) - enableDiscovery: true, // Enables discovery sections on the home screen - - // Discovery section behavior and filtering - enableInfiniteScroll: true, // Use infinite scroll vs Load More button - minPeopleAppearances: 10, // Minimum movie appearances for people to be featured - minGenreMovieCount: 50, // Minimum movie count for genres to be included - minimumShowsForNetwork: 5, // Minimum show count for TV networks to be featured - - // WATCHLIST SECTION - // Requires watchlist script to be enabled - enableWatchlist: true, // Show watchlist section on home screen - - // SEASONAL SECTIONS - // Holiday-themed content sections - enableSeasonal: true, // Master toggle for seasonal sections - seasonalItemLimit: 16, // Maximum items to show in seasonal sections - - // CUSTOM SECTIONS - // Add your own playlist/collection-based sections - customSections: [ - // Add more custom sections here - /* Example: - { - // Optionally provide a name, otherwise the name of the playlist/collection will be used - name: 'Your Custom Section', - - // The ID of the playlist/collection to display in the section, get this from Jellyfin playlist/collection URL - id: 'acc84f03475eb51602b13cde158d21ad', - - // The maximum number of items to display in the section on the home screen - maxItems: 20, - - // The order of the section for it to appear on the home screen - order: 5 - }, - */ - ], - }, - - // ============================================================================ - // EXCLUSIVE ELSEWHERE CONFIGURATION - // ============================================================================ - // Configure exclusive elsewhere branding behavior - exclusiveElsewhere: { - hideServerName: false // Set to true to hide server name, useful when you want to only show a logo or icon via CSS - }, - - // ============================================================================ - // SEARCH CONFIGURATION - // ============================================================================ - // Configure search functionality - search: { - enableJellyseerr: false // Enable Jellyseerr integration for request functionality - }, - - // ============================================================================ - // SKIN CONFIGURATION - // ============================================================================ - // Configure additional skins available in the skin manager - // These will be merged with skins from userConfig.js - // Each skin's url field should be an array of objects with majorServerVersions and urls arrays - // This allows you to specify different CSS bundles for different server versions - // 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: [ - // Add additional skins here that will be available to all users - // These are example skins - replace with your own or remove if not needed - /* Example of adding more skins: - { - name: 'Custom Theme', - author: 'username', - url: [ - { - majorServerVersions: [10, 11], - urls: ['https://cdn.jsdelivr.net/gh/username/custom-theme.css'] - } - ], - colorSchemes: [] - }, - { - name: 'Multi-file Theme', - author: 'username', - url: [ - { - majorServerVersions: [10, 11], - urls: [ - 'https://cdn.jsdelivr.net/gh/username/theme-base.css', - 'https://cdn.jsdelivr.net/gh/username/theme-extensions.css' - ] - } - ], - colorSchemes: [ - { - name: 'Dark Blue', - url: 'https://cdn.jsdelivr.net/gh/username/theme-dark-blue.css' - }, - { - name: 'Light Green', - url: 'https://cdn.jsdelivr.net/gh/username/theme-light-green.css' - } - ] - }, - { - name: 'Version-Specific Theme', - author: 'username', - url: [ - { - majorServerVersions: [10], - urls: ['https://cdn.jsdelivr.net/gh/username/theme-v10.css'] - }, - { - majorServerVersions: [11], - urls: [ - 'https://cdn.jsdelivr.net/gh/username/theme-v11.css', - 'https://cdn.jsdelivr.net/gh/username/theme-v11-fixes.css' - ] - } - ], - colorSchemes: [] - }, - */ - ], - - // Default skin to use when no skin is selected - // This overrides the default selection from userConfig.js - defaultSkin: null, // Set to skin name (e.g., 'ElegantFin') or null to use userConfig default - - - // ============================================================================ - // THEME CONFIGURATION - // ============================================================================ - // Configure additional themes available in the theme manager - // These will be merged with themes from userConfig.js - // Each theme should have a name and URL pointing to a CSS file - themes: [ - // Add additional themes here that will be available to all users - // These are example themes - replace with your own or remove if not needed - /* Example of adding more themes: - { - 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' - }, - */ - ], - - // ============================================================================ - // CUSTOM MENU LINKS CONFIGURATION - // ============================================================================ - // Configure custom menu links to be added to the custom menu - customMenuLinks: [ - // Add your custom menu links here - /* Example: - { - name: 'My Custom Link', // Display name for the menu item - icon: 'link', // Material icon name (optional, defaults to 'link') - url: '#/userpluginsettings.html?pageUrl=https://domain.com/custom-page', // URL to navigate to - openInNewTab: false // Whether to open in new tab (optional, defaults to false) - }, - { - name: 'External Link', - icon: 'open_in_new', - url: 'https://example.com', - openInNewTab: true - }, - */ - ], - }; - - // ============================================================================ - // CONFIGURATION EXPOSURE - // ============================================================================ - // Make configuration available to individual scripts - window.KefinTweaksConfig = KEFIN_TWEAKS_CONFIG; - - // ============================================================================ - // SCRIPT LOADER INITIALIZATION - // ============================================================================ - // Load the injector script which handles loading individual scripts based on configuration - const script = document.createElement("script"); - script.src = `${KEFIN_TWEAKS_CONFIG.kefinTweaksRoot}injector.js?v=${new Date().getTime()}`; - script.async = true; - document.head.appendChild(script); - - console.log('[KefinTweaks] Central configuration loaded. Available at window.KefinTweaksConfig'); -})(); - diff --git a/kefinTweaks.minimal.js b/kefinTweaks.minimal.js index 47c7fa5..f3ac70e 100644 --- a/kefinTweaks.minimal.js +++ b/kefinTweaks.minimal.js @@ -1,63 +1,387 @@ -(function() { - 'use strict'; - - const KEFIN_TWEAKS_CONFIG = { - kefinTweaksRoot: 'https://ranaldsgift.github.io/KefinTweaks/', - scriptRoot: 'https://ranaldsgift.github.io/KefinTweaks/scripts/', - - scripts: { - collections: true, - homeScreen: true, - search: true, - watchlist: true, - headerTabs: true, - customMenuLinks: true, - breadcrumbs: true, - playlist: true, - itemDetailsCollections: true, - flattenSingleSeasonShows: true, - exclusiveElsewhere: false, - backdropLeakFix: true, - dashboardButtonFix: true, - infiniteScroll: true, - removeContinue: true, - subtitleSearch: true, - skinManager: true +window.KefinTweaksConfig = { + "kefinTweaksRoot": "", + "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": { + "defaultItemLimit": 16, + "defaultSortOrder": "Random", + "defaultCardFormat": "Poster", + "recentlyReleased": { + "enabled": true, + "order": 30, + "movies": { + "name": "Recently Released Movies", + "enabled": true, + "itemLimit": 16, + "sortOrder": "ReleaseDate", + "sortOrderDirection": "Descending", + "cardFormat": "Poster", + "order": 30 + }, + "episodes": { + "name": "Recently Aired Episodes", + "enabled": true, + "itemLimit": 16, + "sortOrder": "PremiereDate", + "sortOrderDirection": "Descending", + "cardFormat": "Backdrop", + "order": 31 + } }, - 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: [], + "trending": { + "name": "Trending", + "enabled": false, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 32 }, - exclusiveElsewhere: { - hideServerName: false + "popularTVNetworks": { + "name": "Popular TV Networks", + "enabled": true, + "minimumShowsForNetwork": 5, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 61 }, - search: { - enableJellyseerr: false + "watchlist": { + "name": "Watchlist", + "enabled": true, + "itemLimit": 16, + "sortOrder": "DateAdded", + "sortOrderDirection": "Descending", + "cardFormat": "Poster", + "order": 60 }, - skins: [], - defaultSkin: null, - themes: [], - customMenuLinks: [], - }; - - window.KefinTweaksConfig = KEFIN_TWEAKS_CONFIG; - - const script = document.createElement("script"); - script.src = `${KEFIN_TWEAKS_CONFIG.kefinTweaksRoot}injector.js?v=${new Date().getTime()}`; - script.async = true; - document.head.appendChild(script); - - console.log('[KefinTweaks] Central configuration loaded. Available at window.KefinTweaksConfig'); -})(); - + "watchAgain": { + "name": "Watch Again", + "enabled": true, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 62 + }, + "upcoming": { + "name": "Upcoming", + "enabled": true, + "itemLimit": 48, + "cardFormat": "Thumb", + "order": 20 + }, + "imdbTop250": { + "name": "IMDb Top 250", + "enabled": true, + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 21 + }, + "seasonal": { + "enabled": true, + "defaultItemLimit": 16, + "defaultSortOrder": "Random", + "defaultCardFormat": "Poster", + "seasons": [ + { + "id": "halloween", + "name": "Halloween", + "enabled": true, + "startDate": "10-01", + "endDate": "10-31", + "sections": [ + { + "id": "halloween-tag", + "enabled": true, + "name": "Halloween Movies", + "type": "Tag", + "source": "halloween", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 50 + }, + { + "id": "halloween-horror", + "enabled": true, + "name": "Horror Genre", + "type": "Genre", + "source": "Horror", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 51 + }, + { + "id": "halloween-thriller", + "enabled": true, + "name": "Thriller Genre", + "type": "Genre", + "source": "Thriller", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 52 + } + ] + }, + { + "id": "thanksgiving", + "name": "Thanksgiving", + "enabled": true, + "startDate": "11-20", + "endDate": "11-30", + "order": 50, + "sections": [ + { + "id": "seasonal-2-section-0", + "enabled": true, + "name": "Thanksgiving Movies", + "type": "Tag", + "source": "thanksgiving", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 50, + "spotlight": true, + "discoveryEnabled": false, + "searchTerm": "", + "includeItemTypes": [ + "Movie" + ] + }, + { + "id": "seasonal-2-section-1", + "enabled": true, + "name": "Thanksgiving Episodes", + "type": "Parent", + "source": "", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Thumb", + "order": 51, + "spotlight": false, + "discoveryEnabled": false, + "searchTerm": "thanksgiving", + "includeItemTypes": [ + "Episode" + ] + } + ] + }, + { + "id": "christmas", + "name": "Christmas", + "enabled": true, + "startDate": "12-01", + "endDate": "12-31", + "sections": [ + { + "id": "christmas-genre", + "enabled": true, + "name": "Christmas Movies", + "type": "Genre", + "source": "Christmas", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 60 + }, + { + "id": "christmas-family", + "enabled": true, + "name": "Family Movies", + "type": "Genre", + "source": "Family", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "order": 61 + } + ] + } + ] + }, + "discovery": { + "enabled": true, + "infiniteScroll": true, + "minPeopleAppearances": 10, + "minGenreMovieCount": 50, + "defaultItemLimit": 16, + "defaultSortOrder": "Random", + "defaultCardFormat": "Poster", + "sectionTypes": { + "spotlightGenre": { + "enabled": true, + "name": "Top Rated [Genre] Movies", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "spotlightNetwork": { + "enabled": true, + "name": "Top Rated Shows from [Studio]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "genreMovies": { + "enabled": true, + "name": "[Genre] Movies", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "studioShows": { + "enabled": true, + "name": "Shows from [Studio]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "collections": { + "enabled": true, + "name": "[Collection Name]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster", + "minimumItems": 10 + }, + "becauseYouWatched": { + "enabled": true, + "name": "Because you watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "becauseYouLiked": { + "enabled": true, + "name": "Because you liked [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "starringTopActor": { + "enabled": true, + "name": "Starring [Actor]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "directedByTopDirector": { + "enabled": true, + "name": "Directed by [Director]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "writtenByTopWriter": { + "enabled": true, + "name": "Written by [Writer]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "becauseYouRecentlyWatched": { + "enabled": true, + "name": "Because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "starringActorRecentlyWatched": { + "enabled": true, + "name": "Starring [Actor] because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "directedByDirectorRecentlyWatched": { + "enabled": true, + "name": "Directed by [Director] because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + }, + "writtenByWriterRecentlyWatched": { + "enabled": true, + "name": "Written by [Writer] because you recently watched [Movie]", + "itemLimit": 16, + "sortOrder": "Random", + "sortOrderDirection": "Ascending", + "cardFormat": "Poster" + } + }, + "spotlightDiscoveryChance": 0.5, + "spotlight": { + "autoPlay": true, + "interval": 5000, + "showDots": true, + "showNavButtons": true, + "showClearArt": true + }, + "randomizeOrder": false + }, + "customSections": [] + }, + "exclusiveElsewhere": { + "hideServerName": false + }, + "search": { + "enableJellyseerr": false + }, + "skins": [ + { + "name": "Default", + "author": "Jellyfin", + "url": null, + "colorSchemes": [], + "enabled": true, + "hidden": true + } + ], + "defaultSkin": null, + "themes": [], + "customMenuLinks": [] +}; \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..ff6f9a9 Binary files /dev/null and b/logo.png differ diff --git a/pages/images/injector.png b/pages/images/injector.png index 05871a2..38c0c53 100644 Binary files a/pages/images/injector.png and b/pages/images/injector.png differ diff --git a/pages/images/plugin-install.gif b/pages/images/plugin-install.gif new file mode 100644 index 0000000..d12e7d8 Binary files /dev/null and b/pages/images/plugin-install.gif differ diff --git a/pages/images/plugin-uninstall.gif b/pages/images/plugin-uninstall.gif new file mode 100644 index 0000000..4a8c050 Binary files /dev/null and b/pages/images/plugin-uninstall.gif differ diff --git a/scripts/configuration.js b/scripts/configuration.js index cf6edde..ce94cc7 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -45,8 +45,12 @@ 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'; + ? `${window.KefinTweaksConfig.kefinTweaksRoot}kefinTweaks-default-config.js`; + + if (!defaultConfigUrl) { + reject(new Error('No default config URL found')); + return; + } // Check if already loaded if (window.KefinTweaksDefaultConfig) { @@ -115,9 +119,8 @@ const currentConfigSource = currentLoadedConfig || window.KefinTweaksCurrentConfig || window.KefinTweaksConfig || {}; const currentKefinRoot = currentConfigSource.kefinTweaksRoot || defaultConfig.kefinTweaksRoot; - const currentScriptRoot = currentConfigSource.scriptRoot || defaultConfig.scriptRoot; defaultConfig.kefinTweaksRoot = currentKefinRoot; - defaultConfig.scriptRoot = currentScriptRoot; + // Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/' // Save defaults to JS Injector for future use await saveConfigToJavaScriptInjector(defaultConfig); @@ -132,8 +135,7 @@ 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/', + kefinTweaksRoot: '', scripts: {}, homeScreen: {}, exclusiveElsewhere: {}, @@ -361,10 +363,10 @@ const modal = window.ModalSystem.create({ id: 'scriptRootEditConfirmation', - title: 'Edit Script Root URLs', + title: 'Edit KefinTweaks Root URL', 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. + Editing the Root URL is useful if you are hosting the scripts yourself or if you want to use a specific KefinTweaks version. Scripts will be loaded from this root URL + '/scripts/'. There is no validation done for the URL provided so please ensure it is valid. In any case, you can easily revert this setting.
`, footer: ` @@ -417,8 +419,8 @@
-

Script Root Configuration

-
Configure where KefinTweaks scripts are loaded from
+

KefinTweaks Root Configuration

+
Configure where KefinTweaks scripts are loaded from. Scripts will be loaded from this root URL + '/scripts/'
@@ -4138,7 +4133,6 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; // Collect all form values const config = { kefinTweaksRoot: document.getElementById('kefinTweaksRoot')?.value || '', - scriptRoot: document.getElementById('scriptRoot')?.value || '', scripts: {}, homeScreen: {}, exclusiveElsewhere: {}, @@ -4148,6 +4142,11 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; themes: [], customMenuLinks: [] }; + + // Remove scriptRoot if it exists (legacy field, no longer used) + if (config.scriptRoot) { + delete config.scriptRoot; + } // Collect script toggles document.querySelectorAll('[data-script-key]').forEach(checkbox => { @@ -4435,10 +4434,10 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; try { const defaultConfig = await loadDefaultConfig(); - // Retain script URLs from existing config + // Retain kefinTweaksRoot from existing config const existingConfig = await getKefinTweaksConfig(); defaultConfig.kefinTweaksRoot = existingConfig.kefinTweaksRoot; - defaultConfig.scriptRoot = existingConfig.scriptRoot; + // Note: scriptRoot is no longer used - scripts are loaded from kefinTweaksRoot + '/scripts/' await saveConfigToJavaScriptInjector(defaultConfig);