fix playback bugs

This commit is contained in:
2026-05-23 22:59:51 +03:00
parent e3ff024e61
commit f9034f5356
+72 -32
View File
@@ -78,11 +78,18 @@ export default function PlayerPage() {
[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
@@ -216,25 +223,28 @@ export default function PlayerPage() {
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(() => {
setSleepRemainingSec(sleepTimerMinutes * 60)
const sec = sleepTimerMinutes * 60
sleepRemainingRef.current = sec
setSleepRemainingSec(sec)
}, [sleepTimerMinutes, id])
useEffect(() => {
if (sleepTimerMinutes <= 0 || sleepRemainingSec <= 0) return
if (sleepTimerMinutes <= 0) return
if (!isPaused) {
const id = window.setInterval(() => {
setSleepRemainingSec(prev => {
if (prev <= 1) {
const next = sleepRemainingRef.current - 1
sleepRemainingRef.current = next
setSleepRemainingSec(next)
if (next <= 0) {
playerRef.current?.pause()
return 0
window.clearInterval(id)
}
return prev - 1
})
}, 1000)
return () => window.clearInterval(id)
}
}, [isPaused, sleepTimerMinutes, sleepRemainingSec])
}, [isPaused, sleepTimerMinutes])
const reducedMotion = useReducedMotion()
const {
@@ -455,16 +465,25 @@ export default function PlayerPage() {
*
* 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 () => {
try {
const video = document.querySelector('video') as HTMLVideoElement | null
const video = videoElRef.current
if (video) {
try {
video.pause()
video.removeAttribute('src')
video.load()
}
} catch { /* element already gone */ }
}
detachAudioGraph()
}
}, [])
@@ -501,8 +520,8 @@ export default function PlayerPage() {
}
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).
// 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) {
@@ -512,10 +531,17 @@ export default function PlayerPage() {
setDuration(state.duration)
}
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
// 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.
// 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 &&
@@ -529,9 +555,6 @@ export default function PlayerPage() {
}
}
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(() => {})
@@ -539,16 +562,15 @@ export default function PlayerPage() {
}
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))
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 */
}
@@ -948,6 +970,7 @@ export default function PlayerPage() {
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)
@@ -959,6 +982,7 @@ export default function PlayerPage() {
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).
@@ -1037,9 +1061,10 @@ export default function PlayerPage() {
;(provider as any).config = {
startLevel: -1,
maxBufferLength: 30,
maxMaxBufferLength: 60,
maxMaxBufferLength: 120,
maxBufferSize: 30 * 1024 * 1024,
lowLatencyMode: false,
backBufferLength: 30,
backBufferLength: 15,
fragLoadingTimeOut: 60_000,
fragLoadingMaxRetry: 6,
manifestLoadingTimeOut: 30_000,
@@ -1052,13 +1077,28 @@ export default function PlayerPage() {
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,
})
// 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 })
return
}
if (autoplayNext && nextUpItem?.Id && nextUpItem.Id !== item?.Id) {
if (autoplayNext && nextItem?.Id && nextItem.Id !== item?.Id) {
if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
stillWatchingTargetRef.current = nextUpItem.Id
stillWatchingTargetRef.current = nextItem.Id
setStillWatchingOpen(true)
return
}
autoAdvanceCountRef.current++
navigate(`/play/${nextUpItem.Id}`, { replace: true })
navigate(`/play/${nextItem.Id}`, { replace: true })
return
}
autoAdvanceCountRef.current = 0