+
{
+ // Vidstack picks VideoProvider for native HLS browsers (Safari /
+ // iOS / tvOS) and HLSProvider for MSE-only ones. The block below
+ // only runs in the MSE case - native HLS users skip hls.js
+ // entirely and get the OS hardware HEVC path.
+ if (provider && isHLSProvider(provider)) {
+ // Bundled hls.js (no CDN load = no tracking-prevention quirks)
+ ;(provider as any).library = HLS
+ // hls.js configuration tuned for Jellyfin transcoded streams.
+ // Default fragLoadingTimeOut of 10s is too aggressive when the
+ // server is doing HEVC -> h264 transcoding from a cold start
+ // (ffmpeg needs time to spin up before the first segment).
+ ;(provider as any).config = {
+ startLevel: -1,
+ maxBufferLength: 30,
+ maxMaxBufferLength: 60,
+ lowLatencyMode: false,
+ backBufferLength: 30,
+ fragLoadingTimeOut: 60_000,
+ fragLoadingMaxRetry: 6,
+ manifestLoadingTimeOut: 30_000,
+ levelLoadingTimeOut: 30_000,
+ }
+ }
+ }}
+ onProviderSetup={(provider: MediaProviderAdapter) => {
+ if (isHLSProvider(provider)) {
+ const hls = (provider as any).instance as any
+ if (hls?.on && HLS?.Events) {
+ hls.on(HLS.Events.ERROR, (_event: unknown, data: any) => {
+ // Always log this one - it's the only signal when playback breaks
+ console.warn('[HLS]', data?.type, data?.details, {
+ fatal: data?.fatal,
+ reason: data?.reason,
+ url: data?.url || data?.frag?.url,
+ response: data?.response,
+ })
+ })
+ }
+ }
+ }}
+ className="w-full h-full player-fill"
+ onCanPlay={() => {
+ // Apply the persisted volume the moment the player is actually
+ // ready to accept commands. The subscribe-time restore can be too
+ // early - vidstack silently drops volume writes before its first
+ // can-play, leaving the slider stuck at 1.
+ const p = playerRef.current
+ const persisted = usePreferencesStore.getState().playerVolume
+ if (p && Number.isFinite(persisted)) {
+ try { p.volume = Math.max(0, Math.min(1, persisted)) } catch { /* noop */ }
+ }
+ // Apply the saved playback rate (default 1) so users who like
+ // 1.5x get it on every episode without re-setting.
+ if (p) applyPlaybackRate(playbackRate)
+ }}
+ onEnded={() => {
+ // Queue takes priority - playlist play/shuffle should always
+ // continue through the queue regardless of the global autoplay
+ // pref (the user explicitly opted in by hitting Play / Shuffle).
+ if (queueNext?.Id) {
+ if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
+ stillWatchingTargetRef.current = queueNext.Id
+ setStillWatchingOpen(true)
+ return
+ }
+ autoAdvanceCountRef.current++
+ navigate(`/play/${queueNext.Id}`, { replace: true })
+ return
+ }
+ if (autoplayNext && nextUpItem?.Id && nextUpItem.Id !== item?.Id) {
+ if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
+ stillWatchingTargetRef.current = nextUpItem.Id
+ setStillWatchingOpen(true)
+ return
+ }
+ autoAdvanceCountRef.current++
+ navigate(`/play/${nextUpItem.Id}`, { replace: true })
+ return
+ }
+ autoAdvanceCountRef.current = 0
+ if (usePreferencesStore.getState().endOfVideoCard) {
+ setEndCardOpen(true)
+ } else {
+ navigate(-1)
+ }
+ }}
+ onAutoPlayFail={() => {
+ // Browser blocked unmuted autoplay (Chrome's policy after navigation).
+ // Mute and retry per vidstack's recommendation - user can unmute with
+ // M or the volume slider once playback starts.
+ const p = playerRef.current
+ if (!p) return
+ p.muted = true
+ setIsMuted(true)
+ try {
+ const r = p.play() as Promise | void
+ if (r && typeof (r as Promise).catch === 'function') {
+ ;(r as Promise).catch(() => {})
+ }
+ } catch {
+ /* ignored */
+ }
+ }}
+ >
+
+ {/* Subtitle tracks - one Track per subtitle stream. The `default` prop
+ sets the initially-engaged track based on subtitleMode + language. */}
+ {id && subtitleStreams.map(t => {
+ const idx = t.Index
+ if (idx == null) return null
+ return (
+
+ )
+ })}
+
+
+
+ {/* Click-capture: anywhere on the video toggles play/pause; double-
+ click toggles fullscreen. Sits above the player but below the
+ subtitle layer and the chrome bars (which set their own
+ pointer-events to receive their own clicks). */}
+
{
+ const p = playerRef.current
+ if (!p) return
+ if (p.paused) p.play()
+ else p.pause()
+ showControls()
+ }}
+ onDoubleClick={toggleFullscreen}
+ />
+
+ {/* Subtitle rendering. ASS / SSA tracks go through libass-wasm so
+ positioning, fonts, karaoke and overlapping cues are preserved
+ (Jellyfin would otherwise transcode them to VTT and lose all of
+ that). Everything else goes through our VTT overlay, which is
+ where the styling controls (size / color / edge / position) live. */}
+ {(() => {
+ if (!id || subtitleIndex == null) return null
+ const activeSub = subtitleStreams.find(s => s.Index === subtitleIndex)
+ const codec = (activeSub?.Codec || '').toLowerCase()
+ const isAss = codec === 'ass' || codec === 'ssa'
+ if (isAss) {
+ return (
+
+ )
+ }
+ return (
+
+ )
+ })()}
+
+ {/* In-player episodes browser - opens via the list-details button in
+ the top bar. Only mounted for episodes since movies have no list. */}
+ {seriesId && id && (
+
setEpisodesOpen(false)}
+ seriesId={seriesId}
+ currentItemId={id}
+ initialSeasonId={(item as any)?.SeasonId || undefined}
+ serverUrl={serverUrl}
+ />
+ )}
+
+ {/* Stream info overlay */}
+
+
+
+
+ {/* Are you still watching? - pauses after 3 consecutive auto-advances */}
+
+ {stillWatchingOpen && (
+ {
+ setStillWatchingOpen(false)
+ playerRef.current?.play().catch(() => {})
+ }}
+ >
+
+ Still watching?
+
+ Press play to keep going.
+
+
+ {stillWatchingTargetRef.current && (
+
+ )}
+
+
+ )}
+
+
+ {/* Skip intro / credits button - shown when inside a detected marker */}
+
+ {currentMarker && (
+ {
+ const target = currentMarker.type === 'credits' && duration > 0
+ ? Math.max(currentMarker.endSec, duration - 0.5)
+ : currentMarker.endSec
+ if (playerRef.current) playerRef.current.currentTime = target
+ showControls()
+ // Smart skip suggestion: if the user keeps manually skipping
+ // intros for this series, offer to auto-skip from now on.
+ if (currentMarker.type === 'intro' && seriesId) {
+ const r = recordManualSkip(seriesId)
+ if (r.shouldPrompt && !skipIntros) {
+ setSkipPromptSeriesId(seriesId)
+ }
+ }
+ }}
+ className="absolute bottom-32 right-7 z-20 inline-flex items-center gap-2 h-10 px-4 rounded-md bg-white text-void text-[13px] font-semibold tracking-tight shadow-lg shadow-black/40 hover:scale-[1.02] active:scale-[0.98] transition-transform pointer-events-auto focus-ring"
+ >
+
+ Skip {currentMarker.type === 'intro' ? 'intro' : 'credits'}
+
+ )}
+
+
+ {
+ if (upNextCard?.Id) navigate(`/play/${upNextCard.Id}`, { replace: true })
+ },
+ onDismiss: () => setUpNextDismissed(true),
+ }}
+ resume={{
+ open: resumePromptOpen,
+ positionTicks: Number(item?.UserData?.PlaybackPositionTicks ?? 0),
+ lastPlayedDate: item?.UserData?.LastPlayedDate ?? null,
+ onResume: () => {
+ setResumePromptOpen(false)
+ const p = playerRef.current
+ const pos = Number(item?.UserData?.PlaybackPositionTicks ?? 0)
+ if (p && pos > 0) p.currentTime = pos / 10_000_000
+ p?.play().catch(() => {})
+ },
+ onRestart: () => {
+ setResumePromptOpen(false)
+ const p = playerRef.current
+ if (p) p.currentTime = 0
+ p?.play().catch(() => {})
+ },
+ }}
+ recap={{
+ open: recapCardOpen,
+ previousEpisodes: recapTrigger.previousEpisodes,
+ daysSinceLastWatch: recapTrigger.daysSinceLastWatch,
+ onDismiss: () => {
+ setRecapCardOpen(false)
+ usePlayerRuntimeStore.getState().setRecapDismissed(true)
+ },
+ }}
+ chapters={id ? {
+ open: chaptersOpen,
+ itemId: id,
+ chapters,
+ serverUrl,
+ currentTime,
+ onClose: () => setChaptersOpen(false),
+ onJump: t => seekToSeconds(t),
+ } : null}
+ bookmarks={id ? {
+ open: bookmarksOpen,
+ itemId: id,
+ currentTime,
+ refreshKey: bookmarksRefreshKey,
+ onClose: () => setBookmarksOpen(false),
+ onJump: t => seekToSeconds(t),
+ onAdd: () => {
+ const p = playerRef.current
+ if (!p || !id) return
+ bookmarksAdd(id, p.currentTime ?? 0)
+ setBookmarksRefreshKey(k => k + 1)
+ },
+ } : null}
+ endCard={{
+ open: endCardOpen,
+ hasEpisodes: !!seriesId,
+ item,
+ onReplay: () => {
+ setEndCardOpen(false)
+ const p = playerRef.current
+ if (p) p.currentTime = 0
+ p?.play().catch(() => {})
+ },
+ onEpisodes: () => {
+ setEndCardOpen(false)
+ setEpisodesOpen(true)
+ },
+ onBack: () => {
+ setEndCardOpen(false)
+ navigate(-1)
+ },
+ }}
+ nextItem={upNextCard}
+ onPlayNext={() => {
+ if (upNextCard?.Id) navigate(`/play/${upNextCard.Id}`, { replace: true })
+ }}
+ skipPrompt={{
+ seriesId: skipPromptSeriesId,
+ onAccept: () => {
+ setPreference('skipIntros', true)
+ setSkipPromptSeriesId(null)
+ showToast('Auto-skip enabled')
+ },
+ onNotNow: () => setSkipPromptSeriesId(null),
+ onDismiss: () => {
+ if (skipPromptSeriesId) dismissSkipPrompt(skipPromptSeriesId)
+ setSkipPromptSeriesId(null)
+ },
+ }}
+ hints={{
+ open: hintsOpen,
+ onClose: () => setHintsOpen(false),
+ }}
+ subSearch={{
+ open: subSearchOpen,
+ subtitleUrl: id && subtitleIndex != null
+ ? getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token, 'vtt')
+ : null,
+ onClose: () => setSubSearchOpen(false),
+ onJump: t => seekToSeconds(t),
+ }}
+ syncPlay={{
+ open: syncPlayOpen,
+ onClose: () => setSyncPlayOpen(false),
+ currentItemId: id || null,
+ currentPositionTicks: Math.round(currentTime * 10_000_000),
+ }}
+ />
+
+ {/* Transient toast - used by screenshot, etc. */}
+
+ {transientToast && (
+
+ {transientToast}
+
+ )}
+
+
+ {/* Loading spinner */}
+
+ {seeking && (
+
+
+
+ )}
+
+
+ {/* Seek indicator (10s skip) */}
+
+ {seekIndicator && (
+
+
+ {seekIndicator.direction === 'forward' ? (
+
+ ) : (
+
+ )}
+ 10s
+
+
+ )}
+
+
+ {/* ── Controls overlay ─────────────────────────────────── */}
+
+ {controlsVisible && (
+
+ {
+ setQualityKey(q.key)
+ setMaxBitrate(q.bitrate || undefined)
+ }}
+ onPictureInPicture={togglePictureInPicture}
+ audioTracks={audioTracks}
+ audioIndex={audioIndex}
+ onAudioSelect={pickAudio}
+ subtitleTracks={subtitleTracks}
+ subtitleIndex={subtitleIndex}
+ onSubtitleSelect={i => setSubtitleIndex(i)}
+ hasSeries={!!seriesId}
+ episodesOpen={episodesOpen}
+ onToggleEpisodes={() => setEpisodesOpen(o => !o)}
+ hasChapters={chapters.length > 0}
+ chaptersOpen={chaptersOpen}
+ onToggleChapters={() => setChaptersOpen(o => !o)}
+ bookmarksOpen={bookmarksOpen}
+ onToggleBookmarks={() => setBookmarksOpen(o => !o)}
+ subSearchOpen={subSearchOpen}
+ onToggleSubSearch={() => setSubSearchOpen(o => !o)}
+ syncPlayOpen={syncPlayOpen}
+ onToggleSyncPlay={() => setSyncPlayOpen(o => !o)}
+ syncPlayActive={syncPlayActive}
+ videoBrightness={videoBrightness}
+ videoContrast={videoContrast}
+ videoSaturation={videoSaturation}
+ onPictureChange={(k, v) => {
+ if (k === 'brightness') setPreference('videoBrightness', v)
+ if (k === 'contrast') setPreference('videoContrast', v)
+ if (k === 'saturation') setPreference('videoSaturation', v)
+ }}
+ onPictureReset={() => {
+ setPreference('videoBrightness', 1)
+ setPreference('videoContrast', 1)
+ setPreference('videoSaturation', 1)
+ }}
+ streamInfoOpen={streamInfoOpen}
+ onToggleStreamInfo={() => setStreamInfoOpen(o => !o)}
+ onBack={() => navigate(-1)}
+ sleepRemainingSec={sleepRemainingSec}
+ onDownload={() => {
+ if (!item || !streamUrl) return
+ const poster = getBestImage(serverUrl, item, 'primary') || ''
+ startDownload({
+ itemId: item.Id!,
+ name: item.Name || 'Untitled',
+ posterUrl: poster,
+ streamUrl,
+ })
+ }}
+ isDownloaded={!!item && useDownloads.getState().items.some(d => d.itemId === item.Id)},
+ />
+
+ {/* Center play/pause indicator (only shown briefly when paused) */}
+
+ {isPaused && (
+
+
+
+ )}
+
+
+ {
+ const p = playerRef.current
+ if (p?.paused) p.play()
+ else p?.pause()
+ }}
+ onPrevious={() => {
+ if (previousItem?.Id) navigate(`/play/${previousItem.Id}`, { replace: true })
+ }}
+ onNext={() => {
+ if (nextItem?.Id) navigate(`/play/${nextItem.Id}`, { replace: true })
+ }}
+ onBack10={() => {
+ const p = playerRef.current
+ if (!p) return
+ p.currentTime = Math.max(0, (p.currentTime ?? 0) - 10)
+ showSeekIndicator('backward')
+ }}
+ onForward10={() => {
+ const p = playerRef.current
+ if (!p) return
+ p.currentTime = (p.currentTime ?? 0) + 10
+ showSeekIndicator('forward')
+ }}
+ onToggleMute={() => {
+ const p = playerRef.current
+ if (!p) return
+ p.muted = !p.muted
+ setIsMuted(p.muted)
+ }}
+ onVolumeChange={v => {
+ setVolume(v)
+ if (playerRef.current) {
+ playerRef.current.volume = v
+ playerRef.current.muted = v === 0
+ }
+ }}
+ onToggleFullscreen={toggleFullscreen}
+ />
+
+ )}
+
+
+ )
+}
+
+
diff --git a/src/pages/PlaylistsPage.tsx b/src/pages/PlaylistsPage.tsx
new file mode 100644
index 0000000..6207a9a
--- /dev/null
+++ b/src/pages/PlaylistsPage.tsx
@@ -0,0 +1,99 @@
+import { useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { ListMusic } from '../lib/icons'
+import { useLibraryItems } from '../hooks/use-jellyfin'
+import PosterCard from '../components/ui/PosterCard'
+import { usePosterGridClasses } from '../lib/density'
+
+export default function PlaylistsPage() {
+ const navigate = useNavigate()
+ const gridCls = usePosterGridClasses()
+
+ const { data, isLoading } = useLibraryItems(undefined, {
+ includeItemTypes: ['Playlist'],
+ sortBy: ['SortName'],
+ sortOrder: ['Ascending'],
+ limit: 200,
+ })
+
+ const items = data?.Items || []
+
+ return (
+
+
+
+
+ Library
+
+
+
+ Playlists
+
+ {items.length > 0 && (
+
+ {items.length.toLocaleString()} {items.length === 1 ? 'playlist' : 'playlists'}
+
+ )}
+
+
+
+ {isLoading ? (
+
+ ) : items.length === 0 ? (
+
+ ) : (
+
+ {items.map((item, i) => (
+
+ item.Id && navigate(`/item/${item.Id}`)}
+ />
+
+ ))}
+
+ )}
+
+ )
+}
+
+function EmptyState() {
+ return (
+
+
+
No playlists yet
+
+ Create a playlist on your Jellyfin server and it'll show up here.
+
+
+ )
+}
+
+function SkeletonGrid() {
+ const gridCls = usePosterGridClasses()
+ return (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+ )
+}
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx
new file mode 100644
index 0000000..a112cf9
--- /dev/null
+++ b/src/pages/ProfilePage.tsx
@@ -0,0 +1,343 @@
+import { useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Star, Clock, Flame, Calendar, ChevronRight } from '../lib/icons'
+import { useLibraryItems, useItemsByIds } from '../hooks/use-jellyfin'
+import { usePersonalData } from '../stores/personal-data-store'
+import { useDiary } from '../stores/diary-store'
+import { genreBreakdown, watchStreak, totalHoursWatched, longestBinge } from '../lib/watch-stats'
+import { jellyfinClient } from '../api/jellyfin'
+
+const CURRENT_YEAR = new Date().getFullYear()
+
+/**
+ * Personal profile page - the home for the user's own data layer:
+ * - Watch streak (#46)
+ * - Genre breakdown (#44)
+ * - Year-in-Review summary (#43)
+ * - Recent diary entries (#47, surfaced)
+ */
+export default function ProfilePage() {
+ const navigate = useNavigate()
+ const auth = jellyfinClient.getAuthState()
+ const { data } = useLibraryItems(undefined, {
+ includeItemTypes: ['Movie', 'Series', 'Episode'],
+ sortBy: ['DatePlayed'],
+ sortOrder: ['Descending'],
+ filters: ['IsPlayed'],
+ limit: 1000,
+ })
+ const items = useMemo(() => data?.Items || [], [data?.Items])
+
+ const yearItems = useMemo(
+ () =>
+ items.filter(it => {
+ const raw = it.UserData?.LastPlayedDate
+ if (!raw) return false
+ return new Date(raw).getFullYear() === CURRENT_YEAR
+ }),
+ [items],
+ )
+
+ const streak = useMemo(() => watchStreak(items), [items])
+ const breakdown = useMemo(() => genreBreakdown(items).slice(0, 8), [items])
+ const breakdownYear = useMemo(() => genreBreakdown(yearItems).slice(0, 6), [yearItems])
+ const hoursAll = useMemo(() => totalHoursWatched(items), [items])
+ const hoursYear = useMemo(() => totalHoursWatched(yearItems), [yearItems])
+ const binge = useMemo(() => longestBinge(yearItems), [yearItems])
+
+ const personal = usePersonalData(s => s.entries)
+ // Top rated by personal rating - independent of played status, so an
+ // item rated highly that the user hasn't gotten around to opening
+ // still shows up. Resolve metadata via a separate `useItemsByIds`
+ // call rather than joining against the played-items list.
+ const topRatedIds = useMemo(
+ () =>
+ Object.entries(personal)
+ .filter(([, e]) => e.rating >= 8)
+ .sort((a, b) => b[1].rating - a[1].rating)
+ .slice(0, 10)
+ .map(([id]) => id),
+ [personal],
+ )
+ const { data: topRatedItems = [] } = useItemsByIds(topRatedIds)
+ const topRatedPersonal = useMemo(() => {
+ return topRatedIds
+ .map(id => {
+ const item = topRatedItems.find(it => it.Id === id) || items.find(i => i.Id === id)
+ const entry = personal[id]
+ if (!item || !entry) return null
+ return { id, rating: entry.rating, rewatchCount: entry.rewatchCount, item }
+ })
+ .filter((x): x is { id: string; rating: number; rewatchCount: number; item: any } => !!x)
+ }, [topRatedIds, topRatedItems, personal, items])
+
+ const diary = useDiary(s => s.entries)
+ const recentDiary = useMemo(
+ () =>
+ [...diary]
+ .sort((a, b) => b.watchedAt.localeCompare(a.watchedAt))
+ .slice(0, 8),
+ [diary],
+ )
+
+ return (
+
+
+
+ {/* Stat tiles */}
+
+ = 3}
+ icon={}
+ hint={streak.longest > streak.current ? `Best: ${streak.longest}` : 'Keep going'}
+ />
+ }
+ />
+ }
+ hint={`${hoursAll.toFixed(0)} all-time`}
+ />
+ }
+ hint={binge.day || 'No binge yet'}
+ />
+
+
+ {/* Genre breakdown */}
+
+
+ Genre breakdown
+
+ {breakdown.length === 0 ? (
+ No genre data yet.
+ ) : (
+
+ {breakdown.map((g, i) => (
+
+ ))}
+
+ )}
+ {breakdownYear.length > 0 && (
+
+ Top this year:{' '}
+ {breakdownYear.map((g, i) => (
+
+ {g.genre}
+ {(g.share * 100).toFixed(0)}%
+ {i < breakdownYear.length - 1 && · }
+
+ ))}
+
+ )}
+
+
+ {/* Year in Review - top personal ratings */}
+
+
+ Your top picks
+
+ {topRatedPersonal.length === 0 ? (
+
+ No 8+/10 personal ratings yet. Use the rating row on a detail page to mark your favourites.
+
+ ) : (
+
+ {topRatedPersonal.map((entry, i) => (
+
+ ))}
+
+ )}
+
+
+ {/* Recent diary */}
+ {recentDiary.length > 0 && (
+
+
+ Recent diary
+
+
+ {recentDiary.map(d => (
+ -
+ {d.emoji && {d.emoji}}
+
+
+
+ {new Date(d.watchedAt).toLocaleDateString(undefined, {
+ year: 'numeric', month: 'short', day: 'numeric',
+ })}
+
+ ·
+
+ {d.rating != null && d.rating > 0 && (
+ <>
+ ·
+
+
+ {d.rating}/10
+
+ >
+ )}
+
+ {d.note && (
+
+ {d.note}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function StatTile({
+ label,
+ value,
+ unit,
+ hint,
+ icon,
+ accent,
+}: {
+ label: string
+ value: string
+ unit?: string
+ hint?: string
+ icon?: React.ReactNode
+ accent?: boolean
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+
+ {value}
+
+ {unit && {unit}}
+
+ {hint && {hint}
}
+
+ )
+}
+
+function GenreBar({ share, index }: { share: { genre: string; count: number; share: number }; index: number }) {
+ const pct = Math.max(2, Math.round(share.share * 100))
+ return (
+
+
+ {share.genre}
+
+
+
+
+
+ {(share.share * 100).toFixed(0)}%
+
+
+ )
+}
+
+function TopPickRow({
+ index,
+ entry,
+}: {
+ index: number
+ entry: { id: string; rating: number; rewatchCount: number; item: any }
+}) {
+ const navigate = useNavigate()
+ return (
+
+
+
+ )
+}
+
+function NavLink({ itemId, name }: { itemId: string; name: string }) {
+ const navigate = useNavigate()
+ return (
+
+ )
+}
diff --git a/src/pages/RequestsPage.tsx b/src/pages/RequestsPage.tsx
new file mode 100644
index 0000000..e9b40bf
--- /dev/null
+++ b/src/pages/RequestsPage.tsx
@@ -0,0 +1,391 @@
+import { useMemo } from 'react'
+import { motion } from 'framer-motion'
+import { useNavigate } from 'react-router-dom'
+import { useQueryClient } from '@tanstack/react-query'
+import { Database, RefreshCw, Trash2, Clock, Check, AlertCircle, Settings } from '../lib/icons'
+import { useArrInstances } from '../stores/arr-instances-store'
+import { useRadarrLibrary, useSonarrLibrary, useRadarrQueue, useSonarrQueue } from '../hooks/use-arr'
+import { radarrClient, type RadarrMovie, type RadarrQueueItem } from '../api/radarr'
+import { sonarrClient, type SonarrSeries, type SonarrQueueItem } from '../api/sonarr'
+
+/**
+ * Aggregated requests view: every movie + show currently sitting in
+ * Sonarr/Radarr but not fully available yet, with per-item actions.
+ *
+ * Data sources:
+ * - Radarr/Sonarr libraries (filter to monitored + missing/partial)
+ * - Their queues (in-flight downloads with progress)
+ *
+ * Hides itself with a setup prompt when no *arr instance is configured.
+ */
+
+type Tier = 'default' | '4k'
+
+interface AggregatedRequest {
+ key: string
+ kind: 'movie' | 'tv'
+ tier: Tier
+ arrId: number
+ title: string
+ year?: number | null
+ poster?: string | null
+ /** Render label like "Downloading 64%" or "Pending release". */
+ state: 'processing' | 'pending' | 'partial' | 'requested'
+ detail?: string
+ /** Optional progress 0..100 when processing. */
+ progress?: number
+}
+
+export default function RequestsPage() {
+ const navigate = useNavigate()
+ const radarr = useArrInstances(s => s.pick('radarr', 'default'))
+ const radarr4k = useArrInstances(s => s.pick('radarr', '4k'))
+ const sonarr = useArrInstances(s => s.pick('sonarr', 'default'))
+ const sonarr4k = useArrInstances(s => s.pick('sonarr', '4k'))
+
+ const radarrA = useRadarrLibrary('default')
+ const radarrB = useRadarrLibrary('4k')
+ const sonarrA = useSonarrLibrary('default')
+ const sonarrB = useSonarrLibrary('4k')
+ const radarrQA = useRadarrQueue('default')
+ const radarrQB = useRadarrQueue('4k')
+ const sonarrQA = useSonarrQueue('default')
+ const sonarrQB = useSonarrQueue('4k')
+
+ const aggregated: AggregatedRequest[] = useMemo(() => {
+ const out: AggregatedRequest[] = []
+ function pushMovies(list: RadarrMovie[] | null | undefined, queue: { records?: RadarrQueueItem[] } | null | undefined, tier: Tier) {
+ if (!list) return
+ const queueByMovieId = new Map
()
+ for (const q of queue?.records || []) if (q.movieId != null) queueByMovieId.set(q.movieId, q)
+ for (const m of list) {
+ if (m.hasFile) continue
+ if (!m.id) continue
+ const q = queueByMovieId.get(m.id)
+ const poster = m.images?.find(x => x.coverType === 'poster')?.remoteUrl || null
+ if (q) {
+ const total = (q.size ?? 0)
+ const left = (q.sizeleft ?? 0)
+ const progress = total > 0 ? Math.round(((total - left) / total) * 100) : undefined
+ out.push({
+ key: `radarr-${tier}-${m.id}`,
+ kind: 'movie',
+ tier,
+ arrId: m.id,
+ title: m.title,
+ year: m.year,
+ poster,
+ state: 'processing',
+ detail: q.timeleft ? `${progress ?? 0}% · ${q.timeleft} left` : `${progress ?? 0}%`,
+ progress,
+ })
+ } else if (m.monitored) {
+ out.push({
+ key: `radarr-${tier}-${m.id}`,
+ kind: 'movie',
+ tier,
+ arrId: m.id,
+ title: m.title,
+ year: m.year,
+ poster,
+ state: 'pending',
+ detail: m.status === 'announced' ? 'Announced' : m.status === 'inCinemas' ? 'In cinemas' : 'Searching for release',
+ })
+ } else {
+ out.push({
+ key: `radarr-${tier}-${m.id}`,
+ kind: 'movie',
+ tier,
+ arrId: m.id,
+ title: m.title,
+ year: m.year,
+ poster,
+ state: 'requested',
+ detail: 'Not monitored',
+ })
+ }
+ }
+ }
+ function pushSeries(list: SonarrSeries[] | null | undefined, queue: { records?: SonarrQueueItem[] } | null | undefined, tier: Tier) {
+ if (!list) return
+ const queueBySeriesId = new Map()
+ for (const q of queue?.records || []) {
+ if (q.seriesId == null) continue
+ const existing = queueBySeriesId.get(q.seriesId) || []
+ queueBySeriesId.set(q.seriesId, [...existing, q])
+ }
+ for (const s of list) {
+ if (!s.id) continue
+ const seasons = s.seasons || []
+ const totalEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeCount || 0), 0)
+ const haveEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeFileCount || 0), 0)
+ if (totalEps > 0 && haveEps >= totalEps) continue
+ const poster = s.images?.find(x => x.coverType === 'poster')?.remoteUrl || null
+ const q = queueBySeriesId.get(s.id)
+ const monitoredSeasons = seasons.filter(x => x.monitored).length
+ if (q && q.length > 0) {
+ out.push({
+ key: `sonarr-${tier}-${s.id}`,
+ kind: 'tv',
+ tier,
+ arrId: s.id,
+ title: s.title,
+ year: s.year,
+ poster,
+ state: 'processing',
+ detail: `${q.length} episode${q.length === 1 ? '' : 's'} downloading`,
+ })
+ } else if (haveEps > 0 && haveEps < totalEps) {
+ out.push({
+ key: `sonarr-${tier}-${s.id}`,
+ kind: 'tv',
+ tier,
+ arrId: s.id,
+ title: s.title,
+ year: s.year,
+ poster,
+ state: 'partial',
+ detail: `${haveEps} of ${totalEps} episodes`,
+ progress: totalEps > 0 ? Math.round((haveEps / totalEps) * 100) : undefined,
+ })
+ } else if (monitoredSeasons > 0) {
+ out.push({
+ key: `sonarr-${tier}-${s.id}`,
+ kind: 'tv',
+ tier,
+ arrId: s.id,
+ title: s.title,
+ year: s.year,
+ poster,
+ state: 'pending',
+ detail: `${monitoredSeasons} season${monitoredSeasons === 1 ? '' : 's'} monitored`,
+ })
+ } else {
+ out.push({
+ key: `sonarr-${tier}-${s.id}`,
+ kind: 'tv',
+ tier,
+ arrId: s.id,
+ title: s.title,
+ year: s.year,
+ poster,
+ state: 'requested',
+ detail: 'No seasons monitored',
+ })
+ }
+ }
+ }
+ pushMovies(radarrA.data, radarrQA.data, 'default')
+ pushMovies(radarrB.data, radarrQB.data, '4k')
+ pushSeries(sonarrA.data, sonarrQA.data, 'default')
+ pushSeries(sonarrB.data, sonarrQB.data, '4k')
+
+ // Sort: processing first, then partial, pending, requested. Within
+ // each bucket, alphabetical.
+ const order: Record = { processing: 0, partial: 1, pending: 2, requested: 3 }
+ return out.sort((a, b) => {
+ const oa = order[a.state]
+ const ob = order[b.state]
+ if (oa !== ob) return oa - ob
+ return a.title.localeCompare(b.title)
+ })
+ }, [radarrA.data, radarrB.data, sonarrA.data, sonarrB.data, radarrQA.data, radarrQB.data, sonarrQA.data, sonarrQB.data])
+
+ const noInstance = !radarr && !radarr4k && !sonarr && !sonarr4k
+
+ const counts = useMemo(() => {
+ const out = { processing: 0, partial: 0, pending: 0, requested: 0 }
+ for (const r of aggregated) out[r.state]++
+ return out
+ }, [aggregated])
+
+ return (
+
+
+
+ {noInstance ? (
+
+
+
No Sonarr or Radarr connected
+
+ Connect a Sonarr or Radarr instance to track active downloads, monitor pending releases, and manage requests here.
+
+
+
+ ) : aggregated.length === 0 ? (
+
+
+
All caught up
+
+ Every monitored item in your *arr stack is fully downloaded.
+
+
+ ) : (
+
+ {aggregated.map((req, i) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function RequestRow({ req, index }: { req: AggregatedRequest; index: number }) {
+ const qc = useQueryClient()
+ const radarrInstance = useArrInstances(s => s.pick('radarr', req.tier))
+ const sonarrInstance = useArrInstances(s => s.pick('sonarr', req.tier))
+
+ async function searchNow() {
+ if (req.kind === 'movie' && radarrInstance) {
+ await radarrClient(radarrInstance).searchMovie(req.arrId)
+ qc.invalidateQueries({ queryKey: ['radarr', 'queue'] })
+ } else if (req.kind === 'tv' && sonarrInstance) {
+ await sonarrClient(sonarrInstance).searchSeries(req.arrId)
+ qc.invalidateQueries({ queryKey: ['sonarr', 'queue'] })
+ }
+ }
+
+ async function cancel() {
+ const ok = confirm(`Remove "${req.title}" from ${req.kind === 'movie' ? 'Radarr' : 'Sonarr'}? Files on disk will be left in place.`)
+ if (!ok) return
+ if (req.kind === 'movie' && radarrInstance) {
+ await radarrClient(radarrInstance).removeMovie(req.arrId, false)
+ qc.invalidateQueries({ queryKey: ['radarr', 'library'] })
+ } else if (req.kind === 'tv' && sonarrInstance) {
+ await sonarrClient(sonarrInstance).removeSeries(req.arrId, false)
+ qc.invalidateQueries({ queryKey: ['sonarr', 'library'] })
+ }
+ }
+
+ return (
+
+
+ {req.poster && (
+

+ )}
+
+
+
+
{req.title}
+ {req.year &&
{req.year}}
+
+ {req.tier === '4k' && (
+
+ 4K
+
+ )}
+
+ {req.detail && (
+
{req.detail}
+ )}
+ {req.progress != null && req.progress >= 0 && req.progress <= 100 && (
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+function CountChip({ tone, label, value }: { tone: 'blue' | 'amber' | 'purple' | 'neutral'; label: string; value: number }) {
+ const palette =
+ tone === 'blue' ? 'bg-blue-500/12 text-blue-200 ring-blue-400/30'
+ : tone === 'amber' ? 'bg-amber-500/12 text-amber-200 ring-amber-400/30'
+ : tone === 'purple' ? 'bg-purple-500/12 text-purple-200 ring-purple-400/30'
+ : 'bg-elevated/60 text-text-2 ring-border'
+ return (
+
+ {value}
+ {label}
+
+ )
+}
+
+function StateChip({ state }: { state: AggregatedRequest['state'] }) {
+ const Icon =
+ state === 'processing' ? RefreshCw
+ : state === 'partial' ? Check
+ : state === 'pending' ? Clock
+ : AlertCircle
+ const tone =
+ state === 'processing' ? 'bg-blue-500/20 text-blue-200 ring-blue-400/30'
+ : state === 'partial' ? 'bg-amber-500/20 text-amber-200 ring-amber-400/30'
+ : state === 'pending' ? 'bg-purple-500/20 text-purple-200 ring-purple-400/30'
+ : 'bg-elevated/80 text-text-2 ring-border'
+ const label =
+ state === 'processing' ? 'Downloading'
+ : state === 'partial' ? 'Partial'
+ : state === 'pending' ? 'Pending'
+ : 'Requested'
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx
new file mode 100644
index 0000000..befeb09
--- /dev/null
+++ b/src/pages/SearchPage.tsx
@@ -0,0 +1,237 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Search, X, Loader2 } from '../lib/icons'
+import { useLibraryByTmdbId } from '../hooks/use-jellyfin'
+import { useFuzzyLibrarySearch } from '../hooks/use-fuzzy-search'
+import { useTmdbSearch } from '../hooks/use-tmdb'
+import { mapTmdbToJf } from '../lib/tmdb-mapping'
+import type { BaseItemDto } from '../api/types'
+import { PeopleSection, MoviesShowsSection, EpisodesSection, MusicSection } from './search/sections'
+import { EmptyState, LoadingState, NoResults } from './search/empty-states'
+
+const RECENT_KEY = 'jf_recent_searches'
+const RECENT_MAX = 8
+
+function readRecent(): string[] {
+ if (typeof window === 'undefined') return []
+ try {
+ const raw = localStorage.getItem(RECENT_KEY)
+ if (!raw) return []
+ const arr = JSON.parse(raw)
+ return Array.isArray(arr) ? arr.filter(s => typeof s === 'string').slice(0, RECENT_MAX) : []
+ } catch {
+ return []
+ }
+}
+
+function pushRecent(q: string) {
+ const trimmed = q.trim()
+ if (!trimmed) return
+ try {
+ const cur = readRecent()
+ const next = [trimmed, ...cur.filter(s => s.toLowerCase() !== trimmed.toLowerCase())].slice(0, RECENT_MAX)
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next))
+ } catch { /* noop */ }
+}
+
+function clearRecent() {
+ try { localStorage.removeItem(RECENT_KEY) } catch { /* noop */ }
+}
+
+export default function SearchPage() {
+ const [query, setQuery] = useState('')
+ const [debounced, setDebounced] = useState('')
+ const [recents, setRecents] = useState(() => readRecent())
+ const inputRef = useRef(null)
+ const navigate = useNavigate()
+
+ const fuzzy = useFuzzyLibrarySearch(query)
+ const tmdb = useTmdbSearch(debounced)
+ const libraryByTmdb = useLibraryByTmdbId()
+ const jfLoading = fuzzy.isLoading
+ const jfFetching = fuzzy.isFetching
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ useEffect(() => {
+ const t = setTimeout(() => setDebounced(query), 220)
+ return () => clearTimeout(t)
+ }, [query])
+
+ // Persist successful searches once results land. Avoids polluting the
+ // recents list with mid-typing fragments by waiting for a stable query.
+ useEffect(() => {
+ if (!debounced) return
+ if (debounced.length < 2) return
+ if (jfLoading || jfFetching) return
+ pushRecent(debounced)
+ setRecents(readRecent())
+ }, [debounced, jfLoading, jfFetching])
+
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ if (query) setQuery('')
+ else navigate(-1)
+ }
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [navigate, query])
+
+ const isSearching = !!query && (jfLoading || tmdb.isLoading) && !fuzzy.catalogReady
+ const hasQuery = !!query.trim()
+
+ const grouped = useMemo(() => {
+ const out: Record = {}
+ for (const it of fuzzy.catalogResults) {
+ const t = (it.Type || 'Other') as string
+ if (!out[t]) out[t] = []
+ out[t].push(it)
+ }
+ for (const it of fuzzy.episodesAndTracks) {
+ const t = (it.Type || 'Other') as string
+ if (!out[t]) out[t] = []
+ out[t].push(it)
+ }
+ return out
+ }, [fuzzy.catalogResults, fuzzy.episodesAndTracks])
+
+ const tmdbResults = tmdb.data?.results || []
+ const tmdbPeople = tmdbResults.filter(r => r.media_type === 'person').slice(0, 8)
+ const tmdbMovies = tmdbResults.filter(r => r.media_type === 'movie')
+ const tmdbTv = tmdbResults.filter(r => r.media_type === 'tv')
+
+ const tmdbMappedMovies = useMemo(
+ () => mapTmdbToJf(tmdbMovies, libraryByTmdb.data),
+ [tmdbMovies, libraryByTmdb.data],
+ )
+ const tmdbMappedTv = useMemo(
+ () => mapTmdbToJf(tmdbTv, libraryByTmdb.data),
+ [tmdbTv, libraryByTmdb.data],
+ )
+
+ // Local items first (full BaseItemDto with ImageTags + ProviderIds).
+ // TMDB extras filtered against local TMDB ids so library hits don't
+ // duplicate. Anything in the library overrides its TMDB twin.
+ const localMovies = (grouped.Movie || []) as BaseItemDto[]
+ const localSeries = (grouped.Series || []) as BaseItemDto[]
+ const localTmdbIds = new Set()
+ for (const it of [...localMovies, ...localSeries]) {
+ const t = (it as any).ProviderIds?.Tmdb
+ if (t) localTmdbIds.add(String(t))
+ }
+ const tmdbExtras = [...tmdbMappedMovies, ...tmdbMappedTv].filter(it => {
+ const t = (it as any).ProviderIds?.Tmdb
+ return !!t && !localTmdbIds.has(String(t))
+ })
+
+ const localCards: BaseItemDto[] = [...localMovies, ...localSeries].map(
+ it => ({ ...(it as any), _inLibrary: true } as BaseItemDto),
+ )
+ const mixedCards: BaseItemDto[] = [...localCards, ...tmdbExtras]
+
+ const episodes = (grouped.Episode || []) as BaseItemDto[]
+ const albums = (grouped.MusicAlbum || []) as BaseItemDto[]
+ const artists = (grouped.MusicArtist || []) as BaseItemDto[]
+ const tracks = (grouped.Audio || []) as BaseItemDto[]
+
+ const totalLocal =
+ localMovies.length + localSeries.length + episodes.length + albums.length + artists.length + tracks.length
+ const noResults =
+ hasQuery &&
+ !isSearching &&
+ totalLocal === 0 &&
+ tmdbPeople.length === 0 &&
+ tmdbExtras.length === 0
+
+ return (
+
+ {/* Sticky search input - fully opaque + isolation: isolate + a high
+ z so cards (which may have transform/perspective stacking) cannot
+ render on top during scroll. */}
+
+
+
+
setQuery(e.target.value)}
+ placeholder="Search movies, shows, episodes, people, music..."
+ className="w-full h-12 pl-12 pr-12 bg-elevated/60 hover:bg-elevated rounded-xl text-[15px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/60 focus:ring-2 focus:ring-accent/20 focus:bg-elevated transition-all duration-200"
+ aria-label="Search"
+ />
+
+ {isSearching && (
+
+ )}
+ {query && !isSearching && (
+
+ )}
+
+ esc
+
+
+
+
+
+ {/* Body */}
+
+
+ {!hasQuery && (
+ { setQuery(s); inputRef.current?.focus() }}
+ onClearRecents={() => { clearRecent(); setRecents([]) }}
+ />
+ )}
+
+ {hasQuery && isSearching && }
+
+ {hasQuery && noResults && }
+
+ {hasQuery && !isSearching && !noResults && (
+
+ {tmdbPeople.length > 0 && }
+
+ {mixedCards.length > 0 && (
+
+ )}
+
+ {episodes.length > 0 && }
+
+ {(albums.length > 0 || artists.length > 0 || tracks.length > 0) && (
+
+ )}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..fc6f40b
--- /dev/null
+++ b/src/pages/SettingsPage.tsx
@@ -0,0 +1,314 @@
+import { useEffect, useLayoutEffect, useMemo, useState, type ComponentType } from 'react'
+import { motion } from 'framer-motion'
+import {
+ Server,
+ Play as PlayIcon,
+ Key,
+ Info,
+ Globe,
+ Lock,
+ Palette,
+ Volume2,
+ Database,
+ Home,
+ ListDetails,
+ Tv,
+ User,
+ Search,
+ Hash,
+ Activity,
+ X,
+} from '../lib/icons'
+import { getStoredAuth } from '../api/jellyfin'
+import { usePreferencesStore } from '../stores/preferences-store'
+import { SettingsSearchContext, type SettingsSearchValue } from './settings/_ui'
+import { ServerSection } from './settings/sections/Server'
+import { ServersSection } from './settings/sections/Servers'
+import { ServerDashboardSection } from './settings/sections/ServerDashboard'
+import { PlaybackSection } from './settings/sections/Playback'
+import { AudioSection } from './settings/sections/Audio'
+import { DisplaySection } from './settings/sections/Display'
+import { HomePageSection } from './settings/sections/Home'
+import { DetailPageSection } from './settings/sections/Detail'
+import { EpisodesSection } from './settings/sections/Episodes'
+import { DiscoverySection } from './settings/sections/Discovery'
+import { TmdbSection } from './settings/sections/Tmdb'
+import { FanartSection } from './settings/sections/Fanart'
+import { TraktSection } from './settings/sections/Trakt'
+import { ArrSection } from './settings/sections/Arr'
+import { PersonalSettingsSection } from './settings/sections/Personal'
+import { PrivacySection } from './settings/sections/Privacy'
+import { AboutSection } from './settings/sections/About'
+import { ShortcutsSection } from './settings/sections/Shortcuts'
+
+/* ──────────────────────────────────────────────────────────── */
+/* Categories - drive the left-rail nav and section anchors */
+/* ──────────────────────────────────────────────────────────── */
+
+interface Category {
+ id: string
+ icon: ComponentType<{ size?: number; stroke?: number; className?: string }>
+ label: string
+}
+
+interface CategoryGroup {
+ label: string
+ items: Category[]
+}
+
+const CATEGORY_GROUPS: CategoryGroup[] = [
+ {
+ label: 'Account',
+ items: [
+ { id: 'server', icon: Server, label: 'Server' },
+ { id: 'servers', icon: Server, label: 'All servers' },
+ { id: 'server-dashboard', icon: Activity, label: 'Dashboard' },
+ ],
+ },
+ {
+ label: 'Playback',
+ items: [
+ { id: 'playback', icon: PlayIcon, label: 'Playback' },
+ { id: 'audio', icon: Volume2, label: 'Audio & Subtitles' },
+ { id: 'shortcuts', icon: Hash, label: 'Shortcuts' },
+ ],
+ },
+ {
+ label: 'Appearance',
+ items: [
+ { id: 'display', icon: Palette, label: 'Display' },
+ { id: 'home-page', icon: Home, label: 'Home page' },
+ { id: 'detail-page', icon: ListDetails, label: 'Detail page' },
+ { id: 'episodes', icon: Tv, label: 'Episodes' },
+ ],
+ },
+ {
+ label: 'Discovery',
+ items: [
+ { id: 'discovery', icon: Globe, label: 'Region & content' },
+ { id: 'tmdb', icon: Key, label: 'TMDB' },
+ { id: 'fanart', icon: Palette, label: 'Fanart.tv' },
+ { id: 'trakt', icon: Activity, label: 'Trakt.tv' },
+ ],
+ },
+ {
+ label: 'Requests',
+ items: [
+ { id: 'sonarr-radarr', icon: Database, label: 'Sonarr & Radarr' },
+ ],
+ },
+ {
+ label: 'Data',
+ items: [
+ { id: 'personal', icon: User, label: 'Your data' },
+ { id: 'privacy', icon: Lock, label: 'Privacy' },
+ ],
+ },
+ {
+ label: 'About',
+ items: [
+ { id: 'about', icon: Info, label: 'About' },
+ ],
+ },
+]
+
+const CATEGORIES: Category[] = CATEGORY_GROUPS.flatMap(g => g.items)
+
+export default function SettingsPage() {
+ const prefs = usePreferencesStore()
+ const auth = getStoredAuth()
+ const serverHost = (() => {
+ try { return auth?.serverUrl ? new URL(auth.serverUrl).host : '-' } catch { return '-' }
+ })()
+
+ const [active, setActive] = useState('server')
+ const [query, setQuery] = useState('')
+ const trimmed = query.trim().toLowerCase()
+ const search = useMemo(
+ () => ({
+ query: trimmed,
+ matches: (haystack: string) => {
+ if (!trimmed) return true
+ return haystack.toLowerCase().includes(trimmed)
+ },
+ }),
+ [trimmed],
+ )
+
+ // Track the section closest to the top of the scroll viewport so the
+ // nav highlights what the user is currently reading.
+ useEffect(() => {
+ const root = document.querySelector('main.content-scroll') as HTMLElement | null
+ if (!root) return
+ const obs = new IntersectionObserver(
+ entries => {
+ const visible = entries
+ .filter(e => e.isIntersecting)
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
+ if (visible.length > 0) setActive(visible[0].target.id)
+ },
+ {
+ root,
+ rootMargin: '-15% 0px -65% 0px',
+ threshold: 0,
+ },
+ )
+ CATEGORIES.forEach(c => {
+ const el = document.getElementById(c.id)
+ if (el) obs.observe(el)
+ })
+ return () => obs.disconnect()
+ }, [])
+
+ function scrollTo(id: string) {
+ const el = document.getElementById(id)
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+
+ return (
+
+
+
+
+ Preferences
+
+
Settings
+
Customize your Jellyfin experience
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * "No results" message shown when the current search query hides every
+ * row on the page. Uses an effect to count visible rows via DOM rather
+ * than threading state through every row.
+ */
+function SettingsEmptyState({ query }: { query: string }) {
+ const [empty, setEmpty] = useState(false)
+ useLayoutEffect(() => {
+ if (!query) {
+ setEmpty(false)
+ return
+ }
+ const visible = document.querySelectorAll('[data-settings-row]').length
+ setEmpty(visible === 0)
+ }, [query])
+ if (!query || !empty) return null
+ return (
+
+
No settings match "{query}"
+
Try a different keyword, or clear the search to browse everything.
+
+ )
+}
+
+function SectionNav({
+ active,
+ onSelect,
+ query,
+ onQueryChange,
+}: {
+ active: string
+ onSelect: (id: string) => void
+ query: string
+ onQueryChange: (next: string) => void
+}) {
+ return (
+
+ )
+}
diff --git a/src/pages/StatsPage.tsx b/src/pages/StatsPage.tsx
new file mode 100644
index 0000000..f380e67
--- /dev/null
+++ b/src/pages/StatsPage.tsx
@@ -0,0 +1,386 @@
+import { useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import { Clock, Flame, Film, Tv, Award, Activity, CalendarStar } from '../lib/icons'
+import { useLibraryItems } from '../hooks/use-jellyfin'
+import { useDiary } from '../stores/diary-store'
+import { genreBreakdown, totalHoursWatched, longestBinge, watchStreak } from '../lib/watch-stats'
+import {
+ hoursPerGenre,
+ hoursPerStudio,
+ completion,
+ topByPersonRole,
+ totalTimeSavedSeconds,
+} from '../lib/stats'
+import { formatTimeSaved } from '../lib/time-saved'
+
+const CURRENT_YEAR = new Date().getFullYear()
+
+/**
+ * Detailed stats / year-in-watching. Profile page surfaces the at-a-glance
+ * summary; this one digs deeper into hours-weighted breakdowns, top
+ * directors / actors, completion ratios, and the time-saved-by-skipping
+ * total across every series.
+ */
+export default function StatsPage() {
+ const navigate = useNavigate()
+ const { data, isLoading } = useLibraryItems(undefined, {
+ includeItemTypes: ['Movie', 'Series', 'Episode'],
+ sortBy: ['DatePlayed'],
+ sortOrder: ['Descending'],
+ filters: ['IsPlayed'],
+ limit: 2000,
+ includePeople: true,
+ })
+ const items = useMemo(() => data?.Items || [], [data?.Items])
+
+ const yearItems = useMemo(
+ () =>
+ items.filter(it => {
+ const raw = it.UserData?.LastPlayedDate
+ if (!raw) return false
+ return new Date(raw).getFullYear() === CURRENT_YEAR
+ }),
+ [items],
+ )
+
+ const totalHoursAll = useMemo(() => totalHoursWatched(items), [items])
+ const totalHoursYear = useMemo(() => totalHoursWatched(yearItems), [yearItems])
+ const streak = useMemo(() => watchStreak(items), [items])
+ const binge = useMemo(() => longestBinge(items), [items])
+ const genreShare = useMemo(() => genreBreakdown(items).slice(0, 10), [items])
+ const hoursGenre = useMemo(() => hoursPerGenre(items).slice(0, 10), [items])
+ const hoursStudio = useMemo(() => hoursPerStudio(items).slice(0, 8), [items])
+ const comp = useMemo(() => completion(items), [items])
+ const topDirectors = useMemo(
+ () => topByPersonRole(items, (_role, type) => type === 'Director', 8),
+ [items],
+ )
+ const topActors = useMemo(
+ () => topByPersonRole(items, (_role, type) => type === 'Actor', 10),
+ [items],
+ )
+ const timeSaved = useMemo(() => totalTimeSavedSeconds(), [])
+ const diary = useDiary(s => s.entries)
+ const diaryYearCount = useMemo(
+ () =>
+ diary.filter(d => {
+ const t = Date.parse(d.watchedAt)
+ return Number.isFinite(t) && new Date(t).getFullYear() === CURRENT_YEAR
+ }).length,
+ [diary],
+ )
+
+ const moviesCount = useMemo(() => items.filter(i => i.Type === 'Movie').length, [items])
+ const episodesCount = useMemo(() => items.filter(i => i.Type === 'Episode').length, [items])
+
+ return (
+
+
+
+ {isLoading && items.length === 0 && (
+
+
Crunching the numbers...
+
+ )}
+
+ {/* Headline tiles */}
+
+ }
+ hint={`${totalHoursAll.toFixed(0)} all-time`}
+ accent
+ />
+ }
+ />
+ }
+ />
+ }
+ hint={timeSaved.series > 0 ? `across ${timeSaved.series} ${timeSaved.series === 1 ? 'show' : 'shows'}` : 'no skips yet'}
+ />
+ }
+ hint={streak.longest > streak.current ? `Best: ${streak.longest}` : 'Keep going'}
+ />
+ }
+ />
+
+
+ {/* Two-column: hours per genre + hours per studio */}
+
+
+
+ {hoursGenre.map((g, i) => (
+
+ ))}
+
+
+
+
+ {hoursStudio.map((s, i) => (
+
+ ))}
+
+
+
+
+ {/* Completion ratios */}
+
+
+
+
+
+
+
+ {(comp.completionRate * 100).toFixed(1)}% of tracked items reached the finish line.
+
+
+
+ {/* People */}
+
+
+ navigate(`/search?q=${encodeURIComponent(n)}`)} />
+
+
+ navigate(`/search?q=${encodeURIComponent(n)}`)} />
+
+
+
+ {/* Side note: longest binge + breakdown */}
+
+
+ {binge.count > 0 ? (
+
+
+ {binge.count} items in one day
+
+
+ On {binge.day ? new Date(binge.day + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : 'unknown'}.
+
+
+ ) : (
+ No watched items yet.
+ )}
+
+
+
+ {genreShare.map((g, i) => (
+
+ ))}
+
+
+
+
+ {/* Time saved breakdown */}
+
+ {timeSaved.total > 0 ? (
+
+ } />
+
+
+
+ ) : (
+ No auto-skips recorded yet. Turn intro / credits skipping on in Playback settings.
+ )}
+
+
+ )
+}
+
+function StatTile({
+ label,
+ value,
+ unit,
+ hint,
+ icon,
+ accent,
+}: {
+ label: string
+ value: string
+ unit?: string
+ hint?: string
+ icon?: React.ReactNode
+ accent?: boolean
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+
+ {value}
+
+ {unit && {unit}}
+
+ {hint && {hint}
}
+
+ )
+}
+
+function Section({
+ title,
+ children,
+ empty,
+ className,
+}: {
+ title: string
+ children: React.ReactNode
+ empty?: string | null
+ className?: string
+}) {
+ return (
+
+ {title}
+ {empty ? {empty}
: children}
+
+ )
+}
+
+function Bar({
+ label,
+ share,
+ index,
+ suffix,
+}: {
+ label: string
+ share: number
+ index: number
+ suffix?: string
+}) {
+ const pct = Math.max(2, Math.round(share * 100))
+ return (
+
+
+ {label}
+
+
+
+
+
+ {suffix ?? `${pct}%`}
+
+
+ )
+}
+
+function CompletionTile({
+ label,
+ value,
+ total,
+ accent,
+}: {
+ label: string
+ value: number
+ total: number
+ accent?: boolean
+}) {
+ const pct = total > 0 ? Math.round((value / total) * 100) : 0
+ return (
+
+
{label}
+
+ {value.toLocaleString()}
+
+
{pct}%
+
+ )
+}
+
+function PersonList({
+ rows,
+ onClick,
+}: {
+ rows: { name: string; count: number }[]
+ onClick: (name: string) => void
+}) {
+ return (
+
+ {rows.map((p, i) => (
+ -
+
+
+ ))}
+
+ )
+}
+
+function BadgeTile({
+ label,
+ value,
+ icon,
+}: {
+ label: string
+ value: string
+ icon?: React.ReactNode
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ )
+}
diff --git a/src/pages/TmdbDetailPage.tsx b/src/pages/TmdbDetailPage.tsx
new file mode 100644
index 0000000..4b3d94f
--- /dev/null
+++ b/src/pages/TmdbDetailPage.tsx
@@ -0,0 +1,489 @@
+import { useMemo, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { motion } from 'framer-motion'
+import {
+ ArrowLeft, Clock, Star, Calendar, ExternalLink, Globe, Library,
+} from '../lib/icons'
+import { useTmdbDetailEnrichment } from '../hooks/use-tmdb-detail'
+import { useLibraryByTmdbId } from '../hooks/use-jellyfin'
+import { useFanartMovie, useFanartTv } from '../hooks/use-external'
+import { getTmdbImageUrl, pickTmdbLogo } from '../api/tmdb'
+import { pickBestFanartImage } from '../api/fanart'
+import { mapTmdbToJf } from '../lib/tmdb-mapping'
+import ContentRow from '../components/ui/ContentRow'
+import CrewGrid from '../components/detail/CrewGrid'
+import ComposerBlock from '../components/detail/ComposerBlock'
+import VideosSection from '../components/detail/VideosSection'
+import AwardsBlock from '../components/detail/AwardsBlock'
+import ProductionTrivia from '../components/detail/ProductionTrivia'
+import FilmingLocationsMap from '../components/detail/FilmingLocationsMap'
+import CollectionStrip from '../components/detail/CollectionStrip'
+import ReadingMode from '../components/detail/ReadingMode'
+import DetailStickyBar from '../components/detail/DetailStickyBar'
+import { usePastSentinel } from '../hooks/use-past-sentinel'
+import RequestButton from '../components/request/RequestButton'
+import AvailabilityChip from '../components/ui/AvailabilityChip'
+import HorizontalScroller from '../components/ui/HorizontalScroller'
+
+interface Props {
+ tmdbId: number
+ kind: 'movie' | 'tv'
+}
+
+/**
+ * Detail page for items the user does NOT have in their library. We
+ * render purely from TMDB metadata so users can browse missing canon,
+ * recommendations, awards-row picks, etc. without hitting Jellyfin.
+ *
+ * Mirrors the local DetailPage's chrome where it makes sense (cast,
+ * crew, videos, awards, trivia) but drops sections that need a local
+ * file (Play, Resume, Personal, Diary, Episodes list). The primary
+ * action becomes Request when *arr is configured.
+ */
+export default function TmdbDetailPage({ tmdbId, kind }: Props) {
+ const navigate = useNavigate()
+ const { sentinelRef: stickySentinelRef, past: pastHero } = usePastSentinel()
+ const enrichment = useTmdbDetailEnrichment({ kind, tmdbId })
+ const { data, isLoading, collection: tmdbCollection, awards: awardsQuery, locations: locationsQuery, wikiProduction: wikiProductionQuery } = enrichment
+
+ const libraryByTmdbId = useLibraryByTmdbId()
+
+ // Logo source priority: fanart.tv (cleaner art, typically transparent
+ // PNG wordmarks) → TMDB logos. Mirrors the library DetailHero's chain
+ // (jellyfin → fanart) but without the jellyfin source since this page
+ // exists precisely for items not in the library.
+ const tvdbId = (enrichment.data as any)?.external_ids?.tvdb_id
+ const fanartMovieQuery = useFanartMovie(kind === 'movie' ? String(tmdbId) : null)
+ const fanartTvQuery = useFanartTv(kind === 'tv' && tvdbId ? String(tvdbId) : null)
+ const fanartLogo = pickBestFanartImage(
+ kind === 'movie'
+ ? fanartMovieQuery.data?.hdmovielogo || fanartMovieQuery.data?.movielogo
+ : fanartTvQuery.data?.hdtvlogo || fanartTvQuery.data?.clearlogo,
+ )
+ const tmdbLogo = pickTmdbLogo(enrichment.data?.images?.logos)
+ const logoUrl =
+ fanartLogo?.url ||
+ (tmdbLogo ? getTmdbImageUrl(tmdbLogo.file_path, 'w500') : null)
+
+ const [readingModeOpen, setReadingModeOpen] = useState(false)
+
+ // Library cross-reference: did the item get pulled into Jellyfin since
+ // we landed on this page? If so, offer a "View in your library" button.
+ const matchedLocal = libraryByTmdbId.data?.get(String(tmdbId)) || null
+
+ // Recommendations + similar rows (cross-referenced with library so
+ // already-owned items get the In Library indicator).
+ const recommendations = useMemo(() => {
+ const list = data?.recommendations?.results || []
+ return mapTmdbToJf(list.slice(0, 18), libraryByTmdbId.data)
+ }, [data, libraryByTmdbId.data])
+ const similar = useMemo(() => {
+ const list = data?.similar?.results || []
+ return mapTmdbToJf(list.slice(0, 18), libraryByTmdbId.data)
+ }, [data, libraryByTmdbId.data])
+
+ if (isLoading) {
+ return
+ }
+ if (!data) {
+ return (
+
+
No TMDB record
+
+ We couldn't load metadata for this title. It may have been removed from TMDB.
+
+
+
+ )
+ }
+
+ const title = data.title || data.name || ''
+ const year = (data.release_date || data.first_air_date || '').slice(0, 4)
+ const runtime = kind === 'movie'
+ ? data.runtime
+ : data.episode_run_time?.[0]
+ const overview = data.overview || ''
+ const tagline = data.tagline
+ const genres = data.genres || []
+ const community = data.vote_average
+ const certification = (() => {
+ if (kind === 'movie') {
+ const us = data.release_dates?.results?.find(r => r.iso_3166_1 === 'US')
+ return us?.release_dates?.find(d => d.certification)?.certification
+ }
+ return data.content_ratings?.results?.find(r => r.iso_3166_1 === 'US')?.rating
+ })()
+ const credits = data.credits
+ const cast = (credits?.cast || []).slice(0, 18)
+ const crew = credits?.crew || []
+ const backdrop = data.backdrop_path
+ ? getTmdbImageUrl(data.backdrop_path, 'original')
+ : null
+ const poster = data.poster_path
+ ? getTmdbImageUrl(data.poster_path, 'w500')
+ : null
+ const tmdbWatchUrl = `https://www.themoviedb.org/${kind === 'movie' ? 'movie' : 'tv'}/${tmdbId}`
+ const keywords = (data.keywords?.keywords || data.keywords?.results || []) as Array<{ id: number; name: string }>
+
+ // If the item was pulled into the library, route Play to the local
+ // playback URL; otherwise the sticky bar's Play button has nothing
+ // to do, so we hide it by passing an empty url and a no-op resume.
+ const playUrl = matchedLocal ? `/play/${matchedLocal.id}` : ''
+ const tmdbImdbId = (data as any).external_ids?.imdb_id || null
+
+ return (
+
+ {matchedLocal && (
+
+ )}
+ {/* Hero */}
+
+ {backdrop && (
+
+ )}
+
+
+
+
+ {poster && (
+
+ )}
+
+
+
+
+ Discovery
+
+
+ {matchedLocal && (
+
+ )}
+
+
+ {logoUrl ? (
+
+

{
+ const el = e.target as HTMLImageElement
+ el.style.display = 'none'
+ // Reveal the text fallback if the image fails to load.
+ const fallback = el.nextElementSibling as HTMLElement | null
+ if (fallback) fallback.style.display = 'block'
+ }}
+ />
+
+ {title}
+
+
+ ) : (
+
+ {title}
+
+ )}
+
+ {tagline && (
+
+ "{tagline}"
+
+ )}
+
+
+ {year && {year}}
+ {certification && (
+ <>
+
+
+ {certification}
+
+ >
+ )}
+ {runtime != null && runtime > 0 && (
+ <>
+
+
+
+ {runtime}m
+
+ >
+ )}
+ {community != null && community > 0 && (
+ <>
+
+
+
+ {Number(community).toFixed(1)}
+
+ >
+ )}
+ {kind === 'tv' && (data as any).number_of_seasons > 0 && (
+ <>
+
+
+
+ {(data as any).number_of_seasons} season{(data as any).number_of_seasons === 1 ? '' : 's'}
+
+ >
+ )}
+
+
+ {genres.length > 0 && (
+
+ {genres.slice(0, 5).map(g => (
+
+ {g.name}
+
+ ))}
+
+ )}
+
+ {overview && (
+
+ {overview}
+
+ )}
+
+
+
+ {matchedLocal && (
+
+ )}
+
+ Open on TMDB
+
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {overview && (
+
+
+ {overview}
+
+ {overview.length > 600 && (
+
+ )}
+
+ )}
+
setReadingModeOpen(false)}
+ title={title}
+ body={overview}
+ />
+
+ {cast.length > 0 && (
+
+ )}
+
+ {crew.length > 0 && (
+
+ )}
+
+ {((data as any).videos?.results?.length ?? 0) > 0 && (
+
+ )}
+
+ {(awardsQuery.data?.length ?? 0) > 0 && (
+
+ )}
+
+ {(wikiProductionQuery.data?.extract || (keywords.length > 0 && wikiProductionQuery.data)) && (
+
+ )}
+
+ {(locationsQuery.data?.length ?? 0) > 0 && (
+
+ )}
+
+ {kind === 'movie' && (data as any).belongs_to_collection && (
+
+ )}
+
+
+ {/* Recommendations / similar */}
+ {recommendations.length > 0 && (
+
+
+
+ )}
+ {similar.length > 0 && (
+
+ )}
+
+ )
+}
+
+function Section({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ )
+}
+
+function CastStrip({ cast }: { cast: any[] }) {
+ const navigate = useNavigate()
+ return (
+
+
+ {cast.map(c => (
+
+ ))}
+
+
+ )
+}
+
+function Skeleton() {
+ return (
+
+ )
+}
+
+function Dot() {
+ return ·
+}