fix playback bugs
This commit is contained in:
+72
-32
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user