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
+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 = [];