在数字博物馆里连续点"云雷纹铜尊""鎏金羽人灯""错金银兽首",最晚准备完成的那段讲解不一定属于最后选中的展品。若每个异步回调都直接把标题和播放状态写回页面,用户已经站在兽首前,耳机里却开始播铜尊介绍。
展厅页面把展品、展厅路线和音频控制放在同一块深色画布上。我把"选中的展品"和"正在准备的音频请求"分开,只有仍属于当前选择的回调才允许改变播放器状态。
展品选择先产生一个会话版本
ts
export interface Exhibit {
id: string
hallId: string
title: string
audioUri: string
durationMs: number
}
export interface AudioState {
exhibitId: string
phase: 'idle' | 'preparing' | 'ready' | 'playing' | 'paused' | 'error'
requestId: number
positionMs: number
message: string
}
export class GuideController {
selectedExhibitId: string = ''
audio: AudioState = {
exhibitId: '', phase: 'idle', requestId: 0, positionMs: 0, message: ''
}
select(exhibit: Exhibit): number {
const requestId = this.audio.requestId + 1
this.selectedExhibitId = exhibit.id
this.audio = {
exhibitId: exhibit.id,
phase: 'preparing',
requestId,
positionMs: 0,
message: `正在准备《${exhibit.title}》`
}
return requestId
}
}
每次点击都增加 requestId,原播放器是否真正停止由音频适配器处理,页面状态先进入新的准备阶段,避免沿用前一件展品的进度条。
回调需要同时核对展品和版本
ts
markReady(exhibitId: string, requestId: number): boolean {
if (this.audio.exhibitId !== exhibitId || this.audio.requestId !== requestId) {
return false
}
this.audio = { ...this.audio, phase: 'ready', message: '可以开始讲解' }
return true
}
markError(exhibitId: string, requestId: number, reason: string): boolean {
if (this.audio.exhibitId !== exhibitId || this.audio.requestId !== requestId) {
return false
}
this.audio = { ...this.audio, phase: 'error', message: reason }
return true
}
旧请求准备失败也会被忽略,不会在新展品页面冒出无关的错误提示。用户重新点回同一件展品时会产生新的请求版本,旧版本仍然不能重新接管。
讲解进度按展品保存
用户从青铜厅走到丝路厅,再折返回来,应该续播之前的位置。进度由 exhibitId 索引,不能只保存一个全局 positionMs。
ts
export interface PlaybackMemory {
exhibitId: string
positionMs: number
updatedAt: number
}
export function savePosition(
memories: PlaybackMemory[],
next: PlaybackMemory
): PlaybackMemory[] {
const rest = memories.filter((item: PlaybackMemory) => item.exhibitId !== next.exhibitId)
return [...rest, next]
}
export function positionOf(memories: PlaybackMemory[], exhibitId: string): number {
return memories.find((item: PlaybackMemory) => item.exhibitId === exhibitId)?.positionMs ?? 0
}
正在播放时每隔一段时间保存一次,退出展厅时再做最后一次写入。更新过于频繁会增加存储开销,间隔过长又容易丢进度,因此保存频率应由控制器统一节流。

路线高亮只跟随展厅 ID
路线面板上的序厅、青铜厅、丝路厅和当代厅并不等于数组下标。导览路线可能按无障碍模式删除某一站,页面用 hallId 计算高亮和下一站。
ts
export interface HallRoute {
hallId: string
order: number
}
export function nextHall(route: HallRoute[], currentHallId: string): string | undefined {
const sorted = route.slice().sort((a: HallRoute, b: HallRoute) => a.order - b.order)
const index = sorted.findIndex((item: HallRoute) => item.hallId === currentHallId)
return index >= 0 ? sorted[index + 1]?.hallId : undefined
}
当前音频、路线和展品画面都以本地数据演示。接入真实音频播放器后,适配器只需要接收 exhibitId 和 requestId;无论网络缓冲或资源准备先后到达,旧讲解都不会盖掉用户最后选择的展品。