From 430981cbf7aa9dc8e954b43777d93c934c980b58 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 30 Mar 2026 13:40:42 +0300 Subject: [PATCH] main pages --- src/pages/CollectionPage.tsx | 176 ++++ src/pages/DetailPage.tsx | 359 ++++++++ src/pages/DiscoverPage.tsx | 192 ++++ src/pages/DownloadsPage.tsx | 167 ++++ src/pages/DuplicatesPage.tsx | 134 +++ src/pages/HomePage.tsx | 202 +++++ src/pages/LibraryPage.tsx | 422 +++++++++ src/pages/LiveTvPage.tsx | 268 ++++++ src/pages/LoginPage.tsx | 244 +++++ src/pages/MusicPage.tsx | 164 ++++ src/pages/PersonPage.tsx | 329 +++++++ src/pages/PlayerPage.tsx | 1615 ++++++++++++++++++++++++++++++++++ src/pages/PlaylistsPage.tsx | 99 +++ src/pages/ProfilePage.tsx | 343 ++++++++ src/pages/RequestsPage.tsx | 391 ++++++++ src/pages/SearchPage.tsx | 237 +++++ src/pages/SettingsPage.tsx | 314 +++++++ src/pages/StatsPage.tsx | 386 ++++++++ src/pages/TmdbDetailPage.tsx | 489 ++++++++++ 19 files changed, 6531 insertions(+) create mode 100644 src/pages/CollectionPage.tsx create mode 100644 src/pages/DetailPage.tsx create mode 100644 src/pages/DiscoverPage.tsx create mode 100644 src/pages/DownloadsPage.tsx create mode 100644 src/pages/DuplicatesPage.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/LibraryPage.tsx create mode 100644 src/pages/LiveTvPage.tsx create mode 100644 src/pages/LoginPage.tsx create mode 100644 src/pages/MusicPage.tsx create mode 100644 src/pages/PersonPage.tsx create mode 100644 src/pages/PlayerPage.tsx create mode 100644 src/pages/PlaylistsPage.tsx create mode 100644 src/pages/ProfilePage.tsx create mode 100644 src/pages/RequestsPage.tsx create mode 100644 src/pages/SearchPage.tsx create mode 100644 src/pages/SettingsPage.tsx create mode 100644 src/pages/StatsPage.tsx create mode 100644 src/pages/TmdbDetailPage.tsx diff --git a/src/pages/CollectionPage.tsx b/src/pages/CollectionPage.tsx new file mode 100644 index 0000000..4d5d446 --- /dev/null +++ b/src/pages/CollectionPage.tsx @@ -0,0 +1,176 @@ +import { useParams, useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { Boxes, Calendar } from '../lib/icons' +import { useTmdbCollection } from '../hooks/use-tmdb' +import { getTmdbImageUrl } from '../api/tmdb' +import { usePosterGridClasses } from '../lib/density' + +export default function CollectionPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { data: collection, isLoading } = useTmdbCollection(id) + const gridCls = usePosterGridClasses() + + if (isLoading) return + if (!collection) { + return ( +
+

Collection not found

+
+ ) + } + + // Sort by release date ascending; items with no/unparseable date sort + // to the end so unannounced sequels don't end up at #1. + const parts = (collection.parts || []).slice().sort((a, b) => { + const ta = a.release_date ? Date.parse(a.release_date) : NaN + const tb = b.release_date ? Date.parse(b.release_date) : NaN + const da = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY + const db = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY + return da - db + }) + + const totalRuntime = parts.reduce((sum, p) => sum + (p.runtime || 0), 0) + const avgRating = + parts.length > 0 + ? parts.reduce((sum, p) => sum + (p.vote_average || 0), 0) / parts.length + : 0 + + return ( +
+ {/* Hero */} +
+ {collection.backdrop_path && ( + + )} +
+
+ +
+ + + + Collection + +

+ {collection.name} +

+
+ {parts.length} {parts.length === 1 ? 'film' : 'films'} + {totalRuntime > 0 && ( + <> + · + + {Math.floor(totalRuntime / 60)}h {totalRuntime % 60}m total + + + )} + {avgRating > 0 && ( + <> + · + {avgRating.toFixed(1)} avg rating + + )} +
+ {collection.overview && ( +

+ {collection.overview} +

+ )} +
+
+
+
+ + {/* Films */} +
+
+ +

In order of release

+
+ +
+ {parts.map((m, i) => ( + navigate(`/item/tmdb-${m.id}`)} + initial={{ opacity: 0, y: 8 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }} + whileHover={{ y: -3 }} + className="group text-left focus-ring rounded-lg" + > +
+ {m.poster_path ? ( + {m.title} + ) : ( +
+ {m.title?.[0]} +
+ )} +
+ + {i + 1} + +
+
+

+ {m.title} +

+

+ + {m.release_date ? m.release_date.slice(0, 4) : 'TBA'} +

+
+ ))} +
+
+
+ ) +} + +function CollectionSkeleton() { + const gridCls = usePosterGridClasses() + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/src/pages/DetailPage.tsx b/src/pages/DetailPage.tsx new file mode 100644 index 0000000..48c99cf --- /dev/null +++ b/src/pages/DetailPage.tsx @@ -0,0 +1,359 @@ +import { useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' +import { + useItemDetails, + useLibraryByTmdbId, + useRefreshItem, + useSampleEpisode, + useBulkToggleFavorite, +} from '../hooks/use-jellyfin' +import { useTmdbMovie, useTmdbTvShow, useTmdbCollection } from '../hooks/use-tmdb' +import { + useCinemeta, + useFanartMovie, + useFanartTv, + useTvmazeByImdbId, + useWikiResolve, + useWikiSection, + useWikidataAwards, + useWikidataLocations, + useRottenTomatoes, +} from '../hooks/use-external' +import { pickBestFanartImage } from '../api/fanart' +import DetailHero from '../components/detail/DetailHero' +import DetailStickyBar from '../components/detail/DetailStickyBar' +import type { SectionTab } from '../components/detail/DetailSectionTabs' +import DetailSkeleton from '../components/detail/DetailSkeleton' +import DetailMainSections from '../components/detail/DetailMainSections' +import { usePastSentinel } from '../hooks/use-past-sentinel' +import { parseTmdbRouteId } from '../lib/tmdb-mapping' +import TmdbDetailPage from './TmdbDetailPage' +import { getBestImage, getImageUrl, getStoredServerUrl } from '../api/jellyfin' +import PlaylistView from '../components/detail/PlaylistView' +import FolderView from '../components/detail/FolderView' +import { formatRuntime, regionForUser } from '../lib/format' +import { usePreferencesStore } from '../stores/preferences-store' + +export default function DetailPage() { + const { id } = useParams<{ id: string }>() + // Synthetic TMDB ids from discovery rows route to a dedicated TMDB-only + // page so items not in the library get a proper detail view + Request CTA. + const tmdbRoute = parseTmdbRouteId(id) + if (tmdbRoute) return + return +} + +function JellyfinDetailPage({ id }: { id?: string }) { + const { data: item, isLoading } = useItemDetails(id) + const { sentinelRef: stickySentinelRef, past: pastHero } = usePastSentinel() + const toggleFavorite = useBulkToggleFavorite() + + const itemType = item?.Type + const tmdbId = item?.ProviderIds?.Tmdb + const imdbId = item?.ProviderIds?.Imdb || null + const tvdbId = item?.ProviderIds?.Tvdb || null + + const tmdbMovie = useTmdbMovie(itemType === 'Movie' ? tmdbId : null) + const tmdbTv = useTmdbTvShow(itemType === 'Series' ? tmdbId : null) + const tmdbData = itemType === 'Movie' ? tmdbMovie.data : tmdbTv.data + + // External enrichment - all keyless except Fanart.tv which silently no-ops + // when the personal key is empty. Each hook gates on type so we don't fire + // movie endpoints for shows or vice versa. + const fanartMovieQuery = useFanartMovie(itemType === 'Movie' ? (tmdbId || imdbId) : null) + const fanartTvQuery = useFanartTv(itemType === 'Series' ? tvdbId : null) + const cinemetaQuery = useCinemeta( + imdbId, + itemType === 'Movie' ? 'movie' : itemType === 'Series' ? 'series' : undefined, + ) + const tvmazeQuery = useTvmazeByImdbId(itemType === 'Series' ? imdbId : null) + + // Wikipedia overview fallback - resolved by title+year when neither + // Jellyfin/TMDB nor Cinemeta produced anything substantial. Hook must run + // unconditionally so it goes here, above any early returns. + const baseOverview = item?.Overview || tmdbData?.overview || '' + const overviewIsThin = baseOverview.trim().length < 60 + const cinemetaDescription = cinemetaQuery.data?.description || '' + const wikiQuery = useWikiResolve( + item?.Name && item?.ProductionYear + ? `${item.Name} ${item.ProductionYear}` + : item?.Name, + !!item && overviewIsThin && !cinemetaDescription, + ) + + // Wikipedia "Production" section, resolved unconditionally so the trivia + // block can render even when the overview is fine. We need a known title + // first; we always run a quick resolve for that and then ask for the + // section. Cheap because both queries are cached for a week. + const wikiTitleQuery = useWikiResolve( + item?.Name && item?.ProductionYear + ? `${item.Name} ${item.ProductionYear}` + : item?.Name, + !!item, + ) + const wikiTitle = wikiTitleQuery.data?.title || null + const wikiProductionQuery = useWikiSection(wikiTitle, 'Production') + + // Wikidata awards via SPARQL P166. Keyed off the TMDB external_ids + // wikidata_id field which we already pull on movie + tv full responses. + const wikidataId = tmdbData?.external_ids?.wikidata_id || null + const awardsQuery = useWikidataAwards(wikidataId) + const locationsQuery = useWikidataLocations(wikidataId) + + const collectionId = tmdbMovie.data?.belongs_to_collection?.id + const tmdbCollection = useTmdbCollection(collectionId) + + // Rotten Tomatoes critic + audience scores (Algolia search by title+year, + // same approach Jellyseerr uses). Series ratings work, episode ratings + // would be hit-or-miss so we only fire on Movie / Series. + const rtKind: 'movie' | 'tv' | null = + itemType === 'Movie' ? 'movie' : itemType === 'Series' ? 'tv' : null + const rtYear = itemType === 'Movie' + ? (tmdbMovie.data?.release_date ? Number(tmdbMovie.data.release_date.slice(0, 4)) : item?.ProductionYear) || null + : itemType === 'Series' + ? (tmdbTv.data?.first_air_date ? Number(tmdbTv.data.first_air_date.slice(0, 4)) : item?.ProductionYear) || null + : null + const rtName = + itemType === 'Movie' + ? tmdbMovie.data?.title || item?.Name + : itemType === 'Series' + ? tmdbTv.data?.name || item?.Name + : null + const rtQuery = useRottenTomatoes(rtName, rtYear, rtKind) + + const prefs = usePreferencesStore() + const region = prefs.region || regionForUser() + const showTmdbRatings = prefs.showTmdbRatings + + const libraryMap = useLibraryByTmdbId() + const refresh = useRefreshItem() + const sampleEpisode = useSampleEpisode(id, itemType === 'Series') + + // Versions selector state + const sources = (item?.MediaSources || []) as any[] + const [selectedSourceId, setSelectedSourceId] = useState(null) + const activeSourceId = selectedSourceId || sources[0]?.Id || null + + // Pick a regional certification when available (must run before early returns) + const regionalCert = useMemo(() => { + if (itemType === 'Movie') { + const dates = (tmdbMovie.data?.release_dates?.results || []).find(r => r.iso_3166_1 === region) + return dates?.release_dates?.find(d => d.certification)?.certification || null + } + if (itemType === 'Series') { + const r = (tmdbTv.data?.content_ratings?.results || []).find(r => r.iso_3166_1 === region) + return r?.rating || null + } + return null + }, [itemType, tmdbMovie.data, tmdbTv.data, region]) + + if (isLoading) return + + if (!item) { + return ( +
+

Item not found

+

The item you're looking for may have been removed.

+
+ ) + } + + const serverUrl = getStoredServerUrl() + + const jellyfinBackdrop = getBestImage(serverUrl, item, 'backdrop', 1920) + // Fanart backdrop fallback - useful for older / lesser-known items where + // the Jellyfin scraper didn't fetch a backdrop. Voted-best English wins. + const fanartBackdrop = pickBestFanartImage( + fanartMovieQuery.data?.moviebackground + || fanartTvQuery.data?.showbackground + || undefined, + ) + const backdropUrl = jellyfinBackdrop || fanartBackdrop?.url || null + const posterUrl = getBestImage(serverUrl, item, 'primary', 400) + + // Logo priority: Jellyfin Logo (locally curated) > Fanart.tv HD logo + // (cinematic SVG/PNG) > none (falls back to title typography). + const fanartLogo = pickBestFanartImage( + fanartMovieQuery.data?.hdmovielogo + || fanartMovieQuery.data?.movielogo + || fanartTvQuery.data?.hdtvlogo + || fanartTvQuery.data?.clearlogo + || undefined, + ) + const jellyfinLogo = + item.Id && item.ImageTags?.Logo + ? getImageUrl(serverUrl, item.Id, 'Logo', 600, item.ImageTags.Logo) + : null + const logoUrl = jellyfinLogo || fanartLogo?.url || null + + const progress = item.UserData?.PlayedPercentage + const positionTicks = item.UserData?.PlaybackPositionTicks + const resumeThresholdSec = prefs.resumeThresholdSec + const watchedSec = positionTicks ? Number(positionTicks) / 10_000_000 : 0 + // Only offer resume when we've watched past the threshold (and we're not effectively done) + const meetsResumeThreshold = watchedSec >= resumeThresholdSec && (progress ?? 0) < 95 + const resumeTime = positionTicks && meetsResumeThreshold ? formatRuntime(Number(positionTicks)) : null + const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null + + const genres = item.Genres || [] + const tmdbCredits = tmdbData?.credits + const cast = tmdbCredits?.cast?.slice(0, 18) || [] + const crew = tmdbCredits?.crew || [] + const trailer = tmdbData?.videos?.results?.find( + v => v.site === 'YouTube' && v.type === 'Trailer', + ) + const hideAdult = prefs.hideAdult + const adultFilter = (arr: T[]) => + hideAdult ? arr.filter(x => !x.adult) : arr + const recommendations = adultFilter((tmdbData?.recommendations?.results || []) as { id: number; adult?: boolean }[]) + // Movie + TV similar arrays don't share an exact element type, so widen + // to the adultFilter constraint to keep TS happy across the union. + const similarRaw = itemType === 'Movie' + ? tmdbMovie.data?.similar?.results || [] + : tmdbTv.data?.similar?.results || [] + const similar = adultFilter(similarRaw as { id: number; adult?: boolean }[]) + const keywords = + itemType === 'Movie' + ? tmdbMovie.data?.keywords?.keywords || [] + : tmdbTv.data?.keywords?.results || [] + const reviews = tmdbData?.reviews?.results || [] + + const title = item.Name || 'Untitled' + const year = item.ProductionYear + // Final overview chain (computed values - the source flags + base/thin + // were already established up at the top so the hook ordering is stable). + const overview = baseOverview + || cinemetaDescription + || wikiQuery.data?.extract + || '' + const overviewSource = baseOverview + ? null + : cinemetaDescription + ? 'IMDB' + : wikiQuery.data + ? 'Wikipedia' + : null + const taglineText = tmdbData?.tagline + + const rating = regionalCert || item.OfficialRating + + const isFavorite = item.UserData?.IsFavorite + + const isFolder = + itemType === 'CollectionFolder' || + itemType === 'ManualPlaylistsFolder' || + itemType === 'PlaylistsFolder' + if (isFolder) { + return + } + + // Playlists get a purpose-built view: hero with Play / Shuffle, item list + // with thumbs and watched status. No tech specs, no episodes section. + if (itemType === 'Playlist') { + return + } + + const playUrl = `/play/${item.Id}${activeSourceId && sources.length > 1 ? `?source=${activeSourceId}` : ''}` + const resumeUrl = `/play/${item.Id}?resume=true${activeSourceId && sources.length > 1 ? `&source=${activeSourceId}` : ''}` + + const watchProviders = + itemType === 'Movie' ? tmdbMovie.data?.['watch/providers'] : tmdbTv.data?.['watch/providers'] + + return ( +
+ toggleFavorite.mutate({ itemIds: [item.Id!], favorite: !isFavorite }) + : undefined + } + /> + + +
+ + +
+ ) +} + +function detailTabsFor(itemType: string | undefined): SectionTab[] { + return [ + { id: 'detail-overview', label: 'Overview' }, + ...(itemType === 'Series' ? [{ id: 'detail-episodes', label: 'Episodes' } as SectionTab] : []), + { id: 'detail-reception', label: 'Reception' }, + { id: 'detail-cast', label: 'Cast & crew' }, + { id: 'detail-trivia', label: 'Trivia' }, + { id: 'detail-reviews', label: 'Reviews' }, + { id: 'detail-more', label: 'More like this' }, + ] +} + diff --git a/src/pages/DiscoverPage.tsx b/src/pages/DiscoverPage.tsx new file mode 100644 index 0000000..430fb91 --- /dev/null +++ b/src/pages/DiscoverPage.tsx @@ -0,0 +1,192 @@ +import { useState } from 'react' +import { useHasTmdbKey } from '../hooks/use-external' +import LazyMount from '../components/ui/LazyMount' +import { usePreferencesStore } from '../stores/preferences-store' +import { regionForUser } from '../lib/format' +import DiscoverFilters, { + DEFAULT_FILTERS, + hasAnyActiveFilters, + type DiscoverFilterState, +} from '../components/discover/DiscoverFilters' +import TonightHero from '../components/discover/TonightHero' +import { MoodChips, MoodRow } from '../components/discover/MoodChips' +import Roulette from '../components/discover/Roulette' +import DecadeStrip from '../components/discover/DecadeStrip' +import CanonicalLists from '../components/discover/CanonicalLists' +import OnThisDay from '../components/discover/OnThisDay' +import LibraryGapFinder from '../components/discover/LibraryGapFinder' +import { + BrowseSection, + genreTiles, + languageTiles, + studioTiles, + networkTiles, + type BrowseKey, +} from '../components/discover/BrowseGrid' +import { NoKey, Hero } from './discover/chrome' +import { FilteredGrid } from './discover/filtered-grid' +import { + TrendingDayRow, + TrendingWeekRow, + PopularMoviesRow, + PopularTvRow, + UpcomingMoviesRow, + TopRatedMoviesRow, + TopRatedTvRow, + CultClassicsRow, +} from './discover/rows' + +/** + * Discover page - intent-driven entry to TMDB content the user doesn't + * already have. + * + * Layout: + * 1. Compact page header + * 2. Sticky filter bar (movie/tv + advanced filters) + * 3. Editorial spotlight - one big "pick of the day" + * 4. Mood chips - 10 vibes that resolve to a single content row + * 5. 3-4 curated rows (Trending / Acclaimed / Coming soon / etc.) + * 6. Browse-by sections: genre, language, studio, network - compact + * tile grids with inline expansion when a tile is clicked + * + * Active filters take over the page with the existing FilteredGrid - that + * mode is unchanged so the filter workflow stays familiar. + */ +export default function DiscoverPage() { + const hasTmdb = useHasTmdbKey() + const prefs = usePreferencesStore() + const region = prefs.region || regionForUser() + const [filters, setFilters] = useState(() => ({ + ...DEFAULT_FILTERS, + watchRegion: region, + })) + const [moodId, setMoodId] = useState(null) + const [decade, setDecade] = useState(null) + const [expanded, setExpanded] = useState(null) + + if (!hasTmdb) return + + const filterMode = hasAnyActiveFilters(filters) + const kind = filters.kind + + // Reset the mood / expansion when toggling movie<->tv since both are + // kind-scoped and the previously-active mood may not exist on the + // other side. + function onFiltersChange(next: DiscoverFilterState) { + if (next.kind !== filters.kind) { + setMoodId(null) + setDecade(null) + setExpanded(null) + } + setFilters(next) + } + + return ( +
+ + +
+ +
+ + {filterMode ? ( + + ) : ( + <> + + +
+ +
+ + + + {moodId && ( +
+ +
+ )} + + + +
+ {kind === 'movie' ? ( + <> + + + + + + + ) : ( + <> + + + + + )} +
+ + {kind === 'movie' && } + + {kind === 'movie' && } + + {kind === 'movie' && } + +
+
+ + Browse by + +
+
+ + + + {kind === 'movie' && ( + + )} + + {kind === 'movie' && ( + + )} + + {kind === 'tv' && ( + + )} + + )} +
+ ) +} diff --git a/src/pages/DownloadsPage.tsx b/src/pages/DownloadsPage.tsx new file mode 100644 index 0000000..27f0dca --- /dev/null +++ b/src/pages/DownloadsPage.tsx @@ -0,0 +1,167 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { Download, Trash2, Play, AlertCircle, Check, Clock } from '../lib/icons' +import { useDownloads } from '../stores/downloads-store' +import { isTauri } from '../lib/tauri' + +export default function DownloadsPage() { + const navigate = useNavigate() + const items = useDownloads(s => s.items) + const remove = useDownloads(s => s.remove) + const clearCompleted = useDownloads(s => s.clearCompleted) + + const grouped = useMemo(() => { + const active = items.filter(i => i.status === 'queued' || i.status === 'downloading') + const done = items.filter(i => i.status === 'done') + const error = items.filter(i => i.status === 'error') + return { active, done, error } + }, [items]) + + return ( +
+
+
+
+ + Offline +
+

+ Downloads +

+

+ {isTauri + ? 'Items saved locally for offline playback.' + : 'Downloads are stored in the browser cache for offline playback.'} +

+
+ {grouped.done.length > 0 && ( + + )} +
+ + {items.length === 0 && ( +
+
+
+
+ +
+
+

No downloads yet

+

+ Use the download button in the player to save items for offline viewing. +

+
+ )} + +
+ {grouped.active.length > 0 && ( +
+

+ In progress +

+
+ {grouped.active.map(item => ( + remove(item.id)} /> + ))} +
+
+ )} + + {grouped.done.length > 0 && ( +
+

+ Completed +

+
+ {grouped.done.map(item => ( + remove(item.id)} onPlay={() => navigate(`/play/${item.itemId}`)} /> + ))} +
+
+ )} + + {grouped.error.length > 0 && ( +
+

+ Failed +

+
+ {grouped.error.map(item => ( + remove(item.id)} /> + ))} +
+
+ )} +
+
+ ) +} + +function DownloadRow({ + item, + onRemove, + onPlay, +}: { + item: import('../stores/downloads-store').DownloadItem + onRemove: () => void + onPlay?: () => void +}) { + return ( + + {item.posterUrl ? ( + + ) : ( +
+ +
+ )} +
+

{item.name}

+
+ {item.status === 'downloading' && ( + <> +
+
+
+ {item.progress}% + + )} + {item.status === 'done' && Ready} + {item.status === 'error' && {item.error || 'Failed'}} + {item.status === 'queued' && Queued} +
+
+
+ {onPlay && ( + + )} + +
+ + ) +} diff --git a/src/pages/DuplicatesPage.tsx b/src/pages/DuplicatesPage.tsx new file mode 100644 index 0000000..2cbc374 --- /dev/null +++ b/src/pages/DuplicatesPage.tsx @@ -0,0 +1,134 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { Film, Tv, AlertCircle } from '../lib/icons' +import { useLibraryItems } from '../hooks/use-jellyfin' +import { getBestImage, getStoredServerUrl } from '../api/jellyfin' +import PosterCard from '../components/ui/PosterCard' + +export default function DuplicatesPage() { + const navigate = useNavigate() + const serverUrl = getStoredServerUrl() + const { data, isLoading } = useLibraryItems(undefined, { + includeItemTypes: ['Movie', 'Series'], + sortBy: ['SortName'], + sortOrder: ['Ascending'], + limit: 5000, + }) + const items = data?.Items || [] + + const duplicates = useMemo(() => { + // Group by TMDB ID first (most reliable) + const byTmdb = new Map() + const byNameYear = new Map() + + for (const item of items) { + const tmdb = item.ProviderIds?.Tmdb + if (tmdb) { + const list = byTmdb.get(String(tmdb)) || [] + list.push(item) + byTmdb.set(String(tmdb), list) + continue + } + // Fallback: normalized name + year + type + const key = `${(item.Name || '').toLowerCase().trim()}|${item.ProductionYear || 'none'}|${item.Type}` + const list = byNameYear.get(key) || [] + list.push(item) + byNameYear.set(key, list) + } + + const groups: { key: string; label: string; items: typeof items }[] = [] + for (const [tmdb, list] of byTmdb) { + if (list.length >= 2) { + const first = list[0] + groups.push({ + key: `tmdb-${tmdb}`, + label: `${first.Name} (${first.ProductionYear || '?'})`, + items: list, + }) + } + } + for (const [, list] of byNameYear) { + if (list.length >= 2) { + const first = list[0] + groups.push({ + key: `name-${first.Id}`, + label: `${first.Name} (${first.ProductionYear || '?'}) - no TMDB match`, + items: list, + }) + } + } + + return groups.sort((a, b) => a.label.localeCompare(b.label)) + }, [items]) + + return ( +
+
+
+ + Tools +
+

+ Duplicate finder +

+

+ Scans your library for items that appear more than once - either by shared TMDB ID or by matching name + year. +

+
+ + {isLoading && items.length === 0 && ( +
+

Scanning your library...

+
+ )} + + {duplicates.length === 0 && !isLoading && ( +
+
+
+
+ +
+
+

No duplicates found

+

+ Every movie and series in your library has a unique identity. Nice and tidy. +

+
+ )} + +
+ {duplicates.map((group, gi) => ( +
+
+ {group.items[0]?.Type === 'Series' ? ( + + ) : ( + + )} +

{group.label}

+ {group.items.length} copies +
+
+ {group.items.map((item, i) => ( + + item.Id && navigate(`/item/${item.Id}`)} + /> + + ))} +
+
+ ))} +
+
+ ) +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..b953833 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,202 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { useHomeData, useLibraryItems } from '../hooks/use-jellyfin' +import { useTmdbTrending } from '../hooks/use-tmdb' +import ContentRow from '../components/ui/ContentRow' +import LazyMount, { RowBoundary } from '../components/ui/LazyMount' +import { getTmdbImageUrl } from '../api/tmdb' +import { usePreferencesStore } from '../stores/preferences-store' +import type { BaseItemDto } from '../api/types' +import { FeaturedCarousel } from './home/home-hero' +import { dailyBucket, seededShuffle, HomeSkeleton, EmptyHome } from './home/home-utils' +import { DecadeRow, GenreRow } from './home/rows/library' +import { HomeSections } from './home/rows/composite' + +const FEATURED_GENRES = [ + { name: 'Action', subtitle: 'Bullets, fists, explosions' }, + { name: 'Drama', subtitle: 'Stories that hit deep' }, + { name: 'Comedy', subtitle: 'Light watching' }, + { name: 'Science Fiction', subtitle: 'Beyond the possible' }, + { name: 'Thriller', subtitle: 'Edge-of-your-seat tension' }, + { name: 'Horror', subtitle: 'Lights off' }, + { name: 'Documentary', subtitle: 'Real stories' }, + { name: 'Animation', subtitle: 'Drawn to perfection' }, +] + +export default function HomePage() { + const navigate = useNavigate() + const { continueWatching, nextUp, recentlyAdded } = useHomeData() + const trending = useTmdbTrending('all', 'week') + const hideAdult = usePreferencesStore(s => s.hideAdult) + const prefs = usePreferencesStore() + + const topRated = useLibraryItems(undefined, { + includeItemTypes: ['Movie', 'Series'], + sortBy: ['CommunityRating'], + sortOrder: ['Descending'], + minCommunityRating: 7, + limit: 20, + }) + + const surpriseMe = useLibraryItems(undefined, { + includeItemTypes: ['Movie', 'Series'], + sortBy: ['Random'], + sortOrder: ['Descending'], + limit: 20, + }) + + const recentlyPlayed = useLibraryItems(undefined, { + includeItemTypes: ['Movie', 'Series'], + sortBy: ['DatePlayed'], + sortOrder: ['Descending'], + filters: ['IsPlayed'], + limit: 14, + }) + + const featuredPool = useMemo(() => { + const pool: BaseItemDto[] = [] + const isFeatureWorthy = (item: BaseItemDto) => + (item.Type === 'Movie' || item.Type === 'Series') && + !!item.BackdropImageTags?.length + + // Bias toward continue watching first (user is engaged with these) + for (const item of continueWatching.data || []) if (isFeatureWorthy(item)) pool.push(item) + for (const item of recentlyAdded.data || []) if (isFeatureWorthy(item)) pool.push(item) + for (const item of topRated.data?.Items || []) if (isFeatureWorthy(item)) pool.push(item) + + const seen = new Set() + const unique = pool.filter(p => { + if (!p.Id || seen.has(p.Id)) return false + seen.add(p.Id) + return true + }) + + // Day-bucketed shuffle so the order changes a few times a day but stays + // stable within a session, then clamp to 8 slides max. + return seededShuffle(unique, dailyBucket()).slice(0, 8) + }, [continueWatching.data, recentlyAdded.data, topRated.data]) + + const hasAnyData = + (continueWatching.data?.length ?? 0) > 0 || + (nextUp.data?.length ?? 0) > 0 || + (recentlyAdded.data?.length ?? 0) > 0 + + const allLoading = + continueWatching.isLoading && nextUp.isLoading && recentlyAdded.isLoading + + if (allLoading) return + + if (!hasAnyData) return + + return ( +
+ {featuredPool.length > 0 && } + +
+ {prefs.home.show.continueWatching && continueWatching.data && continueWatching.data.length > 0 && ( + + + + )} + + {prefs.home.show.nextUp && nextUp.data && nextUp.data.length > 0 && ( + + + + )} + + {prefs.home.show.recentlyAdded && recentlyAdded.data && recentlyAdded.data.length > 0 && ( + + + + )} + + {prefs.home.show.trendingWeek && trending.data?.results && trending.data.results.length > 0 && (() => { + const filtered = trending.data.results.filter(r => !hideAdult || !r.adult) + if (filtered.length === 0) return null + return ( + + ({ + Id: `tmdb-${r.id}`, + Name: r.title || r.name, + Type: r.media_type === 'tv' ? 'Series' : 'Movie', + ProductionYear: r.release_date + ? parseInt(r.release_date) + : r.first_air_date + ? parseInt(r.first_air_date) + : undefined, + ImageTags: {}, + ProviderIds: { Tmdb: String(r.id) }, + _tmdbPoster: r.poster_path ? getTmdbImageUrl(r.poster_path, 'w342') : undefined, + } as any as BaseItemDto))} + /> + + ) + })()} + + {prefs.home.show.topRated && topRated.data?.Items && topRated.data.Items.length > 0 && ( + + + + )} + + {prefs.home.show.watchedRecently && recentlyPlayed.data?.Items && recentlyPlayed.data.Items.length > 0 && ( + + + + )} + + {prefs.home.show.surpriseMe && surpriseMe.data?.Items && surpriseMe.data.Items.length > 0 && ( + + + + )} + + + + {prefs.home.show.decadeRows && ( + <> + + + + + )} + + {prefs.home.show.featuredGenres && + FEATURED_GENRES.map(g => ( + + + + ))} +
+
+ ) +} diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx new file mode 100644 index 0000000..11cad7b --- /dev/null +++ b/src/pages/LibraryPage.tsx @@ -0,0 +1,422 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { ArrowDownAZ, Calendar, Shuffle, Film, Tv, Filter, X, Star } from '../lib/icons' +import { useLibraries, useLibraryItems } from '../hooks/use-jellyfin' +import PosterCard from '../components/ui/PosterCard' +import Select, { type SelectOption } from '../components/ui/Select' +import { resolutionLabel, videoRangeLabel } from '../lib/jellyfin-meta' +import { iconForGenre } from '../lib/genre-icons' +import { usePosterGridClasses } from '../lib/density' +import { type SavedSearchFilters } from '../stores/saved-searches-store' +import { useLibrarySelection } from '../stores/library-selection-store' +import BatchActionsBar from '../components/ui/BatchActionsBar' +import { WatchedPills, SavedSearchMenu } from './library/controls' +import { SaveSearchModal, SurpriseMeModal } from './library/modals' +import { EmptyLibrary, PosterGridSkeleton } from './library/states' + +interface Props { + type: 'movies' | 'shows' +} + +const SORT_OPTIONS = [ + { value: 'SortName', label: 'A - Z', icon: ArrowDownAZ }, + { value: 'DateCreated', label: 'Recently added', icon: Calendar }, + { value: 'PremiereDate', label: 'Release date', icon: Calendar }, + { value: 'CommunityRating', label: 'Rating', icon: Star }, + { value: 'Random', label: 'Random', icon: Shuffle }, +] + +const COLLECTION_TYPE_MAP: Record = { + movies: 'movies', + shows: 'tvshows', +} + +const ITEM_TYPE_MAP: Record = { + movies: ['Movie'], + shows: ['Series'], +} + +const DECADE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: '__any__', label: 'Any decade' }, + { value: '2020', label: '2020s' }, + { value: '2010', label: '2010s' }, + { value: '2000', label: '2000s' }, + { value: '1990', label: '1990s' }, + { value: '1980', label: '1980s' }, + { value: '1970', label: '1970s' }, + { value: '1960', label: '1960s' }, + { value: '1950', label: '1950s' }, + { value: '1900', label: 'Pre-1950' }, +] + +export default function LibraryPage({ type }: Props) { + const [sortBy, setSortBy] = useState('SortName') + const [genreFilter, setGenreFilter] = useState(null) + const [decade, setDecade] = useState(null) + const [watchedFilter, setWatchedFilter] = useState<'any' | 'played' | 'unplayed'>('any') + const [only4K, setOnly4K] = useState(false) + const [onlyHdr, setOnlyHdr] = useState(false) + const [surpriseOpen, setSurpriseOpen] = useState(false) + const [saveOpen, setSaveOpen] = useState(false) + const [layoutMode, setLayoutMode] = useState<'grid' | 'map'>(() => { + if (typeof window === 'undefined') return 'grid' + return (localStorage.getItem(`lib_layout:${type}`) as 'grid' | 'map') || 'grid' + }) + const [mapShowAll, setMapShowAll] = useState(false) + const navigate = useNavigate() + const gridCls = usePosterGridClasses() + const mapGridCls = 'grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-12 xl:grid-cols-14 2xl:grid-cols-16 gap-2' + const selected = useLibrarySelection(s => s.selected) + const toggleSelect = useLibrarySelection(s => s.toggle) + const clearSelection = useLibrarySelection(s => s.clear) + + function changeLayoutMode(next: 'grid' | 'map') { + setLayoutMode(next) + try { localStorage.setItem(`lib_layout:${type}`, next) } catch { /* noop */ } + } + + // Clear any in-progress selection when navigating away from the page + // or pressing Escape. Esc is the same dismiss affordance the playlist + // view uses, so the muscle memory carries. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape' && selected.size > 0) { + clearSelection() + } + } + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('keydown', onKey) + } + }, [selected.size, clearSelection]) + useEffect(() => { + return () => clearSelection() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const { data: libraries } = useLibraries() + const collectionType = COLLECTION_TYPE_MAP[type] + const library = libraries?.find(l => l.CollectionType === collectionType) + const parentId = library?.Id + const includeItemTypes = ITEM_TYPE_MAP[type] + + const filtersForApi = useMemo(() => { + const f: string[] = [] + if (watchedFilter === 'played') f.push('IsPlayed') + else if (watchedFilter === 'unplayed') f.push('IsUnplayed') + return f.length > 0 ? f : undefined + }, [watchedFilter]) + + const yearsForApi = useMemo(() => { + if (decade == null) return undefined + if (decade === 1900) { + const arr: number[] = [] + for (let y = 1900; y < 1950; y++) arr.push(y) + return arr + } + return Array.from({ length: 10 }, (_, i) => decade + i) + }, [decade]) + + const sortOrder = useMemo(() => { + if (sortBy === 'Random') return ['Descending'] + if (sortBy === 'CommunityRating' || sortBy === 'DateCreated' || sortBy === 'PremiereDate') { + return ['Descending'] + } + return ['Ascending'] + }, [sortBy]) + + const { data, isLoading } = useLibraryItems(parentId, { + sortBy: [sortBy], + sortOrder, + includeItemTypes, + filters: filtersForApi, + years: yearsForApi, + limit: 500, + }) + + const allItems = useMemo(() => data?.Items || [], [data?.Items]) + + const allGenres = useMemo(() => { + const set = new Set() + for (const it of allItems) { + for (const g of it.Genres || []) set.add(g) + } + return [...set].sort((a, b) => a.localeCompare(b)) + }, [allItems]) + + const items = useMemo(() => { + return allItems.filter(it => { + if (genreFilter && !(it.Genres || []).includes(genreFilter)) return false + if (only4K && resolutionLabel(it as any) !== '4K') return false + if (onlyHdr && !videoRangeLabel(it as any)) return false + return true + }) + }, [allItems, genreFilter, only4K, onlyHdr]) + + const totalCount = items.length + const filtered = !!(genreFilter || only4K || onlyHdr || decade != null || watchedFilter !== 'any') + const Icon = type === 'movies' ? Film : Tv + + const currentFilters: SavedSearchFilters = { + sortBy, + watched: watchedFilter, + genre: genreFilter, + decade, + only4K, + onlyHdr, + } + + function applySaved(filters: SavedSearchFilters) { + setSortBy(filters.sortBy) + setWatchedFilter(filters.watched) + setGenreFilter(filters.genre) + setDecade(filters.decade) + setOnly4K(filters.only4K) + setOnlyHdr(filters.onlyHdr) + } + + function clearAll() { + setGenreFilter(null) + setDecade(null) + setWatchedFilter('any') + setOnly4K(false) + setOnlyHdr(false) + } + + return ( +
+ {/* Header */} +
+
+
+ + Library +
+
+

+ {type === 'movies' ? 'Movies' : 'TV shows'} +

+ {totalCount > 0 && ( + + {totalCount.toLocaleString()} {totalCount === 1 ? 'item' : 'items'} + + )} +
+
+ + {/* Sort chips */} +
+ {SORT_OPTIONS.map(opt => { + const isActive = sortBy === opt.value + return ( + + ) + })} +
+
+ + {/* Filters */} +
+ + + + + + } + value={genreFilter ?? '__all__'} + onChange={v => setGenreFilter(v === '__all__' ? null : v)} + width="min-w-[160px]" + options={[ + { value: '__all__', label: 'All genres', muted: true } as SelectOption, + ...allGenres.map>(g => ({ + value: g, + label: g, + icon: iconForGenre(g), + })), + ]} + /> + )} + + {filtered && ( + + )} + + setSaveOpen(true)} + /> + + + + {filtered && ( + + + {totalCount} match{totalCount === 1 ? '' : 'es'} + + )} +
+ + {/* Layout toggle - sits next to the matches counter */} +
+ + View + +
+ + +
+
+ + {/* Grid */} + {isLoading ? ( + + ) : items.length === 0 ? ( + + ) : ( + + { + // Click on the grid background (not on a card) clears the selection. + if (e.target === e.currentTarget && selected.size > 0) clearSelection() + }} + > + {(layoutMode === 'map' && !mapShowAll ? items.slice(0, 240) : items).map((item, i) => ( + + toggleSelect(item.Id!) : undefined} + libraryContext + compact={layoutMode === 'map'} + onClick={() => { + if (selected.size > 0) { + // While in selection mode, plain clicks toggle membership too. + if (item.Id) toggleSelect(item.Id) + return + } + if (item.Id) navigate(`/item/${item.Id}`) + }} + /> + + ))} + + + )} + + {layoutMode === 'map' && !mapShowAll && items.length > 240 && ( +
+ +
+ )} + + + + setSurpriseOpen(false)} + /> + setSaveOpen(false)} + /> +
+ ) +} + diff --git a/src/pages/LiveTvPage.tsx b/src/pages/LiveTvPage.tsx new file mode 100644 index 0000000..a726ff9 --- /dev/null +++ b/src/pages/LiveTvPage.tsx @@ -0,0 +1,268 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { Radio, CalendarEvent, FileVideo, Play, Clock } from '../lib/icons' +import { useLiveTvInfo, useLiveTvChannels, useLiveTvRecordings, useLiveTvTimers } from '../hooks/use-jellyfin' +import { jellyfinClient, getImageUrl } from '../api/jellyfin' + +type Tab = 'channels' | 'recordings' | 'scheduled' + +/** + * Live TV / DVR landing page. Three tabs surfaced off Jellyfin's + * LiveTv API: + * - Channels (with current-program inline) + * - Recordings (past + in-progress) + * - Scheduled timers + * + * If Live TV isn't configured server-side, the page shows a friendly + * placeholder instead of empty lists. + */ +export default function LiveTvPage() { + const info = useLiveTvInfo() + const [tab, setTab] = useState('channels') + + const enabled = info.data?.IsEnabled !== false + const noTuners = !info.data?.Services?.length + + if (!info.isLoading && (!enabled || noTuners)) { + return ( +
+
+
+ +

Live TV isn't set up on this server

+

+ Add a tuner and a guide provider in the Jellyfin dashboard under Live TV. Once configured, your + channels and DVR recordings will show up here. +

+
+
+ ) + } + + return ( +
+
+ +
+ setTab('channels')} icon={}> + Channels + + setTab('recordings')} icon={}> + Recordings + + setTab('scheduled')} icon={}> + Scheduled + +
+ + {tab === 'channels' && } + {tab === 'recordings' && } + {tab === 'scheduled' && } +
+ ) +} + +function Header() { + return ( +
+

+ Live TV +

+

+ Channels & recordings +

+
+ ) +} + +function TabButton({ + active, + onClick, + children, + icon, +}: { + active: boolean + onClick: () => void + children: React.ReactNode + icon: React.ReactNode +}) { + return ( + + ) +} + +function ChannelsList() { + const navigate = useNavigate() + const serverUrl = jellyfinClient.getAuthState()?.serverUrl || '' + const { data: channels = [], isLoading } = useLiveTvChannels() + + if (isLoading) return + if (channels.length === 0) { + return No channels available right now. + } + + return ( +
    + {channels.map(ch => { + const program = (ch as any).CurrentProgram as { Name?: string; StartDate?: string; EndDate?: string } | undefined + const imageTag = ch.ImageTags?.Primary + const img = ch.Id && imageTag ? getImageUrl(serverUrl, ch.Id, 'Primary', 128, imageTag) : '' + return ( +
  • + +
  • + ) + })} +
+ ) +} + +function RecordingsList() { + const navigate = useNavigate() + const serverUrl = jellyfinClient.getAuthState()?.serverUrl || '' + const { data: recordings = [], isLoading } = useLiveTvRecordings() + + if (isLoading) return + if (recordings.length === 0) { + return No recordings yet. Schedule one from a program to get started. + } + + return ( +
    + {recordings.map(r => { + const imageTag = r.ImageTags?.Primary + const img = r.Id && imageTag ? getImageUrl(serverUrl, r.Id, 'Primary', 220, imageTag) : '' + return ( +
  • + +
  • + ) + })} +
+ ) +} + +function ScheduledList() { + const { data: timers = [], isLoading } = useLiveTvTimers() + + if (isLoading) return + if (timers.length === 0) { + return Nothing on the schedule. + } + + return ( +
    + {timers.map((t: any) => ( +
  • +
    + +
    +
    +

    + {t.Name || t.ProgramName || 'Scheduled recording'} +

    +

    + {t.StartDate ? new Date(t.StartDate).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''} + {t.ChannelName && - {t.ChannelName}} +

    +
    + {t.Status && ( + + {t.Status} + + )} +
  • + ))} +
+ ) +} + +function programTime(start?: string | null, end?: string | null): string { + if (!start) return '' + const s = new Date(start) + const e = end ? new Date(end) : null + if (Number.isNaN(s.getTime())) return '' + const fmt = (d: Date) => d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + return e ? `${fmt(s)}-${fmt(e)} ` : `${fmt(s)} ` +} + +function Skeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) +} + +function EmptyMsg({ children }: { children: React.ReactNode }) { + return ( +
+

{children}

+
+ ) +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..a8ab656 --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,244 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Eye, EyeOff, Loader2, AlertCircle, Server, User as UserIcon, Lock, ArrowRight } from '../lib/icons' +import { jellyfinClient, getLastServerUrl } from '../api/jellyfin' +import type { AuthState } from '../api/types' + +interface Props { + onLogin: (auth: AuthState) => void +} + +export default function LoginPage({ onLogin }: Props) { + const [serverUrl, setServerUrl] = useState(() => getLastServerUrl()) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + + if (!serverUrl.trim()) { + setError('Please enter a server URL') + return + } + + setLoading(true) + try { + jellyfinClient.connect(serverUrl.trim()) + const auth = await jellyfinClient.login(username.trim(), password) + onLogin(auth) + } catch (err: any) { + setError( + err?.response?.data?.message || + err?.message || + 'Connection failed. Check your server URL and credentials.' + ) + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Cinematic ambient backdrop */} +
+
+
+
+
+ + + {/* Brand */} +
+ +
+
+
+ j +
+ + + Welcome back + + + Connect to your Jellyfin server to begin + +
+ +
+ + + + + setShowPassword(!showPassword)} + className="text-text-4 hover:text-text-2 transition-colors p-1 rounded-md focus-ring" + aria-label={showPassword ? 'Hide password' : 'Show password'} + > + {showPassword ? : } + + } + /> + + + {error && ( + +
+ +

{error}

+
+
+ )} +
+ + + {loading ? ( + <> + + Connecting... + + ) : ( + <> + Sign in + + + )} + + + + + Your server connection stays on this device. + + +
+ ) +} + +function Field({ + label, + icon: Icon, + type, + value, + onChange, + placeholder, + autoFocus, + autoComplete, + delay = 0, + trailing, +}: { + label: string + icon: typeof Server + type: string + value: string + onChange: (v: string) => void + placeholder?: string + autoFocus?: boolean + autoComplete?: string + delay?: number + trailing?: React.ReactNode +}) { + return ( + + +
+ + onChange(e.target.value)} + placeholder={placeholder} + autoFocus={autoFocus} + autoComplete={autoComplete} + className={`w-full h-11 pl-10 ${trailing ? 'pr-10' : 'pr-3'} bg-elevated/60 hover:bg-elevated rounded-lg text-[13.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 focus:bg-elevated transition-all duration-200`} + /> + {trailing && ( +
{trailing}
+ )} +
+
+ ) +} diff --git a/src/pages/MusicPage.tsx b/src/pages/MusicPage.tsx new file mode 100644 index 0000000..9f7763e --- /dev/null +++ b/src/pages/MusicPage.tsx @@ -0,0 +1,164 @@ +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { Music, Disc3 } from '../lib/icons' +import { useLibraryItems } from '../hooks/use-jellyfin' +import PosterCard from '../components/ui/PosterCard' +import { getBestImage, getStoredServerUrl } from '../api/jellyfin' + +export default function MusicPage() { + const navigate = useNavigate() + const serverUrl = getStoredServerUrl() + + const { data: artists, isLoading: artistsLoading } = useLibraryItems(undefined, { + includeItemTypes: ['MusicArtist'], + sortBy: ['SortName'], + sortOrder: ['Ascending'], + limit: 100, + }) + + const { data: albums, isLoading: albumsLoading } = useLibraryItems(undefined, { + includeItemTypes: ['MusicAlbum'], + sortBy: ['SortName'], + sortOrder: ['Ascending'], + limit: 100, + }) + + return ( +
+ {/* Header */} +
+
+ + Library +
+

Music

+
+ + {/* Albums */} +
+ {albumsLoading ? ( + + ) : !albums?.Items?.length ? ( + + ) : ( +
+ {albums.Items.map((item, i) => ( + + item.Id && navigate(`/item/${item.Id}`)} + /> + + ))} +
+ )} +
+ + {/* Artists */} +
+ {artistsLoading ? ( + + ) : !artists?.Items?.length ? ( + + ) : ( +
+ {artists.Items.map((item, i) => { + const imageUrl = getBestImage(serverUrl, item, 'primary', 240) + + return ( + item.Id && navigate(`/item/${item.Id}`)} + initial={{ opacity: 0, y: 6 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: Math.min(i * 0.015, 0.3) }} + whileHover={{ y: -2 }} + className="flex flex-col items-center text-center gap-2 group focus-ring rounded-xl p-1" + > +
+
+
+ {imageUrl ? ( + {item.Name + ) : ( +
+ {(item.Name || '?')[0].toUpperCase()} +
+ )} +
+
+ {item.Name} + + ) + })} +
+ )} +
+
+ ) +} + +function Section({ + title, + icon: Icon, + children, +}: { + title: string + icon: typeof Music + children: React.ReactNode +}) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ) +} + +function EmptyState({ message }: { message: string }) { + return

{message}

+} + +function SquareGridSkeleton({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) +} + +function ArtistGridSkeleton() { + return ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) +} diff --git a/src/pages/PersonPage.tsx b/src/pages/PersonPage.tsx new file mode 100644 index 0000000..59287ac --- /dev/null +++ b/src/pages/PersonPage.tsx @@ -0,0 +1,329 @@ +import { useMemo, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { Cake, MapPin, Skull, ExternalLink, ChevronDown } from '../lib/icons' +import { useTmdbPerson } from '../hooks/use-tmdb' +import { useWikiResolve } from '../hooks/use-external' +import { getTmdbImageUrl } from '../api/tmdb' +import type { TmdbCombinedCreditCast, TmdbCombinedCreditCrew } from '../api/tmdb' +import { formatDate } from '../lib/format' +import { usePosterGridClasses } from '../lib/density' + +const DEPT_FILTERS = ['All', 'Acting', 'Directing', 'Writing', 'Production', 'Camera', 'Editing', 'Sound'] + +export default function PersonPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { data: person, isLoading } = useTmdbPerson(id) + const gridCls = usePosterGridClasses() + + // Wikipedia bio fallback - only fetched when TMDB has no biography of + // its own. The query is the person's name; first matching article wins. + const wikiEnabled = !!person && (!person.biography || person.biography.length < 60) + const { data: wiki } = useWikiResolve(person?.name, wikiEnabled) + + const [bioExpanded, setBioExpanded] = useState(false) + const [filter, setFilter] = useState('All') + + const filmography = useMemo(() => { + const cast = (person?.combined_credits?.cast || []).map(c => ({ ...c, _kind: 'cast' as const })) + const crew = (person?.combined_credits?.crew || []).map(c => ({ ...c, _kind: 'crew' as const })) + let merged: ((TmdbCombinedCreditCast | TmdbCombinedCreditCrew) & { _kind: 'cast' | 'crew' })[] = [ + ...cast, + ...crew, + ] + + if (filter !== 'All') { + if (filter === 'Acting') { + merged = merged.filter(c => c._kind === 'cast') + } else { + merged = merged.filter(c => c._kind === 'crew' && (c as any).department === filter) + } + } + + // De-duplicate by media id (a person may appear in cast + crew) + const seen = new Set() + const unique: typeof merged = [] + for (const c of merged) { + const key = `${c.media_type}-${c.id}` + if (seen.has(key)) continue + seen.add(key) + unique.push(c) + } + + // Sort by popularity, then date + return unique.sort((a, b) => { + const pa = a.popularity ?? 0 + const pb = b.popularity ?? 0 + if (pb !== pa) return pb - pa + const da = (a.release_date || a.first_air_date || '0').slice(0, 4) + const db = (b.release_date || b.first_air_date || '0').slice(0, 4) + return db.localeCompare(da) + }) + }, [person, filter]) + + if (isLoading) return + if (!person) { + return ( +
+

Person not found

+

No TMDB key configured, or this person isn't in TMDB.

+
+ ) + } + + const profile = person.profile_path ? getTmdbImageUrl(person.profile_path, 'w500') : '' + const department = person.known_for_department || 'Acting' + + return ( +
+ {/* Top row */} +
+ + {profile ? ( + {person.name} + ) : ( +
+ {person.name[0]} +
+ )} +
+ + +

+ {department} +

+

+ {person.name} +

+ +
+ {person.birthday && ( + + + {formatDate(person.birthday)} + {!person.deathday && ( + ({yearsSince(person.birthday)}) + )} + + )} + {person.deathday && ( + + + {formatDate(person.deathday)} + + )} + {person.place_of_birth && ( + + + {person.place_of_birth} + + )} +
+ + {(person.biography || wiki?.extract) && ( +
+

+ {person.biography || wiki?.extract} +

+ {(person.biography || wiki?.extract || '').length > 600 && ( + + )} + {!person.biography && wiki && ( +

Source: Wikipedia

+ )} +
+ )} + + {/* External links */} +
+ {person.imdb_id && ( + + )} + + {person.external_ids?.instagram_id && ( + + )} + {person.external_ids?.twitter_id && ( + + )} + {person.homepage && } +
+
+
+ + {/* Filmography */} +
+
+ +

+ Filmography +

+ + ({filmography.length}) + +
+
+ {DEPT_FILTERS.map(d => { + const isActive = filter === d + return ( + + ) + })} +
+
+ + {filmography.length === 0 ? ( +

No credits in this category.

+ ) : ( +
+ {filmography.map((c, i) => { + const title = c.title || c.name || 'Untitled' + const year = (c.release_date || c.first_air_date || '').slice(0, 4) + return ( + navigate(`/item/tmdb-${c.id}`)} + initial={{ opacity: 0, y: 8 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, delay: Math.min(i * 0.012, 0.4) }} + whileHover={{ y: -3 }} + className="group text-left focus-ring rounded-lg" + > +
+ {c.poster_path ? ( + {title} + ) : ( +
+ {title[0]} +
+ )} +
+ + {c.media_type === 'tv' ? 'TV' : 'Film'} + +
+
+
+

+ {title} +

+

+ {(c as any).character ? (c as any).character : (c as any).job} + {year && ( + <> + · + {year} + + )} +

+
+
+ ) + })} +
+ )} +
+ ) +} + +function ExternalLinkChip({ href, label }: { href: string; label: string }) { + return ( + + {label} + + + ) +} + +function yearsSince(iso: string): string { + try { + const d = new Date(iso) + const years = (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000) + return `${Math.floor(years)} yrs` + } catch { + return '' + } +} + +function PersonSkeleton() { + const gridCls = usePosterGridClasses() + return ( +
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 14 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ) +} diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx new file mode 100644 index 0000000..25d687d --- /dev/null +++ b/src/pages/PlayerPage.tsx @@ -0,0 +1,1615 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useParams, useNavigate, useSearchParams } from 'react-router-dom' +import { + MediaPlayer, + MediaProvider, + Track, + isHLSProvider, + type MediaPlayerInstance, + type MediaProviderAdapter, +} from '@vidstack/react' +import HLS from 'hls.js' +import '@vidstack/react/player/styles/base.css' +import { motion, AnimatePresence } from 'framer-motion' +import { + Play, + RotateCcw, + RotateCw, +} from '../lib/icons' +import { useItemDetails, useMediaSegments, usePlaybackInfo } from '../hooks/use-jellyfin' +import { usePlayerNavigation } from '../hooks/use-player-navigation' +import { usePlayerChrome } from '../hooks/use-player-chrome' +import { getSubtitleUrl, jellyfinClient } from '../api/jellyfin' +import { usePreferencesStore } from '../stores/preferences-store' +import { ticksToSeconds } from '../lib/format' +import { getAudioStreams, getSubtitleStreams } from '../lib/jellyfin-meta' +import { pickSubtitle } from '../lib/subtitle-match' +import StreamInfo from '../components/player/StreamInfo' +import SubtitleOverlay from '../components/player/SubtitleOverlay' +import LibAssRenderer from '../components/player/LibAssRenderer' +import PlayerOverlays from '../components/player/PlayerOverlays' +import { computeRecapTrigger } from '../lib/recap-trigger' +import { usePersonalData } from '../stores/personal-data-store' +import { usePlayerRuntimeStore } from '../stores/player-runtime-store' +import { usePlayerShortcuts } from '../hooks/use-player-shortcuts' +import { detachAudioGraph } from '../lib/audio-graph' +import type { ShortcutContext } from '../lib/player-shortcuts' +import EpisodesPanel from '../components/player/EpisodesPanel' +import { type QualityOption } from '../components/player/QualityMenu' +import PlayerTopBar from '../components/player/PlayerTopBar' +import PlayerBottomBar from '../components/player/PlayerBottomBar' +import { usePlayerPictureFilter } from '../hooks/use-player-picture-filter' +import { usePlayerAudioGraph } from '../hooks/use-player-audio-graph' +import { usePlaybackReporting } from '../hooks/use-playback-reporting' +import { usePlayerPanels } from '../hooks/use-player-panels' +import { addBookmark as bookmarksAdd } from '../lib/bookmarks' +import { recordManualSkip, dismissSkipPrompt } from '../lib/skip-tracker' +import { recordSkippedSeconds } from '../lib/time-saved' +import { useReducedMotion } from '../hooks/use-reduced-motion' +import { useSyncPlaySocketBridge } from '../hooks/use-syncplay' +import { useSyncPlay } from '../stores/syncplay-store' +import { sendPause as spPause, sendUnpause as spUnpause, sendSeek as spSeek } from '../lib/syncplay' +import { useTraktScrobble } from '../hooks/use-trakt-scrobble' +import { useDownloads } from '../stores/downloads-store' +import { startDownload } from '../lib/downloads' +import { getBestImage } from '../api/jellyfin' + +export default function PlayerPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const { data: item } = useItemDetails(id) + const playerRef = useRef(null) + const qc = useQueryClient() + const progressRef = useRef(0) + + // Start paused so the play icon shows until the player actually begins + // playback - if autoplay succeeds, the onPlay handler flips this to false. + const [isPaused, setIsPaused] = useState(true) + const [isMuted, setIsMuted] = useState(false) + // Volume lives in the preferences store so it persists across episodes, + // shows, and sessions. Mute is intentionally per-session (autoplay + // fallback can mute briefly without that polluting the saved volume). + const volume = usePreferencesStore(s => s.playerVolume) + const setVolumePref = usePreferencesStore(s => s.setPreference) + const setVolume = useCallback( + (v: number) => setVolumePref('playerVolume', Math.max(0, Math.min(1, v))), + [setVolumePref], + ) + const [isFullscreen, setIsFullscreen] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [buffered, setBuffered] = useState(0) + const [scrubPercent, setScrubPercent] = useState(null) + const [seeking, setSeeking] = useState(false) + + /* Track selection (audio / subtitle). + * `audioIndex` is the UI selection; `streamAudioIndex` is what we send + * to PlaybackInfo. They diverge when the active source is direct-play + * multi-audio: we switch instantly via HTMLMediaElement.audioTracks + * without triggering a stream reload, but the menu still highlights the + * picked track. For transcoded sources the two stay in sync. */ + const [audioIndex, setAudioIndex] = useState(null) + const [streamAudioIndex, setStreamAudioIndex] = useState(null) + const [subtitleIndex, setSubtitleIndex] = useState(null) + const panels = usePlayerPanels() + const setPanel = panels.set + const streamInfoOpen = panels.state.streamInfo + const episodesOpen = panels.state.episodes + const hintsOpen = panels.state.hints + const bookmarksOpen = panels.state.bookmarks + const chaptersOpen = panels.state.chapters + const subSearchOpen = panels.state.subSearch + const syncPlayOpen = panels.state.syncPlay + const setStreamInfoOpen = (v: boolean | ((p: boolean) => boolean)) => + panels.set('streamInfo', typeof v === 'function' ? v(streamInfoOpen) : v) + const setEpisodesOpen = (v: boolean | ((p: boolean) => boolean)) => + panels.set('episodes', typeof v === 'function' ? v(episodesOpen) : v) + const setHintsOpen = (v: boolean | ((p: boolean) => boolean)) => + panels.set('hints', typeof v === 'function' ? v(hintsOpen) : v) + const setBookmarksOpen = (v: boolean | ((p: boolean) => boolean)) => + panels.set('bookmarks', typeof v === 'function' ? v(bookmarksOpen) : v) + const setChaptersOpen = (v: boolean | ((p: boolean) => boolean)) => + panels.set('chapters', typeof v === 'function' ? v(chaptersOpen) : v) + const setSubSearchOpen = (v: boolean | ((p: boolean) => boolean)) => + panels.set('subSearch', typeof v === 'function' ? v(subSearchOpen) : v) + const setSyncPlayOpen = (v: boolean | ((p: boolean) => boolean)) => + panels.set('syncPlay', typeof v === 'function' ? v(syncPlayOpen) : v) + + // Trakt scrobble - no-op when the user hasn't connected Trakt. + useTraktScrobble({ item, isPaused, currentTime, duration }) + + // Mirror local pause/play state through a ref so the vidstack + // subscribe callback (which captures isPaused at registration time) + // can compare against the latest value without us re-binding on every + // render. + const isPausedRef = useRef(isPaused) + isPausedRef.current = isPaused + + // SyncPlay socket + remote-command bridge. The hook handles its own + // socket lifecycle; we only need to read the latest remote command + // here and apply it to the local player. + useSyncPlaySocketBridge() + const syncPlayActive = useSyncPlay(s => !!s.active) + const lastRemoteSeq = useSyncPlay(s => s.lastRemoteSeq) + const lastRemoteCommand = useSyncPlay(s => s.lastRemoteCommand) + const remoteQueueItem = useSyncPlay(s => s.remoteQueueItem) + const remoteSuppressRef = useRef(0) + useEffect(() => { + if (!lastRemoteCommand || lastRemoteSeq === 0) return + const p = playerRef.current + if (!p) return + // Mark the next local pause/play/seek as remote-originated so we + // don't echo it back to the server (which would feedback-loop). + remoteSuppressRef.current = Date.now() + 800 + if (lastRemoteCommand.type === 'pause') { + p.pause() + } else if (lastRemoteCommand.type === 'play') { + p.play().catch(() => {}) + } else if (lastRemoteCommand.type === 'seek' && typeof lastRemoteCommand.positionTicks === 'number') { + p.currentTime = lastRemoteCommand.positionTicks / 10_000_000 + } + }, [lastRemoteSeq, lastRemoteCommand]) + // Catch the joiner up to whatever the party is currently watching. + // If the host is on a different item, navigate to it; otherwise seek + // into the current playback. We track the last-applied queue item in + // a ref so we only navigate / seek when the value actually changes. + const appliedQueueRef = useRef(null) + useEffect(() => { + if (!remoteQueueItem) return + const key = `${remoteQueueItem.itemId}:${remoteQueueItem.positionTicks}` + if (appliedQueueRef.current === key) return + appliedQueueRef.current = key + if (remoteQueueItem.itemId !== id) { + navigate(`/play/${remoteQueueItem.itemId}`, { replace: true }) + return + } + const p = playerRef.current + if (!p) return + remoteSuppressRef.current = Date.now() + 800 + p.currentTime = remoteQueueItem.positionTicks / 10_000_000 + if (remoteQueueItem.isPlaying) { + p.play().catch(() => {}) + } else { + p.pause() + } + }, [remoteQueueItem, id, navigate]) + function shouldSuppressRemoteEcho() { + return Date.now() < remoteSuppressRef.current + } + const [bookmarksRefreshKey, setBookmarksRefreshKey] = useState(0) + const [resumePromptOpen, setResumePromptOpen] = useState(false) + const [recapCardOpen, setRecapCardOpen] = useState(false) + const [endCardOpen, setEndCardOpen] = useState(false) + const [qualityKey, setQualityKey] = useState('auto') + const [maxBitrate, setMaxBitrate] = useState(undefined) + const [skipPromptSeriesId, setSkipPromptSeriesId] = useState(null) + const areYouStillWatching = usePreferencesStore(s => s.areYouStillWatching) + const autoAdvanceCountRef = useRef(0) + const [stillWatchingOpen, setStillWatchingOpen] = useState(false) + const stillWatchingTargetRef = useRef(null) + + /* Preferences */ + const autoplayNext = usePreferencesStore(s => s.autoplayNext) + const subtitleMode = usePreferencesStore(s => s.subtitleMode) + const subtitleLanguage = usePreferencesStore(s => s.subtitleLanguage) + const skipIntros = usePreferencesStore(s => s.skipIntros) + const skipCredits = usePreferencesStore(s => s.skipCredits) + const defaultPlaybackRate = usePreferencesStore(s => s.defaultPlaybackRate) + const preserveAudioPitch = usePreferencesStore(s => s.preserveAudioPitch) + const pauseOnBlur = usePreferencesStore(s => s.pauseOnBlur) + const showResumePromptPref = usePreferencesStore(s => s.showResumePrompt) + const showRecapCardPref = usePreferencesStore(s => s.episode.recap.card) + const recapGapDays = usePreferencesStore(s => s.episode.recap.gapDays) + const setPreference = usePreferencesStore(s => s.setPreference) + + /* Per-session player runtime state - subtitle offset is read directly + * inside SubtitleOverlay's tick; audio offset is read by the audio-graph + * hook so it doesn't need to live here. */ + const theaterModeOn = usePlayerRuntimeStore(s => s.theaterMode) + const loopA = usePlayerRuntimeStore(s => s.loopA) + const loopB = usePlayerRuntimeStore(s => s.loopB) + + /* Playback speed - session local, can persist to defaultPlaybackRate */ + const [playbackRate, setPlaybackRate] = useState(defaultPlaybackRate || 1) + + /* Sleep timer - counts down only while playing */ + const [sleepRemainingSec, setSleepRemainingSec] = useState(sleepTimerMinutes * 60) + useEffect(() => { + setSleepRemainingSec(sleepTimerMinutes * 60) + }, [sleepTimerMinutes, id]) + useEffect(() => { + if (sleepTimerMinutes <= 0 || sleepRemainingSec <= 0) return + if (!isPaused) { + const id = window.setInterval(() => { + setSleepRemainingSec(prev => { + if (prev <= 1) { + playerRef.current?.pause() + return 0 + } + return prev - 1 + }) + }, 1000) + return () => window.clearInterval(id) + } + }, [isPaused, sleepTimerMinutes, sleepRemainingSec]) + + const reducedMotion = useReducedMotion() + const { + controlsVisible, + seekIndicator, + transientToast, + showControls, + showSeekIndicator, + showToast, + } = usePlayerChrome(isPaused) + + const subtitleStreams = item ? getSubtitleStreams(item) : [] + const mediaSourceId = ((item as any)?.MediaSources?.[0]?.Id as string | undefined) || undefined + + const { + seriesId, + seasonEpisodes, + nextUpItem, + queueActive, + queueNext, + previousItem, + nextItem, + } = usePlayerNavigation(item, id) + const showUpNextWindow = duration > 0 && currentTime > 0 && duration - currentTime <= 30 + const [upNextDismissed, setUpNextDismissed] = useState(false) + // Up Next card uses the SAME `nextItem` resolution that drives the + // chrome's next button and the auto-advance on `onEnded`. When the + // user is on a shuffled queue, queueNext (next in shuffle order) wins + // over the series's natural next-up so the card actually previews + // what will play next. + const upNextCard = nextItem || nextUpItem || null + const nextUpVisible = + !!upNextCard && + upNextCard.Id !== item?.Id && + (item?.Type === 'Episode' || queueActive) && + showUpNextWindow && + !upNextDismissed + const upNextCountdown = Math.max(0, Math.ceil(duration - currentTime)) + + const auth = jellyfinClient.getAuthState() + const token = auth?.token || '' + const serverUrl = auth?.serverUrl || '' + + const resume = searchParams.get('resume') === 'true' + const positionTicks = item?.UserData?.PlaybackPositionTicks + const startTimeTicks = resume && positionTicks ? Number(positionTicks) : undefined + + /* Resolve the proper stream URL from Jellyfin's PlaybackInfo endpoint. + * The server picks direct-play / direct-stream / transcoded HLS based on + * our DeviceProfile and returns a URL with a valid PlaySessionId so HLS + * segment fetches don't 400. */ + // streamAudioIndex re-runs PlaybackInfo so the server returns a fresh + // TranscodingUrl muxing the chosen audio track. We only set it when a + // native audioTracks switch isn't possible (transcoded streams, or + // single-audio sources where the alternate track isn't in the file). + // maxBitrate threads the user-picked quality cap into the same call. + const { data: playbackInfo } = usePlaybackInfo( + id, + startTimeTicks, + streamAudioIndex ?? undefined, + maxBitrate, + ) + const resolvedSource = playbackInfo?.MediaSources?.[0] + const streamUrl = (() => { + if (!resolvedSource || !serverUrl) return '' + // Direct play: server-confirmed the browser can decode the source as-is + if (resolvedSource.SupportsDirectPlay) { + const sep = `${serverUrl}/Videos/${id}/stream?static=true&MediaSourceId=${resolvedSource.Id}&api_key=${token}` + return startTimeTicks ? `${sep}&StartTimeTicks=${startTimeTicks}` : sep + } + // HLS transcoded - the URL the server prepared for us, which already has + // PlaySessionId, runtimeTicks, and api_key baked in. + if (resolvedSource.TranscodingUrl) { + return `${serverUrl}${resolvedSource.TranscodingUrl}` + } + return '' + })() + + /* Reset transient flags on item change */ + useEffect(() => { + setUpNextDismissed(false) + setPanel('streamInfo', false) + setAudioIndex(null) + setStreamAudioIndex(null) + setEndCardOpen(false) + usePlayerRuntimeStore.getState().resetForNewItem() + }, [id, setPanel]) + + /* Resume prompt: show on first mount when there's a saved position + * past the threshold AND the user wants the prompt AND the URL didn't + * already specify ?resume=true (queue navigation path). */ + useEffect(() => { + if (!item) return + const pos = Number(item.UserData?.PlaybackPositionTicks ?? 0) + const thresholdSec = usePreferencesStore.getState().resumeThresholdSec ?? 5 + const threshold = thresholdSec * 10_000_000 + const fromQueue = searchParams.get('resume') === 'true' + if (showResumePromptPref && !fromQueue && pos > threshold) { + setResumePromptOpen(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item?.Id]) + + /* Auto-rewatch counter: when an already-played item starts playing + * again, record the rewatch. We trip it at most once per item-mount + * so a mid-session pause/resume doesn't double-count, but each fresh + * mount of the same item (close + reopen, navigate away + back) + * counts as a new rewatch. */ + const rewatchedItemIdRef = useRef(null) + useEffect(() => { + if (!item || !item.Id || String(item.Id).startsWith('tmdb-')) return + if (item.Type !== 'Movie' && item.Type !== 'Series' && item.Type !== 'Episode') return + if (!item.UserData?.Played) return + if (rewatchedItemIdRef.current === item.Id) return + rewatchedItemIdRef.current = item.Id + usePersonalData.getState().incrementRewatch(item.Id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item?.Id]) + + /* Auto-recap trigger: decide once per item. Recap card waits for the + * resume prompt (if any) to resolve before appearing. */ + const recapTrigger = useMemo( + () => computeRecapTrigger(item, seasonEpisodes, recapGapDays), + [item, seasonEpisodes, recapGapDays], + ) + const [recapPending, setRecapPending] = useState(false) + useEffect(() => { + if (!item) return + const dismissed = usePlayerRuntimeStore.getState().recapDismissed + if (showRecapCardPref && recapTrigger.shouldShow && !dismissed) { + setRecapPending(true) + } else { + setRecapPending(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item?.Id, recapTrigger.shouldShow]) + useEffect(() => { + if (recapPending && !resumePromptOpen) { + setRecapCardOpen(true) + setRecapPending(false) + } + }, [recapPending, resumePromptOpen]) + + /* Auto-pause on window blur. Only if the user opted in. Tracks whether + * we're the one who paused so re-focus doesn't resume a user-paused + * video. */ + useEffect(() => { + if (!pauseOnBlur) return + const pausedByUs = { current: false } + function onBlur() { + const p = playerRef.current + if (!p || p.paused) return + pausedByUs.current = true + p.pause() + } + function onFocus() { + if (!pausedByUs.current) return + pausedByUs.current = false + playerRef.current?.play().catch(() => {}) + } + window.addEventListener('blur', onBlur) + window.addEventListener('focus', onFocus) + return () => { + window.removeEventListener('blur', onBlur) + window.removeEventListener('focus', onFocus) + } + }, [pauseOnBlur]) + + /* Apply audio graph state when offsets / boost / night mode change */ + usePlayerAudioGraph(playerRef, streamUrl) + + /* Active query eviction for the previous episode. Drops cached + * PlaybackInfo + the played-item details + chapters / markers so the + * cache doesn't accumulate during a long binge. The next episode + * fetches fresh - cost is one round-trip per episode change, gain + * is bounded memory. */ + useEffect(() => { + const evictId = id + return () => { + if (!evictId) return + try { + qc.removeQueries({ queryKey: ['jellyfin', 'playback-info', evictId], exact: false }) + qc.removeQueries({ queryKey: ['jellyfin', 'item', evictId], exact: false }) + qc.removeQueries({ queryKey: ['jellyfin', 'media-segments', evictId], exact: false }) + qc.removeQueries({ queryKey: ['jellyfin', 'episodes', evictId], exact: false }) + } catch { /* ignore */ } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + + /* Aggressive teardown on player unmount. + * + * The official Jellyfin web client (and basically every player) gets + * away with the default vidstack/hls.js cleanup because they don't + * remount per-episode - they swap the source on a single long-lived + * media element. We DO remount per-episode (Routes is keyed on + * pathname), so each shuffle step creates a new MediaSource + + * SourceBuffers and the browser holds the old buffered video data + * until GC catches up. After 13+ episodes the WebView accumulates + * enough buffer pressure to crash. + * + * We can't change the routing without a bigger rewrite, but we can + * help the browser release the previous episode's media path + * immediately: + * 1. Pause the underlying