实现讲解全局播放器
This commit is contained in:
304
src/composables/useGlobalAudioPlayer.ts
Normal file
304
src/composables/useGlobalAudioPlayer.ts
Normal 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
|
||||
})
|
||||
Reference in New Issue
Block a user