From f95c9abd8cf638c0110a61446629f1d6f523281d Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Sun, 9 Nov 2025 18:56:05 -0500 Subject: [PATCH] Improve onViewPage util, improve top person caching for home page, improve watchlist caching, fixes for mobile UI and Flatten Shows improvements --- README.md | 13 +- breadcrumbNav.css | 177 ++++++----- breadcrumbs.js | 5 +- defaultSkin.css | 8 + flattenSingleSeasonShows.js | 163 +++++++++- headerTabs.js | 5 +- homeScreen.css | 7 +- homeScreen.js | 402 +++++++++++++---------- itemDetailsCollections.js | 3 +- kefinTweaks.js | 4 +- playlist.js | 3 +- search.js | 3 +- skinManager.js | 12 +- skins/neutralfin-kefin.css | 73 +++++ userConfig.js | 10 + utils.js | 28 +- watchlist.css | 4 - watchlist.js | 613 ++++++++++++++++++++++++++---------- 18 files changed, 1055 insertions(+), 478 deletions(-) create mode 100644 skins/neutralfin-kefin.css diff --git a/README.md b/README.md index 8eeb390..6be422c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ While working on these scripts to meet my personal needs, I noticed that there w There is no configuration required, but if you wish to customize some of the features, there are options to do so. +I think it's also worth mentioning that before the 1.0 release, you can expect to potentially encounter some issues or bugs. I try to address these as quickly as I can, and I appreciate your time for raising the issues in the first place! + ## Table of Contents - [Installation](#-installation) @@ -46,17 +48,18 @@ There is no configuration required, but if you wish to customize some of the fea - [Planned Features](#planned-features) - [Version History](#version-history) -## 📦 Installation +## Installation ### Prerequisites -- Jellyfin 10.10.7 or later +- Jellyfin 10.10.7 or earlier (10.11.X is untested and "unsupported" but may be mostly functional) - [JS Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector) plugin installed and configured - [Custom Tabs](https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs) plugin for watchlist functionality - [JellyfinEnhanced](https://github.com/n00bcodr/Jellyfin-Enhanced) plugin for Jellyseerr search functionality and ExclusiveElsewhere ### Setup Instructions -0. **Install Prerequisites listed above** +0a. **Install Prerequisites listed above** +0b. **If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings** 1. **Add a new script to your JS Injector Plugin** 2. **Copy this entire contents of kefinTweaks.js into the new script** @@ -98,8 +101,8 @@ The features in KefinTweaks which use local data caching are listed below, along - **Top People** [24h]: In order to populate the "Top People" in your movie library, we fetch the Person data for every movie in your library. We build a list of all the People who appear in at least X number of items based on their type (Actor/Director/Writer). This is an expensive operation, and is longer as the size of your library grows. - Watchlist - - **Watchlist Items** [24h]: - This is mostly done to provide a more responsive UX, as typically the API call to retreive these items should not be very expensive. The Watchlist cache is also updated any time there is a change to an item's Play State or Watchlist status. Keep in mind that the cache can only be updated locally, so if you watch a movie from your watchlist on Device X, and then you browse your Watchlist on Device Y, the Watchlist will still show the watched item until the cache is manually refreshed or it expires. + - **Watchlist Items** [5m]: + This is mostly done to provide a more responsive UX, as typically the API call to retreive these items should not be very expensive. The Watchlist cache is also updated any time there is a change to an item's Play State or Watchlist status. - **Series Progress and Movie History** [24h]: These are both more expensive operations, especially if you have watched a very high number of Movies or Shows. It's not practical to fetch this data on demand so we cache it to improve UX. - Collections on Details Page diff --git a/breadcrumbNav.css b/breadcrumbNav.css index c7db4d4..f7f23fb 100644 --- a/breadcrumbNav.css +++ b/breadcrumbNav.css @@ -1,104 +1,103 @@ /* KefinTweaks Breadcrumbs Styles */ - .kefinTweaks-breadcrumbs-wrapper { - margin-left: -.5em; - transition: background 0.2s ease; + display: none; } -.kefinTweaks-breadcrumbs-wrapper:hover { - background: rgba(0, 0, 0, 0.5); - border-radius: 0.5em; -} - -.kefinTweaks-breadcrumbs { - color: rgba(255, 255, 255, 0.8); - font-size: 1.17em; - font-family: inherit; - pointer-events: auto; - position: relative; - padding: 8px 12px; - border-radius: 4px; - word-wrap: break-word; - display: inline-block; -} - -.kefinTweaks-popover-container { - position: absolute; - top: 100%; - left: 0; - z-index: 1001; -} - -.kefinTweaks-breadcrumbs span { - display: inline; -} - -.kefinTweaks-breadcrumbs span:hover { - color: rgba(255, 255, 255, 1); -} - -/* Hover effects for clickable breadcrumbs only */ -.kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover { - text-decoration: underline !important; -} - -/* Responsive behavior */ -@media (max-width: 768px) { +@media (min-width: 768px) { .kefinTweaks-breadcrumbs-wrapper { - display: none !important; + display: block; + margin-left: -.5em; + transition: background 0.2s ease; } -} -/* Popover styles for seasons and albums */ -.kefinTweaks-popover { - position: relative; - background-color: rgba(29, 38, 53, 0.95); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - padding: 0; - min-width: 200px; - max-height: 300px; - overflow-y: auto; - z-index: 1001; - display: block; - margin-top: 8px; -} + .kefinTweaks-breadcrumbs-wrapper:hover { + background: rgba(0, 0, 0, 0.5); + border-radius: 0.5em; + } -.kefinTweaks-popover-item { - padding: 8px 16px 8px 24px; - cursor: pointer; - color: rgba(255, 255, 255, 0.8); - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - position: relative; -} + .kefinTweaks-breadcrumbs { + color: rgba(255, 255, 255, 0.8); + font-size: 1.17em; + font-family: inherit; + pointer-events: auto; + position: relative; + padding: 8px 12px; + border-radius: 4px; + word-wrap: break-word; + display: inline-block; + } -.kefinTweaks-popover-item:hover { - background-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 1); -} + .kefinTweaks-popover-container { + position: absolute; + top: 100%; + left: 0; + z-index: 1001; + } -.kefinTweaks-popover-item:last-child { - border-bottom: none; -} + .kefinTweaks-breadcrumbs span { + display: inline; + } -.kefinTweaks-popover-item.selected { - color: rgba(255, 255, 255, 1); - font-weight: bold; -} + .kefinTweaks-breadcrumbs span:hover { + color: rgba(255, 255, 255, 1); + } -.kefinTweaks-popover-item.selected::before { - content: '>'; - position: absolute; - left: 8px; - top: calc(50% - 1px); - transform: translateY(-50%); - font-weight: bold; -} + /* Hover effects for clickable breadcrumbs only */ + .kefinTweaks-breadcrumbs span[style*="cursor: pointer"]:hover { + text-decoration: underline !important; + } -.skinHeader { - contain: none; -} + /* Popover styles for seasons and albums */ + .kefinTweaks-popover { + position: relative; + background-color: rgba(29, 38, 53, 0.95); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 0; + min-width: 200px; + max-height: 300px; + overflow-y: auto; + z-index: 1001; + display: block; + margin-top: 8px; + } -.headerLeft { - overflow: visible; + .kefinTweaks-popover-item { + padding: 8px 16px 8px 24px; + cursor: pointer; + color: rgba(255, 255, 255, 0.8); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + } + + .kefinTweaks-popover-item:hover { + background-color: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 1); + } + + .kefinTweaks-popover-item:last-child { + border-bottom: none; + } + + .kefinTweaks-popover-item.selected { + color: rgba(255, 255, 255, 1); + font-weight: bold; + } + + .kefinTweaks-popover-item.selected::before { + content: '>'; + position: absolute; + left: 8px; + top: calc(50% - 1px); + transform: translateY(-50%); + font-weight: bold; + } + + .skinHeader { + contain: none; + } + + .headerLeft { + overflow: visible; + } } \ No newline at end of file diff --git a/breadcrumbs.js b/breadcrumbs.js index e55f70c..013c1ea 100644 --- a/breadcrumbs.js +++ b/breadcrumbs.js @@ -515,8 +515,6 @@ const currentPath = Emby.Page.lastPath || ''; const isSupportedPage = currentPath.startsWith('/details?'); - log('showBreadcrumbs called - wrapper exists:', !!wrapper, 'isWideScreen:', wideScreen, 'isSupportedPage:', isSupportedPage, 'currentPath:', currentPath); - if (wrapper && wideScreen && isSupportedPage) { wrapper.style.display = 'block'; log('Breadcrumbs shown successfully'); @@ -878,7 +876,7 @@ log('Registering breadcrumb handler with KefinTweaksUtils'); // Register handler for all pages (breadcrumbs can appear on any detail page) - window.KefinTweaksUtils.onViewPage(async (view, element, itemPromise) => { + window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => { try { // Await the item promise to get the actual item data const item = await itemPromise; @@ -888,7 +886,6 @@ error('Breadcrumb page change handler failed:', err); } }, { - immediate: true, pages: [] // Empty array means all pages }); } else { diff --git a/defaultSkin.css b/defaultSkin.css index 1f1e82b..481d065 100644 --- a/defaultSkin.css +++ b/defaultSkin.css @@ -23,4 +23,12 @@ div#seriesScheduleSection { .commentsSection { order: 3; +} + +@media (max-width: 768px) { + .headerRight .btnRecommendations, + .headerRight .btnAdmin, + .headerRight .headerAppearanceButton { + display: none; + } } \ No newline at end of file diff --git a/flattenSingleSeasonShows.js b/flattenSingleSeasonShows.js index 8499a0d..820058e 100644 --- a/flattenSingleSeasonShows.js +++ b/flattenSingleSeasonShows.js @@ -9,6 +9,51 @@ const WARN = (...args) => console.warn('[KefinTweaks FlattenSingleSeasonShows]', ...args); const ERR = (...args) => console.error('[KefinTweaks FlattenSingleSeasonShows]', ...args); + /** + * Fetches the Next Up episode for a given series + * @param {string} seriesId - The series ID + * @returns {Promise} - Episode number object with season and episode, or null + */ + async function fetchNextUpEpisode(seriesId) { + try { + const apiClient = window.ApiClient; + const userId = apiClient.getCurrentUserId(); + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + + LOG(`Fetching Next Up episode for series: ${seriesId}`); + const nextUpUrl = `${serverUrl}/Shows/NextUp?SeriesId=${seriesId}&UserId=${userId}&Fields=MediaSourceCount`; + const nextUpRes = await fetch(nextUpUrl, { + headers: { "Authorization": `MediaBrowser Token="${token}"` } + }); + + if (!nextUpRes.ok) { + throw new Error(`HTTP ${nextUpRes.status}: ${nextUpRes.statusText}`); + } + + const nextUpData = await nextUpRes.json(); + const items = nextUpData.Items || []; + + if (items.length > 0) { + const item = items[0]; + if (item.IndexNumber && item.ParentIndexNumber) { + const episodeNumber = { + season: item.ParentIndexNumber, + episode: item.IndexNumber + }; + LOG(`Fetched Next Up episode from API: S${episodeNumber.season}:E${episodeNumber.episode}`); + return episodeNumber; + } + } + + LOG('No Next Up episode found in API response'); + return null; + } catch (error) { + ERR(`Failed to fetch Next Up episode for series ${seriesId}:`, error); + return null; + } + } + /** * Fetches all episodes for a given series * @param {string} seriesId - The series ID @@ -22,7 +67,7 @@ const token = apiClient.accessToken(); LOG(`Fetching episodes for series: ${seriesId}`); - const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData&EnableImageTypes=Primary`; + const episodesUrl = `${serverUrl}/Shows/${seriesId}/Episodes?UserId=${userId}&Fields=UserData`; const episodesRes = await fetch(episodesUrl, { headers: { "Authorization": `MediaBrowser Token="${token}"` } }); @@ -104,12 +149,51 @@ WARN('Could not determine season ID from episodes'); } - // Render Season 1 section using renderCards + // Check if Next Up section exists + const nextUpSection = activePage?.querySelector('.nextUpSection'); + const nextUpItemsContainer = nextUpSection?.querySelector('.itemsContainer'); + + // Extract episode info from Next Up if it exists + let targetEpisodeNumber = null; + if (nextUpSection && nextUpItemsContainer) { + LOG('Next Up section found, hiding nextUpSection'); + + // Get the existing card from Next Up and extract episode info + const existingCard = nextUpItemsContainer.querySelector('.card'); + if (existingCard) { + // Find the .cardText a element that contains the episode number + const cardTextLinks = existingCard.querySelectorAll('.cardText a'); + for (const link of cardTextLinks) { + const linkText = link.innerText || link.textContent; + // Match SX:EY pattern (may be part of longer text like "S1:E5 - Episode Name") + const match = linkText.match(/S(\d+):E(\d+)/); + if (match) { + targetEpisodeNumber = { + season: parseInt(match[1], 10), + episode: parseInt(match[2], 10) + }; + LOG(`Found Next Up episode from DOM: S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + break; + } + } + } + + // If we couldn't find it from the DOM, fetch from API + if (!targetEpisodeNumber) { + LOG('Could not find episode number from DOM, fetching from API'); + targetEpisodeNumber = await fetchNextUpEpisode(seriesId); + } + + // Hide the entire Next Up section + nextUpSection.style.display = 'none'; + } + + // Render Season 1 section using renderCards (default behavior) const seasonSection = window.cardBuilder.renderCards( episodes, 'Season 1', viewMoreUrl, - false, // overflowCard + true, // overflowCard null // cardFormat (use default) ); @@ -130,6 +214,72 @@ // Prepend the Season 1 section right before #childrenCollapsible childrenCollapsible.parentNode.insertBefore(seasonSection, childrenCollapsible); + // Check if childrenCollapsible has only one child in its itemsContainer, and hide it if so + const childrenItemsContainer = childrenCollapsible.querySelector('.itemsContainer'); + if (childrenItemsContainer) { + LOG('childrenCollapsible contains only one child, hiding it'); + childrenCollapsible.style.display = 'none'; + } + + // If we found a target episode from Next Up, scroll to it + if (targetEpisodeNumber && scrollerContainer) { + // Wait for cards to be rendered and positioned + setTimeout(() => { + const scrollerItemsContainer = scrollerContainer.querySelector('.itemsContainer'); + if (!scrollerItemsContainer) { + WARN('Could not find itemsContainer in scroller'); + return; + } + + // Find the card that matches the target episode number + const cards = scrollerItemsContainer.querySelectorAll('.card'); + let targetCard = null; + + for (const card of cards) { + const cardTextLinks = card.querySelectorAll('.cardText a'); + for (const link of cardTextLinks) { + const linkText = link.innerText || link.textContent; + const match = linkText.match(/S(\d+):E(\d+)/); + if (match && + parseInt(match[1], 10) === targetEpisodeNumber.season && + parseInt(match[2], 10) === targetEpisodeNumber.episode) { + targetCard = card; + break; + } + } + if (targetCard) break; + } + + // Scroll to make the target card the leftmost visible element + if (targetCard) { + const itemsContainer = scrollerItemsContainer; + const cardOffset = targetCard.offsetLeft; + const scrollerPadding = parseInt(window.getComputedStyle(scrollerContainer).paddingLeft, 10) || 0; + + // Calculate the translateX value (negative to move left) + // The card offset minus padding gives us how much to translate + const translateX = -(cardOffset - scrollerPadding); + + // Update the transform property on the itemsContainer + itemsContainer.style.transform = `translateX(${translateX}px)`; + + // Enable the left scroll button so users can scroll back + const scrollButtons = scrollerContainer.closest('.emby-scroller-container')?.querySelector('.emby-scrollbuttons'); + if (scrollButtons) { + const leftButton = scrollButtons.querySelector('button[data-direction="left"]'); + if (leftButton) { + leftButton.removeAttribute('disabled'); + LOG('Enabled left scroll button'); + } + } + + LOG(`Scrolled to target episode S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode} using translateX(${translateX}px)`); + } else { + WARN(`Could not find target episode card S${targetEpisodeNumber.season}:E${targetEpisodeNumber.episode}`); + } + }, 100); + } + // Mark as flattened for this series to avoid duplicates if (activePage) { activePage.dataset.flattenedSeriesId = seriesId; @@ -154,7 +304,7 @@ LOG('Registering flatten single season shows handler with KefinTweaksUtils'); window.KefinTweaksUtils.onViewPage( - async (view, element, itemPromise) => { + async (view, element, hash, itemPromise) => { // Only handle details pages const activePage = document.querySelector('.libraryPage:not(.hide)'); if (!activePage) return; @@ -162,9 +312,9 @@ // Remove any existing flattened section and reset flag when page changes const existingSection = activePage.querySelector('.flattened-season-section'); if (existingSection) { - existingSection.remove(); + LOG('Flattened section already exists, skipping'); + return; } - delete activePage.dataset.flattenedSeriesId; // Await the item promise to get the actual item data const item = await itemPromise; @@ -180,7 +330,6 @@ } }, { - immediate: true, pages: ['details'] } ); diff --git a/headerTabs.js b/headerTabs.js index c6a524a..00c1896 100644 --- a/headerTabs.js +++ b/headerTabs.js @@ -89,7 +89,7 @@ } // Manually notify handlers for Header Tab navigation because it doesn't trigger normal Jellyfin navigation - window.KefinTweaksUtils.notifyHandlers(currentPage, document); + window.KefinTweaksUtils.notifyHandlers(currentPage, document, newHash); // Replace history entry instead of adding new one const newUrl = window.location.origin + window.location.pathname + '#' + newHash.substring(1); @@ -343,8 +343,7 @@ // Sync active tab state when page view changes syncActiveTabState(); }, { - pages: SUPPORTED_PAGES, // Only trigger for supported pages - immediate: false + pages: SUPPORTED_PAGES // Only trigger for supported pages }); LOG('Registered onViewPage handler for syncActiveTabState'); } else { diff --git a/homeScreen.css b/homeScreen.css index ccce776..4780ca2 100644 --- a/homeScreen.css +++ b/homeScreen.css @@ -16,7 +16,7 @@ order: 1; } -.sections.homeSectionsContainer>div:has(div[data-positionticks]) { +.sections.homeSectionsContainer>div:has(>.emby-scroller div[data-positionticks]) { order: 2; } @@ -86,4 +86,9 @@ div#discovery-loading-indicator:not(.show)::after { #indexPage:not([data-discovery-ready]) div#discovery-loading-indicator:not(.show)::after { content: ''; +} + +/* Ensure Editor's Choice Banner appears at top of home screen if enabled */ +.editorsChoiceContainer { + order: 1 !important; } \ No newline at end of file diff --git a/homeScreen.js b/homeScreen.js index 46b0a50..ace84b7 100644 --- a/homeScreen.js +++ b/homeScreen.js @@ -177,7 +177,6 @@ // Set up page view monitoring if (window.KefinTweaksUtils) { window.KefinTweaksUtils.onViewPage(manageBodyClasses, { - immediate: true, pages: [] }); LOG('Registered body class management for home page'); @@ -771,22 +770,25 @@ const token = apiClient.accessToken(); const userId = apiClient.getCurrentUserId(); const cache = new window.LocalStorageCache(); + const indexedDBCache = new window.IndexedDBCache(); try { LOG('Starting paginated fetch of movies for people data processing...'); - let allMovies = []; let startIndex = 0; const limit = 500; let hasMoreData = true; + let peopleMap = null; // Will hold raw (unfiltered) people data - // Load existing partial data if available - const existingData = cache.get('movies_top_people'); - if (existingData && !existingData.isComplete) { - const moviesProcessed = countMoviesFromPeopleData(existingData); + // Load existing raw people data if available (for resuming) - use IndexedDB + const existingRawData = await indexedDBCache.get('movies_top_people_raw', userId); + if (existingRawData && !existingRawData.isComplete) { + const moviesProcessed = existingRawData.moviesProcessedCount; if (moviesProcessed > 0) { startIndex = moviesProcessed; - LOG(`Resuming from existing data: ${moviesProcessed} movies already processed`); + // Reconstruct Map from cached array + peopleMap = new Map(existingRawData.peopleData || []); + LOG(`Resuming from existing data: ${moviesProcessed} movies already processed, ${peopleMap.size} people in cache`); } } @@ -813,26 +815,29 @@ hasMoreData = false; LOG('No more movies to fetch'); } else { - allMovies = allMovies.concat(movies); + // Process new movies and merge with existing people data + peopleMap = processPeopleDataRaw(movies, peopleMap); startIndex += movies.length; - LOG(`Fetched ${movies.length} movies, total: ${allMovies.length}`); + LOG(`Fetched ${movies.length} movies, total processed: ${startIndex}, people tracked: ${peopleMap ? peopleMap.size : 0}`); - // 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; - + // Filter for display (but cache raw data) + const filteredData = filterPeopleData(peopleMap); + if (filteredData) { // 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)`); + moviesTopPeople = filteredData; + LOG(`Updated moviesTopPeople with partial data: ${filteredData.actors.length} actors, ${filteredData.directors.length} directors, ${filteredData.writers.length} writers (${startIndex} movies processed)`); } + // Cache RAW (unfiltered) data for resuming - use IndexedDB for large data + // Convert Map to array for JSON serialization + const rawDataToCache = { + isComplete: movies.length < limit, + moviesProcessedCount: startIndex, + peopleData: Array.from(peopleMap.entries()) + }; + await indexedDBCache.set('movies_top_people_raw', rawDataToCache, userId, 7 * 24 * 60 * 60 * 1000); + // If we got less than the limit, we've reached the end if (movies.length < limit) { hasMoreData = false; @@ -842,20 +847,23 @@ } } - // Final processing of all data - LOG(`Final processing of ${allMovies.length} movies...`); - const finalPeopleData = processPeopleData(allMovies); + // Final processing: filter the complete raw data + LOG(`Final filtering of people data from ${startIndex} movies...`); + const finalPeopleData = filterPeopleData(peopleMap); if (finalPeopleData) { - // Mark as complete and remove movies count to save space + // Mark as complete 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); + // Cache final filtered data (for quick loading) - use IndexedDBCache for larger data with items + await indexedDBCache.set('movies_top_people', finalPeopleData, userId, 7 * 24 * 60 * 60 * 1000); + + // Also remove raw cache since we're complete (optional, saves space) + await indexedDBCache.clear('movies_top_people_raw', userId); + LOG(`Final cached people data: ${finalPeopleData.actors.length} actors, ${finalPeopleData.directors.length} directors, ${finalPeopleData.writers.length} writers`); } @@ -868,53 +876,109 @@ } /** - * 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 + * Extracts necessary item data for caching (similar to watchlist format) + * @param {Object} item - Full Jellyfin item object + * @returns {Object} - Optimized item data for storage */ - 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); - } - } - }); + function extractItemDataForCache(item) { + return { + Id: item.Id, + Type: item.Type, + MediaType: item.MediaType, + Name: item.Name, + SeriesId: item.SeriesId, + SeriesName: item.SeriesName, + SeriesPrimaryImageTag: item.SeriesPrimaryImageTag, + ParentBackdropItemId: item.ParentBackdropItemId, + ParentBackdropImageTags: item.ParentBackdropImageTags, + ParentThumbImageTag: item.ParentPrimaryImageTags, + IndexNumber: item.IndexNumber, + ParentIndexNumber: item.ParentIndexNumber, + ImageTags: item.ImageTags, + BackdropImageTags: item.BackdropImageTags, + PremiereDate: item.PremiereDate, + ProductionYear: item.ProductionYear, + UserData: { + IsFavorite: item.UserData?.IsFavorite || false, + Played: item.UserData?.Played || false, + LastPlayedDate: item.UserData?.LastPlayedDate || null, + PlayCount: item.UserData?.PlayCount || 0, + Likes: item.UserData?.Likes || false } - }); - - // Filter and sort people by count, taking top 100 per category + }; + } + + /** + * Processes people data from movies array and returns RAW data (all people, unfiltered) + * @param {Array} movies - Array of movie objects with People data + * @param {Map} existingPeopleMap - Optional existing people map to merge with + * @returns {Map|null} - Map of all people data (unfiltered) or null if failed + */ + function processPeopleDataRaw(movies, existingPeopleMap = null) { + try { + const peopleMap = existingPeopleMap ? new Map(existingPeopleMap) : new Map(); + + movies.forEach(movie => { + // Extract item data for caching + const itemData = extractItemDataForCache(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, + directorItems: [], + writerItems: [], + actorItems: [] + }); + } + + const personData = peopleMap.get(key); + if (person.Type === 'Director') { + personData.directorCount++; + // Check if item already exists by Id + if (!personData.directorItems.some(item => item.Id === movie.Id)) { + personData.directorItems.push(itemData); + } + } else if (person.Type === 'Writer') { + personData.writerCount++; + if (!personData.writerItems.some(item => item.Id === movie.Id)) { + personData.writerItems.push(itemData); + } + } else if (person.Type === 'Actor') { + personData.actorCount++; + if (!personData.actorItems.some(item => item.Id === movie.Id)) { + personData.actorItems.push(itemData); + } + } + }); + } + }); + + return peopleMap; + + } catch (err) { + ERR('Failed to process people data:', err); + return null; + } + } + + /** + * Filters and sorts raw people data by minPeopleAppearances, returning top 100 per category + * @param {Map} peopleMap - Map of all people data (unfiltered) + * @returns {Object|null} - Filtered and sorted people data or null if failed + */ + function filterPeopleData(peopleMap) { + try { + if (!peopleMap) { + return null; + } + const allPeople = Array.from(peopleMap.values()); const topDirectors = allPeople @@ -925,7 +989,7 @@ id: person.id, name: person.name, count: person.directorCount, - itemIds: person.directorItemIds + items: person.directorItems || [] })); const topWriters = allPeople @@ -936,7 +1000,7 @@ id: person.id, name: person.name, count: person.writerCount, - itemIds: person.writerItemIds + items: person.writerItems || [] })); const topActors = allPeople @@ -947,7 +1011,7 @@ id: person.id, name: person.name, count: person.actorCount, - itemIds: person.actorItemIds + items: person.actorItems || [] })); return { @@ -957,11 +1021,21 @@ }; } catch (err) { - ERR('Failed to process people data:', err); + ERR('Failed to filter people data:', err); return null; } } + /** + * Processes people data from movies array (legacy function, now uses raw processing + filtering) + * @param {Array} movies - Array of movie objects with People data + * @returns {Object|null} - Processed people data or null if failed + */ + function processPeopleData(movies) { + const peopleMap = processPeopleDataRaw(movies); + return filterPeopleData(peopleMap); + } + /** * Initializes the people cache in the background @@ -979,43 +1053,48 @@ return; } - const cache = new window.LocalStorageCache(); + const indexedDBCache = new window.IndexedDBCache(); + const apiClient = window.ApiClient; + const userId = apiClient.getCurrentUserId(); - // Check if we have valid cached data - if (cache.isCacheValid('movies_top_people')) { - const cachedData = cache.get('movies_top_people'); + // First, check if we have valid complete filtered data (in IndexedDBCache) + if (await indexedDBCache.isCacheValid('movies_top_people', userId)) { + const cachedData = await indexedDBCache.get('movies_top_people', userId); // Check if data is complete - if (cachedData.isComplete) { + if (cachedData && cachedData.isComplete) { moviesTopPeople = cachedData; isPeopleCacheComplete = true; - LOG('Loaded complete top people data from cache'); + LOG('Loaded complete top people data from IndexedDB 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...'); + } + } + + // If no complete data, check for raw partial data (in IndexedDBCache) + if (await indexedDBCache.isCacheValid('movies_top_people_raw', userId)) { + const rawCachedData = await indexedDBCache.get('movies_top_people_raw', userId); + + if (rawCachedData && rawCachedData.peopleData) { + // Reconstruct Map and filter for immediate display + const peopleMap = new Map(rawCachedData.peopleData); + const filteredData = filterPeopleData(peopleMap); + + if (filteredData) { + moviesTopPeople = filteredData; + isPeopleCacheComplete = rawCachedData.isComplete || false; + LOG(`Loaded partial top people data from raw cache (${rawCachedData.moviesProcessedCount || 0} movies), ${filteredData.actors.length} actors, ${filteredData.directors.length} directors, ${filteredData.writers.length} writers`); + + // If incomplete, we'll continue fetching in background + if (!isPeopleCacheComplete) { + // Continue in background + } else { + // It's marked complete but wasn't in filtered cache, update filtered cache + filteredData.isComplete = true; + await indexedDBCache.set('movies_top_people', filteredData, userId, 7 * 24 * 60 * 60 * 1000); + await indexedDBCache.clear('movies_top_people_raw', userId); + return; + } } - } 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...'); } } @@ -1745,7 +1824,7 @@ // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( movies, - 'New Movies', + 'Recently Released Movies', null, true ); @@ -1788,7 +1867,7 @@ // Render the scrollable container const scrollableContainer = window.cardBuilder.renderCards( episodes, - 'New Episodes', + 'Recently Aired Episodes', null, true ); @@ -1908,7 +1987,7 @@ // Render the scrollable container with the network/studio items const scrollableContainer = window.cardBuilder.renderCards( limitedNetworks, - 'Popular TV Networks', + 'Popular TV Studios', null, true, 'backdrop' @@ -1984,78 +2063,63 @@ return null; })(), - // 2. Directed by [Director] (async - fetch actual items) + // 2. Directed by [Director] (use cached items directly) (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, - viewMoreUrl: `#/details?id=${randomDirector.id}&serverId=${ApiClient.serverId()}` - }; - } + if (randomDirector && randomDirector.items && randomDirector.items.length > 0) { + // Use cached items directly - no need to fetch from API + const shuffledItems = shuffleArray(randomDirector.items).slice(0, 16); + + if (shuffledItems && shuffledItems.length > 0) { + return { + type: 'director', + data: randomDirector, + items: shuffledItems, + viewMoreUrl: `#/details?id=${randomDirector.id}&serverId=${ApiClient.serverId()}` + }; } } } return null; })(), - // 3. Written by [Writer] (async - fetch actual items) + // 3. Written by [Writer] (use cached items directly) (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, - viewMoreUrl: `#/details?id=${randomWriter.id}&serverId=${ApiClient.serverId()}` - }; - } + if (randomWriter && randomWriter.items && randomWriter.items.length > 0) { + // Use cached items directly - no need to fetch from API + const shuffledItems = shuffleArray(randomWriter.items).slice(0, 16); + + if (shuffledItems && shuffledItems.length > 0) { + return { + type: 'writer', + data: randomWriter, + items: shuffledItems, + viewMoreUrl: `#/details?id=${randomWriter.id}&serverId=${ApiClient.serverId()}` + }; } } } return null; })(), - // 4. Starring [Actor] (async - fetch actual items) + // 4. Starring [Actor] (use cached items directly) (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, - viewMoreUrl: `#/details?id=${randomActor.id}&serverId=${ApiClient.serverId()}` - }; - } + if (randomActor && randomActor.items && randomActor.items.length > 0) { + // Use cached items directly - no need to fetch from API + const shuffledItems = shuffleArray(randomActor.items).slice(0, 16); + + if (shuffledItems && shuffledItems.length > 0) { + return { + type: 'actor', + data: randomActor, + items: shuffledItems, + viewMoreUrl: `#/details?id=${randomActor.id}&serverId=${ApiClient.serverId()}` + }; } } } @@ -3526,19 +3590,7 @@ } // 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) { + if (homeSectionsContainer.dataset.customSectionsRendered === 'true') { return; } @@ -3546,6 +3598,7 @@ // Set processing flag to prevent parallel execution isProcessing = true; + homeSectionsContainer.dataset.customSectionsRendered = 'true'; try { LOG('Starting parallel initialization of home screen sections...'); @@ -3605,7 +3658,6 @@ ERR('Home screen page change handler failed:', err); } }, { - immediate: true, pages: ['home', 'home.html'] }); } else { diff --git a/itemDetailsCollections.js b/itemDetailsCollections.js index 6ca9216..82d27e3 100644 --- a/itemDetailsCollections.js +++ b/itemDetailsCollections.js @@ -194,7 +194,7 @@ LOG('Registering collections handler with KefinTweaksUtils'); window.KefinTweaksUtils.onViewPage( - async (view, element, itemPromise) => { + async (view, element, hash, itemPromise) => { // Only handle details pages const activePage = document.querySelector('.libraryPage:not(.hide)'); if (!activePage) return; @@ -210,7 +210,6 @@ }, 100); }, { - immediate: true, pages: ['details'] } ); diff --git a/kefinTweaks.js b/kefinTweaks.js index 4582c8e..1bf198a 100644 --- a/kefinTweaks.js +++ b/kefinTweaks.js @@ -18,7 +18,8 @@ // // INSTALLATION: // ============ -// 0. Install required prerequisites listed above +// 0a. Install required prerequisites listed above +// 0b. If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings // 1. Add a new script to your JS Injector plugin // 2. Copy this entire file into the new script // (Optional) Enable/disable features, or customize their configuration by editing the script below @@ -61,6 +62,7 @@ homeScreen: true, // Custom home screen sections and discovery engine search: true, // Enhanced search with better UI and Jellyseerr integration // REQUIREMENTS: Watchlist requires Custom Tabs plugin https://github.com/IAmParadox27/jellyfin-plugin-custom-tabs + // If you are using JellyfinEnhanced, you MUST disable the "Watchlist" feature from the plugin settings watchlist: true, // Complete watchlist management with progress tracking // UI and navigation improvements diff --git a/playlist.js b/playlist.js index aec73b0..68c5fa7 100644 --- a/playlist.js +++ b/playlist.js @@ -227,7 +227,7 @@ LOG('Registering playlist handler with KefinTweaksUtils'); // Register handler for details pages - window.KefinTweaksUtils.onViewPage(async (view, element, itemPromise) => { + window.KefinTweaksUtils.onViewPage(async (view, element, hash, itemPromise) => { // Await the item promise to get the actual item data const item = await itemPromise; if (item && item.Type === 'Playlist') { @@ -235,7 +235,6 @@ return; } }, { - immediate: true, pages: ['details'] }); diff --git a/search.js b/search.js index e022342..26fa6e2 100644 --- a/search.js +++ b/search.js @@ -59,7 +59,7 @@ // config const CORE_TYPES = ['Movie', 'Series', 'Episode', 'Person']; - const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist']; + const MUSIC_TYPES = ['MusicAlbum', 'Audio', 'Artist', 'MusicVideo']; const BOOKS_TYPES = ['Book', 'AudioBook']; const OTHER_TYPES = ['Playlist', 'Photo', 'PhotoAlbum', 'TvChannel', 'LiveTvProgram', 'BoxSet']; @@ -677,7 +677,6 @@ } }, 100); }, { - immediate: true, pages: ['search'] }); diff --git a/skinManager.js b/skinManager.js index 5104ef5..72a6d3f 100644 --- a/skinManager.js +++ b/skinManager.js @@ -282,15 +282,19 @@ // Register for the display preferences page unregisterDisplayPreferencesHandler = window.KefinTweaksUtils.onViewPage(handleDisplayPreferencesPage, { - pages: ['mypreferencesdisplay'], - immediate: true + pages: ['mypreferencesdisplay'] }); // Register for all pages to add the appearance button unregisterAnyPageHandler = window.KefinTweaksUtils.onViewPage(handleAnyPage, { - pages: [], - immediate: true + pages: [] }); + +/* if (window.location.hash.includes('mypreferencesdisplay')) { + handleDisplayPreferencesPage(); + } + + handleAnyPage(); */ LOG('SkinManager handlers registered'); } diff --git a/skins/neutralfin-kefin.css b/skins/neutralfin-kefin.css new file mode 100644 index 0000000..a96c657 --- /dev/null +++ b/skins/neutralfin-kefin.css @@ -0,0 +1,73 @@ +.material-icons.book:before { + content: "\e062"; +} +.material-icons.newspaper:before { + content: "\e34c"; +} +.material-icons.quiz:before { + content: "\e34b"; +} +.material-icons.person_add:before { + content: '\e1a2'; +} +.material-icons.help:before { + content: '\e0ff'; +} +.material-icons.bookmark:before, .material-icons.bookmark_border:before, .material-icons.watchlist:before { + content: '\e064'; +} +.material-icons.bookmark_border:before { + content: '\e064'; +} +.material-icons.thumb_up:before { + content: '\e18a'; +} +.material-icons.palette:before { + content: '\e1dd'; +} +.material-icons.star:before { + content: '\e17a'; +} +.material-icons.sort:before { + content: '\e04b'; +} +.material-icons.arrow_drop_down:before { + content: '\e071'; +} +.material-icons.play_circle:before { + content: '\e084'; +} +.material-icons.check_circle_outline:before { + content: '\e226'; +} +.material-icons.favorite_border:before { + content: '\e0f6'; +} + +/* Lazy workaround for the pause button on the media bar plugin */ +.pause-button .material-icons { + font-family: var(--iconPack) !important; +} + +/* Jellyfin Enhanced Random Item Button */ +button#randomItemButton>i { + display: none; +} + +button#randomItemButton:before { + content: '\e28c'; + font-family: lucide !important; + font-weight: 400; + font-style: normal; + display: inline-block; + line-height: 1; + text-transform: none; + letter-spacing: normal; + word-wrap: normal; + white-space: nowrap; + direction: ltr; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "liga"; +} \ No newline at end of file diff --git a/userConfig.js b/userConfig.js index 3a16c9c..6bdbe8d 100644 --- a/userConfig.js +++ b/userConfig.js @@ -38,6 +38,16 @@ ], colorSchemes: [] }, + { + name: 'NeutralFin', + author: 'KartoffelChipss', + url: [ + 'https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@latest/theme/neutralfin-minified.css', + 'https://cdn.jsdelivr.net/gh/KartoffelChipss/Jellyfin-Lucide@main/theme/jellyfin-lucide.css', + `${KefinTweaksConfig.scriptRoot}skins/neutralfin-kefin.css` + ], + colorSchemes: [] + }, { name: 'Jellypane', author: 'tedhinklater', diff --git a/utils.js b/utils.js index ab0f731..27b70b4 100644 --- a/utils.js +++ b/utils.js @@ -36,7 +36,7 @@ } // Call our registered handlers - notifyHandlers(args[0], args[1]); + notifyHandlers(args[0], args[1], window.location.hash); }; LOG('Hooked into Emby.Page.onViewShow'); @@ -68,7 +68,6 @@ const handlerConfig = { callback, options: { - immediate: false, // Call immediately if on matching page pages: [], // Specific pages to watch (empty = all pages) ...options } @@ -77,18 +76,15 @@ handlers.push(handlerConfig); LOG(`Registered onViewPage handler (total: ${handlers.length})`); - // Call immediately if requested and we're on a matching page // Test if this is causing issues - if (handlerConfig.options.immediate && false) { - const currentView = getCurrentView(); - if (currentView && shouldCallHandler(handlerConfig, currentView)) { - try { - // Pass the promise (not awaited) for consistency with notifyHandlers - const itemPromise = getCurrentItem(); - callback(currentView, document, itemPromise); - } catch (err) { - ERR('Error in immediate handler call:', err); - } + const currentView = getCurrentView(); + if (currentView && shouldCallHandler(handlerConfig, currentView)) { + try { + // Pass the promise (not awaited) for consistency with notifyHandlers + const itemPromise = getCurrentItem(); + callback(currentView, document, window.location.hash, itemPromise); + } catch (err) { + ERR('Error in immediate handler call:', err); } } @@ -122,7 +118,7 @@ * @returns {Promise} The item object or null if not found */ async function fetchItemById(itemId) { - if (!itemId || !window.ApiClient || !window.ApiClient.getItem || !window.ApiClient.getCurrentUserId) { + if (!itemId || !window.ApiClient || !window.ApiClient.getItem || !window.ApiClient.getCurrentUserId || !window.ApiClient._loggedIn) { return null; } @@ -186,7 +182,7 @@ * @param {string} view - The view name * @param {Element} element - The view element */ - function notifyHandlers(view, element) { + function notifyHandlers(view, element, hash) { // Clear cache when view changes to ensure fresh data const currentItemId = getItemIdFromUrl(); if (cachedItemId !== currentItemId) { @@ -206,7 +202,7 @@ if (shouldCallHandler(config, view)) { try { // Pass the promise as third parameter - handlers can await if needed - config.callback(view, element, itemPromise); + config.callback(view, element, hash, itemPromise); } catch (err) { ERR('Error in onViewPage handler:', err); } diff --git a/watchlist.css b/watchlist.css index 65ea2dd..b19ef7a 100644 --- a/watchlist.css +++ b/watchlist.css @@ -1858,10 +1858,6 @@ div[data-tab="watchlist"] .loading-message { -webkit-tap-highlight-color: #fff0; } -.mainDetailButtons:has(.btnPlay.hide) .watchlist-icon, .mainDetailButtons:has(.btnPlay.hide) .btnRecommend { - display: none; -} - .card:has(button[data-played="true"]) .watchlist-button, .card:not(:has(button[data-played])) .watchlist-button, .mainDetailButtons:has(button[data-played="true"]) .watchlist-icon, diff --git a/watchlist.js b/watchlist.js index e31e6cf..df3cb7f 100644 --- a/watchlist.js +++ b/watchlist.js @@ -21,10 +21,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Initialize localStorage cache manager const localStorageCache = new window.LocalStorageCache(); + + const WATCHLIST_CACHE_TTL = 5 * 60 * 1000; // 5 minutes // Playback monitoring for watchlist cleanup let playbackMonitorInitialized = false; + // Store watchlist tab index + let _watchlistTabIndex = null; + // Data optimization functions for localStorage storage function optimizeProgressDataForStorage(progressDataArray) { return progressDataArray.map(progress => ({ @@ -92,6 +97,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: SeriesId: item.SeriesId, SeriesName: item.SeriesName, SeriesPrimaryImageTag: item.SeriesPrimaryImageTag, + ParentBackdropItemId: item.ParentBackdropItemId, ParentBackdropImageTags: item.ParentBackdropImageTags, ParentThumbImageTag: item.ParentPrimaryImageTags, IndexNumber: item.IndexNumber, @@ -148,12 +154,12 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: function createRefreshButton(cacheName, label) { const refreshBtn = document.createElement('button'); refreshBtn.className = 'paper-icon-button-light emby-button '; - refreshBtn.innerHTML = 'refresh'; + refreshBtn.innerHTML = ''; refreshBtn.title = `Refresh ${label} data from server`; refreshBtn.onclick = async () => { refreshBtn.disabled = true; - refreshBtn.innerHTML = 'refresh'; + refreshBtn.innerHTML = ''; try { // Clear localStorage cache @@ -190,17 +196,17 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } // Show success feedback with checkmark - refreshBtn.innerHTML = 'check'; + refreshBtn.innerHTML = ''; setTimeout(() => { - refreshBtn.innerHTML = 'refresh'; + refreshBtn.innerHTML = ''; refreshBtn.disabled = false; }, 2000); } catch (error) { ERR('Failed to refresh cache:', error); - refreshBtn.innerHTML = 'error'; + refreshBtn.innerHTML = ''; setTimeout(() => { - refreshBtn.innerHTML = 'refresh'; + refreshBtn.innerHTML = ''; refreshBtn.disabled = false; }, 3000); } @@ -220,9 +226,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const currentLabel = SORT_OPTIONS[currentSort]?.label || 'Last Watched'; sortBtn.innerHTML = ` - sort + ${currentLabel} - arrow_drop_down + `; sortBtn.title = 'Sort series by different criteria'; @@ -249,9 +255,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const currentLabel = MOVIE_SORT_OPTIONS[currentSort]?.label || 'Last Watched'; sortBtn.innerHTML = ` - sort + ${currentLabel} - arrow_drop_down + `; sortBtn.title = 'Sort movies by different criteria'; @@ -558,8 +564,41 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } } + // Helper function to fetch episode by series ID, season, and episode number + async function fetchEpisode(seriesId, season, episode) { + if (!seriesId || !season || !episode) { + throw new Error('Missing required information: seriesId, season, or episode'); + } + + LOG(`Fetching episode ID: series ${seriesId}, season ${season}, episode ${episode}`); + const episodes = await fetchEpisodesForSeries(seriesId); + + if (!episodes || episodes.length === 0) { + throw new Error(`No episodes found for series ${seriesId}`); + } + + // Find the episode that matches the season and episode number + const matchingEpisode = episodes.find(ep => { + const start = ep.IndexNumber; + const end = ep.IndexNumberEnd || ep.IndexNumber; + return ep.ParentIndexNumber === parseInt(season) && + parseInt(episode) >= start && + parseInt(episode) <= end; + }); + + if (matchingEpisode) { + LOG(`Found episode ID: ${matchingEpisode.Id} for season ${season}, episode ${episode}`); + return matchingEpisode; + } else { + throw new Error(`Could not find episode for series ${seriesId}, season ${season}, episode ${episode}`); + } + } + // Show episode watched confirmation modal function showEpisodeWatchedConfirmation(episodeId, element, episodeInfo = null, seriesId = null, isWatched = false) { + // Note: If episodeId is missing, it will be fetched when the confirm button is clicked + // using the seriesId, season, and episode information from episodeInfo + // 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'; @@ -581,15 +620,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
@@ -602,15 +641,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: content: confirmationContent, onOpen: (modalInstance) => { // Store the episode ID, element, and watched status for the confirmation - modalInstance.dialog.dataset.episodeId = episodeId; + modalInstance.dialog.dataset.episodeId = episodeId || ''; modalInstance.dialog.dataset.elementType = element.classList.contains('progress-chunk') ? 'chunk' : 'button'; - modalInstance.dialog.dataset.seriesId = episodeInfo.seriesId; + modalInstance.dialog.dataset.seriesId = (episodeInfo && episodeInfo.seriesId) || seriesId || ''; modalInstance.dialog.dataset.isWatched = isWatched; // Store season/episode for binary mode if (episodeInfo) { - modalInstance.dialog.dataset.season = episodeInfo.season; - modalInstance.dialog.dataset.episode = episodeInfo.episode; + modalInstance.dialog.dataset.season = episodeInfo.season || ''; + modalInstance.dialog.dataset.episode = episodeInfo.episode || ''; } // Add event listeners @@ -618,9 +657,28 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const cancelBtn = modalInstance.dialog.querySelector('#cancel-episode-action'); const viewEpisodeBtn = modalInstance.dialog.querySelector('#view-episode-btn'); - viewEpisodeBtn.onclick = () => { + viewEpisodeBtn.onclick = async () => { // Navigate to the episode's item page - const episodeId = modalInstance.dialog.dataset.episodeId; + let episodeId = modalInstance.dialog.dataset.episodeId; + const seriesId = modalInstance.dialog.dataset.seriesId; + const season = modalInstance.dialog.dataset.season; + const episode = modalInstance.dialog.dataset.episode; + + // If episodeId is missing, fetch it using seriesId, season, and episode + if (!episodeId) { + try { + const item = await fetchEpisode(seriesId, season, episode); + if (item) { + episodeId = item.Id; + modalInstance.dialog.dataset.episodeId = episodeId; + } + } catch (err) { + WARN('Cannot navigate to episode:', err.message); + window.ModalSystem.close('episode-confirmation-modal'); + return; + } + } + if (episodeId) { Dashboard.navigate(`#/details?id=${episodeId}`); } @@ -629,11 +687,28 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }; confirmBtn.onclick = async () => { - const episodeId = modalInstance.dialog.dataset.episodeId; + let episodeId = modalInstance.dialog.dataset.episodeId; const elementType = modalInstance.dialog.dataset.elementType; const seriesId = modalInstance.dialog.dataset.seriesId; + const season = modalInstance.dialog.dataset.season; + const episode = modalInstance.dialog.dataset.episode; const isCurrentlyWatched = modalInstance.dialog.dataset.isWatched === 'true'; + // If episodeId is missing, fetch it using seriesId, season, and episode + if (!episodeId) { + try { + const item = await fetchEpisode(seriesId, season, episode); + if (item) { + episodeId = item.Id; + modalInstance.dialog.dataset.episodeId = episodeId; + } + } catch (err) { + ERR(err.message); + window.ModalSystem.close('episode-confirmation-modal'); + return; + } + } + // Find the original element let element = null; if (elementType === 'chunk') { @@ -646,7 +721,17 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } } - element = document.querySelector(`[data-episode-id="${episodeId}"].episode-watched-toggle`); + if (!element) { + element = document.querySelector(`[data-episode-id="${episodeId}"].episode-watched-toggle`); + } + + // Reconstruct episodeInfo for updateLastWatched if needed + const episodeInfoForUpdate = episodeInfo || { + title: modalInstance.dialog.querySelector('.episode-title')?.textContent || 'Episode', + season: parseInt(modalInstance.dialog.dataset.season), + episode: parseInt(modalInstance.dialog.dataset.episode), + seriesId: seriesId + }; if (isCurrentlyWatched) { // Remove from watched @@ -662,7 +747,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: const progressCard = document.querySelector(`[data-series-id="${seriesId}"]`)?.closest('.progress-card'); if (progressCard) { const lastWatchedElement = progressCard.querySelector('.progress-last-watched'); - updateLastWatched(lastWatchedElement, episodeInfo); + updateLastWatched(lastWatchedElement, episodeInfoForUpdate); } } @@ -746,11 +831,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
@@ -1241,11 +1326,41 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: } // Main function to update episode watched status in cache - function updateEpisodeWatchedStatusInCache(seriesId, episodeId, isWatched = true) { - const cachedProgress = getCachedSeriesProgress(seriesId); + async function updateEpisodeWatchedStatusInCache(seriesId, episodeId, isWatched = true) { + let cachedProgress = getCachedSeriesProgress(seriesId); if (!cachedProgress) { - LOG(`No cached progress found for series: ${seriesId}`); - return false; + LOG(`No cached progress found for series: ${seriesId}, fetching and adding to cache`); + + // Fetch series data from API + const apiClient = window.ApiClient; + const userId = apiClient.getCurrentUserId(); + const serverUrl = apiClient.serverAddress(); + const token = apiClient.accessToken(); + + try { + // Fetch the series item + const seriesUrl = `${serverUrl}/Items/${seriesId}?UserId=${userId}&Fields=UserData,RecursiveItemCount&EnableImageTypes=Primary,Banner`; + const seriesRes = await fetch(seriesUrl, { headers: { "Authorization": `MediaBrowser Token=\"${token}\"` } }); + const series = await seriesRes.json(); + + if (!series || !series.Id) { + LOG(`Failed to fetch series data for seriesId: ${seriesId}`); + return false; + } + + // Fetch and create progress data for this series + cachedProgress = await fetchSeriesProgressWithMissingEpisodes(series, userId, serverUrl, token); + + if (!cachedProgress) { + LOG(`Failed to create progress data for series: ${seriesId}`); + return false; + } + + LOG(`Successfully added series ${series.Name} to cache`); + } catch (err) { + ERR(`Failed to fetch and add series ${seriesId} to cache:`, err); + return false; + } } // Update episode data if available in memory @@ -1299,6 +1414,15 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: cachedProgress.binaryProgress = generateBinaryProgressData(cachedProgress.episodes); } + // Get the last watched episode based on the cachedProgress.episodes + const lastWatchedEpisode = cachedProgress.episodes + .filter(ep => ep.UserData && ep.UserData.LastPlayedDate) + .sort((a, b) => new Date(b.UserData.LastPlayedDate) - new Date(a.UserData.LastPlayedDate))[0]; + + if (lastWatchedEpisode) { + cachedProgress.lastWatchedEpisode = lastWatchedEpisode; + } + // Update the cache cacheSeriesProgress(seriesId, cachedProgress); @@ -1472,20 +1596,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: if (progressData.binaryProgress) { // Create progress bar from binary data (instant) createBinaryProgressBar(progressBar, progressData.binaryProgress, seriesId); - LOG(`Created binary progress bar for series: ${progressData.series.Name}`); - - // Enhance with episode details in background - setTimeout(async () => { - try { - const episodes = await fetchEpisodesForSeries(seriesId); - if (episodes && episodes.length > 0) { - enhanceProgressBarWithEpisodes(progressBar, episodes); - LOG(`Enhanced progress bar with episode details for series: ${progressData.series.Name}`); - } - } catch (err) { - ERR(`Failed to enhance progress bar for series ${seriesId}:`, err); - } - }, 100); // Small delay to let initial render complete } else { // Fallback to simple progress bar @@ -2081,7 +2191,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); @@ -2173,7 +2283,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: // Store in localStorage for next time (optimized) const optimizedData = optimizeWatchlistDataForStorage(sortedItems); - localStorageCache.set(`watchlist_${section}`, optimizedData); + localStorageCache.set(`watchlist_${section}`, optimizedData, ApiClient._currentUser.Id, WATCHLIST_CACHE_TTL); LOG(`Stored optimized watchlist ${section} data in localStorage`); // Sync watched status for this section (remove played items) @@ -2453,8 +2563,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: LOG(`Using cached progress for series: ${series.Name}`); return cachedProgress; } - - LOG(`Fetching progress for series: ${series.Name} (${series.Status})`); // Get all episodes including missing ones to get accurate TotalRecordCount const episodesUrl = `${serverUrl}/Shows/${series.Id}/Episodes?UserId=${userId}&Fields=UserData&EnableImageTypes=Primary`; @@ -2985,7 +3093,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: @@ -2995,10 +3103,10 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
@@ -3016,10 +3124,10 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
- search +
@@ -3028,7 +3136,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
- search +
@@ -3060,7 +3168,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: