1795 lines
74 KiB
TypeScript
1795 lines
74 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 { useQueueStore } from '../stores/queue-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=***'),
|
|
})
|
|
}
|
|
|
|
/* Resume behaviour: when the saved position is past the threshold,
|
|
* queue the seek so onCanPlay can apply it (the underlying
|
|
* HTMLMediaElement.currentTime is the only path that works for both
|
|
* direct-play MP4 and HLS without restarting from 0). Decide whether
|
|
* to also show the prompt:
|
|
* - ?resume=true : the caller already chose Resume, don't ask again
|
|
* - playlist queue: auto-advance through a playlist shouldn't ask
|
|
* before every episode
|
|
* - otherwise : show the prompt so the user can pick Resume /
|
|
* Start over (if the show-resume-prompt pref is on) */
|
|
const resumePromptShownRef = useRef<string | null>(null)
|
|
const resumeItemId = item?.Id
|
|
const resumePositionTicks = item?.UserData?.PlaybackPositionTicks
|
|
|
|
/* Reset transient flags on item change */
|
|
useEffect(() => {
|
|
setUpNextDismissed(false)
|
|
setPanel('streamInfo', false)
|
|
setAudioIndex(null)
|
|
setStreamAudioIndex(null)
|
|
setEndCardOpen(false)
|
|
pendingSeekRef.current = null
|
|
resumePromptShownRef.current = null
|
|
usePlayerRuntimeStore.getState().resetForNewItem()
|
|
}, [id, setPanel])
|
|
|
|
/* Resume behaviour: when the saved position is past the threshold,
|
|
* queue the seek so onCanPlay can apply it (the underlying
|
|
* HTMLMediaElement.currentTime is the only path that works for both
|
|
* direct-play MP4 and HLS without restarting from 0). Decide whether
|
|
* to also show the prompt:
|
|
* - ?resume=true : the caller already chose Resume, don't ask again
|
|
* - playlist queue: auto-advance through a playlist shouldn't ask
|
|
* before every episode
|
|
* - otherwise : show the prompt so the user can pick Resume /
|
|
* Start over (if the show-resume-prompt pref is on) */
|
|
const queueSource = useQueueStore(s => s.source)
|
|
useEffect(() => {
|
|
if (!resumeItemId) return
|
|
const pos = Number(resumePositionTicks ?? 0)
|
|
const thresholdSec = usePreferencesStore.getState().resumeThresholdSec ?? 5
|
|
const threshold = thresholdSec * 10_000_000
|
|
if (pos <= threshold) return
|
|
const target = pos / 10_000_000
|
|
if (pendingSeekRef.current == null) {
|
|
pendingSeekRef.current = target
|
|
}
|
|
if (resumePromptShownRef.current === resumeItemId) return
|
|
const inPlaylist = queueActive && queueSource?.type === 'playlist'
|
|
const skipPrompt = resume || inPlaylist
|
|
if (!skipPrompt && showResumePromptPref) {
|
|
setResumePromptOpen(true)
|
|
resumePromptShownRef.current = resumeItemId
|
|
}
|
|
}, [resumeItemId, resumePositionTicks, showResumePromptPref, resume, queueActive, queueSource])
|
|
|
|
/* 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>
|
|
)
|
|
}
|
|
|
|
|