diff --git a/scripts/apiHelper.js b/scripts/apiHelper.js index 6b5f286..7bdff8d 100644 --- a/scripts/apiHelper.js +++ b/scripts/apiHelper.js @@ -344,9 +344,17 @@ cachedServerVersion: null } + /** + * Fields used by progressive Watchlist sections. Must match getWatchlistQuery / + * invalidateWatchlistQueries so cache clear hits the same URL as fetch. + */ + const WATCHLIST_PROGRESSIVE_FIELDS = + 'PrimaryImageAspectRatio,DateCreated,ProductionYear,PremiereDate,ProviderIds,UserData'; + /** * Build a single top-level Likes query def (shared by getWatchlistItems / getWatchlistQuery). * Callers pass IncludeItemTypes (and other options) to narrow results. + * Default Fields match progressive sections so invalidate clears the fetched URL. * @param {Object} options * @returns {{ queryOptions: Object }} */ @@ -356,6 +364,7 @@ Recursive: true, ImageTypeLimit: 1, EnableImageTypes: 'Primary,Backdrop,Thumb', + Fields: WATCHLIST_PROGRESSIVE_FIELDS, ...options, Filters: 'Likes' } @@ -1501,6 +1510,9 @@ return genreSyncScheduledPromise; } + // Shared with watchlist progressive sections / invalidate (same Fields → same cache URL) + apiHelper.WATCHLIST_PROGRESSIVE_FIELDS = WATCHLIST_PROGRESSIVE_FIELDS; + // Expose apiHelper to global window object window.apiHelper = apiHelper; diff --git a/scripts/cardBuilder.js b/scripts/cardBuilder.js index 947901c..3c1bc54 100644 --- a/scripts/cardBuilder.js +++ b/scripts/cardBuilder.js @@ -2226,12 +2226,28 @@ }); } + function updateCardFavoriteButtons(itemId, userData) { + if (!itemId || !userData || !('IsFavorite' in userData)) return; + + const isFavorite = userData.IsFavorite === true; + itemIdDomVariants(itemId).forEach((variant) => { + const escapedId = escapeCssIdentForCardSync(variant); + document.querySelectorAll( + `[is="emby-ratingbutton"][data-id="${escapedId}"], button[data-isfavorite][data-id="${escapedId}"]` + ).forEach((button) => { + button.setAttribute('data-isfavorite', isFavorite ? 'true' : 'false'); + button.title = isFavorite ? 'Remove from favorites' : 'Add to favorites'; + }); + }); + } + function applyUserDataEntriesToCards(entries, userId) { if (!isPrimaryUserDataChange(userId)) return; entries.forEach((entry) => { if (entry?.ItemId) { updateCardResumeAttributes(entry.ItemId, entry); updateCardWatchlistButtons(entry.ItemId, entry); + updateCardFavoriteButtons(entry.ItemId, entry); } }); } diff --git a/scripts/watchlist.js b/scripts/watchlist.js index ea61b1e..871326d 100644 --- a/scripts/watchlist.js +++ b/scripts/watchlist.js @@ -3297,6 +3297,37 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: }); } + /** + * Keep paint-snapshot membership + header counts in sync on like/unlike without re-rendering sections. + * Ghost cards may remain in the DOM; counts reflect liked membership. + */ + function patchWatchlistItemsByTypeMembership(itemId, itemType, isAdded) { + if (!itemId) return; + + const removeFromType = (type) => { + const list = watchlistItemsByType[type]; + if (!Array.isArray(list) || !list.length) return false; + const next = list.filter((item) => item?.Id !== itemId); + if (next.length === list.length) return false; + watchlistItemsByType[type] = next; + return true; + }; + + if (isAdded) { + const type = itemType && isWatchlistSupportedType(itemType) ? itemType : null; + if (!type) return; + const list = watchlistItemsByType[type] || (watchlistItemsByType[type] = []); + if (list.some((item) => item?.Id === itemId)) return; + list.push({ Id: itemId }); + } else if (itemType && isWatchlistSupportedType(itemType)) { + removeFromType(itemType); + } else { + WATCHLIST_SECTION_TYPES.forEach(({ type }) => removeFromType(type)); + } + + updateWatchlistHeaderStats(); + } + function getWatchlistProgressiveHost() { const watchlistSection = getWatchlistSection(); if (!watchlistSection) return null; @@ -3330,9 +3361,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: throw new Error('apiHelper.getWatchlistQuery unavailable'); } + // Same Fields as apiHelper.buildWatchlistQueryDef so invalidate hits this fetch URL const queryOptions = { IncludeItemTypes: itemType, - Fields: 'PrimaryImageAspectRatio,DateCreated,ProductionYear,PremiereDate,ProviderIds,UserData' + Fields: apiHelper.WATCHLIST_PROGRESSIVE_FIELDS + || 'PrimaryImageAspectRatio,DateCreated,ProductionYear,PremiereDate,ProviderIds,UserData' }; const queryResult = await apiHelper.getWatchlistQuery(queryOptions, { ttl: WATCHLIST_CACHE_TTL, @@ -4974,31 +5007,52 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: alert(`Watchlist cleared: ${cleared} item(s) removed.`); } - // Invalidate progressive watchlist cache and re-render when membership changes + /** + * Membership changed: update liked-Id cache + invalidate Likes query URL. + * Do not full-rebuild the Watchlist tab (avoids scroll jump / stale re-paint races). + * Cards stay as ghosts until next visit; cardBuilder owns overlay button UI via UserDataChanged. + */ async function updateWatchlistCacheOnToggle(itemId, itemType, isAdded) { try { - LOG(`Watchlist membership changed for ${itemType} ${itemId} (added=${isAdded}); refreshing progressive sections`); + LOG(`Watchlist membership changed for ${itemType || 'unknown'} ${itemId} (added=${isAdded}); invalidating query cache`); setLikedIdInCache(itemId, isAdded === true); - syncOverlayWatchlistButtons(); + patchWatchlistItemsByTypeMembership(itemId, itemType, isAdded === true); - if (itemType) { + if (itemType && isWatchlistSupportedType(itemType)) { await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType }); } else { await invalidateAllWatchlistQueries(); } - const watchlistSection = getWatchlistSection(); - const watchlistVisible = !!watchlistSection - && !watchlistSection.closest('.libraryPage.hide') - && !!watchlistSection.querySelector('div[data-tab="watchlist"]:not([style*="display: none"])'); - - if (watchlistVisible || tabStates.watchlist.isDataFetched) { - await initWatchlistTab(true); - } else { - tabStates.watchlist.isDataFetched = false; - } + tabStates.watchlist.isDataFetched = false; } catch (err) { - ERR('Error refreshing watchlist after toggle:', err); + ERR('Error refreshing watchlist cache after toggle:', err); + } + } + + /** + * Sync liked-Id cache + invalidate progressive Likes queries from UserData.Likes. + * Idempotent for the Set when cardBuilder already applied Likes; always invalidate + * so a remote/Library like is visible on the next Watchlist visit. + */ + async function handleItemWatchlistChange(itemId, isLiked, itemType) { + if (!itemId || typeof isLiked !== 'boolean') return; + + if (isItemInLikedIdCache(itemId) !== isLiked) { + setLikedIdInCache(itemId, isLiked); + } + // Keep header counts aligned even if liked-Id Set was already updated by cardBuilder + patchWatchlistItemsByTypeMembership(itemId, itemType, isLiked); + + try { + if (itemType && isWatchlistSupportedType(itemType)) { + await window.apiHelper?.invalidateWatchlistQueries?.({ IncludeItemTypes: itemType }); + } else { + await invalidateAllWatchlistQueries(); + } + tabStates.watchlist.isDataFetched = false; + } catch (err) { + ERR('Error invalidating watchlist after Likes change:', err); } } @@ -5022,8 +5076,11 @@ In the Custom Tabs plugin, add a new tab with the following HTML content: for (const userData of entries) { if (!userData?.ItemId) continue; - LOG(`Detected UserDataChanged for item: ${userData.ItemId}, Played: ${userData.Played}`); - handleItemWatchedStatusChange(userData.ItemId, userData.Played, userData.Likes).catch(err => { + LOG(`Detected UserDataChanged for item: ${userData.ItemId}, Played: ${userData.Played}, Likes: ${userData.Likes}`); + (async () => { + await handleItemWatchlistChange(userData.ItemId, userData.Likes); + await handleItemWatchedStatusChange(userData.ItemId, userData.Played, userData.Likes); + })().catch(err => { ERR('Error handling UserDataChanged event:', err); }); }