521 lines
13 KiB
TypeScript
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
|
|
})
|