实现讲解全局播放器

This commit is contained in:
lyf
2026-07-07 23:35:03 +08:00
parent 1d930fa63a
commit 07beae95d0
7 changed files with 599 additions and 159 deletions

View File

@@ -0,0 +1,304 @@
import { computed, ref } from 'vue'
import type { AudioItem } from '@/components/audio/AudioPlayer.vue'
import type { AudioLanguage } from '@/repositories/AudioPlayInfoRepository'
import type { AudioPlayTargetType } from '@/domain/museum'
export interface GlobalAudioSource {
exhibitId?: string
targetType?: AudioPlayTargetType
targetId?: string
lang?: AudioLanguage | string
detailRoute?: string
title?: string
}
export type GlobalAudioRetryHandler = (message: string) => Promise<AudioItem | null>
export interface GlobalAudioPlayOptions {
source?: GlobalAudioSource
retryOnError?: GlobalAudioRetryHandler
retryAttempt?: boolean
}
const currentAudio = ref<AudioItem | null>(null)
const currentSource = ref<GlobalAudioSource | null>(null)
const visible = ref(false)
const playing = ref(false)
const loading = ref(false)
const error = ref('')
const currentTime = ref(0)
const duration = ref(0)
const activeHostId = ref('')
let audioElement: HTMLAudioElement | null = null
let retryOnError: GlobalAudioRetryHandler | null = null
let retrying = false
let retryUsed = false
let stopping = false
let hostSequence = 0
const showToast = (title: string) => {
if (typeof uni !== 'undefined') {
uni.showToast({
title,
icon: 'none'
})
}
}
const isAutoplayBlocked = (err: unknown) => {
const name = typeof err === 'object' && err && 'name' in err
? String((err as { name?: unknown }).name)
: ''
const message = err instanceof Error ? err.message : String(err || '')
return name === 'NotAllowedError' || /user didn't interact|not allowed|gesture/i.test(message)
}
const syncAudioDuration = () => {
if (!audioElement) return
duration.value = Number.isFinite(audioElement.duration)
? audioElement.duration
: currentAudio.value?.duration || 0
}
const ensureAudioElement = () => {
if (audioElement || typeof window === 'undefined' || !window.Audio) return audioElement
const audio = new window.Audio()
audio.preload = 'metadata'
audio.addEventListener('loadedmetadata', syncAudioDuration)
audio.addEventListener('play', () => {
playing.value = true
loading.value = false
error.value = ''
})
audio.addEventListener('pause', () => {
if (!stopping) {
playing.value = false
}
})
audio.addEventListener('ended', () => {
handleEnded()
})
audio.addEventListener('timeupdate', () => {
currentTime.value = audio.currentTime || 0
syncAudioDuration()
})
audio.addEventListener('waiting', () => {
loading.value = true
})
audio.addEventListener('canplay', () => {
loading.value = false
})
audio.addEventListener('error', () => {
if (!stopping && currentAudio.value?.audioUrl) {
void handleError('音频加载失败,当前提供图文讲解。')
}
})
audioElement = audio
return audioElement
}
const resetState = () => {
currentAudio.value = null
currentSource.value = null
visible.value = false
playing.value = false
loading.value = false
error.value = ''
currentTime.value = 0
duration.value = 0
retryOnError = null
retrying = false
retryUsed = false
}
const stopAudioElement = () => {
if (!audioElement) return
stopping = true
audioElement.pause()
audioElement.removeAttribute('src')
audioElement.load()
const resetStopping = () => {
stopping = false
}
if (typeof window !== 'undefined') {
window.setTimeout(resetStopping, 0)
return
}
resetStopping()
}
const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
if (!audio.audioUrl) {
handleError('当前语言暂无语音讲解。')
return false
}
const element = ensureAudioElement()
if (!element) {
handleError('当前环境暂不支持音频播放。')
return false
}
const isNewAudio = currentAudio.value?.id !== audio.id
if (!options.retryAttempt) {
retryUsed = false
}
currentAudio.value = audio
currentSource.value = options.source || currentSource.value
retryOnError = options.retryOnError || retryOnError
visible.value = true
loading.value = true
error.value = ''
if (isNewAudio) {
currentTime.value = 0
duration.value = audio.duration || 0
}
if (element.getAttribute('src') !== audio.audioUrl) {
element.src = audio.audioUrl
element.load()
}
try {
await element.play()
return true
} catch (err) {
loading.value = false
playing.value = false
if (isAutoplayBlocked(err)) {
error.value = '点击播放按钮开始播放'
return false
}
await handleError('音频暂时无法播放,当前提供图文讲解。')
return false
}
}
const pause = () => {
audioElement?.pause()
playing.value = false
}
const resume = () => {
if (!currentAudio.value) return Promise.resolve(false)
return play(currentAudio.value, {
source: currentSource.value || undefined,
retryOnError: retryOnError || undefined
})
}
const stop = () => {
stopAudioElement()
resetState()
}
const close = () => {
stop()
}
function handleEnded() {
stopAudioElement()
resetState()
}
async function handleError(message: string) {
error.value = message
playing.value = false
loading.value = false
if (retryOnError && !retrying && !retryUsed) {
retryUsed = true
retrying = true
const retryAudio = await retryOnError(message).catch((err) => {
console.warn('讲解音频刷新播放信息失败:', err)
return null
})
retrying = false
if (retryAudio?.audioUrl) {
return play(retryAudio, {
source: currentSource.value || undefined,
retryOnError: retryOnError || undefined,
retryAttempt: true
})
}
}
showToast(message || '讲解音频播放失败')
stop()
return false
}
const seekToPercent = (percent: number) => {
const totalDuration = duration.value || currentAudio.value?.duration || 0
if (!audioElement || totalDuration <= 0) return
const targetTime = Math.max(0, Math.min(100, percent)) / 100 * totalDuration
audioElement.currentTime = targetTime
currentTime.value = targetTime
}
const registerHost = () => {
hostSequence += 1
const hostId = `global-audio-host-${hostSequence}`
activeHostId.value = hostId
return hostId
}
const activateHost = (hostId: string) => {
activeHostId.value = hostId
}
const unregisterHost = (hostId: string) => {
if (activeHostId.value === hostId) {
activeHostId.value = ''
}
}
const isCurrentSource = (source: Pick<GlobalAudioSource, 'targetType' | 'targetId' | 'lang'>) => {
if (!currentAudio.value || !currentSource.value) return false
return Boolean(
source.targetType
&& source.targetId
&& currentSource.value.targetType === source.targetType
&& currentSource.value.targetId === source.targetId
&& (!source.lang || !currentSource.value.lang || currentSource.value.lang === source.lang)
)
}
export const useGlobalAudioPlayer = () => ({
currentAudio,
currentSource,
visible,
playing,
loading,
error,
currentTime,
duration,
activeHostId,
hasAudio: computed(() => Boolean(currentAudio.value)),
play,
pause,
resume,
stop,
close,
handleEnded,
handleError,
seekToPercent,
registerHost,
activateHost,
unregisterHost,
isCurrentSource
})