Files
jellybloom/src/pages/PlayerPage.tsx
T
2026-06-06 22:21:48 +03:00

1770 lines
73 KiB
TypeScript

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<MediaPlayerInstance>(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<number | null>(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<number | null>(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<number | null>(null)
const [streamAudioIndex, setStreamAudioIndex] = useState<number | null>(null)
const [subtitleIndex, setSubtitleIndex] = useState<number | null>(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<string | null>(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<string>('auto')
const [maxBitrate, setMaxBitrate] = useState<number | undefined>(undefined)
const [skipPromptSeriesId, setSkipPromptSeriesId] = useState<string | null>(null)
const areYouStillWatching = usePreferencesStore(s => s.areYouStillWatching)
const autoAdvanceCountRef = useRef(0)
const [stillWatchingOpen, setStillWatchingOpen] = useState(false)
const stillWatchingTargetRef = useRef<string | null>(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<string | null>(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<string | null>(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 <video> so the decoder stops.
* 2. Detach src so the MediaSource gets disconnected.
* 3. Call load() so the element resets and any pending buffer
* operations abort.
* 4. Detach the audio graph (closes AudioContext).
*
* Wrapped in try/catch since vidstack may have already cleaned the
* element by the time this runs. */
// Capture the underlying <video> element in a ref so the unmount
// teardown can clean up THIS component's video without accidentally
// grabbing the next episode's element (which React may have already
// committed to the DOM before our cleanup runs).
const videoElRef = useRef<HTMLVideoElement | null>(null)
useEffect(() => {
const el = (playerRef.current as { el?: HTMLElement } | null)?.el
videoElRef.current = el?.querySelector('video') as HTMLVideoElement | null ?? null
}, [streamUrl])
useEffect(() => {
return () => {
const video = videoElRef.current
if (video) {
try {
video.pause()
video.removeAttribute('src')
video.load()
} catch { /* element already gone */ }
}
detachAudioGraph()
}
}, [])
/* Picture filters - apply CSS `filter` to the underlying <video> element.
* Drives off prefs directly so a refresh restores the user's tuning. */
const { videoBrightness, videoContrast, videoSaturation } = usePlayerPictureFilter(playerRef, streamUrl)
/* Subscribe to vidstack's reactive state. Going through .subscribe() means
* we get duration, currentTime, paused, muted, volume, buffered, and seeking
* as a single coherent snapshot - safer than mixing event handlers where
* each event's detail shape varies between vidstack versions. */
useEffect(() => {
const player = playerRef.current
if (!player || !streamUrl) return
// Volume restore - vidstack's high-level player.volume setter sometimes
// no-ops during early init / source-swap, so we also write directly to
// the underlying <video> element. Both paths are idempotent. We retry
// on every meaningful state tick until the native volume actually
// matches the pref - that's the only reliable signal.
function getNativeVideo(): HTMLVideoElement | null {
const el = (player as { el?: HTMLElement } | null)?.el
return (el?.querySelector('video') as HTMLVideoElement | null) || null
}
function applyVolume() {
const target = usePreferencesStore.getState().playerVolume
if (!Number.isFinite(target)) return
const clamped = Math.max(0, Math.min(1, target))
try { player!.volume = clamped } catch { /* not ready */ }
const video = getNativeVideo()
if (video) {
try { video.volume = clamped } catch { /* readonly during init */ }
}
}
applyVolume()
const unsub = player.subscribe(state => {
// Volume restore - only retry if the native volume hasn't converged
// yet AND the player is actually ready. Avoids writing on every tick.
const target = usePreferencesStore.getState().playerVolume
const video = getNativeVideo()
if (video && Math.abs(video.volume - target) > 0.001) {
if (state.canPlay || state.duration > 0) applyVolume()
}
if (Number.isFinite(state.duration) && state.duration > 0) {
setDuration(state.duration)
}
if (Number.isFinite(state.currentTime)) {
// Always update the ref at full rate for imperative code
currentTimeRef.current = state.currentTime
progressRef.current = state.currentTime * 10000000
// Throttle React state updates to ~4 Hz to avoid re-rendering
// the entire player tree on every video frame
const now = performance.now()
if (now - lastUiUpdateRef.current >= 250) {
lastUiUpdateRef.current = now
setCurrentTime(state.currentTime)
}
// A-B loop check runs on every tick (needs frame accuracy)
const rt = usePlayerRuntimeStore.getState()
if (
rt.loopA != null &&
rt.loopB != null &&
rt.loopB > rt.loopA &&
!state.seeking &&
state.currentTime >= rt.loopB
) {
const player = playerRef.current
if (player) player.currentTime = rt.loopA
}
}
if (state.paused !== isPausedRef.current) {
if (useSyncPlay.getState().active && !shouldSuppressRemoteEcho()) {
if (state.paused) spPause().catch(() => {})
else spUnpause().catch(() => {})
}
}
setIsPaused(state.paused)
setIsMuted(state.muted)
const ranges: any = state.buffered
if (ranges && ranges.length > 0) {
try {
bufferedRef.current = ranges.end(ranges.length - 1)
// Buffered changes rarely - update state alongside currentTime throttle
const now = performance.now()
if (now - lastUiUpdateRef.current >= 250) {
setBuffered(bufferedRef.current)
}
} catch {
/* TimeRanges can throw if the index moved */
}
}
setSeeking(Boolean(state.seeking || state.waiting))
})
// Final safety net - if the player stalls before firing another state
// event after canPlay, retry once at 250ms. Idempotent.
const safetyTimer = setTimeout(applyVolume, 250)
return () => {
clearTimeout(safetyTimer)
unsub()
}
}, [streamUrl])
/* Vidstack handles autoplay via the autoPlay prop - we don't kick play()
* ourselves because doing so before the can-play event throws "media not
* ready". The autoplay-blocked case is handled by onAutoPlayFail below. */
/* Detect intro / credits ranges. Two sources:
* 1. Jellyfin MediaSegments API (the modern source the official client
* uses - populated by the segment-detection backend or a plugin).
* 2. Chapters with names matching "Intro" / "Opening" / "Credits" /
* "Outro" - older fallback for items the segments backend never
* processed.
* MediaSegments take precedence when available.
*/
type Marker = { type: 'intro' | 'credits'; startSec: number; endSec: number }
const { data: mediaSegments } = useMediaSegments(id)
const markers: Marker[] = (() => {
const out: Marker[] = []
// Primary: MediaSegments
for (const seg of mediaSegments || []) {
const t = seg.Type
let type: 'intro' | 'credits' | null = null
if (t === 'Intro') type = 'intro'
else if (t === 'Outro') type = 'credits'
if (!type) continue
const startSec = ticksToSeconds(Number(seg.StartTicks ?? 0))
const endSec = ticksToSeconds(Number(seg.EndTicks ?? 0))
if (endSec > startSec) out.push({ type, startSec, endSec })
}
// Fallback: chapters with intro/credits names
if (out.length === 0) {
const chapters = (item?.Chapters || []) as { Name?: string; StartPositionTicks?: number }[]
if (chapters.length && duration) {
for (let i = 0; i < chapters.length; i++) {
const c = chapters[i]
const name = (c.Name || '').toLowerCase()
if (/intro end|credits end|outro end/.test(name)) continue
let type: 'intro' | 'credits' | null = null
if (/^intro\b|opening/.test(name)) type = 'intro'
else if (/credits|outro/.test(name)) type = 'credits'
if (!type) continue
const startSec = ticksToSeconds(c.StartPositionTicks)
const endNamed = chapters
.slice(i + 1)
.find(x => (type === 'intro' ? /intro end/ : /credits end|outro end/).test((x.Name || '').toLowerCase()))
let endSec: number
if (endNamed) endSec = ticksToSeconds(endNamed.StartPositionTicks)
else if (i + 1 < chapters.length) endSec = ticksToSeconds(chapters[i + 1].StartPositionTicks)
else endSec = duration
if (endSec > startSec) out.push({ type, startSec, endSec })
}
}
}
return out
})()
const currentMarker: Marker | undefined = markers.find(
m => currentTime >= m.startSec && currentTime < m.endSec - 0.5,
)
const currentMarkerType = currentMarker?.type
const currentMarkerStartSec = currentMarker?.startSec
const currentMarkerEndSec = currentMarker?.endSec
/* Auto-skip when entering a marker AND the corresponding pref is on.
* Also accumulate the skipped seconds into the per-series tally so the
* detail page can surface a "you saved Xh Ym" badge. */
useEffect(() => {
if (!currentMarkerType || currentMarkerEndSec == null || !playerRef.current) return
const p = playerRef.current
const from = p.currentTime
if (currentMarkerType === 'intro' && skipIntros) {
p.currentTime = currentMarkerEndSec
if (seriesId) recordSkippedSeconds(seriesId, 'intro', currentMarkerEndSec - from)
} else if (currentMarkerType === 'credits' && skipCredits && duration > 0) {
const target = Math.max(currentMarkerEndSec, duration - 0.5)
p.currentTime = target
if (seriesId) recordSkippedSeconds(seriesId, 'credits', target - from)
}
}, [currentMarkerType, currentMarkerStartSec, currentMarkerEndSec, skipIntros, skipCredits, duration, seriesId])
/* Imperatively switch the active subtitle track. We use 'hidden' rather
* than 'showing' so the browser doesn't paint its own caption UI over our
* custom overlay - 'hidden' still loads cues and populates activeCues.
*
* We set mode on BOTH lists: vidstack's wrapped TextTrackList (drives the
* vidstack renderer state) AND the browser's HTMLMediaElement.textTracks
* (which actually loads and parses the VTT file). They aren't always in
* sync, and setting only vidstack's list leaves the browser track disabled
* with empty activeCues. */
useEffect(() => {
const player = playerRef.current
if (!player) return
const apply = () => {
const targetId = subtitleIndex != null ? `sub-${subtitleIndex}` : null
// 1. Vidstack's list
const vsTracks: any = player.textTracks
if (vsTracks) {
const vsList: any[] =
typeof vsTracks.toArray === 'function' ? vsTracks.toArray() : Array.from(vsTracks)
for (const tr of vsList) {
if (tr.kind !== 'subtitles' && tr.kind !== 'captions') continue
const want = tr.id === targetId ? 'hidden' : 'disabled'
if (typeof tr.setMode === 'function') tr.setMode(want)
else tr.mode = want
}
}
// 2. Browser's native list on the underlying <video>
const el = (player as { el?: HTMLElement } | null)?.el
const video = el?.querySelector?.('video') as HTMLVideoElement | null
if (video?.textTracks) {
for (let i = 0; i < video.textTracks.length; i++) {
const tr = video.textTracks[i]
if (tr.kind !== 'subtitles' && tr.kind !== 'captions') continue
const want: TextTrackMode = tr.id === targetId ? 'hidden' : 'disabled'
tr.mode = want
}
}
}
apply()
const tt: any = player.textTracks
if (tt?.addEventListener) {
tt.addEventListener('add', apply)
tt.addEventListener('change', apply)
return () => {
tt.removeEventListener('add', apply)
tt.removeEventListener('change', apply)
}
}
}, [subtitleIndex, subtitleStreams.length, id])
/* Honour the subtitle preference whenever the item or pref changes:
* none -> off
* always -> best track matching subtitleLanguage (text > image,
* non-forced > forced); falls back to the server's
* default track if no language match exists
* default -> a track matching subtitleLanguage if present, else
* the server-marked default, else off
*
* Depends on the source id so it re-runs when a new episode loads -
* the previous deps array missed `item` and bailed out early while
* data was still streaming in, leaving subtitles off.
*/
useEffect(() => {
if (!item) return
const subs = getSubtitleStreams(item)
if (subs.length === 0 || subtitleMode === 'none') {
setSubtitleIndex(null)
return
}
if (subtitleMode === 'always') {
const match = pickSubtitle(subs, subtitleLanguage) || subs[0]
setSubtitleIndex(match.Index ?? null)
return
}
// default: prefer a language match, then default-flagged, then off
const match = pickSubtitle(subs, subtitleLanguage)
setSubtitleIndex(match?.Index ?? null)
}, [id, mediaSourceId, subtitleStreams.length, subtitleMode, subtitleLanguage, item])
/* ── Playback reporting ──────────────────────────────────── */
const playSessionId = playbackInfo?.PlaySessionId || undefined
const playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode' | undefined =
resolvedSource?.SupportsDirectPlay
? 'DirectPlay'
: resolvedSource?.SupportsDirectStream
? 'DirectStream'
: resolvedSource?.TranscodingUrl
? 'Transcode'
: undefined
usePlaybackReporting({
itemId: id,
mediaSourceId,
startTimeTicks,
playSessionId,
playMethod,
audioIndex,
subtitleIndex,
isPaused,
isMuted,
volume,
progressRef,
})
/* ── Helpers: PiP, screenshot, theater, frame step, etc. ─── */
function togglePictureInPicture() {
const el = (playerRef.current as { el?: HTMLElement } | null)?.el
const video = el?.querySelector('video') as HTMLVideoElement | null
if (!video) return
if (document.pictureInPictureElement === video) {
;document.exitPictureInPicture?.().catch(() => {})
} else {
;video.requestPictureInPicture?.().catch(() => {})
}
}
function toggleTheaterMode() {
usePlayerRuntimeStore.getState().setTheaterMode(!usePlayerRuntimeStore.getState().theaterMode)
}
function takeScreenshot() {
const el = (playerRef.current as { el?: HTMLElement } | null)?.el
const video = el?.querySelector('video') as HTMLVideoElement | null
if (!video) return
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
canvas.toBlob(async blob => {
if (!blob) return
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
showToast('Frame copied')
} catch {
showToast('Clipboard write failed')
}
}, 'image/png')
} catch {
showToast('Screenshot failed')
}
}
function getFps(): number {
const streams = item?.MediaSources?.[0]?.MediaStreams || []
const v = streams.find(s => s.Type === 'Video')
const fps = Number(v?.RealFrameRate ?? v?.AverageFrameRate ?? 0)
return Number.isFinite(fps) && fps > 1 ? fps : 24
}
function stepFrame(direction: 1 | -1) {
const p = playerRef.current
if (!p) return
if (!p.paused) p.pause()
const step = 1 / getFps()
p.currentTime = Math.max(0, (p.currentTime ?? 0) + direction * step)
}
function cycleSpeed(direction: 1 | -1) {
const opts = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
const i = opts.findIndex(o => Math.abs(o - playbackRate) < 0.001)
const next = opts[Math.max(0, Math.min(opts.length - 1, (i < 0 ? 2 : i) + direction))]
applyPlaybackRate(next)
}
function resetSpeed() {
applyPlaybackRate(1)
}
function applyPlaybackRate(rate: number) {
setPlaybackRate(rate)
const p = playerRef.current
if (!p) return
try {
p.playbackRate = rate
} catch { /* not ready */ }
const el = (p as { el?: HTMLElement } | null)?.el
const video = el?.querySelector('video') as HTMLVideoElement | null
if (video) {
video.playbackRate = rate
;video.preservesPitch = preserveAudioPitch
;video.mozPreservesPitch = preserveAudioPitch
;video.webkitPreservesPitch = preserveAudioPitch
}
}
function bumpVolume(delta: number) {
const p = playerRef.current
if (!p) return
const v = Math.max(0, Math.min(1, volume + delta))
setVolume(v)
p.volume = v
}
/* ── Keyboard shortcuts via the registry ──────────────────── */
const shortcutContext: ShortcutContext = {
navigate,
showSeekIndicator,
showControls,
setStreamInfoOpen: (fn) => setStreamInfoOpen(fn),
setIsMuted: (m) => setIsMuted(m),
toggleFullscreen,
togglePictureInPicture,
toggleTheaterMode,
takeScreenshot,
toggleHints: () => setHintsOpen(o => !o),
bumpVolume,
cycleSpeed,
resetSpeed,
bumpSubtitleDelay: (deltaMs) => usePlayerRuntimeStore.getState().bumpSubtitleOffsetMs(deltaMs),
resetSubtitleDelay: () => usePlayerRuntimeStore.getState().setSubtitleOffsetMs(0),
bumpAudioDelay: (deltaMs) => usePlayerRuntimeStore.getState().bumpAudioOffsetMs(deltaMs),
resetAudioDelay: () => usePlayerRuntimeStore.getState().setAudioOffsetMs(0),
setLoopA: () => {
const p = playerRef.current
if (p) usePlayerRuntimeStore.getState().setLoopA(p.currentTime || 0)
},
setLoopB: () => {
const p = playerRef.current
if (p) usePlayerRuntimeStore.getState().setLoopB(p.currentTime || 0)
},
clearLoop: () => usePlayerRuntimeStore.getState().clearLoop(),
addBookmark: () => {
const p = playerRef.current
if (!p || !id) return
const t = p.currentTime ?? 0
bookmarksAdd(id, t)
setBookmarksRefreshKey(k => k + 1)
showToast('Bookmark saved')
},
toggleBookmarksPanel: () => setBookmarksOpen(o => !o),
toggleSubSearch: () => setSubSearchOpen(o => !o),
stepFrame,
prevChapter: () => {
const p = playerRef.current
if (!p || chapters.length === 0) return
const t = p.currentTime ?? 0
const ticks = t * 10_000_000
// Find the chapter whose start is immediately before current time
let prev = chapters[chapters.length - 1]
for (let i = chapters.length - 1; i >= 0; i--) {
const pos = Number(chapters[i].StartPositionTicks ?? 0)
if (pos < ticks - 1_000_000) {
prev = chapters[i]
break
}
}
if (prev) p.currentTime = Number(prev.StartPositionTicks ?? 0) / 10_000_000
},
nextChapter: () => {
const p = playerRef.current
if (!p || chapters.length === 0) return
const t = p.currentTime ?? 0
const ticks = t * 10_000_000
const next = chapters.find(c => Number(c.StartPositionTicks ?? 0) > ticks + 1_000_000)
if (next) p.currentTime = Number(next.StartPositionTicks ?? 0) / 10_000_000
},
}
usePlayerShortcuts(playerRef, shortcutContext)
async function toggleFullscreen() {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen()
setIsFullscreen(true)
} else {
await document.exitFullscreen()
setIsFullscreen(false)
}
} catch {
setIsFullscreen(prev => !prev)
}
}
/* ── Scrubber ──────────────────────────────────────────────── */
// Drag-preview seek: pointer-down starts a "scrubbing" state where the
// hover preview tracks the cursor without committing the seek. The seek
// commits on pointer-up. Hover-only (no pointer pressed) still shows
// the thumbnail preview without affecting playback.
const scrubDraggingRef = useRef(false)
function pctFromEvent(e: React.PointerEvent<HTMLDivElement>): number {
const rect = e.currentTarget.getBoundingClientRect()
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
}
function handleScrubPointer(e: React.PointerEvent<HTMLDivElement>) {
setScrubPercent(pctFromEvent(e))
}
function handleScrubLeave() {
if (!scrubDraggingRef.current) setScrubPercent(null)
}
function handleScrubDown(e: React.PointerEvent<HTMLDivElement>) {
if (e.button !== 0) return
scrubDraggingRef.current = true
e.currentTarget.setPointerCapture?.(e.pointerId)
setScrubPercent(pctFromEvent(e))
}
function handleScrubUp(e: React.PointerEvent<HTMLDivElement>) {
if (!scrubDraggingRef.current) return
scrubDraggingRef.current = false
try { e.currentTarget.releasePointerCapture?.(e.pointerId) } catch { /* noop */ }
const pct = pctFromEvent(e)
const time = pct * duration
if (!Number.isFinite(time)) return
if (playerRef.current) playerRef.current.currentTime = time
currentTimeRef.current = time
setCurrentTime(time)
progressRef.current = time * 10000000
setScrubPercent(null)
}
const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0
const bufferedPercent = duration > 0 ? (buffered / duration) * 100 : 0
function seekToSeconds(t: number) {
if (!Number.isFinite(t)) return
if (playerRef.current) playerRef.current.currentTime = t
currentTimeRef.current = t
setCurrentTime(t)
progressRef.current = t * 10000000
// Mirror to watch party if active (and not echoing a remote command).
if (useSyncPlay.getState().active && !shouldSuppressRemoteEcho()) {
spSeek(Math.round(t * 10_000_000)).catch(() => {})
}
}
/* Track lists from Jellyfin metadata */
const audioTracks = item ? getAudioStreams(item) : []
const videoStream = (item?.MediaSources?.[0]?.MediaStreams || []).find((s: { Type?: string }) => s.Type === 'Video')
const videoWidth = videoStream?.Width || 1920
const videoHeight = videoStream?.Height || 1080
/**
* Pick an audio track. For direct-play sources whose underlying <video>
* exposes more than one AudioTrack, we toggle them via the native API -
* instant switch, no source reload, no audible glitch. For everything
* else we trip a PlaybackInfo re-request which makes the server mux the
* chosen track into a new TranscodingUrl.
*/
function pickAudio(jfIndex: number | null) {
setAudioIndex(jfIndex)
if (jfIndex == null) return
if (resolvedSource?.SupportsDirectPlay) {
const el = (playerRef.current as { el?: HTMLElement } | null)?.el
const video = el?.querySelector('video') as HTMLVideoElement | null
if (!video) return
const native = video.audioTracks as { length: number; [i: number]: { enabled: boolean; language?: string } } | undefined
if (native && native.length > 1) {
const target = audioTracks.find(t => t.Index === jfIndex)
const targetLang = (target?.Language || '').toLowerCase()
let switched = false
for (let i = 0; i < native.length; i++) {
const nt = native[i]
const matches = !!targetLang && (nt.language || '').toLowerCase() === targetLang
nt.enabled = matches
if (matches) switched = true
}
if (switched) return
}
}
// Fallback: server-side audio swap via PlaybackInfo reload
setStreamAudioIndex(jfIndex)
}
const subtitleTracks = item ? getSubtitleStreams(item) : []
const chapters = item?.Chapters || []
return (
<div
className="absolute inset-0 z-fullscreen bg-black"
data-theater={theaterModeOn ? 'true' : undefined}
onMouseMove={showControls}
style={{ cursor: controlsVisible || isPaused ? 'default' : 'none' }}
>
<MediaPlayer
ref={playerRef}
src={streamUrl}
autoPlay
crossOrigin="anonymous"
playsInline
onProviderChange={(provider: MediaProviderAdapter | null) => {
// 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: 120,
maxBufferSize: 30 * 1024 * 1024,
lowLatencyMode: false,
backBufferLength: 15,
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) => {
console.warn('[HLS]', data?.type, data?.details, {
fatal: data?.fatal,
reason: data?.reason,
url: data?.url || data?.frag?.url,
response: data?.response,
})
// Non-fatal errors (buffer stalls, fragment load failures)
// can often be recovered without user intervention.
if (data?.fatal) {
switch (data.type) {
case HLS.ErrorTypes.NETWORK_ERROR:
hls.startLoad()
break
case HLS.ErrorTypes.MEDIA_ERROR:
// fragParsingError ("Found no media") means the
// transcoder returned an empty segment - usually a
// cold-start race. Give it a second and retry from
// the current position before doing the heavy
// recoverMediaError reset (which causes a visible skip).
if (data.details === 'fragParsingError') {
const pos = hls.media?.currentTime
setTimeout(() => {
if (hls.destroyed) return
try {
hls.loadSource(hls.url)
if (pos != null && hls.media) {
hls.media.currentTime = pos
}
hls.startLoad()
} catch { hls.recoverMediaError() }
}, 1000)
} else {
hls.recoverMediaError()
}
break
default:
hls.destroy()
break
}
}
})
}
}
}}
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)
// Apply any deferred seek queued by the resume prompt.
if (p && pendingSeekRef.current != null) {
const target = pendingSeekRef.current
pendingSeekRef.current = null
const mediaEl = (p as any).media as HTMLMediaElement | undefined
const before = mediaEl ? mediaEl.currentTime : p.currentTime
if (before < target - 0.5 || before > target + 0.5) {
try {
if (mediaEl) mediaEl.currentTime = target
else p.currentTime = target
} catch { /* ignore */ }
}
p.play().catch(() => {})
}
}}
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 && nextItem?.Id && nextItem.Id !== item?.Id) {
if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
stillWatchingTargetRef.current = nextItem.Id
setStillWatchingOpen(true)
return
}
autoAdvanceCountRef.current++
navigate(`/play/${nextItem.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> | void
if (r && typeof (r as Promise<void>).catch === 'function') {
;(r as Promise<void>).catch(() => {})
}
} catch {
/* ignored */
}
}}
>
<MediaProvider />
{/* 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 (
<Track
key={String(idx)}
id={`sub-${idx}`}
src={getSubtitleUrl(serverUrl, id, mediaSourceId, idx, token)}
kind="subtitles"
label={t.Title || t.Language || `Track ${idx}`}
lang={t.Language || undefined}
default={idx === subtitleIndex}
/>
)
})}
</MediaPlayer>
{/* 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). */}
<div
className="absolute inset-0 z-10"
onClick={() => {
const p = playerRef.current
if (!p) return
if (p.paused) p.play().catch(() => {})
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 (
<LibAssRenderer
playerRef={playerRef}
subtitleUrl={getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token, codec === 'ssa' ? 'ssa' : 'ass')}
/>
)
}
return (
<SubtitleOverlay
playerRef={playerRef}
subtitleUrl={getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token)}
videoWidth={videoWidth}
videoHeight={videoHeight}
/>
)
})()}
{/* 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 && (
<EpisodesPanel
open={episodesOpen}
onClose={() => setEpisodesOpen(false)}
seriesId={seriesId}
currentItemId={id}
initialSeasonId={item?.SeasonId || undefined}
serverUrl={serverUrl}
/>
)}
{/* Stream info overlay */}
<AnimatePresence>
<StreamInfo item={item} visible={streamInfoOpen} playMethod={playMethod} />
</AnimatePresence>
{/* Are you still watching? - pauses after 3 consecutive auto-advances */}
<AnimatePresence>
{stillWatchingOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 grid place-items-center bg-black/70 backdrop-blur-sm"
onClick={() => {
setStillWatchingOpen(false)
playerRef.current?.play().catch(() => {})
}}
>
<motion.div
initial={{ y: 20, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 20, scale: 0.96 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
className="bg-glass-strong backdrop-blur-2xl border border-white/14 rounded-2xl p-8 text-center shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] w-[min(400px,92vw)]"
>
<p className="text-[14px] text-white font-medium mb-1">Still watching?</p>
<p className="text-[12.5px] text-white/55 mb-5">
Press play to keep going.
</p>
<button
onClick={() => {
setStillWatchingOpen(false)
autoAdvanceCountRef.current = 0
playerRef.current?.play().catch(() => {})
}}
className="inline-flex items-center gap-2 h-11 px-6 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight transition focus-ring"
>
Yes, keep playing
</button>
{stillWatchingTargetRef.current && (
<button
onClick={() => {
setStillWatchingOpen(false)
autoAdvanceCountRef.current = 0
navigate(`/play/${stillWatchingTargetRef.current}`, { replace: true })
}}
className="mt-2 block mx-auto text-[12px] text-white/50 hover:text-white/80 transition"
>
Skip to next anyway
</button>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Skip intro / credits button - shown when inside a detected marker */}
<AnimatePresence>
{currentMarker && (
<motion.button
key={`${currentMarker.type}-${currentMarker.startSec}`}
initial={{ opacity: 0, y: 10, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.96 }}
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
onClick={() => {
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"
>
<RotateCw size={14} stroke={2.25} />
Skip {currentMarker.type === 'intro' ? 'intro' : 'credits'}
</motion.button>
)}
</AnimatePresence>
<PlayerOverlays
upNext={{
item: upNextCard,
visible: !!nextUpVisible,
countdown: upNextCountdown,
onSkip: () => {
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)
const target = pos > 0 ? pos / 10_000_000 : 0
if (typeof window !== 'undefined') {
console.log('[player] resume prompt clicked', {
id,
pos,
target,
mediaElementCurrentTime: (p as any)?.media?.currentTime,
streamUrlStart: streamUrl.includes('StartTimeTicks=') || streamUrl.includes('runtimeTicks='),
})
}
if (p) {
// Vidstack has a known issue (GH #941) where setting
// currentTime on the MediaPlayerInstance for a direct-play
// MP4 source restarts the video from the beginning. The
// workaround is to set currentTime on the underlying
// HTMLMediaElement. For HLS it works either way; we use
// the underlying element for consistency.
pendingSeekRef.current = target
const mediaEl = (p as any).media as HTMLMediaElement | undefined
try {
if (mediaEl) mediaEl.currentTime = target
else p.currentTime = target
} catch { /* ignore */ }
p.play().catch(() => {})
}
},
onRestart: () => {
setResumePromptOpen(false)
const p = playerRef.current
if (p) {
pendingSeekRef.current = 0
const mediaEl = (p as any).media as HTMLMediaElement | undefined
try {
if (mediaEl) mediaEl.currentTime = 0
else p.currentTime = 0
} catch { /* ignore */ }
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. */}
<AnimatePresence>
{transientToast && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.2 }}
className="absolute bottom-32 left-1/2 -translate-x-1/2 z-toast inline-flex items-center h-9 px-4 rounded-full bg-black/85 backdrop-blur-xl border border-white/12 text-[12px] text-white font-medium tracking-tight shadow-lg"
role="status"
>
{transientToast}
</motion.div>
)}
</AnimatePresence>
{/* Loading spinner */}
<AnimatePresence>
{seeking && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 grid place-items-center pointer-events-none"
>
<div className="w-14 h-14 rounded-full bg-black/40 backdrop-blur grid place-items-center">
<div className="w-7 h-7 rounded-full border-2 border-white/20 border-t-accent animate-[spin-soft_0.7s_linear_infinite]" />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Seek indicator (10s skip) */}
<AnimatePresence mode="wait">
{seekIndicator && (
<motion.div
key={seekIndicator.key}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 1.1 }}
transition={{ duration: reducedMotion ? 0.1 : 0.4 }}
className={`absolute top-1/2 -translate-y-1/2 w-32 h-32 rounded-full grid place-items-center bg-black/60 backdrop-blur pointer-events-none ${
seekIndicator.direction === 'forward' ? 'right-[18%]' : 'left-[18%]'
}`}
>
<div className="flex flex-col items-center gap-1">
{seekIndicator.direction === 'forward' ? (
<RotateCw size={28} className="text-white" />
) : (
<RotateCcw size={28} className="text-white" />
)}
<span className="text-[11px] font-semibold text-white tabular-nums">10s</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Controls overlay ─────────────────────────────────── */}
<AnimatePresence>
{controlsVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-0 z-20 flex flex-col justify-between pointer-events-none"
>
<PlayerTopBar
item={item}
playbackRate={playbackRate}
onPlaybackRateChange={applyPlaybackRate}
qualityKey={qualityKey}
onQualitySelect={(q: QualityOption) => {
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) */}
<AnimatePresence>
{isPaused && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 grid place-items-center pointer-events-none"
>
<div className="w-20 h-20 rounded-full bg-black/40 backdrop-blur grid place-items-center ring-1 ring-white/10">
<Play size={28} className="text-white translate-x-1" fill="currentColor" />
</div>
</motion.div>
)}
</AnimatePresence>
<PlayerBottomBar
itemId={id}
item={item}
serverUrl={serverUrl}
token={token}
duration={duration}
currentTime={currentTime}
bufferedPercent={bufferedPercent}
playedPercent={playedPercent}
scrubPercent={scrubPercent}
isPaused={isPaused}
isMuted={isMuted}
isFullscreen={isFullscreen}
volume={volume}
loopA={loopA}
loopB={loopB}
chapters={chapters}
bookmarksRefreshKey={bookmarksRefreshKey}
previousItem={previousItem}
nextItem={nextItem}
itemType={item?.Type}
queueActive={queueActive}
onScrubPointerMove={handleScrubPointer}
onScrubPointerLeave={handleScrubLeave}
onScrubPointerDown={handleScrubDown}
onScrubPointerUp={handleScrubUp}
onChapterJump={seekToSeconds}
onTogglePlay={() => {
const p = playerRef.current
if (p?.paused) p.play().catch(() => {})
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}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)
}