fix: recover playback after media errors
This commit is contained in:
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user