Files
jellybloom/src/pages/PlayerPage.tsx
T

1629 lines
67 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)
// Start paused so the play icon shows until the player actually begins
// playback - if autoplay succeeds, the onPlay handler flips this to false.
const [isPaused, setIsPaused] = useState(true)
const [isMuted, setIsMuted] = useState(false)
// Volume lives in the preferences store so it persists across episodes,
// shows, and sessions. Mute is intentionally per-session (autoplay
// fallback can mute briefly without that polluting the saved volume).
const volume = usePreferencesStore(s => s.playerVolume)
const setVolumePref = usePreferencesStore(s => s.setPreference)
const setVolume = useCallback(
(v: number) => setVolumePref('playerVolume', Math.max(0, Math.min(1, v))),
[setVolumePref],
)
const [isFullscreen, setIsFullscreen] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [buffered, setBuffered] = useState(0)
const [scrubPercent, setScrubPercent] = useState<number | null>(null)
const [seeking, setSeeking] = useState(false)
/* Track selection (audio / subtitle).
* `audioIndex` is the UI selection; `streamAudioIndex` is what we send
* to PlaybackInfo. They diverge when the active source is direct-play
* multi-audio: we switch instantly via HTMLMediaElement.audioTracks
* without triggering a stream reload, but the menu still highlights the
* picked track. For transcoded sources the two stay in sync. */
const [audioIndex, setAudioIndex] = useState<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 [sleepRemainingSec, setSleepRemainingSec] = useState(sleepTimerMinutes * 60)
useEffect(() => {
setSleepRemainingSec(sleepTimerMinutes * 60)
}, [sleepTimerMinutes, id])
useEffect(() => {
if (sleepTimerMinutes <= 0 || sleepRemainingSec <= 0) return
if (!isPaused) {
const id = window.setInterval(() => {
setSleepRemainingSec(prev => {
if (prev <= 1) {
playerRef.current?.pause()
return 0
}
return prev - 1
})
}, 1000)
return () => window.clearInterval(id)
}
}, [isPaused, sleepTimerMinutes, sleepRemainingSec])
const reducedMotion = useReducedMotion()
const {
controlsVisible,
seekIndicator,
transientToast,
showControls,
showSeekIndicator,
showToast,
} = usePlayerChrome(isPaused)
const subtitleStreams = item ? getSubtitleStreams(item) : []
const mediaSourceId = ((item as any)?.MediaSources?.[0]?.Id as string | undefined) || undefined
const {
seriesId,
seasonEpisodes,
nextUpItem,
queueActive,
queueNext,
previousItem,
nextItem,
} = usePlayerNavigation(item, id)
const showUpNextWindow = duration > 0 && currentTime > 0 && duration - currentTime <= 30
const [upNextDismissed, setUpNextDismissed] = useState(false)
// Up Next card uses the SAME `nextItem` resolution that drives the
// chrome's next button and the auto-advance on `onEnded`. When the
// user is on a shuffled queue, queueNext (next in shuffle order) wins
// over the series's natural next-up so the card actually previews
// what will play next.
const upNextCard = nextItem || nextUpItem || null
const nextUpVisible =
!!upNextCard &&
upNextCard.Id !== item?.Id &&
(item?.Type === 'Episode' || queueActive) &&
showUpNextWindow &&
!upNextDismissed
const upNextCountdown = Math.max(0, Math.ceil(duration - currentTime))
const auth = jellyfinClient.getAuthState()
const token = auth?.token || ''
const serverUrl = auth?.serverUrl || ''
const resume = searchParams.get('resume') === 'true'
const positionTicks = item?.UserData?.PlaybackPositionTicks
const startTimeTicks = resume && positionTicks ? Number(positionTicks) : undefined
/* Resolve the proper stream URL from Jellyfin's PlaybackInfo endpoint.
* The server picks direct-play / direct-stream / transcoded HLS based on
* our DeviceProfile and returns a URL with a valid PlaySessionId so HLS
* segment fetches don't 400. */
// streamAudioIndex re-runs PlaybackInfo so the server returns a fresh
// TranscodingUrl muxing the chosen audio track. We only set it when a
// native audioTracks switch isn't possible (transcoded streams, or
// single-audio sources where the alternate track isn't in the file).
// maxBitrate threads the user-picked quality cap into the same call.
// 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,
)
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 ''
})()
/* Reset transient flags on item change */
useEffect(() => {
setUpNextDismissed(false)
setPanel('streamInfo', false)
setAudioIndex(null)
setStreamAudioIndex(null)
setEndCardOpen(false)
usePlayerRuntimeStore.getState().resetForNewItem()
}, [id, setPanel])
/* Resume prompt: show on first mount when there's a saved position
* past the threshold AND the user wants the prompt AND the URL didn't
* already specify ?resume=true (queue navigation path). */
useEffect(() => {
if (!item) return
const pos = Number(item.UserData?.PlaybackPositionTicks ?? 0)
const thresholdSec = usePreferencesStore.getState().resumeThresholdSec ?? 5
const threshold = thresholdSec * 10_000_000
const fromQueue = searchParams.get('resume') === 'true'
if (showResumePromptPref && !fromQueue && pos > threshold) {
setResumePromptOpen(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item?.Id])
/* Auto-rewatch counter: when an already-played item starts playing
* again, record the rewatch. We trip it at most once per item-mount
* so a mid-session pause/resume doesn't double-count, but each fresh
* mount of the same item (close + reopen, navigate away + back)
* counts as a new rewatch. */
const rewatchedItemIdRef = useRef<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)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item?.Id])
/* Auto-recap trigger: decide once per item. Recap card waits for the
* resume prompt (if any) to resolve before appearing. */
const recapTrigger = useMemo(
() => computeRecapTrigger(item, seasonEpisodes, recapGapDays),
[item, seasonEpisodes, recapGapDays],
)
const [recapPending, setRecapPending] = useState(false)
useEffect(() => {
if (!item) return
const dismissed = usePlayerRuntimeStore.getState().recapDismissed
if (showRecapCardPref && recapTrigger.shouldShow && !dismissed) {
setRecapPending(true)
} else {
setRecapPending(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item?.Id, recapTrigger.shouldShow])
useEffect(() => {
if (recapPending && !resumePromptOpen) {
setRecapCardOpen(true)
setRecapPending(false)
}
}, [recapPending, resumePromptOpen])
/* Auto-pause on window blur. Only if the user opted in. Tracks whether
* we're the one who paused so re-focus doesn't resume a user-paused
* video. */
useEffect(() => {
if (!pauseOnBlur) return
const pausedByUs = { current: false }
function onBlur() {
const p = playerRef.current
if (!p || p.paused) return
pausedByUs.current = true
p.pause()
}
function onFocus() {
if (!pausedByUs.current) return
pausedByUs.current = false
playerRef.current?.play().catch(() => {})
}
window.addEventListener('blur', onBlur)
window.addEventListener('focus', onFocus)
return () => {
window.removeEventListener('blur', onBlur)
window.removeEventListener('focus', onFocus)
}
}, [pauseOnBlur])
/* Apply audio graph state when offsets / boost / night mode change */
usePlayerAudioGraph(playerRef, streamUrl)
/* Active query eviction for the previous episode. Drops cached
* PlaybackInfo + the played-item details + chapters / markers so the
* cache doesn't accumulate during a long binge. The next episode
* fetches fresh - cost is one round-trip per episode change, gain
* is bounded memory. */
useEffect(() => {
const evictId = id
return () => {
if (!evictId) return
try {
qc.removeQueries({ queryKey: ['jellyfin', 'playback-info', evictId], exact: false })
qc.removeQueries({ queryKey: ['jellyfin', 'item', evictId], exact: false })
qc.removeQueries({ queryKey: ['jellyfin', 'media-segments', evictId], exact: false })
qc.removeQueries({ queryKey: ['jellyfin', 'episodes', evictId], exact: false })
} catch { /* ignore */ }
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
/* Aggressive teardown on player unmount.
*
* The official Jellyfin web client (and basically every player) gets
* away with the default vidstack/hls.js cleanup because they don't
* remount per-episode - they swap the source on a single long-lived
* media element. We DO remount per-episode (Routes is keyed on
* pathname), so each shuffle step creates a new MediaSource +
* SourceBuffers and the browser holds the old buffered video data
* until GC catches up. After 13+ episodes the WebView accumulates
* enough buffer pressure to crash.
*
* We can't change the routing without a bigger rewrite, but we can
* help the browser release the previous episode's media path
* immediately:
* 1. Pause the underlying <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. */
useEffect(() => {
return () => {
try {
const video = document.querySelector('video') as HTMLVideoElement | null
if (video) {
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 any)?.el as HTMLElement | undefined
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 => {
// Keep retrying until the native volume actually matches the pref.
// Once it does, applyVolume becomes a no-op anyway (idempotent).
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)) {
setCurrentTime(state.currentTime)
progressRef.current = state.currentTime * 10000000
// A-B loop: when both markers set and we've crossed B, seek to A.
// Only when not actively seeking - avoids fighting the user's drag.
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) {
// Mirror local pause/play to the watch party if we're in one
// (skip when the change was driven by a remote command we just
// received, so we don't echo it back).
if (useSyncPlay.getState().active && !shouldSuppressRemoteEcho()) {
if (state.paused) spPause().catch(() => {})
else spUnpause().catch(() => {})
}
}
setIsPaused(state.paused)
setIsMuted(state.muted)
// NOTE: volume is intentionally NOT synced from subscribe state. The
// user owns the volume value via the slider / arrow keys, and we
// write it through to the player imperatively. Reading it back here
// races against our own optimistic write - vidstack briefly reports
// the stale value before applying our setter, which would snap the
// slider back.
const ranges: any = state.buffered
if (ranges && ranges.length > 0) {
try {
setBuffered(ranges.end(ranges.length - 1))
} 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 as any)?.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,
)
/* 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 (!currentMarker || !playerRef.current) return
const p = playerRef.current
const from = p.currentTime
if (currentMarker.type === 'intro' && skipIntros) {
p.currentTime = currentMarker.endSec
if (seriesId) recordSkippedSeconds(seriesId, 'intro', currentMarker.endSec - from)
} else if (currentMarker.type === 'credits' && skipCredits && duration > 0) {
const target = Math.max(currentMarker.endSec, duration - 0.5)
p.currentTime = target
if (seriesId) recordSkippedSeconds(seriesId, 'credits', target - from)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMarker?.type, currentMarker?.startSec, skipIntros, skipCredits])
/* 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 any).el as HTMLElement | undefined
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)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, mediaSourceId, subtitleStreams.length, subtitleMode, subtitleLanguage])
/* ── 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 any)?.el as HTMLElement | undefined
const video = el?.querySelector('video') as HTMLVideoElement | null
if (!video) return
if ((document as any).pictureInPictureElement === video) {
;(document as any).exitPictureInPicture?.().catch(() => {})
} else {
;(video as any).requestPictureInPicture?.().catch(() => {})
}
}
function toggleTheaterMode() {
usePlayerRuntimeStore.getState().setTheaterMode(!usePlayerRuntimeStore.getState().theaterMode)
}
function takeScreenshot() {
const el = (playerRef.current as any)?.el as HTMLElement | undefined
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 as any)?.MediaSources?.[0]?.MediaStreams || []) as any[]
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 any)?.el as HTMLElement | undefined
const video = el?.querySelector('video') as HTMLVideoElement | null
if (video) {
video.playbackRate = rate
;(video as any).preservesPitch = preserveAudioPitch
;(video as any).mozPreservesPitch = preserveAudioPitch
;(video as any).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
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
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 as any)?.MediaSources?.[0]?.MediaStreams || []).find((s: any) => 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 any)?.el as HTMLElement | undefined
const video = el?.querySelector('video') as HTMLVideoElement | null
const native = (video as any)?.audioTracks as { length: number; [i: number]: any } | 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 || []) as any[]
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: 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> | 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()
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 as any)?.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)
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. */}
<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()
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>
)
}