mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Add support for admins to configure KefinTweaks from Jellyfin UI
This commit is contained in:
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user