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) // Seek the in-player resume prompt asks for, but only applied on the // next canPlay so the seek survives when the source isn't ready yet. const pendingSeekRef = useRef(null) // 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) // High-frequency playback state. currentTime and buffered update on every // vidstack tick (4-60 Hz) - we store the raw value in a ref for imperative // code (progress reporting, A-B loop, seek calculations) and only push to // React state at ~4 Hz so the UI doesn't re-render on every video frame. const currentTimeRef = useRef(0) const bufferedRef = useRef(0) 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) const lastUiUpdateRef = useRef(0) /* 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 sleepTimerMinutes = usePreferencesStore(s => s.sleepTimerMinutes) 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 sleepRemainingRef = useRef(sleepTimerMinutes * 60) const [sleepRemainingSec, setSleepRemainingSec] = useState(sleepTimerMinutes * 60) useEffect(() => { const sec = sleepTimerMinutes * 60 sleepRemainingRef.current = sec setSleepRemainingSec(sec) }, [sleepTimerMinutes, id]) useEffect(() => { if (sleepTimerMinutes <= 0) return if (!isPaused) { const id = window.setInterval(() => { const next = sleepRemainingRef.current - 1 sleepRemainingRef.current = next setSleepRemainingSec(next) if (next <= 0) { playerRef.current?.pause() window.clearInterval(id) } }, 1000) return () => window.clearInterval(id) } }, [isPaused, sleepTimerMinutes]) const reducedMotion = useReducedMotion() const { controlsVisible, seekIndicator, transientToast, showControls, showSeekIndicator, showToast, } = usePlayerChrome(isPaused) const subtitleStreams = item ? getSubtitleStreams(item) : [] const mediaSourceId = item?.MediaSources?.[0]?.Id || 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 // If the user clicked Resume from a deep link, the item hasn't // returned yet on the first render and startTimeTicks is undefined. // Block the first PlaybackInfo call until the saved position is // known - otherwise the server returns a stream that starts at 0 // and the video buffers before the second query refires. const playbackInfoReady = !resume || startTimeTicks !== undefined if (typeof window !== 'undefined') { console.log('[player] resume state', { id, resume, positionTicks, startTimeTicks, playbackInfoReady, }) } /* 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. // Offline fallback: if this item was downloaded, use the Blob URL // instead of hitting the server. PlaybackInfo and reporting gracefully // no-op when there's no connection. const downloaded = useDownloads(s => s.getByItemId(id || '')) const offlineUrl = downloaded?.status === 'done' ? downloaded.localPath : undefined const { data: playbackInfo } = usePlaybackInfo( id, startTimeTicks, streamAudioIndex ?? undefined, maxBitrate, playbackInfoReady, startTimeTicks !== undefined, ) const resolvedSource = playbackInfo?.MediaSources?.[0] const streamUrl = (() => { if (offlineUrl) return offlineUrl 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 '' })() if (typeof window !== 'undefined' && playbackInfo) { const transcodingUrl = resolvedSource?.TranscodingUrl const supportsDirectPlay = resolvedSource?.SupportsDirectPlay const hasRuntimeTicks = transcodingUrl ? transcodingUrl.includes('runtimeTicks=') : null console.log('[player] stream resolved', { id, startTimeTicks, supportsDirectPlay, transcodingUrlPresent: !!transcodingUrl, transcodingUrlHasRuntimeTicks: hasRuntimeTicks, streamUrl: streamUrl.replace(/api_key=[^&]+/, 'api_key=***'), }) } /* Reset transient flags on item change */ useEffect(() => { setUpNextDismissed(false) setPanel('streamInfo', false) setAudioIndex(null) setStreamAudioIndex(null) setEndCardOpen(false) pendingSeekRef.current = null 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). * Intentionally scoped to item?.Id only - we only want to evaluate * the resume condition once per item, not re-trigger when item data * refreshes or prefs change mid-playback. */ const resumePromptShownRef = useRef(null) const resumeItemId = item?.Id const resumePositionTicks = item?.UserData?.PlaybackPositionTicks useEffect(() => { if (!resumeItemId || resumePromptShownRef.current === resumeItemId) return const pos = Number(resumePositionTicks ?? 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) resumePromptShownRef.current = resumeItemId } }, [resumeItemId, resumePositionTicks, searchParams, showResumePromptPref]) /* 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) }, [item]) /* 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) } }, [item?.Id, recapTrigger.shouldShow, showRecapCardPref, item]) 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 */ } } }, [id, qc]) /* 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