fix: recover playback after media errors

This commit is contained in:
DiheChen committed 2026-11-04 22:06:41 +08:00
1 parent 8b13436ba7
commit 0c12c92d1f
2 files changed
+247 -8

No files matched your search

@@ -17,12 +17,14 @@ class FakeAudio extends EventTarget {
seeking = false
playError: Error | null = null
loadError: Error | null = null
error: { code: number } | null = null
load(): void {
const error = this.loadError
this.loadError = null
if (error)
throw error
this.error = null
this.loads += 1
}
@@ -46,6 +48,12 @@ class FakeAudio extends EventTarget {
emit(type: string): void {
this.dispatchEvent(new Event(type))
}
fail(): void {
this.paused = true
this.error = { code: 4 }
this.emit('error')
}
}
function fakeClock() {
@@ -428,6 +436,206 @@ describe('播放过程中', () => {
})
})
describe('音频出错之后', () => {
async function notices() {
const { notificationStore } = await import('@/shared/ui')
return () => notificationStore.state.notifications.map(({ tone, message }) => ({ tone, message }))
}
const retrying = { tone: 'error', message: '这首歌暂时放不了,正在重试。' }
const gaveUp = { tone: 'error', message: '这首歌没能加载,已停止重试。' }
// 第一次出错立刻重试,之后等 3、6、12、24 秒(随机数为 0.5 时)。
async function exhaust(): Promise<void> {
audio.fail()
audio.fail()
for (const delayMs of [3000, 6000, 12_000, 24_000]) {
await vi.advanceTimersByTimeAsync(delayMs)
audio.fail()
}
}
beforeEach(() => {
vi.spyOn(Math, 'random').mockReturnValue(0.5)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('第一次出错立刻重试,不提示:放到一半地址过期时马上就能换到新地址', async () => {
await attached()
const shown = await notices()
setPlaying(song('a'))
audio.src = 'https://cdn.test/expired'
audio.fail()
expect(audio.src).toBe('/media/netease/a')
expect(audio.loads).toBe(2)
expect(shown()).toEqual([])
})
it('立刻重试也失败时提示一次,通知显示 8 秒', async () => {
await attached()
const shown = await notices()
setPlaying(song('a'))
audio.fail()
audio.fail()
expect(shown()).toEqual([retrying])
await vi.advanceTimersByTimeAsync(7999)
expect(shown()).toEqual([retrying])
await vi.advanceTimersByTimeAsync(1)
expect(shown()).toEqual([])
})
it('之后按退避的间隔重试,一共重试 5 次', async () => {
await attached()
setPlaying(song('a'))
audio.fail()
audio.fail()
expect(audio.loads).toBe(2)
for (const [delayMs, loads] of [[3000, 3], [6000, 4], [12_000, 5], [24_000, 6]] as const) {
await vi.advanceTimersByTimeAsync(delayMs - 1)
expect(audio.loads).toBe(loads - 1)
await vi.advanceTimersByTimeAsync(1)
expect(audio.loads).toBe(loads)
audio.fail()
}
await vi.advanceTimersByTimeAsync(120_000)
expect(audio.loads).toBe(6)
})
it('重试用完时再提示一次,不再请求', async () => {
await attached()
const shown = await notices()
setPlaying(song('a'))
await exhaust()
expect(shown()).toContainEqual(gaveUp)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(8000)
expect(shown()).toEqual([])
audio.fail()
expect(shown()).toEqual([])
})
it('恢复之后从应在的位置接着放', async () => {
await attached()
setPlaying(song('a'))
audio.emit('canplay')
audio.fail()
audio.fail()
clock.nowMs = startedAtMs + 20_000
await vi.advanceTimersByTimeAsync(3000)
audio.emit('canplay')
expect(audio.currentTime).toBe(20)
expect(audio.paused).toBe(false)
})
it('播放成功之后重新计数,这首歌后面再出错还能立刻重试', async () => {
await attached()
const shown = await notices()
setPlaying(song('a'))
audio.fail()
audio.emit('playing')
audio.fail()
expect(audio.loads).toBe(3)
expect(shown()).toEqual([])
})
it('换歌时取消等待中的重试并重新计数', async () => {
await attached()
const shown = await notices()
setPlaying(song('a'))
audio.fail()
audio.fail()
setPlaying(song('b'))
await vi.advanceTimersByTimeAsync(3000)
expect(audio.src).toBe('/media/netease/b')
expect(audio.loads).toBe(3)
audio.fail()
expect(audio.loads).toBe(4)
expect(shown()).toEqual([retrying])
})
it('没有在播的歌时取消等待中的重试,也不再理会错误', async () => {
await attached()
setPlaying(song('a'))
audio.fail()
audio.fail()
setPlaying(null)
const { loads } = audio
await vi.advanceTimersByTimeAsync(8000)
audio.fail()
expect(audio.loads).toBe(loads)
expect(vi.getTimerCount()).toBe(0)
})
it('卸载时取消等待中的重试', async () => {
const playerStore = await attached()
const shown = await notices()
setPlaying(song('a'))
audio.fail()
audio.fail()
await vi.advanceTimersByTimeAsync(8000)
expect(shown()).toEqual([])
playerStore.detach()
expect(vi.getTimerCount()).toBe(0)
})
it('收到新快照时说明连接恢复了,不等下一次重试', async () => {
await attached()
setPlaying(song('a'))
audio.fail()
audio.fail()
setPlaying(song('a'))
expect(audio.loads).toBe(3)
await vi.advanceTimersByTimeAsync(120_000)
expect(audio.loads).toBe(3)
})
it('重试用完之后,收到新快照时再试一次,失败也不再提示', async () => {
await attached()
const shown = await notices()
setPlaying(song('a'))
await exhaust()
await vi.advanceTimersByTimeAsync(8000)
const { loads } = audio
setPlaying(song('a'))
expect(audio.loads).toBe(loads + 1)
audio.fail()
await vi.advanceTimersByTimeAsync(120_000)
expect(audio.loads).toBe(loads + 1)
expect(shown()).toEqual([])
})
it('点播放时重新加载:出错的音频元素调用 play() 一定失败', async () => {
const playerStore = await attached()
setPlaying(song('a'))
await exhaust()
const { loads, plays } = audio
playerStore.resume()
expect(audio.loads).toBe(loads + 1)
expect(audio.plays).toBe(plays)
audio.emit('canplay')
expect(audio.plays).toBe(plays + 1)
})
it('点播放时取消等待中的重试,不重复加载', async () => {
const playerStore = await attached()
setPlaying(song('a'))
audio.fail()
audio.fail()
playerStore.resume()
const { loads } = audio
await vi.advanceTimersByTimeAsync(3000)
expect(audio.loads).toBe(loads)
})
})
describe('卸载', () => {
it('卸载之后房间和时钟的变化不再影响音频', async () => {
const playerStore = await attached()
+39 -8
View File
@@ -1,9 +1,11 @@
import type { NowPlaying } from '@/core/realtime'
import { brandStore } from '@/core/brand'
import { clock } from '@/core/clock'
import { reconnectDelay } from '@/core/realtime'
import { roomStore } from '@/core/room'
import { Emitter } from '@/shared/lib/emitter'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
import { documentTitle } from './document-title'
import { setMediaSessionHandlers, showMediaSession } from './media-session'
import { driftSeekPosition, startPlan } from './timeline'
@@ -11,6 +13,9 @@ import { volumeStore } from './volume-store'
const driftCheckIntervalMs = 2000
const maxGain = 1.5
// 音源解析持续失败时,房间里的每个客户端都在请求上游,所以重试要有上限。
const maxMediaRetries = 5
const mediaNoticeMs = 8000
function sameTrack(a: NowPlaying, b: NowPlaying): boolean {
return a.entry.id === b.entry.id && a.startedAtMs === b.startedAtMs
@@ -22,9 +27,10 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
#audio: HTMLAudioElement | null = null
#current: NowPlaying | null = null
#load = 0
#reloaded = false
#failures = 0
#awaitingClock = false
#startTimer: ReturnType<typeof setTimeout> | undefined
#retryTimer: ReturnType<typeof setTimeout> | undefined
#driftTimer: ReturnType<typeof setInterval> | undefined
#frame: number | undefined
@@ -88,7 +94,11 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
if (!current)
return
this.setState({ needsGesture: false })
this.#start()
// 出错的音频元素调用 play() 一定失败,只能重新加载。
if (this.#audio?.error)
this.#reload(current)
else
this.#start()
// 恢复播放时重新获取快照;即使快照没有变化,也按应在位置重新定位一次。
void roomStore.sync().then(() => {
if (this.#current && sameTrack(this.#current, current))
@@ -113,10 +123,15 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
this.#stop()
return
}
if (previous && sameTrack(previous, next))
if (previous && sameTrack(previous, next)) {
// 同一首歌的新快照来自重连的握手或 room.sync:连接恢复了,不等下一次重试;重试用完之后也靠它恢复。
if (this.#audio?.error)
this.#reload(next)
return
}
clearTimeout(this.#startTimer)
this.#reloaded = false
clearTimeout(this.#retryTimer)
this.#failures = 0
this.#loadSource(next.mediaUrl, ++this.#load)
}
@@ -137,6 +152,11 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
audio.load()
}
#reload(current: NowPlaying): void {
clearTimeout(this.#retryTimer)
this.#loadSource(current.mediaUrl, ++this.#load)
}
#start(): void {
clearTimeout(this.#startTimer)
const audio = this.#audio
@@ -175,14 +195,24 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
#onError = (): void => {
const current = this.#current
if (!current || this.#reloaded)
if (!current)
return
this.#reloaded = true
// /media 在同一实例内复用 1 分钟内取到的播放地址,所以只有取到 1 分钟以后才失效的地址,这次重试才能换到新地址。
this.#loadSource(current.mediaUrl, ++this.#load)
this.#failures += 1
if (this.#failures === 1) {
// /media 在同一实例内复用 1 分钟内取到的播放地址,所以只有取到 1 分钟以后才失效的地址,这次重试才能换到新地址。
this.#reload(current)
return
}
if (this.#failures === 2)
notificationStore.error('这首歌暂时放不了,正在重试。', mediaNoticeMs)
if (this.#failures <= maxMediaRetries)
this.#retryTimer = setTimeout(() => this.#reload(current), reconnectDelay(this.#failures - 1))
else if (this.#failures === maxMediaRetries + 1)
notificationStore.error('这首歌没能加载,已停止重试。', mediaNoticeMs)
}
#onPlaying = (): void => {
this.#failures = 0
this.setState({ needsGesture: false })
cancelAnimationFrame(this.#frame ?? 0)
this.#frame = requestAnimationFrame(this.#tick)
@@ -241,6 +271,7 @@ class PlayerStore extends Store<{ needsGesture: boolean }> {
#stop(): void {
++this.#load
clearTimeout(this.#startTimer)
clearTimeout(this.#retryTimer)
this.#awaitingClock = false
this.setState({ needsGesture: false })
const audio = this.#audio