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 export interface GlobalAudioPlayOptions { source?: GlobalAudioSource retryOnError?: GlobalAudioRetryHandler retryAttempt?: boolean displayMode?: AudioDisplayMode } const currentAudio = ref(null) const currentSource = ref(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('mini') const playbackRate = ref(1) const muted = ref(false) const pendingLanguage = ref('') const activeHostId = ref('') const lastClosedSource = ref(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) => { 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 })