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