From a792f332c316bc09b56cd0049178bd88385dc20e Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Tue, 16 Sep 2025 00:49:25 -0400 Subject: [PATCH] Add a bunch of new scripts with Injector.js to bundle and configure --- backdrop-leak-fix.js | 40 ++ cardBuilder.js | 380 +++++++++++++++++++ custom-menu.js | 99 +++++ exclusiveElsewhere.js | 37 ++ headerTabs.js | 246 ++++++++++++ homeScreen.css | 13 + homeScreen.js | 326 ++++++++++++++++ injector.js | 301 +++++++++++++++ search.css | 31 ++ search.js | 853 ++++++++++++++++++++++++++++++++++++++++++ watchlist.css | 3 + watchlist.js | 29 +- 12 files changed, 2349 insertions(+), 9 deletions(-) create mode 100644 backdrop-leak-fix.js create mode 100644 custom-menu.js create mode 100644 exclusiveElsewhere.js create mode 100644 headerTabs.js create mode 100644 homeScreen.css create mode 100644 homeScreen.js create mode 100644 injector.js create mode 100644 search.css create mode 100644 search.js create mode 100644 watchlist.css 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();