diff --git a/backdrop-leak-fix.js b/backdrop-leak-fix.js new file mode 100644 index 0000000..19075c7 --- /dev/null +++ b/backdrop-leak-fix.js @@ -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'); +})(); diff --git a/cardBuilder.js b/cardBuilder.js index 3e2dd96..a4ac872 100644 --- a/cardBuilder.js +++ b/cardBuilder.js @@ -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} - 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; diff --git a/custom-menu.js b/custom-menu.js new file mode 100644 index 0000000..2455e48 --- /dev/null +++ b/custom-menu.js @@ -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'); +})(); diff --git a/exclusiveElsewhere.js b/exclusiveElsewhere.js new file mode 100644 index 0000000..bf52307 --- /dev/null +++ b/exclusiveElsewhere.js @@ -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 + }); +})(); \ No newline at end of file diff --git a/headerTabs.js b/headerTabs.js new file mode 100644 index 0000000..93d7bd7 --- /dev/null +++ b/headerTabs.js @@ -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'); + +})(); \ No newline at end of file diff --git a/homeScreen.css b/homeScreen.css new file mode 100644 index 0000000..f40ef4b --- /dev/null +++ b/homeScreen.css @@ -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; +} \ No newline at end of file diff --git a/homeScreen.js b/homeScreen.js new file mode 100644 index 0000000..e7d96ce --- /dev/null +++ b/homeScreen.js @@ -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} - 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} - 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()'); + +})(); diff --git a/injector.js b/injector.js new file mode 100644 index 0000000..d39c5ec --- /dev/null +++ b/injector.js @@ -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'); + +})(); diff --git a/search.css b/search.css new file mode 100644 index 0000000..3f0f193 --- /dev/null +++ b/search.css @@ -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; + } +} \ No newline at end of file diff --git a/search.js b/search.js new file mode 100644 index 0000000..15c89f8 --- /dev/null +++ b/search.js @@ -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 = '

No results found

'; + 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'); +})(); diff --git a/watchlist.css b/watchlist.css new file mode 100644 index 0000000..dceb78c --- /dev/null +++ b/watchlist.css @@ -0,0 +1,3 @@ +.watchlist-icon[data-active='true'] .detailButton-icon:before { + font-variation-settings: 'FILL' 1,'wght' 400,'GRAD' 0,'opsz' 20; +} \ No newline at end of file diff --git a/watchlist.js b/watchlist.js index 4cea6e8..2484b75 100644 --- a/watchlist.js +++ b/watchlist.js @@ -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();