From 629f524b4c46816d971f0d0a0a73bdb81c6f8e70 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Wed, 29 Oct 2025 01:45:44 -0400 Subject: [PATCH] New feature: Skin Manager, also minor fixes and improvements --- breadcrumbs.js | 134 +- cardBuilder.js | 417 +-- dashboardButtonFix.js | 3 +- headerTabs.js | 104 +- homeScreen.css | 17 + homeScreen.js | 5886 ++++++++++++++++++--------------------- infiniteScroll.js | 26 +- injector.js | 143 +- kefinTweaks.js | 74 +- playlist.js | 148 +- removeContinue.js | 6 +- search.css | 12 + search.js | 113 +- skinManager.js | 1505 ++++++++++ skins/chromic-kefin.css | 3 + skins/elegantKefin.css | 78 + skins/flow-kefin.css | 14 + skins/jamfin-kefin.css | 29 + skins/scyfin-kefin.css | 14 + subtitleSearch.js | 5 +- userConfig.js | 306 ++ utils.js | 25 +- watchlist.css | 167 +- watchlist.js | 950 +++++-- 24 files changed, 6166 insertions(+), 4013 deletions(-) create mode 100644 skinManager.js create mode 100644 skins/chromic-kefin.css create mode 100644 skins/elegantKefin.css create mode 100644 skins/flow-kefin.css create mode 100644 skins/jamfin-kefin.css create mode 100644 skins/scyfin-kefin.css create mode 100644 userConfig.js diff --git a/breadcrumbs.js b/breadcrumbs.js index 3de39b4..cf47cfa 100644 --- a/breadcrumbs.js +++ b/breadcrumbs.js @@ -11,7 +11,6 @@ const CONFIG = { supportedTypes: ['Movie', 'Series', 'Season', 'Episode', 'MusicArtist', 'MusicAlbum'], minWidth: 768, // Only show on screens wider than 768px - headerHeight: 80 // Position below .skinHeader }; // State management @@ -59,15 +58,26 @@ elements: [], item: item }; + + // Don't append .html for server versions 10.11 and above + const urlSuffix = ApiClient._serverVersion.split('.')[1] > 10 ? '' : '.html' + + let queryParams = ''; + if (item.ParentId) { + queryParams = `?topParentId=${item.ParentId}&serverId=${ApiClient.serverId()}`; + } + else if (ApiClient.serverId()) { + queryParams = `?serverId=${ApiClient.serverId()}`; + } if (item.Type === 'Movie') { structure.elements = [ - { text: 'Movies', url: '#/movies.html', clickable: true }, + { text: 'Movies', url: `#/movies${urlSuffix}${queryParams}`, clickable: true }, { text: item.Name, url: null, clickable: false } ]; } else if (item.Type === 'Series') { structure.elements = [ - { text: 'Shows', url: '#/tv.html', clickable: true }, + { text: 'Shows', url: `#/tv${urlSuffix}${queryParams}`, clickable: true }, { text: item.Name, url: null, clickable: false }, { text: 'All Seasons', url: null, clickable: true, popover: true } ]; @@ -77,8 +87,8 @@ const seriesName = seriesDetails ? seriesDetails.Name : 'Unknown Series'; structure.elements = [ - { text: 'Shows', url: '#/tv.html', clickable: true }, - { text: seriesName, url: `#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true }, + { text: 'Shows', url: `#/tv${urlSuffix}${queryParams}`, clickable: true }, + { text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true }, { text: item.Name || `Season ${item.IndexNumber}`, url: null, clickable: true, popover: true } ]; } else if (item.Type === 'Episode') { @@ -91,21 +101,21 @@ const seasonName = seasonDetails ? (seasonDetails.Name || `Season ${seasonDetails.IndexNumber}`) : `Season ${item.ParentIndexNumber}`; structure.elements = [ - { text: 'Shows', url: '#/tv.html', clickable: true }, - { text: seriesName, url: `#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true }, + { text: 'Shows', url: `#/tv${urlSuffix}${queryParams}`, clickable: true }, + { text: seriesName, url: `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId}&serverId=${ApiClient.serverId()}`, clickable: true }, { text: seasonName, url: null, clickable: true, popover: true }, { text: `${item.ParentIndexNumber}x${padNumber(item.IndexNumber)} - ${item.Name}`, url: null, clickable: false } ]; } else if (item.Type === 'MusicArtist') { structure.elements = [ - { text: 'Music', url: '#/music.html', clickable: true }, + { text: 'Music', url: `#/music${urlSuffix}${queryParams}`, clickable: true }, { text: item.Name, url: null, clickable: false }, { text: 'All Albums', url: null, clickable: true, popover: true } ]; } else if (item.Type === 'MusicAlbum') { structure.elements = [ - { text: 'Music', url: '#/music.html', clickable: true }, - { text: item.AlbumArtist, url: `#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true }, + { text: 'Music', url: `#/music${urlSuffix}${queryParams}`, clickable: true }, + { text: item.AlbumArtist, url: `${ApiClient._serverAddress}/web/#/details?id=${item.ParentId}&serverId=${ApiClient.serverId()}`, clickable: true }, { text: item.Name, url: null, clickable: true, popover: true } ]; } @@ -233,7 +243,7 @@ if (elementDef.url) { // Direct link element.addEventListener('click', () => { - window.location.hash = elementDef.url; + window.location.href = elementDef.url; }); } else if (elementDef.popover) { // Popover functionality @@ -262,7 +272,7 @@ if (elementDef.url) { // Direct link newElement.addEventListener('click', () => { - window.location.hash = elementDef.url; + window.location.href = elementDef.url; }); } else if (elementDef.popover) { // Popover functionality @@ -281,7 +291,7 @@ element.addEventListener('click', (e) => { e.stopPropagation(); const popover = createPopover(seasons, null, (selectedSeason) => { - window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`; }); showPopover(element, popover); }); @@ -291,7 +301,7 @@ element.addEventListener('click', (e) => { e.stopPropagation(); const popover = createPopover(albums, null, (selectedAlbum) => { - window.location.hash = `#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`; }); showPopover(element, popover); }); @@ -304,7 +314,7 @@ element.addEventListener('click', (e) => { e.stopPropagation(); const popover = createPopover(seasons, item, (selectedSeason) => { - window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`; }); showPopover(element, popover); }); @@ -314,14 +324,14 @@ const seasons = await getSeasonsIfNeeded(item.SeriesId); if (seasons.length === 1) { element.addEventListener('click', () => { - window.location.hash = `#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${seasons[0].Id}&serverId=${ApiClient.serverId()}`; }); } else { const currentSeason = seasons.find(season => season.Id === item.ParentId); element.addEventListener('click', (e) => { e.stopPropagation(); const popover = createPopover(seasons, currentSeason, (selectedSeason) => { - window.location.hash = `#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedSeason.Id}&serverId=${ApiClient.serverId()}`; }); showPopover(element, popover); }); @@ -331,13 +341,13 @@ const albums = await getAlbumsIfNeeded(item.ParentId); if (albums.length === 1) { element.addEventListener('click', () => { - window.location.hash = `#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${albums[0].Id}&serverId=${ApiClient.serverId()}`; }); } else { element.addEventListener('click', (e) => { e.stopPropagation(); const popover = createPopover(albums, item, (selectedAlbum) => { - window.location.hash = `#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`; + window.location.href = `${ApiClient._serverAddress}/web/#/details?id=${selectedAlbum.Id}&serverId=${ApiClient.serverId()}`; }); showPopover(element, popover); }); @@ -354,52 +364,6 @@ console.error(`[KefinTweaks Breadcrumbs] ${message}`, ...args); } - function debugBreadcrumbLinks() { - if (!breadcrumbContainer) return; - - const children = Array.from(breadcrumbContainer.children); - log('Breadcrumb structure:'); - children.forEach((child, index) => { - if (child.style.cursor === 'pointer') { - log(` Element ${index}: "${child.textContent}" (clickable)`); - } else { - log(` Element ${index}: "${child.textContent}" (static)`); - } - }); - } - - function ensureSeriesNameIsClickable(seriesId) { - if (!breadcrumbContainer) return; - - const children = Array.from(breadcrumbContainer.children); - - // Find the series name element (should be the 3rd element: Shows / Series Name / ...) - if (children.length >= 3) { - const seriesElement = children[2]; // Shows (0) / Series Name (2) / ... - - // Check if it's already clickable - if (seriesElement.style.cursor !== 'pointer') { - log('Making series name clickable:', seriesElement.textContent); - - // Make it clickable - seriesElement.style.cursor = 'pointer'; - - // Add click handler - seriesElement.onclick = () => { - window.location.hash = `#/details?id=${seriesId}&serverId=${ApiClient.serverId()}`; - }; - - log('Series name is now clickable and links to series page:', seriesId); - } else { - // Update existing click handler to ensure it points to the series page - seriesElement.onclick = () => { - window.location.hash = `#/details?id=${seriesId}&serverId=${ApiClient.serverId()}`; - }; - log('Updated series name click handler to point to series page:', seriesId); - } - } - } - function isWideScreen() { return window.innerWidth > CONFIG.minWidth; } @@ -491,12 +455,7 @@ } closePopover(); } - - - - - - + function closePopover() { if (activePopover) { activePopover.remove(); @@ -643,22 +602,6 @@ } } - // Breadcrumb generation - function createBreadcrumbElement(text, url = null, isClickable = true) { - const element = document.createElement('span'); - element.className = 'kefinTweaks-breadcrumb-element'; - element.textContent = text; - - if (url && isClickable) { - element.style.cursor = 'pointer'; - element.addEventListener('click', () => { - window.location.hash = url; - }); - } - - return element; - } - function createSeparator() { const separator = document.createElement('span'); separator.className = 'kefinTweaks-breadcrumb-separator'; @@ -747,23 +690,26 @@ breadcrumbContainer = createBreadcrumbContainer(); } - // Clear existing content - clearBreadcrumbs(); - - // Add all elements + // Build all elements first + const elements = []; for (let i = 0; i < targetStructure.elements.length; i++) { const elementDef = targetStructure.elements[i]; const element = await createBreadcrumbElementFromStructure(elementDef, targetStructure.item); - - breadcrumbContainer.appendChild(element); + elements.push(element); // Add separator after each element except the last if (i < targetStructure.elements.length - 1) { const separator = createSeparator(); - breadcrumbContainer.appendChild(separator); + elements.push(separator); } } + // Replace the innerHTML entirely to avoid any duplicate appending issues + breadcrumbContainer.innerHTML = ''; + + // Append all elements at once + elements.forEach(el => breadcrumbContainer.appendChild(el)); + log('Created breadcrumbs from scratch with', targetStructure.elements.length, 'elements'); } diff --git a/cardBuilder.js b/cardBuilder.js index fce4294..7de16ce 100644 --- a/cardBuilder.js +++ b/cardBuilder.js @@ -10,10 +10,12 @@ /** * Main entry point function to build a Jellyfin card * @param {Object} item - The Jellyfin item object + * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' * @returns {HTMLElement} - The constructed card element */ - buildCard: function(item) { - return createJellyfinCardElement(item); + buildCard: function(item, overflowCard = false, cardFormat = null) { + return createJellyfinCardElement(item, overflowCard, cardFormat); }, /** @@ -21,11 +23,12 @@ * @param {Array} items - Array of Jellyfin item objects * @param {string} title - Title for the scrollable container * @param {string|Function} viewMoreUrl - Optional URL to make title clickable, or function to call on click - * @param {number} sectionIndex - Index for debugging + * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' * @returns {HTMLElement} - The constructed scrollable container */ - renderCards: function(items, title, viewMoreUrl = null, sectionIndex = 0) { - return createScrollableContainer(items, title, viewMoreUrl, sectionIndex); + renderCards: function(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) { + return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat); }, /** @@ -33,15 +36,16 @@ * @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 + * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' * @returns {Promise} - The constructed scrollable container */ - renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, sectionIndex = 0) { + renderCardsFromIds: async function(itemIds, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) { 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); + return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat); } try { @@ -51,10 +55,10 @@ ); LOG(`Fetched ${items.length} items for section: ${title}`); - return createScrollableContainer(items, title, viewMoreUrl, sectionIndex); + return createScrollableContainer(items, title, viewMoreUrl, overflowCard, cardFormat); } catch (error) { console.error('[CardBuilder] Error fetching items:', error); - return createScrollableContainer([], title, viewMoreUrl, sectionIndex); + return createScrollableContainer([], title, viewMoreUrl, overflowCard, cardFormat); } } }; @@ -62,27 +66,49 @@ /** * Creates a Jellyfin card element from an item * @param {Object} item - The Jellyfin item object + * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' * @returns {HTMLElement} - The constructed card element */ - function createJellyfinCardElement(item) { + function createJellyfinCardElement(item, overflowCard = false, cardFormat = null) { const serverId = ApiClient.serverId(); const serverAddress = ApiClient.serverAddress(); - // Determine card type based on item type + // Determine card type based on cardFormat override or item type let cardClass, padderClass, imageParams; - if (item.Type === 'Episode' || item.Type === 'TvChannel') { - cardClass = 'overflowBackdropCard'; - padderClass = 'cardPadder-overflowBackdrop'; - imageParams = 'fillHeight=267&fillWidth=474'; - } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) { - cardClass = 'overflowSquareCard'; - padderClass = 'cardPadder-overflowSquare'; - imageParams = 'fillHeight=297&fillWidth=297'; + + if (cardFormat) { + // Use specified cardFormat + if (cardFormat === 'backdrop' || cardFormat === 'thumb') { + cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; + padderClass = 'cardPadder-backdrop'; + imageParams = 'fillHeight=267&fillWidth=474'; + } else if (cardFormat === 'square') { + cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; + padderClass = 'cardPadder-square'; + imageParams = 'fillHeight=297&fillWidth=297'; + } else { + // portrait (default) + cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; + padderClass = 'cardPadder-portrait'; + imageParams = 'fillHeight=446&fillWidth=297'; + } } else { - // Default poster style for Movies, Series, etc. - cardClass = 'overflowPortraitCard'; - padderClass = 'cardPadder-overflowPortrait'; - imageParams = 'fillHeight=446&fillWidth=297'; + // Use item type to determine card type + if (item.Type === 'Episode' || item.Type === 'TvChannel') { + cardClass = overflowCard ? 'overflowBackdropCard' : 'backdropCard'; + padderClass = 'cardPadder-backdrop'; + imageParams = 'fillHeight=267&fillWidth=474'; + } else if (['MusicAlbum', 'Audio', 'Artist', 'MusicArtist'].includes(item.Type)) { + cardClass = overflowCard ? 'overflowSquareCard' : 'squareCard'; + padderClass = 'cardPadder-square'; + imageParams = 'fillHeight=297&fillWidth=297'; + } else { + // Default poster style for Movies, Series, etc. + cardClass = overflowCard ? 'overflowPortraitCard' : 'portraitCard'; + padderClass = 'cardPadder-portrait'; + imageParams = 'fillHeight=446&fillWidth=297'; + } } // Create the main card container @@ -140,13 +166,49 @@ // Card image container const cardImageContainer = document.createElement('a'); - cardImageContainer.href = `#/details?id=${item.Id}&serverId=${serverId}`; + cardImageContainer.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; cardImageContainer.className = 'cardImageContainer coveredImage cardContent itemAction lazy blurhashed lazy-image-fadein-fast'; cardImageContainer.setAttribute('data-action', 'link'); cardImageContainer.setAttribute('aria-label', item.Name || 'Unknown'); - - // Set background image or icon - if (item.ImageTags?.Primary) { + + // Force specific image if card format is specified + if (cardFormat === 'backdrop') { + let imageUrl = ''; + if (item.BackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}`; + } else if (item.ParentBackdropImageTags[0]) { + imageUrl = `${serverAddress}/Items/${item.ParentBackdropItemId}/Images/Backdrop?${imageParams}&quality=96&tag=${item.ParentBackdropImageTags[0]}`; + } else { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; + } + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } else if (cardFormat === 'square') { + if (item.Type === 'Season' || item.Type === 'Series' || item.Type === 'Movie') { + const imageUrl = item.BackdropImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Backdrop?${imageParams}&quality=96&tag=${item.BackdropImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } else { + const imageUrl = item.PrimaryImageTags[0] ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.PrimaryImageTags[0]}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } + } else if (cardFormat === 'portrait') { + if (item.Type === 'Episode' && item.SeriesPrimaryImageTag) { + const imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Primary?${imageParams}&quality=96&tag=${item.SeriesPrimaryImageTag}`; + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } else { + const imageUrl = item.ImageTags?.Primary ? `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}` : `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } + } else if (cardFormat === 'thumb') { + let imageUrl = ''; + if (item.ImageTags?.Thumb) { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Thumb?${imageParams}&quality=96&tag=${item.ImageTags?.Thumb}`; + } else if (item.ParentThumbImageTag) { + imageUrl = `${serverAddress}/Items/${item.SeriesId}/Images/Thumb?${imageParams}&quality=96&tag=${item.ParentThumbImageTag}`; + } else { + imageUrl = `${serverAddress}/Items/${item.Id}/Images/Primary?${imageParams}&quality=96&tag=${item.ImageTags?.Primary}`; + } + cardImageContainer.style.backgroundImage = `url("${imageUrl}")`; + } else if (item.ImageTags?.Primary) { let imageUrl; if (item.IsJellyseerr) { // Jellyseerr items use external image URLs @@ -208,7 +270,7 @@ // Overlay link const overlayLink = document.createElement('a'); - overlayLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + overlayLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; overlayLink.className = 'cardImageContainer'; // Play button @@ -275,7 +337,7 @@ if (item.Type === 'Episode') { // Episodes: Series name as primary, episode title as secondary const seriesLink = document.createElement('a'); - seriesLink.href = `#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`; + seriesLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.SeriesId || item.Id}&serverId=${serverId}`; seriesLink.className = 'itemAction textActionButton'; seriesLink.setAttribute('data-id', item.SeriesId || item.Id); seriesLink.setAttribute('data-serverid', serverId); @@ -295,7 +357,7 @@ const secondaryText = document.createElement('div'); secondaryText.className = 'cardText cardTextCentered cardText-secondary'; const episodeLink = document.createElement('a'); - episodeLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; episodeLink.className = 'itemAction textActionButton'; episodeLink.setAttribute('data-id', item.Id); episodeLink.setAttribute('data-serverid', serverId); @@ -313,7 +375,7 @@ } else { // Default: Item name as primary, year as secondary const titleLink = document.createElement('a'); - titleLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + titleLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; titleLink.className = 'itemAction textActionButton'; titleLink.setAttribute('data-id', item.Id); titleLink.setAttribute('data-serverid', serverId); @@ -352,7 +414,7 @@ secondaryText.className = 'cardText cardTextCentered cardText-secondary'; const episodeLink = document.createElement('a'); const episodeName = item.IndexNumber && item.ParentIndexNumber ? `S${item.ParentIndexNumber}:E${item.IndexNumber} - ${item.Name}` : item.Name; - episodeLink.href = `#/details?id=${item.Id}&serverId=${serverId}`; + episodeLink.href = `${ApiClient._serverAddress}/web/#/details?id=${item.Id}&serverId=${serverId}`; episodeLink.className = 'itemAction textActionButton'; episodeLink.setAttribute('data-id', item.Id); episodeLink.setAttribute('data-serverid', serverId); @@ -387,10 +449,11 @@ * @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 + * @param {boolean} overflowCard - Use overflow card classes instead of normal card classes + * @param {string} cardFormat - Override card format: 'portrait', 'backdrop', or 'square' * @returns {HTMLElement} - The constructed scrollable container */ - function createScrollableContainer(items, title, viewMoreUrl = null, sectionIndex = 0) { + function createScrollableContainer(items, title, viewMoreUrl = null, overflowCard = false, cardFormat = null) { const LOG = (...args) => console.log('[CardBuilder]', ...args); // Create the main vertical section container @@ -398,8 +461,8 @@ verticalSection.className = 'verticalSection emby-scroller-container custom-scroller-container'; // Create section title - const sectionTitle = document.createElement('h2'); - sectionTitle.className = 'sectionTitle sectionTitle-cards focuscontainer-x padded-left padded-right'; + const sectionTitleContainer = document.createElement('div'); + sectionTitleContainer.className = 'sectionTitleContainer sectionTitleContainer-cards padded-left'; if (viewMoreUrl) { // Create clickable title with chevron icon @@ -417,7 +480,8 @@ titleLink.href = viewMoreUrl; } - const titleText = document.createElement('span'); + const titleText = document.createElement('h2'); + titleText.className = 'sectionTitle sectionTitle-cards'; titleText.textContent = title; const chevronIcon = document.createElement('span'); @@ -426,57 +490,23 @@ titleLink.appendChild(titleText); titleLink.appendChild(chevronIcon); - sectionTitle.appendChild(titleLink); + sectionTitleContainer.appendChild(titleLink); } else { - // Regular non-clickable title - sectionTitle.textContent = title; + // Regular non-clickable title + const titleText = document.createElement('h2'); + titleText.className = 'sectionTitle sectionTitle-cards'; + titleText.textContent = title; + sectionTitleContainer.appendChild(titleText); } // Create "Show All" button (will be added after items are created) const showAllButton = document.createElement('button'); showAllButton.type = 'button'; showAllButton.className = 'show-all-button'; - showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer;'; + showAllButton.style.cssText = 'margin-left: 10px; font-size: 12px; padding: 4px 8px; min-width: auto; background: transparent; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; cursor: pointer; color: var(--main-text, #fff) !important; margin-bottom: .35em; align-self: center;'; showAllButton.textContent = 'Show All'; showAllButton.title = 'Show all items in a grid layout'; - // 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'); @@ -491,13 +521,10 @@ 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); + const card = createJellyfinCardElement(item, overflowCard, cardFormat); card.setAttribute('data-index', index); itemsContainer.appendChild(card); }); @@ -506,7 +533,8 @@ // Add "Show All" button to title if there are more than 20 items if (items.length > 20) { - sectionTitle.appendChild(showAllButton); + sectionTitleContainer.style.display = 'flex'; + sectionTitleContainer.appendChild(showAllButton); } // Toggle between scroll and grid view @@ -514,241 +542,34 @@ const originalItemsContainerStyle = itemsContainer.style.cssText; showAllButton.addEventListener('click', () => { + // Find the scroll buttons for this container + const scrollerContainer = showAllButton.closest('.emby-scroller-container'); + const scrollButtons = scrollerContainer ? scrollerContainer.querySelector('.emby-scrollbuttons') : null; + if (isShowingAll) { // Switch back to scroll view itemsContainer.style.cssText = originalItemsContainerStyle; - scrollButtons.style.display = ''; + if (scrollButtons) scrollButtons.style.display = ''; showAllButton.textContent = 'Show All'; showAllButton.title = 'Show all items in a grid layout'; isShowingAll = false; } else { // Switch to grid view itemsContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 12px; white-space: normal; transform: none !important; transition: none !important;'; - scrollButtons.style.display = 'none'; + if (scrollButtons) scrollButtons.style.display = 'none'; showAllButton.textContent = 'Show Less'; showAllButton.title = 'Show items in scrollable layout'; isShowingAll = true; } }); - // 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(sectionTitleContainer); 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 = ` - /* Native horizontal scrolling experience - ONLY for our custom scrollers */ - - - /* Ensure smooth scrolling on mobile */ - @media (max-width: 768px) { - .emby-scroller.custom-scroller { - scroll-behavior: smooth !important; - } - .custom-scroller-container .emby-scrollbuttons { - display: none; - } - } - `; - document.head.appendChild(style); - // Expose the cardBuilder to the global window object window.cardBuilder = cardBuilder; diff --git a/dashboardButtonFix.js b/dashboardButtonFix.js index 3133ccf..7900554 100644 --- a/dashboardButtonFix.js +++ b/dashboardButtonFix.js @@ -20,7 +20,8 @@ console.log("[DashboardButtonFix]: No history to go back. Redirecting to home page."); // Changes this to the page you'd like to return to by default - Dashboard.navigate('/home.html'); + const homeUrl = ApiClient.serverVersion().split('.')[1] > 10 ? 'home' : 'home.html'; + Dashboard.navigate(`/${homeUrl}`); } }, true); } diff --git a/headerTabs.js b/headerTabs.js index 22e85f5..25efe65 100644 --- a/headerTabs.js +++ b/headerTabs.js @@ -10,9 +10,9 @@ const WARN = (...args) => console.warn('[HeaderTabs]', ...args); const ERR = (...args) => console.error('[HeaderTabs]', ...args); - LOG('Script loaded - JS Injector mode'); + LOG('Initializing...'); - const SUPPORTED_PAGES = ['home.html', 'tv.html', 'movies.html', 'music.html', 'livetv.html']; + const SUPPORTED_PAGES = ['home', 'home.html', 'tv', 'tv.html', 'movies', 'movies.html', 'music', 'music.html', 'livetv', 'livetv.html']; // Get current page from URL hash function getCurrentPage() { @@ -87,10 +87,12 @@ newHash = `${currentHash}`; } } - - const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1); + + // Manually notify handlers for Header Tab navigation because it doesn't trigger normal Jellyfin navigation + window.KefinTweaksUtils.notifyHandlers(currentPage, document); // Replace history entry instead of adding new one + const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1); window.history.replaceState(null, '', newUrl); LOG('Updated URL with tab:', tabIndex, newUrl); } @@ -139,7 +141,7 @@ // Patch for media bar because it usually reacts slowly and hides later // Hide media bar slideshow if we aren't specifically on the home page first tab const currentPage = getCurrentPage(); - if (currentPage !== 'home.html' || currentTabFromUrl !== 0) { + if (currentPage !== 'home' && currentPage !== 'home.html' || currentTabFromUrl !== 0) { const mediaBarSlideshow = document.getElementById('slides-container'); if (mediaBarSlideshow) { mediaBarSlideshow.style.display = 'none'; @@ -149,6 +151,23 @@ const buttons = headerTabs.querySelectorAll('.emby-tab-button'); const activeButton = headerTabs.querySelector('.emby-tab-button-active'); + const activeTabContent = document.querySelector('.libraryPage:not(.hide) .pageTabContent.is-active'); + const activeTabContentIndex = activeTabContent ? activeTabContent.getAttribute('data-index') : null; + + // Remove is-active class from the active tab content if it's not the current tab + if (activeTabContentIndex && activeTabContentIndex !== currentTabFromUrl) { + activeTabContent.classList.remove('is-active'); + } + + // Add is-active class to the current tab content if it's not already active + if (!activeTabContentIndex || activeTabContentIndex !== currentTabFromUrl) { + const targetContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabFromUrl}"]`); + if (targetContent) { + targetContent.classList.add('is-active'); + LOG('Set active tab content:', currentTabFromUrl); + } + } + if (activeButton && activeButton.getAttribute('data-index') === currentTabFromUrl.toString()) { return; } @@ -168,23 +187,6 @@ correctButton.classList.add('emby-tab-button-active'); LOG('Set active tab button to index:', currentTabFromUrl); } - - const activeTabContent = document.querySelector('.libraryPage:not(.hide) .pageTabContent.is-active'); - const activeTabContentIndex = activeTabContent ? activeTabContent.getAttribute('data-index') : null; - - // Remove is-active class from the active tab content if it's not the current tab - if (activeTabContentIndex && activeTabContentIndex !== currentTabFromUrl) { - activeTabContent.classList.remove('is-active'); - } - - // Add is-active class to the current tab content if it's not already active - if (!activeTabContentIndex || activeTabContentIndex !== currentTabFromUrl) { - const targetContent = document.querySelector(`.libraryPage:not(.hide) .pageTabContent[data-index="${currentTabFromUrl}"]`); - if (targetContent) { - targetContent.classList.add('is-active'); - LOG('Set active tab content:', currentTabFromUrl); - } - } } // Add click listeners to tab buttons @@ -203,6 +205,7 @@ } } + // TODO - Replace the MutationObservers with a less taxing alternative // Setup observer to watch for emby-tab-button additions function setupHeaderTabsObserver() { const headerTabs = document.querySelector('.headerTabs'); @@ -331,63 +334,8 @@ // Check on hash changes window.addEventListener('hashchange', checkPageChange); - - // Fallback: check URL periodically but less frequently -/* urlCheckInterval = setInterval(() => { - if (window.location.href !== lastUrl) { - checkPageChange(); - } - }, 500); */ - - // Check periodically in case we miss something - //setInterval(checkPageChange, 1000); } - // Setup home navigation click handler with retry logic -/* function setupHomeNavClickHandler() { - const homeNavOption = document.querySelector('.mainDrawer .navMenuOption[href="#/home.html"]'); - if (homeNavOption) { - homeNavOption.addEventListener('click', function(event) { - const headerTabs = document.querySelector('.headerTabs'); - if (headerTabs) { - const firstTab = headerTabs.querySelector('.emby-tab-button[data-index="0"]'); - if (firstTab) { - LOG('Triggering click on first homepage tab'); - firstTab.click(); - } else { - WARN('First homepage tab not found'); - } - } else { - WARN('Header tabs not found'); - } - }); - LOG('Added click listener to home navigation option'); - return true; // Success - } else { - WARN('Home navigation option not found, will retry...'); - return false; // Failed, needs retry - } - } - - // Retry setup until successful - function retryHomeNavSetup() { - const maxRetries = 50; // Try for up to 25 seconds (50 * 500ms) - let retryCount = 0; - - const retryInterval = setInterval(() => { - if (setupHomeNavClickHandler()) { - clearInterval(retryInterval); - LOG('Home navigation click handler successfully attached'); - } else { - retryCount++; - if (retryCount >= maxRetries) { - clearInterval(retryInterval); - ERR('Failed to attach home navigation click handler after maximum retries'); - } - } - }, 500); - } */ - // Register onViewPage handler to sync active tab state if (window.KefinTweaksUtils && window.KefinTweaksUtils.onViewPage) { window.KefinTweaksUtils.onViewPage((view, element) => { @@ -405,7 +353,7 @@ // Initialize setupPageMonitor(); - //retryHomeNavSetup(); + setupHeaderTabsObserver(); LOG('Header tabs functionality initialized'); diff --git a/homeScreen.css b/homeScreen.css index 3efd909..6aaa781 100644 --- a/homeScreen.css +++ b/homeScreen.css @@ -6,12 +6,20 @@ display: flex; flex-direction: column; } + .sections.homeSectionsContainer>div { order: 100; } + +.sections.homeSectionsContainer>div:has(a.homeLibraryButton), +.sections.homeSectionsContainer>div:has([data-type="CollectionFolder"]) { + order: 1; +} + .sections.homeSectionsContainer>div:has(div[data-positionticks]) { order: 2; } + .sections.homeSectionsContainer>div:has(a[href*="type=nextup"]) { order: 3; } @@ -27,4 +35,13 @@ background-image: url('/files/KefinTweaks/pages/images/halloween.jpg'); background-position: 0px -250px; background-size: auto 102%; +} + +.home-screen h2.sectionTitle { + margin-bottom: .35em; + margin-top: 0; +} + +.sectionTitleContainer.sectionTitleContainer-cards.padded-left > .sectionTitle.sectionTitle-cards { + display: flex !important; } \ No newline at end of file diff --git a/homeScreen.js b/homeScreen.js index ac2270e..c635a2c 100644 --- a/homeScreen.js +++ b/homeScreen.js @@ -12,7 +12,7 @@ const WARN = (...args) => console.warn('[HomeScreen]', ...args); const ERR = (...args) => console.error('[HomeScreen]', ...args); - LOG('Script loaded - JS Injector mode'); + LOG('Initializing...'); // Add CSS for discovery section loading indicator function addDiscoveryLoadingCSS() { @@ -60,6 +60,20 @@ 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + + /* Discovery section smooth reveal animation */ + .discovery-section-reveal { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); + } + + /* When section enters viewport, trigger animation */ + .discovery-section-reveal.in-viewport { + opacity: 1; + transform: translateY(0); + } `; document.head.appendChild(style); @@ -124,7 +138,7 @@ function manageBodyClasses() { const body = document.body; const currentView = window.KefinTweaksUtils?.getCurrentView(); - const isHomePage = currentView === 'home' || window.location.pathname.includes('home.html'); + const isHomePage = currentView === 'home' || currentView === 'home.html'; // Manage home-screen class if (isHomePage && body) { @@ -202,6 +216,7 @@ // Processing flag to prevent parallel execution let isProcessing = false; + let isLoadingDiscoveryGroup = false; // Seasonal data storage let halloweenMovies = []; @@ -221,532 +236,38 @@ let isPeopleCacheComplete = false; // Track if all people data has been loaded let preloadedSections = []; // Preloaded sections ready for instant rendering + // Track rendered items to avoid duplicates + let renderedActors = new Set(); // Track rendered actor IDs + let renderedDirectors = new Set(); // Track rendered director IDs + let renderedWriters = new Set(); // Track rendered writer IDs + let renderedWatchedMovies = new Set(); // Track rendered watched movie IDs + let renderedFavoriteMovies = new Set(); // Track rendered favorite movie IDs + + // Pre-rendered sections waiting to be revealed + let hiddenDiscoverySections = []; // Array of pre-rendered section DOM elements + // Discovery buffer system let discoveryBuffer = []; // Buffer holding groups of discovery items (always 2 groups) let additionalDiscoveryContent = true; // Flag indicating if more discovery content is available let preloadedSectionElements = []; // Preloaded section DOM elements (hidden) let isPreloadingSections = false; // Prevent parallel preloading + let discoveryScrollHandler = null; // Reference to scroll handler for cleanup -/************ Helpers ************/ + /************ Helpers ************/ -/** - * Fetches item data from Jellyfin API to determine type - * @param {string} itemId - The item ID to fetch - * @returns {Promise} - Item data with type information - */ -async function fetchItemData(itemId) { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - const url = `${serverUrl}/Items/${itemId}`; - - try { - const response = await fetch(url, { - headers: { - "Authorization": `MediaBrowser Token="${token}"` - } - }); + /** + * Fetches item data from Jellyfin API to determine type + * @param {string} itemId - The item ID to fetch + * @returns {Promise} - Item data with type information + */ + async function fetchItemData(itemId) { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } + const url = `${serverUrl}/Items/${itemId}`; - const data = await response.json(); - return data; - } catch (err) { - ERR(`Failed to fetch item data for ${itemId}:`, err); - return null; - } -} - -/** - * 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: [] }; - } -} - -/** - * Fetches collection data from Jellyfin API - * @param {string} collectionId - The collection ID to fetch - * @returns {Promise} - Collection data with Items array - */ -async function fetchCollectionData(collectionId) { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - - const url = `${serverUrl}/Users/${userId}/Items?ParentId=${collectionId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CCanDelete%2CMediaSourceCount`; - - 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 collection data for ${collectionId}:`, err); - return { Items: [] }; - } -} - -/** - * Generates the view more URL for a playlist or collection - * @param {string} itemId - The item ID - * @param {string} itemType - The item type ('Playlist' or 'BoxSet') - * @returns {string} - The complete URL for the item page - */ -function generateViewMoreUrl(itemId, itemType) { - const apiClient = window.ApiClient; - const serverId = apiClient.serverId(); - - if (itemType === 'BoxSet') { - // For collections, use the collection page - return `#/details?id=${itemId}&serverId=${serverId}`; - } else { - // For playlists, use the list page - return `#/list.html?parentId=${itemId}&serverId=${serverId}`; - } -} - -/** - * Renders a custom home section - * @param {Object} section - Section configuration object - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ -async function renderCustomSection(section, container) { - try { - // First, fetch item data to determine type - const itemData = await fetchItemData(section.id); - if (!itemData) { - ERR(`Failed to fetch item data for section ${section.name}`); - return false; - } - - let itemIds = []; - let itemType = itemData.Type; - - // Handle different item types - if (itemType === 'Playlist') { - // Fetch playlist data - const playlistData = await fetchPlaylistData(section.id); - itemIds = playlistData.ItemIds || []; - } else if (itemType === 'BoxSet') { - // Fetch collection data - const collectionData = await fetchCollectionData(section.id); - itemIds = (collectionData.Items || []).map(item => item.Id); - } else { - ERR(`Unsupported item type ${itemType} for section ${section.name}`); - return false; - } - - // 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, itemType); - - // 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 ?? itemData.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); - scrollableContainer.setAttribute('data-custom-section-type', itemType); - - 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`); -} - -/** - * Renders trending movies section (stub for future implementation) - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ -function renderTrendingMoviesSection(container) { - // TODO: Implement trending movies section - LOG('Trending Movies section not yet implemented'); - return false; -} - -/** - * Renders trending series section (stub for future implementation) - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ -function renderTrendingSeriesSection(container) { - // TODO: Implement trending series section - LOG('Trending Series section not yet implemented'); - return false; -} - -/** - * Renders all new and trending sections - * @param {HTMLElement} container - Container to append sections to - */ -async function renderAllNewAndTrendingSections(container) { - if (!enableNewAndTrending) { - LOG('New and Trending sections disabled, skipping...'); - return; - } - - const sectionsToRender = []; - - // Add new movies section if enabled - if (enableNewMovies) { - sectionsToRender.push(renderNewMoviesSection(container)); - } - - // Add new episodes section if enabled - if (enableNewEpisodes) { - sectionsToRender.push(renderNewEpisodesSection(container)); - } - - // Add trending sections if enabled (stubs for now) - if (enableTrending) { - sectionsToRender.push(renderTrendingMoviesSection(container)); - sectionsToRender.push(renderTrendingSeriesSection(container)); - } - - if (sectionsToRender.length === 0) { - return; - } - - const results = await Promise.all(sectionsToRender); - const successCount = results.filter(Boolean).length; - LOG(`Rendered ${successCount}/${sectionsToRender.length} new and trending sections`); -} - -/************ Seasonal Section Helpers ************/ - -/** - * Checks if current date is within Halloween period (Oct 1-31) - * @returns {boolean} - True if within Halloween period - */ -function isHalloweenPeriod() { - const now = new Date(); - const month = now.getMonth() + 1; // getMonth() returns 0-11 - const day = now.getDate(); - return month === 10 && day >= 1 && day <= 31; -} - -/** - * Checks if current date is within Christmas period (Dec 1-30) - * @returns {boolean} - True if within Christmas period - */ -function isChristmasPeriod() { - const now = new Date(); - const month = now.getMonth() + 1; - const day = now.getDate(); - return month === 12 && day >= 1 && day <= 30; -} - -/** - * Checks if current date is within New Years period (Dec 31 + Jan 1) - * @returns {boolean} - True if within New Years period - */ -function isNewYearsPeriod() { - const now = new Date(); - const month = now.getMonth() + 1; - const day = now.getDate(); - return (month === 12 && day === 31) || (month === 1 && day === 1); -} - -/** - * Checks if current date is within Valentine's Day period (Feb 14) - * @returns {boolean} - True if within Valentine's Day period - */ -function isValentinesPeriod() { - const now = new Date(); - const month = now.getMonth() + 1; - const day = now.getDate(); - return month === 2 && day === 14; -} - -/** - * Fetches all movie genres from Jellyfin API and caches them - * @returns {Promise} - Array of genre objects with Name, ImageTags, and Id - */ -async function fetchAndCacheMovieGenres() { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - try { - const response = await fetch(`${serverUrl}/Genres?IncludeItemTypes=Movie`, { - headers: { - "Authorization": `MediaBrowser Token="${token}"` - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - const genres = data.Items || []; - - // Optimize genres to only include essential fields - const optimizedGenres = genres.map(genre => ({ - Id: genre.Id, - Name: genre.Name, - ImageTags: genre.ImageTags || {}, - MovieCount: genre.MovieCount - })); - - // Cache the optimized genres - const cache = new window.LocalStorageCache(); - cache.set('movieGenres', optimizedGenres); - LOG(`Cached ${optimizedGenres.length} movie genres`); - - return optimizedGenres; - } catch (err) { - ERR('Failed to fetch movie genres:', err); - return []; - } -} - -/** - * Gets a genre ID from cached movie genres - * @param {string} genreName - Name of the genre to find - * @returns {Promise} - Genre ID or null if not found - */ -async function getGenreId(genreName) { - const cache = new window.LocalStorageCache(); - - // Try to get from cache first - let movieGenres = cache.get('movieGenres'); - - // If not in cache or cache is invalid, fetch and cache - if (!movieGenres || movieGenres.length === 0) { - LOG('Movie genres not in cache, fetching...'); - movieGenres = await fetchAndCacheMovieGenres(); - } - - // Find the genre by name (case insensitive) - const genre = movieGenres.find(g => g.Name.toLowerCase() === genreName.toLowerCase()); - return genre ? genre.Id : null; -} - -/** - * Fetches Halloween movie data from Jellyfin API - * @returns {Promise} - Object containing halloween, horror, and thriller movies - */ -async function fetchHalloweenMovieData() { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - const serverId = apiClient.serverId(); - - // No longer fetching people data for Halloween - const fieldsParam = ''; - - // Fetch genre IDs for ViewMore URLs - const [horrorGenreId, thrillerGenreId] = await Promise.all([ - getGenreId('Horror'), - getGenreId('Thriller') - ]); - - const queries = [ - { - name: 'halloween', - url: `${serverUrl}/Items?userId=${userId}&Tags=halloween&IncludeItemTypes=Movie&Recursive=true&Limit=200${fieldsParam}`, - viewMoreUrl: null - }, - { - name: 'horror', - url: `${serverUrl}/Items?userId=${userId}&Genres=Horror&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`, - viewMoreUrl: horrorGenreId ? `/web/#/list.html?genreId=${horrorGenreId}&serverId=${serverId}` : null - }, - { - name: 'thriller', - url: `${serverUrl}/Items?userId=${userId}&Genres=Thriller&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`, - viewMoreUrl: thrillerGenreId ? `/web/#/list.html?genreId=${thrillerGenreId}&serverId=${serverId}` : null - } - ]; - - try { - const results = await Promise.all( - queries.map(async (query) => { - const response = await fetch(query.url, { - headers: { - "Authorization": `MediaBrowser Token="${token}"` - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - return { - name: query.name, - items: data.Items || [] - }; - }) - ); - - // Process results - const halloweenItems = results.find(r => r.name === 'halloween')?.items || []; - const horrorItems = results.find(r => r.name === 'horror')?.items || []; - const thrillerItems = results.find(r => r.name === 'thriller')?.items || []; - - // Combine all items into halloweenMovies, removing duplicates by ID - const allItems = [...halloweenItems]; - const existingIds = new Set(halloweenItems.map(item => item.Id)); - - // Add horror items that aren't already in halloween - horrorItems.forEach(item => { - if (!existingIds.has(item.Id)) { - allItems.push(item); - existingIds.add(item.Id); - } - }); - - // Add thriller items that aren't already in halloween or horror - thrillerItems.forEach(item => { - if (!existingIds.has(item.Id)) { - allItems.push(item); - existingIds.add(item.Id); - } - }); - - return { - allHalloweenMovies: allItems, - halloweenMovies: halloweenItems, - horrorMovies: horrorItems, - thrillerMovies: thrillerItems, - viewMoreUrls: { - horror: queries.find(q => q.name === 'horror')?.viewMoreUrl || null, - thriller: queries.find(q => q.name === 'thriller')?.viewMoreUrl || null - } - }; - - } catch (err) { - ERR('Failed to fetch Halloween movie data:', err); - return { - allHalloweenMovies: [], - halloweenMovies: [], - horrorMovies: [], - thrillerMovies: [], - viewMoreUrls: { - horror: null, - thriller: null - } - }; - } -} - - - -/** - * Fetches and processes top people data from all movies using pagination - * @returns {Promise} - Top people data or null if failed - */ -async function fetchTopPeople() { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - const cache = new window.LocalStorageCache(); - - try { - LOG('Starting paginated fetch of movies for people data processing...'); - - let allMovies = []; - let startIndex = 0; - const limit = 500; - let hasMoreData = true; - - // Load existing partial data if available - const existingData = cache.get('movies_top_people'); - if (existingData && !existingData.isComplete) { - const moviesProcessed = countMoviesFromPeopleData(existingData); - if (moviesProcessed > 0) { - startIndex = moviesProcessed; - LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`); - } - } - - // Fetch movies in chunks of 500 - while (hasMoreData) { - const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=Movie&Recursive=true&Fields=People&Limit=${limit}&StartIndex=${startIndex}`; - - LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`); - + try { const response = await fetch(url, { headers: { "Authorization": `MediaBrowser Token="${token}"` @@ -758,1110 +279,1684 @@ async function fetchTopPeople() { } const data = await response.json(); - const movies = data.Items || []; - - if (movies.length === 0) { - hasMoreData = false; - LOG('No more movies to fetch'); - } else { - allMovies = allMovies.concat(movies); - startIndex += movies.length; - - LOG(`Fetched ${movies.length} movies, total: ${allMovies.length}`); - - // Process and cache partial data after each chunk - const partialPeopleData = processPeopleData(allMovies); - if (partialPeopleData) { - // Mark as incomplete and store movie count for resuming - partialPeopleData.isComplete = movies.length < limit; // Complete if we got less than limit - partialPeopleData.moviesProcessedCount = allMovies.length; - - // Update the global moviesTopPeople immediately so discovery sections can use it - moviesTopPeople = partialPeopleData; - - // Cache partial data - cache.set('movies_top_people', partialPeopleData, userId, 7 * 24 * 60 * 60 * 1000); - LOG(`Updated moviesTopPeople with partial data: ${partialPeopleData.actors.length} actors, ${partialPeopleData.directors.length} directors, ${partialPeopleData.writers.length} writers (${allMovies.length} movies processed)`); - } - - // If we got less than the limit, we've reached the end - if (movies.length < limit) { - hasMoreData = false; - isPeopleCacheComplete = true; - LOG('Reached end of movies, people cache is complete'); - } - } + return data; + } catch (err) { + ERR(`Failed to fetch item data for ${itemId}:`, err); + return null; } - - // Final processing of all data - LOG(`Final processing of ${allMovies.length} movies...`); - const finalPeopleData = processPeopleData(allMovies); - - if (finalPeopleData) { - // Mark as complete and remove movies count to save space - finalPeopleData.isComplete = true; - delete finalPeopleData.moviesProcessedCount; - - // Update global variable - moviesTopPeople = finalPeopleData; - - // Cache final data - cache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); - LOG(`Final cached people data: ${finalPeopleData.actors.length} actors, ${finalPeopleData.directors.length} directors, ${finalPeopleData.writers.length} writers`); - } - - return finalPeopleData; - - } catch (err) { - ERR('Failed to fetch top people data:', err); - return null; } -} -/** - * Processes people data from movies array - * @param {Array} movies - Array of movie objects with People data - * @returns {Object|null} - Processed people data or null if failed - */ -function processPeopleData(movies) { - try { - const peopleMap = new Map(); - - movies.forEach(movie => { - if (movie.People) { - movie.People.forEach(person => { - const key = person.Id; - if (!peopleMap.has(key)) { - peopleMap.set(key, { - id: person.Id, - name: person.Name, - directorCount: 0, - writerCount: 0, - actorCount: 0, - directorItemIds: [], - writerItemIds: [], - actorItemIds: [] - }); - } - - const personData = peopleMap.get(key); - if (person.Type === 'Director') { - personData.directorCount++; - if (!personData.directorItemIds.includes(movie.Id)) { - personData.directorItemIds.push(movie.Id); - } - } else if (person.Type === 'Writer') { - personData.writerCount++; - if (!personData.writerItemIds.includes(movie.Id)) { - personData.writerItemIds.push(movie.Id); - } - } else if (person.Type === 'Actor') { - personData.actorCount++; - if (!personData.actorItemIds.includes(movie.Id)) { - personData.actorItemIds.push(movie.Id); - } + /** + * 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: [] }; } - }); - - // Filter and sort people by count, taking top 100 per category - const allPeople = Array.from(peopleMap.values()); - - const topDirectors = allPeople - .filter(person => person.directorCount >= minPeopleAppearances) - .sort((a, b) => b.directorCount - a.directorCount) - .slice(0, 100) - .map(person => ({ - name: person.name, - count: person.directorCount, - itemIds: person.directorItemIds - })); - - const topWriters = allPeople - .filter(person => person.writerCount >= minPeopleAppearances) - .sort((a, b) => b.writerCount - a.writerCount) - .slice(0, 100) - .map(person => ({ - name: person.name, - count: person.writerCount, - itemIds: person.writerItemIds - })); - - const topActors = allPeople - .filter(person => person.actorCount >= minPeopleAppearances) - .sort((a, b) => b.actorCount - a.actorCount) - .slice(0, 100) - .map(person => ({ - name: person.name, - count: person.actorCount, - itemIds: person.actorItemIds - })); - - return { - actors: topActors, - directors: topDirectors, - writers: topWriters - }; - - } catch (err) { - ERR('Failed to process people data:', err); - return null; } -} - -/** - * Initializes the people cache in the background - */ -async function initializePeopleCache() { - // Prevent concurrent initialization - if (isInitializingPeopleCache) { - LOG('People cache initialization already in progress, skipping...'); - return; - } - - // Check if we already have complete data - if (moviesTopPeople !== null && isPeopleCacheComplete) { - LOG('People cache already loaded and complete'); - return; - } - - const cache = new window.LocalStorageCache(); - - // Check if we have valid cached data - if (cache.isCacheValid('movies_top_people')) { - const cachedData = cache.get('movies_top_people'); + /** + * Fetches collection data from Jellyfin API + * @param {string} collectionId - The collection ID to fetch + * @returns {Promise} - Collection data with Items array + */ + async function fetchCollectionData(collectionId) { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); - // Check if data is complete - if (cachedData.isComplete) { - moviesTopPeople = cachedData; - isPeopleCacheComplete = true; - LOG('Loaded complete top people data from cache'); + const url = `${serverUrl}/Users/${userId}/Items?ParentId=${collectionId}&Fields=ItemCounts%2CPrimaryImageAspectRatio%2CCanDelete%2CMediaSourceCount`; + + 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 collection data for ${collectionId}:`, err); + return { Items: [] }; + } + } + + /** + * Generates the view more URL for a playlist or collection + * @param {string} itemId - The item ID + * @param {string} itemType - The item type ('Playlist' or 'BoxSet') + * @returns {string} - The complete URL for the item page + */ + function generateViewMoreUrl(itemId, itemType) { + const apiClient = window.ApiClient; + const serverId = apiClient.serverId(); + + if (itemType === 'BoxSet') { + // For collections, use the collection page + return `${apiClient._serverAddress}/web/#/details?id=${itemId}&serverId=${serverId}`; + } else { + // For playlists, use the list page + return `#/list.html?parentId=${itemId}&serverId=${serverId}`; + } + } + + /** + * Renders a custom home section + * @param {Object} section - Section configuration object + * @param {HTMLElement} container - Container to append the section to + * @returns {boolean} - Success status + */ + async function renderCustomSection(section, container) { + try { + // First, fetch item data to determine type + const itemData = await fetchItemData(section.id); + if (!itemData) { + ERR(`Failed to fetch item data for section ${section.name}`); + return false; + } + + let itemIds = []; + let itemType = itemData.Type; + + // Handle different item types + if (itemType === 'Playlist') { + // Fetch playlist data + const playlistData = await fetchPlaylistData(section.id); + itemIds = playlistData.ItemIds || []; + } else if (itemType === 'BoxSet') { + // Fetch collection data + const collectionData = await fetchCollectionData(section.id); + itemIds = (collectionData.Items || []).map(item => item.Id); + } else { + ERR(`Unsupported item type ${itemType} for section ${section.name}`); + return false; + } + + // 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, itemType); + + // 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 ?? itemData.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); + scrollableContainer.setAttribute('data-custom-section-type', itemType); + + 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`); + } + + /** + * Renders trending movies section (stub for future implementation) + * @param {HTMLElement} container - Container to append the section to + * @returns {boolean} - Success status + */ + function renderTrendingMoviesSection(container) { + // TODO: Implement trending movies section + LOG('Trending Movies section not yet implemented'); + return false; + } + + /** + * Renders trending series section (stub for future implementation) + * @param {HTMLElement} container - Container to append the section to + * @returns {boolean} - Success status + */ + function renderTrendingSeriesSection(container) { + // TODO: Implement trending series section + LOG('Trending Series section not yet implemented'); + return false; + } + + /** + * Renders all new and trending sections + * @param {HTMLElement} container - Container to append sections to + */ + async function renderAllNewAndTrendingSections(container) { + if (!enableNewAndTrending) { + LOG('New and Trending sections disabled, skipping...'); return; - } else if (cachedData.moviesProcessedCount) { - // Use the stored count for resuming - const moviesProcessed = cachedData.moviesProcessedCount; - if (moviesProcessed > 0 && moviesProcessed % 500 === 0) { - // We have a complete set of 500-item chunks, continue loading - moviesTopPeople = cachedData; - isPeopleCacheComplete = false; - LOG(`Loaded partial top people data from cache (${moviesProcessed} movies), will continue fetching...`); - } else if (moviesProcessed > 0) { - // We have incomplete data, use what we have but don't continue loading - moviesTopPeople = cachedData; - isPeopleCacheComplete = true; - LOG(`Loaded incomplete top people data from cache (${moviesProcessed} movies), not divisible by 500, using as-is`); - return; - } else { - // We have partial data without count info, load it and continue fetching - moviesTopPeople = cachedData; - isPeopleCacheComplete = false; - LOG('Loaded partial top people data from cache, will continue fetching...'); - } - } else { - // No moviesProcessedCount stored, treat as incomplete and continue fetching - moviesTopPeople = cachedData; - isPeopleCacheComplete = false; - LOG('Loaded partial top people data from cache (no count stored), will continue fetching...'); - } - } - - // Set flag to prevent concurrent calls - isInitializingPeopleCache = true; - - try { - // Fetch/continue fetching data in the background - LOG('Fetching/continuing top people data in background...'); - const result = await fetchTopPeople(); - if (result) { - moviesTopPeople = result; - isPeopleCacheComplete = result.isComplete || false; - } - } finally { - // Always reset the flag - isInitializingPeopleCache = false; - } -} - -/** - * Navigates to the watchlist tab by clicking the appropriate tab button - */ -function gotoWatchlistTab() { - const visibleWatchlistTab = document.querySelector('.libraryPage:not(.hide) .pageTabContent:has(.watchlist.sections)'); - const visibleWatchlistTabIndex = visibleWatchlistTab ? visibleWatchlistTab.getAttribute('data-index') : null; - - if (visibleWatchlistTabIndex) { - const tabButton = document.querySelector(`.headerTabs .emby-tab-button[data-index="${visibleWatchlistTabIndex}"]`); - if (tabButton) { - tabButton.click(); - - const watchlistTabButton = document.querySelector('.libraryPage:not(.hide) .pageTabContent:has(.watchlist.sections) .watchlist-tabs button:not(.active)[data-tab="watchlist"]'); - if (watchlistTabButton) { - watchlistTabButton.click(); - } else { - WARN('Watchlist tab button not found'); - } - } else { - WARN('Watchlist tab button not found, navigating with page handler'); - const pageHandler = Emby?.Page; - - if (pageHandler && visibleWatchlistTabIndex) { - pageHandler.show(`/home.html?tab=${visibleWatchlistTabIndex}`); - return; - } - } - } else { - WARN('Watchlist tab not found'); - } -} - -/************ Discovery Section Helpers ************/ - -/** - * Fetches similar content for a movie or series - * @param {string} itemId - The item ID - * @param {string} type - 'Movie' or 'Series' - * @returns {Promise} - Array of similar items - */ -async function fetchSimilarContent(itemId, type) { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - const endpoint = type === 'Movie' ? 'Movies' : 'Shows'; - // Double the limit to account for filtering out played items - // Sadly Jellyfin API doesn't seem to support filtering out played items in the Similar endpoint - const url = `${serverUrl}/${endpoint}/${itemId}/Similar?limit=32`; - - 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(); - const allItems = data.Items || []; + const sectionsToRender = []; - // Filter out played items - const unplayedItems = allItems.filter(item => !item.UserData?.Played); - - // If we have enough unplayed items, randomly select 16 - if (unplayedItems.length >= 16) { - // Shuffle array and take first 16 - const shuffled = [...unplayedItems].sort(() => Math.random() - 0.5); - return shuffled.slice(0, 16); + // Add new movies section if enabled + if (enableNewMovies) { + sectionsToRender.push(renderNewMoviesSection(container)); } - // If we don't have enough unplayed items, return what we have - // (shuffled to avoid always showing the same items) - return [...unplayedItems].sort(() => Math.random() - 0.5); - - } catch (err) { - ERR(`Failed to fetch similar content for ${type} ${itemId}:`, err); - return []; - } -} - -/** - * Fetches items with IsFavorite filter - * @param {string} includeItemTypes - Item types to include (e.g., 'Movie,Series') - * @returns {Promise} - Array of favorite items - */ -async function fetchFavoriteItems(includeItemTypes = 'Movie,Series') { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - - const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=${includeItemTypes}&SortBy=DatePlayed&SortOrder=Descending&Recursive=true&Limit=16&Filters=IsFavorite`; - - try { - const response = await fetch(url, { - headers: { - "Authorization": `MediaBrowser Token="${token}"` - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + // Add new episodes section if enabled + if (enableNewEpisodes) { + sectionsToRender.push(renderNewEpisodesSection(container)); } - const data = await response.json(); - return data.Items || []; - } catch (err) { - ERR(`Failed to fetch favorite items:`, err); - return []; - } -} - -/** - * Fetches items by person (director or actor) - * @param {string} personId - The person ID - * @param {string} includeItemTypes - Item types to include (e.g., 'Movie') - * @returns {Promise} - Array of items by person - */ -async function fetchPersonItems(personId, includeItemTypes = 'Movie') { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - - const url = `${serverUrl}/Items?personIds=${personId}&IncludeItemTypes=${includeItemTypes}&Recursive=true&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1&limit=16`; - - try { - const response = await fetch(url, { - headers: { - "Authorization": `MediaBrowser Token="${token}"` - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + // Add trending sections if enabled (stubs for now) + if (enableTrending) { + sectionsToRender.push(renderTrendingMoviesSection(container)); + sectionsToRender.push(renderTrendingSeriesSection(container)); } - const data = await response.json(); - return data.Items || []; - } catch (err) { - ERR(`Failed to fetch items for person ${personId}:`, err); - return []; + if (sectionsToRender.length === 0) { + return; + } + + const results = await Promise.all(sectionsToRender); + const successCount = results.filter(Boolean).length; + LOG(`Rendered ${successCount}/${sectionsToRender.length} new and trending sections`); } -} -/** - * Gets random item from array - * @param {Array} items - Array of items - * @returns {Object|null} - Random item or null if array is empty - */ -function getRandomItem(items) { - if (!items || items.length === 0) return null; - return items[Math.floor(Math.random() * items.length)]; -} + /************ Seasonal Section Helpers ************/ -/** - * Shuffles an array and returns a new shuffled copy - * @param {Array} array - Array to shuffle - * @returns {Array} - New shuffled array - */ -function shuffleArray(array) { - if (!array || array.length === 0) return []; - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + /** + * Checks if current date is within Halloween period (Oct 1-31) + * @returns {boolean} - True if within Halloween period + */ + function isHalloweenPeriod() { + const now = new Date(); + const month = now.getMonth() + 1; // getMonth() returns 0-11 + const day = now.getDate(); + return month === 10 && day >= 1 && day <= 31; } - return shuffled; -} -/** - * Gets top N most frequent people from movie history - * @param {Array} movies - Array of movie objects - * @param {string} personType - 'Director' or 'Actor' - * @param {number} limit - Number of top people to return - * @returns {Array} - Array of person objects with counts - */ -function getTopPeople(movies, personType, limit = 25) { - const personCounts = {}; - - movies.forEach(movie => { - if (movie.People) { - movie.People.forEach(person => { - if (person.Type === personType) { - const key = person.Id; - if (!personCounts[key]) { - personCounts[key] = { - ...person, - count: 0 - }; - } - personCounts[key].count++; + /** + * Checks if current date is within Christmas period (Dec 1-30) + * @returns {boolean} - True if within Christmas period + */ + function isChristmasPeriod() { + const now = new Date(); + const month = now.getMonth() + 1; + const day = now.getDate(); + return month === 12 && day >= 1 && day <= 30; + } + + /** + * Checks if current date is within New Years period (Dec 31 + Jan 1) + * @returns {boolean} - True if within New Years period + */ + function isNewYearsPeriod() { + const now = new Date(); + const month = now.getMonth() + 1; + const day = now.getDate(); + return (month === 12 && day === 31) || (month === 1 && day === 1); + } + + /** + * Checks if current date is within Valentine's Day period (Feb 14) + * @returns {boolean} - True if within Valentine's Day period + */ + function isValentinesPeriod() { + const now = new Date(); + const month = now.getMonth() + 1; + const day = now.getDate(); + return month === 2 && day === 14; + } + + /** + * Fetches all movie genres from Jellyfin API and caches them + * @returns {Promise} - Array of genre objects with Name, ImageTags, and Id + */ + async function fetchAndCacheMovieGenres() { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + + try { + const response = await fetch(`${serverUrl}/Genres?IncludeItemTypes=Movie`, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` } }); - } - }); - - return Object.values(personCounts) - .sort((a, b) => b.count - a.count) - .slice(0, limit); -} - -/** - * Gets watchlist data from localStorageCache - * @returns {Array} - Combined watchlist items (movies + series) - */ -function getWatchlistData() { - if (typeof window.LocalStorageCache === 'undefined') { - WARN('LocalStorageCache not available'); - return []; - } - - const cache = new window.LocalStorageCache(); - const movies = cache.get('watchlist_movies') || []; - const series = cache.get('watchlist_series') || []; - - return [...movies, ...series].sort((a, b) => { - const dateA = new Date(a.PremiereDate || 0); - const dateB = new Date(b.PremiereDate || 0); - return dateB - dateA; - }); -} - -/** - * Gets movie history data from localStorageCache - * @returns {Array} - Array of watched movies - */ -function getMovieHistoryData() { - if (typeof window.LocalStorageCache === 'undefined') { - WARN('LocalStorageCache not available'); - return []; - } - - const cache = new window.LocalStorageCache(); - return cache.get('movies') || []; -} - -/** - * Gets series progress data from localStorageCache - * @returns {Array} - Array of series with progress data - */ -function getSeriesProgressData() { - if (typeof window.LocalStorageCache === 'undefined') { - WARN('LocalStorageCache not available'); - return []; - } - - const cache = new window.LocalStorageCache(); - return cache.getChunked('progress') || []; -} - -/** - * Gets random watched movie from history (last 25 by DatePlayed) - * @returns {Object|null} - Random watched movie or null - */ -function getRandomWatchedMovie() { - const movies = getMovieHistoryData(); - if (movies.length === 0) return null; - - // Sort by DatePlayed descending and take last 25 - const sortedMovies = movies - .filter(movie => movie.UserData && movie.UserData.Played) - .sort((a, b) => new Date(b.UserData.LastPlayedDate || 0) - new Date(a.UserData.LastPlayedDate || 0)) - .slice(0, 25); - - return getRandomItem(sortedMovies); -} - -/** - * Gets random watched series from progress (last 25 by lastWatchedEpisode date) - * @returns {Object|null} - Random watched series or null - */ -function getRandomWatchedSeries() { - const series = getSeriesProgressData(); - if (series.length === 0) return null; - - // Sort by lastWatchedEpisode date and take last 25 - const sortedSeries = series - .filter(s => s.lastWatchedEpisode && s.lastWatchedEpisode.UserData && s.lastWatchedEpisode.UserData.Played) - .sort((a, b) => new Date(b.lastWatchedEpisode.UserData.LastPlayedDate || 0) - new Date(a.lastWatchedEpisode.UserData.LastPlayedDate || 0)) - .slice(0, 25); - - const randomSeries = getRandomItem(sortedSeries); - - return randomSeries ? randomSeries.series : null; -} - -/** - * Gets random favorite movie - * @returns {Promise} - Random favorite movie or null - */ -async function getRandomFavoriteMovie() { - const favorites = await fetchFavoriteItems('Movie'); - return getRandomItem(favorites); -} - -/** - * Gets random favorite series - * @returns {Promise} - Random favorite series or null - */ -async function getRandomFavoriteSeries() { - const favorites = await fetchFavoriteItems('Series'); - return getRandomItem(favorites); -} - -/** - * Gets random director from top 25 most-watched movie directors - * @returns {Object|null} - Random director or null - */ -function getRandomDirector() { - const movies = getMovieHistoryData(); - if (movies.length === 0) return null; - - const topDirectors = getTopPeople(movies, 'Director', 25); - return getRandomItem(topDirectors); -} - -/** - * Gets random actor from top 25 most-watched movie actors - * @returns {Object|null} - Random actor or null - */ -function getRandomActor() { - const movies = getMovieHistoryData(); - if (movies.length === 0) return null; - - const topActors = getTopPeople(movies, 'Actor', 25); - return getRandomItem(topActors); -} - -/** - * Fetches newest movies from Jellyfin API - * @returns {Promise} - Array of newest movie items - */ -async function fetchNewMovies() { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - - const today = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD - const url = `${serverUrl}/Items?IncludeItemTypes=Movie&SortBy=PremiereDate&SortOrder=Descending&Limit=16&Fields=PremiereDate&Recursive=true&MaxPremiereDate=${today}&userId=${userId}`; - - try { - const response = await fetch(url, { - headers: { - 'X-Emby-Token': token + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + + const data = await response.json(); + const genres = data.Items || []; + + // Optimize genres to only include essential fields + const optimizedGenres = genres.map(genre => ({ + Id: genre.Id, + Name: genre.Name, + ImageTags: genre.ImageTags || {}, + MovieCount: genre.MovieCount + })); + + // Cache the optimized genres + const cache = new window.LocalStorageCache(); + cache.set('movieGenres', optimizedGenres); + LOG(`Cached ${optimizedGenres.length} movie genres`); + + return optimizedGenres; + } catch (err) { + ERR('Failed to fetch movie genres:', err); + return []; } - - const data = await response.json(); - return data.Items || []; - - } catch (err) { - ERR('Failed to fetch new movies:', err); - return []; } -} -/** - * Fetches newest episodes from the last 7 days - * Deduplicates episodes from the same series that aired on the same date, - * keeping only the episode with the lowest index number - * @returns {Promise} - Array of deduplicated newest episode items - */ -async function fetchNewEpisodes() { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - - // Compute ISO date range for the last 7 days - const now = new Date(); - const maxDate = now.toISOString(); - const minDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); - - const url = `${serverUrl}/Items?IncludeItemTypes=Episode` + - `&LocationTypes=FileSystem` + - `&Recursive=true` + - `&SortBy=PremiereDate&SortOrder=Descending` + - `&MinPremiereDate=${encodeURIComponent(minDate)}` + - `&MaxPremiereDate=${encodeURIComponent(maxDate)}` + - `&Limit=100` + - `&Fields=PremiereDate,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds,Path` + - `&userId=${userId}`; - - try { - const response = await fetch(url, { - headers: { 'X-Emby-Token': token } - }); + /** + * Gets a genre ID from cached movie genres + * @param {string} genreName - Name of the genre to find + * @returns {Promise} - Genre ID or null if not found + */ + async function getGenreId(genreName) { + const cache = new window.LocalStorageCache(); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + // Try to get from cache first + let movieGenres = cache.get('movieGenres'); + + // If not in cache or cache is invalid, fetch and cache + if (!movieGenres || movieGenres.length === 0) { + LOG('Movie genres not in cache, fetching...'); + movieGenres = await fetchAndCacheMovieGenres(); } - const data = await response.json(); - const episodes = data.Items || []; - - // Deduplicate episodes from the same series that aired on the same date - const deduplicatedEpisodes = deduplicateEpisodesBySeriesAndDate(episodes); - - LOG(`Fetched ${episodes.length} episodes, deduplicated to ${deduplicatedEpisodes.length} episodes`); - - return deduplicatedEpisodes; - - } catch (err) { - ERR('Failed to fetch new episodes:', err); - return []; + // Find the genre by name (case insensitive) + const genre = movieGenres.find(g => g.Name.toLowerCase() === genreName.toLowerCase()); + return genre ? genre.Id : null; } -} -/** - * Deduplicates episodes from the same series that aired on the same date - * Keeps only the episode with the lowest index number (first episode of season) - * @param {Array} episodes - Array of episode objects - * @returns {Array} - Deduplicated array of episodes - */ -function deduplicateEpisodesBySeriesAndDate(episodes) { - if (!episodes || episodes.length === 0) return []; - - // Group episodes by series name and premiere date - const groupedEpisodes = new Map(); - - episodes.forEach(episode => { - const seriesName = episode.SeriesName || 'Unknown Series'; - const premiereDate = episode.PremiereDate || ''; - const key = `${seriesName}|${premiereDate}`; + /** + * Fetches Halloween movie data from Jellyfin API + * @returns {Promise} - Object containing halloween, horror, and thriller movies + */ + async function fetchHalloweenMovieData() { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + const serverId = apiClient.serverId(); - if (!groupedEpisodes.has(key)) { - groupedEpisodes.set(key, []); - } + // No longer fetching people data for Halloween + const fieldsParam = ''; - groupedEpisodes.get(key).push(episode); - }); - - // For each group, keep only the episode with the lowest index number - const deduplicatedEpisodes = []; - - groupedEpisodes.forEach(episodeGroup => { - if (episodeGroup.length === 1) { - // Only one episode for this series/date, keep it - deduplicatedEpisodes.push(episodeGroup[0]); - } else { - // Multiple episodes for this series/date, find the one with lowest index number - const episodeWithLowestIndex = episodeGroup.reduce((lowest, current) => { - const currentIndex = current.IndexNumber || 999999; - const lowestIndex = lowest.IndexNumber || 999999; - return currentIndex < lowestIndex ? current : lowest; - }); - - deduplicatedEpisodes.push(episodeWithLowestIndex); - - LOG(`Deduplicated ${episodeGroup.length} episodes from "${episodeGroup[0].SeriesName}" on ${episodeGroup[0].PremiereDate}, kept episode ${episodeWithLowestIndex.IndexNumber}`); - } - }); - - return deduplicatedEpisodes; -} - -/** - * Renders New Movies section - * @param {HTMLElement} container - Container to append the section to - * @returns {Promise} - Success status - */ -async function renderNewMoviesSection(container) { - try { - const movies = await fetchNewMovies(); + // Fetch genre IDs for ViewMore URLs + const [horrorGenreId, thrillerGenreId] = await Promise.all([ + getGenreId('Horror'), + getGenreId('Thriller') + ]); - if (movies.length === 0) { - return false; - } - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping New Movies section"); - return false; - } - - // Render the scrollable container - const scrollableContainer = window.cardBuilder.renderCards( - movies, - 'New Movies', - null - ); - - // Add data attributes to track rendered sections - scrollableContainer.setAttribute('data-custom-section-id', 'new-movies'); - scrollableContainer.setAttribute('data-custom-section-name', 'New Movies'); - scrollableContainer.style.order = 30; - - // Append to container - container.appendChild(scrollableContainer); - - return true; - - } catch (err) { - ERR('Error rendering New Movies section:', err); - return false; - } -} - -/** - * Renders New Episodes section - * @param {HTMLElement} container - Container to append the section to - * @returns {Promise} - Success status - */ -async function renderNewEpisodesSection(container) { - try { - const episodes = await fetchNewEpisodes(); - - if (episodes.length === 0) { - return false; - } - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping New Episodes section"); - return false; - } - - // Render the scrollable container - const scrollableContainer = window.cardBuilder.renderCards( - episodes, - 'New Episodes', - null - ); - - // Add data attributes to track rendered sections - scrollableContainer.setAttribute('data-custom-section-id', 'new-episodes'); - scrollableContainer.setAttribute('data-custom-section-name', 'New Episodes'); - scrollableContainer.style.order = 31; - - // Append to container - container.appendChild(scrollableContainer); - - return true; - - } catch (err) { - ERR('Error rendering New Episodes section:', err); - return false; - } -} - -/** - * Renders watchlist section - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ -function renderWatchlistSection(container) { - try { - const watchlistItems = getWatchlistData(); - - if (watchlistItems.length === 0) { - return false; - } - - // Get 16 random items from watchlist - const shuffled = [...watchlistItems].sort(() => Math.random() - 0.5); - const limitedItems = shuffled.slice(0, 16); - - if (limitedItems.length === 0) { - return false; - } - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping watchlist section"); - return false; - } - - // Render the scrollable container with function-based navigation - const scrollableContainer = window.cardBuilder.renderCards( - limitedItems, - 'My Watchlist', - gotoWatchlistTab - ); - - // Add data attribute to track rendered sections - scrollableContainer.setAttribute('data-custom-section-id', 'watchlist'); - scrollableContainer.setAttribute('data-custom-section-name', 'Your Watchlist'); - scrollableContainer.style.order = 32; - - // Append to container - container.appendChild(scrollableContainer); - - return true; - - } catch (err) { - ERR('Error rendering watchlist section:', err); - return false; - } -} - -/** - * Fetches full item objects from API using item IDs - * @param {Array} itemIds - Array of item IDs to fetch - * @returns {Promise} Array of full item objects - */ -async function fetchItemsByIds(itemIds) { - try { - if (!itemIds || itemIds.length === 0) return []; - - const userId = ApiClient.getCurrentUserId(); - const serverId = ApiClient.serverId(); - - // Use the Items endpoint to fetch multiple items by IDs - const idsParam = itemIds.join(','); - const url = `${ApiClient.serverAddress()}/Items?Ids=${idsParam}&UserId=${userId}&Fields=MediaSources,MediaStreams,Chapters,People,Genres,Studios,Taglines&EnableTotalRecordCount=false&EnableImageTypes=Primary,Backdrop,Thumb,Logo`; - - const response = await ApiClient.fetch({ url, method: 'GET' }); - const data = await response.json(); - - return data.Items || []; - - } catch (err) { - ERR('Error fetching items by IDs:', err); - return []; - } -} - -/** - * Generates a single group of discovery sections (6 sections total) - * @returns {Promise} Array of section data objects - */ -async function generateDiscoveryGroup() { - try { - LOG('Generating single discovery group...'); - - // Run all async operations in parallel for maximum speed - const preloadPromises = [ - // 1. [Genre] Movies - (async () => { - const randomGenre = await getRandomGenre(); - if (randomGenre) { - const genreData = await preloadGenreSection(randomGenre); - if (genreData) { - return { - type: 'genre', - data: randomGenre, - items: genreData.items, - viewMoreUrl: genreData.viewMoreUrl - }; - } - } - return null; - })(), - - // 2. Directed by [Director] (async - fetch actual items) - (async () => { - if (moviesTopPeople && moviesTopPeople.directors.length > 0) { - const randomDirector = getRandomItem(moviesTopPeople.directors); - if (randomDirector && randomDirector.itemIds && randomDirector.itemIds.length > 0) { - // Validate that itemIds contain actual valid IDs (not undefined/null) - const validItemIds = randomDirector.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== ''); - if (validItemIds.length > 0) { - // Fetch actual items from API - const shuffledIds = shuffleArray(validItemIds).slice(0, 16); - const items = await fetchItemsByIds(shuffledIds); - - if (items && items.length > 0) { - return { - type: 'director', - data: randomDirector, - items: items - }; - } - } - } - } - return null; - })(), - - // 3. Written by [Writer] (async - fetch actual items) - (async () => { - if (moviesTopPeople && moviesTopPeople.writers.length > 0) { - const randomWriter = getRandomItem(moviesTopPeople.writers); - if (randomWriter && randomWriter.itemIds && randomWriter.itemIds.length > 0) { - // Validate that itemIds contain actual valid IDs (not undefined/null) - const validItemIds = randomWriter.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== ''); - if (validItemIds.length > 0) { - // Fetch actual items from API - const shuffledIds = shuffleArray(validItemIds).slice(0, 16); - const items = await fetchItemsByIds(shuffledIds); - - if (items && items.length > 0) { - return { - type: 'writer', - data: randomWriter, - items: items - }; - } - } - } - } - return null; - })(), - - // 4. Starring [Actor] (async - fetch actual items) - (async () => { - if (moviesTopPeople && moviesTopPeople.actors.length > 0) { - const randomActor = getRandomItem(moviesTopPeople.actors); - if (randomActor && randomActor.itemIds && randomActor.itemIds.length > 0) { - // Validate that itemIds contain actual valid IDs (not undefined/null) - const validItemIds = randomActor.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== ''); - if (validItemIds.length > 0) { - // Fetch actual items from API - const shuffledIds = shuffleArray(validItemIds).slice(0, 16); - const items = await fetchItemsByIds(shuffledIds); - - if (items && items.length > 0) { - return { - type: 'actor', - data: randomActor, - items: items - }; - } - } - } - } - return null; - })(), - - // 5. Because you watched [Movie] - (async () => { - const randomWatchedMovie = getRandomWatchedMovie(); - if (randomWatchedMovie) { - const watchedData = await preloadBecauseYouWatchedSection(randomWatchedMovie); - if (watchedData) { - return { - type: 'watched', - data: randomWatchedMovie, - items: watchedData.items - }; - } - } - return null; - })(), - - // 6. Because you liked [Movie] - (async () => { - const randomLikedMovie = await getRandomFavoriteMovie(); - if (randomLikedMovie) { - const likedData = await preloadBecauseYouLikedSection(randomLikedMovie); - if (likedData) { - return { - type: 'liked', - data: randomLikedMovie, - items: likedData.items - }; - } - } - return null; - })() + const queries = [ + { + name: 'halloween', + url: `${serverUrl}/Items?userId=${userId}&Tags=halloween&IncludeItemTypes=Movie&Recursive=true&Limit=200${fieldsParam}`, + viewMoreUrl: null + }, + { + name: 'horror', + url: `${serverUrl}/Items?userId=${userId}&Genres=Horror&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`, + viewMoreUrl: horrorGenreId ? `/web/#/list.html?genreId=${horrorGenreId}&serverId=${serverId}` : null + }, + { + name: 'thriller', + url: `${serverUrl}/Items?userId=${userId}&Genres=Thriller&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=200${fieldsParam}`, + viewMoreUrl: thrillerGenreId ? `/web/#/list.html?genreId=${thrillerGenreId}&serverId=${serverId}` : null + } ]; - // Wait for all parallel operations to complete - const preloadResults = await Promise.all(preloadPromises); - - // Filter out null results and collect valid sections - const sections = preloadResults.filter(section => section !== null); - - LOG(`Generated discovery group with ${sections.length} sections`); - return sections; - - } catch (err) { - ERR('Error generating discovery group:', err); - return []; + try { + const results = await Promise.all( + queries.map(async (query) => { + const response = await fetch(query.url, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return { + name: query.name, + items: data.Items || [] + }; + }) + ); + + // Process results + const halloweenItems = results.find(r => r.name === 'halloween')?.items || []; + const horrorItems = results.find(r => r.name === 'horror')?.items || []; + const thrillerItems = results.find(r => r.name === 'thriller')?.items || []; + + // Combine all items into halloweenMovies, removing duplicates by ID + const allItems = [...halloweenItems]; + const existingIds = new Set(halloweenItems.map(item => item.Id)); + + // Add horror items that aren't already in halloween + horrorItems.forEach(item => { + if (!existingIds.has(item.Id)) { + allItems.push(item); + existingIds.add(item.Id); + } + }); + + // Add thriller items that aren't already in halloween or horror + thrillerItems.forEach(item => { + if (!existingIds.has(item.Id)) { + allItems.push(item); + existingIds.add(item.Id); + } + }); + + return { + allHalloweenMovies: allItems, + halloweenMovies: halloweenItems, + horrorMovies: horrorItems, + thrillerMovies: thrillerItems, + viewMoreUrls: { + horror: queries.find(q => q.name === 'horror')?.viewMoreUrl || null, + thriller: queries.find(q => q.name === 'thriller')?.viewMoreUrl || null + } + }; + + } catch (err) { + ERR('Failed to fetch Halloween movie data:', err); + return { + allHalloweenMovies: [], + halloweenMovies: [], + horrorMovies: [], + thrillerMovies: [], + viewMoreUrls: { + horror: null, + thriller: null + } + }; + } } -} -/** - * Ensures the discovery buffer has 2 groups of discovery items - * Simple approach: always maintain 2 groups in buffer - */ -async function ensureDiscoveryBuffer() { - const targetBufferSize = 2; - - if (discoveryBuffer.length >= targetBufferSize) { - LOG(`Discovery buffer already has ${discoveryBuffer.length} groups (target: ${targetBufferSize})`); - return; - } - - LOG(`Ensuring discovery buffer has ${targetBufferSize} groups (current: ${discoveryBuffer.length})`); - - // Generate groups in parallel to fill the buffer - const groupsToGenerate = targetBufferSize - discoveryBuffer.length; - const generationPromises = []; - - for (let i = 0; i < groupsToGenerate; i++) { - generationPromises.push(generateDiscoveryGroup()); - } - - try { - const newGroups = await Promise.all(generationPromises); + + + /** + * Fetches and processes top people data from all movies using pagination + * @returns {Promise} - Top people data or null if failed + */ + async function fetchTopPeople() { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + const cache = new window.LocalStorageCache(); - // Add non-empty groups to buffer - newGroups.forEach(group => { - if (group && group.length > 0) { - discoveryBuffer.push(group); + try { + LOG('Starting paginated fetch of movies for people data processing...'); + + let allMovies = []; + let startIndex = 0; + const limit = 500; + let hasMoreData = true; + + // Load existing partial data if available + const existingData = cache.get('movies_top_people'); + if (existingData && !existingData.isComplete) { + const moviesProcessed = countMoviesFromPeopleData(existingData); + if (moviesProcessed > 0) { + startIndex = moviesProcessed; + LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`); + } + } + + // Fetch movies in chunks of 500 + while (hasMoreData) { + const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=Movie&Recursive=true&Fields=People&Limit=${limit}&StartIndex=${startIndex}`; + + LOG(`Fetching movies ${startIndex} to ${startIndex + limit - 1}...`); + + 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(); + const movies = data.Items || []; + + if (movies.length === 0) { + hasMoreData = false; + LOG('No more movies to fetch'); + } else { + allMovies = allMovies.concat(movies); + startIndex += movies.length; + + LOG(`Fetched ${movies.length} movies, total: ${allMovies.length}`); + + // Process and cache partial data after each chunk + const partialPeopleData = processPeopleData(allMovies); + if (partialPeopleData) { + // Mark as incomplete and store movie count for resuming + partialPeopleData.isComplete = movies.length < limit; // Complete if we got less than limit + partialPeopleData.moviesProcessedCount = allMovies.length; + + // Update the global moviesTopPeople immediately so discovery sections can use it + moviesTopPeople = partialPeopleData; + + // Cache partial data + cache.set('movies_top_people', partialPeopleData, userId, 7 * 24 * 60 * 60 * 1000); + LOG(`Updated moviesTopPeople with partial data: ${partialPeopleData.actors.length} actors, ${partialPeopleData.directors.length} directors, ${partialPeopleData.writers.length} writers (${allMovies.length} movies processed)`); + } + + // If we got less than the limit, we've reached the end + if (movies.length < limit) { + hasMoreData = false; + isPeopleCacheComplete = true; + LOG('Reached end of movies, people cache is complete'); + } + } + } + + // Final processing of all data + LOG(`Final processing of ${allMovies.length} movies...`); + const finalPeopleData = processPeopleData(allMovies); + + if (finalPeopleData) { + // Mark as complete and remove movies count to save space + finalPeopleData.isComplete = true; + delete finalPeopleData.moviesProcessedCount; + + // Update global variable + moviesTopPeople = finalPeopleData; + + // Cache final data + cache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); + LOG(`Final cached people data: ${finalPeopleData.actors.length} actors, ${finalPeopleData.directors.length} directors, ${finalPeopleData.writers.length} writers`); + } + + return finalPeopleData; + + } catch (err) { + ERR('Failed to fetch top people data:', err); + return null; + } + } + + /** + * Processes people data from movies array + * @param {Array} movies - Array of movie objects with People data + * @returns {Object|null} - Processed people data or null if failed + */ + function processPeopleData(movies) { + try { + const peopleMap = new Map(); + + movies.forEach(movie => { + if (movie.People) { + movie.People.forEach(person => { + const key = person.Id; + if (!peopleMap.has(key)) { + peopleMap.set(key, { + id: person.Id, + name: person.Name, + directorCount: 0, + writerCount: 0, + actorCount: 0, + directorItemIds: [], + writerItemIds: [], + actorItemIds: [] + }); + } + + const personData = peopleMap.get(key); + if (person.Type === 'Director') { + personData.directorCount++; + if (!personData.directorItemIds.includes(movie.Id)) { + personData.directorItemIds.push(movie.Id); + } + } else if (person.Type === 'Writer') { + personData.writerCount++; + if (!personData.writerItemIds.includes(movie.Id)) { + personData.writerItemIds.push(movie.Id); + } + } else if (person.Type === 'Actor') { + personData.actorCount++; + if (!personData.actorItemIds.includes(movie.Id)) { + personData.actorItemIds.push(movie.Id); + } + } + }); } }); - LOG(`Added ${newGroups.filter(g => g && g.length > 0).length} groups to discovery buffer (total: ${discoveryBuffer.length})`); - - // If we couldn't generate any new groups, mark that additional content is not available - if (newGroups.every(group => !group || group.length === 0)) { - additionalDiscoveryContent = false; - LOG('No additional discovery content available'); + // Filter and sort people by count, taking top 100 per category + const allPeople = Array.from(peopleMap.values()); + + const topDirectors = allPeople + .filter(person => person.directorCount >= minPeopleAppearances) + .sort((a, b) => b.directorCount - a.directorCount) + .slice(0, 100) + .map(person => ({ + name: person.name, + count: person.directorCount, + itemIds: person.directorItemIds + })); + + const topWriters = allPeople + .filter(person => person.writerCount >= minPeopleAppearances) + .sort((a, b) => b.writerCount - a.writerCount) + .slice(0, 100) + .map(person => ({ + name: person.name, + count: person.writerCount, + itemIds: person.writerItemIds + })); + + const topActors = allPeople + .filter(person => person.actorCount >= minPeopleAppearances) + .sort((a, b) => b.actorCount - a.actorCount) + .slice(0, 100) + .map(person => ({ + name: person.name, + count: person.actorCount, + itemIds: person.actorItemIds + })); + + return { + actors: topActors, + directors: topDirectors, + writers: topWriters + }; + + } catch (err) { + ERR('Failed to process people data:', err); + return null; + } + } + + + /** + * Initializes the people cache in the background + */ + async function initializePeopleCache() { + // Prevent concurrent initialization + if (isInitializingPeopleCache) { + LOG('People cache initialization already in progress, skipping...'); + return; } - } catch (err) { - ERR('Error ensuring discovery buffer:', err); - additionalDiscoveryContent = false; + // Check if we already have complete data + if (moviesTopPeople !== null && isPeopleCacheComplete) { + LOG('People cache already loaded and complete'); + return; + } + + const cache = new window.LocalStorageCache(); + + // Check if we have valid cached data + if (cache.isCacheValid('movies_top_people')) { + const cachedData = cache.get('movies_top_people'); + + // Check if data is complete + if (cachedData.isComplete) { + moviesTopPeople = cachedData; + isPeopleCacheComplete = true; + LOG('Loaded complete top people data from cache'); + return; + } else if (cachedData.moviesProcessedCount) { + // Use the stored count for resuming + const moviesProcessed = cachedData.moviesProcessedCount; + if (moviesProcessed > 0 && moviesProcessed % 500 === 0) { + // We have a complete set of 500-item chunks, continue loading + moviesTopPeople = cachedData; + isPeopleCacheComplete = false; + LOG(`Loaded partial top people data from cache (${moviesProcessed} movies), will continue fetching...`); + } else if (moviesProcessed > 0) { + // We have incomplete data, use what we have but don't continue loading + moviesTopPeople = cachedData; + isPeopleCacheComplete = true; + LOG(`Loaded incomplete top people data from cache (${moviesProcessed} movies), not divisible by 500, using as-is`); + return; + } else { + // We have partial data without count info, load it and continue fetching + moviesTopPeople = cachedData; + isPeopleCacheComplete = false; + LOG('Loaded partial top people data from cache, will continue fetching...'); + } + } else { + // No moviesProcessedCount stored, treat as incomplete and continue fetching + moviesTopPeople = cachedData; + isPeopleCacheComplete = false; + LOG('Loaded partial top people data from cache (no count stored), will continue fetching...'); + } + } + + // Set flag to prevent concurrent calls + isInitializingPeopleCache = true; + + try { + // Fetch/continue fetching data in the background + LOG('Fetching/continuing top people data in background...'); + const result = await fetchTopPeople(); + if (result) { + moviesTopPeople = result; + isPeopleCacheComplete = result.isComplete || false; + } + } finally { + // Always reset the flag + isInitializingPeopleCache = false; + } } -} -/** - * Preloads the next batch of discovery sections in the background - * Uses the new buffer-based system for better performance - */ -async function preloadNextSections() { - if (isPreloadingSections) { - LOG('Preloading already in progress, skipping...'); - return; - } - - isPreloadingSections = true; - - try { - LOG('Preloading discovery sections using buffer system...'); - - // Ensure buffer has 2 groups (simple constant buffer) - await ensureDiscoveryBuffer(); - - LOG(`Discovery buffer populated with ${discoveryBuffer.length} groups`); - - } catch (err) { - ERR('Error preloading discovery sections:', err); - } finally { - isPreloadingSections = false; - } -} + /** + * Navigates to the watchlist tab by clicking the appropriate tab button + */ + function getWatchlistUrl() { + let homePageSuffix = '.html'; + if (ApiClient._serverInfo.Version?.split('.')[1] > 10) { + homePageSuffix = ''; + } + const visibleWatchlistTab = document.querySelector('.libraryPage:not(.hide) .pageTabContent:has(.watchlist.sections)'); + const visibleWatchlistTabIndex = visibleWatchlistTab ? visibleWatchlistTab.getAttribute('data-index') : null; -/** - * Renders the next group of discovery sections from the buffer - * This is the unified function used by both infinite scroll and load more button - * @param {HTMLElement} container - Container to render sections into - * @param {HTMLElement} loadMoreButton - Optional load more button to update state - * @returns {Promise} - Success status - */ -async function renderNextDiscoveryGroup(container, loadMoreButton = null) { - if (isRenderingDiscoveryGroup) { - LOG('Discovery group rendering already in progress, skipping...'); - return false; + if (visibleWatchlistTabIndex) { + return `#/home${homePageSuffix}?tab=${visibleWatchlistTabIndex}`; + } else { + WARN('Watchlist tab not found'); + return null; + } } - - isRenderingDiscoveryGroup = true; - - try { - LOG('Rendering next discovery group from buffer'); + + /************ Discovery Section Helpers ************/ + + /** + * Fetches similar content for a movie or series + * @param {string} itemId - The item ID + * @param {string} type - 'Movie' or 'Series' + * @returns {Promise} - Array of similar items + */ + async function fetchSimilarContent(itemId, type) { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); - // Show loading indicator - showDiscoveryLoadingIndicator(); + const endpoint = type === 'Movie' ? 'Movies' : 'Shows'; + // Double the limit to account for filtering out played items + // Sadly Jellyfin API doesn't seem to support filtering out played items in the Similar endpoint + const url = `${serverUrl}/${endpoint}/${itemId}/Similar?limit=32`; + + 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(); + const allItems = data.Items || []; + + // Filter out played items + const unplayedItems = allItems.filter(item => !item.UserData?.Played); + + // If we have enough unplayed items, randomly select 16 + if (unplayedItems.length >= 16) { + // Shuffle array and take first 16 + const shuffled = [...unplayedItems].sort(() => Math.random() - 0.5); + return shuffled.slice(0, 16); + } + + // If we don't have enough unplayed items, return what we have + // (shuffled to avoid always showing the same items) + return [...unplayedItems].sort(() => Math.random() - 0.5); + + } catch (err) { + ERR(`Failed to fetch similar content for ${type} ${itemId}:`, err); + return []; + } + } + + /** + * Fetches items with IsFavorite filter + * @param {string} includeItemTypes - Item types to include (e.g., 'Movie,Series') + * @returns {Promise} - Array of favorite items + */ + async function fetchFavoriteItems(includeItemTypes = 'Movie,Series') { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + const url = `${serverUrl}/Items?userId=${userId}&IncludeItemTypes=${includeItemTypes}&SortBy=DatePlayed&SortOrder=Descending&Recursive=true&Limit=16&Filters=IsFavorite`; + + 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.Items || []; + } catch (err) { + ERR(`Failed to fetch favorite items:`, err); + return []; + } + } + + /** + * Gets random item from array + * @param {Array} items - Array of items + * @param {Set} trackingSet - Optional set to track and filter out already rendered items + * @returns {Object|null} - Random item or null if array is empty or all items are already rendered + */ + function getRandomItem(items, trackingSet = null) { + if (!items || items.length === 0) return null; + + // If tracking set provided, filter out already rendered items + let availableItems = items; + if (trackingSet) { + availableItems = items.filter(item => { + const name = item.Name || item.name; + return name && !trackingSet.has(name); + }); + } + + // Check if we have any available items + if (availableItems.length === 0) return null; + + // Get random item from available items + const randomItem = availableItems[Math.floor(Math.random() * availableItems.length)]; + + // Track the item if tracking set provided + if (trackingSet && randomItem) { + const name = randomItem.Name || randomItem.name; + if (name) { + trackingSet.add(name); + } + } + + return randomItem; + } + + /** + * Shuffles an array and returns a new shuffled copy + * @param {Array} array - Array to shuffle + * @returns {Array} - New shuffled array + */ + function shuffleArray(array) { + if (!array || array.length === 0) return []; + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; + } + + /** + * Gets top N most frequent people from movie history + * @param {Array} movies - Array of movie objects + * @param {string} personType - 'Director' or 'Actor' + * @param {number} limit - Number of top people to return + * @returns {Array} - Array of person objects with counts + */ + function getTopPeople(movies, personType, limit = 25) { + const personCounts = {}; + + movies.forEach(movie => { + if (movie.People) { + movie.People.forEach(person => { + if (person.Type === personType) { + const key = person.Id; + if (!personCounts[key]) { + personCounts[key] = { + ...person, + count: 0 + }; + } + personCounts[key].count++; + } + }); + } + }); + + return Object.values(personCounts) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + } + + /** + * Gets watchlist data from localStorageCache + * @returns {Array} - Combined watchlist items (movies + series) + */ + function getWatchlistData() { + if (typeof window.LocalStorageCache === 'undefined') { + WARN('LocalStorageCache not available'); + return []; + } + + const cache = new window.LocalStorageCache(); + const movies = cache.get('watchlist_movies') || []; + const series = cache.get('watchlist_series') || []; + const seasons = cache.get('watchlist_seasons') || []; + const episodes = cache.get('watchlist_episodes') || []; + + return [...movies, ...series, ...seasons, ...episodes].sort((a, b) => { + const dateA = new Date(a.PremiereDate || 0); + const dateB = new Date(b.PremiereDate || 0); + return dateB - dateA; + }); + } + + /** + * Gets movie history data from localStorageCache + * @returns {Array} - Array of watched movies + */ + function getMovieHistoryData() { + if (typeof window.LocalStorageCache === 'undefined') { + WARN('LocalStorageCache not available'); + return []; + } + + const cache = new window.LocalStorageCache(); + return cache.get('movies') || []; + } + + /** + * Gets series progress data from localStorageCache + * @returns {Array} - Array of series with progress data + */ + function getSeriesProgressData() { + if (typeof window.LocalStorageCache === 'undefined') { + WARN('LocalStorageCache not available'); + return []; + } + + const cache = new window.LocalStorageCache(); + return cache.getChunked('progress') || []; + } + + /** + * Gets random watched movie from history (last 25 by DatePlayed) + * @returns {Object|null} - Random watched movie or null + */ + function getRandomWatchedMovie() { + const movies = getMovieHistoryData(); + if (movies.length === 0) return null; + + // Sort by DatePlayed descending and take last 25 + const sortedMovies = movies + .filter(movie => movie.UserData && movie.UserData.Played) + .sort((a, b) => new Date(b.UserData.LastPlayedDate || 0) - new Date(a.UserData.LastPlayedDate || 0)) + .slice(0, 25); + + // Filter out already rendered movies + const availableMovies = sortedMovies.filter(movie => + movie.Id && !renderedWatchedMovies.has(movie.Id) + ); + + if (availableMovies.length === 0) return null; + + const randomMovie = getRandomItem(availableMovies); + + // Track rendered movie + if (randomMovie && randomMovie.Id) { + renderedWatchedMovies.add(randomMovie.Id); + } + + return randomMovie; + } + + /** + * Gets random watched series from progress (last 25 by lastWatchedEpisode date) + * @returns {Object|null} - Random watched series or null + */ + function getRandomWatchedSeries() { + const series = getSeriesProgressData(); + if (series.length === 0) return null; + + // Sort by lastWatchedEpisode date and take last 25 + const sortedSeries = series + .filter(s => s.lastWatchedEpisode && s.lastWatchedEpisode.UserData && s.lastWatchedEpisode.UserData.Played) + .sort((a, b) => new Date(b.lastWatchedEpisode.UserData.LastPlayedDate || 0) - new Date(a.lastWatchedEpisode.UserData.LastPlayedDate || 0)) + .slice(0, 25); + + const randomSeries = getRandomItem(sortedSeries); + + return randomSeries ? randomSeries.series : null; + } + + /** + * Gets random favorite movie + * @returns {Promise} - Random favorite movie or null + */ + async function getRandomFavoriteMovie() { + const favorites = await fetchFavoriteItems('Movie'); + if (!favorites || favorites.length === 0) return null; + + // Filter out already rendered movies + const availableMovies = favorites.filter(movie => + movie.Id && !renderedFavoriteMovies.has(movie.Id) + ); + + if (availableMovies.length === 0) return null; + + const randomMovie = getRandomItem(availableMovies); + + // Track rendered movie + if (randomMovie && randomMovie.Id) { + renderedFavoriteMovies.add(randomMovie.Id); + } + + return randomMovie; + } + + /** + * Gets random favorite series + * @returns {Promise} - Random favorite series or null + */ + async function getRandomFavoriteSeries() { + const favorites = await fetchFavoriteItems('Series'); + return getRandomItem(favorites); + } + + /** + * Fetches newest movies from Jellyfin API + * @returns {Promise} - Array of newest movie items + */ + async function fetchNewMovies() { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + const today = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD + const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // Format: YYYY-MM-DD + const url = `${serverUrl}/Items?IncludeItemTypes=Movie&SortBy=DateCreated&SortOrder=Descending&Limit=16&Fields=PremiereDate&Recursive=true&MinPremiereDate=${monthAgo}&MaxPremiereDate=${today}&userId=${userId}`; + + try { + const response = await fetch(url, { + headers: { + 'X-Emby-Token': token + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return data.Items || []; + + } catch (err) { + ERR('Failed to fetch new movies:', err); + return []; + } + } + + /** + * Fetches newest episodes from the last 7 days + * Deduplicates episodes from the same series that aired on the same date, + * keeping only the episode with the lowest index number + * @returns {Promise} - Array of deduplicated newest episode items + */ + async function fetchNewEpisodes() { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + + // Compute ISO date range for the last 7 days + const now = new Date(); + const maxDate = now.toISOString(); + const minDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + + const url = `${serverUrl}/Items?IncludeItemTypes=Episode` + + `&LocationTypes=FileSystem` + + `&Recursive=true` + + `&SortBy=PremiereDate&SortOrder=Descending` + + `&MinPremiereDate=${encodeURIComponent(minDate)}` + + `&MaxPremiereDate=${encodeURIComponent(maxDate)}` + + `&Limit=100` + + `&Fields=PremiereDate,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds,Path` + + `&userId=${userId}`; + + try { + const response = await fetch(url, { + headers: { 'X-Emby-Token': token } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const episodes = data.Items || []; + + // Deduplicate episodes from the same series that aired on the same date + const deduplicatedEpisodes = deduplicateEpisodesBySeriesAndDate(episodes); + + LOG(`Fetched ${episodes.length} episodes, deduplicated to ${deduplicatedEpisodes.length} episodes`); + + return deduplicatedEpisodes; + + } catch (err) { + ERR('Failed to fetch new episodes:', err); + return []; + } + } + + /** + * Deduplicates episodes from the same series that aired on the same date + * Keeps only the episode with the lowest index number (first episode of season) + * @param {Array} episodes - Array of episode objects + * @returns {Array} - Deduplicated array of episodes + */ + function deduplicateEpisodesBySeriesAndDate(episodes) { + if (!episodes || episodes.length === 0) return []; + + // Group episodes by series name and premiere date + const groupedEpisodes = new Map(); + + episodes.forEach(episode => { + const seriesName = episode.SeriesName || 'Unknown Series'; + const premiereDate = episode.PremiereDate || ''; + const key = `${seriesName}|${premiereDate}`; + + if (!groupedEpisodes.has(key)) { + groupedEpisodes.set(key, []); + } + + groupedEpisodes.get(key).push(episode); + }); + + // For each group, keep only the episode with the lowest index number + const deduplicatedEpisodes = []; + + groupedEpisodes.forEach(episodeGroup => { + if (episodeGroup.length === 1) { + // Only one episode for this series/date, keep it + deduplicatedEpisodes.push(episodeGroup[0]); + } else { + // Multiple episodes for this series/date, find the one with lowest index number + const episodeWithLowestIndex = episodeGroup.reduce((lowest, current) => { + const currentIndex = current.IndexNumber || 999999; + const lowestIndex = lowest.IndexNumber || 999999; + return currentIndex < lowestIndex ? current : lowest; + }); + + deduplicatedEpisodes.push(episodeWithLowestIndex); + + LOG(`Deduplicated ${episodeGroup.length} episodes from "${episodeGroup[0].SeriesName}" on ${episodeGroup[0].PremiereDate}, kept episode ${episodeWithLowestIndex.IndexNumber}`); + } + }); + + return deduplicatedEpisodes; + } + + /** + * Renders New Movies section + * @param {HTMLElement} container - Container to append the section to + * @returns {Promise} - Success status + */ + async function renderNewMoviesSection(container) { + try { + const movies = await fetchNewMovies(); + + if (movies.length === 0) { + return false; + } + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping New Movies section"); + return false; + } + + // Render the scrollable container + const scrollableContainer = window.cardBuilder.renderCards( + movies, + 'New Movies', + null + ); + + // Add data attributes to track rendered sections + scrollableContainer.setAttribute('data-custom-section-id', 'new-movies'); + scrollableContainer.setAttribute('data-custom-section-name', 'New Movies'); + scrollableContainer.style.order = 30; + + // Append to container + container.appendChild(scrollableContainer); + + return true; + + } catch (err) { + ERR('Error rendering New Movies section:', err); + return false; + } + } + + /** + * Renders New Episodes section + * @param {HTMLElement} container - Container to append the section to + * @returns {Promise} - Success status + */ + async function renderNewEpisodesSection(container) { + try { + const episodes = await fetchNewEpisodes(); + + if (episodes.length === 0) { + return false; + } + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping New Episodes section"); + return false; + } + + // Render the scrollable container + const scrollableContainer = window.cardBuilder.renderCards( + episodes, + 'New Episodes', + null + ); + + // Add data attributes to track rendered sections + scrollableContainer.setAttribute('data-custom-section-id', 'new-episodes'); + scrollableContainer.setAttribute('data-custom-section-name', 'New Episodes'); + scrollableContainer.style.order = 31; + + // Append to container + container.appendChild(scrollableContainer); + + return true; + + } catch (err) { + ERR('Error rendering New Episodes section:', err); + return false; + } + } + + /** + * Renders watchlist section + * @param {HTMLElement} container - Container to append the section to + * @returns {boolean} - Success status + */ + function renderWatchlistSection(container) { + try { + const watchlistItems = getWatchlistData(); + + if (watchlistItems.length === 0) { + return false; + } + + // Get 16 random items from watchlist + const shuffled = [...watchlistItems].sort(() => Math.random() - 0.5); + const limitedItems = shuffled.slice(0, 16); + + if (limitedItems.length === 0) { + return false; + } + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping watchlist section"); + return false; + } + + // Render the scrollable container with function-based navigation + const scrollableContainer = window.cardBuilder.renderCards( + limitedItems, + 'My Watchlist', + getWatchlistUrl(), + true, + 'portrait' + ); + + // Add data attribute to track rendered sections + scrollableContainer.setAttribute('data-custom-section-id', 'watchlist'); + scrollableContainer.setAttribute('data-custom-section-name', 'Your Watchlist'); + scrollableContainer.style.order = 32; + + // Append to container + container.appendChild(scrollableContainer); + + return true; + + } catch (err) { + ERR('Error rendering watchlist section:', err); + return false; + } + } + + /** + * Fetches full item objects from API using item IDs + * @param {Array} itemIds - Array of item IDs to fetch + * @returns {Promise} Array of full item objects + */ + async function fetchItemsByIds(itemIds) { + try { + if (!itemIds || itemIds.length === 0) return []; + + const userId = ApiClient.getCurrentUserId(); + const serverId = ApiClient.serverId(); + + // Use the Items endpoint to fetch multiple items by IDs + const idsParam = itemIds.join(','); + const url = `${ApiClient.serverAddress()}/Items?Ids=${idsParam}&UserId=${userId}&Fields=MediaSources,MediaStreams,Chapters,People,Genres,Studios,Taglines&EnableTotalRecordCount=false&EnableImageTypes=Primary,Backdrop,Thumb,Logo`; + + const response = await ApiClient.fetch({ url, method: 'GET' }); + const data = await response.json(); + + return data.Items || []; + + } catch (err) { + ERR('Error fetching items by IDs:', err); + return []; + } + } + + /** + * Generates a single group of discovery sections (6 sections total) + * @returns {Promise} Array of section data objects + */ + async function generateDiscoveryGroup() { + try { + LOG('Generating single discovery group...'); + + // Run all async operations in parallel for maximum speed + const preloadPromises = [ + // 1. [Genre] Movies + (async () => { + const randomGenre = await getRandomGenre(); + if (randomGenre) { + const genreData = await preloadGenreSection(randomGenre); + if (genreData) { + return { + type: 'genre', + data: randomGenre, + items: genreData.items, + viewMoreUrl: genreData.viewMoreUrl + }; + } + } + return null; + })(), + + // 2. Directed by [Director] (async - fetch actual items) + (async () => { + if (moviesTopPeople && moviesTopPeople.directors.length > 0) { + const randomDirector = getRandomItem(moviesTopPeople.directors, renderedDirectors); + if (randomDirector && randomDirector.itemIds && randomDirector.itemIds.length > 0) { + // Validate that itemIds contain actual valid IDs (not undefined/null) + const validItemIds = randomDirector.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== ''); + if (validItemIds.length > 0) { + // Fetch actual items from API + const shuffledIds = shuffleArray(validItemIds).slice(0, 16); + const items = await fetchItemsByIds(shuffledIds); + + if (items && items.length > 0) { + return { + type: 'director', + data: randomDirector, + items: items + }; + } + } + } + } + return null; + })(), + + // 3. Written by [Writer] (async - fetch actual items) + (async () => { + if (moviesTopPeople && moviesTopPeople.writers.length > 0) { + const randomWriter = getRandomItem(moviesTopPeople.writers, renderedWriters); + if (randomWriter && randomWriter.itemIds && randomWriter.itemIds.length > 0) { + // Validate that itemIds contain actual valid IDs (not undefined/null) + const validItemIds = randomWriter.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== ''); + if (validItemIds.length > 0) { + // Fetch actual items from API + const shuffledIds = shuffleArray(validItemIds).slice(0, 16); + const items = await fetchItemsByIds(shuffledIds); + + if (items && items.length > 0) { + return { + type: 'writer', + data: randomWriter, + items: items + }; + } + } + } + } + return null; + })(), + + // 4. Starring [Actor] (async - fetch actual items) + (async () => { + if (moviesTopPeople && moviesTopPeople.actors.length > 0) { + const randomActor = getRandomItem(moviesTopPeople.actors, renderedActors); + if (randomActor && randomActor.itemIds && randomActor.itemIds.length > 0) { + // Validate that itemIds contain actual valid IDs (not undefined/null) + const validItemIds = randomActor.itemIds.filter(id => id && typeof id === 'string' && id.trim() !== ''); + if (validItemIds.length > 0) { + // Fetch actual items from API + const shuffledIds = shuffleArray(validItemIds).slice(0, 16); + const items = await fetchItemsByIds(shuffledIds); + + if (items && items.length > 0) { + return { + type: 'actor', + data: randomActor, + items: items + }; + } + } + } + } + return null; + })(), + + // 5. Because you watched [Movie] + (async () => { + const randomWatchedMovie = getRandomWatchedMovie(); + if (randomWatchedMovie) { + const watchedData = await preloadBecauseYouWatchedSection(randomWatchedMovie); + if (watchedData) { + return { + type: 'watched', + data: randomWatchedMovie, + items: watchedData.items + }; + } + } + return null; + })(), + + // 6. Because you liked [Movie] + (async () => { + const randomLikedMovie = await getRandomFavoriteMovie(); + if (randomLikedMovie) { + const likedData = await preloadBecauseYouLikedSection(randomLikedMovie); + if (likedData) { + return { + type: 'liked', + data: randomLikedMovie, + items: likedData.items + }; + } + } + return null; + })() + ]; + + // Wait for all parallel operations to complete + const preloadResults = await Promise.all(preloadPromises); + + // Filter out null results and collect valid sections + const sections = preloadResults.filter(section => section !== null); + + LOG(`Generated discovery group with ${sections.length} sections`); + return sections; + + } catch (err) { + ERR('Error generating discovery group:', err); + return []; + } + } + + /** + * Ensures the discovery buffer has 2 groups of discovery items + * Simple approach: always maintain 2 groups in buffer + */ + async function ensureDiscoveryBuffer() { + const targetBufferSize = 2; + + if (discoveryBuffer.length >= targetBufferSize) { + LOG(`Discovery buffer already has ${discoveryBuffer.length} groups (target: ${targetBufferSize})`); + // Manage UI state based on buffer content + manageDiscoveryUIState().catch(err => ERR('Error managing UI state:', err)); + return; + } + + // Don't block recursive calls - only block if actively generating a group + if (isLoadingDiscoveryGroup) { + LOG('Discovery group already loading, waiting...'); + return; + } + + isLoadingDiscoveryGroup = true; + + LOG(`Generating next discovery group (current: ${discoveryBuffer.length}/${targetBufferSize})`); + + try { + // Generate one group at a time + const newGroup = await generateDiscoveryGroup(); + + // Add non-empty group to buffer + if (newGroup && newGroup.length > 0) { + discoveryBuffer.push(newGroup); + LOG(`Added group to discovery buffer (total: ${discoveryBuffer.length})`); + + // Mark that additional content is available + additionalDiscoveryContent = true; + + // Update UI state after adding first group (enables handlers earlier) + manageDiscoveryUIState().catch(err => ERR('Error managing UI state:', err)); + + // Recursively call to get more groups if needed + if (discoveryBuffer.length < targetBufferSize) { + isLoadingDiscoveryGroup = false; // Allow recursive call + await ensureDiscoveryBuffer(); + } + } else { + // Couldn't generate any new group, mark that additional content is not available + additionalDiscoveryContent = false; + LOG('No additional discovery content available'); + manageDiscoveryUIState().catch(err => ERR('Error managing UI state:', err)); + } + } catch (err) { + ERR('Error ensuring discovery buffer:', err); + additionalDiscoveryContent = false; + manageDiscoveryUIState().catch(e => ERR('Error managing UI state:', e)); + } finally { + isLoadingDiscoveryGroup = false; + } + } + + /** + * Manages the UI state (handlers and button visibility) based on buffer content + */ + async function manageDiscoveryUIState() { + const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); - // Check if we have a group in the buffer to render if (discoveryBuffer.length > 0) { + // Buffer has content - set up handlers + if (homeSectionsContainer) { + setupInfiniteLoading(homeSectionsContainer); + + // Pre-render first group to prepare for smooth reveal + if (hiddenDiscoverySections.length === 0) { + const firstGroup = discoveryBuffer[0]; + if (firstGroup) { + LOG('Pre-rendering first discovery group for smooth reveal'); + preRenderDiscoveryGroup(firstGroup).then(preRendered => { + hiddenDiscoverySections.push(...preRendered); + LOG(`Pre-rendered first group: ${preRendered.length} sections ready`); + }).catch(err => { + ERR('Error pre-rendering first group:', err); + }); + } + } + } + LOG(`UI state updated: handlers enabled (buffer has ${discoveryBuffer.length} groups)`); + } else { + // Buffer empty - remove handlers and hide button + removeScrollBasedLoading(); + const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); + if (loadMoreButton) { + loadMoreButton.style.display = 'none'; + } + LOG('UI state updated: handlers removed, button hidden (buffer empty)'); + } + } + + /** + * Preloads the next batch of discovery sections in the background + * Uses the new buffer-based system for better performance + */ + async function preloadNextSections() { + if (isPreloadingSections) { + LOG('Preloading already in progress, skipping...'); + return; + } + + isPreloadingSections = true; + + try { + LOG('Preloading discovery sections using buffer system...'); + + // Ensure buffer has 2 groups (simple constant buffer) + await ensureDiscoveryBuffer(); + + LOG(`Discovery buffer populated with ${discoveryBuffer.length} groups`); + + } catch (err) { + ERR('Error preloading discovery sections:', err); + } finally { + isPreloadingSections = false; + } + } + + /** + * Renders the next group of discovery sections from the buffer + * This is the unified function used by both infinite scroll and load more button + * Uses pre-rendered sections with smooth sequential animation + * @param {HTMLElement} container - Container to render sections into + * @param {HTMLElement} loadMoreButton - Optional load more button to update state + * @returns {Promise} - Success status + */ + async function renderNextDiscoveryGroup(container, loadMoreButton = null) { + if (isRenderingDiscoveryGroup) { + LOG('Discovery group rendering already in progress, skipping...'); + return false; + } + + try { + LOG('Rendering next discovery group from buffer'); + + // Show loading indicator + if (enableInfiniteScroll) { + showDiscoveryLoadingIndicator(); + } + + isRenderingDiscoveryGroup = true; + LOG(`Rendering group from buffer (${discoveryBuffer.length} groups available)`); - // Get the first group from buffer - const groupToRender = discoveryBuffer.shift(); + // Check if we have pre-rendered sections ready to reveal + if (hiddenDiscoverySections.length > 0) { + LOG(`Revealing ${hiddenDiscoverySections.length} pre-rendered sections with animation`); + const sectionsToReveal = hiddenDiscoverySections.splice(0); // Take all sections + + // Reveal sections with smooth animation + const revealedCount = await revealSectionsSequentially(container, sectionsToReveal); + + if (revealedCount > 0) { + LOG(`Successfully revealed ${revealedCount} pre-rendered sections`); + + // Pre-render next group from buffer in background + if (discoveryBuffer.length > 0) { + const nextGroup = discoveryBuffer.shift(); + if (nextGroup) { + const preRendered = await preRenderDiscoveryGroup(nextGroup); + hiddenDiscoverySections.push(...preRendered); + } + } + + // Trigger background refill of buffer to update UI state + ensureDiscoveryBuffer().catch(err => { + ERR('Error refilling discovery buffer:', err); + }); + + return revealedCount > 0; + } + } - // Render the group immediately + // Fallback: if no pre-rendered sections, render directly from buffer (legacy behavior) + const groupToRender = discoveryBuffer.shift(); if (groupToRender && groupToRender.length > 0) { let renderedCount = 0; @@ -1876,1607 +1971,1192 @@ async function renderNextDiscoveryGroup(container, loadMoreButton = null) { } } - LOG(`Rendered ${renderedCount}/${groupToRender.length} sections from buffer`); + LOG(`Rendered ${renderedCount}/${groupToRender.length} sections directly from buffer`); - // Update button state if provided - if (loadMoreButton) { - if (discoveryBuffer.length > 0 || additionalDiscoveryContent) { - loadMoreButton.textContent = 'Discover More'; - loadMoreButton.disabled = false; - loadMoreButton.style.opacity = '1'; - } else { - // No more content available, hide button - loadMoreButton.style.display = 'none'; - } - } + // Trigger background refill of buffer to update UI state + ensureDiscoveryBuffer().catch(err => { + ERR('Error refilling discovery buffer:', err); + }); return renderedCount > 0; } - } else { - LOG('No groups in buffer, ensuring buffer is populated...'); - // Ensure buffer is populated - await ensureDiscoveryBuffer(); + return false; - // Try again after buffer is populated - if (discoveryBuffer.length > 0) { - return await renderNextDiscoveryGroup(container, loadMoreButton); + } catch (err) { + ERR('Error rendering next discovery group:', err); + return false; + } finally { + isRenderingDiscoveryGroup = false; + + // Hide loading indicator + hideDiscoveryLoadingIndicator(); + } + } + + /** + * Handles the "Discover More" button click using the buffer system + * @param {HTMLElement} loadMoreButton - The button element + */ + async function handleDiscoverMoreClick(loadMoreButton) { + try { + LOG('Discover More button clicked'); + + const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); + if (homeSectionsContainer) { + await renderNextDiscoveryGroup(homeSectionsContainer, loadMoreButton); + // renderNextDiscoveryGroup now triggers buffer refill automatically } else { - // Still no content, hide button if provided - if (loadMoreButton) { - loadMoreButton.style.display = 'none'; - } - return false; + ERR('Home sections container not found'); } - } - - return false; - - } catch (err) { - ERR('Error rendering next discovery group:', err); - return false; - } finally { - isRenderingDiscoveryGroup = false; - - // Hide loading indicator - hideDiscoveryLoadingIndicator(); - } -} - -/** - * Handles the "Discover More" button click using the buffer system - * @param {HTMLElement} loadMoreButton - The button element - */ -async function handleDiscoverMoreClick(loadMoreButton) { - try { - LOG('Discover More button clicked'); - - const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); - if (homeSectionsContainer) { - await renderNextDiscoveryGroup(homeSectionsContainer, loadMoreButton); - // Trigger background refill of buffer (don't await) - ensureDiscoveryBuffer().catch(err => { - ERR('Error refilling discovery buffer:', err); - }); - } else { - ERR('Home sections container not found'); + } catch (err) { + ERR('Error handling Discover More click:', err); + } + } + + /** + * Pre-renders a discovery group as DOM fragments (not yet in DOM) + * @param {Array} group - Array of section data from buffer + * @returns {Promise} - Array of DOM elements for each section + */ + async function preRenderDiscoveryGroup(group) { + if (!group || group.length === 0) return []; + + LOG(`Pre-rendering discovery group with ${group.length} sections`); + + const preRenderedSections = []; + + for (const sectionData of group) { + try { + const sectionElement = await buildDiscoverySectionElement(sectionData); + if (sectionElement) { + preRenderedSections.push(sectionElement); + } + } catch (err) { + ERR(`Error pre-rendering section ${sectionData.type}:`, err); + } } - } catch (err) { - ERR('Error handling Discover More click:', err); - - // Reset button state on error - loadMoreButton.textContent = 'Discover More'; - loadMoreButton.disabled = false; - loadMoreButton.style.opacity = '1'; + LOG(`Pre-rendered ${preRenderedSections.length}/${group.length} sections`); + return preRenderedSections; } -} -/** - * Preloads genre section data - * @param {Object} genre - Genre object - * @returns {Promise} - Genre data with items and viewMoreUrl - */ -async function preloadGenreSection(genre) { - try { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - const serverId = apiClient.serverId(); + /** + * Builds a single discovery section DOM element without appending to DOM + * @param {Object} sectionData - Section data object + * @returns {Promise} - Section DOM element + */ + async function buildDiscoverySectionElement(sectionData) { + // Use a temporary container that we'll remove from DOM after extracting elements + const tempContainer = document.createElement('div'); + tempContainer.style.display = 'none'; // Hide container - const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=16`; + // Build the section using existing renderDiscoverySection logic + const success = await renderDiscoverySection(sectionData, tempContainer); - const response = await fetch(url, { - headers: { - "Authorization": `MediaBrowser Token="${token}"` - } + if (!success || tempContainer.children.length === 0) { + return null; + } + + // Extract the first child (the section element) + const sectionElement = tempContainer.firstElementChild; + + // Remove from temp container + tempContainer.removeChild(sectionElement); + + return sectionElement; + } + + /** + * Reveals pre-rendered sections with scroll-triggered viewport animation + * @param {HTMLElement} container - Container to append sections to + * @param {Array} sections - Array of DOM elements to reveal + * @returns {Promise} - Number of sections revealed + */ + async function revealSectionsSequentially(container, sections) { + if (!sections || sections.length === 0) return 0; + + LOG(`Revealing ${sections.length} pre-rendered sections with scroll-triggered animation`); + + // Add all sections to DOM with initial animation class (hidden) + sections.forEach(section => { + section.style.display = 'none'; + section.classList.add('discovery-section-reveal'); + container.appendChild(section); }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - const items = data.Items || []; - - if (items.length === 0) return null; - - const viewMoreUrl = `/web/#/list.html?genreId=${genre.Id}&serverId=${serverId}`; - - return { - items: items, - viewMoreUrl: viewMoreUrl, - hasFullItems: true + // Create intersection observer for scroll-triggered animations + const observerOptions = { + threshold: 0.1, // Trigger when 10% of section is visible + rootMargin: '0px 0px -50px 0px' // Trigger slightly before fully in view }; - } catch (err) { - ERR(`Error preloading genre section for ${genre.Name}:`, err); - return null; - } -} - -/** - * Preloads "because you watched" section data - * @param {Object} movie - Movie object - * @returns {Promise} - Similar content data - */ -async function preloadBecauseYouWatchedSection(movie) { - try { - const items = await fetchSimilarContent(movie.Id, 'Movie'); - if (items.length === 0) return null; - - return { - items: items.slice(0, 16), - hasFullItems: true - }; - - } catch (err) { - ERR(`Error preloading because you watched section for ${movie.Name}:`, err); - return null; - } -} - -/** - * Preloads "because you liked" section data - * @param {Object} movie - Movie object - * @returns {Promise} - Similar content data - */ -async function preloadBecauseYouLikedSection(movie) { - try { - const items = await fetchSimilarContent(movie.Id, 'Movie'); - if (items.length === 0) return null; - - return { - items: items.slice(0, 16), - hasFullItems: true - }; - - } catch (err) { - ERR(`Error preloading because you liked section for ${movie.Name}:`, err); - return null; - } -} - -/** - * Generates discovery sections with infinite loading behavior - * @param {HTMLElement} container - Container to append sections to - * @returns {Promise} - Success status - */ -async function generateDiscoverySections(container) { - if (isGeneratingDiscoverySections) { - LOG('Discovery sections generation already in progress, skipping...'); - return false; - } - - isGeneratingDiscoverySections = true; - - // Show loading indicator - showDiscoveryLoadingIndicator(); - - try { - // Ensure people cache is initialized - if (!moviesTopPeople) { - await initializePeopleCache(); - - // If people cache is still not available (initialization failed or in progress), - // skip people-related sections - if (!moviesTopPeople) { - LOG('People cache not available, skipping people-related sections'); - } - } - - let successCount = 0; - - // First, try to unhide preloaded sections - if (preloadedSectionElements.length >= 6) { - LOG('Unhiding preloaded sections for instant display'); - const sectionsToUnhide = preloadedSectionElements.splice(0, 6); // Take first 6 and remove them - - for (const sectionElement of sectionsToUnhide) { - try { - // Unhide the section - sectionElement.style.display = ''; - sectionElement.removeAttribute('data-preloaded'); - successCount++; - } catch (err) { - ERR('Error unhiding preloaded section:', err); + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + // Add in-viewport class to trigger animation + entry.target.classList.add('in-viewport'); + // Unobserve after animation is triggered + observer.unobserve(entry.target); } + }); + }, observerOptions); + + // Remove display: none and observe sections + sections.forEach(section => { + section.style.display = ''; + + // Force reflow to ensure the initial hidden state is rendered + void section.offsetHeight; + + // Observe section for viewport entry + observer.observe(section); + }); + + LOG(`Revealed ${sections.length} sections with scroll-triggered animation`); + return sections.length; + } + + /** + * Preloads genre section data + * @param {Object} genre - Genre object + * @returns {Promise} - Genre data with items and viewMoreUrl + */ + async function preloadGenreSection(genre) { + try { + const apiClient = window.ApiClient; + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + const userId = apiClient.getCurrentUserId(); + const serverId = apiClient.serverId(); + + const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=16`; + + const response = await fetch(url, { + headers: { + "Authorization": `MediaBrowser Token="${token}"` + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - // Also remove corresponding data - preloadedSections.splice(0, 6); + const data = await response.json(); + const items = data.Items || []; - LOG(`Unhid ${successCount} preloaded discovery sections`); - } else if (preloadedSections.length >= 6) { - // Fallback: use preloaded data to render sections - LOG('Using preloaded data for instant rendering'); - const sectionsToRender = preloadedSections.splice(0, 6); // Take first 6 and remove them + if (items.length === 0) return null; - const results = await Promise.all( - sectionsToRender.map(section => renderDiscoverySection(section, container)) - ); - successCount = results.filter(Boolean).length; + const viewMoreUrl = `/web/#/list.html?genreId=${genre.Id}&serverId=${serverId}`; - LOG(`Generated ${successCount}/${sectionsToRender.length} discovery sections from preloaded data`); - } else { - // Generate new sections if not enough preloaded - LOG('Generating new sections (not enough preloaded)'); - const sectionsToRender = await generateNewSections(); + return { + items: items, + viewMoreUrl: viewMoreUrl, + hasFullItems: true + }; - const results = await Promise.all( - sectionsToRender.map(section => renderDiscoverySection(section, container)) - ); - successCount = results.filter(Boolean).length; - - LOG(`Generated ${successCount}/${sectionsToRender.length} new discovery sections`); + } catch (err) { + ERR(`Error preloading genre section for ${genre.Name}:`, err); + return null; } - - // Immediately start preloading next batch (don't await) - preloadNextSections(); - - return successCount > 0; - - } catch (err) { - ERR('Error generating discovery sections:', err); - return false; - } finally { - isGeneratingDiscoverySections = false; - - // Hide loading indicator - hideDiscoveryLoadingIndicator(); } -} -/** - * Generates new sections when preloaded data is not available - * @returns {Promise} - Array of section data - */ -async function generateNewSections() { - const sectionsToRender = []; - - // 1. [Genre] Movies - const randomGenre = await getRandomGenre(); - if (randomGenre) { - const genreData = await preloadGenreSection(randomGenre); - if (genreData) { - sectionsToRender.push({ - type: 'genre', - data: randomGenre, - items: genreData.items, - viewMoreUrl: genreData.viewMoreUrl - }); + /** + * Preloads "because you watched" section data + * @param {Object} movie - Movie object + * @returns {Promise} - Similar content data + */ + async function preloadBecauseYouWatchedSection(movie) { + try { + const items = await fetchSimilarContent(movie.Id, 'Movie'); + if (items.length === 0) return null; + + return { + items: items.slice(0, 16), + hasFullItems: true + }; + + } catch (err) { + ERR(`Error preloading because you watched section for ${movie.Name}:`, err); + return null; } } - - // 2. Directed by [Director] - if (moviesTopPeople && moviesTopPeople.directors.length > 0) { - const randomDirector = getRandomItem(moviesTopPeople.directors); - if (randomDirector) { - sectionsToRender.push({ - type: 'director', - data: randomDirector, - items: shuffleArray(randomDirector.itemIds).slice(0, 16) - }); - } - } - - // 3. Written by [Writer] - if (moviesTopPeople && moviesTopPeople.writers.length > 0) { - const randomWriter = getRandomItem(moviesTopPeople.writers); - if (randomWriter) { - sectionsToRender.push({ - type: 'writer', - data: randomWriter, - items: shuffleArray(randomWriter.itemIds).slice(0, 16) - }); - } - } - - // 4. Starring [Actor] - if (moviesTopPeople && moviesTopPeople.actors.length > 0) { - const randomActor = getRandomItem(moviesTopPeople.actors); - if (randomActor) { - sectionsToRender.push({ - type: 'actor', - data: randomActor, - items: shuffleArray(randomActor.itemIds).slice(0, 16) - }); - } - } - - // 5. Because you watched [Movie] - const randomWatchedMovie = getRandomWatchedMovie(); - if (randomWatchedMovie) { - const watchedData = await preloadBecauseYouWatchedSection(randomWatchedMovie); - if (watchedData) { - sectionsToRender.push({ - type: 'watched', - data: randomWatchedMovie, - items: watchedData.items - }); - } - } - - // 6. Because you liked [Movie] - const randomLikedMovie = await getRandomFavoriteMovie(); - if (randomLikedMovie) { - const likedData = await preloadBecauseYouLikedSection(randomLikedMovie); - if (likedData) { - sectionsToRender.push({ - type: 'liked', - data: randomLikedMovie, - items: likedData.items - }); - } - } - - return sectionsToRender; -} -/** - * Renders a discovery section (unified function for both preloaded and dynamic sections) - * @param {Object|string} sectionData - Preloaded section data object OR section type string - * @param {HTMLElement} container - Container to append section to - * @returns {Promise} - Success status - */ -async function renderDiscoverySection(sectionData, container) { - try { - let sectionName = ''; - let items = []; - let viewMoreUrl = null; - let sectionId = ''; - let order = 200 + renderedSections.size; - - // Handle preloaded section data - if (typeof sectionData === 'object' && sectionData.type) { - if (!sectionData.items || sectionData.items.length === 0) return false; + /** + * Preloads "because you liked" section data + * @param {Object} movie - Movie object + * @returns {Promise} - Similar content data + */ + async function preloadBecauseYouLikedSection(movie) { + try { + const items = await fetchSimilarContent(movie.Id, 'Movie'); + if (items.length === 0) return null; - items = sectionData.items; - viewMoreUrl = sectionData.viewMoreUrl || null; + return { + items: items.slice(0, 16), + hasFullItems: true + }; - switch (sectionData.type) { - case 'genre': - sectionName = `${sectionData.data.Name} Movies`; - sectionId = `genre-${sectionData.data.Name.toLowerCase()}`; - break; - case 'director': - sectionName = `Directed by ${sectionData.data.name}`; - sectionId = `director-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; - break; - case 'writer': - sectionName = `Written by ${sectionData.data.name}`; - sectionId = `writer-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; - break; - case 'actor': - sectionName = `Starring ${sectionData.data.name}`; - sectionId = `actor-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; - break; - case 'watched': - sectionName = `Because you watched ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; - sectionId = `watched-${sectionData.data.Id}`; - break; - case 'liked': - sectionName = `Because you liked ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; - sectionId = `liked-${sectionData.data.Id}`; - break; - default: + } catch (err) { + ERR(`Error preloading because you liked section for ${movie.Name}:`, err); + return null; + } + } + + /** + * Renders a discovery section (unified function for both preloaded and dynamic sections) + * @param {Object|string} sectionData - Preloaded section data object OR section type string + * @param {HTMLElement} container - Container to append section to + * @returns {Promise} - Success status + */ + async function renderDiscoverySection(sectionData, container) { + try { + let sectionName = ''; + let items = []; + let viewMoreUrl = null; + let sectionId = ''; + let order = 200 + renderedSections.size; + + // Handle preloaded section data + if (typeof sectionData === 'object' && sectionData.type) { + if (!sectionData.items || sectionData.items.length === 0) return false; + + items = sectionData.items; + viewMoreUrl = sectionData.viewMoreUrl || null; + + switch (sectionData.type) { + case 'genre': + sectionName = `${sectionData.data.Name} Movies`; + sectionId = `genre-${sectionData.data.Name.toLowerCase()}`; + break; + case 'director': + sectionName = `Directed by ${sectionData.data.name}`; + sectionId = `director-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; + break; + case 'writer': + sectionName = `Written by ${sectionData.data.name}`; + sectionId = `writer-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; + break; + case 'actor': + sectionName = `Starring ${sectionData.data.name}`; + sectionId = `actor-${sectionData.data.name.toLowerCase().replace(/\s+/g, '-')}`; + break; + case 'watched': + sectionName = `Because you watched ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; + sectionId = `watched-${sectionData.data.Id}`; + break; + case 'liked': + sectionName = `Because you liked ${sectionData.data.Name} ${sectionData.data.ProductionYear ? `(${sectionData.data.ProductionYear})` : ''}`; + sectionId = `liked-${sectionData.data.Id}`; + break; + default: + return false; + } + } + + if (items.length === 0) { + return false; + } + + // Limit to 16 items + const limitedItems = items.slice(0, 16); + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined') { + WARN("cardBuilder not available, skipping discovery section"); + return false; + } + + // Determine if we have full items or just IDs + const hasFullItems = limitedItems.length > 0 && limitedItems[0] && typeof limitedItems[0] === 'object' && limitedItems[0].Id; + + let scrollableContainer; + if (hasFullItems) { + // Use renderCards for full item objects + if (!window.cardBuilder.renderCards) { + WARN("cardBuilder.renderCards not available, skipping discovery section"); return false; - } - } - // Handle dynamic section generation (legacy support) - else if (typeof sectionData === 'string') { - const sectionType = sectionData; - - switch (sectionType) { - case 'watched-movie': { - const randomMovie = getRandomWatchedMovie(); - if (!randomMovie) return false; - - sectionName = `Because you watched ${randomMovie.Name} ${randomMovie.ProductionYear ? `(${randomMovie.ProductionYear})` : ''}`; - items = await fetchSimilarContent(randomMovie.Id, 'Movie'); - sectionId = 'watched-movie'; - order = 102; - break; - } - - case 'watched-series': { - const randomSeries = getRandomWatchedSeries(); - if (!randomSeries) return false; - - sectionName = `Because you watched ${randomSeries.Name} ${randomSeries.ProductionYear ? `(${randomSeries.ProductionYear})` : ''}`; - items = await fetchSimilarContent(randomSeries.Id, 'Series'); - sectionId = 'watched-series'; - order = 103; - break; - } - - case 'liked-movie': { - const randomMovie = await getRandomFavoriteMovie(); - if (!randomMovie) return false; - - sectionName = `Because you liked ${randomMovie.Name} ${randomMovie.ProductionYear ? `(${randomMovie.ProductionYear})` : ''}`; - items = await fetchSimilarContent(randomMovie.Id, 'Movie'); - sectionId = 'liked-movie'; - order = 104; - break; - } - - case 'liked-series': { - const randomSeries = await getRandomFavoriteSeries(); - if (!randomSeries) return false; - - sectionName = `Because you liked ${randomSeries.Name} ${randomSeries.ProductionYear ? `(${randomSeries.ProductionYear})` : ''}`; - items = await fetchSimilarContent(randomSeries.Id, 'Series'); - sectionId = 'liked-series'; - order = 105; - break; - } - - case 'directed-by': { - const randomDirector = getRandomDirector(); - if (!randomDirector) return false; - - sectionName = `Directed by ${randomDirector.Name}`; - items = await fetchPersonItems(randomDirector.Id, 'Movie'); - sectionId = 'directed-by'; - order = 106; - - // Generate view more URL for person details - const apiClient = window.ApiClient; - const serverId = apiClient.serverId(); - viewMoreUrl = `/web/#/details?id=${randomDirector.Id}&serverId=${serverId}`; - break; - } - - case 'starring': { - const randomActor = getRandomActor(); - if (!randomActor) return false; - - sectionName = `Starring ${randomActor.Name}`; - items = await fetchPersonItems(randomActor.Id, 'Movie'); - sectionId = 'starring'; - order = 107; - - // Generate view more URL for person details - const apiClient = window.ApiClient; - const serverId = apiClient.serverId(); - viewMoreUrl = `/web/#/details?id=${randomActor.Id}&serverId=${serverId}`; - break; - } - - default: - return false; - } - } - - if (items.length === 0) { - return false; - } - - // Limit to 16 items - const limitedItems = items.slice(0, 16); - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined') { - WARN("cardBuilder not available, skipping discovery section"); - return false; - } - - // Determine if we have full items or just IDs - const hasFullItems = limitedItems.length > 0 && limitedItems[0] && typeof limitedItems[0] === 'object' && limitedItems[0].Id; - - let scrollableContainer; - if (hasFullItems) { - // Use renderCards for full item objects - if (!window.cardBuilder.renderCards) { - WARN("cardBuilder.renderCards not available, skipping discovery section"); - return false; - } - scrollableContainer = window.cardBuilder.renderCards(limitedItems, sectionName, viewMoreUrl); - } else { - // Use renderCardsFromIds for ID arrays - if (!window.cardBuilder.renderCardsFromIds) { - WARN("cardBuilder.renderCardsFromIds not available, skipping discovery section"); - return false; - } - scrollableContainer = await window.cardBuilder.renderCardsFromIds(limitedItems, sectionName, viewMoreUrl); - } - - // Add data attributes to track rendered sections - scrollableContainer.setAttribute('data-custom-section-id', sectionId); - scrollableContainer.setAttribute('data-custom-section-name', sectionName); - scrollableContainer.style.order = order; - - // Append to container - container.appendChild(scrollableContainer); - - // Track rendered section for preloaded sections - if (typeof sectionData === 'object') { - renderedSections.add(sectionId); - } - - return true; - - } catch (err) { - ERR(`Error rendering discovery section:`, err); - return false; - } -} - - -/** - * Sets up infinite loading for discovery sections - * @param {HTMLElement} container - Container to watch for scroll - */ -function setupInfiniteLoading(container) { - if (enableInfiniteScroll) { - setupScrollBasedLoading(container); - } else { - setupLoadMoreButton(container); - } -} - -/** - * Sets up scroll-based infinite loading - * @param {HTMLElement} container - Container to watch for scroll - */ -function setupScrollBasedLoading(container) { - let lastScrollTop = 0; - let scrollTimeout = null; - - const handleScroll = () => { - if (isRenderingDiscoveryGroup) return; - - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - - // Check if user has scrolled to bottom 30% of the page - const scrollPercentage = (scrollTop + windowHeight) / documentHeight; - - if (scrollPercentage >= 0.7 && scrollTop > lastScrollTop) { - // Clear any existing timeout - if (scrollTimeout) { - clearTimeout(scrollTimeout); - } - - // Debounce the scroll event - scrollTimeout = setTimeout(async () => { - LOG('User scrolled to bottom 30%, rendering more discovery sections from buffer...'); - await renderNextDiscoveryGroup(container); - }, 500); - } - - lastScrollTop = scrollTop; - }; - - // Add scroll listener - window.addEventListener('scroll', handleScroll, { passive: true }); - - LOG('Scroll-based infinite loading for discovery sections enabled'); -} - -/** - * Sets up load more button for discovery sections - * @param {HTMLElement} container - Container to append button to - */ -function setupLoadMoreButton(container) { - // Create load more button - const loadMoreButton = document.createElement('button'); - loadMoreButton.textContent = 'Discover More'; - loadMoreButton.className = 'load-more-discovery-btn'; - loadMoreButton.style.cssText = ` - order: 9998 !important; - display: block; - width: 100%; - max-width: 400px; - margin: 20px auto; - padding: 12px 24px; - background: var(--btnSubmitColor); - color: white; - border: none; - border-radius: 6px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 2px 8px rgba(0, 164, 220, 0.3); - `; - - // Add hover effects - loadMoreButton.addEventListener('mouseenter', () => { - loadMoreButton.style.transform = 'translateY(-2px)'; - loadMoreButton.style.boxShadow = '0 4px 12px rgba(0, 164, 220, 0.4)'; - }); - - loadMoreButton.addEventListener('mouseleave', () => { - loadMoreButton.style.transform = 'translateY(0)'; - loadMoreButton.style.boxShadow = '0 2px 8px rgba(0, 164, 220, 0.3)'; - }); - - // Add click handler using new buffer system - loadMoreButton.addEventListener('click', async () => { - await handleDiscoverMoreClick(loadMoreButton); - }); - - // Append button to container - container.appendChild(loadMoreButton); - - LOG('Load more button for discovery sections enabled'); -} - -/** - * Gets a random genre that hasn't been rendered yet - * @returns {Promise} - Random genre or null - */ -async function getRandomGenre() { - const cache = new window.LocalStorageCache(); - let movieGenres = cache.get('movieGenres'); - - if (!movieGenres || movieGenres.length === 0) { - movieGenres = await fetchAndCacheMovieGenres(); - } - - if (movieGenres.length === 0) return null; - - // Filter out already rendered genres and genres with insufficient movie count - const availableGenres = movieGenres.filter(genre => - !renderedSections.has(`genre-${genre.Name.toLowerCase()}`) && - (genre.MovieCount || 0) >= minGenreMovieCount - ); - - if (availableGenres.length === 0) return null; - - const randomGenre = getRandomItem(availableGenres); - if (randomGenre) { - renderedSections.add(`genre-${randomGenre.Name.toLowerCase()}`); - } - - return randomGenre; -} - -/** - * Renders a genre section - * @param {Object} genre - Genre object - * @param {HTMLElement} container - Container to append section to - * @returns {Promise} - Success status - */ -async function renderGenreSection(genre, container) { - try { - const apiClient = window.ApiClient; - const serverUrl = apiClient.serverAddress(); - const token = apiClient.accessToken(); - const userId = apiClient.getCurrentUserId(); - const serverId = apiClient.serverId(); - - const url = `${serverUrl}/Items?userId=${userId}&Genres=${encodeURIComponent(genre.Name)}&IncludeItemTypes=Movie&Recursive=true&SortBy=Random&Limit=16`; - - 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(); - const items = data.Items || []; - - if (items.length === 0) return false; - - const viewMoreUrl = `/web/#/list.html?genreId=${genre.Id}&serverId=${serverId}`; - - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping genre section"); - return false; - } - - const scrollableContainer = window.cardBuilder.renderCards( - items, - `${genre.Name} Movies`, - viewMoreUrl - ); - - scrollableContainer.setAttribute('data-custom-section-id', `genre-${genre.Name.toLowerCase()}`); - scrollableContainer.setAttribute('data-custom-section-name', `${genre.Name} Movies`); - scrollableContainer.style.order = 200 + renderedSections.size; - - container.appendChild(scrollableContainer); - return true; - - } catch (err) { - ERR(`Error rendering genre section for ${genre.Name}:`, err); - return false; - } -} - -/** - * Renders a person section (director, writer, actor) - * @param {string} personType - Type of person ('director', 'writer', 'actor') - * @param {Object} person - Person object with name and itemIds - * @param {HTMLElement} container - Container to append section to - * @returns {Promise} - Success status - */ -async function renderPersonSection(personType, person, container) { - try { - if (!person.itemIds || person.itemIds.length === 0) return false; - - // Limit to 16 items and randomize order - const limitedItemIds = shuffleArray(person.itemIds).slice(0, 16); - - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCardsFromIds) { - WARN("cardBuilder not available, skipping person section"); - return false; - } - - const sectionName = personType === 'actor' ? `Starring ${person.name}` : - personType === 'director' ? `Directed by ${person.name}` : - `Written by ${person.name}`; - - const scrollableContainer = await window.cardBuilder.renderCardsFromIds( - limitedItemIds, - sectionName, - null - ); - - const sectionId = `${personType}-${person.name.toLowerCase().replace(/\s+/g, '-')}`; - scrollableContainer.setAttribute('data-custom-section-id', sectionId); - scrollableContainer.setAttribute('data-custom-section-name', sectionName); - scrollableContainer.style.order = 200 + renderedSections.size; - - container.appendChild(scrollableContainer); - - // Track rendered section - renderedSections.add(sectionId); - - return true; - - } catch (err) { - ERR(`Error rendering ${personType} section for ${person.name}:`, err); - return false; - } -} - -/** - * Renders "Because you watched" section - * @param {Object} movie - Movie object - * @param {HTMLElement} container - Container to append section to - * @returns {Promise} - Success status - */ -async function renderBecauseYouWatchedSection(movie, container) { - try { - const items = await fetchSimilarContent(movie.Id, 'Movie'); - if (items.length === 0) return false; - - const limitedItems = items.slice(0, 16); - - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping because you watched section"); - return false; - } - - const sectionName = `Because you watched ${movie.Name} ${movie.ProductionYear ? `(${movie.ProductionYear})` : ''}`; - - const scrollableContainer = window.cardBuilder.renderCards( - limitedItems, - sectionName, - null - ); - - const sectionId = `watched-${movie.Id}`; - scrollableContainer.setAttribute('data-custom-section-id', sectionId); - scrollableContainer.setAttribute('data-custom-section-name', sectionName); - scrollableContainer.style.order = 200 + renderedSections.size; - - container.appendChild(scrollableContainer); - - // Track rendered section - renderedSections.add(sectionId); - - return true; - - } catch (err) { - ERR(`Error rendering because you watched section for ${movie.Name}:`, err); - return false; - } -} - -/** - * Renders "Because you liked" section - * @param {Object} movie - Movie object - * @param {HTMLElement} container - Container to append section to - * @returns {Promise} - Success status - */ -async function renderBecauseYouLikedSection(movie, container) { - try { - const items = await fetchSimilarContent(movie.Id, 'Movie'); - if (items.length === 0) return false; - - const limitedItems = items.slice(0, 16); - - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping because you liked section"); - return false; - } - - const sectionName = `Because you liked ${movie.Name} ${movie.ProductionYear ? `(${movie.ProductionYear})` : ''}`; - - const scrollableContainer = window.cardBuilder.renderCards( - limitedItems, - sectionName, - null - ); - - const sectionId = `liked-${movie.Id}`; - scrollableContainer.setAttribute('data-custom-section-id', sectionId); - scrollableContainer.setAttribute('data-custom-section-name', sectionName); - scrollableContainer.style.order = 200 + renderedSections.size; - - container.appendChild(scrollableContainer); - - // Track rendered section - renderedSections.add(sectionId); - - return true; - - } catch (err) { - ERR(`Error rendering because you liked section for ${movie.Name}:`, err); - return false; - } -} - -/** - * Renders discovery section based on type - * @param {string} sectionType - Type of discovery section - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ - -/** - * Renders Halloween Movies section - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ -function renderHalloweenMoviesSection(container) { - try { - if (halloweenMovies.length === 0) return false; - - const shuffled = [...halloweenMovies].sort(() => Math.random() - 0.5); - const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); - - if (limitedItems.length === 0) return false; - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping Halloween Movies section"); - return false; - } - - // Render the scrollable container - const scrollableContainer = window.cardBuilder.renderCards( - limitedItems, - 'Halloween Movies', - null - ); - - scrollableContainer.setAttribute('data-custom-section-id', 'halloween-movies'); - scrollableContainer.setAttribute('data-custom-section-name', 'Halloween Movies'); - scrollableContainer.style.order = 50; - - container.appendChild(scrollableContainer); - return true; - - } catch (err) { - ERR('Error rendering Halloween Movies section:', err); - return false; - } -} - -/** - * Renders Horror Movies section - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ -function renderHorrorMoviesSection(container) { - try { - if (horrorMovies.length === 0) return false; - - const shuffled = [...horrorMovies].sort(() => Math.random() - 0.5); - const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); - - if (limitedItems.length === 0) return false; - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping Horror Movies section"); - return false; - } - - // Render the scrollable container - const scrollableContainer = window.cardBuilder.renderCards( - limitedItems, - 'Horror Movies', - halloweenViewMoreUrls.horror - ); - - scrollableContainer.setAttribute('data-custom-section-id', 'horror-movies'); - scrollableContainer.setAttribute('data-custom-section-name', 'Horror Movies'); - scrollableContainer.style.order = 51; - - container.appendChild(scrollableContainer); - return true; - - } catch (err) { - ERR('Error rendering Horror Movies section:', err); - return false; - } -} - -/** - * Renders Thriller Movies section - * @param {HTMLElement} container - Container to append the section to - * @returns {boolean} - Success status - */ -function renderThrillerMoviesSection(container) { - try { - if (thrillerMovies.length === 0) return false; - - const shuffled = [...thrillerMovies].sort(() => Math.random() - 0.5); - const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); - - if (limitedItems.length === 0) return false; - - // Check if cardBuilder is available - if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { - WARN("cardBuilder not available, skipping Thriller Movies section"); - return false; - } - - // Render the scrollable container - const scrollableContainer = window.cardBuilder.renderCards( - limitedItems, - 'Thriller Movies', - halloweenViewMoreUrls.thriller - ); - - scrollableContainer.setAttribute('data-custom-section-id', 'thriller-movies'); - scrollableContainer.setAttribute('data-custom-section-name', 'Thriller Movies'); - scrollableContainer.style.order = 52; - - container.appendChild(scrollableContainer); - return true; - - } catch (err) { - ERR('Error rendering Thriller Movies section:', err); - return false; - } -} - - -/** - * Renders all Halloween sections - * @param {HTMLElement} container - Container to append sections to - */ -async function renderAllHalloweenSections(container) { - if (!isHalloweenPeriod()) { - LOG('Not in Halloween period, skipping Halloween sections'); - return; - } - - try { - // Fetch Halloween data if not already loaded - if (halloweenMovies.length === 0) { - LOG('Fetching Halloween movie data...'); - const movieData = await fetchHalloweenMovieData(); - halloweenMovies = movieData.halloweenMovies; - horrorMovies = movieData.horrorMovies; - thrillerMovies = movieData.thrillerMovies; - halloweenViewMoreUrls = movieData.viewMoreUrls; - - } - - const results = [ - renderHalloweenMoviesSection(container), - renderHorrorMoviesSection(container), - renderThrillerMoviesSection(container) - ]; - - const successCount = results.filter(Boolean).length; - LOG(`Rendered ${successCount}/${results.length} Halloween sections`); - return successCount === results.length; - - } catch (err) { - ERR('Error rendering Halloween sections:', err); - } -} - -/** - * Renders all Christmas sections (stub for future implementation) - * @param {HTMLElement} container - Container to append sections to - */ -async function renderAllChristmasSections(container) { - if (!isChristmasPeriod()) { - LOG('Not in Christmas period, skipping Christmas sections'); - return; - } - - // TODO: Implement Christmas sections - LOG('Christmas sections not yet implemented'); -} - -/** - * Renders all New Years sections (stub for future implementation) - * @param {HTMLElement} container - Container to append sections to - */ -async function renderAllNewYearsSections(container) { - if (!isNewYearsPeriod()) { - LOG('Not in New Years period, skipping New Years sections'); - return; - } - - // TODO: Implement New Years sections - LOG('New Years sections not yet implemented'); -} - -/** - * Renders all Valentine's Day sections (stub for future implementation) - * @param {HTMLElement} container - Container to append sections to - */ -async function renderAllValentinesSections(container) { - if (!isValentinesPeriod()) { - LOG('Not in Valentine\'s Day period, skipping Valentine\'s Day sections'); - return; - } - - // TODO: Implement Valentine's Day sections - LOG('Valentine\'s Day sections not yet implemented'); -} - -/** - * Renders all seasonal sections based on current date - * @param {HTMLElement} container - Container to append sections to - */ -async function renderAllSeasonalSections(container) { - const sectionsToRender = []; - - // Add Halloween sections if in Halloween period - if (isHalloweenPeriod()) { - sectionsToRender.push(renderAllHalloweenSections(container)); - } - - // Add Christmas sections if in Christmas period - if (isChristmasPeriod()) { - sectionsToRender.push(renderAllChristmasSections(container)); - } - - // Add New Years sections if in New Years period - if (isNewYearsPeriod()) { - sectionsToRender.push(renderAllNewYearsSections(container)); - } - - // Add Valentine's Day sections if in Valentine's Day period - if (isValentinesPeriod()) { - sectionsToRender.push(renderAllValentinesSections(container)); - } - - if (sectionsToRender.length === 0) { - LOG('No seasonal periods active, skipping seasonal sections'); - return; - } - - const results = await Promise.all(sectionsToRender); - const successCount = results.filter(Boolean).length; - LOG(`Rendered ${successCount}/${sectionsToRender.length} seasonal section groups`); -} - -/************ 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 = [ - '.libraryPage: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 hasRenderedCustomSections = customHomeSections.some(section => - homeSectionsContainer.querySelector(`[data-custom-section-id="${section.id}"]`) - ); - - const hasRenderedNewAndTrendingSections = (enableNewMovies && homeSectionsContainer.querySelector('[data-custom-section-id="new-movies"]')) || - (enableNewEpisodes && homeSectionsContainer.querySelector('[data-custom-section-id="new-episodes"]')) || - (enableTrending && homeSectionsContainer.querySelector('[data-custom-section-id="trending-movies"]')); - - const hasRenderedDiscoverySections = (enableWatchlist && homeSectionsContainer.querySelector('[data-custom-section-id="watchlist"]')) || - (enableDiscovery && homeSectionsContainer.querySelector('[data-custom-section-id="watched-movie"]')) || - (enableSeasonal && homeSectionsContainer.querySelector('[data-custom-section-id="halloween-movies"]')); - - if (hasRenderedCustomSections || hasRenderedNewAndTrendingSections || hasRenderedDiscoverySections) { - return; - } - - // Set processing flag to prevent parallel execution - isProcessing = true; - - try { - LOG('Starting parallel initialization of home screen sections...'); - - // Run all initialization functions in parallel for faster loading - const initPromises = [ - // Initialize people cache in background (non-blocking) - initializePeopleCache(), - - // Render custom sections - renderAllCustomSections(homeSectionsContainer), - - // Render new and trending sections - renderAllNewAndTrendingSections(homeSectionsContainer), - - // Render seasonal sections - renderAllSeasonalSections(homeSectionsContainer), - - // Render watchlist section - renderWatchlistSection(homeSectionsContainer), - - // Set up infinite loading for discovery sections (synchronous, wrapped in Promise.resolve) - Promise.resolve().then(() => setupInfiniteLoading(homeSectionsContainer)), - - // Start preloading first batch of discovery sections (non-blocking) - preloadNextSections() - ]; - - // Wait for all parallel operations to complete - await Promise.all(initPromises); - - LOG('All 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; } + scrollableContainer = window.cardBuilder.renderCards(limitedItems, sectionName, viewMoreUrl); + } else { + // Use renderCardsFromIds for ID arrays + if (!window.cardBuilder.renderCardsFromIds) { + WARN("cardBuilder.renderCardsFromIds not available, skipping discovery section"); + return false; + } + scrollableContainer = await window.cardBuilder.renderCardsFromIds(limitedItems, sectionName, viewMoreUrl); } - }); - - if (shouldCheck) { - setTimeout(checkAndRenderCustomSections, 100); // Small delay to ensure DOM is ready + + // Add data attributes to track rendered sections + scrollableContainer.setAttribute('data-custom-section-id', sectionId); + scrollableContainer.setAttribute('data-custom-section-name', sectionName); + scrollableContainer.style.order = order; + + // Append to container + container.appendChild(scrollableContainer); + + // Track rendered section for preloaded sections + if (typeof sectionData === 'object') { + renderedSections.add(sectionId); + } + + return true; + + } catch (err) { + ERR(`Error rendering discovery section:`, err); + return false; } - }); - - // 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 + /** + * Sets up infinite loading for discovery sections + * @param {HTMLElement} container - Container to watch for scroll + */ + function setupInfiniteLoading(container) { + if (enableInfiniteScroll) { + setupScrollBasedLoading(container); + } else { + setupLoadMoreButton(container); + } + } + + /** + * Removes scroll-based infinite loading handler + */ + function removeScrollBasedLoading() { + if (discoveryScrollHandler) { + window.removeEventListener('scroll', discoveryScrollHandler); + discoveryScrollHandler = null; + LOG('Scroll-based infinite loading for discovery sections removed'); + } + } + + /** + * Sets up scroll-based infinite loading + * @param {HTMLElement} container - Container to watch for scroll + */ + function setupScrollBasedLoading(container) { + // Remove existing handler if present (make idempotent) + removeScrollBasedLoading(); + + let lastScrollTop = 0; + let scrollTimeout = null; + + const handleScroll = () => { + if (isRenderingDiscoveryGroup) return; + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + // Calculate distance from bottom (in pixels) + const distanceFromBottom = documentHeight - (scrollTop + windowHeight); + + // Trigger when user is 1500px from the bottom and scrolling down + const triggerDistance = 3000; + + if (distanceFromBottom <= triggerDistance && scrollTop > lastScrollTop) { + // Clear any existing timeout + if (scrollTimeout) { + clearTimeout(scrollTimeout); + } + + // Debounce the scroll event + scrollTimeout = setTimeout(async () => { + LOG(`User scrolled to ${distanceFromBottom.toFixed(0)}px from bottom, rendering more discovery sections from buffer...`); + await renderNextDiscoveryGroup(container); + }, 500); + } + + lastScrollTop = scrollTop; + }; + + // Store handler reference and add scroll listener + discoveryScrollHandler = handleScroll; + window.addEventListener('scroll', handleScroll, { passive: true }); + + LOG('Scroll-based infinite loading for discovery sections enabled'); + } + + /** + * Sets up load more button for discovery sections + * @param {HTMLElement} container - Container to append button to + */ + function setupLoadMoreButton(container) { + let loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); + + if (!loadMoreButton) { + // Create load more button + loadMoreButton = document.createElement('button'); + loadMoreButton.textContent = 'Discover More'; + loadMoreButton.className = 'load-more-discovery-btn'; + loadMoreButton.style.cssText = ` + order: 9998 !important; + display: block; + width: 100%; + max-width: 400px; + margin: 20px auto; + padding: 12px 24px; + background: #673AB7; + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + `; + + // Add hover effects + loadMoreButton.addEventListener('mouseenter', () => { + loadMoreButton.style.transform = 'translateY(-2px)'; + loadMoreButton.style.boxShadow = '0 4px 12px rgba(0, 164, 220, 0.4)'; + }); + + loadMoreButton.addEventListener('mouseleave', () => { + loadMoreButton.style.transform = 'translateY(0)'; + loadMoreButton.style.boxShadow = '0 2px 8px rgba(0, 164, 220, 0.3)'; + }); + + // Add click handler using new buffer system + loadMoreButton.addEventListener('click', async () => { + await handleDiscoverMoreClick(loadMoreButton); + }); + + // Append button to container + container.appendChild(loadMoreButton); + + LOG('Load more button for discovery sections created'); + } + + // Update visibility based on buffer state + updateLoadMoreButtonVisibility(); + } + + /** + * Updates the visibility of the load more button based on buffer state + */ + function updateLoadMoreButtonVisibility() { + const loadMoreButton = document.querySelector('.libraryPage:not(.hide) .load-more-discovery-btn'); + if (!loadMoreButton) return; + + if (discoveryBuffer.length > 0 || additionalDiscoveryContent) { + loadMoreButton.textContent = 'Discover More'; + loadMoreButton.style.display = 'block'; + loadMoreButton.disabled = false; + LOG('Load more button shown'); + } else { + loadMoreButton.style.display = 'none'; + LOG('Load more button hidden'); + } + } + + /** + * Gets a random genre that hasn't been rendered yet + * @returns {Promise} - Random genre or null + */ + async function getRandomGenre() { + const cache = new window.LocalStorageCache(); + let movieGenres = cache.get('movieGenres'); + + if (!movieGenres || movieGenres.length === 0) { + movieGenres = await fetchAndCacheMovieGenres(); + } + + if (movieGenres.length === 0) return null; + + // Filter out already rendered genres and genres with insufficient movie count + const availableGenres = movieGenres.filter(genre => + !renderedSections.has(`genre-${genre.Name.toLowerCase()}`) && + (genre.MovieCount || 0) >= minGenreMovieCount + ); + + if (availableGenres.length === 0) return null; + + const randomGenre = getRandomItem(availableGenres); + if (randomGenre) { + renderedSections.add(`genre-${randomGenre.Name.toLowerCase()}`); + } + + return randomGenre; + } + + /** + * Renders Halloween Movies section + * @param {HTMLElement} container - Container to append the section to + * @returns {boolean} - Success status + */ + function renderHalloweenMoviesSection(container) { + try { + if (halloweenMovies.length === 0) return false; + + const shuffled = [...halloweenMovies].sort(() => Math.random() - 0.5); + const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); + + if (limitedItems.length === 0) return false; + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping Halloween Movies section"); + return false; + } + + // Render the scrollable container + const scrollableContainer = window.cardBuilder.renderCards( + limitedItems, + 'Halloween Movies', + null + ); + + scrollableContainer.setAttribute('data-custom-section-id', 'halloween-movies'); + scrollableContainer.setAttribute('data-custom-section-name', 'Halloween Movies'); + scrollableContainer.style.order = 50; + + container.appendChild(scrollableContainer); + return true; + + } catch (err) { + ERR('Error rendering Halloween Movies section:', err); + return false; + } + } + + /** + * Renders Horror Movies section + * @param {HTMLElement} container - Container to append the section to + * @returns {boolean} - Success status + */ + function renderHorrorMoviesSection(container) { + try { + if (horrorMovies.length === 0) return false; + + const shuffled = [...horrorMovies].sort(() => Math.random() - 0.5); + const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); + + if (limitedItems.length === 0) return false; + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping Horror Movies section"); + return false; + } + + // Render the scrollable container + const scrollableContainer = window.cardBuilder.renderCards( + limitedItems, + 'Horror Movies', + halloweenViewMoreUrls.horror + ); + + scrollableContainer.setAttribute('data-custom-section-id', 'horror-movies'); + scrollableContainer.setAttribute('data-custom-section-name', 'Horror Movies'); + scrollableContainer.style.order = 51; + + container.appendChild(scrollableContainer); + return true; + + } catch (err) { + ERR('Error rendering Horror Movies section:', err); + return false; + } + } + + /** + * Renders Thriller Movies section + * @param {HTMLElement} container - Container to append the section to + * @returns {boolean} - Success status + */ + function renderThrillerMoviesSection(container) { + try { + if (thrillerMovies.length === 0) return false; + + const shuffled = [...thrillerMovies].sort(() => Math.random() - 0.5); + const limitedItems = shuffled.slice(0, SEASONAL_ITEM_LIMIT); + + if (limitedItems.length === 0) return false; + + // Check if cardBuilder is available + if (typeof window.cardBuilder === 'undefined' || !window.cardBuilder.renderCards) { + WARN("cardBuilder not available, skipping Thriller Movies section"); + return false; + } + + // Render the scrollable container + const scrollableContainer = window.cardBuilder.renderCards( + limitedItems, + 'Thriller Movies', + halloweenViewMoreUrls.thriller + ); + + scrollableContainer.setAttribute('data-custom-section-id', 'thriller-movies'); + scrollableContainer.setAttribute('data-custom-section-name', 'Thriller Movies'); + scrollableContainer.style.order = 52; + + container.appendChild(scrollableContainer); + return true; + + } catch (err) { + ERR('Error rendering Thriller Movies section:', err); + return false; + } + } + + + /** + * Renders all Halloween sections + * @param {HTMLElement} container - Container to append sections to + */ + async function renderAllHalloweenSections(container) { + if (!isHalloweenPeriod()) { + LOG('Not in Halloween period, skipping Halloween sections'); + return; + } + + try { + // Fetch Halloween data if not already loaded + if (halloweenMovies.length === 0) { + LOG('Fetching Halloween movie data...'); + const movieData = await fetchHalloweenMovieData(); + halloweenMovies = movieData.halloweenMovies; + horrorMovies = movieData.horrorMovies; + thrillerMovies = movieData.thrillerMovies; + halloweenViewMoreUrls = movieData.viewMoreUrls; + + } + + const results = [ + renderHalloweenMoviesSection(container), + renderHorrorMoviesSection(container), + renderThrillerMoviesSection(container) + ]; + + const successCount = results.filter(Boolean).length; + LOG(`Rendered ${successCount}/${results.length} Halloween sections`); + return successCount === results.length; + + } catch (err) { + ERR('Error rendering Halloween sections:', err); + } + } + + /** + * Renders all Christmas sections (stub for future implementation) + * @param {HTMLElement} container - Container to append sections to + */ + async function renderAllChristmasSections(container) { + if (!isChristmasPeriod()) { + LOG('Not in Christmas period, skipping Christmas sections'); + return; + } + + // TODO: Implement Christmas sections + LOG('Christmas sections not yet implemented'); + } + + /** + * Renders all New Years sections (stub for future implementation) + * @param {HTMLElement} container - Container to append sections to + */ + async function renderAllNewYearsSections(container) { + if (!isNewYearsPeriod()) { + LOG('Not in New Years period, skipping New Years sections'); + return; + } + + // TODO: Implement New Years sections + LOG('New Years sections not yet implemented'); + } + + /** + * Renders all Valentine's Day sections (stub for future implementation) + * @param {HTMLElement} container - Container to append sections to + */ + async function renderAllValentinesSections(container) { + if (!isValentinesPeriod()) { + LOG('Not in Valentine\'s Day period, skipping Valentine\'s Day sections'); + return; + } + + // TODO: Implement Valentine's Day sections + LOG('Valentine\'s Day sections not yet implemented'); + } + + /** + * Renders all seasonal sections based on current date + * @param {HTMLElement} container - Container to append sections to + */ + async function renderAllSeasonalSections(container) { + const sectionsToRender = []; + + // Add Halloween sections if in Halloween period + if (isHalloweenPeriod()) { + sectionsToRender.push(renderAllHalloweenSections(container)); + } + + // Add Christmas sections if in Christmas period + if (isChristmasPeriod()) { + sectionsToRender.push(renderAllChristmasSections(container)); + } + + // Add New Years sections if in New Years period + if (isNewYearsPeriod()) { + sectionsToRender.push(renderAllNewYearsSections(container)); + } + + // Add Valentine's Day sections if in Valentine's Day period + if (isValentinesPeriod()) { + sectionsToRender.push(renderAllValentinesSections(container)); + } + + if (sectionsToRender.length === 0) { + LOG('No seasonal periods active, skipping seasonal sections'); + return; + } + + const results = await Promise.all(sectionsToRender); + const successCount = results.filter(Boolean).length; + LOG(`Rendered ${successCount}/${sectionsToRender.length} seasonal section groups`); + } + + /************ 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; } - const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); - if (homeSectionsContainer && !homeSectionsContainer.querySelector('[data-custom-section-id]')) { - checkAndRenderCustomSections(); + // Try to find the home sections container with retry logic + // Retry every 100ms for up to 3 seconds + let homeSectionsContainer = null; + const maxRetries = 100; + let retries = 0; + + while (!homeSectionsContainer && retries < maxRetries) { + homeSectionsContainer = document.querySelector('.libraryPage:not(.hide) .homeSectionsContainer'); + + if (!homeSectionsContainer) { + retries++; + await new Promise(resolve => setTimeout(resolve, 100)); + } } - }, 2000); // Check every 2 seconds -}; + + if (!homeSectionsContainer) { + LOG('Home sections container not found after 3 seconds'); + return; + } + + if (retries > 0) { + LOG(`Found home sections container after ${retries} retries (${retries * 100}ms)`); + } + + // Check if sections are already rendered + const hasRenderedCustomSections = customHomeSections.some(section => + homeSectionsContainer.querySelector(`[data-custom-section-id="${section.id}"]`) + ); + + const hasRenderedNewAndTrendingSections = (enableNewMovies && homeSectionsContainer.querySelector('[data-custom-section-id="new-movies"]')) || + (enableNewEpisodes && homeSectionsContainer.querySelector('[data-custom-section-id="new-episodes"]')) || + (enableTrending && homeSectionsContainer.querySelector('[data-custom-section-id="trending-movies"]')); + + const hasRenderedDiscoverySections = (enableWatchlist && homeSectionsContainer.querySelector('[data-custom-section-id="watchlist"]')) || + (enableDiscovery && homeSectionsContainer.querySelector('[data-custom-section-id="watched-movie"]')) || + (enableSeasonal && homeSectionsContainer.querySelector('[data-custom-section-id="halloween-movies"]')); + + if (hasRenderedCustomSections || hasRenderedNewAndTrendingSections || hasRenderedDiscoverySections) { + return; + } + + // Set processing flag to prevent parallel execution + isProcessing = true; + + try { + LOG('Starting parallel initialization of home screen sections...'); + + // Run all initialization functions in parallel for faster loading + const initPromises = [ + // Initialize people cache in background (non-blocking) + initializePeopleCache(), + + // Render custom sections + renderAllCustomSections(homeSectionsContainer), + + // Render new and trending sections + renderAllNewAndTrendingSections(homeSectionsContainer), -// Start periodic check after a short delay -setTimeout(startPeriodicCheck, 1000); + // Render seasonal sections + renderAllSeasonalSections(homeSectionsContainer), + + // Render watchlist section + renderWatchlistSection(homeSectionsContainer), + + // Start preloading first batch of discovery sections (non-blocking) + // This will also set up infinite loading handlers if buffer has content + preloadNextSections() + ]; + + // Wait for all parallel operations to complete + await Promise.all(initPromises); + + LOG('All custom sections rendered successfully'); + } catch (error) { + ERR('Error rendering custom sections:', error); + } finally { + // Always reset the processing flag + isProcessing = false; + } + } + + if (window.KefinTweaksUtils) { + LOG('Registering home screen handler with KefinTweaksUtils'); + + // Register handler for all pages (breadcrumbs can appear on any detail page) + window.KefinTweaksUtils.onViewPage((view, element) => { + try { + // Run our custom code + checkAndRenderCustomSections(); + } catch (err) { + ERR('Home screen page change handler failed:', err); + } + }, { + immediate: true, + pages: ['home', 'home.html'] + }); + } else { + ERR('KefinTweaksUtils not available, breadcrumbs may not work correctly'); + } -// 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('New and Trending configuration - enableNewAndTrending:', enableNewAndTrending, 'enableNewMovies:', enableNewMovies, 'enableNewEpisodes:', enableNewEpisodes, 'enableTrending:', enableTrending); - LOG('Discovery configuration - enableWatchlist:', enableWatchlist, 'enableDiscovery:', enableDiscovery, 'enableSeasonal:', enableSeasonal); - LOG('Seasonal configuration - seasonalItemLimit:', SEASONAL_ITEM_LIMIT); - LOG('Processing flag status:', isProcessing); - - // Check multiple selectors - const selectors = [ - '.libraryPage: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'); - - // Check if LocalStorageCache is available - LOG('LocalStorageCache available:', typeof window.LocalStorageCache !== 'undefined'); - - // Check discovery section data availability - if (enableWatchlist) { + // 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('New and Trending configuration - enableNewAndTrending:', enableNewAndTrending, 'enableNewMovies:', enableNewMovies, 'enableNewEpisodes:', enableNewEpisodes, 'enableTrending:', enableTrending); + LOG('Discovery configuration - enableWatchlist:', enableWatchlist, 'enableDiscovery:', enableDiscovery, 'enableSeasonal:', enableSeasonal); + LOG('Seasonal configuration - seasonalItemLimit:', SEASONAL_ITEM_LIMIT); + LOG('Processing flag status:', isProcessing); + + // Check multiple selectors + const selectors = [ + '.libraryPage: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'); + + // Check if LocalStorageCache is available + LOG('LocalStorageCache available:', typeof window.LocalStorageCache !== 'undefined'); + + // Check discovery section data availability + if (enableWatchlist) { + const watchlistData = getWatchlistData(); + LOG('Watchlist data available:', watchlistData.length, 'items'); + } + + if (enableDiscovery) { + const movieHistory = getMovieHistoryData(); + const seriesProgress = getSeriesProgressData(); + LOG('Movie history available:', movieHistory.length, 'items'); + LOG('Series progress available:', seriesProgress.length, 'items'); + } + }; + + + // Debug function for seasonal sections + window.debugSeasonalSections = function() { + LOG('=== Seasonal Sections Debug ==='); + LOG('Configuration - enableSeasonal:', enableSeasonal, 'seasonalItemLimit:', SEASONAL_ITEM_LIMIT); + + LOG('Date checks:'); + LOG('- Halloween period:', isHalloweenPeriod()); + LOG('- Christmas period:', isChristmasPeriod()); + LOG('- New Years period:', isNewYearsPeriod()); + LOG('- Valentine\'s Day period:', isValentinesPeriod()); + + if (enableSeasonal) { + LOG('Halloween data:'); + LOG('- Halloween movies:', halloweenMovies.length); + LOG('- Horror movies:', horrorMovies.length); + LOG('- Thriller movies:', thrillerMovies.length); + LOG('- Halloween people:', halloweenPeople.length); + LOG('- ViewMore URLs:', halloweenViewMoreUrls); + + if (halloweenPeople.length > 0) { + const directors = halloweenPeople.filter(p => p.directorCount >= 2); + const actors = halloweenPeople.filter(p => p.actorCount >= 2); + const writers = halloweenPeople.filter(p => p.writerCount >= 2); + LOG('- Directors with 2+ movies:', directors.length); + LOG('- Actors with 2+ movies:', actors.length); + LOG('- Writers with 2+ movies:', writers.length); + } + } + }; + + // Debug function for movie genres cache + window.debugMovieGenres = async function() { + LOG('=== Movie Genres Cache Debug ==='); + + const cache = new window.LocalStorageCache(); + const movieGenres = cache.get('movieGenres'); + + if (movieGenres && movieGenres.length > 0) { + LOG(`Cached genres: ${movieGenres.length}`); + LOG('Sample genres:', movieGenres.slice(0, 5).map(g => ({ name: g.Name, id: g.Id }))); + + // Test genre lookups + const horrorId = await getGenreId('Horror'); + const thrillerId = await getGenreId('Thriller'); + LOG('Horror genre ID:', horrorId); + LOG('Thriller genre ID:', thrillerId); + } else { + LOG('No genres in cache, fetching...'); + const genres = await fetchAndCacheMovieGenres(); + LOG(`Fetched and cached ${genres.length} genres`); + } + + // Show cache age + const cacheAge = cache.getCacheAge('movieGenres'); + if (cacheAge !== null) { + LOG(`Cache age: ${cacheAge.toFixed(2)} hours`); + } + }; + + // Debug function for discovery sections + window.debugDiscoverySections = function() { + LOG('=== Discovery Sections Debug ==='); + LOG('Configuration - enableDiscovery:', enableDiscovery); + LOG('Configuration - enableInfiniteScroll:', enableInfiniteScroll); + LOG('Configuration - minPeopleAppearances:', minPeopleAppearances); + LOG('Configuration - minGenreMovieCount:', minGenreMovieCount); + LOG('Movies top people loaded:', moviesTopPeople !== null); + LOG('People cache complete:', isPeopleCacheComplete); + LOG('Is initializing people cache:', isInitializingPeopleCache); + LOG('Rendered sections count:', renderedSections.size); + LOG('Rendered sections:', Array.from(renderedSections)); + LOG('Is generating discovery sections:', isGeneratingDiscoverySections); + LOG('Preloaded sections count:', preloadedSections.length); + LOG('Preloaded section elements count:', preloadedSectionElements.length); + LOG('Is preloading sections:', isPreloadingSections); + + if (moviesTopPeople) { + LOG('Top people data:'); + LOG('- Actors:', moviesTopPeople.actors.length); + LOG('- Directors:', moviesTopPeople.directors.length); + LOG('- Writers:', moviesTopPeople.writers.length); + + if (moviesTopPeople.actors.length > 0) { + LOG('Sample actor:', moviesTopPeople.actors[0]); + } + if (moviesTopPeople.directors.length > 0) { + LOG('Sample director:', moviesTopPeople.directors[0]); + } + if (moviesTopPeople.writers.length > 0) { + LOG('Sample writer:', moviesTopPeople.writers[0]); + } + } + + // Test genre availability + const cache = new window.LocalStorageCache(); + const movieGenres = cache.get('movieGenres'); + LOG('Available genres:', movieGenres ? movieGenres.length : 0); + + if (movieGenres && movieGenres.length > 0) { + const genresWithEnoughMovies = movieGenres.filter(genre => + (genre.MovieCount || 0) >= minGenreMovieCount + ); + LOG(`Genres with ${minGenreMovieCount}+ movies:`, genresWithEnoughMovies.length); + + if (genresWithEnoughMovies.length > 0) { + LOG('Sample qualifying genres:', genresWithEnoughMovies.slice(0, 5).map(g => ({ + name: g.Name, + movieCount: g.MovieCount + }))); + } + } + + // Test watchlist data const watchlistData = getWatchlistData(); LOG('Watchlist data available:', watchlistData.length, 'items'); - } - - if (enableDiscovery) { + + // Test movie history const movieHistory = getMovieHistoryData(); - const seriesProgress = getSeriesProgressData(); LOG('Movie history available:', movieHistory.length, 'items'); - LOG('Series progress available:', seriesProgress.length, 'items'); - } -}; - - -// Debug function for seasonal sections -window.debugSeasonalSections = function() { - LOG('=== Seasonal Sections Debug ==='); - LOG('Configuration - enableSeasonal:', enableSeasonal, 'seasonalItemLimit:', SEASONAL_ITEM_LIMIT); - - LOG('Date checks:'); - LOG('- Halloween period:', isHalloweenPeriod()); - LOG('- Christmas period:', isChristmasPeriod()); - LOG('- New Years period:', isNewYearsPeriod()); - LOG('- Valentine\'s Day period:', isValentinesPeriod()); - - if (enableSeasonal) { - LOG('Halloween data:'); - LOG('- Halloween movies:', halloweenMovies.length); - LOG('- Horror movies:', horrorMovies.length); - LOG('- Thriller movies:', thrillerMovies.length); - LOG('- Halloween people:', halloweenPeople.length); - LOG('- ViewMore URLs:', halloweenViewMoreUrls); - if (halloweenPeople.length > 0) { - const directors = halloweenPeople.filter(p => p.directorCount >= 2); - const actors = halloweenPeople.filter(p => p.actorCount >= 2); - const writers = halloweenPeople.filter(p => p.writerCount >= 2); - LOG('- Directors with 2+ movies:', directors.length); - LOG('- Actors with 2+ movies:', actors.length); - LOG('- Writers with 2+ movies:', writers.length); + // Test favorites + fetchFavoriteItems('Movie').then(favorites => { + LOG('Favorite movies available:', favorites.length, 'items'); + }); + }; + + // Function to manually trigger discovery sections rendering from buffer + window.generateDiscoverySections = async function() { + LOG('Manually triggering discovery sections rendering from buffer...'); + const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); + if (homeSectionsContainer) { + const result = await renderNextDiscoveryGroup(homeSectionsContainer); + LOG('Discovery sections rendering result:', result); + } else { + LOG('Home sections container not found'); } - } -}; + }; -// Debug function for movie genres cache -window.debugMovieGenres = async function() { - LOG('=== Movie Genres Cache Debug ==='); - - const cache = new window.LocalStorageCache(); - const movieGenres = cache.get('movieGenres'); - - if (movieGenres && movieGenres.length > 0) { - LOG(`Cached genres: ${movieGenres.length}`); - LOG('Sample genres:', movieGenres.slice(0, 5).map(g => ({ name: g.Name, id: g.Id }))); + // Function to manually trigger preloading + window.preloadDiscoverySections = async function() { + LOG('Manually triggering discovery sections preloading...'); + await preloadNextSections(); + LOG('Preloading completed. Preloaded sections:', preloadedSections.length); + }; + + // Function to manually continue loading people data + window.continueLoadingPeopleData = async function() { + LOG('Manually continuing people data loading...'); + if (isInitializingPeopleCache) { + LOG('People cache initialization already in progress'); + return; + } - // Test genre lookups - const horrorId = await getGenreId('Horror'); - const thrillerId = await getGenreId('Thriller'); - LOG('Horror genre ID:', horrorId); - LOG('Thriller genre ID:', thrillerId); - } else { - LOG('No genres in cache, fetching...'); + isInitializingPeopleCache = true; + try { + const result = await fetchTopPeople(); + if (result) { + moviesTopPeople = result; + isPeopleCacheComplete = result.isComplete || false; + LOG('People data loading result:', result.isComplete ? 'Complete' : 'Partial'); + } + } finally { + isInitializingPeopleCache = false; + } + }; + + // Function to manually refresh movie genres cache + window.refreshMovieGenres = async function() { + LOG('Manually refreshing movie genres cache...'); + const cache = new window.LocalStorageCache(); + cache.clear('movieGenres'); const genres = await fetchAndCacheMovieGenres(); - LOG(`Fetched and cached ${genres.length} genres`); - } - - // Show cache age - const cacheAge = cache.getCacheAge('movieGenres'); - if (cacheAge !== null) { - LOG(`Cache age: ${cacheAge.toFixed(2)} hours`); - } -}; + LOG(`Refreshed cache with ${genres.length} genres`); + return genres; + }; -// Debug function for discovery sections -window.debugDiscoverySections = function() { - LOG('=== Discovery Sections Debug ==='); - LOG('Configuration - enableDiscovery:', enableDiscovery); - LOG('Configuration - enableInfiniteScroll:', enableInfiniteScroll); - LOG('Configuration - minPeopleAppearances:', minPeopleAppearances); - LOG('Configuration - minGenreMovieCount:', minGenreMovieCount); - LOG('Movies top people loaded:', moviesTopPeople !== null); - LOG('People cache complete:', isPeopleCacheComplete); - LOG('Is initializing people cache:', isInitializingPeopleCache); - LOG('Rendered sections count:', renderedSections.size); - LOG('Rendered sections:', Array.from(renderedSections)); - LOG('Is generating discovery sections:', isGeneratingDiscoverySections); - LOG('Preloaded sections count:', preloadedSections.length); - LOG('Preloaded section elements count:', preloadedSectionElements.length); - LOG('Is preloading sections:', isPreloadingSections); - - if (moviesTopPeople) { - LOG('Top people data:'); - LOG('- Actors:', moviesTopPeople.actors.length); - LOG('- Directors:', moviesTopPeople.directors.length); - LOG('- Writers:', moviesTopPeople.writers.length); + // Function to manually unhide preloaded sections for testing + window.unhidePreloadedSections = function() { + LOG('Manually unhiding preloaded sections...'); + const hiddenSections = document.querySelectorAll('[data-preloaded="true"]'); + LOG(`Found ${hiddenSections.length} hidden preloaded sections`); - if (moviesTopPeople.actors.length > 0) { - LOG('Sample actor:', moviesTopPeople.actors[0]); - } - if (moviesTopPeople.directors.length > 0) { - LOG('Sample director:', moviesTopPeople.directors[0]); - } - if (moviesTopPeople.writers.length > 0) { - LOG('Sample writer:', moviesTopPeople.writers[0]); - } - } - - // Test genre availability - const cache = new window.LocalStorageCache(); - const movieGenres = cache.get('movieGenres'); - LOG('Available genres:', movieGenres ? movieGenres.length : 0); - - if (movieGenres && movieGenres.length > 0) { - const genresWithEnoughMovies = movieGenres.filter(genre => - (genre.MovieCount || 0) >= minGenreMovieCount - ); - LOG(`Genres with ${minGenreMovieCount}+ movies:`, genresWithEnoughMovies.length); + let unhiddenCount = 0; + hiddenSections.forEach(section => { + section.style.display = ''; + section.removeAttribute('data-preloaded'); + unhiddenCount++; + }); - if (genresWithEnoughMovies.length > 0) { - LOG('Sample qualifying genres:', genresWithEnoughMovies.slice(0, 5).map(g => ({ - name: g.Name, - movieCount: g.MovieCount - }))); - } - } - - // Test watchlist data - const watchlistData = getWatchlistData(); - LOG('Watchlist data available:', watchlistData.length, 'items'); - - // Test movie history - const movieHistory = getMovieHistoryData(); - LOG('Movie history available:', movieHistory.length, 'items'); - - // Test favorites - fetchFavoriteItems('Movie').then(favorites => { - LOG('Favorite movies available:', favorites.length, 'items'); - }); -}; + LOG(`Unhid ${unhiddenCount} preloaded sections`); + return unhiddenCount; + }; -// Function to manually trigger discovery sections rendering from buffer -window.generateDiscoverySections = async function() { - LOG('Manually triggering discovery sections rendering from buffer...'); - const homeSectionsContainer = document.querySelector('.homeSectionsContainer'); - if (homeSectionsContainer) { - const result = await renderNextDiscoveryGroup(homeSectionsContainer); - LOG('Discovery sections rendering result:', result); - } else { - LOG('Home sections container not found'); - } -}; + // Function to test new movies section + window.testNewMovies = async function() { + LOG('Testing New Movies section...'); + const movies = await fetchNewMovies(); + LOG(`Found ${movies.length} new movies:`, movies.map(m => ({ name: m.Name, premiereDate: m.PremiereDate }))); + return movies; + }; -// Function to manually trigger preloading -window.preloadDiscoverySections = async function() { - LOG('Manually triggering discovery sections preloading...'); - await preloadNextSections(); - LOG('Preloading completed. Preloaded sections:', preloadedSections.length); -}; + // Function to test new episodes section + window.testNewEpisodes = async function() { + LOG('Testing New Episodes section...'); + const episodes = await fetchNewEpisodes(); + LOG(`Found ${episodes.length} new episodes from last 7 days (after deduplication):`, episodes.map(e => ({ + name: e.Name, + series: e.SeriesName, + episode: `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`, + premiereDate: e.PremiereDate + }))); + + // Group by series to show deduplication effect + const seriesGroups = {}; + episodes.forEach(episode => { + const series = episode.SeriesName || 'Unknown'; + if (!seriesGroups[series]) { + seriesGroups[series] = []; + } + seriesGroups[series].push(episode); + }); + + LOG('Episodes grouped by series:', Object.keys(seriesGroups).map(series => ({ + series: series, + episodeCount: seriesGroups[series].length, + episodes: seriesGroups[series].map(e => `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`) + }))); + + return episodes; + }; -// Function to manually continue loading people data -window.continueLoadingPeopleData = async function() { - LOG('Manually continuing people data loading...'); - if (isInitializingPeopleCache) { - LOG('People cache initialization already in progress'); - return; - } - - isInitializingPeopleCache = true; - try { - const result = await fetchTopPeople(); - if (result) { - moviesTopPeople = result; - isPeopleCacheComplete = result.isComplete || false; - LOG('People data loading result:', result.isComplete ? 'Complete' : 'Partial'); - } - } finally { - isInitializingPeopleCache = false; - } -}; - -// Function to manually refresh movie genres cache -window.refreshMovieGenres = async function() { - LOG('Manually refreshing movie genres cache...'); - const cache = new window.LocalStorageCache(); - cache.clear('movieGenres'); - const genres = await fetchAndCacheMovieGenres(); - LOG(`Refreshed cache with ${genres.length} genres`); - return genres; -}; - -// Function to manually unhide preloaded sections for testing -window.unhidePreloadedSections = function() { - LOG('Manually unhiding preloaded sections...'); - const hiddenSections = document.querySelectorAll('[data-preloaded="true"]'); - LOG(`Found ${hiddenSections.length} hidden preloaded sections`); - - let unhiddenCount = 0; - hiddenSections.forEach(section => { - section.style.display = ''; - section.removeAttribute('data-preloaded'); - unhiddenCount++; - }); - - LOG(`Unhid ${unhiddenCount} preloaded sections`); - return unhiddenCount; -}; - -// Function to test new movies section -window.testNewMovies = async function() { - LOG('Testing New Movies section...'); - const movies = await fetchNewMovies(); - LOG(`Found ${movies.length} new movies:`, movies.map(m => ({ name: m.Name, premiereDate: m.PremiereDate }))); - return movies; -}; - -// Function to test new episodes section -window.testNewEpisodes = async function() { - LOG('Testing New Episodes section...'); - const episodes = await fetchNewEpisodes(); - LOG(`Found ${episodes.length} new episodes from last 7 days (after deduplication):`, episodes.map(e => ({ - name: e.Name, - series: e.SeriesName, - episode: `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`, - premiereDate: e.PremiereDate - }))); - - // Group by series to show deduplication effect - const seriesGroups = {}; - episodes.forEach(episode => { - const series = episode.SeriesName || 'Unknown'; - if (!seriesGroups[series]) { - seriesGroups[series] = []; - } - seriesGroups[series].push(episode); - }); - - LOG('Episodes grouped by series:', Object.keys(seriesGroups).map(series => ({ - series: series, - episodeCount: seriesGroups[series].length, - episodes: seriesGroups[series].map(e => `S${e.ParentIndexNumber?.toString().padStart(2,'0') ?? '--'}E${e.IndexNumber?.toString().padStart(2,'0') ?? '--'}`) - }))); - - return episodes; -}; - -// Function to test deduplication logic with sample data -window.testDeduplication = function() { - LOG('Testing episode deduplication logic...'); - - // Create sample episodes for testing - const sampleEpisodes = [ - { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 1, ParentIndexNumber: 1 }, - { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 2, ParentIndexNumber: 1 }, - { Name: 'Episode 3', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 3, ParentIndexNumber: 1 }, - { Name: 'Episode 1', SeriesName: 'Test Series B', PremiereDate: '2024-01-16', IndexNumber: 1, ParentIndexNumber: 1 }, - { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 1, ParentIndexNumber: 2 }, - { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 2, ParentIndexNumber: 2 } - ]; - - LOG('Original episodes:', sampleEpisodes.map(e => ({ - series: e.SeriesName, - episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`, - date: e.PremiereDate - }))); - - const deduplicated = deduplicateEpisodesBySeriesAndDate(sampleEpisodes); - - LOG('After deduplication:', deduplicated.map(e => ({ - series: e.SeriesName, - episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`, - date: e.PremiereDate - }))); - - LOG(`Deduplication result: ${sampleEpisodes.length} episodes → ${deduplicated.length} episodes`); - - return deduplicated; -}; - -LOG('Home screen functionality initialized'); -LOG('Debug functions available: window.debugHomeScreen(), window.debugCustomSections(), window.debugDiscoverySections(), window.debugSeasonalSections(), window.debugMovieGenres(), window.refreshMovieGenres(), window.generateDiscoverySections(), window.preloadDiscoverySections(), window.continueLoadingPeopleData(), window.unhidePreloadedSections(), window.testNewMovies(), window.testNewEpisodes(), window.testDeduplication()'); + // Function to test deduplication logic with sample data + window.testDeduplication = function() { + LOG('Testing episode deduplication logic...'); + + // Create sample episodes for testing + const sampleEpisodes = [ + { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 1, ParentIndexNumber: 1 }, + { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 2, ParentIndexNumber: 1 }, + { Name: 'Episode 3', SeriesName: 'Test Series A', PremiereDate: '2024-01-15', IndexNumber: 3, ParentIndexNumber: 1 }, + { Name: 'Episode 1', SeriesName: 'Test Series B', PremiereDate: '2024-01-16', IndexNumber: 1, ParentIndexNumber: 1 }, + { Name: 'Episode 1', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 1, ParentIndexNumber: 2 }, + { Name: 'Episode 2', SeriesName: 'Test Series A', PremiereDate: '2024-01-17', IndexNumber: 2, ParentIndexNumber: 2 } + ]; + + LOG('Original episodes:', sampleEpisodes.map(e => ({ + series: e.SeriesName, + episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`, + date: e.PremiereDate + }))); + + const deduplicated = deduplicateEpisodesBySeriesAndDate(sampleEpisodes); + + LOG('After deduplication:', deduplicated.map(e => ({ + series: e.SeriesName, + episode: `S${e.ParentIndexNumber}E${e.IndexNumber}`, + date: e.PremiereDate + }))); + + LOG(`Deduplication result: ${sampleEpisodes.length} episodes → ${deduplicated.length} episodes`); + + return deduplicated; + }; + LOG('Home screen functionality initialized'); })(); diff --git a/infiniteScroll.js b/infiniteScroll.js index 8ecc11a..af67d13 100644 --- a/infiniteScroll.js +++ b/infiniteScroll.js @@ -7,8 +7,10 @@ const WARN = (...args) => console.warn('[InfiniteScroll]', ...args); const ERR = (...args) => console.error('[InfiniteScroll]', ...args); + LOG('Initializing...'); + // Supported pages (same as headerTabs) - const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html']; + const SUPPORTED_PAGES = ['tv.html', 'movies.html', 'music.html', 'tv', 'movies', 'music']; // Media type configurations - Read from centralized config or use defaults const MEDIA_CONFIGS = { @@ -22,6 +24,26 @@ context: 'tvshows', sortBy: 'SortName,ProductionYear' }, + 'music.html': { + includeItemTypes: 'Music', + context: 'music', + sortBy: 'SortName,ProductionYear' + }, + 'movies': { + includeItemTypes: 'Movie', + context: 'movies', + sortBy: 'SortName,ProductionYear' + }, + 'tv': { + includeItemTypes: 'Series', + context: 'tvshows', + sortBy: 'SortName,ProductionYear' + }, + 'music': { + includeItemTypes: 'Music', + context: 'music', + sortBy: 'SortName,ProductionYear' + } }; let userId = null; @@ -813,4 +835,6 @@ // Cleanup on page unload window.addEventListener('beforeunload', cleanup); + + LOG('Initialized successfully'); })(); diff --git a/injector.js b/injector.js index 8910337..0459f64 100644 --- a/injector.js +++ b/injector.js @@ -4,9 +4,12 @@ (function() { 'use strict'; + + console.log('[KefinTweaks Injector] Initializing...'); - // Configuration: Read from centralized config or use defaults - const ENABLED_SCRIPTS = window.KefinTweaksConfig?.scripts || { + // Configuration: Start with defaults, then merge user config + // This allows new scripts to work out of the box without requiring config updates + const ENABLED_SCRIPTS = { // UI and functional enhancements watchlist: true, // Watchlist functionality homeScreen: true, // Custom home screen sections @@ -20,10 +23,14 @@ infiniteScroll: true, // Infinite scroll functionality removeContinue: true, // Remove from continue watching functionality subtitleSearch: true, // Subtitle search functionality - playlist: true // Playlist view page modifications + playlist: true, // Playlist view page modifications + skinManager: true, // Skin selection and management // Note: Core functionality scripts (utils, cardBuilder, localStorageCache, modal) // are automatically enabled when needed by other scripts + + // Merge user config on top of defaults (allows users to override defaults) + ...window.KefinTweaksConfig?.scripts }; // Script definitions with dependencies and metadata @@ -35,6 +42,20 @@ dependencies: [], description: 'Common utilities for page view management and MutationObserver conversion' }, + { + name: 'userConfig', + script: 'userConfig.js', + css: null, + dependencies: [], + description: 'User-specific configuration for skins and other settings' + }, + { + name: 'skinManager', + script: 'skinManager.js', + css: null, + dependencies: ['utils', 'userConfig'], + description: 'Adds skin selection dropdown to display preferences page and manages skin CSS loading' + }, { name: 'cardBuilder', script: 'cardBuilder.js', @@ -153,7 +174,7 @@ css: null, dependencies: ['cardBuilder', 'utils'], description: 'Modifies playlist view page behavior to navigate to item details instead of playing, and adds play button to playlist items' - } + }, ]; // Auto-enable dependencies for enabled scripts @@ -243,7 +264,7 @@ function loadScript(filename) { return new Promise((resolve, reject) => { // Check if script is already loaded - const existingScript = document.querySelector(`script[src*="${filename}"]`); + const existingScript = document.querySelector(`script[src="${getScriptRoot()}${filename}"]`); if (existingScript) { console.log(`[KefinTweaks Injector] Script already loaded: ${filename}`); resolve(); @@ -268,33 +289,55 @@ }); } - // Load a script and its dependencies - async function loadScriptWithDependencies(scriptDef) { - try { - // Load dependencies first - only if they are enabled - for (const depName of scriptDef.dependencies) { - const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName); - if (depScript && ENABLED_SCRIPTS[depName]) { - await loadScriptWithDependencies(depScript); - } else if (depScript && !ENABLED_SCRIPTS[depName]) { - console.warn(`[KefinTweaks Injector] Dependency '${depName}' is disabled, skipping for '${scriptDef.name}'`); - } - } - - // 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; + // Recursively collect all dependencies for a given script + function collectAllDependencies(scriptDef, collected = new Set(), visited = new Set()) { + // Avoid infinite recursion + if (visited.has(scriptDef.name)) { + return collected; } + visited.add(scriptDef.name); + + // For each dependency + for (const depName of scriptDef.dependencies) { + // Only process enabled dependencies + if (!ENABLED_SCRIPTS[depName]) { + continue; + } + + const depScript = SCRIPT_DEFINITIONS.find(s => s.name === depName); + if (!depScript) { + continue; + } + + // Add to collected if we haven't already + if (!collected.has(depScript.name)) { + collected.add(depScript.name); + // Recursively collect this dependency's dependencies + collectAllDependencies(depScript, collected, visited); + } + } + + return collected; + } + + // Load a single script (assumes dependencies are already loaded) + async function loadScriptSync(scriptDef) { + // Check if already loaded + const isAlreadyLoaded = document.querySelector(`script[src*="${scriptDef.script}"]`); + if (isAlreadyLoaded) { + console.log(`[KefinTweaks Injector] Script already loaded: ${scriptDef.name}`); + return; + } + + // 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}`); } // Main initialization function @@ -307,17 +350,45 @@ return; } - // Get enabled scripts in dependency order + // Get enabled scripts const enabledScripts = SCRIPT_DEFINITIONS.filter(script => ENABLED_SCRIPTS[script.name]); - console.log(`[KefinTweaks Injector] Loading ${enabledScripts.length} scripts:`, - enabledScripts.map(s => s.name)); + // Step 1: Collect all dependencies from all enabled scripts + const allDependencyNames = new Set(); + for (const script of enabledScripts) { + const deps = collectAllDependencies(script); + deps.forEach(dep => allDependencyNames.add(dep)); + } - // Load all enabled scripts - const loadPromises = enabledScripts.map(script => loadScriptWithDependencies(script)); + // Step 2: Separate dependencies from non-dependencies + const dependencyScripts = SCRIPT_DEFINITIONS.filter(script => + allDependencyNames.has(script.name) && ENABLED_SCRIPTS[script.name] + ); + + const nonDependencyScripts = enabledScripts.filter(script => + !allDependencyNames.has(script.name) + ); + + console.log(`[KefinTweaks Injector] Found ${dependencyScripts.length} dependency scripts:`, + dependencyScripts.map(s => s.name)); + console.log(`[KefinTweaks Injector] Found ${nonDependencyScripts.length} non-dependency scripts:`, + nonDependencyScripts.map(s => s.name)); try { + // Step 3: Load all dependencies first + console.log('[KefinTweaks Injector] Loading dependencies first...'); + console.log(`[KefinTweaks Injector] Dependency load order:`, dependencyScripts.map(s => s.name)); + + let dependencyLoadPromises = []; + dependencyLoadPromises.push(...dependencyScripts.map(script => loadScriptSync(script))); + await Promise.all(dependencyLoadPromises); + + // Step 4: Load non-dependencies in parallel (their dependencies are already loaded) + let loadPromises = []; + console.log('[KefinTweaks Injector] Loading non-dependencies...'); + loadPromises.push(...nonDependencyScripts.map(script => loadScriptSync(script))); await Promise.all(loadPromises); + console.log('[KefinTweaks Injector] All scripts loaded successfully!'); // Dispatch custom event when all scripts are loaded diff --git a/kefinTweaks.js b/kefinTweaks.js index af3e0d4..0cfdae2 100644 --- a/kefinTweaks.js +++ b/kefinTweaks.js @@ -68,19 +68,22 @@ breadcrumbs: true, // Breadcrumb navigation for Movies, Series, Seasons, Episodes, Music // Branding and external integrations - updoot: false, // Integration with Jellyfin Updoot upvote functionality + updoot: false, // EXPERIMENTAL: Integration with Jellyfin Updoot upvote functionality - Needs more work // REQUIRES: ExclusiveElsewhere requires JellyfinEnhanced plugin https://github.com/n00bcodr/Jellyfin-Enhanced exclusiveElsewhere: false, // Custom branding when items aren't available on streaming services // Performance and stability improvements backdropLeakFix: true, // Fixes memory leaks from backdrop images when tab isn't focused dashboardButtonFix: true, // Proper back button behavior on dashboard - infiniteScroll: true, // Smooth infinite scrolling for large media libraries + infiniteScroll: true, // Smooth infinite scrolling. Supports Movie and TV libraries. Music to be added in the future. // Playback and media enhancements removeContinue: true, // Add "Remove from Continue Watching" functionality subtitleSearch: true, // Search and download subtitles directly from video OSD - playlist: true // Enhanced playlist view with better navigation + playlist: true, // Enhanced playlist view with better navigation + + // UI customization + skinManager: true // Skin selection and management for display preferences }, // ============================================================================ @@ -158,6 +161,71 @@ enableJellyseerr: false // Enable Jellyseerr integration for request functionality }, + // ============================================================================ + // SKIN CONFIGURATION + // ============================================================================ + // Configure additional skins available in the skin manager + // These will be merged with skins from userConfig.js + // Each skin can have a single URL (string) or multiple URLs (array) for complex themes + // Each skin can also have colorSchemes array for color variations specific to that skin + // Each skin should have an author field indicating who created the skin + skins: [ + // Add additional skins here that will be available to all users + // These are example skins - replace with your own or remove if not needed + /* Example of adding more skins: + { + name: 'Custom Theme', + author: 'username', + url: 'https://cdn.jsdelivr.net/gh/username/custom-theme.css', + colorSchemes: [] + }, + { + name: 'Multi-file Theme', + author: 'username', + url: [ + 'https://cdn.jsdelivr.net/gh/username/theme-base.css', + 'https://cdn.jsdelivr.net/gh/username/theme-extensions.css' + ], + colorSchemes: [ + { + name: 'Dark Blue', + url: 'https://cdn.jsdelivr.net/gh/username/theme-dark-blue.css' + }, + { + name: 'Light Green', + url: 'https://cdn.jsdelivr.net/gh/username/theme-light-green.css' + } + ] + }, + */ + ], + + // Default skin to use when no skin is selected + // This overrides the default selection from userConfig.js + defaultSkin: null, // Set to skin name (e.g., 'Dark Theme') or null to use userConfig default + + + // ============================================================================ + // THEME CONFIGURATION + // ============================================================================ + // Configure additional themes available in the theme manager + // These will be merged with themes from userConfig.js + // Each theme should have a name and URL pointing to a CSS file + themes: [ + // Add additional themes here that will be available to all users + // These are example themes - replace with your own or remove if not needed + /* Example of adding more themes: + { + name: 'Custom Dark Theme', + url: 'https://cdn.jsdelivr.net/gh/username/custom-dark-theme.css' + }, + { + name: 'Custom Light Theme', + url: 'https://cdn.jsdelivr.net/gh/username/custom-light-theme.css' + }, + */ + ], + // ============================================================================ // CUSTOM MENU LINKS CONFIGURATION // ============================================================================ diff --git a/playlist.js b/playlist.js index 0934c79..7373b58 100644 --- a/playlist.js +++ b/playlist.js @@ -11,7 +11,7 @@ const WARN = (...args) => console.warn('[Playlist]', ...args); const ERR = (...args) => console.error('[Playlist]', ...args); - LOG('Script loaded - JS Injector mode'); + LOG('Initializing...'); let currentPlaylistId = null; @@ -25,9 +25,13 @@ return false; } - const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer'); + let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer'); if (!childrenItemsContainer) { - return false; + childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible'); + if (!childrenItemsContainer) { + WARN('Children items container not found'); + return false; + } } const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]'); @@ -90,16 +94,24 @@ return; } - const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer'); + let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer'); if (!childrenItemsContainer) { - WARN('Children items container not found'); - return; + childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible'); + if (!childrenItemsContainer) { + WARN('Children items container not found'); + return; + } } const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]'); LOG(`Found ${playlistItems.length} playlist items`); playlistItems.forEach((item, index) => { + if (item.dataset.customPlaylistButton === 'true') { + LOG(`Playlist item ${index} already has a custom button, skipping`); + return; + } + const playlistItemId = item.getAttribute('data-playlistitemid'); const serverId = ApiClient.serverId(); @@ -114,31 +126,38 @@ // Add new click handler to navigate to details page item.addEventListener('click', (e) => { - // Check if the click target is within the listViewUserDataButtons container - const buttonsContainer = item.querySelector('.listViewUserDataButtons'); - if (buttonsContainer && buttonsContainer.contains(e.target)) { - LOG(`Click on button detected, not navigating for item: ${playlistItemId}`); - return; // Don't navigate when clicking on buttons - } - - e.preventDefault(); - e.stopPropagation(); - - const detailsUrl = `#/details?id=${playlistItemId}&serverId=${serverId}`; - LOG(`Navigating to: ${detailsUrl}`); - - // Use Jellyfin's navigation method - if (window.Emby && window.Emby.Page) { - window.Emby.Page.show(detailsUrl); - } else { - // Fallback to direct navigation - window.location.hash = detailsUrl; - } + handlePlaylistItemClick(e, item, playlistItemId, serverId); }); + + item.dataset.customPlaylistButton = 'true'; LOG(`Modified click handler for playlist item: ${playlistItemId}`); }); } + + function handlePlaylistItemClick(e, item, playlistItemId, serverId) { + // Check if the click target is within the listViewUserDataButtons container + const buttonsContainer = item.querySelector('.listViewUserDataButtons'); + if (buttonsContainer && buttonsContainer.contains(e.target)) { + LOG(`Click on button detected, not navigating for item: ${playlistItemId}`); + return; // Don't navigate when clicking on buttons + } + + e.preventDefault(); + e.stopPropagation(); + + const detailsUrl = `/details?id=${playlistItemId}&serverId=${serverId}`; + LOG(`Navigating to: ${detailsUrl}`); + + // Use Jellyfin's navigation method + if (Dashboard) { + Dashboard.navigate(detailsUrl); + } else { + // Fallback to direct navigation + const fullDetailsUrl = `${ApiClient.serverAddress()}/web/#${detailsUrl}`; + window.location.href = fullDetailsUrl; + } + } /** * Adds play button to each playlist item's listViewUserDataButtons container @@ -150,10 +169,13 @@ return; } - const childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer'); + let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer'); if (!childrenItemsContainer) { - WARN('Children items container not found'); - return; + childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible'); + if (!childrenItemsContainer) { + WARN('Children items container not found for play buttons'); + return; + } } const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]'); @@ -161,7 +183,6 @@ playlistItems.forEach((item, index) => { const playlistItemId = item.getAttribute('data-playlistitemid'); - const serverId = ApiClient.serverId(); if (!playlistItemId) { WARN(`Playlist item ${index} has no data-playlistitemid`); @@ -215,12 +236,54 @@ LOG(`Modifying playlist page for item: ${itemId}`); currentPlaylistId = itemId; - // Wait a bit for the page to fully load - setTimeout(() => { + // Poll for page elements to be ready (every 100ms for up to 10 seconds) + const pollInterval = 100; + const maxAttempts = 100; // 100ms * 100 = 10 seconds + let attempts = 0; + + const pollForElements = () => { + attempts++; + + const libraryPage = document.querySelector('.libraryPage:not(.hide)'); + if (!libraryPage) { + if (attempts < maxAttempts) { + setTimeout(pollForElements, pollInterval); + } else { + WARN('Library page not found after 10 seconds'); + } + return; + } + + let childrenItemsContainer = libraryPage.querySelector('.childrenItemsContainer'); + if (!childrenItemsContainer) { + childrenItemsContainer = libraryPage.querySelector('#listChildrenCollapsible'); + if (!childrenItemsContainer) { + if (attempts < maxAttempts) { + setTimeout(pollForElements, pollInterval); + } else { + WARN('Children items container not found after 10 seconds'); + } + return; + } + } + + const playlistItems = childrenItemsContainer.querySelectorAll('.listItem[data-playlistitemid]'); + if (playlistItems.length === 0) { + if (attempts < maxAttempts) { + setTimeout(pollForElements, pollInterval); + } else { + WARN('No playlist items found after 10 seconds'); + } + return; + } + + // Elements found, proceed with modifications modifyPlaylistItemClicks(); addPlayButtons(); LOG('Playlist page modifications complete'); - }, 500); + }; + + pollForElements(); } /** @@ -286,24 +349,5 @@ // Initialize the hook when the script loads initializePlaylistHook(); - // Also check current page on script load in case we're already on a playlist page - const currentUrl = window.location.hash; - if (currentUrl.includes('#/details')) { - const urlParams = new URLSearchParams(currentUrl.split('?')[1]); - const itemId = urlParams.get('id'); - - if (itemId) { - LOG(`Script loaded on details page with item ID: ${itemId}`); - isPlaylistPage(itemId).then(isPlaylist => { - if (isPlaylist) { - LOG(`Already on playlist page: ${itemId}`); - modifyPlaylistPage(itemId); - } - }).catch(error => { - ERR('Error checking current playlist page:', error); - }); - } - } - console.log('[Playlist] Module loaded and ready'); })(); diff --git a/removeContinue.js b/removeContinue.js index 5c748be..2e9a991 100644 --- a/removeContinue.js +++ b/removeContinue.js @@ -12,8 +12,8 @@ const LOG = (...args) => console.log('[RemoveContinue]', ...args); const WARN = (...args) => console.warn('[RemoveContinue]', ...args); const ERROR = (...args) => console.error('[RemoveContinue]', ...args); - - LOG('Remove continue watching functionality initialized'); + + LOG('Initializing...'); // Add CSS for positioning the remove button in top right corner const style = document.createElement('style'); @@ -291,5 +291,5 @@ processExistingOverlayContainers(); }; - LOG('Remove continue watching functionality loaded successfully'); + LOG('Initialized successfully'); })(); \ No newline at end of file diff --git a/search.css b/search.css index c9d1d0f..432ac63 100644 --- a/search.css +++ b/search.css @@ -47,4 +47,16 @@ div#searchPage>.jellyseerr-section { div#smart-search-results { padding-bottom: 0 !important; padding-right: 0 !important; +} + +label[for="searchTextInput"] { + display: none; +} + +.searchFieldsInner>.searchfields-icon { + display: none; +} + +.input-wrapper { + position: relative; } \ No newline at end of file diff --git a/search.js b/search.js index 2f99fc5..8093167 100644 --- a/search.js +++ b/search.js @@ -4,8 +4,8 @@ const LOG = (...args) => console.log('[SmartSearch]', ...args); const WARN = (...args) => console.warn('[SmartSearch]', ...args); const ERR = (...args) => console.error('[SmartSearch]', ...args); - - LOG('script load start'); + + LOG('Initializing...'); // Configuration const CONFIG = window.KefinTweaksConfig?.search || { @@ -14,7 +14,7 @@ // Track if we should block XHR requests (only for direct URL navigation with query params) let shouldBlockXHR = false; - let hasBlockedInitialSearch = false; + //let hasBlockedInitialSearch = false; // Override XHR to block automatic search requests from direct URL navigation const OriginalXHR = window.XMLHttpRequest; @@ -23,17 +23,15 @@ const originalOpen = xhr.open; const originalSend = xhr.send; - xhr.open = function(method, url, ...args) { - LOG('XHR request:', method, url); - + xhr.open = function(method, url, ...args) { // Check if this is a search request we want to block // Only block if we're in smart search mode and haven't blocked yet const isSearchRequest = url.includes('/Items?') && (url.includes('searchTerm=') || url.includes('query=')); - if (isSearchRequest && shouldBlockXHR && !hasBlockedInitialSearch) { + if (isSearchRequest && shouldBlockXHR) { LOG('Blocking initial automatic search XHR request:', url); - hasBlockedInitialSearch = true; + //hasBlockedInitialSearch = true; // Disable blocking after this first request shouldBlockXHR = false; LOG('XHR blocking disabled after initial search'); @@ -68,10 +66,11 @@ (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=')) { + if (window.location.hash.includes('#/search') && 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 + queryFromUrl = decodeURIComponent(queryFromUrl).replace(/\+/g, ' '); } } @@ -79,7 +78,7 @@ 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'))) { + if (queryFromUrl && window.location.pathname.includes('#/search')) { LOG('URL query detected, enabling XHR blocking for automatic search prevention'); shouldBlockXHR = true; // Enable XHR blocking for initial URL query } @@ -127,19 +126,6 @@ } } - // Function to wait for jellyseerrAPI to be available - async function waitForJellyseerrAPI(maxAttempts = 50, interval = 200) { - for (let i = 0; i < maxAttempts; i++) { - if (window.JellyfinEnhanced?.jellyseerrAPI) { - LOG('jellyseerrAPI found after', i * interval, 'ms'); - return true; - } - await new Promise(resolve => setTimeout(resolve, interval)); - } - WARN('jellyseerrAPI not found after', maxAttempts * interval, 'ms'); - return false; - } - // styles (dedupe by id) function addCustomStyles() { if (document.getElementById('smart-search-styles')) return; @@ -147,18 +133,17 @@ const style = document.createElement('style'); style.id = 'smart-search-styles'; style.textContent = ` - .smart-search-wrapper { margin: 12px 0 20px 0; } + .smart-search-wrapper { margin: 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-btn { padding:8px 12px; border:none; border-radius:4px; cursor:pointer; background:#444; color:#fff; } - .smart-search-btn.active { background:var(--btnSubmitColor); } + .smart-search-btn.active { background:#673AB7; } .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; + top: 50%; + transform: translateY(-50%); position: absolute; z-index: 1; right: 0; @@ -172,14 +157,15 @@ function updateSearchUrl(searchTerm, searchType = 'videos') { try { // Jellyfin uses hash-based routing, so we need to update the hash - const baseHash = '#/search.html'; + const urlSuffix = ApiClient._serverVersion.split('.')[1] > 10 ? '' : '.html'; + const baseHash = `#/search${urlSuffix}`; const params = new URLSearchParams(); + const trimmed = searchTerm.trim(); - if (searchTerm && searchTerm.trim()) { - params.set('query', searchTerm.trim()); + if (trimmed) { + params.set('query', trimmed); } params.set('type', searchType); - const newHash = `${baseHash}?${params.toString()}`; window.history.replaceState({}, '', window.location.pathname + newHash); LOG('Updated search URL hash:', newHash); @@ -197,7 +183,7 @@ userId, limit: '100', recursive: 'true', - searchTerm, + searchTerm: searchTerm.trim(), fields: 'PrimaryImageAspectRatio,CanDelete', imageTypeLimit: '1', enableTotalRecordCount: 'false', @@ -413,11 +399,11 @@ // 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) => { + sectionOrder.forEach((itemType) => { 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, null, index); + const section = window.cardBuilder.renderCards(items, title, null); frag.appendChild(section); totalShown += items.length; } @@ -439,10 +425,11 @@ // 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=')) { + if (window.location.hash.includes('#/search') && 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 + queryFromUrl = decodeURIComponent(queryFromUrl).replace(/\+/g, ' '); } } LOG('initSmartSearch - URL query:', queryFromUrl); @@ -462,6 +449,11 @@ wrapper.id = 'smart-search-wrapper'; wrapper.className = 'smart-search-wrapper'; + // Create the search icon element + const searchIcon = document.createElement('span'); + searchIcon.className = 'searchfields-icon material-icons search'; + searchIcon.setAttribute('aria-hidden', 'true'); + // Replace the original input with our smart search input const input = originalInput.cloneNode(true); input.id = 'searchTextInput'; // Use the same ID as original @@ -509,7 +501,14 @@ stats.id = 'smart-search-stats'; stats.className = 'smart-search-stats'; - wrapper.appendChild(input); + // Create input wrapper + const inputWrapper = document.createElement('div'); + inputWrapper.className = 'input-wrapper'; + + inputWrapper.appendChild(searchIcon); + inputWrapper.appendChild(input); + + wrapper.appendChild(inputWrapper); wrapper.appendChild(btnRow); wrapper.appendChild(stats); @@ -538,7 +537,10 @@ smartCoreBtn.classList.remove('active'); smartMusicBtn.classList.remove('active'); smartBooksBtn.classList.remove('active'); - smartRequestBtn.classList.remove('active'); + + if (smartRequestBtn) { + smartRequestBtn.classList.remove('active'); + } // Add active class to the selected button if (activeType === 'all') { @@ -549,7 +551,7 @@ smartMusicBtn.classList.add('active'); } else if (activeType === 'books') { smartBooksBtn.classList.add('active'); - } else if (activeType === 'request') { + } else if (activeType === 'request' && smartRequestBtn) { smartRequestBtn.classList.add('active'); } } @@ -698,31 +700,6 @@ 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); - - - LOG('Calling initSmartSearch'); - initSmartSearch(); - } - } - // Initialize search hook using utils function initializeSearchHook() { if (!window.KefinTweaksUtils) { @@ -753,14 +730,14 @@ } // Initialize the hook when the script loads - initializeSearchHook(); + //initializeSearchHook(); if (document.readyState==='loading') { document.addEventListener('DOMContentLoaded', () => { - init(); + initializeSearchHook(); }); } else { - init(); + initializeSearchHook(); } - LOG('script load end'); + LOG('Initialized successfully'); })(); diff --git a/skinManager.js b/skinManager.js new file mode 100644 index 0000000..7a1c9a9 --- /dev/null +++ b/skinManager.js @@ -0,0 +1,1505 @@ +// KefinTweaks Skin Manager +// Manages skin selection and CSS loading for the display settings page +// Adds a skin dropdown to mypreferencesdisplay.html and handles skin switching + +(function() { + 'use strict'; + + // Common logging function + const LOG = (...args) => console.log('[KefinTweaks SkinManager]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks SkinManager]', ...args); + const ERR = (...args) => console.error('[KefinTweaks SkinManager]', ...args); + + LOG('Initializing...'); + + // Configuration from user config + let SKINS_CONFIG = []; + let THEMES_CONFIG = []; + const STORAGE_KEY = 'kefinTweaks_selectedSkin'; + const THEME_STORAGE_KEY = 'kefinTweaks_selectedTheme'; + const COLOR_SCHEMES_STORAGE_KEY = 'kefinTweaks_selectedColorScheme'; + + // Load and merge skin configurations + function loadSkinConfig() { + const userSkins = window.KefinTweaksUserConfig?.skins || []; + const additionalSkins = window.KefinTweaksConfig?.skins || []; + + // Start with user skins (these take priority) + SKINS_CONFIG = [...userSkins]; + + // Add additional skins from main config, avoiding duplicates by name + additionalSkins.forEach(skin => { + const exists = SKINS_CONFIG.some(existingSkin => existingSkin.name === skin.name); + if (!exists) { + SKINS_CONFIG.push(skin); + } + }); + + // Ensure we always have at least a default skin + if (SKINS_CONFIG.length === 0) { + SKINS_CONFIG = [ + { + name: 'Default', + url: null + } + ]; + } + + LOG(`Loaded ${SKINS_CONFIG.length} skins (${userSkins.length} user + ${additionalSkins.length} additional)`); + } + + // Load and merge theme configurations + function loadThemeConfig() { + const userThemes = window.KefinTweaksUserConfig?.themes || []; + const additionalThemes = window.KefinTweaksConfig?.themes || []; + + // Start with user themes (these take priority) + THEMES_CONFIG = [...userThemes]; + + // Add additional themes from main config, avoiding duplicates by name + additionalThemes.forEach(theme => { + const exists = THEMES_CONFIG.some(existingTheme => existingTheme.name === theme.name); + if (!exists) { + THEMES_CONFIG.push(theme); + } + }); + + LOG(`Loaded ${THEMES_CONFIG.length} themes (${userThemes.length} user + ${additionalThemes.length} additional)`); + } + + + // Track loaded skin CSS URLs + let loadedSkinUrls = new Set(); + + // Tooltip management + let currentTooltip = null; + + /** + * Create and show a tooltip + * @param {string} text - The tooltip text + * @param {Element} target - The target element to position the tooltip near + */ + function showTooltip(text, target) { + // Remove existing tooltip + hideTooltip(); + + // Create tooltip element + currentTooltip = document.createElement('div'); + currentTooltip.className = 'kefin-tooltip'; + currentTooltip.textContent = text; + + // Add tooltip styles + currentTooltip.style.cssText = ` + position: absolute; + background: rgba(0, 0, 0, 0.9); + color: white; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + z-index: 10000; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + max-width: 200px; + word-wrap: break-word; + opacity: 0; + transition: opacity 0.2s ease; + `; + + // Add to document + document.body.appendChild(currentTooltip); + + // Position tooltip + positionTooltip(target); + + // Fade in + setTimeout(() => { + if (currentTooltip) { + currentTooltip.style.opacity = '1'; + } + }, 10); + } + + /** + * Position the tooltip relative to the target element + * @param {Element} target - The target element + */ + function positionTooltip(target) { + if (!currentTooltip || !target) return; + + const rect = target.getBoundingClientRect(); + const tooltipRect = currentTooltip.getBoundingClientRect(); + + // Position above the target element + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + let top = rect.top - tooltipRect.height - 8; + + // Adjust if tooltip would go off screen + if (left < 8) { + left = 8; + } else if (left + tooltipRect.width > window.innerWidth - 8) { + left = window.innerWidth - tooltipRect.width - 8; + } + + if (top < 8) { + // Position below if no room above + top = rect.bottom + 8; + } + + currentTooltip.style.left = `${left}px`; + currentTooltip.style.top = `${top}px`; + } + + /** + * Hide and remove the current tooltip + */ + function hideTooltip() { + if (currentTooltip) { + currentTooltip.style.opacity = '0'; + setTimeout(() => { + if (currentTooltip && currentTooltip.parentNode) { + currentTooltip.parentNode.removeChild(currentTooltip); + } + currentTooltip = null; + }, 200); + } + } + + /** + * Verify that the current skin/scheme/theme is properly applied + * @returns {boolean} True if verification passes, false otherwise + */ + function verifySkinApplication() { + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (!selectedSkin) { + ERR('Selected skin not found during verification'); + return false; + } + + // Check if skin CSS is properly loaded + if (selectedSkin.url) { + const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); + const hasCorrectSkin = Array.from(skinLinks).some(link => + link.getAttribute('data-skin') === selectedSkinName + ); + + if (!hasCorrectSkin) { + WARN(`Skin verification failed: Expected skin '${selectedSkinName}' not found in DOM`); + return false; + } + } + + // Check if theme is properly applied + const selectedThemeValue = localStorage.getItem(THEME_STORAGE_KEY); + if (selectedThemeValue) { + const selectedTheme = THEMES_CONFIG.find(theme => theme.name === selectedThemeValue); + if (selectedTheme) { + const cssThemeLink = document.getElementById('cssTheme'); + if (!cssThemeLink || !cssThemeLink.hasAttribute('data-kefin-custom-theme')) { + WARN(`Theme verification failed: Custom theme '${selectedThemeValue}' not properly applied`); + return false; + } + } + } + + // Check if color scheme is properly applied + const selectedColorSchemeName = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); + if (selectedColorSchemeName && selectedSkin.colorSchemes) { + const selectedColorScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === selectedColorSchemeName); + if (selectedColorScheme) { + const cssColorSchemesLink = document.getElementById('cssColorSchemes'); + if (!cssColorSchemesLink || !cssColorSchemesLink.hasAttribute('data-kefin-color-schemes')) { + WARN(`Color scheme verification failed: Color scheme '${selectedColorSchemeName}' not properly applied`); + return false; + } + } + } + + LOG('Skin application verification passed'); + return true; + } + + /** + * Initialize the skin manager with verification and retry logic + */ + function initialize(retryCount = 0) { + const MAX_RETRIES = 3; + + if (!window.KefinTweaksUtils) { + ERR('KefinTweaksUtils not available - skin manager cannot initialize'); + return; + } + + // Load and merge skin configurations + loadSkinConfig(); + + // Load and merge theme configurations + loadThemeConfig(); + + loadSelectedSkin(); + loadSelectedTheme(); + loadSelectedColorScheme(); + + // Register for the display preferences page + window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, { + pages: ['mypreferencesdisplay'], + immediate: true + }); + + // Register for all pages to add the appearance button + window.KefinTweaksUtils.onViewPage(handleAnyPage, { + pages: [], + immediate: true + }); + + LOG('SkinManager initialized'); + + // Verify skin application after a short delay to allow CSS to load + setTimeout(() => { + if (!verifySkinApplication()) { + if (retryCount < MAX_RETRIES) { + WARN(`Skin application verification failed, retrying... (attempt ${retryCount + 1}/${MAX_RETRIES})`); + setTimeout(() => { + initialize(retryCount + 1); + }, 500); // Wait 500ms before retry + } else { + ERR(`Skin application verification failed after ${MAX_RETRIES} attempts - giving up`); + } + } else { + LOG('Skin application verified successfully'); + } + }, 200); // Wait 200ms for CSS to load before verification + } + + /** + * Handle the display preferences page + * @param {string} view - The view name + * @param {Element} element - The view element + */ + function handleDisplayPreferencesPage(view, element) { + LOG('Display preferences page detected'); + + // Wait for the page to be fully loaded + setTimeout(() => { + addSkinDropdown(); + loadSelectedSkin(); + addColorSchemesDropdown(); + loadSelectedColorScheme(); + addThemeOptions(); + loadSelectedTheme(); + }, 100); + } + + /** + * Handle any page to add the appearance button + * @param {string} view - The view name + * @param {Element} element - The view element + */ + function handleAnyPage(view, element) { + // Skip the display preferences page as it's handled separately + if (view === 'mypreferencesdisplay') { + return; + } + + // Wait for the page to be fully loaded + setTimeout(() => { + addAppearanceButton(); + }, 100); + } + + /** + * Add the appearance button to the header + */ + function addAppearanceButton() { + const headerRight = document.querySelector('.headerRight'); + if (!headerRight) { + WARN('Header right section not found'); + return; + } + + // Check if appearance button already exists + if (headerRight.querySelector('.headerAppearanceButton')) { + LOG('Appearance button already exists'); + return; + } + + // Create the appearance button + const appearanceButton = document.createElement('button'); + appearanceButton.type = 'button'; + appearanceButton.setAttribute('is', 'paper-icon-button-light'); + appearanceButton.className = 'headerButton headerButtonRight headerAppearanceButton paper-icon-button-light'; + appearanceButton.title = 'Appearance'; + + // Create the icon span + const iconSpan = document.createElement('span'); + iconSpan.className = 'material-icons palette'; + iconSpan.setAttribute('aria-hidden', 'true'); + + appearanceButton.appendChild(iconSpan); + + // Add click event listener + appearanceButton.addEventListener('click', toggleAppearancePopover); + + // Insert before the search button (or at the end if no search button) + const searchButton = headerRight.querySelector('.headerSearchButton'); + if (searchButton) { + headerRight.insertBefore(appearanceButton, searchButton); + } else { + headerRight.appendChild(appearanceButton); + } + + LOG('Appearance button added successfully'); + } + + /** + * Toggle the appearance popover + */ + function toggleAppearancePopover(event) { + event.preventDefault(); + event.stopPropagation(); + + // Remove existing popover if it exists + const existingPopover = document.querySelector('.kefin-appearance-popover'); + if (existingPopover) { + existingPopover.remove(); + return; + } + + // Create the popover + createAppearancePopover(event.target); + } + + /** + * Create the appearance popover with skin and color scheme dropdowns + * @param {Element} button - The button that triggered the popover + */ + function createAppearancePopover(button) { + // Create backdrop + const backdrop = document.createElement('div'); + backdrop.className = 'dialogBackdrop dialogBackdropOpened'; + backdrop.setAttribute('data-modal-id', 'appearance-popover'); + backdrop.style.position = 'fixed'; + backdrop.style.top = '0'; + backdrop.style.left = '0'; + backdrop.style.width = '100%'; + backdrop.style.height = '100%'; + backdrop.style.zIndex = '9999'; + backdrop.style.backgroundColor = 'transparent'; + + // Create dialog container + const dialogContainer = document.createElement('div'); + dialogContainer.className = 'dialogContainer'; + dialogContainer.setAttribute('data-modal-id', 'appearance-popover'); + dialogContainer.style.position = 'fixed'; + dialogContainer.style.top = '0'; + dialogContainer.style.left = '0'; + dialogContainer.style.width = '100%'; + dialogContainer.style.height = '100%'; + dialogContainer.style.zIndex = '10000'; + dialogContainer.style.pointerEvents = 'none'; + + // Create dialog (popover) + const dialog = document.createElement('div'); + dialog.className = 'focuscontainer dialog smoothScrollY ui-body-a background-theme-a formDialog'; + dialog.style.position = 'absolute'; + dialog.style.minWidth = '280px'; + dialog.style.pointerEvents = 'auto'; + dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; + + // Create dialog content + const dialogContent = document.createElement('div'); + dialogContent.style.margin = '0'; + dialogContent.style.padding = '1.25em 1.5em 1.5em'; + dialogContent.style.display = 'flex'; + dialogContent.style.flexDirection = 'column'; + dialogContent.style.gap = '0.8em'; + + // Create skin dropdown + const skinContainer = createDropdown({ + label: 'Skin', + id: 'selectSkinPopover', + options: SKINS_CONFIG.map(skin => ({ + value: skin.name, + text: skin.name, + author: skin.author + })), + changeHandler: handleSkinChange, + isPopover: true + }); + dialogContent.appendChild(skinContainer); + + // Create color schemes dropdown + const colorSchemesContainer = createDropdown({ + label: 'Color Schemes', + id: 'selectColorSchemesPopover', + options: [], // Will be populated by updatePopoverColorSchemes + changeHandler: handleColorSchemeChange, + isPopover: true + }); + dialogContent.appendChild(colorSchemesContainer); + + // Assemble dialog + dialog.appendChild(dialogContent); + dialogContainer.appendChild(dialog); + + // Position the popover + positionPopover(dialog, button); + + // Add to document + document.body.appendChild(backdrop); + document.body.appendChild(dialogContainer); + + // Set current values from localStorage + const skinSelect = document.getElementById('selectSkinPopover'); + const colorSchemesSelect = document.getElementById('selectColorSchemesPopover'); + + if (skinSelect) { + const currentSkin = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + skinSelect.value = currentSkin; + } + + // Update color schemes dropdown based on current skin + updatePopoverColorSchemes(); + + // Add click outside to close + setTimeout(() => { + document.addEventListener('click', handlePopoverClickOutside); + }, 10); + + LOG('Appearance popover created with Jellyfin styling'); + } + + /** + * Create a generic dropdown with Jellyfin styling + * @param {Object} config - Configuration object + * @param {string} config.label - The dropdown label + * @param {string} config.id - The dropdown ID + * @param {Array} config.options - The dropdown options + * @param {Function} config.changeHandler - The change event handler + * @param {boolean} config.isPopover - Whether this is for a popover (affects styling) + * @param {Function} config.hoverHandler - Optional hover handler for tooltips + * @param {Function} config.focusHandler - Optional focus handler for tooltips + * @param {Function} config.blurHandler - Optional blur handler for tooltips + * @returns {Element} The dropdown container + */ + function createDropdown(config) { + const { + label, + id, + options = [], + changeHandler, + isPopover = false, + hoverHandler, + focusHandler, + blurHandler + } = config; + + const container = document.createElement('div'); + + // Create label + const labelElement = document.createElement('label'); + if (isPopover) { + labelElement.style.cssText = ` + display: block; + margin-bottom: 8px; + font-size: 14px; + color: var(--main-color, var(--accent, #fff)); + `; + } else { + labelElement.className = 'selectLabel'; + } + labelElement.textContent = label; + labelElement.setAttribute('for', id); + container.appendChild(labelElement); + + // Create select container with Jellyfin styling + const selectContainer = document.createElement('div'); + selectContainer.className = 'selectContainer'; + if (isPopover) { + selectContainer.style.marginBottom = '0px'; + } + + // Create select + const select = document.createElement('select'); + select.id = id; + select.setAttribute('is', 'emby-select'); + select.setAttribute('label', label); + select.className = 'emby-select-withcolor emby-select'; + + // Add options + options.forEach(option => { + const optionElement = document.createElement('option'); + optionElement.value = option.value; + optionElement.textContent = option.text; + if (option.author) { + optionElement.setAttribute('data-author', option.author); + } + if (option.url) { + optionElement.setAttribute('data-scheme-url', option.url); + } + select.appendChild(optionElement); + }); + + // Create the arrow container + const arrowContainer = document.createElement('div'); + arrowContainer.className = 'selectArrowContainer'; + + const hiddenDiv = document.createElement('div'); + hiddenDiv.style.visibility = 'hidden'; + hiddenDiv.style.display = 'none'; + hiddenDiv.textContent = '0'; + + const arrow = document.createElement('span'); + arrow.className = 'selectArrow material-icons keyboard_arrow_down'; + arrow.setAttribute('aria-hidden', 'true'); + arrow.style.marginTop = '.2em'; + + arrowContainer.appendChild(hiddenDiv); + arrowContainer.appendChild(arrow); + + // Assemble the select container + selectContainer.appendChild(select); + selectContainer.appendChild(arrowContainer); + + // Add event handlers + if (changeHandler) { + select.addEventListener('change', changeHandler); + } + if (hoverHandler) { + select.addEventListener('mouseover', hoverHandler); + select.addEventListener('mouseout', hideTooltip); + } + if (focusHandler) { + select.addEventListener('focus', focusHandler); + select.addEventListener('blur', blurHandler || hideTooltip); + } + + container.appendChild(selectContainer); + return container; + } + + /** + * Create a dropdown for the popover (wrapper for createDropdown) + * @param {string} label - The dropdown label + * @param {string} id - The dropdown ID + * @param {Array} options - The dropdown options + * @param {Function} changeHandler - The change event handler + * @returns {Element} The dropdown container + */ + function createPopoverDropdown(label, id, options, changeHandler) { + return createDropdown({ + label, + id, + options, + changeHandler, + isPopover: true + }); + } + + /** + * Handle skin change (unified for both popover and display page) + * @param {Event} event - The change event + */ + function handleSkinChange(event) { + const selectedSkinName = event.target.value; + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (!selectedSkin) { + ERR('Selected skin not found in configuration'); + return; + } + + LOG(`Skin changed to: ${selectedSkin.name}`); + + // Save skin selection to localStorage + localStorage.setItem(STORAGE_KEY, selectedSkinName); + + // Load the new skin + loadSkin(selectedSkin); + + // Set default color scheme for the new skin + const defaultColorScheme = getDefaultColorScheme(selectedSkin); + if (defaultColorScheme) { + localStorage.setItem(COLOR_SCHEMES_STORAGE_KEY, defaultColorScheme); + syncAllColorSchemeDropdowns(); + + // Load the default color scheme + const defaultScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === defaultColorScheme); + if (defaultScheme) { + updateColorSchemesCSS(defaultScheme.url); + } + } else { + localStorage.removeItem(COLOR_SCHEMES_STORAGE_KEY); + syncAllColorSchemeDropdowns(); + updateColorSchemesCSS(null); + } + } + + /** + * Handle color scheme change (unified for both popover and display page) + * @param {Event} event - The change event + */ + function handleColorSchemeChange(event) { + const selectedColorSchemeName = event.target.value; + const selectedOption = event.target.selectedOptions[0]; + + LOG(`Color scheme changed to: ${selectedColorSchemeName}`); + + // Save color scheme selection to localStorage + localStorage.setItem(COLOR_SCHEMES_STORAGE_KEY, selectedColorSchemeName); + + const schemeUrl = selectedOption.getAttribute('data-scheme-url'); + updateColorSchemesCSS(schemeUrl); + } + + /** + * Update the color schemes dropdown in the popover + */ + function updatePopoverColorSchemes() { + const colorSchemesSelect = document.getElementById('selectColorSchemesPopover'); + if (!colorSchemesSelect) return; + + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + // Get the color schemes container to show/hide it + // We need to go up to the container that holds both label and select + const selectContainer = colorSchemesSelect.closest('.selectContainer'); + const colorSchemesContainer = selectContainer ? selectContainer.parentNode : colorSchemesSelect.closest('div'); + + // Clear existing options + const options = colorSchemesSelect.querySelectorAll('option'); + options.forEach(option => { + option.remove(); + }); + + // Show/hide color schemes dropdown based on whether skin has color schemes + if (selectedSkin && selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { + // Show the container + colorSchemesContainer.style.display = ''; + + // Add color scheme options + selectedSkin.colorSchemes.forEach(scheme => { + const option = document.createElement('option'); + option.value = scheme.name; + option.textContent = scheme.name; + option.setAttribute('data-scheme-url', scheme.url); + colorSchemesSelect.appendChild(option); + }); + + // Set the current selection + const storedColorScheme = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); + if (storedColorScheme) { + colorSchemesSelect.value = storedColorScheme; + } else { + // Use the default color scheme for this skin + const defaultColorScheme = getDefaultColorScheme(selectedSkin); + if (defaultColorScheme) { + colorSchemesSelect.value = defaultColorScheme; + } + } + } else { + // Hide the container if no color schemes + colorSchemesContainer.style.display = 'none'; + } + } + + /** + * Position the popover relative to the button + * @param {Element} popover - The popover element + * @param {Element} button - The button element + */ + function positionPopover(popover, button) { + const rect = button.getBoundingClientRect(); + const popoverRect = popover.getBoundingClientRect(); + + // Position below the button, right-aligned using right property + let currentPos = rect.right - popoverRect.width; + let top = rect.bottom + 16; // Increased from 8 to 16 for more spacing + + // Adjust if popover would go off screen + if (currentPos < 8) { + currentPos = 8; + } else if (currentPos + popoverRect.width > window.innerWidth - 8) { + currentPos = window.innerWidth - popoverRect.width - 8; + } + + if (top + popoverRect.height > window.innerHeight - 8) { + // Position above if no room below + top = rect.top - popoverRect.height - 8; + } + + popover.style.right = `calc(100% - ${currentPos}px - 50px)`; + popover.style.top = `${top}px`; + } + + /** + * Handle clicks outside the popover to close it + * @param {Event} event - The click event + */ + function handlePopoverClickOutside(event) { + const backdrop = document.querySelector('.dialogBackdrop[data-modal-id]'); + const dialogContainer = document.querySelector('.dialogContainer[data-modal-id]'); + + if (backdrop && dialogContainer && + !dialogContainer.contains(event.target) && + !event.target.closest('.headerAppearanceButton')) { + + // Remove backdrop and dialog container + if (backdrop.parentNode) { + backdrop.parentNode.removeChild(backdrop); + } + if (dialogContainer.parentNode) { + dialogContainer.parentNode.removeChild(dialogContainer); + } + + document.removeEventListener('click', handlePopoverClickOutside); + } + } + + /** + * Add the skin dropdown to the display preferences page + */ + function addSkinDropdown() { + const settingsContainer = document.querySelector('.settingsContainer'); + if (!settingsContainer) { + WARN('Settings container not found'); + return; + } + + const form = settingsContainer.querySelector('form'); + if (!form) { + WARN('Settings form not found'); + return; + } + + const displayModeField = form.querySelector('.fldDisplayMode'); + if (!displayModeField) { + WARN('Display mode field not found'); + return; + } + + // Check if skin dropdown already exists + if (form.querySelector('.fldSkin')) { + LOG('Skin dropdown already exists'); + return; + } + + // Create the skin dropdown container + const skinContainer = document.createElement('div'); + skinContainer.className = 'fldSkin'; + + // Create the dropdown using the generic function + const dropdown = createDropdown({ + label: 'Skin', + id: 'selectSkin', + options: SKINS_CONFIG.map(skin => ({ + value: skin.name, + text: skin.name, + author: skin.author + })), + changeHandler: handleSkinChange, + hoverHandler: handleSkinSelectHover, + focusHandler: handleSkinSelectFocus + }); + + // Add to skin container + skinContainer.appendChild(dropdown); + + // Insert after the display mode field + displayModeField.parentNode.insertBefore(skinContainer, displayModeField.nextSibling); + + // Set the current selection + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const select = document.getElementById('selectSkin'); + if (select) { + select.value = selectedSkinName; + } + + LOG('Skin dropdown added successfully'); + } + + /** + * Handle hover events on the skin select dropdown + * @param {Event} event - The mouseover event + */ + function handleSkinSelectHover(event) { + const select = event.target; + const selectedSkinName = select.value; + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (selectedSkin && selectedSkin.author) { + let tooltipText = `Author: ${selectedSkin.author}`; + if (selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { + tooltipText += `\nColor Schemes: ${selectedSkin.colorSchemes.length}`; + } + showTooltip(tooltipText, select); + } + } + + /** + * Handle focus events on the skin select dropdown + * @param {Event} event - The focus event + */ + function handleSkinSelectFocus(event) { + const select = event.target; + const selectedSkinName = select.value; + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (selectedSkin && selectedSkin.author) { + let tooltipText = `Author: ${selectedSkin.author}`; + if (selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { + tooltipText += `\nColor Schemes: ${selectedSkin.colorSchemes.length}`; + } + showTooltip(tooltipText, select); + } + } + + /** + * Add the color schemes dropdown to the display preferences page + */ + function addColorSchemesDropdown() { + const settingsContainer = document.querySelector('.settingsContainer'); + if (!settingsContainer) { + WARN('Settings container not found'); + return; + } + + const form = settingsContainer.querySelector('form'); + if (!form) { + WARN('Settings form not found'); + return; + } + + const skinField = form.querySelector('.fldSkin'); + if (!skinField) { + WARN('Skin field not found - color schemes dropdown requires skin field'); + return; + } + + // Check if color schemes dropdown already exists + if (form.querySelector('.fldColorSchemes')) { + LOG('Color schemes dropdown already exists'); + return; + } + + // Create the color schemes dropdown container + const colorSchemesContainer = document.createElement('div'); + colorSchemesContainer.className = 'fldColorSchemes'; + + // Create the dropdown using the generic function + const dropdown = createDropdown({ + label: 'Color Schemes', + id: 'selectColorSchemes', + options: [], // Will be populated by updateColorSchemesDropdown + changeHandler: handleColorSchemeChange + }); + + // Add to color schemes container + colorSchemesContainer.appendChild(dropdown); + + // Insert after the skin field + skinField.parentNode.insertBefore(colorSchemesContainer, skinField.nextSibling); + + // Update color schemes based on current skin selection + updateColorSchemesDropdown(); + + LOG('Color schemes dropdown added successfully'); + } + + /** + * Synchronize all color scheme dropdowns with the current skin and localStorage + * This ensures both popover and display page dropdowns stay in sync + */ + function syncAllColorSchemeDropdowns() { + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + // Get all color scheme dropdowns + const displayDropdown = document.getElementById('selectColorSchemes'); + const popoverDropdown = document.getElementById('selectColorSchemesPopover'); + + // Update display page dropdown + if (displayDropdown) { + updateColorSchemesDropdown(); + } + + // Update popover dropdown + if (popoverDropdown) { + updatePopoverColorSchemes(); + } + + // Ensure localStorage has a valid color scheme for the current skin + const storedColorScheme = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); + if (selectedSkin && selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { + // Check if stored color scheme is valid for current skin + const isValidScheme = selectedSkin.colorSchemes.some(scheme => scheme.name === storedColorScheme); + if (!isValidScheme) { + // Remove invalid color scheme from localStorage + localStorage.removeItem(COLOR_SCHEMES_STORAGE_KEY); + LOG('Removed invalid color scheme from localStorage'); + } + } else { + // No color schemes available, remove from localStorage + localStorage.removeItem(COLOR_SCHEMES_STORAGE_KEY); + } + } + + /** + * Update the color schemes dropdown based on the selected skin + */ + function updateColorSchemesDropdown() { + const select = document.getElementById('selectColorSchemes'); + if (!select) { + return; + } + + // Get the currently selected skin + const skinSelect = document.getElementById('selectSkin'); + if (!skinSelect) { + return; + } + + const selectedSkinName = skinSelect.value; + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + // Clear existing options + const options = select.querySelectorAll('option'); + options.forEach(option => { + option.remove(); + }); + + // Show/hide the color schemes dropdown based on whether the skin has color schemes + const colorSchemesContainer = select.closest('.fldColorSchemes'); + if (colorSchemesContainer) { + if (selectedSkin && selectedSkin.colorSchemes && selectedSkin.colorSchemes.length > 0) { + // Add color scheme options + selectedSkin.colorSchemes.forEach(scheme => { + const option = document.createElement('option'); + option.value = scheme.name; + option.textContent = scheme.name; + option.setAttribute('data-scheme-url', scheme.url); + select.appendChild(option); + }); + + // Show the dropdown + colorSchemesContainer.style.display = ''; + LOG(`Updated color schemes dropdown with ${selectedSkin.colorSchemes.length} options for skin: ${selectedSkinName}`); + } else { + // Hide the dropdown if no color schemes + colorSchemesContainer.style.display = 'none'; + LOG(`Hiding color schemes dropdown - skin '${selectedSkinName}' has no color schemes`); + } + } + + // Set the selected color scheme + const selectedColorSchemeName = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); + if (selectedColorSchemeName) { + select.value = selectedColorSchemeName; + } else { + // Use the default color scheme for this skin + const defaultColorScheme = getDefaultColorScheme(selectedSkin); + if (defaultColorScheme) { + select.value = defaultColorScheme; + } + } + } + + /** + * Add custom theme options to the existing select#selectTheme dropdown + */ + function addThemeOptions() { + const themeSelect = document.getElementById('selectTheme'); + if (!themeSelect) { + WARN('Theme select dropdown not found'); + return; + } + + // Check if custom themes have already been added + if (themeSelect.hasAttribute('data-kefin-themes-added')) { + LOG('Custom theme options already added'); + return; + } + + // Check if Jellyfin has already added its options + if (themeSelect.options.length > 0) { + addCustomThemeOptions(themeSelect); + } else { + // Wait for Jellyfin to add its options using MutationObserver + LOG('Waiting for Jellyfin theme options to load...'); + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'childList' && themeSelect.options.length > 0) { + LOG('Jellyfin theme options detected, adding custom options'); + addCustomThemeOptions(themeSelect); + observer.disconnect(); // Stop observing once we've added our options + } + }); + }); + + observer.observe(themeSelect, { childList: true }); + + // Fallback timeout in case MutationObserver doesn't trigger + setTimeout(() => { + if (!themeSelect.hasAttribute('data-kefin-themes-added') && themeSelect.options.length > 0) { + LOG('Fallback: Adding custom theme options after timeout'); + addCustomThemeOptions(themeSelect); + } + observer.disconnect(); + }, 2000); + } + } + + /** + * Add the actual custom theme options to the select element + * @param {HTMLSelectElement} themeSelect - The theme select element + */ + function addCustomThemeOptions(themeSelect) { + // Add custom theme options + THEMES_CONFIG.forEach(theme => { + const option = document.createElement('option'); + option.value = theme.name; + option.textContent = theme.name; + option.setAttribute('data-theme-url', theme.url); + option.setAttribute('data-custom-theme', 'true'); + themeSelect.appendChild(option); + }); + + // Mark as processed + themeSelect.setAttribute('data-kefin-themes-added', 'true'); + + // Add event listener for theme changes + themeSelect.addEventListener('change', handleThemeChange); + + // Set the selected value from localStorage cache + const cachedTheme = localStorage.getItem(THEME_STORAGE_KEY); + if (cachedTheme) { + themeSelect.value = cachedTheme; + LOG(`Restored theme selection from cache: ${cachedTheme}`); + } + + LOG(`Added ${THEMES_CONFIG.length} custom theme options`); + } + + /** + * Handle theme selection change + * @param {Event} event - The change event + */ + function handleThemeChange(event) { + const selectedOption = event.target.selectedOptions[0]; + const selectedThemeValue = event.target.value; + + if (selectedOption) { + + LOG(`Custom theme changed to: ${selectedThemeValue}`); + + // Save selection to localStorage + localStorage.setItem(THEME_STORAGE_KEY, selectedThemeValue); + + // Custom theme selected + const themeUrl = selectedOption.getAttribute('data-theme-url') ?? `themes/${selectedThemeValue}/theme.css`; + + if (themeUrl) { + // Update the cssTheme link href + updateThemeCSS(themeUrl); + } + } else { + // Default Jellyfin theme selected + LOG(`Default theme changed to: ${selectedThemeValue}`); + + // Save selection to localStorage + localStorage.setItem(THEME_STORAGE_KEY, selectedThemeValue); + + // Reset to default Jellyfin theme behavior + resetThemeCSS(); + } + } + + /** + * Update the cssTheme link href to a custom theme URL + * @param {string} url - The custom theme URL + */ + function updateThemeCSS(url) { + let cssThemeLink = document.getElementById('cssTheme'); + + if (!cssThemeLink) { + // Create the cssTheme link if it doesn't exist + cssThemeLink = document.createElement('link'); + cssThemeLink.id = 'cssTheme'; + cssThemeLink.rel = 'stylesheet'; + cssThemeLink.type = 'text/css'; + cssThemeLink.setAttribute('data-kefin-custom-theme', 'true'); + document.head.appendChild(cssThemeLink); + LOG('Created cssTheme link'); + } + + cssThemeLink.href = url; + LOG(`Updated cssTheme link to: ${url}`); + } + + /** + * Reset the cssTheme link to default Jellyfin behavior + */ + function resetThemeCSS() { + const cssThemeLink = document.getElementById('cssTheme'); + + if (cssThemeLink && cssThemeLink.hasAttribute('data-kefin-custom-theme')) { + // Only remove if we created it + cssThemeLink.remove(); + LOG('Removed custom cssTheme link'); + } + } + + /** + * Load the currently selected theme + */ + function loadSelectedTheme() { + const selectedThemeValue = localStorage.getItem(THEME_STORAGE_KEY); + + if (selectedThemeValue) { + const selectedTheme = THEMES_CONFIG.find(theme => theme.name === selectedThemeValue); + + if (selectedTheme) { + LOG(`Loading selected custom theme: ${selectedTheme.name}`); + updateThemeCSS(selectedTheme.url); + + // Set the dropdown selection + const themeSelect = document.getElementById('selectTheme'); + if (themeSelect) { + themeSelect.value = selectedThemeValue; + } + } else { + // Check if it's a default Jellyfin theme + const themeSelect = document.getElementById('selectTheme'); + if (themeSelect) { + const option = themeSelect.querySelector(`option[value="${selectedThemeValue}"]`); + if (option && !option.hasAttribute('data-custom-theme')) { + LOG(`Loading selected default theme: ${selectedThemeValue}`); + themeSelect.value = selectedThemeValue; + resetThemeCSS(); + } else { + WARN(`Selected theme '${selectedThemeValue}' not found`); + } + } + } + } + } + + function removeColorSchemesCSS() { + const cssColorSchemesLink = document.getElementById('cssColorSchemes'); + if (cssColorSchemesLink && cssColorSchemesLink.hasAttribute('data-kefin-color-schemes')) { + cssColorSchemesLink.remove(); + LOG('Removed cssColorSchemes link'); + } + } + + + /** + * Update the cssColorSchemes link href to a custom color scheme URL + * @param {string|null} url - The color scheme URL, or null/empty to remove CSS + */ + function updateColorSchemesCSS(url) { + // Handle reset case (null or empty URL) + if (!url) { + removeColorSchemesCSS(); + return; + } + + let cssColorSchemesLink = document.getElementById('cssColorSchemes'); + + if (!cssColorSchemesLink) { + // Create the cssColorSchemes link if it doesn't exist + cssColorSchemesLink = document.createElement('link'); + cssColorSchemesLink.id = 'cssColorSchemes'; + cssColorSchemesLink.rel = 'stylesheet'; + cssColorSchemesLink.type = 'text/css'; + cssColorSchemesLink.setAttribute('data-kefin-color-schemes', 'true'); + + // Find the best insertion point to maintain CSS cascade order: + // cssTheme -> cssSkin(s) -> cssColorSchemes + const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); + let insertAfter = null; + + if (skinLinks.length > 0) { + // Insert after the last skin CSS link + insertAfter = skinLinks[skinLinks.length - 1]; + } else { + // No skin CSS, insert after cssTheme + insertAfter = document.getElementById('cssTheme'); + } + + if (insertAfter && insertAfter.parentNode) { + insertAfter.parentNode.insertBefore(cssColorSchemesLink, insertAfter.nextSibling); + } else { + // Fallback to appending to head if no reference point found + document.head.appendChild(cssColorSchemesLink); + } + LOG('Created cssColorSchemes link'); + } + + // Find the scheme by URL and update localStorage + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (selectedSkin && selectedSkin.colorSchemes) { + const matchingScheme = selectedSkin.colorSchemes.find(scheme => scheme.url === url); + if (matchingScheme) { + LOG(`Updated cssColorSchemes link to: ${url} for skin: ${selectedSkinName}`); + + // Update all color scheme dropdowns to match the applied scheme + const displayDropdown = document.getElementById('selectColorSchemes'); + const popoverDropdown = document.getElementById('selectColorSchemesPopover'); + + if (displayDropdown && matchingScheme) { + displayDropdown.value = matchingScheme.name; + LOG(`Updated display color scheme dropdown to: ${matchingScheme.name}`); + } + + if (popoverDropdown && matchingScheme) { + popoverDropdown.value = matchingScheme.name; + LOG(`Updated popover color scheme dropdown to: ${matchingScheme.name}`); + } + + if (cssColorSchemesLink) { + cssColorSchemesLink.href = url; + LOG(`Updated cssColorSchemes link to: ${url}`); + } + return; + } + } + // If no skin or no color schemes, reset to default + LOG('No skin or color schemes available, removing cssColorSchemes link'); + removeColorSchemesCSS(); + } + + + /** + * Load the currently selected color scheme + */ + function loadSelectedColorScheme() { + const selectedColorSchemeName = localStorage.getItem(COLOR_SCHEMES_STORAGE_KEY); + + if (selectedColorSchemeName) { + // Get the currently selected skin + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (selectedSkin && selectedSkin.colorSchemes) { + const selectedColorScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === selectedColorSchemeName); + + if (selectedColorScheme) { + LOG(`Loading selected color scheme: ${selectedColorScheme.name}`); + updateColorSchemesCSS(selectedColorScheme.url); + } else { + LOG(`Selected color scheme '${selectedColorSchemeName}' not found for skin '${selectedSkinName}', using default`); + // Use the default color scheme for this skin + const defaultColorScheme = getDefaultColorScheme(selectedSkin); + if (defaultColorScheme) { + const defaultScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === defaultColorScheme); + if (defaultScheme) { + updateColorSchemesCSS(defaultScheme.url); + } + } else { + updateColorSchemesCSS(null); + } + } + } else { + WARN(`No color schemes available for skin '${selectedSkinName}'`); + } + } else { + // No color scheme stored, use default for current skin + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (selectedSkin) { + const defaultColorScheme = getDefaultColorScheme(selectedSkin); + if (defaultColorScheme) { + const defaultScheme = selectedSkin.colorSchemes.find(scheme => scheme.name === defaultColorScheme); + if (defaultScheme) { + LOG(`Loading default color scheme for skin '${selectedSkinName}': ${defaultScheme.name}`); + updateColorSchemesCSS(defaultScheme.url); + } + } else { + LOG(`No default color scheme for skin '${selectedSkinName}', removing CSS`); + updateColorSchemesCSS(null); + } + } + } + } + + + /** + * Get the default color scheme for a skin + * @param {Object} skin - The skin configuration object + * @returns {string|null} The default color scheme name, or null if no schemes + */ + function getDefaultColorScheme(skin) { + if (!skin || !skin.colorSchemes || skin.colorSchemes.length === 0) { + return null; + } + return skin.colorSchemes[0].name; + } + + /** + * Get the default skin name from configuration + * @returns {string} The default skin name + */ + function getDefaultSkinName() { + // Check if a default skin is specified in the main configuration + const configDefaultSkin = window.KefinTweaksConfig?.defaultSkin; + if (configDefaultSkin) { + LOG(`Using configured default skin: ${configDefaultSkin}`); + return configDefaultSkin; + } + + // Fall back to 'Default' if no configuration is specified + return 'Default'; + } + + /** + * Load the currently selected skin + */ + function loadSelectedSkin() { + const selectedSkinName = localStorage.getItem(STORAGE_KEY) || getDefaultSkinName(); + const selectedSkin = SKINS_CONFIG.find(skin => skin.name === selectedSkinName); + + if (selectedSkin) { + LOG(`Loading selected skin: ${selectedSkin.name}`); + loadSkin(selectedSkin); + + // Update color schemes dropdown after skin is loaded + setTimeout(() => { + syncAllColorSchemeDropdowns(); + }, 100); + } else { + const defaultSkinName = getDefaultSkinName(); + WARN(`Selected skin '${selectedSkinName}' not found, falling back to ${defaultSkinName}`); + const defaultSkin = SKINS_CONFIG.find(skin => skin.name === defaultSkinName); + if (defaultSkin) { + loadSkin(defaultSkin); + + // Update color schemes dropdown after default skin is loaded + setTimeout(() => { + syncAllColorSchemeDropdowns(); + }, 100); + } + } + } + + /** + * Load a specific skin + * @param {Object} skin - The skin configuration object + */ + function loadSkin(skin) { + // Check if this skin is already loaded by looking at the DOM + const currentSkinLink = document.querySelector('link[data-kefin-skin="true"]'); + const currentSkinName = currentSkinLink ? currentSkinLink.getAttribute('data-skin') : null; + + if (currentSkinName === skin.name) { + LOG(`Skin '${skin.name}' is already loaded - skipping`); + return; + } + + // Use requestAnimationFrame to avoid blocking + requestAnimationFrame(() => { + removeAllSkinCSS(); + }); + + // Performance optimization: Load new CSS first, then remove old CSS + // This prevents the double-reflow/recalc that causes UI freezing + + // Step 1: Load the new CSS first (gets cached and starts loading) + requestAnimationFrame(() => { + let cssUrls = []; + if (Array.isArray(skin.url)) { + LOG(`Loading skin with ${skin.url.length} CSS files: ${skin.name}`); + cssUrls = skin.url; + skin.url.forEach(url => loadSkinCSS(url, skin.name)); + } else { + LOG(`Loading skin with single CSS file: ${skin.name}`); + cssUrls = [skin.url]; + loadSkinCSS(skin.url, skin.name); + } + }); + } + + /** + * Load CSS for a specific skin using link tags + * @param {string} url - The CSS URL + * @param {string} skinName - The skin name + */ + function loadSkinCSS(url, skinName) { + // Check if this CSS is already loaded + if (loadedSkinUrls.has(url)) { + LOG(`Skin CSS already loaded: ${url}`); + return; + } + + const link = document.createElement('link'); + link.id = `cssSkin-${Date.now()}-${Math.random()}`; // Unique ID to prevent conflicts + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = url; + link.setAttribute('data-kefin-skin', 'true'); + link.setAttribute('data-skin', skinName); + + // Insert after the cssTheme link to ensure skins take precedence over themes + const cssThemeLink = document.getElementById('cssTheme'); + if (cssThemeLink && cssThemeLink.parentNode) { + cssThemeLink.parentNode.insertBefore(link, cssThemeLink.nextSibling); + } else { + // Fallback to appending to head if cssTheme link not found + document.getElementById('reactRoot').after(link); + } + + loadedSkinUrls.add(url); + LOG(`Skin CSS loaded via link tag: ${url}`); + + // Defer expensive DOM operations (cssColorSchemes repositioning) + // to avoid blocking during the initial CSS load + requestAnimationFrame(() => { + const cssColorSchemesLink = document.getElementById('cssColorSchemes'); + if (cssColorSchemesLink && cssColorSchemesLink.parentNode) { + // Only reposition if it's not already in the right position + const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); + const lastSkinLink = skinLinks[skinLinks.length - 1]; + + if (lastSkinLink && lastSkinLink.nextSibling !== cssColorSchemesLink) { + // Remove it from its current position + cssColorSchemesLink.remove(); + // Insert it after the last skin CSS link + lastSkinLink.parentNode.insertBefore(cssColorSchemesLink, lastSkinLink.nextSibling); + LOG('Repositioned cssColorSchemes after skin CSS'); + } + } + }); + } + + /** + * Remove all previously loaded skin CSS + */ + function removeAllSkinCSS() { + const skinLinks = document.querySelectorAll('link[data-kefin-skin="true"]'); + + skinLinks.forEach(link => { + const url = link.href; + loadedSkinUrls.delete(url); + link.remove(); + LOG(`Removed skin CSS link: ${url}`); + }); + + // Clear the tracking set + loadedSkinUrls.clear(); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } + + LOG('Initialized successfully'); +})(); diff --git a/skins/chromic-kefin.css b/skins/chromic-kefin.css new file mode 100644 index 0000000..d70c726 --- /dev/null +++ b/skins/chromic-kefin.css @@ -0,0 +1,3 @@ +#indexPage, #moviesPage, #tvRecommendedPage, #musicRecommendedPage { + overflow-y: visible !important; +} \ No newline at end of file diff --git a/skins/elegantKefin.css b/skins/elegantKefin.css new file mode 100644 index 0000000..230e054 --- /dev/null +++ b/skins/elegantKefin.css @@ -0,0 +1,78 @@ +.layout-desktop .streaming-lookup-container { + position: absolute; + bottom: 100%; + z-index: 2; + margin: 0 !important; +} + +.layout-desktop .nextUpSection.verticalSection.detailVerticalSection { + display: flex; + flex-direction: column; + align-items: end; + outline: none; + border: var(--defaultBorder); + border-radius: var(--smallRadius); + background-color: var(--lighterGradientPointAlpha); +} + +.layout-desktop .nextUpSection .card.overflowBackdropCard { + width: auto !important; +} + +.layout-desktop .nextUpSection h2.sectionTitle.sectionTitle-cards { + border-bottom: var(--defaultBorder); + width: 100%; + margin: 0; + text-align: center; +} + +.layout-desktop .nextUpItems { + margin-left: 0 !important; +} + +.layout-desktop .nextUpSection .cardScalable, .layout-desktop .nextUpSection .cardText { + width: 18em; +} + +.layout-desktop .nextUpSection .cardBox { +} + +.layout-desktop .nextUpSection .cardOverlayContainer { + opacity: 1; +} + +.layout-desktop .nextUpSection .cardBox { + margin-top: 0; +} +.layout-desktop h3.parentName.musicParentName { + margin-bottom: 2rem; +} +.layout-desktop .nextUpSection .cardBox { + display: flex; + flex-direction: column; + align-items: center; +} + +.layout-desktop .nextUpSection { + position: absolute; + right: 2em; + bottom: 18vh; + z-index: 10; +} + +.layout-desktop .nextUpSection h2.sectionTitle { + margin-left: 2em; + margin-bottom: -0.6em; +} + +.layout-desktop .nextUpSection .card.overflowBackdropCard { + width: 18vw !important; +} + +#slides-container { + top: 2em !important; +} + +.slide .watchlist-button:not(:hover) { + background: transparent; +} \ No newline at end of file diff --git a/skins/flow-kefin.css b/skins/flow-kefin.css new file mode 100644 index 0000000..240efad --- /dev/null +++ b/skins/flow-kefin.css @@ -0,0 +1,14 @@ +/* Media bar slides container */ +div#slides-container { + margin-left: 250px !important; + width: calc(100% - 250px) !important; +} +/* body.home-screen .emby-tab-button { + display: flex; +} +body.home-screen .emby-tabs-slider { + display: flex; +} +body.home-screen .emby-tab-button > div { + align-self: normal; +} */ \ No newline at end of file diff --git a/skins/jamfin-kefin.css b/skins/jamfin-kefin.css new file mode 100644 index 0000000..e9b8398 --- /dev/null +++ b/skins/jamfin-kefin.css @@ -0,0 +1,29 @@ +.kefinTweaks-breadcrumbs-wrapper { + margin-left: 180px; + color: var(--theme-text-colour); + border-radius: var(--theme-roundness); + box-shadow: inset 0 1px var(--theme-menu-shadow-colour); + background-color: var(--theme-menu-background-colour); + backdrop-filter: blur(var(--theme-blur)) !important; + -webkit-backdrop-filter: blur(var(--theme-blur)) !important; + -moz-backdrop-filter: blur(var(--theme-blur)) !important; + -o-backdrop-filter: blur(var(--theme-blur)) !important; + -ms-backdrop-filter: blur(var(--theme-blur)) !important; + height: 100%; + display: flex !important; + align-items: center; +} + +.watchlist-icon .detailButton-content:before { + content: 'Watchlist'; +} + +.kefinTweaks-breadcrumbs-wrapper:hover { + background: var(--theme-menu-background-colour); +} + +/* Media bar slides container */ +div#slides-container { + margin-left: 250px !important; + width: calc(100% - 250px) !important; +} \ No newline at end of file diff --git a/skins/scyfin-kefin.css b/skins/scyfin-kefin.css new file mode 100644 index 0000000..e8c47f7 --- /dev/null +++ b/skins/scyfin-kefin.css @@ -0,0 +1,14 @@ +/* Media bar slides container */ +div#slides-container { + margin-left: 250px !important; + width: calc(100% - 250px) !important; +} +body.home-screen .emby-tab-button { + display: flex; +} +body.home-screen .emby-tabs-slider { + display: flex; +} +body.home-screen .emby-tab-button > div { + align-self: normal; +} \ No newline at end of file diff --git a/subtitleSearch.js b/subtitleSearch.js index decd3b1..128d71d 100644 --- a/subtitleSearch.js +++ b/subtitleSearch.js @@ -10,7 +10,7 @@ const WARN = (...args) => console.warn('[SubtitleSearch]', ...args); const ERR = (...args) => console.error('[SubtitleSearch]', ...args); - LOG('Script loaded - Subtitle Search functionality'); + LOG('Initializing...'); // State management let isInitialized = false; @@ -1259,5 +1259,6 @@ window.setSubtitleStreamIndex = setSubtitleStreamIndex; window.listenForSubtitleStreamChange = listenForSubtitleStreamChange; window.triggerPlaybackProgressEvent = triggerPlaybackProgressEvent; - + + LOG('Initialized successfully'); })(); diff --git a/userConfig.js b/userConfig.js new file mode 100644 index 0000000..3a16c9c --- /dev/null +++ b/userConfig.js @@ -0,0 +1,306 @@ +// KefinTweaks User Configuration +// User-specific settings and configurations for KefinTweaks features +// This file contains settings that users can customize for their specific needs + +(function() { + 'use strict'; + + // ============================================================================ + // USER CONFIGURATION + // ============================================================================ + + const USER_CONFIG = { + // ============================================================================ + // SKIN CONFIGURATION + // ============================================================================ + // Configure available skins for the display settings page + // These skins will be merged with skins from the main KefinTweaks config + // User-defined skins take priority over main config skins (no duplicates by name) + // Each skin can have a single URL (string) or multiple URLs (array) for complex themes + // Each skin can also have colorSchemes array for color variations specific to that skin + // Each skin should have an author field indicating who created the skin + skins: [ + { + name: 'Default', + author: 'Jellyfin', + url: null, // null means no additional CSS (default Jellyfin theme) + colorSchemes: [] + }, + { + name: 'ElegantFin', + author: 'lscambo13', + url: [ + 'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css', + 'https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/Theme/assets/add-ons/media-bar-plugin-support-latest-min.css', + 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFLow-RoundCastCrew.css', + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/episodelist/episodes_grid.css', + `${KefinTweaksConfig.scriptRoot}skins/elegantKefin.css` + ], + colorSchemes: [] + }, + { + name: 'Jellypane', + author: 'tedhinklater', + url: 'https://cdn.jsdelivr.net/gh/tedhinklater/Jellypane@main/Jellypane.css', + colorSchemes: [] + }, + { + name: 'Jellyfish', + author: 'n00bcodr', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/jellyfish@main/theme.css', + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Banana', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/banana.css' + }, + { + name: 'Coal', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coal.css' + }, + { + name: 'Coral', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/coral.css' + }, + { + name: 'Grass', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/grass.css' + }, + { + name: 'Jellyblue', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyblue.css' + }, + { + name: 'Jellyflix', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/jellyflix.css' + }, + { + name: 'Lavender', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/lavender.css' + }, + { + name: 'Mint', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/mint.css' + }, + { + name: 'Ocean', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/ocean.css' + }, + { + name: 'Peach', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/peach.css' + }, + { + name: 'Watermelon', + url: 'https://cdn.jsdelivr.net/gh/n00bcodr/Jellyfish/colors/watermelon.css' + } + ] + }, + { + name: 'Glassmorphism', + author: 'alexyle', + url: [ + 'https://cdn.jsdelivr.net/gh/alexyle/jellyfin-theme@main/glassmorphism/theme.css', + ], + colorSchemes: [] + }, + { + name: 'Zombie', + author: 'MakD', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/zombie_revived.css', + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Amazon Prime', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/amazon-prime.css' + }, + { + name: 'Apple TV', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/apple-tv.css' + }, + { + name: 'Disney', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/disney.css' + }, + { + name: 'HBO Max', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hbo-max.css' + }, + { + name: 'Hulu', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/hulu.css' + }, + { + name: 'Netflix', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/netflix.css' + }, + { + name: 'YouTube', + url: 'https://cdn.jsdelivr.net/gh/MakD/zombie-release@latest/color-schemes/youtube.css' + } + ] + }, + { + name: 'Jamfin', + author: 'JamsRepos', + url: [ + 'https://cdn.jsdelivr.net/gh/JamsRepos/Jamfin@latest/theme/complete.css', + `${KefinTweaksConfig.scriptRoot}skins/jamfin-kefin.css` + ], + colorSchemes: [] + }, + { + name: 'Scyfin', + author: 'loof2736', + url: [ + 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css', + `${KefinTweaksConfig.scriptRoot}skins/scyfin-kefin.css` + ], + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Seafoam', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css' + }, + { + name: 'Coral', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css' + }, + { + name: 'Snow', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css' + } + ] + }, + { + name: 'Catppuccin', + author: 'Catppuccin', + url: 'https://jellyfin.catppuccin.com/theme.css', + colorSchemes: [ + { + name: 'Latte', + url: 'https://jellyfin.catppuccin.com/catppuccin-latte.css' + }, + { + name: 'Frappé', + url: 'https://jellyfin.catppuccin.com/catppuccin-frappe.css' + }, + { + name: 'Macchiato', + url: 'https://jellyfin.catppuccin.com/catppuccin-macchiato.css' + }, + { + name: 'Mocha', + url: 'https://jellyfin.catppuccin.com/catppuccin-mocha.css' + } + ] + }, + { + name: 'Flow', + author: 'LitCastVlog', + url: [ + 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/ScyFlow-main.css', + `${KefinTweaksConfig.scriptRoot}skins/flow-kefin.css` + ], + colorSchemes: [ + { + name: 'Default', + url: '' + }, + { + name: 'Orange', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Orange.css' + }, + { + name: 'Pink', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Pink.css' + }, + { + name: 'Rainbow', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Rainbow.css' + }, + { + name: 'Red', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Red.css' + }, + { + name: 'White', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-White.css' + }, + { + name: 'Blue', + url: 'https://cdn.jsdelivr.net/gh/LitCastVlog/Flow@main/CSS/Themes/ScyFlow-Blue.css' + }, + { + name: 'Seafoam', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-seafoam.css' + }, + { + name: 'Coral', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-coral.css' + }, + { + name: 'Snow', + url: 'https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-snow.css' + } + ] + }, + { + name: 'Monochromic', + author: 'CTalvio', + url: [ + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/monochromic_preset.css', + `${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css` + ], + colorSchemes: [] + }, + { + name: 'Kaleidochromic', + author: 'CTalvio', + url: [ + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/kaleidochromic_preset.css', + `${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css` + ], + colorSchemes: [] + }, + { + name: 'Novachromic', + author: 'CTalvio', + url: [ + 'https://cdn.jsdelivr.net/gh/CTalvio/Ultrachromic/presets/novachromic_preset.css', + `${KefinTweaksConfig.scriptRoot}skins/chromic-kefin.css` + ], + colorSchemes: [] + } + // Add more skins here as needed + ], + + // ============================================================================ + // THEME CONFIGURATION + // ============================================================================ + // Configure available themes for color variations + // These themes will be added as options to the existing select#selectTheme dropdown + // When a custom theme is selected, the cssTheme link href will be updated to the custom URL + // Each theme should have a name and URL pointing to a CSS file + themes: [ + ] + }; + + // ============================================================================ + // CONFIGURATION EXPOSURE + // ============================================================================ + // Make user configuration available to scripts + window.KefinTweaksUserConfig = USER_CONFIG; + + console.log('[KefinTweaks UserConfig] User configuration loaded. Available at window.KefinTweaksUserConfig'); + +})(); diff --git a/utils.js b/utils.js index 6dde399..7fbc8dd 100644 --- a/utils.js +++ b/utils.js @@ -10,7 +10,7 @@ const WARN = (...args) => console.warn('[KefinTweaks Utils]', ...args); const ERR = (...args) => console.error('[KefinTweaks Utils]', ...args); - LOG('Utils script loaded'); + LOG('Initializing'); // Store the original onViewShow function let originalOnViewShow = null; @@ -142,26 +142,14 @@ * @returns {string|null} Current view name or null if not available */ function getCurrentView() { - // Try to get view from URL hash (primary method) const hash = window.location.hash; if (hash) { - // Extract page name from hash patterns like #/home.html, #/search.html, etc. - const pageMatch = hash.match(/#\/([^\/\?]+)\.html/); + // Extract page name from hash - match part after #/ and before ? or / + // This handles both .html pages (old) and pages without .html (Jellyfin 10.11+) + const pageMatch = hash.match(/#\/([^\/\?]+)/); if (pageMatch) { return pageMatch[1]; } - - // Fallback: look for /view/ pattern - const viewMatch = hash.match(/\/view\/([^\/\?]+)/); - if (viewMatch) { - return viewMatch[1]; - } - } - - // Try to get view from active page element (fallback) - const activePage = document.querySelector('.page:not(.hide)'); - if (activePage) { - return activePage.getAttribute('data-page') || activePage.className.split(' ').find(cls => cls.includes('Page')); } return null; @@ -287,12 +275,13 @@ // Expose utilities to global scope window.KefinTweaksUtils = { onViewPage, + notifyHandlers, getCurrentView, getHandlerCount, clearHandlers, addCustomMenuLink }; - LOG('KefinTweaks Utils loaded and available at window.KefinTweaksUtils'); - + LOG('Initialized successfully'); + LOG('Available at window.KefinTweaksUtils'); })(); diff --git a/watchlist.css b/watchlist.css index 827b2e1..8149863 100644 --- a/watchlist.css +++ b/watchlist.css @@ -9,6 +9,7 @@ /* Watchlist Tab Styling */ .sections.watchlist { padding-bottom: 20px; + margin-top: 20px; } .sections.watchlist .watchlist-tabs { @@ -35,6 +36,7 @@ } .sections.watchlist .watchlist-tabs button:hover { + color: rgba(200, 200, 200, 0.9); color: rgba(200, 200, 200, 0.9); background: rgba(255, 255, 255, 0.05); } @@ -43,6 +45,9 @@ color: rgba(200, 200, 200, 1); border-bottom-color: #ffffff; background: rgba(200, 200, 200, 0.1); + color: rgba(200, 200, 200, 1); + border-bottom-color: #ffffff; + background: rgba(200, 200, 200, 0.1); } /* Tab content styling - hide content by default, show only active */ @@ -133,12 +138,15 @@ gap: 16px; } -.progress-tab-header, .movie-history-header { +.progress-tab-header, .movie-history-header, .watchlist-header { border-bottom: 1px solid rgba(255, 255, 255, 0.1); display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 10px 0 10px 10px; + max-width: 1200px; + margin: 0 auto; } .progress-title { @@ -242,22 +250,50 @@ gap: 8px; } -.progress-header-stats-container, .movie-history-stats-container { +.progress-header-stats-container, .movie-history-stats-container, .watchlist-header-stats-container { display: flex; gap: 10px; } -.progress-header-stats { +.progress-header-stats, .watchlist-header-stats, .movie-history-stats { font-size: 14px; color: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.1); - padding: 6px 12px; + padding: 12px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.15); display: flex; align-items: center; } +.watchlist-header-right { + display: flex; + align-items: center; + gap: 10px; +} + +.layout-toggle-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + padding: 8px; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.layout-toggle-btn:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); +} + +.layout-toggle-btn .material-icons { + font-size: 20px; +} + .progress-stats-separator { color: rgba(255, 255, 255, 0.5); font-weight: 300; @@ -485,8 +521,20 @@ display: none; } - .progress-tab-header h2 { - display: none; + .tab-header { + display: flex; + flex-direction: column; + padding: 10px; + } + + .progress-header-stats-container, .movie-history-stats-container, .watchlist-header-stats-container { + flex-wrap: wrap; + justify-content: center; + } + + .tab-header h2 { + padding: 0; + margin: 0; } } @@ -718,17 +766,6 @@ display: none; } -.movie-history-stats { - font-size: 14px; - color: rgba(255, 255, 255, 0.7); - background: rgba(255, 255, 255, 0.1); - padding: 6px 12px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.15); - display: flex; - align-items: center; -} - .movie-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); @@ -972,19 +1009,7 @@ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; } - - .movie-history-header { - flex-direction: column; - align-items: flex-start; - gap: 10px; - align-items: center; - padding: 20px; - } - - .movie-history-header h2 { - display: none; - } - + .movie-poster { aspect-ratio: 16 / 9; } @@ -1623,15 +1648,6 @@ div[data-tab="watchlist"] .loading-message { color: rgba(255, 255, 255, 0.7); } -.episode-count-info { - background: rgba(255, 193, 7, 0.1); - border: 1px solid rgba(255, 193, 7, 0.3); - border-radius: 6px; - padding: 12px; - margin-bottom: 16px; - text-align: center; -} - .episode-count-info .episode-count { font-weight: 600; font-size: 16px; @@ -1639,6 +1655,15 @@ div[data-tab="watchlist"] .loading-message { } .episode-count-info { + background: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.3); + border-radius: 6px; + padding: 12px; + margin-bottom: 16px; + text-align: center; + display: flex; + gap: 10px; + justify-content: center; color: rgba(255, 255, 255, 0.9); font-size: 14px; } @@ -1809,4 +1834,66 @@ div[data-tab="watchlist"] .loading-message { .watchlist-button[data-active="true"], .watchlist-icon[data-active="true"] { color: rgb(34, 197, 94) !important; -} \ No newline at end of file +} + +.watchlist-button[data-active="true"] span, .watchlist-icon[data-active="true"] span { + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48; +} +.slide .watchlist-button::before { + content: 'bookmark'; + font-family: 'Material Icons'; + font-weight: 600; + display: inline-block; + font-size: 22px; + vertical-align: middle; + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48; +} +.slide .watchlist-button { + border-radius: 50%; + height: 50px; + width: 50px; + border: none; + cursor: pointer; + transition: color 0.2s; + -webkit-tap-highlight-color: #fff0; +} + +.card:has(button[data-played="true"]) .watchlist-button { + display: none; +} + +/* List View Styling */ +[data-layout='List'] .cardScalable { + display: none; +} + +[data-layout='List'] .itemsContainer { + display: flex; + flex-direction: column; +} + +[data-layout='List'] .verticalSection.emby-scroller-container { + max-width: 1200px; + margin: 0 auto; +} + +[data-layout='List'] .cardBox { + display: flex; + width: 100%; +} + +[data-layout='List'] .card { + width: 100% !important; + cursor: initial; +} + +[data-layout='List'] .cardText-first { + flex: 1; + text-align: left !important; +} + +[data-layout='List'] .cardText-secondary { + font-size: 100%; + padding-top: .24em; +} + diff --git a/watchlist.js b/watchlist.js index ed93c8d..d2fdf30 100644 --- a/watchlist.js +++ b/watchlist.js @@ -17,10 +17,10 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const WARN = (...args) => console.warn('[Watchlist]', ...args); const ERR = (...args) => console.error('[Watchlist]', ...args); - LOG('Script loaded - JS Injector mode'); + LOG('Initializing...'); // Initialize localStorage cache manager - const localStorageCache = new LocalStorageCache('kefinTweaks_', 24 * 60 * 60 * 1000); + const localStorageCache = new window.LocalStorageCache(); // Playback monitoring for watchlist cleanup let originalFetch = window.fetch; @@ -92,6 +92,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: Name: item.Name, SeriesId: item.SeriesId, SeriesName: item.SeriesName, + SeriesPrimaryImageTag: item.SeriesPrimaryImageTag, + ParentBackdropImageTags: item.ParentBackdropImageTags, + ParentThumbImageTag: item.ParentPrimaryImageTags, IndexNumber: item.IndexNumber, ParentIndexNumber: item.ParentIndexNumber, ImageTags: item.ImageTags, @@ -135,14 +138,10 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } // Watchlist tab refresh button - const watchlistTab = watchlistSection.querySelector('div[data-tab="watchlist"]'); - if (watchlistTab) { + const watchlistHeaderRight = watchlistSection.querySelector('.watchlist-header-right'); + if (watchlistHeaderRight) { const refreshBtn = createRefreshButton('watchlist', 'Watchlist'); - refreshBtn.style.position = 'absolute'; - refreshBtn.style.left = '50%'; - refreshBtn.style.top = '1rem'; - refreshBtn.style.transform = 'translate(-50%, 0%)'; - watchlistTab.appendChild(refreshBtn); + watchlistHeaderRight.appendChild(refreshBtn); } } @@ -514,11 +513,62 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } } + // Function to update the last watched display + function updateLastWatched(lastWatchedElement, episodeInfo) { + if (!lastWatchedElement || !episodeInfo) return; + + const timeAgo = formatTimeAgo(new Date().toISOString()); + const formattedDate = formatLastWatchedDate(new Date().toISOString()); + const episodeInfoText = `${episodeInfo.season}x${episodeInfo.episode.toString().padStart(2, '0')} "${episodeInfo.title}"`; + lastWatchedElement.innerHTML = `Last watched ${episodeInfoText} ${timeAgo} on ${formattedDate}.`; + } + + // Function to update last watched display after removing an episode from watched + async function updateLastWatchedAfterRemoval(seriesId) { + const cachedProgress = getCachedSeriesProgress(seriesId); + if (!cachedProgress || !cachedProgress.episodes) { + return; + } + + const progressCard = document.querySelector(`[data-series-id="${seriesId}"]`)?.closest('.progress-card'); + if (!progressCard) return; + + const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); + if (!lastWatchedElement) return; + + // Find the most recently watched episode from the cache + const watchedEpisodes = cachedProgress.episodes.filter(ep => + ep.UserData && ep.UserData.Played === true && ep.UserData.LastPlayedDate + ); + + if (watchedEpisodes.length > 0) { + // Sort by LastPlayedDate and get the most recent + const lastWatched = watchedEpisodes.sort((a, b) => + new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate) + )[0]; + + const episodeInfo = { + title: lastWatched.Name, + season: lastWatched.ParentIndexNumber || 0, + episode: lastWatched.IndexNumber || 0 + }; + updateLastWatched(lastWatchedElement, episodeInfo); + } else { + // No more watched episodes, clear the display + lastWatchedElement.innerHTML = ''; + } + } + // Show episode watched confirmation modal - function showEpisodeWatchedConfirmation(episodeId, element, episodeInfo = null, seriesId = null) { - // Create modal content + function showEpisodeWatchedConfirmation(episodeId, element, episodeInfo = null, seriesId = null, isWatched = false) { + // Create modal content based on watched status const episodeTitle = episodeInfo ? episodeInfo.title : 'Episode Title'; const episodeDetails = episodeInfo ? `Season ${episodeInfo.season}, Episode ${episodeInfo.episode}` : 'Season X, Episode Y'; + const message = isWatched + ? 'Are you sure you want to remove this episode from watched?' + : 'Are you sure you want to mark this episode as watched?'; + const confirmButtonText = isWatched ? 'Remove from Watched' : 'Mark as Watched'; + const confirmButtonIcon = isWatched ? 'remove_circle' : 'check'; const confirmationContent = `
@@ -527,7 +577,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
${episodeDetails}
- Are you sure you want to mark this episode as watched? + ${message}
@@ -535,11 +585,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: info Go To Episode - - @@ -549,13 +599,14 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Create modal using the generic modal system const modal = window.ModalSystem.create({ id: 'episode-confirmation-modal', - title: 'Mark Episode as Watched', + title: isWatched ? 'Remove Episode from Watched' : 'Mark Episode as Watched', content: confirmationContent, onOpen: (modalInstance) => { - // Store the episode ID and element for the confirmation + // Store the episode ID, element, and watched status for the confirmation modalInstance.dialog.dataset.episodeId = episodeId; modalInstance.dialog.dataset.elementType = element.classList.contains('progress-chunk') ? 'chunk' : 'button'; - modalInstance.dialog.dataset.seriesId = seriesId; + modalInstance.dialog.dataset.seriesId = episodeInfo.seriesId; + modalInstance.dialog.dataset.isWatched = isWatched; // Store season/episode for binary mode if (episodeInfo) { @@ -564,8 +615,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } // Add event listeners - const confirmBtn = modalInstance.dialog.querySelector('#confirm-mark-watched'); - const cancelBtn = modalInstance.dialog.querySelector('#cancel-mark-watched'); + const confirmBtn = modalInstance.dialog.querySelector('#confirm-episode-action'); + const cancelBtn = modalInstance.dialog.querySelector('#cancel-episode-action'); const viewEpisodeBtn = modalInstance.dialog.querySelector('#view-episode-btn'); viewEpisodeBtn.onclick = () => { @@ -582,6 +633,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const episodeId = modalInstance.dialog.dataset.episodeId; const elementType = modalInstance.dialog.dataset.elementType; const seriesId = modalInstance.dialog.dataset.seriesId; + const isCurrentlyWatched = modalInstance.dialog.dataset.isWatched === 'true'; // Find the original element let element = null; @@ -593,12 +645,26 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const episode = modalInstance.dialog.dataset.episode; element = progressBar.querySelector(`[data-season="${season}"][data-episode="${episode}"]`); } - } else { - element = document.querySelector(`[data-episode-id="${episodeId}"].episode-watched-toggle`); } - if (element) { + element = document.querySelector(`[data-episode-id="${episodeId}"].episode-watched-toggle`); + + if (isCurrentlyWatched) { + // Remove from watched + await toggleEpisodeWatchedStatus(episodeId, element, false); + + // Update last watched display + await updateLastWatchedAfterRemoval(seriesId); + } else { + // Mark as watched await toggleEpisodeWatchedStatus(episodeId, element); + + // Update the .progress-last-watched element with the newly watched episode + const progressCard = document.querySelector(`[data-series-id="${seriesId}"]`)?.closest('.progress-card'); + if (progressCard) { + const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); + updateLastWatched(lastWatchedElement, episodeInfo); + } } window.ModalSystem.close('episode-confirmation-modal'); @@ -611,6 +677,58 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }); } + // Function to update the unwatched episodes list, removing watched episodes + async function updateUnwatchedEpisodesList(seriesId) { + const progressCard = document.querySelector(`[data-series-id="${seriesId}"].progress-card`); + if (!progressCard) return; + + const unwatchedEpisodesList = progressCard.querySelector('.unwatched-episodes-list'); + if (!unwatchedEpisodesList) return; + + // Check if there are any episode items rendered + const episodeItems = unwatchedEpisodesList.querySelectorAll('.unwatched-episode-item'); + if (episodeItems.length === 0) return; + + // Get the updated cache to check which episodes are now watched + const cachedProgress = getCachedSeriesProgress(seriesId); + if (!cachedProgress || !cachedProgress.episodes) return; + + // Create a set of watched episode identifiers (season + episode number) + const watchedEpisodes = new Set(); + cachedProgress.episodes.forEach(ep => { + if (ep.UserData && ep.UserData.Played === true) { + // For multi-part episodes, mark all parts as watched + const expanded = expandMultiPartEpisodes(ep); + expanded.forEach(expandedEp => { + watchedEpisodes.add(`${expandedEp.ParentIndexNumber}_${expandedEp.expandedIndex}`); + }); + } + }); + + // Remove any episode items that are now watched + episodeItems.forEach(item => { + const season = item.getAttribute('data-season'); + const episode = item.getAttribute('data-episode'); + const key = `${season}_${episode}`; + + if (watchedEpisodes.has(key)) { + // Fade out and remove the item + item.style.opacity = '0.5'; + item.style.transition = 'opacity 0.3s ease'; + + setTimeout(() => { + item.remove(); + + // Check if no more episodes remain + const remainingEpisodes = unwatchedEpisodesList.querySelectorAll('.unwatched-episode-item'); + if (remainingEpisodes.length === 0) { + unwatchedEpisodesList.innerHTML = '
No unwatched episodes found
'; + } + }, 300); + } + }); + } + // Show mark all episodes confirmation modal function showMarkAllEpisodesConfirmation(seriesId, buttonElement, seriesInfo) { // Create modal content @@ -658,8 +776,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Find the mark all button const markAllBtn = document.querySelector(`[data-series-id="${seriesId}"].mark-all-watched-btn`); - if (markAllBtn) { - await markAllEpisodesAsWatched(seriesId, markAllBtn); + if (confirmBtn) { + await markAllEpisodesAsWatched(seriesId, confirmBtn); } window.ModalSystem.close('mark-all-confirmation-modal'); @@ -740,7 +858,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Tab state tracking to avoid unnecessary re-rendering const tabStates = { - progress: { currentPage: 1, currentSearch: '', currentSort: 'lastWatched', currentDirection: 'desc', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false }, + progress: { currentPage: 1, currentSearch: '', currentSort: 'lastWatched', currentDirection: 'desc', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false, isRendering: false }, history: { currentPage: 1, currentSearch: '', currentSort: 'lastWatched', currentDirection: 'desc', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false }, watchlist: { currentPage: 1, currentSearch: '', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false }, statistics: { currentPage: 1, currentSearch: '', hasContent: false, lastDataHash: '', isFetching: false, isDataFetched: false } @@ -822,7 +940,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Safe element getter that targets the visible library page function getElementByIdSafe(elementId) { const libraryPage = getVisibleLibraryPage(); - return libraryPage ? libraryPage.querySelector(`#${elementId}`) : document.getElementById(elementId); + return libraryPage ? libraryPage.querySelector(`#${elementId}`) : getElementByIdSafe(elementId); } // Add custom CSS for watchlist icon @@ -1086,14 +1204,22 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: ep.PremiereDate && new Date(ep.PremiereDate) <= today ); - // Count watched episodes + // Expand multi-part episodes to get the actual total count + const expandedForCount = []; + airedEpisodes.forEach(episode => { + expandedForCount.push(...expandMultiPartEpisodes(episode)); + }); + progressData.totalEpisodes = expandedForCount.length; + + // Count watched episodes - expand multi-part episodes and count all parts if watched const watchedEpisodes = airedEpisodes.filter(ep => ep.UserData && ep.UserData.Played === true ); - - // Update metrics - progressData.totalEpisodes = airedEpisodes.length; - progressData.watchedCount = watchedEpisodes.length; + const watchedExpanded = []; + watchedEpisodes.forEach(episode => { + watchedExpanded.push(...expandMultiPartEpisodes(episode)); + }); + progressData.watchedCount = watchedExpanded.length; progressData.remainingCount = progressData.totalEpisodes - progressData.watchedCount; progressData.percentage = progressData.totalEpisodes > 0 ? Math.round((progressData.watchedCount / progressData.totalEpisodes) * 100) @@ -1131,22 +1257,37 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: episode.UserData.Played = isWatched; episode.UserData.IsPlayed = isWatched; - // Set LastPlayedDate if marking as watched + // Update LastPlayedDate based on watched status if (isWatched) { episode.UserData.LastPlayedDate = new Date().toISOString(); + } else { + // Clear LastPlayedDate when unwatching + episode.UserData.LastPlayedDate = null; } LOG(`Updated episode ${episodeId} watched status to ${isWatched} in memory cache`); } } - // Update basic statistics - if (isWatched) { - cachedProgress.watchedCount = Math.min(cachedProgress.watchedCount + 1, cachedProgress.totalEpisodes); - cachedProgress.remainingCount = Math.max(cachedProgress.remainingCount - 1, 0); - } else { - cachedProgress.watchedCount = Math.max(cachedProgress.watchedCount - 1, 0); - cachedProgress.remainingCount = Math.min(cachedProgress.remainingCount + 1, cachedProgress.totalEpisodes); + // Recalculate statistics by actually counting watched episodes + // This accounts for multi-part episodes and is more reliable than incrementing/decrementing + if (Array.isArray(cachedProgress.episodes) && cachedProgress.episodes.length > 0) { + // Expand multi-part episodes to get accurate counts + const expandedEpisodes = []; + cachedProgress.episodes.forEach(ep => { + expandedEpisodes.push(...expandMultiPartEpisodes(ep)); + }); + + // Count watched episodes by checking UserData.Played status + const watchedCount = cachedProgress.episodes.filter(ep => + ep.UserData && ep.UserData.Played === true + ).reduce((count, ep) => count + expandMultiPartEpisodes(ep).length, 0); + + // Total is the count of all expanded episodes + const totalEpisodes = expandedEpisodes.length; + cachedProgress.watchedCount = watchedCount; + cachedProgress.remainingCount = totalEpisodes - watchedCount; + cachedProgress.totalEpisodes = totalEpisodes; } // Recalculate percentage @@ -1190,9 +1331,12 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: episode.UserData.Played = isWatched; episode.UserData.IsPlayed = isWatched; - // Set LastPlayedDate if marking as watched + // Update LastPlayedDate based on watched status if (isWatched) { episode.UserData.LastPlayedDate = new Date().toISOString(); + } else { + // Clear LastPlayedDate when unwatching + episode.UserData.LastPlayedDate = null; } // Recalculate series metrics @@ -1295,6 +1439,24 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: unwatchedToggle.classList.remove('hide'); } } + + const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); + if (lastWatchedElement) { + if (updatedProgress.lastWatchedEpisode) { + const episodeInfo = { + title: updatedProgress.lastWatchedEpisode.Name, + season: updatedProgress.lastWatchedEpisode.ParentIndexNumber, + episode: updatedProgress.lastWatchedEpisode.IndexNumber + }; + updateLastWatched(lastWatchedElement, episodeInfo); + } else { + // No episodes watched yet + lastWatchedElement.innerHTML = ''; + } + } + + // Update the unwatched episodes list to remove watched episodes + await updateUnwatchedEpisodesList(seriesId); LOG(`Updated UI for series: ${updatedProgress.series.Name} (${updatedProgress.percentage}% complete)`); } @@ -1857,15 +2019,10 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: LOG(`Hash updated: pageTab=${pageTab}, page=${page}, search="${search}", movieSearch="${movieSearch}"`, newUrl); } - function isProgressTabActive() { - const params = getUrlParams(); - return params.pageTab === 'progress'; - } - // Hash change listener for browser navigation function setupHashChangeListener() { window.addEventListener('hashchange', () => { - if (!window.location.hash.includes('home.html')) { + if (!window.location.hash.includes('#/home')) { return; } @@ -1925,7 +2082,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } LOG('Initializing progress tab'); - renderProgressContent(); + //renderProgressContent(); try { await fetchAllProgressData(true); @@ -2322,11 +2479,22 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const airedEpisodes = episodes.filter(ep => ep.PremiereDate && new Date(ep.PremiereDate) <= today ); - const totalEpisodes = airedEpisodes.length; - // Count watched episodes from aired episodes only - const watchedEpisodes = airedEpisodes.filter(ep => ep.UserData && ep.UserData.Played === true); - const watchedCount = watchedEpisodes.length; + // Expand multi-part episodes to get the actual total count + const expandedForCount = []; + airedEpisodes.forEach(episode => { + expandedForCount.push(...expandMultiPartEpisodes(episode)); + }); + const totalEpisodes = expandedForCount.length; + + // Count watched episodes - expand multi-part episodes and count all parts if watched + const watchedExpanded = []; + airedEpisodes.forEach(episode => { + if (episode.UserData && episode.UserData.Played === true) { + watchedExpanded.push(...expandMultiPartEpisodes(episode)); + } + }); + const watchedCount = watchedExpanded.length; const remainingCount = totalEpisodes - watchedCount; // Debug logging to verify we're getting the right data @@ -2339,15 +2507,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const totalRuntime = availableEpisodes.reduce((total, ep) => total + (ep.RunTimeTicks / 10000000), 0); // Calculate watched runtime from actually watched episodes - const watchedRuntime = watchedEpisodes.reduce((total, ep) => total + (ep.RunTimeTicks ? ep.RunTimeTicks / 10000000 : 0), 0); + const watchedRuntime = watchedExpanded.reduce((total, ep) => total + (ep.RunTimeTicks ? ep.RunTimeTicks / 10000000 : 0), 0); // Estimate remaining runtime (this is approximate since we don't have runtime for missing episodes) const remainingRuntime = totalRuntime - watchedRuntime; // Find last watched episode let lastWatchedEpisode = null; - if (watchedEpisodes.length > 0) { - lastWatchedEpisode = watchedEpisodes + if (watchedExpanded.length > 0) { + lastWatchedEpisode = watchedExpanded .filter(ep => ep.UserData && ep.UserData.LastPlayedDate) .sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate))[0]; } @@ -2597,6 +2765,12 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } async function renderProgressContent() { + if (tabStates.progress.isRendering) { + return; + } + + tabStates.progress.isRendering = true; + const watchlistSection = getWatchlistSection(); const progressTab = watchlistSection ? watchlistSection.querySelector('div[data-tab="progress"]') : null; const paginatedContainer = progressTab ? progressTab.querySelector('.paginated-container') : null; @@ -2706,6 +2880,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: container.innerHTML = ''; clearTabState('progress'); showEmptyProgressMessage(); + } finally { + tabStates.progress.isRendering = false; } } @@ -2791,22 +2967,44 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
-
-
-
-
-
-