Files
frontend-miniapp/src/composables/useGlobalAudioPlayer.ts
lyf 2edb6bd9f2
Some checks failed
CI / verify (push) Has been cancelled
适配H5讲解统一详情接口
2026-08-05 14:52:36 +08:00

521 lines
13 KiB
TypeScript

import { computed, ref } from 'vue'
import type {
AudioDisplayMode,
AudioItem
} from '@/components/audio/AudioPlayer.vue'
import {
type AudioLanguage
} from '@/repositories/AudioPlayInfoRepository'
import type { AudioPlayTargetType, MuseumGuideAudioOption } from '@/domain/museum'
import { resolveGuideAudioOption } from '@/domain/guideAudioOptions'
export interface GlobalAudioSource {
exhibitId?: string
stopId?: string
targetType?: AudioPlayTargetType
targetId?: string
lang?: AudioLanguage | string
channelCode?: string
voiceGender?: 'male' | 'female'
audioOptions?: MuseumGuideAudioOption[]
detailRoute?: string
title?: string
}
export type GlobalAudioRetryHandler = (message: string) => Promise<AudioItem | null>
export interface GlobalAudioPlayOptions {
source?: GlobalAudioSource
retryOnError?: GlobalAudioRetryHandler
retryAttempt?: boolean
displayMode?: AudioDisplayMode
}
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 displayMode = ref<AudioDisplayMode>('mini')
const playbackRate = ref(1)
const muted = ref(false)
const pendingLanguage = ref<AudioLanguage | ''>('')
const activeHostId = ref('')
const lastClosedSource = ref<GlobalAudioSource | null>(null)
const closeVersion = ref(0)
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.playbackRate = playbackRate.value
audio.muted = muted.value
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 setPlaybackRate = (rate: number) => {
const nextRate = Math.max(0.5, Math.min(2, Number.isFinite(rate) ? rate : 1))
playbackRate.value = nextRate
if (audioElement) {
audioElement.playbackRate = nextRate
}
}
const setMuted = (nextMuted: boolean) => {
muted.value = nextMuted
if (audioElement) {
audioElement.muted = nextMuted
}
}
const toggleMute = () => {
setMuted(!muted.value)
}
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
displayMode.value = 'mini'
pendingLanguage.value = ''
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 stopPlayback = () => {
stopAudioElement()
playing.value = false
loading.value = false
pendingLanguage.value = ''
currentTime.value = 0
duration.value = currentAudio.value?.duration || 0
}
const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguage) => {
if (!route) return route
const [path, rawQuery = ''] = route.split('?')
const params = new URLSearchParams(rawQuery)
params.set('lang', lang)
return `${path}?${params.toString()}`
}
const toSwitchedAudioItem = (
audioOption: MuseumGuideAudioOption,
lang: AudioLanguage
): AudioItem => ({
id: `channel-${audioOption.channelCode}`,
name: currentAudio.value?.name || currentSource.value?.title || audioOption.displayName || '讲解音频',
audioUrl: audioOption.playUrl,
image: currentAudio.value?.image,
duration: audioOption.duration || currentAudio.value?.duration,
language: lang,
supportedLanguages: currentAudio.value?.supportedLanguages
})
const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
if (!audio.audioUrl) {
const preservedMode = options.displayMode || displayMode.value
stopAudioElement()
currentAudio.value = audio
currentSource.value = options.source || currentSource.value
retryOnError = options.retryOnError || retryOnError
visible.value = true
displayMode.value = preservedMode
playing.value = false
loading.value = false
error.value = '当前语言暂无语音讲解'
currentTime.value = 0
duration.value = 0
showToast(error.value)
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
if (options.displayMode) {
displayMode.value = options.displayMode
}
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()
}
element.playbackRate = playbackRate.value
element.muted = muted.value
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,
displayMode: displayMode.value
})
}
const switchLanguage = async (lang: AudioLanguage) => {
if (pendingLanguage.value) {
return false
}
const source = currentSource.value
if (!source?.audioOptions?.length) {
error.value = '当前讲解暂不支持语言切换'
showToast(error.value)
return false
}
if (currentAudio.value?.language === lang && source.lang === lang) {
return true
}
const preservedMode = displayMode.value
pendingLanguage.value = lang
stopAudioElement()
playing.value = false
currentTime.value = 0
duration.value = 0
loading.value = true
error.value = ''
const nextSource: GlobalAudioSource = {
...source,
lang,
detailRoute: updateDetailRouteLanguage(source.detailRoute, lang)
}
try {
const audioOption = resolveGuideAudioOption(
source.audioOptions,
lang,
source.voiceGender || 'female'
)
if (!audioOption?.playUrl) {
stopAudioElement()
currentSource.value = nextSource
currentAudio.value = currentAudio.value
? {
...currentAudio.value,
id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
}
: null
visible.value = Boolean(currentAudio.value)
displayMode.value = preservedMode
playing.value = false
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = '当前语言暂无语音讲解'
showToast(error.value)
return false
}
const nextTrackSource: GlobalAudioSource = {
...nextSource,
channelCode: audioOption.channelCode,
voiceGender: audioOption.gender
}
currentSource.value = nextTrackSource
return await play(toSwitchedAudioItem(audioOption, lang), {
source: nextTrackSource,
retryOnError: retryOnError || undefined,
displayMode: preservedMode
})
} catch (err) {
console.warn('讲解音频语言切换失败:', err)
stopAudioElement()
currentSource.value = nextSource
if (currentAudio.value) {
currentAudio.value = {
...currentAudio.value,
id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`,
audioUrl: '',
duration: 0,
language: lang
}
visible.value = true
}
displayMode.value = preservedMode
playing.value = false
loading.value = false
currentTime.value = 0
duration.value = 0
error.value = '语音服务暂不可用,请稍后重试'
showToast(error.value)
return false
} finally {
pendingLanguage.value = ''
}
}
const stop = () => {
stopPlayback()
}
const close = () => {
lastClosedSource.value = currentSource.value ? { ...currentSource.value } : null
closeVersion.value += 1
stopAudioElement()
resetState()
}
const setDisplayMode = (mode: AudioDisplayMode) => {
displayMode.value = mode
if (currentAudio.value) {
visible.value = true
}
}
const collapse = (mode: AudioDisplayMode = 'mini') => {
setDisplayMode(mode)
}
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,
displayMode: displayMode.value
})
}
}
showToast(message || '讲解音频播放失败')
stopPlayback()
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}`
return hostId
}
const activateHost = (hostId: string) => {
activeHostId.value = hostId
}
const unregisterHost = (hostId: string) => {
if (activeHostId.value === hostId) {
activeHostId.value = ''
}
}
const isCurrentSource = (source: Pick<GlobalAudioSource, 'stopId' | 'targetType' | 'targetId' | 'lang' | 'channelCode'>) => {
if (!currentAudio.value || !currentSource.value) return false
return Boolean(
(source.stopId
? currentSource.value.stopId === source.stopId || currentSource.value.targetId === source.stopId
: source.targetType
&& source.targetId
&& currentSource.value.targetType === source.targetType
&& currentSource.value.targetId === source.targetId)
&& (!source.lang || !currentSource.value.lang || currentSource.value.lang === source.lang)
&& (!source.channelCode || currentSource.value.channelCode === source.channelCode)
)
}
export const useGlobalAudioPlayer = () => ({
currentAudio,
currentSource,
visible,
playing,
loading,
error,
currentTime,
duration,
displayMode,
playbackRate,
muted,
pendingLanguage,
activeHostId,
lastClosedSource,
closeVersion,
hasAudio: computed(() => Boolean(currentAudio.value)),
play,
pause,
resume,
stop,
close,
setDisplayMode,
setPlaybackRate,
setMuted,
toggleMute,
collapse,
switchLanguage,
handleEnded,
handleError,
seekToPercent,
registerHost,
activateHost,
unregisterHost,
isCurrentSource
})