fix playback bugs

This commit is contained in:
2026-05-23 22:59:51 +03:00
parent e3ff024e61
commit f9034f5356
+76 -36
View File
@@ -78,11 +78,18 @@ export default function PlayerPage() {
[setVolumePref], [setVolumePref],
) )
const [isFullscreen, setIsFullscreen] = useState(false) 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 [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const [buffered, setBuffered] = useState(0) const [buffered, setBuffered] = useState(0)
const [scrubPercent, setScrubPercent] = useState<number | null>(null) const [scrubPercent, setScrubPercent] = useState<number | null>(null)
const [seeking, setSeeking] = useState(false) const [seeking, setSeeking] = useState(false)
const lastUiUpdateRef = useRef(0)
/* Track selection (audio / subtitle). /* Track selection (audio / subtitle).
* `audioIndex` is the UI selection; `streamAudioIndex` is what we send * `audioIndex` is the UI selection; `streamAudioIndex` is what we send
@@ -216,25 +223,28 @@ export default function PlayerPage() {
const [playbackRate, setPlaybackRate] = useState(defaultPlaybackRate || 1) const [playbackRate, setPlaybackRate] = useState(defaultPlaybackRate || 1)
/* Sleep timer - counts down only while playing */ /* Sleep timer - counts down only while playing */
const sleepRemainingRef = useRef(sleepTimerMinutes * 60)
const [sleepRemainingSec, setSleepRemainingSec] = useState(sleepTimerMinutes * 60) const [sleepRemainingSec, setSleepRemainingSec] = useState(sleepTimerMinutes * 60)
useEffect(() => { useEffect(() => {
setSleepRemainingSec(sleepTimerMinutes * 60) const sec = sleepTimerMinutes * 60
sleepRemainingRef.current = sec
setSleepRemainingSec(sec)
}, [sleepTimerMinutes, id]) }, [sleepTimerMinutes, id])
useEffect(() => { useEffect(() => {
if (sleepTimerMinutes <= 0 || sleepRemainingSec <= 0) return if (sleepTimerMinutes <= 0) return
if (!isPaused) { if (!isPaused) {
const id = window.setInterval(() => { const id = window.setInterval(() => {
setSleepRemainingSec(prev => { const next = sleepRemainingRef.current - 1
if (prev <= 1) { sleepRemainingRef.current = next
playerRef.current?.pause() setSleepRemainingSec(next)
return 0 if (next <= 0) {
} playerRef.current?.pause()
return prev - 1 window.clearInterval(id)
}) }
}, 1000) }, 1000)
return () => window.clearInterval(id) return () => window.clearInterval(id)
} }
}, [isPaused, sleepTimerMinutes, sleepRemainingSec]) }, [isPaused, sleepTimerMinutes])
const reducedMotion = useReducedMotion() const reducedMotion = useReducedMotion()
const { const {
@@ -455,16 +465,25 @@ export default function PlayerPage() {
* *
* Wrapped in try/catch since vidstack may have already cleaned the * Wrapped in try/catch since vidstack may have already cleaned the
* element by the time this runs. */ * 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(() => { useEffect(() => {
return () => { return () => {
try { const video = videoElRef.current
const video = document.querySelector('video') as HTMLVideoElement | null if (video) {
if (video) { try {
video.pause() video.pause()
video.removeAttribute('src') video.removeAttribute('src')
video.load() video.load()
} } catch { /* element already gone */ }
} catch { /* element already gone */ } }
detachAudioGraph() detachAudioGraph()
} }
}, []) }, [])
@@ -501,8 +520,8 @@ export default function PlayerPage() {
} }
applyVolume() applyVolume()
const unsub = player.subscribe(state => { const unsub = player.subscribe(state => {
// Keep retrying until the native volume actually matches the pref. // Volume restore - only retry if the native volume hasn't converged
// Once it does, applyVolume becomes a no-op anyway (idempotent). // yet AND the player is actually ready. Avoids writing on every tick.
const target = usePreferencesStore.getState().playerVolume const target = usePreferencesStore.getState().playerVolume
const video = getNativeVideo() const video = getNativeVideo()
if (video && Math.abs(video.volume - target) > 0.001) { if (video && Math.abs(video.volume - target) > 0.001) {
@@ -512,10 +531,17 @@ export default function PlayerPage() {
setDuration(state.duration) setDuration(state.duration)
} }
if (Number.isFinite(state.currentTime)) { if (Number.isFinite(state.currentTime)) {
setCurrentTime(state.currentTime) // Always update the ref at full rate for imperative code
currentTimeRef.current = state.currentTime
progressRef.current = state.currentTime * 10000000 progressRef.current = state.currentTime * 10000000
// A-B loop: when both markers set and we've crossed B, seek to A. // Throttle React state updates to ~4 Hz to avoid re-rendering
// Only when not actively seeking - avoids fighting the user's drag. // 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() const rt = usePlayerRuntimeStore.getState()
if ( if (
rt.loopA != null && rt.loopA != null &&
@@ -529,9 +555,6 @@ export default function PlayerPage() {
} }
} }
if (state.paused !== isPausedRef.current) { 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 (useSyncPlay.getState().active && !shouldSuppressRemoteEcho()) {
if (state.paused) spPause().catch(() => {}) if (state.paused) spPause().catch(() => {})
else spUnpause().catch(() => {}) else spUnpause().catch(() => {})
@@ -539,16 +562,15 @@ export default function PlayerPage() {
} }
setIsPaused(state.paused) setIsPaused(state.paused)
setIsMuted(state.muted) 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 const ranges: any = state.buffered
if (ranges && ranges.length > 0) { if (ranges && ranges.length > 0) {
try { try {
setBuffered(ranges.end(ranges.length - 1)) 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 { } catch {
/* TimeRanges can throw if the index moved */ /* TimeRanges can throw if the index moved */
} }
@@ -948,6 +970,7 @@ export default function PlayerPage() {
const time = pct * duration const time = pct * duration
if (!Number.isFinite(time)) return if (!Number.isFinite(time)) return
if (playerRef.current) playerRef.current.currentTime = time if (playerRef.current) playerRef.current.currentTime = time
currentTimeRef.current = time
setCurrentTime(time) setCurrentTime(time)
progressRef.current = time * 10000000 progressRef.current = time * 10000000
setScrubPercent(null) setScrubPercent(null)
@@ -959,6 +982,7 @@ export default function PlayerPage() {
function seekToSeconds(t: number) { function seekToSeconds(t: number) {
if (!Number.isFinite(t)) return if (!Number.isFinite(t)) return
if (playerRef.current) playerRef.current.currentTime = t if (playerRef.current) playerRef.current.currentTime = t
currentTimeRef.current = t
setCurrentTime(t) setCurrentTime(t)
progressRef.current = t * 10000000 progressRef.current = t * 10000000
// Mirror to watch party if active (and not echoing a remote command). // Mirror to watch party if active (and not echoing a remote command).
@@ -1037,9 +1061,10 @@ export default function PlayerPage() {
;(provider as any).config = { ;(provider as any).config = {
startLevel: -1, startLevel: -1,
maxBufferLength: 30, maxBufferLength: 30,
maxMaxBufferLength: 60, maxMaxBufferLength: 120,
maxBufferSize: 30 * 1024 * 1024,
lowLatencyMode: false, lowLatencyMode: false,
backBufferLength: 30, backBufferLength: 15,
fragLoadingTimeOut: 60_000, fragLoadingTimeOut: 60_000,
fragLoadingMaxRetry: 6, fragLoadingMaxRetry: 6,
manifestLoadingTimeOut: 30_000, manifestLoadingTimeOut: 30_000,
@@ -1052,13 +1077,28 @@ export default function PlayerPage() {
const hls = (provider as any).instance as any const hls = (provider as any).instance as any
if (hls?.on && HLS?.Events) { if (hls?.on && HLS?.Events) {
hls.on(HLS.Events.ERROR, (_event: unknown, data: any) => { 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, { console.warn('[HLS]', data?.type, data?.details, {
fatal: data?.fatal, fatal: data?.fatal,
reason: data?.reason, reason: data?.reason,
url: data?.url || data?.frag?.url, url: data?.url || data?.frag?.url,
response: data?.response, 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:
hls.recoverMediaError()
break
default:
// Unrecoverable - destroy and let the user retry
hls.destroy()
break
}
}
}) })
} }
} }
@@ -1092,14 +1132,14 @@ export default function PlayerPage() {
navigate(`/play/${queueNext.Id}`, { replace: true }) navigate(`/play/${queueNext.Id}`, { replace: true })
return return
} }
if (autoplayNext && nextUpItem?.Id && nextUpItem.Id !== item?.Id) { if (autoplayNext && nextItem?.Id && nextItem.Id !== item?.Id) {
if (areYouStillWatching && autoAdvanceCountRef.current >= 2) { if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
stillWatchingTargetRef.current = nextUpItem.Id stillWatchingTargetRef.current = nextItem.Id
setStillWatchingOpen(true) setStillWatchingOpen(true)
return return
} }
autoAdvanceCountRef.current++ autoAdvanceCountRef.current++
navigate(`/play/${nextUpItem.Id}`, { replace: true }) navigate(`/play/${nextItem.Id}`, { replace: true })
return return
} }
autoAdvanceCountRef.current = 0 autoAdvanceCountRef.current = 0