mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Add a bunch of new scripts with Injector.js to bundle and configure
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// Backdrop Leak Fix Script
|
||||
// Fixes issue that causes backdrop images to be continuously added to the page if the tab isn't focused
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Common logging function
|
||||
const LOG = (...args) => console.log('[BackdropLeakFix]', ...args);
|
||||
|
||||
LOG('Script loaded - JS Injector mode');
|
||||
|
||||
// Step 1: watch for .backdropContainer to appear
|
||||
const waitForContainer = new MutationObserver(() => {
|
||||
const container = document.querySelector('.backdropContainer');
|
||||
if (!container) return;
|
||||
|
||||
LOG('Found .backdropContainer, attaching prune observer');
|
||||
waitForContainer.disconnect();
|
||||
|
||||
// Step 2: prune children as needed
|
||||
const pruneObserver = new MutationObserver(() => {
|
||||
LOG('Mutation detected, checking children count:', container.children.length);
|
||||
while (container.children.length > 2) {
|
||||
LOG('Too many children (' + container.children.length + '), removing oldest');
|
||||
container.removeChild(container.firstElementChild);
|
||||
}
|
||||
});
|
||||
|
||||
pruneObserver.observe(container, { childList: true });
|
||||
LOG('Prune observer is now active');
|
||||
});
|
||||
|
||||
waitForContainer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
LOG('Waiting for .backdropContainer to appear...');
|
||||
LOG('Backdrop leak fix functionality initialized');
|
||||
})();
|
||||
+380
@@ -14,6 +14,48 @@
|
||||
*/
|
||||
buildCard: function(item) {
|
||||
return createJellyfinCardElement(item);
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders a scrollable container with cards
|
||||
* @param {Array} items - Array of Jellyfin item objects
|
||||
* @param {string} title - Title for the scrollable container
|
||||
* @param {string} viewMoreUrl - Optional URL to make title clickable
|
||||
* @param {number} sectionIndex - Index for debugging
|
||||
* @returns {HTMLElement} - The constructed scrollable container
|
||||
*/
|
||||
renderCards: function(items, title, viewMoreUrl = null, sectionIndex = 0) {
|
||||
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders a scrollable container with cards from item IDs
|
||||
* @param {Array} itemIds - Array of Jellyfin item IDs
|
||||
* @param {string} title - Title for the scrollable container
|
||||
* @param {string} viewMoreUrl - Optional URL to make title clickable
|
||||
* @param {number} sectionIndex - Index for debugging
|
||||
* @returns {Promise<HTMLElement>} - The constructed scrollable container
|
||||
*/
|
||||
renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, sectionIndex = 0) {
|
||||
const LOG = (...args) => console.log('[CardBuilder]', ...args);
|
||||
|
||||
if (!itemIds || itemIds.length === 0) {
|
||||
LOG(`No item IDs provided for section: ${title}`);
|
||||
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch all items at once
|
||||
const items = await Promise.all(
|
||||
itemIds.map(id => ApiClient.getItem(ApiClient.getCurrentUserId(), id))
|
||||
);
|
||||
|
||||
LOG(`Fetched ${items.length} items for section: ${title}`);
|
||||
return createScrollableContainer(items, title, viewMoreUrl, sectionIndex);
|
||||
} catch (error) {
|
||||
console.error('[CardBuilder] Error fetching items:', error);
|
||||
return createScrollableContainer([], title, viewMoreUrl, sectionIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -301,6 +343,344 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scrollable container with horizontal scrolling functionality
|
||||
* @param {Array} items - Array of Jellyfin item objects
|
||||
* @param {string} title - Title for the scrollable container
|
||||
* @param {string} viewMoreUrl - Optional URL to make title clickable
|
||||
* @param {number} sectionIndex - Index for debugging
|
||||
* @returns {HTMLElement} - The constructed scrollable container
|
||||
*/
|
||||
function createScrollableContainer(items, title, viewMoreUrl = null, sectionIndex = 0) {
|
||||
const LOG = (...args) => console.log('[CardBuilder]', ...args);
|
||||
|
||||
// Create the main vertical section container
|
||||
const verticalSection = document.createElement('div');
|
||||
verticalSection.className = 'verticalSection emby-scroller-container';
|
||||
|
||||
// Create section title
|
||||
const sectionTitle = document.createElement('h2');
|
||||
sectionTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right';
|
||||
|
||||
if (viewMoreUrl) {
|
||||
// Create clickable title with chevron icon
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.href = viewMoreUrl;
|
||||
titleLink.className = 'sectionTitle-link sectionTitleTextButton';
|
||||
titleLink.style.cssText = 'text-decoration: none; cursor: pointer; display: flex; align-items: center;';
|
||||
|
||||
const titleText = document.createElement('span');
|
||||
titleText.textContent = title;
|
||||
|
||||
const chevronIcon = document.createElement('span');
|
||||
chevronIcon.className = 'material-icons chevron_right';
|
||||
chevronIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
titleLink.appendChild(titleText);
|
||||
titleLink.appendChild(chevronIcon);
|
||||
sectionTitle.appendChild(titleLink);
|
||||
} else {
|
||||
// Regular non-clickable title
|
||||
sectionTitle.textContent = title;
|
||||
}
|
||||
|
||||
// Create scroll buttons container
|
||||
const scrollButtons = document.createElement('div');
|
||||
//scrollButtons.setAttribute('is', 'emby-scrollbuttons');
|
||||
scrollButtons.className = 'emby-scrollbuttons custom-scrollbuttons padded-right';
|
||||
|
||||
// Previous button
|
||||
const prevButton = document.createElement('button');
|
||||
prevButton.type = 'button';
|
||||
prevButton.setAttribute('is', 'paper-icon-button-light');
|
||||
prevButton.setAttribute('data-ripple', 'false');
|
||||
prevButton.setAttribute('data-direction', 'left');
|
||||
prevButton.title = 'Previous';
|
||||
prevButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
|
||||
prevButton.disabled = true;
|
||||
|
||||
const prevIcon = document.createElement('span');
|
||||
prevIcon.className = 'material-icons chevron_left';
|
||||
prevIcon.setAttribute('aria-hidden', 'true');
|
||||
prevButton.appendChild(prevIcon);
|
||||
|
||||
// Next button
|
||||
const nextButton = document.createElement('button');
|
||||
nextButton.type = 'button';
|
||||
nextButton.setAttribute('is', 'paper-icon-button-light');
|
||||
nextButton.setAttribute('data-ripple', 'false');
|
||||
nextButton.setAttribute('data-direction', 'right');
|
||||
nextButton.title = 'Next';
|
||||
nextButton.className = 'emby-scrollbuttons-button paper-icon-button-light';
|
||||
|
||||
const nextIcon = document.createElement('span');
|
||||
nextIcon.className = 'material-icons chevron_right';
|
||||
nextIcon.setAttribute('aria-hidden', 'true');
|
||||
nextButton.appendChild(nextIcon);
|
||||
|
||||
scrollButtons.appendChild(prevButton);
|
||||
scrollButtons.appendChild(nextButton);
|
||||
|
||||
// Create scroller container
|
||||
const scroller = document.createElement('div');
|
||||
scroller.setAttribute('is', 'emby-scroller');
|
||||
scroller.setAttribute('data-horizontal', 'true');
|
||||
scroller.setAttribute('data-centerfocus', 'card');
|
||||
scroller.className = 'padded-top-focusscale padded-bottom-focusscale emby-scroller custom-scroller';
|
||||
scroller.setAttribute('data-scroll-mode-x', 'custom');
|
||||
scroller.style.overflow = 'hidden';
|
||||
|
||||
// Create items container
|
||||
const itemsContainer = document.createElement('div');
|
||||
itemsContainer.setAttribute('is', 'emby-itemscontainer');
|
||||
itemsContainer.className = 'focuscontainer-x itemsContainer scrollSlider animatedScrollX';
|
||||
itemsContainer.style.whiteSpace = 'nowrap';
|
||||
itemsContainer.style.willChange = 'transform';
|
||||
itemsContainer.style.transition = 'transform 270ms ease-out';
|
||||
itemsContainer.style.transform = 'translateX(0px)';
|
||||
|
||||
// Add items to container
|
||||
items.forEach((item, index) => {
|
||||
const card = createJellyfinCardElement(item);
|
||||
card.setAttribute('data-index', index);
|
||||
itemsContainer.appendChild(card);
|
||||
});
|
||||
|
||||
scroller.appendChild(itemsContainer);
|
||||
|
||||
// Scroll functionality - calculate dynamically when needed
|
||||
let scrollPosition = 0;
|
||||
|
||||
// Calculate scroll values dynamically based on current DOM state
|
||||
const calculateScrollValues = () => {
|
||||
const firstCard = itemsContainer.querySelector('.card');
|
||||
if (!firstCard) {
|
||||
LOG(`[SCROLL ERROR] No cards found in scroller ${sectionIndex}`);
|
||||
return { cardWidth: 212, visibleCards: 1, scrollStep: 1, maxScroll: 0 };
|
||||
}
|
||||
|
||||
const cardRect = firstCard.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(firstCard);
|
||||
const marginRight = parseFloat(computedStyle.marginRight) || 0;
|
||||
const cardWidth = cardRect.width + marginRight; // Card width + gap
|
||||
|
||||
const visibleCards = Math.floor(scroller.offsetWidth / cardWidth);
|
||||
const scrollStep = Math.max(1, visibleCards); // Scroll by the number of fully visible cards
|
||||
const maxScroll = Math.max(0, items.length - visibleCards);
|
||||
|
||||
LOG(`[SCROLL CALC] Scroller ${sectionIndex}: containerWidth=${scroller.offsetWidth}, cardWidth=${cardWidth}, visibleCards=${visibleCards}, scrollStep=${scrollStep}, totalItems=${items.length}, maxScroll=${maxScroll}`);
|
||||
|
||||
return { cardWidth, visibleCards, scrollStep, maxScroll };
|
||||
};
|
||||
|
||||
const updateScrollButtons = () => {
|
||||
const { maxScroll } = calculateScrollValues();
|
||||
prevButton.disabled = scrollPosition <= 0;
|
||||
nextButton.disabled = scrollPosition >= maxScroll;
|
||||
};
|
||||
|
||||
const scrollTo = (position, smooth = true) => {
|
||||
const { cardWidth, maxScroll } = calculateScrollValues();
|
||||
|
||||
scrollPosition = Math.max(0, Math.min(position, maxScroll));
|
||||
|
||||
if (smooth) {
|
||||
itemsContainer.style.transition = 'transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
} else {
|
||||
itemsContainer.style.transition = 'none';
|
||||
}
|
||||
|
||||
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
|
||||
updateScrollButtons();
|
||||
|
||||
if (smooth) {
|
||||
setTimeout(() => {
|
||||
itemsContainer.style.transition = 'transform 270ms ease-out';
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if buttons already have event listeners (from Jellyfin's default handlers)
|
||||
const hasExistingListeners = () => {
|
||||
// Check if the buttons have any event listeners attached
|
||||
// We'll look for the presence of Jellyfin's default scroll behavior
|
||||
const existingButtons = document.querySelectorAll('.emby-scrollbuttons-button.paper-icon-button-light');
|
||||
return existingButtons.length > 0 && existingButtons[0].onclick !== null;
|
||||
};
|
||||
|
||||
// Native horizontal scrolling with momentum and snap
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchCurrentX = 0;
|
||||
let isDragging = false;
|
||||
let startScrollPosition = 0;
|
||||
let currentScrollOffset = 0;
|
||||
let lastTouchTime = 0;
|
||||
let velocity = 0;
|
||||
let lastTouchX = 0;
|
||||
let animationFrame = null;
|
||||
let momentumAnimation = null;
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
// Cancel any ongoing momentum animation
|
||||
if (momentumAnimation) {
|
||||
cancelAnimationFrame(momentumAnimation);
|
||||
momentumAnimation = null;
|
||||
}
|
||||
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
lastTouchX = touchStartX;
|
||||
lastTouchTime = Date.now();
|
||||
isDragging = true;
|
||||
startScrollPosition = scrollPosition;
|
||||
currentScrollOffset = 0;
|
||||
velocity = 0;
|
||||
|
||||
// Disable smooth transitions during drag
|
||||
itemsContainer.style.transition = 'none';
|
||||
|
||||
// Don't prevent default here - let touch move determine if we should handle it
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
if (!isDragging || e.touches.length !== 1) return;
|
||||
|
||||
touchCurrentX = e.touches[0].clientX;
|
||||
const deltaX = touchStartX - touchCurrentX;
|
||||
const deltaY = touchStartY - e.touches[0].clientY;
|
||||
|
||||
// Only process horizontal swipes
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
|
||||
e.preventDefault(); // Prevent default scrolling behavior
|
||||
|
||||
// Calculate velocity for momentum
|
||||
const currentTime = Date.now();
|
||||
const timeDelta = currentTime - lastTouchTime;
|
||||
if (timeDelta > 0) {
|
||||
velocity = (lastTouchX - touchCurrentX) / timeDelta;
|
||||
}
|
||||
lastTouchX = touchCurrentX;
|
||||
lastTouchTime = currentTime;
|
||||
|
||||
// Update scroll position in real-time
|
||||
const { cardWidth } = calculateScrollValues();
|
||||
currentScrollOffset = deltaX / cardWidth;
|
||||
const newPosition = startScrollPosition + currentScrollOffset;
|
||||
|
||||
// Apply the transform immediately
|
||||
itemsContainer.style.transform = `translateX(-${newPosition * cardWidth}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
isDragging = false;
|
||||
|
||||
// Calculate final position with momentum
|
||||
const { cardWidth, maxScroll } = calculateScrollValues();
|
||||
let finalPosition = startScrollPosition + currentScrollOffset;
|
||||
|
||||
// Apply momentum if velocity is significant
|
||||
if (Math.abs(velocity) > 0.1) {
|
||||
const momentumDistance = velocity * 200; // Adjust multiplier for momentum strength
|
||||
finalPosition += momentumDistance / cardWidth;
|
||||
}
|
||||
|
||||
// Clamp to bounds
|
||||
finalPosition = Math.max(0, Math.min(finalPosition, maxScroll));
|
||||
|
||||
// Snap to nearest card
|
||||
const snapPosition = Math.round(finalPosition);
|
||||
finalPosition = Math.max(0, Math.min(snapPosition, maxScroll));
|
||||
|
||||
// Update scroll position and animate to final position
|
||||
scrollPosition = finalPosition;
|
||||
|
||||
// Re-enable smooth transitions
|
||||
itemsContainer.style.transition = 'transform 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
itemsContainer.style.transform = `translateX(-${scrollPosition * cardWidth}px)`;
|
||||
|
||||
// Update button states
|
||||
updateScrollButtons();
|
||||
|
||||
LOG(`[TOUCH END] Scroller ${sectionIndex}: velocity=${velocity.toFixed(3)}, finalPosition=${finalPosition}, snapPosition=${snapPosition}`);
|
||||
|
||||
// Reset velocity
|
||||
velocity = 0;
|
||||
currentScrollOffset = 0;
|
||||
};
|
||||
|
||||
// Add touch event listeners to the scroller
|
||||
scroller.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
scroller.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
scroller.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||
scroller.addEventListener('touchcancel', handleTouchEnd, { passive: false });
|
||||
|
||||
// Only attach our event listeners if there are no existing ones
|
||||
if (true) {
|
||||
prevButton.addEventListener('click', () => {
|
||||
const { scrollStep, maxScroll } = calculateScrollValues();
|
||||
const newPosition = Math.max(0, scrollPosition - scrollStep);
|
||||
LOG(`[PREV CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
|
||||
scrollTo(newPosition, true);
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', () => {
|
||||
const { scrollStep, maxScroll } = calculateScrollValues();
|
||||
const newPosition = Math.min(maxScroll, scrollPosition + scrollStep);
|
||||
LOG(`[NEXT CLICK] Scroller ${sectionIndex}: currentPosition=${scrollPosition}, scrollStep=${scrollStep}, newPosition=${newPosition}`);
|
||||
scrollTo(newPosition, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial state
|
||||
updateScrollButtons();
|
||||
|
||||
// Assemble the section
|
||||
verticalSection.appendChild(sectionTitle);
|
||||
verticalSection.appendChild(scrollButtons);
|
||||
verticalSection.appendChild(scroller);
|
||||
|
||||
return verticalSection;
|
||||
}
|
||||
|
||||
// Add CSS to hide emby-scrollbuttons that aren't custom-scrollbuttons and improve touch experience
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.smart-search-results .emby-scrollbuttons {
|
||||
display: none;
|
||||
}
|
||||
.layout-desktop .smart-search-results .emby-scrollbuttons:not(.custom-scrollbuttons) {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Native horizontal scrolling experience - ONLY for our custom scrollers */
|
||||
.emby-scroller.custom-scroller {
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
touch-action: pan-x !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
}
|
||||
|
||||
/* Improve touch responsiveness */
|
||||
.emby-scroller.custom-scroller .scrollSlider {
|
||||
touch-action: pan-x !important;
|
||||
-webkit-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* Ensure smooth scrolling on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.emby-scroller.custom-scroller {
|
||||
scroll-behavior: smooth !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Expose the cardBuilder to the global window object
|
||||
window.cardBuilder = cardBuilder;
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Custom Menu Script
|
||||
// Removes target="_blank" from custom menu links to prevent opening in new tabs
|
||||
// Ensures all custom menu links open in the same tab for better user experience
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Common logging function
|
||||
const LOG = (...args) => console.log('[CustomMenu]', ...args);
|
||||
const WARN = (...args) => console.warn('[CustomMenu]', ...args);
|
||||
const ERR = (...args) => console.error('[CustomMenu]', ...args);
|
||||
|
||||
LOG('Script loaded - JS Injector mode');
|
||||
|
||||
function initMenuWatcher() {
|
||||
const menuContainer = document.querySelector('.customMenuOptions');
|
||||
if (!menuContainer) return false;
|
||||
|
||||
LOG('Custom menu container found, initializing watcher');
|
||||
|
||||
const cleanLinks = (node) => {
|
||||
if (node.matches?.('a.navMenuOption[target="_blank"]')) {
|
||||
LOG('Removing target="_blank" from menu link');
|
||||
node.removeAttribute('target');
|
||||
}
|
||||
node.querySelectorAll?.('a.navMenuOption[target="_blank"]').forEach(link => {
|
||||
LOG('Removing target="_blank" from nested menu link');
|
||||
link.removeAttribute('target');
|
||||
});
|
||||
};
|
||||
|
||||
// Clean existing links
|
||||
cleanLinks(menuContainer);
|
||||
|
||||
// Watch for new/changed nodes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach(node => cleanLinks(node));
|
||||
if (mutation.type === 'attributes' && mutation.target.matches('a.navMenuOption')) {
|
||||
cleanLinks(mutation.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(menuContainer, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['target']
|
||||
});
|
||||
|
||||
LOG('Menu watcher initialized successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Watch for menu container to appear
|
||||
const waitForMenu = () => {
|
||||
const menuContainer = document.querySelector('.customMenuOptions');
|
||||
if (menuContainer) {
|
||||
initMenuWatcher();
|
||||
return;
|
||||
}
|
||||
|
||||
LOG('Custom menu container not found, waiting for it to appear');
|
||||
|
||||
// Use MutationObserver to watch for the menu container
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Check if the added node is the menu or contains it
|
||||
if (node.matches?.('.customMenuOptions') || node.querySelector?.('.customMenuOptions')) {
|
||||
LOG('Custom menu container detected, initializing watcher');
|
||||
observer.disconnect();
|
||||
initMenuWatcher();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Fallback timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
WARN('Menu not found after 10 seconds');
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Start watching
|
||||
waitForMenu();
|
||||
|
||||
LOG('Custom menu functionality initialized');
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Exclusive Elsewhere
|
||||
Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality
|
||||
Adds custom branding when the item is not available on any selected streaming services
|
||||
|
||||
If you want to add an icon to the text you can do so by targeting the "exclusive" class.
|
||||
|
||||
For example:
|
||||
.streaming-lookup-container a.exclusive::after {
|
||||
content: '';
|
||||
background: url(/path/to/icon.png);
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
*/
|
||||
|
||||
(function () {
|
||||
const observer = new MutationObserver(() => {
|
||||
const link = document.querySelector('.itemDetailPage:not(.hide) .streaming-lookup-container>div>div:first-child a');
|
||||
if (link && link.innerHTML.trim() === "Not available on any streaming services in Canada") {
|
||||
link.innerHTML = "Only available on ";
|
||||
link.classList.add("exclusive");
|
||||
link.title = "24kev.in Exclusive"
|
||||
link.disable = true;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
})();
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// Header Tabs Script
|
||||
// Enhances header tab functionality by updating URL parameters when tabs are clicked
|
||||
// Maintains tab state in the URL for better navigation and bookmarking
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Common logging function
|
||||
const LOG = (...args) => console.log('[HeaderTabs]', ...args);
|
||||
const WARN = (...args) => console.warn('[HeaderTabs]', ...args);
|
||||
const ERR = (...args) => console.error('[HeaderTabs]', ...args);
|
||||
|
||||
LOG('Script loaded - JS Injector mode');
|
||||
|
||||
const SUPPORTED_PAGES = ['home.html', 'tv.html', 'movies.html', 'music.html', 'livetv.html'];
|
||||
|
||||
// Get current page from URL hash
|
||||
function getCurrentPage() {
|
||||
const hash = window.location.hash;
|
||||
const hashMatches = hash.match(/#\/([^?]+)/g);
|
||||
if (!hashMatches || hashMatches.length === 0) return null;
|
||||
|
||||
for (const match of hashMatches) {
|
||||
const pageFromHash = match.replace('#/', '');
|
||||
if (SUPPORTED_PAGES.includes(pageFromHash)) {
|
||||
LOG('Current page detected:', pageFromHash);
|
||||
return pageFromHash;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update URL with tab parameter
|
||||
function updateUrlWithTab(tabIndex) {
|
||||
const currentHash = window.location.hash;
|
||||
const currentPage = getCurrentPage();
|
||||
|
||||
if (!currentPage) return;
|
||||
|
||||
let newHash = currentHash;
|
||||
|
||||
if (currentHash.includes('?')) {
|
||||
// Replace existing tab parameter
|
||||
const [hashPath, hashSearch] = currentHash.split('?');
|
||||
const params = new URLSearchParams(hashSearch);
|
||||
|
||||
if (tabIndex === 0) {
|
||||
params.delete('tab');
|
||||
} else {
|
||||
params.set('tab', tabIndex.toString());
|
||||
}
|
||||
|
||||
const paramString = params.toString();
|
||||
newHash = paramString ? `${hashPath}?${paramString}` : hashPath;
|
||||
} else {
|
||||
// Add tab parameter
|
||||
if (tabIndex !== 0) {
|
||||
newHash = `${currentHash}?tab=${tabIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1);
|
||||
|
||||
// Replace history entry instead of adding new one
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
LOG('Updated URL with tab:', tabIndex, newUrl);
|
||||
}
|
||||
|
||||
// Handle tab button clicks
|
||||
function handleTabClick(event) {
|
||||
const clickedButton = event.currentTarget;
|
||||
const tabIndex = parseInt(clickedButton.getAttribute('data-index') || '0', 10);
|
||||
const currentPage = getCurrentPage();
|
||||
|
||||
if (currentPage) {
|
||||
updateUrlWithTab(tabIndex);
|
||||
|
||||
// Clean up tab content - ensure only the clicked tab has is-active
|
||||
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
|
||||
if (libraryPage) {
|
||||
const tabContents = libraryPage.querySelectorAll('.pageTabContent');
|
||||
|
||||
// Remove is-active class from all tab contents
|
||||
tabContents.forEach(content => {
|
||||
content.classList.remove('is-active');
|
||||
});
|
||||
|
||||
// Add is-active class to the clicked tab content
|
||||
const targetContent = libraryPage.querySelector(`.pageTabContent[data-index="${tabIndex}"]`);
|
||||
if (targetContent) {
|
||||
targetContent.classList.add('is-active');
|
||||
LOG('Set active tab content:', tabIndex);
|
||||
} else {
|
||||
WARN('Target tab content not found for index:', tabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
LOG('Tab clicked:', currentPage, tabIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Add click listeners to tab buttons
|
||||
function addClickListeners() {
|
||||
const headerTabs = document.querySelector('.headerTabs');
|
||||
if (headerTabs) {
|
||||
const buttons = headerTabs.querySelectorAll('.emby-tab-button');
|
||||
buttons.forEach(button => {
|
||||
button.removeEventListener('click', handleTabClick);
|
||||
button.addEventListener('click', handleTabClick);
|
||||
});
|
||||
LOG('Added click listeners to', buttons.length, 'buttons');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup observer to watch for emby-tab-button additions
|
||||
function setupHeaderTabsObserver() {
|
||||
const headerTabs = document.querySelector('.headerTabs');
|
||||
if (!headerTabs) {
|
||||
LOG('No .headerTabs found, will retry...');
|
||||
setTimeout(setupHeaderTabsObserver, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG('Setting up headerTabs observer');
|
||||
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
let shouldReinit = false;
|
||||
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.type === 'childList') {
|
||||
const addedNodes = Array.from(mutation.addedNodes);
|
||||
const removedNodes = Array.from(mutation.removedNodes);
|
||||
|
||||
const hasTabButtons = addedNodes.some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.classList.contains('emby-tab-button') ||
|
||||
node.querySelector && node.querySelector('.emby-tab-button'))
|
||||
) || removedNodes.some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.classList.contains('emby-tab-button') ||
|
||||
node.querySelector && node.querySelector('.emby-tab-button'))
|
||||
);
|
||||
|
||||
if (hasTabButtons) {
|
||||
shouldReinit = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldReinit) {
|
||||
LOG('Tab buttons changed, reinitializing...');
|
||||
setTimeout(addClickListeners, 100);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(headerTabs, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Initial setup
|
||||
addClickListeners();
|
||||
}
|
||||
|
||||
// Setup observer to watch for pageTabContent changes
|
||||
function setupObserver() {
|
||||
// Find the library page container
|
||||
const libraryPage = document.querySelector('.libraryPage:not(.hide)');
|
||||
if (!libraryPage) {
|
||||
LOG('No library page found, will retry...');
|
||||
setTimeout(setupObserver, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG('Setting up observer on library page');
|
||||
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
let shouldReinit = false;
|
||||
|
||||
mutations.forEach(mutation => {
|
||||
// Check if pageTabContent elements were added/removed
|
||||
if (mutation.type === 'childList') {
|
||||
const addedNodes = Array.from(mutation.addedNodes);
|
||||
const removedNodes = Array.from(mutation.removedNodes);
|
||||
|
||||
const hasPageTabContent = addedNodes.some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.classList.contains('pageTabContent') ||
|
||||
node.querySelector && node.querySelector('.pageTabContent'))
|
||||
) || removedNodes.some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node.classList.contains('pageTabContent') ||
|
||||
node.querySelector && node.querySelector('.pageTabContent'))
|
||||
);
|
||||
|
||||
if (hasPageTabContent) {
|
||||
shouldReinit = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldReinit) {
|
||||
LOG('pageTabContent changed, reinitializing...');
|
||||
setTimeout(addClickListeners, 100);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(libraryPage, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
// Monitor for page changes
|
||||
function setupPageMonitor() {
|
||||
let lastPage = null;
|
||||
|
||||
function checkPageChange() {
|
||||
const newPage = getCurrentPage();
|
||||
if (newPage !== lastPage) {
|
||||
lastPage = newPage;
|
||||
if (newPage) {
|
||||
LOG('Page change detected:', newPage);
|
||||
// Setup observers when page changes
|
||||
setupHeaderTabsObserver();
|
||||
setupObserver();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
checkPageChange();
|
||||
|
||||
// Check on hash changes
|
||||
window.addEventListener('hashchange', checkPageChange);
|
||||
|
||||
// Check periodically in case we miss something
|
||||
setInterval(checkPageChange, 1000);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
setupPageMonitor();
|
||||
|
||||
LOG('Header tabs functionality initialized');
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
// This will put the Continue Watching and Next Up sections at the top of the home screen
|
||||
// You can order your custom sections between those and the rest of the sections by using 3-99 for the section order
|
||||
// If you want your custom section to appear after the normal sections, use 101+ for the section order
|
||||
|
||||
.sections.homeSectionsContainer>div {
|
||||
order: 100;
|
||||
}
|
||||
.sections.homeSectionsContainer>div:has(div[data-positionticks]) {
|
||||
order: 1;
|
||||
}
|
||||
.sections.homeSectionsContainer>div:has(a[href*="type=nextup"]) {
|
||||
order: 2;
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
// Jellyfin Home Screen Script
|
||||
// Adds custom scrollable sections to the home screen
|
||||
// Requires: cardBuilder.js module to be loaded before this script
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Common logging function
|
||||
const LOG = (...args) => console.log('[HomeScreen]', ...args);
|
||||
const WARN = (...args) => console.warn('[HomeScreen]', ...args);
|
||||
const ERR = (...args) => console.error('[HomeScreen]', ...args);
|
||||
|
||||
LOG('Script loaded - JS Injector mode');
|
||||
|
||||
// Custom home sections configuration
|
||||
const customHomeSections = [
|
||||
// Add home screen sections here with the following format:
|
||||
/* {
|
||||
name: 'Section Name',
|
||||
id: 'playlistId', <- Make sure the playlist is Public
|
||||
maxItems: 16, <- Maximum number of items to display on the home screen
|
||||
order: 3 <- Order of the section on the home screen
|
||||
}, */
|
||||
];
|
||||
|
||||
// Processing flag to prevent parallel execution
|
||||
let isProcessing = false;
|
||||
|
||||
/************ Helpers ************/
|
||||
|
||||
/**
|
||||
* Fetches playlist data from Jellyfin API
|
||||
* @param {string} playlistId - The playlist ID to fetch
|
||||
* @returns {Promise<Object>} - Playlist data with ItemIds array
|
||||
*/
|
||||
async function fetchPlaylistData(playlistId) {
|
||||
const apiClient = window.ApiClient;
|
||||
const serverUrl = apiClient.serverAddress();
|
||||
const token = apiClient.accessToken();
|
||||
|
||||
const url = `${serverUrl}/Playlists/${playlistId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `MediaBrowser Token="${token}"`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (err) {
|
||||
ERR(`Failed to fetch playlist data for ${playlistId}:`, err);
|
||||
return { ItemIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the view more URL for a playlist
|
||||
* @param {string} playlistId - The playlist ID
|
||||
* @returns {string} - The complete URL for the playlist page
|
||||
*/
|
||||
function generateViewMoreUrl(playlistId) {
|
||||
const apiClient = window.ApiClient;
|
||||
const serverId = apiClient.serverId();
|
||||
return `/web/#/list.html?parentId=${playlistId}&serverId=${serverId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a custom home section
|
||||
* @param {Object} section - Section configuration object
|
||||
* @param {HTMLElement} container - Container to append the section to
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async function renderCustomSection(section, container) {
|
||||
try {
|
||||
// Fetch playlist data
|
||||
const playlistData = await fetchPlaylistData(section.id);
|
||||
const itemIds = playlistData.ItemIds || [];
|
||||
|
||||
// Limit to maxItems
|
||||
const limitedItemIds = itemIds.slice(0, section.maxItems);
|
||||
|
||||
if (limitedItemIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate view more URL
|
||||
const viewMoreUrl = generateViewMoreUrl(section.id);
|
||||
|
||||
// Check if cardBuilder is available
|
||||
if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCardsFromIds) {
|
||||
WARN("cardBuilder not available, skipping section:", section.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Render the scrollable container
|
||||
const scrollableContainer = await window.cardBuilder.renderCardsFromIds(
|
||||
limitedItemIds,
|
||||
section.name,
|
||||
viewMoreUrl
|
||||
);
|
||||
|
||||
// Add data attribute to track rendered sections
|
||||
scrollableContainer.setAttribute('data-custom-section-id', section.id);
|
||||
scrollableContainer.setAttribute('data-custom-section-name', section.name);
|
||||
|
||||
if (section.order) {
|
||||
scrollableContainer.style.order = section.order;
|
||||
}
|
||||
|
||||
// Append to container
|
||||
container.appendChild(scrollableContainer);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
ERR(`Error rendering section ${section.name}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all custom home sections
|
||||
* @param {HTMLElement} container - Container to append sections to
|
||||
*/
|
||||
async function renderAllCustomSections(container) {
|
||||
const results = await Promise.all(
|
||||
customHomeSections.map(section => renderCustomSection(section, container))
|
||||
);
|
||||
|
||||
const successCount = results.filter(Boolean).length;
|
||||
LOG(`Rendered ${successCount}/${customHomeSections.length} custom sections`);
|
||||
}
|
||||
|
||||
/************ Home Screen Observer ************/
|
||||
|
||||
/**
|
||||
* Checks if custom sections are already rendered and renders them if not
|
||||
*/
|
||||
async function checkAndRenderCustomSections() {
|
||||
// Check if already processing to prevent parallel execution
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try multiple selectors to find the container
|
||||
const selectors = [
|
||||
'.itemDetailPage:not(.hide) .homeSectionsContainer',
|
||||
'.homeSectionsContainer',
|
||||
'.homeSectionsContainer:not(.hide)',
|
||||
'[class*="homeSections"]'
|
||||
];
|
||||
|
||||
let homeSectionsContainer = null;
|
||||
for (const selector of selectors) {
|
||||
homeSectionsContainer = document.querySelector(selector);
|
||||
if (homeSectionsContainer) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!homeSectionsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if sections are already rendered
|
||||
const hasRenderedSections = customHomeSections.some(section =>
|
||||
homeSectionsContainer.querySelector(`[data-custom-section-id="${section.id}"]`)
|
||||
);
|
||||
|
||||
if (hasRenderedSections) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set processing flag to prevent parallel execution
|
||||
isProcessing = true;
|
||||
|
||||
try {
|
||||
await renderAllCustomSections(homeSectionsContainer);
|
||||
LOG('Custom sections rendered successfully');
|
||||
} catch (error) {
|
||||
ERR('Error rendering custom sections:', error);
|
||||
} finally {
|
||||
// Always reset the processing flag
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up observer to watch for home sections container
|
||||
*/
|
||||
function setupHomeScreenObserver() {
|
||||
// Check immediately in case the element already exists
|
||||
checkAndRenderCustomSections();
|
||||
|
||||
// Set up MutationObserver to watch for home sections container
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldCheck = false;
|
||||
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Check if the added node contains home sections container
|
||||
if (node.querySelector && node.querySelector('.homeSectionsContainer')) {
|
||||
shouldCheck = true;
|
||||
}
|
||||
|
||||
// Also check if the added node is the home sections container itself
|
||||
if (node.classList && node.classList.contains('homeSectionsContainer')) {
|
||||
shouldCheck = true;
|
||||
}
|
||||
|
||||
// Check for any class containing "home" or "section"
|
||||
if (node.classList) {
|
||||
for (const className of node.classList) {
|
||||
if (className.toLowerCase().includes('home') || className.toLowerCase().includes('section')) {
|
||||
shouldCheck = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also check on attribute changes (like class changes)
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
const target = mutation.target;
|
||||
if (target.classList && (target.classList.contains('homeSectionsContainer') ||
|
||||
Array.from(target.classList).some(cls => cls.toLowerCase().includes('home') || cls.toLowerCase().includes('section')))) {
|
||||
shouldCheck = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldCheck) {
|
||||
setTimeout(checkAndRenderCustomSections, 100); // Small delay to ensure DOM is ready
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
LOG('Home screen observer initialized');
|
||||
}
|
||||
|
||||
// Initialize the home screen observer
|
||||
setupHomeScreenObserver();
|
||||
|
||||
// Fallback: Check periodically in case mutation observer misses something
|
||||
let checkInterval = null;
|
||||
const startPeriodicCheck = () => {
|
||||
if (checkInterval) return;
|
||||
|
||||
checkInterval = setInterval(() => {
|
||||
// Skip if already processing
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const homeSectionsContainer = document.querySelector('.homeSectionsContainer');
|
||||
if (homeSectionsContainer && !homeSectionsContainer.querySelector('[data-custom-section-id]')) {
|
||||
checkAndRenderCustomSections();
|
||||
}
|
||||
}, 2000); // Check every 2 seconds
|
||||
};
|
||||
|
||||
// Start periodic check after a short delay
|
||||
setTimeout(startPeriodicCheck, 1000);
|
||||
|
||||
// Debug functions for troubleshooting (available in console)
|
||||
window.debugHomeScreen = function() {
|
||||
LOG('Manual debug trigger called');
|
||||
checkAndRenderCustomSections();
|
||||
};
|
||||
|
||||
window.debugCustomSections = function() {
|
||||
LOG('Custom sections configuration:', customHomeSections);
|
||||
LOG('Processing flag status:', isProcessing);
|
||||
|
||||
// Check multiple selectors
|
||||
const selectors = [
|
||||
'.itemDetailPage:not(.hide) .homeSectionsContainer',
|
||||
'.homeSectionsContainer',
|
||||
'.homeSectionsContainer:not(.hide)',
|
||||
'[class*="homeSections"]'
|
||||
];
|
||||
|
||||
selectors.forEach(selector => {
|
||||
const element = document.querySelector(selector);
|
||||
LOG(`Selector "${selector}":`, element ? 'FOUND' : 'NOT FOUND', element);
|
||||
});
|
||||
|
||||
// Log all elements with "home" or "section" in class name
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const relevantElements = Array.from(allElements).filter(el =>
|
||||
el.className && typeof el.className === 'string' &&
|
||||
(el.className.toLowerCase().includes('home') || el.className.toLowerCase().includes('section'))
|
||||
);
|
||||
|
||||
LOG('Elements with "home" or "section" in class:', relevantElements.map(el => ({
|
||||
tagName: el.tagName,
|
||||
className: el.className,
|
||||
id: el.id
|
||||
})));
|
||||
|
||||
// Check if cardBuilder is available
|
||||
LOG('cardBuilder available:', typeof window.cardBuilder !== 'undefined');
|
||||
LOG('renderCardsFromIds available:', typeof window.cardBuilder?.renderCardsFromIds === 'function');
|
||||
};
|
||||
|
||||
LOG('Home screen functionality initialized');
|
||||
LOG('Debug functions available: window.debugHomeScreen(), window.debugCustomSections()');
|
||||
|
||||
})();
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
// KefinTweaks Script Injector
|
||||
// Dynamically loads scripts and CSS files based on user configuration
|
||||
// Usage: Modify the ENABLED_SCRIPTS array below to enable/disable features
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration: Set which scripts to load
|
||||
// Set to true to enable, false to disable
|
||||
const ENABLED_SCRIPTS = {
|
||||
// Core functionality (required for other scripts)
|
||||
cardBuilder: true, // Required by watchlist and homeScreen
|
||||
|
||||
// UI Enhancements
|
||||
watchlist: true, // Watchlist functionality
|
||||
homeScreen: true, // Custom home screen sections
|
||||
search: true, // Enhanced search functionality
|
||||
headerTabs: true, // Header tab enhancements
|
||||
customMenu: true, // Custom menu functionality
|
||||
exclusiveElsewhere: true, // Exclusive elsewhere branding
|
||||
updoot: true, // Upvote functionality
|
||||
|
||||
// Utility scripts
|
||||
backdropLeakFix: true, // Memory leak fixes
|
||||
};
|
||||
|
||||
// Script definitions with dependencies and metadata
|
||||
const SCRIPT_DEFINITIONS = [
|
||||
{
|
||||
name: 'cardBuilder',
|
||||
script: 'cardBuilder.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Core card building functionality (required by other scripts)',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'watchlist',
|
||||
script: 'watchlist.js',
|
||||
css: 'watchlist.css',
|
||||
dependencies: ['cardBuilder'],
|
||||
description: 'Adds watchlist functionality throughout Jellyfin interface'
|
||||
},
|
||||
{
|
||||
name: 'homeScreen',
|
||||
script: 'homeScreen.js',
|
||||
css: 'homeScreen.css',
|
||||
dependencies: ['cardBuilder'],
|
||||
description: 'Adds custom home screen sections'
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
script: 'search.js',
|
||||
css: 'search.css',
|
||||
dependencies: [],
|
||||
description: 'Enhanced search functionality'
|
||||
},
|
||||
{
|
||||
name: 'headerTabs',
|
||||
script: 'headerTabs.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Header tab improvements'
|
||||
},
|
||||
{
|
||||
name: 'customMenu',
|
||||
script: 'custom-menu.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Remove target="_blank" from custom menu links'
|
||||
},
|
||||
{
|
||||
name: 'exclusiveElsewhere',
|
||||
script: 'exclusiveElsewhere.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Modifies the behavior of the Jellyfin Enhanced Elsewhere functionality to add custom branding when items are not available on streaming services'
|
||||
},
|
||||
{
|
||||
name: 'backdropLeakFix',
|
||||
script: 'backdrop-leak-fix.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Fixes issue that causes backdrop images to be continuously added to the page if the tab isn\'t focused.'
|
||||
},
|
||||
{
|
||||
name: 'updoot',
|
||||
script: 'updoot.js',
|
||||
css: null,
|
||||
dependencies: [],
|
||||
description: 'Upvote functionality'
|
||||
}
|
||||
];
|
||||
|
||||
// Configuration validation
|
||||
function validateConfiguration() {
|
||||
const errors = [];
|
||||
|
||||
// Check if required scripts are enabled
|
||||
SCRIPT_DEFINITIONS.forEach(script => {
|
||||
if (script.required && !ENABLED_SCRIPTS[script.name]) {
|
||||
errors.push(`Required script '${script.name}' is disabled`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check dependencies
|
||||
SCRIPT_DEFINITIONS.forEach(script => {
|
||||
if (ENABLED_SCRIPTS[script.name]) {
|
||||
script.dependencies.forEach(dep => {
|
||||
if (!ENABLED_SCRIPTS[dep]) {
|
||||
errors.push(`Script '${script.name}' requires '${dep}' to be enabled`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('[KefinTweaks Injector] Configuration errors:', errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the root path for scripts (adjust this based on your setup)
|
||||
function getScriptRoot() {
|
||||
// Fallback: assume scripts are in the same directory
|
||||
return 'https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks/';
|
||||
}
|
||||
|
||||
// Load a CSS file
|
||||
function loadCSS(filename) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if CSS is already loaded
|
||||
const existingLink = document.querySelector(`link[href*="${filename}"]`);
|
||||
if (existingLink) {
|
||||
console.log(`[KefinTweaks Injector] CSS already loaded: ${filename}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.type = 'text/css';
|
||||
link.href = `${getScriptRoot()}${filename}`;
|
||||
|
||||
link.onload = () => {
|
||||
console.log(`[KefinTweaks Injector] CSS loaded: ${filename}`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
link.onerror = () => {
|
||||
console.warn(`[KefinTweaks Injector] Failed to load CSS: ${filename}`);
|
||||
reject(new Error(`Failed to load CSS: ${filename}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
// Load a JavaScript file
|
||||
function loadScript(filename) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if script is already loaded
|
||||
const existingScript = document.querySelector(`script[src*="${filename}"]`);
|
||||
if (existingScript) {
|
||||
console.log(`[KefinTweaks Injector] Script already loaded: ${filename}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = `${getScriptRoot()}${filename}`;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
console.log(`[KefinTweaks Injector] Script loaded: ${filename}`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
console.error(`[KefinTweaks Injector] Failed to load script: ${filename}`);
|
||||
reject(new Error(`Failed to load script: ${filename}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Load a script and its dependencies
|
||||
async function loadScriptWithDependencies(scriptDef) {
|
||||
try {
|
||||
// Load dependencies first
|
||||
for (const depName of scriptDef.dependencies) {
|
||||
const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName);
|
||||
if (depScript && ENABLED_SCRIPTS[depName]) {
|
||||
await loadScriptWithDependencies(depScript);
|
||||
}
|
||||
}
|
||||
|
||||
// Load CSS if specified
|
||||
if (scriptDef.css) {
|
||||
await loadCSS(scriptDef.css);
|
||||
}
|
||||
|
||||
// Load the script
|
||||
await loadScript(scriptDef.script);
|
||||
|
||||
console.log(`[KefinTweaks Injector] Successfully loaded: ${scriptDef.name}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[KefinTweaks Injector] Error loading ${scriptDef.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Main initialization function
|
||||
async function initialize() {
|
||||
console.log('[KefinTweaks Injector] Starting initialization...');
|
||||
|
||||
// Validate configuration
|
||||
if (!validateConfiguration()) {
|
||||
console.error('[KefinTweaks Injector] Configuration validation failed. Aborting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get enabled scripts in dependency order
|
||||
const enabledScripts = SCRIPT_DEFINITIONS.filter(script => ENABLED_SCRIPTS[script.name]);
|
||||
|
||||
console.log(`[KefinTweaks Injector] Loading ${enabledScripts.length} scripts:`,
|
||||
enabledScripts.map(s => s.name));
|
||||
|
||||
// Load all enabled scripts
|
||||
const loadPromises = enabledScripts.map(script => loadScriptWithDependencies(script));
|
||||
|
||||
try {
|
||||
await Promise.all(loadPromises);
|
||||
console.log('[KefinTweaks Injector] All scripts loaded successfully!');
|
||||
|
||||
// Dispatch custom event when all scripts are loaded
|
||||
const event = new CustomEvent('kefinTweaksLoaded', {
|
||||
detail: {
|
||||
loadedScripts: enabledScripts.map(s => s.name),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[KefinTweaks Injector] Error during initialization:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions for debugging and configuration
|
||||
window.KefinTweaks = {
|
||||
// Get current configuration
|
||||
getConfig: () => ({ ...ENABLED_SCRIPTS }),
|
||||
|
||||
// Get script definitions
|
||||
getScripts: () => [...SCRIPT_DEFINITIONS],
|
||||
|
||||
// Check if a script is loaded
|
||||
isScriptLoaded: (scriptName) => {
|
||||
const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
|
||||
if (!scriptDef) return false;
|
||||
|
||||
const scriptElement = document.querySelector(`script[src*="${scriptDef.script}"]`);
|
||||
return !!scriptElement;
|
||||
},
|
||||
|
||||
// Reload all scripts (useful for development)
|
||||
reload: () => {
|
||||
console.log('[KefinTweaks Injector] Reloading all scripts...');
|
||||
initialize();
|
||||
},
|
||||
|
||||
// Load a specific script (useful for dynamic loading)
|
||||
loadScript: async (scriptName) => {
|
||||
const scriptDef = SCRIPT_DEFINITIONS.find(s => s.name === scriptName);
|
||||
if (!scriptDef) {
|
||||
throw new Error(`Script '${scriptName}' not found`);
|
||||
}
|
||||
|
||||
if (!ENABLED_SCRIPTS[scriptName]) {
|
||||
throw new Error(`Script '${scriptName}' is disabled in configuration`);
|
||||
}
|
||||
|
||||
await loadScriptWithDependencies(scriptDef);
|
||||
}
|
||||
};
|
||||
|
||||
// Start initialization when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
|
||||
console.log('[KefinTweaks Injector] Injector script loaded. Available at window.KefinTweaks');
|
||||
|
||||
})();
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
.searchFields {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.padded-left.padded-right.searchFields {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-desktop .searchFieldsInner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div#smart-search-wrapper:not([style*="display: none"]) ~ #jellyseerr-search-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.smart-search-mode .searchFieldsInner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div#searchPage {
|
||||
padding-top: 3rem !important;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
div#searchPage {
|
||||
padding-top: 5rem !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
// Jellyfin Smart Search - Dual Input + Persistent Toggle + Query Copy
|
||||
(function() {
|
||||
'use strict';
|
||||
const LOG = (...args) => console.log('[SmartSearch]', ...args);
|
||||
const WARN = (...args) => console.warn('[SmartSearch]', ...args);
|
||||
const ERR = (...args) => console.error('[SmartSearch]', ...args);
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
enableJellyseerr: false, // Toggle for Jellyseerr integration
|
||||
};
|
||||
|
||||
LOG('script load start');
|
||||
|
||||
// Monitor network requests to see what's happening
|
||||
const originalFetch = window.fetch;
|
||||
const originalXHR = window.XMLHttpRequest;
|
||||
|
||||
// Track if we should block XHR requests (only for the initial URL query)
|
||||
let shouldBlockXHR = false;
|
||||
let hasBlockedInitialSearch = false;
|
||||
|
||||
// Override fetch to log our requests
|
||||
window.fetch = function(...args) {
|
||||
LOG('FETCH request:', args[0]);
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
// Override XHR to log and potentially block default search requests
|
||||
const OriginalXHR = window.XMLHttpRequest;
|
||||
window.XMLHttpRequest = function() {
|
||||
const xhr = new OriginalXHR();
|
||||
const originalOpen = xhr.open;
|
||||
const originalSend = xhr.send;
|
||||
|
||||
xhr.open = function(method, url, ...args) {
|
||||
LOG('XHR request:', method, url);
|
||||
|
||||
// Check if this is a search request we want to block
|
||||
// Our smart search uses fetch(), so any XHR search request should be blocked
|
||||
const isSearchRequest = url.includes('/Items?') &&
|
||||
(url.includes('searchTerm=') || url.includes('query='));
|
||||
|
||||
if (isSearchRequest && shouldBlockXHR && !hasBlockedInitialSearch) {
|
||||
LOG('Blocking initial default search XHR request:', url);
|
||||
hasBlockedInitialSearch = true;
|
||||
// Disable blocking after this first request
|
||||
shouldBlockXHR = false;
|
||||
LOG('XHR blocking disabled after initial search');
|
||||
|
||||
// Override send to do nothing
|
||||
xhr.send = function() {
|
||||
LOG('Initial default search XHR blocked');
|
||||
// Simulate a successful response
|
||||
setTimeout(() => {
|
||||
xhr.readyState = 4;
|
||||
xhr.status = 200;
|
||||
xhr.responseText = '{"Items":[],"TotalRecordCount":0}';
|
||||
if (xhr.onreadystatechange) {
|
||||
xhr.onreadystatechange();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
|
||||
return originalOpen.apply(this, [method, url, ...args]);
|
||||
};
|
||||
|
||||
xhr.send = function(...args) {
|
||||
LOG('XHR sending:', args);
|
||||
return originalSend.apply(this, args);
|
||||
};
|
||||
|
||||
return xhr;
|
||||
};
|
||||
|
||||
// Immediate interception for URL queries - run this as early as possible
|
||||
(function() {
|
||||
// Parse query from hash since Jellyfin uses hash-based routing
|
||||
let queryFromUrl = null;
|
||||
if (window.location.hash.includes('search.html') && window.location.hash.includes('query=')) {
|
||||
const hashQuery = window.location.hash.split('query=')[1];
|
||||
if (hashQuery) {
|
||||
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
|
||||
}
|
||||
}
|
||||
|
||||
LOG('Early interception - URL query:', queryFromUrl);
|
||||
LOG('Current pathname:', window.location.pathname);
|
||||
LOG('Current hash:', window.location.hash);
|
||||
|
||||
if (queryFromUrl && (window.location.pathname.includes('search.html') || window.location.hash.includes('search'))) {
|
||||
LOG('URL query detected, setting up protection');
|
||||
shouldBlockXHR = true; // Enable XHR blocking for initial URL query
|
||||
|
||||
// Just enable XHR blocking - don't touch the input
|
||||
LOG('XHR blocking enabled for initial search');
|
||||
}
|
||||
})();
|
||||
|
||||
// config
|
||||
const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person'];
|
||||
const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist'];
|
||||
const BOOKS_TYPES = ['Book', 'AudioBook'];
|
||||
const OTHER_TYPES = ['Playlist', 'Photo', 'PhotoAlbum', 'TvChannel', 'LiveTvProgram', 'BoxSet'];
|
||||
|
||||
// Cache for search results by search term and type
|
||||
const searchCache = new Map();
|
||||
|
||||
// Function to generate cache key
|
||||
function getCacheKey(searchTerm, searchType) {
|
||||
return `${searchTerm.toLowerCase().trim()}_${searchType}`;
|
||||
}
|
||||
|
||||
// Function to check cache and return cached results if available
|
||||
function getCachedResults(searchTerm, searchType) {
|
||||
const cacheKey = getCacheKey(searchTerm, searchType);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
LOG('Cache check:', { searchTerm, searchType, cacheKey, cached: !!cached });
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Function to cache results
|
||||
function cacheResults(searchTerm, searchType, results) {
|
||||
const cacheKey = getCacheKey(searchTerm, searchType);
|
||||
searchCache.set(cacheKey, results);
|
||||
LOG('Cached results:', { searchTerm, searchType, cacheKey, resultCount: results.total });
|
||||
}
|
||||
|
||||
// styles (dedupe by id)
|
||||
function addCustomStyles() {
|
||||
if (document.getElementById('smart-search-styles')) return;
|
||||
LOG('adding styles');
|
||||
const style = document.createElement('style');
|
||||
style.id = 'smart-search-styles';
|
||||
style.textContent = `
|
||||
.smart-search-wrapper { margin: 12px 0 20px 0; }
|
||||
.smart-search-input { width:100%; padding:8px; border-radius:4px; box-sizing:border-box; }
|
||||
.smart-search-buttons { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; justify-content: center; }
|
||||
.smart-search-primary, .smart-search-secondary { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; }
|
||||
.smart-search-primary { background:#00a4dc; color:#fff; }
|
||||
.smart-search-secondary { background:#444; color:#fff; }
|
||||
.smart-search-results { margin-top:12px; }
|
||||
.smart-search-stats { color:#999; font-size:12px; margin-top:6px; display: none; }
|
||||
#persistent-toggle-btn { display:none; position:relative; padding:8px 12px; background:#00a4dc; color:#fff; border:none; border-radius:4px; cursor:pointer; }
|
||||
|
||||
/* Smart search mode - hide search icon */
|
||||
.smart-search-mode .searchfields-icon {
|
||||
top: 37px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// update search URL helper
|
||||
function updateSearchUrl(searchTerm, searchType = 'videos') {
|
||||
try {
|
||||
// Jellyfin uses hash-based routing, so we need to update the hash
|
||||
const baseHash = '#/search.html';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (searchTerm && searchTerm.trim()) {
|
||||
params.set('query', searchTerm.trim());
|
||||
}
|
||||
params.set('type', searchType);
|
||||
|
||||
const newHash = `${baseHash}?${params.toString()}`;
|
||||
window.history.replaceState({}, '', window.location.pathname + newHash);
|
||||
LOG('Updated search URL hash:', newHash);
|
||||
} catch (e) {
|
||||
ERR('updateSearchUrl error', e);
|
||||
}
|
||||
}
|
||||
|
||||
// build URL helper
|
||||
function buildSearchUrl(searchTerm, itemTypes, additionalParams = {}) {
|
||||
try {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
const baseUrl = ApiClient.serverAddress();
|
||||
const params = new URLSearchParams({
|
||||
userId,
|
||||
limit: '100',
|
||||
recursive: 'true',
|
||||
searchTerm,
|
||||
fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount',
|
||||
imageTypeLimit: '1',
|
||||
enableTotalRecordCount: 'false',
|
||||
...additionalParams
|
||||
});
|
||||
itemTypes.forEach(t => params.append('includeItemTypes', t));
|
||||
const url = `${baseUrl}/Items?${params.toString()}`;
|
||||
LOG('buildSearchUrl ->', url);
|
||||
return url;
|
||||
} catch (e) {
|
||||
ERR('buildSearchUrl error', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide loading spinner
|
||||
function setLoadingSpinner(show) {
|
||||
const spinner = document.querySelector('.docspinner.mdl-spinner');
|
||||
if (spinner) {
|
||||
if (show) {
|
||||
spinner.classList.add('mdlSpinnerActive');
|
||||
LOG('Loading spinner shown');
|
||||
} else {
|
||||
spinner.classList.remove('mdlSpinnerActive');
|
||||
LOG('Loading spinner hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Jellyseerr results
|
||||
async function fetchJellyseerrResults(searchTerm) {
|
||||
try {
|
||||
const jellyseerrUrl = `${ApiClient.serverAddress()}/JellyfinEnhanced/jellyseerr/search?query=${encodeURIComponent(searchTerm)}`;
|
||||
LOG('Fetching Jellyseerr results:', jellyseerrUrl);
|
||||
|
||||
const response = await ApiClient.fetch({
|
||||
url: jellyseerrUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Jellyfin-User-Id': ApiClient.getCurrentUserId()
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
LOG('Jellyseerr response:', data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
WARN('Jellyseerr fetch failed', err);
|
||||
return { movies: [], tv: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Jellyseerr item to our format
|
||||
function convertJellyseerrItem(item, type) {
|
||||
return {
|
||||
Id: `jellyseerr_${item.id}`,
|
||||
Name: item.title || item.name,
|
||||
Type: type === 'movie' ? 'Movie' : 'Series',
|
||||
MediaType: 'Video',
|
||||
ImageTags: item.posterPath ? { Primary: item.posterPath } : null,
|
||||
ProductionYear: item.releaseDate ? new Date(item.releaseDate).getFullYear() : null,
|
||||
Overview: item.overview,
|
||||
// Jellyseerr specific data
|
||||
JellyseerrId: item.id,
|
||||
JellyseerrType: type,
|
||||
ExternalId: item.tmdbId || item.tvdbId,
|
||||
IsJellyseerr: true
|
||||
};
|
||||
}
|
||||
|
||||
// perform smart search
|
||||
async function performSmartSearch(searchTerm, searchType = 'core') {
|
||||
LOG('performSmartSearch()', { searchTerm, searchType });
|
||||
const results = { groupedItems: {}, total: 0 };
|
||||
|
||||
// Hide search suggestions
|
||||
const searchSuggestions = document.querySelector('.searchSuggestions');
|
||||
if (searchSuggestions) {
|
||||
searchSuggestions.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show loading spinner
|
||||
setLoadingSpinner(true);
|
||||
|
||||
try {
|
||||
const typeGroups = searchType === 'music'
|
||||
? MUSIC_TYPES
|
||||
: (searchType === 'books'
|
||||
? BOOKS_TYPES
|
||||
: (searchType === 'all' ? [...CORE_TYPES, ...MUSIC_TYPES, ...BOOKS_TYPES, ...OTHER_TYPES] : CORE_TYPES));
|
||||
|
||||
const promises = typeGroups.map(type => {
|
||||
let url;
|
||||
if (type === 'Artist') {
|
||||
// Use dedicated Artists endpoint for better results
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
const baseUrl = ApiClient.serverAddress();
|
||||
url = `${baseUrl}/Artists?limit=100&searchTerm=${encodeURIComponent(searchTerm)}&fields=PrimaryImageAspectRatio&fields=CanDelete&fields=MediaSourceCount&imageTypeLimit=1&userId=${userId}&enableTotalRecordCount=false`;
|
||||
} else {
|
||||
url = buildSearchUrl(searchTerm, [type]);
|
||||
}
|
||||
|
||||
return ApiClient.fetch({ url, method: 'GET' })
|
||||
.then(resp => resp.json())
|
||||
.catch(err => {
|
||||
WARN('fetch failed for', type, err);
|
||||
return { Items: [] };
|
||||
});
|
||||
});
|
||||
|
||||
// Add Jellyseerr search for core types (if enabled)
|
||||
if (CONFIG.enableJellyseerr && (searchType === 'core' || searchType === 'all')) {
|
||||
promises.push(fetchJellyseerrResults(searchTerm));
|
||||
} else {
|
||||
promises.push(Promise.resolve({ movies: [], tv: [] }));
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const responses = await Promise.all(promises);
|
||||
const end = performance.now();
|
||||
|
||||
// Group results by type
|
||||
responses.forEach((r, idx) => {
|
||||
if (idx < typeGroups.length) {
|
||||
// Regular Jellyfin results
|
||||
const type = typeGroups[idx];
|
||||
if (r && r.Items && r.Items.length > 0) {
|
||||
// For Artists, use all items (they often don't have primary images)
|
||||
// For other types, filter to show only those with images
|
||||
let filteredItems;
|
||||
if (type === 'Artist') {
|
||||
// Artists: show all items, they often don't have primary images
|
||||
filteredItems = r.Items;
|
||||
} else {
|
||||
// Other types: filter to show only those with images
|
||||
filteredItems = r.Items.filter(item => item.ImageTags?.Primary);
|
||||
}
|
||||
|
||||
results.groupedItems[type] = filteredItems;
|
||||
results.total += filteredItems.length;
|
||||
}
|
||||
} else {
|
||||
// Jellyseerr results
|
||||
if (r && (r.movies || r.tv)) {
|
||||
// Process Jellyseerr movies
|
||||
if (r.movies && r.movies.length > 0) {
|
||||
const jellyseerrMovies = r.movies.map(item => convertJellyseerrItem(item, 'movie'));
|
||||
if (results.groupedItems['Movie']) {
|
||||
results.groupedItems['Movie'] = [...results.groupedItems['Movie'], ...jellyseerrMovies];
|
||||
} else {
|
||||
results.groupedItems['Movie'] = jellyseerrMovies;
|
||||
}
|
||||
results.total += jellyseerrMovies.length;
|
||||
}
|
||||
|
||||
// Process Jellyseerr TV shows
|
||||
if (r.tv && r.tv.length > 0) {
|
||||
const jellyseerrTv = r.tv.map(item => convertJellyseerrItem(item, 'tv'));
|
||||
if (results.groupedItems['Series']) {
|
||||
results.groupedItems['Series'] = [...results.groupedItems['Series'], ...jellyseerrTv];
|
||||
} else {
|
||||
results.groupedItems['Series'] = jellyseerrTv;
|
||||
}
|
||||
results.total += jellyseerrTv.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
displaySmartResults(results, Math.round(end - start));
|
||||
|
||||
// Cache the results
|
||||
cacheResults(searchTerm, searchType, results);
|
||||
|
||||
// Update URL with search query and type
|
||||
const urlType = searchType === 'core' ? 'videos' : searchType;
|
||||
updateSearchUrl(searchTerm, urlType);
|
||||
|
||||
// Hide loading spinner
|
||||
setLoadingSpinner(false);
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
ERR('performSmartSearch error', e);
|
||||
// Hide loading spinner on error too
|
||||
setLoadingSpinner(false);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function createSmartItemElement(item) {
|
||||
const serverId = ApiClient.serverId();
|
||||
const div = document.createElement('div');
|
||||
div.className = 'smart-search-item';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = item.ImageTags?.Primary
|
||||
? `${ApiClient.serverAddress()}/Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}&maxWidth=300`
|
||||
: '/web/assets/img/icon-transparent.png';
|
||||
img.alt = item.Name || 'Unknown';
|
||||
img.onerror = () => { img.src = '/web/assets/img/icon-transparent.png'; };
|
||||
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = item.Name || 'Unknown';
|
||||
h.title = h.textContent;
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.textContent = `${item.Type}${item.ProductionYear ? ` (${item.ProductionYear})` : ''}`;
|
||||
p.title = p.textContent;
|
||||
|
||||
div.appendChild(img);
|
||||
div.appendChild(h);
|
||||
div.appendChild(p);
|
||||
|
||||
div.addEventListener('click', () => {
|
||||
LOG('item click', { id: item.Id, name: item.Name });
|
||||
try { Dashboard.navigate(`#!/details?id=${item.Id}&serverId=${serverId}`); } catch(e){ ERR(e); }
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
|
||||
function getTypeDisplayName(itemType) {
|
||||
const typeMap = {
|
||||
'Movie': 'Movies',
|
||||
'Series': 'TV Shows',
|
||||
'Episode': 'Episodes',
|
||||
'Person': 'People',
|
||||
'MusicAlbum': 'Albums',
|
||||
'Audio': 'Songs',
|
||||
'Artist': 'Artists',
|
||||
'Playlist': 'Playlists',
|
||||
'Book': 'Books',
|
||||
'AudioBook': 'Audiobooks',
|
||||
'Photo': 'Photos',
|
||||
'PhotoAlbum': 'Photo Albums',
|
||||
'TvChannel': 'TV Channels',
|
||||
'LiveTvProgram': 'Live TV',
|
||||
'BoxSet': 'Collections'
|
||||
};
|
||||
return typeMap[itemType] || itemType;
|
||||
}
|
||||
|
||||
// Create smart search results container lazily when needed
|
||||
function ensureSmartResultsContainer() {
|
||||
let smartResults = document.getElementById('smart-search-results');
|
||||
if (!smartResults) {
|
||||
LOG('Creating smart-search-results container');
|
||||
smartResults = document.createElement('div');
|
||||
smartResults.id = 'smart-search-results';
|
||||
smartResults.className = 'smart-search-results emby-scroller';
|
||||
const searchPage = document.getElementById('searchPage');
|
||||
if (searchPage) {
|
||||
searchPage.appendChild(smartResults);
|
||||
} else {
|
||||
// Fallback to body if #searchPage not found
|
||||
document.body.appendChild(smartResults);
|
||||
}
|
||||
}
|
||||
return smartResults;
|
||||
}
|
||||
|
||||
function displaySmartResults(results, ms) {
|
||||
const resultsContainer = ensureSmartResultsContainer();
|
||||
const stats = document.getElementById('smart-search-stats');
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
resultsContainer.innerHTML = '';
|
||||
|
||||
// Check if we have any grouped results
|
||||
if (!results.groupedItems || Object.keys(results.groupedItems).length === 0) {
|
||||
resultsContainer.innerHTML = '<p style="color:#999;text-align:center;">No results found</p>';
|
||||
stats.textContent = `Search completed in ${ms}ms - 0 results`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create sections for each item type that has results
|
||||
const frag = document.createDocumentFragment();
|
||||
let totalShown = 0;
|
||||
|
||||
// Add hidden dummy section for testing/placeholder
|
||||
const dummySection = document.createElement('div');
|
||||
dummySection.className = 'verticalSection emby-scroller-container';
|
||||
dummySection.style.display = 'none'; // Hidden dummy section
|
||||
dummySection.setAttribute('data-dummy', 'true');
|
||||
|
||||
const dummyTitle = document.createElement('h2');
|
||||
dummyTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right';
|
||||
dummyTitle.textContent = 'Hidden Dummy Section';
|
||||
dummySection.appendChild(dummyTitle);
|
||||
|
||||
frag.appendChild(dummySection);
|
||||
|
||||
// Define the order to display sections
|
||||
const sectionOrder = ['Movie', 'Series', 'Episode', 'Person', 'MusicAlbum', 'Audio', 'Artist', 'Playlist', 'Book', 'AudioBook', 'Photo', 'PhotoAlbum', 'TvChannel', 'LiveTvProgram', 'BoxSet'];
|
||||
|
||||
sectionOrder.forEach((itemType, index) => {
|
||||
if (results.groupedItems[itemType] && results.groupedItems[itemType].length > 0) {
|
||||
const items = results.groupedItems[itemType];
|
||||
const title = getTypeDisplayName(itemType);
|
||||
const section = window.cardBuilder.renderCards(items, title, index);
|
||||
frag.appendChild(section);
|
||||
totalShown += items.length;
|
||||
}
|
||||
});
|
||||
|
||||
resultsContainer.appendChild(frag);
|
||||
stats.textContent = `Search completed in ${ms}ms - ${results.total} results found`;
|
||||
}
|
||||
|
||||
// main init
|
||||
function initDualSearch() {
|
||||
if (typeof ApiClient === 'undefined') { setTimeout(initDualSearch, 500); return; }
|
||||
|
||||
const originalInput = document.getElementById('searchTextInput');
|
||||
if (!originalInput) { setTimeout(initDualSearch, 500); return; }
|
||||
|
||||
addCustomStyles();
|
||||
|
||||
// Handle URL query parameter on page load
|
||||
// Parse query from hash since Jellyfin uses hash-based routing
|
||||
let queryFromUrl = null;
|
||||
if (window.location.hash.includes('search.html') && window.location.hash.includes('query=')) {
|
||||
const hashQuery = window.location.hash.split('query=')[1];
|
||||
if (hashQuery) {
|
||||
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
|
||||
}
|
||||
}
|
||||
LOG('initDualSearch - URL query:', queryFromUrl);
|
||||
LOG('initDualSearch - originalInput exists:', !!originalInput);
|
||||
if (originalInput) {
|
||||
LOG('initDualSearch - originalInput value:', originalInput.value);
|
||||
}
|
||||
|
||||
if (queryFromUrl) {
|
||||
LOG('initDualSearch - URL query detected, leaving input alone');
|
||||
}
|
||||
|
||||
// create Smart search UI if not exist
|
||||
let wrapper = document.getElementById('smart-search-wrapper');
|
||||
if (!wrapper) {
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.id = 'smart-search-wrapper';
|
||||
wrapper.className = 'smart-search-wrapper';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.id = 'smart-search-input';
|
||||
input.className = 'smart-search-input emby-input searchfields-txtSearch';
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Smart Search...';
|
||||
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.className = 'smart-search-buttons';
|
||||
|
||||
const btnAll = document.createElement('button');
|
||||
btnAll.id = 'smart-search-all';
|
||||
btnAll.className = 'smart-search-secondary emby-button';
|
||||
btnAll.textContent = 'All';
|
||||
const btnCore = document.createElement('button');
|
||||
btnCore.id = 'smart-search-core';
|
||||
btnCore.className = 'smart-search-primary emby-button';
|
||||
btnCore.textContent = 'Movies/TV';
|
||||
const btnMusic = document.createElement('button');
|
||||
btnMusic.id = 'smart-search-music';
|
||||
btnMusic.className = 'smart-search-secondary emby-button';
|
||||
btnMusic.textContent = 'Music';
|
||||
const btnBooks = document.createElement('button');
|
||||
btnBooks.id = 'smart-search-books';
|
||||
btnBooks.className = 'smart-search-secondary emby-button';
|
||||
btnBooks.textContent = 'Books';
|
||||
|
||||
btnRow.appendChild(btnAll);
|
||||
btnRow.appendChild(btnCore);
|
||||
btnRow.appendChild(btnMusic);
|
||||
btnRow.appendChild(btnBooks);
|
||||
|
||||
const stats = document.createElement('div');
|
||||
stats.id = 'smart-search-stats';
|
||||
stats.className = 'smart-search-stats';
|
||||
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(btnRow);
|
||||
wrapper.appendChild(stats);
|
||||
|
||||
originalInput.parentElement.insertBefore(wrapper, originalInput);
|
||||
}
|
||||
|
||||
// Smart results container will be created lazily when needed
|
||||
|
||||
const smartInput = document.getElementById('smart-search-input');
|
||||
const smartCoreBtn = document.getElementById('smart-search-core');
|
||||
const smartMusicBtn = document.getElementById('smart-search-music');
|
||||
const smartBooksBtn = document.getElementById('smart-search-books');
|
||||
const smartAllBtn = document.getElementById('smart-search-all');
|
||||
|
||||
// Track current search type
|
||||
let currentSearchType = 'videos';
|
||||
|
||||
// Function to update button states
|
||||
function updateButtonStates(activeType) {
|
||||
// Remove active class from all buttons
|
||||
smartAllBtn.classList.remove('smart-search-primary');
|
||||
smartAllBtn.classList.add('smart-search-secondary');
|
||||
smartCoreBtn.classList.remove('smart-search-primary');
|
||||
smartCoreBtn.classList.add('smart-search-secondary');
|
||||
smartMusicBtn.classList.remove('smart-search-primary');
|
||||
smartMusicBtn.classList.add('smart-search-secondary');
|
||||
smartBooksBtn.classList.remove('smart-search-primary');
|
||||
smartBooksBtn.classList.add('smart-search-secondary');
|
||||
|
||||
// Add active class to the selected button
|
||||
if (activeType === 'all') {
|
||||
smartAllBtn.classList.remove('smart-search-secondary');
|
||||
smartAllBtn.classList.add('smart-search-primary');
|
||||
} else if (activeType === 'videos') {
|
||||
smartCoreBtn.classList.remove('smart-search-secondary');
|
||||
smartCoreBtn.classList.add('smart-search-primary');
|
||||
} else if (activeType === 'music') {
|
||||
smartMusicBtn.classList.remove('smart-search-secondary');
|
||||
smartMusicBtn.classList.add('smart-search-primary');
|
||||
} else if (activeType === 'books') {
|
||||
smartBooksBtn.classList.remove('smart-search-secondary');
|
||||
smartBooksBtn.classList.add('smart-search-primary');
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle search type toggle
|
||||
function setSearchType(type) {
|
||||
LOG('setSearchType called:', { type, currentSearchType });
|
||||
currentSearchType = type;
|
||||
updateButtonStates(type);
|
||||
|
||||
// If there's a search term, check cache first
|
||||
const searchTerm = smartInput.value.trim();
|
||||
LOG('Search term from input:', searchTerm);
|
||||
if (searchTerm) {
|
||||
// Map URL type to internal type
|
||||
const internalType = type === 'videos' ? 'core' : type;
|
||||
LOG('Internal type mapping:', { type, internalType });
|
||||
|
||||
// Check if we have cached results for this search term and type
|
||||
const cachedResults = getCachedResults(searchTerm, internalType);
|
||||
if (cachedResults) {
|
||||
LOG('Using cached results for', searchTerm, internalType);
|
||||
displaySmartResults(cachedResults, 0); // 0ms since it's cached
|
||||
|
||||
// Update URL with search query and type even for cached results
|
||||
const urlType = type === 'videos' ? 'videos' : type;
|
||||
updateSearchUrl(searchTerm, urlType);
|
||||
return;
|
||||
}
|
||||
|
||||
// No cache hit, perform new search
|
||||
LOG('No cache hit, performing new search');
|
||||
performSmartSearch(searchTerm, internalType);
|
||||
}
|
||||
}
|
||||
|
||||
if (!wrapper.dataset.initialized) {
|
||||
wrapper.dataset.initialized = 'true';
|
||||
let timer = null;
|
||||
const doSmartSearchDebounced = () => {
|
||||
const term = (smartInput.value || '').trim();
|
||||
if (!term) {
|
||||
const resultsContainer = ensureSmartResultsContainer();
|
||||
resultsContainer.innerHTML='';
|
||||
setLoadingSpinner(false); // Hide spinner when clearing results
|
||||
return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType;
|
||||
timer = setTimeout(() => performSmartSearch(term, internalType), 300);
|
||||
};
|
||||
|
||||
smartInput.addEventListener('input', doSmartSearchDebounced);
|
||||
smartInput.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ e.preventDefault(); const internalType = currentSearchType === 'videos' ? 'core' : currentSearchType; performSmartSearch((smartInput.value||'').trim(), internalType); }});
|
||||
smartCoreBtn.addEventListener('click', ()=>setSearchType('videos'));
|
||||
smartMusicBtn.addEventListener('click', ()=>setSearchType('music'));
|
||||
smartBooksBtn.addEventListener('click', ()=>setSearchType('books'));
|
||||
smartAllBtn.addEventListener('click', ()=>setSearchType('all'));
|
||||
|
||||
// Parse type parameter from URL
|
||||
let typeFromUrl = 'videos'; // default
|
||||
if (window.location.hash.includes('type=')) {
|
||||
const typeMatch = window.location.hash.match(/type=([^&]+)/);
|
||||
if (typeMatch) {
|
||||
typeFromUrl = typeMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial search type and update UI
|
||||
currentSearchType = typeFromUrl;
|
||||
updateButtonStates(typeFromUrl);
|
||||
|
||||
// Handle URL query parameter - populate smart search and trigger search
|
||||
if (queryFromUrl) {
|
||||
smartInput.value = queryFromUrl;
|
||||
// Trigger immediate search with the URL query
|
||||
const internalType = typeFromUrl === 'videos' ? 'core' : typeFromUrl;
|
||||
performSmartSearch(queryFromUrl, internalType);
|
||||
}
|
||||
}
|
||||
|
||||
// persistent toggle button
|
||||
let toggleBtn = document.getElementById('persistent-toggle-btn');
|
||||
if (!toggleBtn) {
|
||||
toggleBtn = document.createElement('button');
|
||||
toggleBtn.id = 'persistent-toggle-btn';
|
||||
toggleBtn.textContent = 'Switch to Default Search';
|
||||
const searchFields = document.querySelector('.searchFields');
|
||||
if (searchFields) {
|
||||
searchFields.appendChild(toggleBtn);
|
||||
} else {
|
||||
// Fallback to body if .searchFields not found
|
||||
document.body.appendChild(toggleBtn);
|
||||
}
|
||||
}
|
||||
|
||||
let smartMode = true;
|
||||
const setMode = (isSmart) => {
|
||||
smartMode = isSmart;
|
||||
LOG('setMode', smartMode);
|
||||
if (smartMode) {
|
||||
wrapper.style.display = '';
|
||||
originalInput.style.display = 'none';
|
||||
// Hide all search results containers
|
||||
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
|
||||
searchResults.forEach(sr => sr.style.display = 'none');
|
||||
const resultsContainer = ensureSmartResultsContainer();
|
||||
resultsContainer.style.display = '';
|
||||
toggleBtn.textContent='Switch to Default Search';
|
||||
// Keep XHR blocking enabled for smart mode
|
||||
shouldBlockXHR = true;
|
||||
// Add smart-search-mode class to body for CSS targeting
|
||||
document.body.classList.add('smart-search-mode');
|
||||
} else {
|
||||
wrapper.style.display = 'none';
|
||||
originalInput.style.display = '';
|
||||
// Show all search results containers
|
||||
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
|
||||
searchResults.forEach(sr => sr.style.display = '');
|
||||
const resultsContainer = ensureSmartResultsContainer();
|
||||
resultsContainer.style.display = 'none';
|
||||
toggleBtn.textContent='Switch to Smart Search';
|
||||
// Disable XHR blocking for default search mode
|
||||
shouldBlockXHR = false;
|
||||
LOG('Disabled XHR blocking for default search mode');
|
||||
// Remove smart-search-mode class from body
|
||||
document.body.classList.remove('smart-search-mode');
|
||||
|
||||
// Input is left alone - let it keep its value
|
||||
}
|
||||
};
|
||||
|
||||
toggleBtn.addEventListener('click', ()=>setMode(!smartMode));
|
||||
|
||||
// initial mode
|
||||
setMode(true);
|
||||
}
|
||||
|
||||
function init() {
|
||||
LOG('init() called');
|
||||
LOG('Pathname:', window.location.pathname);
|
||||
LOG('Hash:', window.location.hash);
|
||||
|
||||
if (window.location.pathname.includes('search.html') || window.location.hash.includes('search')) {
|
||||
LOG('Search page detected');
|
||||
|
||||
// Check for URL query parameter and prevent default search early
|
||||
// Parse query from hash since Jellyfin uses hash-based routing
|
||||
let queryFromUrl = null;
|
||||
if (window.location.hash.includes('search.html') && window.location.hash.includes('query=')) {
|
||||
const hashQuery = window.location.hash.split('query=')[1];
|
||||
if (hashQuery) {
|
||||
queryFromUrl = hashQuery.split('&')[0]; // Get query before any other params
|
||||
}
|
||||
}
|
||||
LOG('URL query in init:', queryFromUrl);
|
||||
|
||||
if (queryFromUrl) {
|
||||
LOG('Query detected in init, enabling XHR blocking');
|
||||
shouldBlockXHR = true;
|
||||
|
||||
// Also try to prevent search form submission
|
||||
const searchForm = document.querySelector('form[action*="search"]');
|
||||
LOG('Search form found:', !!searchForm);
|
||||
if (searchForm) {
|
||||
searchForm.addEventListener('submit', (e) => {
|
||||
LOG('Search form submission prevented');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Try to hide search results containers immediately
|
||||
const hideSearchResults = () => {
|
||||
const searchResults = document.querySelectorAll('.searchResults,.padded-top,.padded-bottom-page');
|
||||
LOG('Found search results containers:', searchResults.length);
|
||||
searchResults.forEach(sr => {
|
||||
sr.style.display = 'none';
|
||||
LOG('Hidden search result container');
|
||||
});
|
||||
};
|
||||
|
||||
// Hide immediately and on DOM changes
|
||||
hideSearchResults();
|
||||
document.addEventListener('DOMContentLoaded', hideSearchResults);
|
||||
|
||||
// Use mutation observer to hide any new search results
|
||||
const hideObserver = new MutationObserver(() => {
|
||||
hideSearchResults();
|
||||
});
|
||||
hideObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Stop observing after 10 seconds
|
||||
setTimeout(() => {
|
||||
LOG('Stopping hide observer after 10 seconds');
|
||||
hideObserver.disconnect();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
LOG('Calling initDualSearch');
|
||||
initDualSearch();
|
||||
}
|
||||
}
|
||||
|
||||
// Mutation observer to detect when search page content is loaded dynamically
|
||||
function setupMutationObserver() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
// Check if search input appears in the DOM
|
||||
const searchInput = document.getElementById('searchTextInput');
|
||||
if (searchInput && !document.getElementById('smart-search-wrapper')) {
|
||||
LOG('Search input detected via mutation observer');
|
||||
initDualSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Observe the entire document for changes
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
LOG('Mutation observer setup complete');
|
||||
}
|
||||
|
||||
if (document.readyState==='loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
setupMutationObserver();
|
||||
});
|
||||
} else {
|
||||
init();
|
||||
setupMutationObserver();
|
||||
}
|
||||
LOG('script load end');
|
||||
})();
|
||||
@@ -0,0 +1,3 @@
|
||||
.watchlist-icon[data-active='true'] .detailButton-icon:before {
|
||||
font-variation-settings: 'FILL' 1,'wght' 400,'GRAD' 0,'opsz' 20;
|
||||
}
|
||||
+20
-9
@@ -56,13 +56,26 @@ async function renderCards(containerSelector, type) {
|
||||
return { type, itemCount: 0 };
|
||||
}
|
||||
|
||||
// Show section and create proper Jellyfin section structure
|
||||
// Show section and use cardBuilder to create scrollable container
|
||||
container.style.display = '';
|
||||
const sectionHtml = createWatchlistSection(type, items);
|
||||
container.innerHTML = sectionHtml;
|
||||
|
||||
// Check if cardBuilder is available
|
||||
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.renderCards) {
|
||||
const scrollableContainer = window.cardBuilder.renderCards(items, getTypeDisplayName(type));
|
||||
container.innerHTML = '';
|
||||
container.appendChild(scrollableContainer);
|
||||
LOG(`Rendered ${items.length} ${type} items using cardBuilder`);
|
||||
} else {
|
||||
WARN("cardBuilder not available, using fallback");
|
||||
// Fallback to old method if cardBuilder not available
|
||||
const sectionHtml = createWatchlistSection(type, items);
|
||||
container.innerHTML = sectionHtml;
|
||||
}
|
||||
|
||||
return { type, itemCount: items.length };
|
||||
}
|
||||
|
||||
// Fallback function for when cardBuilder is not available
|
||||
function createWatchlistSection(type, items) {
|
||||
// This function assumes items array is not empty (checked at higher level)
|
||||
|
||||
@@ -134,8 +147,6 @@ function createWatchlistSection(type, items) {
|
||||
items.forEach((item, index) => {
|
||||
if (typeof window.cardBuilder !== 'undefined' && window.cardBuilder.buildCard) {
|
||||
const card = window.cardBuilder.buildCard(item);
|
||||
card.setAttribute('data-index', index);
|
||||
card.classList.add('discover-card');
|
||||
itemsContainer.appendChild(card);
|
||||
} else {
|
||||
// Fallback to HTML string
|
||||
@@ -227,9 +238,9 @@ function getTypeDisplayName(itemType) {
|
||||
async function renderWatchlistContent() {
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
renderCards(".sections.watchlist > .watchlist-movies", "Movie"),
|
||||
renderCards(".sections.watchlist > .watchlist-series", "Series"),
|
||||
renderCards(".sections.watchlist > .watchlist-episodes", "Episode")
|
||||
renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-movies", "Movie"),
|
||||
renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-series", "Series"),
|
||||
renderCards("#indexPage:not(.hide) .sections.watchlist > .watchlist-episodes", "Episode")
|
||||
]);
|
||||
|
||||
// Check if all sections are empty
|
||||
@@ -249,7 +260,7 @@ async function renderWatchlistContent() {
|
||||
|
||||
// Function to check if watchlist section exists and render content
|
||||
function checkAndRenderWatchlist() {
|
||||
const watchlistSection = document.querySelector('.sections.watchlist');
|
||||
const watchlistSection = document.querySelector('#indexPage:not(.hide) .sections.watchlist');
|
||||
if (watchlistSection && !watchlistSection.dataset.watchlistRendered) {
|
||||
watchlistSection.dataset.watchlistRendered = 'true';
|
||||
renderWatchlistContent();
|
||||
|
||||
Reference in New Issue
Block a user