diff --git a/scripts/statistics.js b/scripts/statistics.js
index 6401cc3..0dcd154 100644
--- a/scripts/statistics.js
+++ b/scripts/statistics.js
@@ -642,6 +642,27 @@
return chart;
}
+ function getSeriesWatchedRuntimeTicks(progress) {
+ if (!progress) return 0;
+ const source = Array.isArray(progress.episodes) && progress.episodes.length
+ ? progress.episodes
+ : (progress.playEvents || []);
+ if (source.length) {
+ let totalTicks = 0;
+ source.forEach((ep) => {
+ const key = ep?.UserData?.LastPlayedDate || ep?.LastPlayedDate;
+ const played = ep?.UserData ? ep.UserData.Played === true : !!key;
+ if (!played) return;
+ totalTicks += Number(ep.RunTimeTicks) || 0;
+ });
+ return totalTicks;
+ }
+ if (typeof progress.watchedRuntime === 'number' && progress.watchedRuntime > 0) {
+ return Math.round(progress.watchedRuntime * 10000000);
+ }
+ return 0;
+ }
+
function calculateSummary(progressData, movies) {
const list = progressData || [];
const seriesStarted = list.length;
@@ -654,15 +675,7 @@
totalTicks += Number(movie.RunTimeTicks) || 0;
});
list.forEach((progress) => {
- const source = Array.isArray(progress.episodes) && progress.episodes.length
- ? progress.episodes
- : (progress.playEvents || []);
- source.forEach((ep) => {
- const key = ep?.UserData?.LastPlayedDate || ep?.LastPlayedDate;
- const played = ep?.UserData ? ep.UserData.Played === true : !!key;
- if (!played) return;
- totalTicks += Number(ep.RunTimeTicks) || 0;
- });
+ totalTicks += getSeriesWatchedRuntimeTicks(progress);
});
const hoursWatched = Math.round(totalTicks / 10000000 / 3600);
@@ -672,12 +685,13 @@
episodesWatched: progress.watchedCount || 0,
totalEpisodes: progress.totalEpisodes || 0,
percentage: progress.percentage || 0,
- seriesId: progress.series?.Id
+ seriesId: progress.series?.Id,
+ watchedRuntimeTicks: getSeriesWatchedRuntimeTicks(progress)
}))
.sort((a, b) => {
- const percentageDiff = b.percentage - a.percentage;
- if (percentageDiff !== 0) return percentageDiff;
- return b.totalEpisodes - a.totalEpisodes;
+ const tickDiff = b.watchedRuntimeTicks - a.watchedRuntimeTicks;
+ if (tickDiff !== 0) return tickDiff;
+ return (b.episodesWatched || 0) - (a.episodesWatched || 0);
})
.slice(0, 5);
@@ -705,11 +719,12 @@
const rank = index + 1;
const rankClass = rank <= 3 ? ` rank-${rank}` : '';
const episodesText = show.episodesWatched === 1 ? 'episode' : 'episodes';
+ const timeLabel = formatHours(show.watchedRuntimeTicks || 0);
return `
${rank}
${escapeHtml(show.name)}
-
${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
+
${timeLabel} · ${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
`;
}).join('');
diff --git a/scripts/watchlist.js b/scripts/watchlist.js
index 9f58c31..6f1ece3 100644
--- a/scripts/watchlist.js
+++ b/scripts/watchlist.js
@@ -927,27 +927,86 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
});
}
- // Show all shows modal
- function showAllShowsModal() {
- // Get all progress data and sort it the same way as top 5
- const progressData = progressCache.data;
- const allShows = progressData
- .map(progress => ({
- name: progress.series.Name,
- episodesWatched: progress.watchedCount,
- totalEpisodes: progress.totalEpisodes,
- percentage: progress.percentage,
- seriesId: progress.series.Id
+ /** Sum played-episode RunTimeTicks for a progress row (watchedRuntime is seconds when set). */
+ function getSeriesWatchedRuntimeTicks(progress) {
+ if (!progress) return 0;
+ const source = Array.isArray(progress.episodes) && progress.episodes.length
+ ? progress.episodes
+ : (progress.playEvents || []);
+ if (source.length) {
+ let totalTicks = 0;
+ source.forEach((ep) => {
+ const key = ep?.UserData?.LastPlayedDate || ep?.LastPlayedDate;
+ const played = ep?.UserData ? ep.UserData.Played === true : !!key;
+ if (!played) return;
+ totalTicks += Number(ep.RunTimeTicks) || 0;
+ });
+ return totalTicks;
+ }
+ if (typeof progress.watchedRuntime === 'number' && progress.watchedRuntime > 0) {
+ return Math.round(progress.watchedRuntime * 10000000);
+ }
+ return 0;
+ }
+
+ function formatWatchedRuntimeLabel(ticks) {
+ const seconds = (Number(ticks) || 0) / 10000000;
+ const hours = seconds / 3600;
+ if (hours >= 1) return `${hours.toFixed(1)} hours`;
+ const mins = Math.round(seconds / 60);
+ return `${mins} min`;
+ }
+
+ function escapeHtmlWatchlist(str) {
+ return String(str || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ /** Map progress rows to top-show entries sorted by total watch time. */
+ function mapProgressToTopShows(progressData, limit = null) {
+ const mapped = (progressData || [])
+ .map((progress) => ({
+ name: progress.series?.Name || 'Unknown',
+ episodesWatched: progress.watchedCount || 0,
+ totalEpisodes: progress.totalEpisodes || 0,
+ percentage: progress.percentage || 0,
+ seriesId: progress.series?.Id,
+ watchedRuntimeTicks: getSeriesWatchedRuntimeTicks(progress)
}))
.sort((a, b) => {
- // Primary sort: completion percentage (highest first)
- const percentageDiff = b.percentage - a.percentage;
- if (percentageDiff !== 0) {
- return percentageDiff;
- }
- // Tiebreaker: episode count (most episodes first)
- return b.totalEpisodes - a.totalEpisodes;
+ const tickDiff = b.watchedRuntimeTicks - a.watchedRuntimeTicks;
+ if (tickDiff !== 0) return tickDiff;
+ return (b.episodesWatched || 0) - (a.episodesWatched || 0);
});
+ return limit != null ? mapped.slice(0, limit) : mapped;
+ }
+
+ function bindShowAllShowsButton(root) {
+ const scope = root && root.querySelector ? root : document;
+ const showAllBtn = scope.querySelector?.('#show-all-shows-btn')
+ || getElementByIdSafe('show-all-shows-btn');
+ if (!showAllBtn || showAllBtn.dataset.showAllBound === 'true') return;
+ showAllBtn.dataset.showAllBound = 'true';
+ showAllBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ showAllShowsModal();
+ });
+ }
+
+ // Show all shows modal
+ function showAllShowsModal() {
+ if (!window.ModalSystem) {
+ WARN('ModalSystem not available');
+ return;
+ }
+
+ // Get all progress data and sort by total watch time (same as Top Shows)
+ const progressData = progressCache.data || [];
+ const allShows = mapProgressToTopShows(progressData);
let showsHtml = '';
if (allShows.length === 0) {
@@ -961,8 +1020,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
${rank}
-
${show.name}
-
${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
+
${escapeHtmlWatchlist(show.name)}
+
${formatWatchedRuntimeLabel(show.watchedRuntimeTicks)} · ${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
`;
@@ -1087,12 +1146,6 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
return visibleWatchlistSections[0];
}
- // Safe element getter that targets the visible library page
- function getElementByIdSafe(elementId) {
- const libraryPage = getVisibleLibraryPage();
- return libraryPage ? libraryPage.querySelector(`#${elementId}`) : getElementByIdSafe(elementId);
- }
-
// Add custom CSS for watchlist icon
const style = document.createElement('style');
style.textContent = `
@@ -2790,25 +2843,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
const moviesWatched = movieCache.data.length;
const hoursWatched = sumWatchedRuntimeHours(progressData, movieCache.data);
- // Calculate top 5 shows by completion percentage, then by episode count
- const topShows = progressData
- .map(progress => ({
- name: progress.series.Name,
- episodesWatched: progress.watchedCount,
- totalEpisodes: progress.totalEpisodes,
- percentage: progress.percentage,
- seriesId: progress.series.Id
- }))
- .sort((a, b) => {
- // Primary sort: completion percentage (highest first)
- const percentageDiff = b.percentage - a.percentage;
- if (percentageDiff !== 0) {
- return percentageDiff;
- }
- // Tiebreaker: episode count (most episodes first)
- return b.totalEpisodes - a.totalEpisodes;
- })
- .slice(0, 5);
+ // Top 5 shows by total watch time (RunTimeTicks), then episode count
+ const topShows = mapProgressToTopShows(progressData, 5);
return {
seriesStarted,
@@ -2855,12 +2891,13 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
const rank = index + 1;
const rankClass = rank <= 3 ? ` rank-${rank}` : '';
const episodesText = show.episodesWatched === 1 ? 'episode' : 'episodes';
+ const timeLabel = formatWatchedRuntimeLabel(show.watchedRuntimeTicks);
return `
${rank}
-
${show.name}
-
${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
+
${escapeHtmlWatchlist(show.name)}
+
${timeLabel} · ${show.episodesWatched} of ${show.totalEpisodes} ${episodesText} (${show.percentage}%)
`;
}).join('');
@@ -3540,6 +3577,9 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
// Reset listeners setup flag since we have new DOM elements
watchlistSection.dataset.listenersSetup = 'false';
+ // Bind Show All immediately on the fresh button (does not wait for tab listener pass)
+ bindShowAllShowsButton(watchlistSection);
+
// Add refresh buttons after HTML is rendered
addRefreshButtons();
@@ -5723,6 +5763,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
await updateProgressStatistics(progressCache.data);
}
+ bindShowAllShowsButton(statisticsTab);
+
LOG('Statistics content rendered successfully');
} catch (err) {
ERR('Error rendering statistics content:', err);
@@ -6814,14 +6856,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
setupMovieSearch();
// Setup "Show All" button for top shows
- const showAllBtn = getElementByIdSafe('show-all-shows-btn');
- if (showAllBtn) {
- showAllBtn.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
- showAllShowsModal();
- });
- }
+ bindShowAllShowsButton(watchlistSection);
// Mark as setup to prevent duplicates
watchlistSection.dataset.listenersSetup = 'true';
@@ -7511,6 +7546,52 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
/************ Watchlist Button Observer ************/
+ function bindWatchlistButton(button, itemId, itemType) {
+ if (!button || !itemId) return;
+ if (!button.getAttribute('data-id')) {
+ button.setAttribute('data-id', itemId);
+ }
+ if (button.dataset.watchlistBound === 'true') return;
+ button.dataset.watchlistBound = 'true';
+ button.addEventListener('click', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const newRating = button.dataset.active === 'false' ? 'true' : 'false';
+ await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
+ const isActive = newRating === 'true';
+ applyWatchlistButtonActiveState(button, isActive);
+ await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
+ });
+ }
+
+ function bindSpotlightWatchlistButtons(root) {
+ const scope = root && root.nodeType === Node.ELEMENT_NODE ? root : document;
+ const buttons = new Set();
+ if (
+ scope.classList?.contains('watchlist-button') &&
+ scope.getAttribute('data-id') &&
+ (scope.classList.contains('spotlight-watchlist-button') || scope.closest?.('.spotlight-section'))
+ ) {
+ buttons.add(scope);
+ }
+ scope.querySelectorAll?.(
+ '.spotlight-section .watchlist-button[data-id], .watchlist-button.spotlight-watchlist-button[data-id]'
+ )?.forEach((button) => buttons.add(button));
+
+ buttons.forEach((button) => {
+ const itemId = button.getAttribute('data-id');
+ if (!itemId) return;
+ const host = button.closest('[data-item-type], [data-type]');
+ const itemType =
+ button.getAttribute('data-type') ||
+ host?.getAttribute('data-item-type') ||
+ host?.getAttribute('data-type') ||
+ '';
+ bindWatchlistButton(button, itemId, itemType);
+ });
+ }
+
// Function to add watchlist button to a card overlay container
function addWatchlistButton(overlayContainer) {
if (!overlayContainer) return;
@@ -7579,19 +7660,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
applyWatchlistButtonActiveState(watchlistButton, isItemInLikedIdCache(itemId));
}
- if (watchlistButton.dataset.watchlistBound !== 'true') {
- watchlistButton.dataset.watchlistBound = 'true';
- watchlistButton.addEventListener('click', async (e) => {
- e.preventDefault();
- e.stopPropagation();
-
- const newRating = watchlistButton.dataset.active === 'false' ? 'true' : 'false';
- await ApiClient.updateUserItemRating(ApiClient.getCurrentUserId(), itemId, newRating);
- const isActive = newRating === 'true';
- applyWatchlistButtonActiveState(watchlistButton, isActive);
- await updateWatchlistCacheOnToggle(itemId, itemType, isActive);
- });
- }
+ bindWatchlistButton(watchlistButton, itemId, itemType);
}
// Function to process all existing overlay containers
@@ -7604,10 +7673,12 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
addWatchlistButton(overlayContainer);
}
});
+ bindSpotlightWatchlistButtons(document);
}
function processWatchlistOverlaysInRoot(root) {
if (!root || root.nodeType !== Node.ELEMENT_NODE) return;
+ bindSpotlightWatchlistButtons(root);
if (root.classList?.contains('cardOverlayContainer')) {
const buttonContainer = root.querySelector('.cardOverlayButton-br');
if (buttonContainer) addWatchlistButton(root);
@@ -7656,6 +7727,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
+ bindSpotlightWatchlistButtons(node);
// Skip deep card-internal inserts (handled by shallow itemsContainer observers)
if (node.closest?.('.card') && !node.classList?.contains('itemsContainer')) {
continue;
@@ -7685,6 +7757,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
has: isItemInLikedIdCache,
set: setLikedIdInCache,
syncOverlayButtons: syncOverlayWatchlistButtons,
+ bindWatchlistButton,
isSupportedType: isWatchlistSupportedType
};