实现讲解全局播放器
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<view
|
||||
class="audio-player"
|
||||
:class="{ 'avoid-bottom-nav': avoidBottomNav }"
|
||||
v-if="visible && currentAudio"
|
||||
v-if="visible && displayAudio"
|
||||
>
|
||||
<!-- 迷你播放器 (底部固定) -->
|
||||
<view class="mini-player" @tap="handleExpand">
|
||||
@@ -11,21 +11,21 @@
|
||||
<view class="player-cover">
|
||||
<image
|
||||
class="cover-image"
|
||||
:src="currentAudio?.image || EXHIBIT_PLACEHOLDER_IMAGE"
|
||||
:src="displayAudio?.image || EXHIBIT_PLACEHOLDER_IMAGE"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 播放信息 -->
|
||||
<view class="player-info">
|
||||
<text class="player-title">{{ currentAudio?.name || '讲解音频' }}</text>
|
||||
<text class="player-subtitle" :class="{ error: playbackError }">{{ playerSubtitle }}</text>
|
||||
<text class="player-title">{{ displayAudio?.name || '讲解音频' }}</text>
|
||||
<text class="player-subtitle" :class="{ error: displayPlaybackError }">{{ playerSubtitle }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 播放/暂停按钮 -->
|
||||
<view class="player-controls">
|
||||
<view class="control-btn play-btn" @tap.stop="togglePlay">
|
||||
<svg v-if="isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<svg v-if="displayPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||
</svg>
|
||||
@@ -73,6 +73,12 @@ const props = defineProps<{
|
||||
audio?: AudioItem | null
|
||||
autoPlay?: boolean
|
||||
avoidBottomNav?: boolean
|
||||
controlled?: boolean
|
||||
playing?: boolean
|
||||
loading?: boolean
|
||||
playbackError?: string
|
||||
currentTime?: number
|
||||
durationValue?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -82,15 +88,19 @@ const emit = defineEmits<{
|
||||
ended: [audio: AudioItem]
|
||||
timeUpdate: [currentTime: number]
|
||||
error: [audio: AudioItem | null, message: string]
|
||||
toggle: []
|
||||
close: []
|
||||
expand: []
|
||||
seek: [percent: number]
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
const currentAudio = ref<AudioItem | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const localCurrentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const playbackError = ref('')
|
||||
const localPlaybackError = ref('')
|
||||
const h5AudioElement = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// 内部音频上下文
|
||||
@@ -102,22 +112,32 @@ let cleanupH5Audio: (() => void) | null = null
|
||||
const progressPercent = computed(() => {
|
||||
const totalDuration = resolvedDuration.value
|
||||
if (totalDuration === 0) return 0
|
||||
return (currentTime.value / totalDuration) * 100
|
||||
return (displayCurrentTime.value / totalDuration) * 100
|
||||
})
|
||||
|
||||
const resolvedDuration = computed(() => {
|
||||
if (props.controlled) {
|
||||
if (props.durationValue && props.durationValue > 0) return props.durationValue
|
||||
return props.audio?.duration || 0
|
||||
}
|
||||
if (duration.value > 0) return duration.value
|
||||
return currentAudio.value?.duration || 0
|
||||
})
|
||||
|
||||
const displayAudio = computed(() => props.controlled ? props.audio || null : currentAudio.value)
|
||||
const displayPlaying = computed(() => props.controlled ? props.playing === true : isPlaying.value)
|
||||
const displayLoading = computed(() => props.controlled ? props.loading === true : isLoading.value)
|
||||
const displayPlaybackError = computed(() => props.controlled ? props.playbackError || '' : localPlaybackError.value)
|
||||
const displayCurrentTime = computed(() => props.controlled ? props.currentTime || 0 : localCurrentTime.value)
|
||||
|
||||
const playerSubtitle = computed(() => {
|
||||
if (playbackError.value) return playbackError.value
|
||||
if (isLoading.value) return '音频加载中'
|
||||
if (!currentAudio.value) return '请选择讲解'
|
||||
if (displayPlaybackError.value) return displayPlaybackError.value
|
||||
if (displayLoading.value) return '音频加载中'
|
||||
if (!displayAudio.value) return '请选择讲解'
|
||||
if (resolvedDuration.value > 0) {
|
||||
return `${formatTime(currentTime.value)} / ${formatTime(resolvedDuration.value)}`
|
||||
return `${formatTime(displayCurrentTime.value)} / ${formatTime(resolvedDuration.value)}`
|
||||
}
|
||||
return isPlaying.value ? '正在播放' : '点击播放按钮开始'
|
||||
return displayPlaying.value ? '正在播放' : '点击播放按钮开始'
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
@@ -137,7 +157,7 @@ const initAudio = () => {
|
||||
innerAudioContext.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
isLoading.value = false
|
||||
playbackError.value = ''
|
||||
localPlaybackError.value = ''
|
||||
if (currentAudio.value) {
|
||||
emit('play', currentAudio.value)
|
||||
}
|
||||
@@ -149,21 +169,21 @@ const initAudio = () => {
|
||||
|
||||
innerAudioContext.onStop(() => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
localCurrentTime.value = 0
|
||||
})
|
||||
|
||||
innerAudioContext.onEnded(() => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
localCurrentTime.value = 0
|
||||
if (currentAudio.value) {
|
||||
emit('ended', currentAudio.value)
|
||||
}
|
||||
})
|
||||
|
||||
innerAudioContext.onTimeUpdate(() => {
|
||||
currentTime.value = innerAudioContext.currentTime
|
||||
localCurrentTime.value = innerAudioContext.currentTime
|
||||
duration.value = innerAudioContext.duration || 0
|
||||
emit('timeUpdate', currentTime.value)
|
||||
emit('timeUpdate', localCurrentTime.value)
|
||||
})
|
||||
|
||||
innerAudioContext.onError((err: any) => {
|
||||
@@ -229,7 +249,7 @@ const showToast = (title: string) => {
|
||||
const handlePlaybackError = (message: string, shouldClose = false) => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
playbackError.value = message
|
||||
localPlaybackError.value = message
|
||||
emit('error', currentAudio.value, message)
|
||||
showToast(message)
|
||||
|
||||
@@ -241,9 +261,9 @@ const handlePlaybackError = (message: string, shouldClose = false) => {
|
||||
const resetPlaybackState = (clearAudio = true) => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
currentTime.value = 0
|
||||
localCurrentTime.value = 0
|
||||
duration.value = 0
|
||||
playbackError.value = ''
|
||||
localPlaybackError.value = ''
|
||||
|
||||
if (clearAudio) {
|
||||
currentAudio.value = null
|
||||
@@ -260,11 +280,11 @@ const playAudio = async (audio: AudioItem) => {
|
||||
|
||||
const isNewAudio = currentAudio.value?.id !== audio.id
|
||||
currentAudio.value = audio
|
||||
playbackError.value = ''
|
||||
localPlaybackError.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
if (isNewAudio) {
|
||||
currentTime.value = 0
|
||||
localCurrentTime.value = 0
|
||||
duration.value = audio.duration || 0
|
||||
}
|
||||
|
||||
@@ -284,7 +304,7 @@ const playAudio = async (audio: AudioItem) => {
|
||||
isPlaying.value = false
|
||||
|
||||
if (isAutoplayBlocked(error)) {
|
||||
playbackError.value = '点击播放按钮开始播放'
|
||||
localPlaybackError.value = '点击播放按钮开始播放'
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -356,6 +376,11 @@ const stopAudio = () => {
|
||||
|
||||
// 切换播放/暂停
|
||||
const togglePlay = () => {
|
||||
if (props.controlled) {
|
||||
emit('toggle')
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlaying.value) {
|
||||
pauseAudio()
|
||||
} else {
|
||||
@@ -374,13 +399,13 @@ const seekTo = (percent: number) => {
|
||||
|
||||
if (h5AudioElement.value) {
|
||||
h5AudioElement.value.currentTime = targetTime
|
||||
currentTime.value = targetTime
|
||||
localCurrentTime.value = targetTime
|
||||
return
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.seek(targetTime)
|
||||
currentTime.value = targetTime
|
||||
localCurrentTime.value = targetTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,19 +414,30 @@ const handleSeek = (e: any) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.detail.x || (e.touches && e.touches[0]?.clientX - rect.left) || 0
|
||||
const percent = (x / rect.width) * 100
|
||||
seekTo(Math.max(0, Math.min(100, percent)))
|
||||
const nextPercent = Math.max(0, Math.min(100, percent))
|
||||
if (props.controlled) {
|
||||
emit('seek', nextPercent)
|
||||
return
|
||||
}
|
||||
seekTo(nextPercent)
|
||||
}
|
||||
|
||||
// 展开播放器
|
||||
const handleExpand = () => {
|
||||
// 展开逻辑 (如果需要全屏播放器)
|
||||
console.log('展开播放器')
|
||||
emit('expand')
|
||||
}
|
||||
|
||||
// 关闭播放器
|
||||
const handleClose = () => {
|
||||
if (props.controlled) {
|
||||
emit('close')
|
||||
emit('update:visible', false)
|
||||
return
|
||||
}
|
||||
|
||||
stopAudio()
|
||||
emit('update:visible', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleH5LoadedMetadata = () => {
|
||||
@@ -413,7 +449,7 @@ const handleH5LoadedMetadata = () => {
|
||||
const handleH5Play = () => {
|
||||
isPlaying.value = true
|
||||
isLoading.value = false
|
||||
playbackError.value = ''
|
||||
localPlaybackError.value = ''
|
||||
if (currentAudio.value) {
|
||||
emit('play', currentAudio.value)
|
||||
}
|
||||
@@ -429,7 +465,7 @@ const handleH5Pause = () => {
|
||||
const handleH5Ended = () => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
currentTime.value = 0
|
||||
localCurrentTime.value = 0
|
||||
if (currentAudio.value) {
|
||||
emit('ended', currentAudio.value)
|
||||
}
|
||||
@@ -438,9 +474,9 @@ const handleH5Ended = () => {
|
||||
const handleH5TimeUpdate = () => {
|
||||
const audio = h5AudioElement.value
|
||||
if (!audio) return
|
||||
currentTime.value = audio.currentTime || 0
|
||||
localCurrentTime.value = audio.currentTime || 0
|
||||
duration.value = Number.isFinite(audio.duration) ? audio.duration : duration.value
|
||||
emit('timeUpdate', currentTime.value)
|
||||
emit('timeUpdate', localCurrentTime.value)
|
||||
}
|
||||
|
||||
const handleH5Waiting = () => {
|
||||
@@ -458,6 +494,8 @@ const handleH5AudioError = () => {
|
||||
|
||||
// 监听外部 audio 属性变化
|
||||
watch(() => props.audio, (newAudio) => {
|
||||
if (props.controlled) return
|
||||
|
||||
if (newAudio && props.visible && props.autoPlay && currentAudio.value?.id !== newAudio.id) {
|
||||
void playAudio(newAudio)
|
||||
} else if (newAudio && currentAudio.value?.id !== newAudio.id) {
|
||||
@@ -468,17 +506,23 @@ watch(() => props.audio, (newAudio) => {
|
||||
|
||||
// 监听外部 visible 属性变化
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (props.controlled) return
|
||||
|
||||
if (!visible) {
|
||||
stopAudio()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.controlled) return
|
||||
|
||||
initH5Audio()
|
||||
initAudio()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (props.controlled) return
|
||||
|
||||
cleanupH5Audio?.()
|
||||
cleanupH5Audio = null
|
||||
|
||||
@@ -495,7 +539,7 @@ defineExpose({
|
||||
stop: stopAudio,
|
||||
seekTo: seekTo,
|
||||
isPlaying: () => isPlaying.value,
|
||||
currentTime: () => currentTime.value,
|
||||
currentTime: () => localCurrentTime.value,
|
||||
duration: () => duration.value
|
||||
})
|
||||
</script>
|
||||
|
||||
82
src/components/audio/GlobalAudioPlayerHost.vue
Normal file
82
src/components/audio/GlobalAudioPlayerHost.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<AudioPlayer
|
||||
v-if="isHostVisible"
|
||||
controlled
|
||||
:visible="visible"
|
||||
:audio="currentAudio"
|
||||
:playing="playing"
|
||||
:loading="loading"
|
||||
:playback-error="error"
|
||||
:current-time="currentTime"
|
||||
:duration-value="duration"
|
||||
avoid-bottom-nav
|
||||
@toggle="handleToggle"
|
||||
@close="player.close"
|
||||
@seek="player.seekToPercent"
|
||||
@expand="handleExpand"
|
||||
@update:visible="handleVisibleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AudioPlayer from '@/components/audio/AudioPlayer.vue'
|
||||
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
|
||||
|
||||
const player = useGlobalAudioPlayer()
|
||||
const {
|
||||
currentAudio,
|
||||
currentSource,
|
||||
visible,
|
||||
playing,
|
||||
loading,
|
||||
error,
|
||||
currentTime,
|
||||
duration,
|
||||
activeHostId,
|
||||
hasAudio
|
||||
} = player
|
||||
const hostId = player.registerHost()
|
||||
const isHostVisible = computed(() => (
|
||||
visible.value
|
||||
&& hasAudio.value
|
||||
&& activeHostId.value === hostId
|
||||
))
|
||||
|
||||
const handleToggle = () => {
|
||||
if (playing.value) {
|
||||
player.pause()
|
||||
return
|
||||
}
|
||||
|
||||
void player.resume()
|
||||
}
|
||||
|
||||
const handleVisibleChange = (visible: boolean) => {
|
||||
if (!visible) {
|
||||
player.close()
|
||||
}
|
||||
}
|
||||
|
||||
const handleExpand = () => {
|
||||
const route = currentSource.value?.detailRoute
|
||||
if (!route) return
|
||||
|
||||
uni.navigateTo({
|
||||
url: route
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
player.activateHost(hostId)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
player.activateHost(hostId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
player.unregisterHost(hostId)
|
||||
})
|
||||
</script>
|
||||
@@ -32,11 +32,13 @@
|
||||
<view class="guide-page-frame-body">
|
||||
<slot></slot>
|
||||
</view>
|
||||
<GlobalAudioPlayerHost />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue'
|
||||
import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue'
|
||||
import type { GuideTopTab } from '@/utils/guideTopTabs'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
|
||||
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
|
||||
})
|
||||
@@ -128,27 +128,16 @@
|
||||
<text class="location-text">查看位置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AudioPlayer
|
||||
ref="audioPlayerRef"
|
||||
:visible="showAudioPlayer"
|
||||
:audio="currentAudio"
|
||||
:auto-play="false"
|
||||
@update:visible="handleAudioVisibleChange"
|
||||
@play="handleAudioPlay"
|
||||
@pause="handleAudioPause"
|
||||
@ended="handleAudioEnded"
|
||||
@error="handleAudioError"
|
||||
/>
|
||||
</view>
|
||||
</GuidePageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue'
|
||||
import type { AudioItem } from '@/components/audio/AudioPlayer.vue'
|
||||
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
|
||||
import {
|
||||
explainUseCase
|
||||
} from '@/usecases/explainUseCase'
|
||||
@@ -186,16 +175,8 @@ const defaultDetail: ExplainDetailPageViewModel = {
|
||||
relatedItems: []
|
||||
}
|
||||
|
||||
interface AudioPlayerExpose {
|
||||
play: (audio: AudioItem) => void
|
||||
pause: () => void
|
||||
}
|
||||
|
||||
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
|
||||
const audioPlayerRef = ref<AudioPlayerExpose | null>(null)
|
||||
const showAudioPlayer = ref(false)
|
||||
const currentAudio = ref<AudioItem | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const globalAudioPlayer = useGlobalAudioPlayer()
|
||||
const activeTopTab = ref<GuideTopTab>('explain')
|
||||
const resolvedHallId = ref('')
|
||||
const retryingAudio = ref(false)
|
||||
@@ -231,6 +212,18 @@ const visibleLinkedExhibits = computed(() => (
|
||||
[...(exhibit.value.linkedExhibits || [])]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
))
|
||||
const currentAudioTarget = computed(() => ({
|
||||
targetType: exhibit.value.audio.playTargetType || 'ITEM',
|
||||
targetId: exhibit.value.audio.playTargetId || exhibit.value.id,
|
||||
lang: exhibit.value.audio.language
|
||||
}))
|
||||
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
|
||||
const isPlaying = computed(() => (
|
||||
isCurrentDetailAudio.value && globalAudioPlayer.playing.value
|
||||
))
|
||||
const showAudioPlayer = computed(() => (
|
||||
globalAudioPlayer.visible.value && globalAudioPlayer.hasAudio.value
|
||||
))
|
||||
const supportedDetailLanguages = computed(() => new Set(
|
||||
(exhibit.value.audio.supportedLanguages || [selectedAudioLanguage.value])
|
||||
.filter(isAudioLanguage)
|
||||
@@ -284,10 +277,9 @@ const resolveHallGuidePoi = async () => {
|
||||
}
|
||||
|
||||
const resetAudioPlayer = () => {
|
||||
audioPlayerRef.value?.pause()
|
||||
showAudioPlayer.value = false
|
||||
currentAudio.value = null
|
||||
isPlaying.value = false
|
||||
if (isCurrentDetailAudio.value) {
|
||||
globalAudioPlayer.close()
|
||||
}
|
||||
retryingAudio.value = false
|
||||
}
|
||||
|
||||
@@ -413,12 +405,92 @@ const handleLoadDetailText = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildDetailRoute = () => {
|
||||
const request = detailEntryRequest.value
|
||||
const params = new URLSearchParams({
|
||||
id: request?.exhibitId || exhibit.value.id,
|
||||
tab: activeTopTab.value
|
||||
})
|
||||
if (currentAudioTarget.value.targetType) {
|
||||
params.set('targetType', currentAudioTarget.value.targetType)
|
||||
}
|
||||
if (currentAudioTarget.value.targetId) {
|
||||
params.set('targetId', currentAudioTarget.value.targetId)
|
||||
}
|
||||
if (selectedAudioLanguage.value) {
|
||||
params.set('lang', selectedAudioLanguage.value)
|
||||
}
|
||||
return `/pages/exhibit/detail?${params.toString()}`
|
||||
}
|
||||
|
||||
const toAudioItem = (
|
||||
selection: NonNullable<Awaited<ReturnType<typeof explainUseCase.selectAudioForExplainDetail>>>
|
||||
): AudioItem | null => {
|
||||
if (!selection.playable || !selection.media?.url) return null
|
||||
|
||||
return {
|
||||
id: selection.media.id,
|
||||
name: selection.playInfo?.title || selection.exhibit.name,
|
||||
audioUrl: selection.media.url,
|
||||
image: heroImage.value,
|
||||
duration: selection.media.duration
|
||||
}
|
||||
}
|
||||
|
||||
const refreshCurrentAudioOnce = async (message: string) => {
|
||||
if (retryingAudio.value || exhibit.value.audio.status !== 'playable') return null
|
||||
|
||||
retryingAudio.value = true
|
||||
const selection = await explainUseCase.selectAudioForExplainDetail({
|
||||
id: exhibit.value.id,
|
||||
name: exhibit.value.title,
|
||||
image: heroImage.value,
|
||||
description: exhibit.value.summary,
|
||||
guideText: exhibit.value.summary,
|
||||
audioUrl: exhibit.value.audio.url,
|
||||
audioDuration: exhibit.value.audio.duration,
|
||||
audioStatus: 'READY',
|
||||
audioLanguage: exhibit.value.audio.language,
|
||||
playTargetType: exhibit.value.audio.playTargetType,
|
||||
playTargetId: exhibit.value.audio.playTargetId
|
||||
}, {
|
||||
refreshPlayInfo: true
|
||||
})
|
||||
|
||||
retryingAudio.value = false
|
||||
const retryAudio = toAudioItem(selection)
|
||||
if (retryAudio) return retryAudio
|
||||
|
||||
exhibit.value = {
|
||||
...exhibit.value,
|
||||
audio: {
|
||||
...exhibit.value.audio,
|
||||
status: 'unavailable',
|
||||
url: undefined,
|
||||
unavailableReason: selection.unavailableMessage || message || '音频加载失败,当前提供图文讲解。'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handlePlayAudio = async () => {
|
||||
if (isCurrentDetailAudio.value) {
|
||||
if (globalAudioPlayer.playing.value) {
|
||||
globalAudioPlayer.pause()
|
||||
return
|
||||
}
|
||||
|
||||
void globalAudioPlayer.resume()
|
||||
return
|
||||
}
|
||||
|
||||
const selection = exhibit.value.id
|
||||
? await explainUseCase.selectAudioForExplainDetail({
|
||||
id: exhibit.value.id,
|
||||
name: exhibit.value.title,
|
||||
image: heroImage.value,
|
||||
description: exhibit.value.summary,
|
||||
guideText: exhibit.value.summary,
|
||||
audioUrl: exhibit.value.audio.url,
|
||||
audioDuration: exhibit.value.audio.duration,
|
||||
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
|
||||
@@ -437,99 +509,20 @@ const handlePlayAudio = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const audio: AudioItem = {
|
||||
id: selection.media.id,
|
||||
name: selection.playInfo?.title || selection.exhibit.name,
|
||||
audioUrl: selection.media.url,
|
||||
image: heroImage.value,
|
||||
duration: selection.media.duration
|
||||
}
|
||||
const audio = toAudioItem(selection)
|
||||
if (!audio) return
|
||||
|
||||
const isSameAudio = currentAudio.value?.id === audio.id
|
||||
if (showAudioPlayer.value && isSameAudio && isPlaying.value) {
|
||||
audioPlayerRef.value?.pause()
|
||||
return
|
||||
}
|
||||
|
||||
showAudioPlayer.value = true
|
||||
currentAudio.value = audio
|
||||
await nextTick()
|
||||
audioPlayerRef.value?.play(audio)
|
||||
}
|
||||
|
||||
const handleAudioVisibleChange = (visible: boolean) => {
|
||||
showAudioPlayer.value = visible
|
||||
if (!visible) {
|
||||
isPlaying.value = false
|
||||
currentAudio.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleAudioPlay = () => {
|
||||
retryingAudio.value = false
|
||||
isPlaying.value = true
|
||||
}
|
||||
|
||||
const handleAudioPause = () => {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
const handleAudioEnded = () => {
|
||||
isPlaying.value = false
|
||||
showAudioPlayer.value = false
|
||||
currentAudio.value = null
|
||||
retryingAudio.value = false
|
||||
}
|
||||
|
||||
const handleAudioError = async (_audio: AudioItem | null, message: string) => {
|
||||
console.warn('详情音频不可播放:', message)
|
||||
isPlaying.value = false
|
||||
|
||||
if (!retryingAudio.value && exhibit.value.audio.status === 'playable') {
|
||||
retryingAudio.value = true
|
||||
const selection = await explainUseCase.selectAudioForExplainDetail({
|
||||
id: exhibit.value.id,
|
||||
name: exhibit.value.title,
|
||||
image: heroImage.value,
|
||||
audioUrl: exhibit.value.audio.url,
|
||||
audioDuration: exhibit.value.audio.duration,
|
||||
audioStatus: 'READY',
|
||||
audioLanguage: exhibit.value.audio.language,
|
||||
playTargetType: exhibit.value.audio.playTargetType,
|
||||
playTargetId: exhibit.value.audio.playTargetId
|
||||
}, {
|
||||
refreshPlayInfo: true
|
||||
})
|
||||
|
||||
if (selection.playable && selection.media?.url) {
|
||||
const retryAudio: AudioItem = {
|
||||
id: selection.media.id,
|
||||
name: selection.playInfo?.title || selection.exhibit.name,
|
||||
audioUrl: selection.media.url,
|
||||
image: heroImage.value,
|
||||
duration: selection.media.duration
|
||||
}
|
||||
|
||||
currentAudio.value = retryAudio
|
||||
showAudioPlayer.value = true
|
||||
await nextTick()
|
||||
audioPlayerRef.value?.play(retryAudio)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
retryingAudio.value = false
|
||||
showAudioPlayer.value = false
|
||||
currentAudio.value = null
|
||||
exhibit.value = {
|
||||
...exhibit.value,
|
||||
audio: {
|
||||
...exhibit.value.audio,
|
||||
status: 'unavailable',
|
||||
url: undefined,
|
||||
unavailableReason: message || '音频加载失败,当前提供图文讲解。'
|
||||
}
|
||||
}
|
||||
await globalAudioPlayer.play(audio, {
|
||||
source: {
|
||||
exhibitId: exhibit.value.id,
|
||||
targetType: currentAudioTarget.value.targetType,
|
||||
targetId: currentAudioTarget.value.targetId,
|
||||
lang: selectedAudioLanguage.value,
|
||||
title: audio.name,
|
||||
detailRoute: buildDetailRoute()
|
||||
},
|
||||
retryOnError: refreshCurrentAudioOnce
|
||||
})
|
||||
}
|
||||
|
||||
const handleNavigate = async () => {
|
||||
@@ -565,8 +558,13 @@ const handleNavigate = async () => {
|
||||
}
|
||||
|
||||
const fallbackToExplainHome = () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?tab=explain'
|
||||
uni.redirectTo({
|
||||
url: '/pages/index/index?tab=explain',
|
||||
fail: () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?tab=explain'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -153,8 +153,13 @@ const handleBack = () => {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?tab=explain'
|
||||
uni.redirectTo({
|
||||
url: '/pages/index/index?tab=explain',
|
||||
fail: () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?tab=explain'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -240,8 +240,13 @@ const handleBack = () => {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?tab=explain'
|
||||
uni.redirectTo({
|
||||
url: '/pages/index/index?tab=explain',
|
||||
fail: () => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?tab=explain'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user