Add support for admins to configure KefinTweaks from Jellyfin UI

This commit is contained in:
HighImKevin
2025-11-15 07:26:20 -05:00
parent fa06c682b4
commit 2a878a0099
16 changed files with 2836 additions and 93 deletions
+62 -16
View File
@@ -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**
<div align="center">
<img src="pages/images/injector.png" alt="injector" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<hr>
## 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
<div class="sections watchlist"></div>
```
<div align="center">
<img src="pages/images/customtab.png" alt="customtab" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<img src="pages/images/config-1.png" alt="Configuration Interface" style="max-width: 100%; height: auto;"/>
</div>
<br/>
<div align="center">
<img src="pages/images/config-2.png" alt="Configuration Interface" style="max-width: 100%; height: auto;"/>
</div>
<br/>
### 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.
<hr>
## 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` |
<br>
## 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
<hr>
### 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
+331 -1
View File
@@ -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 !== '<div class="sections watchlist"></div>') {
watchlistTab.ContentHtml = '<div class="sections watchlist"></div>';
// 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: '<div class="sections watchlist"></div>'
});
// 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');
+447
View File
@@ -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/[email protected]/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": []
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+64
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+72 -10
View File
@@ -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),
+7
View File
@@ -59,4 +59,11 @@ label[for="searchTextInput"] {
.input-wrapper {
position: relative;
}
span.searchfields-icon {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
+24 -4
View File
@@ -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
+18 -14
View File
@@ -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
+95 -45
View File
@@ -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;
+9 -2
View File
@@ -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 = [];
+4 -1
View File
@@ -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';
}
+3
View File
@@ -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 {