Compare commits
2 Commits
1d930fa63a
...
89923ab861
| Author | SHA1 | Date | |
|---|---|---|---|
| 89923ab861 | |||
| 07beae95d0 |
@@ -3,7 +3,7 @@
|
|||||||
<view
|
<view
|
||||||
class="audio-player"
|
class="audio-player"
|
||||||
:class="{ 'avoid-bottom-nav': avoidBottomNav }"
|
:class="{ 'avoid-bottom-nav': avoidBottomNav }"
|
||||||
v-if="visible && currentAudio"
|
v-if="visible && displayAudio"
|
||||||
>
|
>
|
||||||
<!-- 迷你播放器 (底部固定) -->
|
<!-- 迷你播放器 (底部固定) -->
|
||||||
<view class="mini-player" @tap="handleExpand">
|
<view class="mini-player" @tap="handleExpand">
|
||||||
@@ -11,21 +11,21 @@
|
|||||||
<view class="player-cover">
|
<view class="player-cover">
|
||||||
<image
|
<image
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
:src="currentAudio?.image || EXHIBIT_PLACEHOLDER_IMAGE"
|
:src="displayAudio?.image || EXHIBIT_PLACEHOLDER_IMAGE"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 播放信息 -->
|
<!-- 播放信息 -->
|
||||||
<view class="player-info">
|
<view class="player-info">
|
||||||
<text class="player-title">{{ currentAudio?.name || '讲解音频' }}</text>
|
<text class="player-title">{{ displayAudio?.name || '讲解音频' }}</text>
|
||||||
<text class="player-subtitle" :class="{ error: playbackError }">{{ playerSubtitle }}</text>
|
<text class="player-subtitle" :class="{ error: displayPlaybackError }">{{ playerSubtitle }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 播放/暂停按钮 -->
|
<!-- 播放/暂停按钮 -->
|
||||||
<view class="player-controls">
|
<view class="player-controls">
|
||||||
<view class="control-btn play-btn" @tap.stop="togglePlay">
|
<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="6" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
<rect x="14" y="5" width="4" height="14" rx="1" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -73,6 +73,12 @@ const props = defineProps<{
|
|||||||
audio?: AudioItem | null
|
audio?: AudioItem | null
|
||||||
autoPlay?: boolean
|
autoPlay?: boolean
|
||||||
avoidBottomNav?: boolean
|
avoidBottomNav?: boolean
|
||||||
|
controlled?: boolean
|
||||||
|
playing?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
playbackError?: string
|
||||||
|
currentTime?: number
|
||||||
|
durationValue?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -82,15 +88,19 @@ const emit = defineEmits<{
|
|||||||
ended: [audio: AudioItem]
|
ended: [audio: AudioItem]
|
||||||
timeUpdate: [currentTime: number]
|
timeUpdate: [currentTime: number]
|
||||||
error: [audio: AudioItem | null, message: string]
|
error: [audio: AudioItem | null, message: string]
|
||||||
|
toggle: []
|
||||||
|
close: []
|
||||||
|
expand: []
|
||||||
|
seek: [percent: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const currentAudio = ref<AudioItem | null>(null)
|
const currentAudio = ref<AudioItem | null>(null)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const currentTime = ref(0)
|
const localCurrentTime = ref(0)
|
||||||
const duration = ref(0)
|
const duration = ref(0)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const playbackError = ref('')
|
const localPlaybackError = ref('')
|
||||||
const h5AudioElement = ref<HTMLAudioElement | null>(null)
|
const h5AudioElement = ref<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
// 内部音频上下文
|
// 内部音频上下文
|
||||||
@@ -102,22 +112,32 @@ let cleanupH5Audio: (() => void) | null = null
|
|||||||
const progressPercent = computed(() => {
|
const progressPercent = computed(() => {
|
||||||
const totalDuration = resolvedDuration.value
|
const totalDuration = resolvedDuration.value
|
||||||
if (totalDuration === 0) return 0
|
if (totalDuration === 0) return 0
|
||||||
return (currentTime.value / totalDuration) * 100
|
return (displayCurrentTime.value / totalDuration) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvedDuration = computed(() => {
|
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
|
if (duration.value > 0) return duration.value
|
||||||
return currentAudio.value?.duration || 0
|
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(() => {
|
const playerSubtitle = computed(() => {
|
||||||
if (playbackError.value) return playbackError.value
|
if (displayPlaybackError.value) return displayPlaybackError.value
|
||||||
if (isLoading.value) return '音频加载中'
|
if (displayLoading.value) return '音频加载中'
|
||||||
if (!currentAudio.value) return '请选择讲解'
|
if (!displayAudio.value) return '请选择讲解'
|
||||||
if (resolvedDuration.value > 0) {
|
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(() => {
|
innerAudioContext.onPlay(() => {
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
playbackError.value = ''
|
localPlaybackError.value = ''
|
||||||
if (currentAudio.value) {
|
if (currentAudio.value) {
|
||||||
emit('play', currentAudio.value)
|
emit('play', currentAudio.value)
|
||||||
}
|
}
|
||||||
@@ -149,21 +169,21 @@ const initAudio = () => {
|
|||||||
|
|
||||||
innerAudioContext.onStop(() => {
|
innerAudioContext.onStop(() => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
currentTime.value = 0
|
localCurrentTime.value = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
innerAudioContext.onEnded(() => {
|
innerAudioContext.onEnded(() => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
currentTime.value = 0
|
localCurrentTime.value = 0
|
||||||
if (currentAudio.value) {
|
if (currentAudio.value) {
|
||||||
emit('ended', currentAudio.value)
|
emit('ended', currentAudio.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
innerAudioContext.onTimeUpdate(() => {
|
innerAudioContext.onTimeUpdate(() => {
|
||||||
currentTime.value = innerAudioContext.currentTime
|
localCurrentTime.value = innerAudioContext.currentTime
|
||||||
duration.value = innerAudioContext.duration || 0
|
duration.value = innerAudioContext.duration || 0
|
||||||
emit('timeUpdate', currentTime.value)
|
emit('timeUpdate', localCurrentTime.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
innerAudioContext.onError((err: any) => {
|
innerAudioContext.onError((err: any) => {
|
||||||
@@ -229,7 +249,7 @@ const showToast = (title: string) => {
|
|||||||
const handlePlaybackError = (message: string, shouldClose = false) => {
|
const handlePlaybackError = (message: string, shouldClose = false) => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
playbackError.value = message
|
localPlaybackError.value = message
|
||||||
emit('error', currentAudio.value, message)
|
emit('error', currentAudio.value, message)
|
||||||
showToast(message)
|
showToast(message)
|
||||||
|
|
||||||
@@ -241,9 +261,9 @@ const handlePlaybackError = (message: string, shouldClose = false) => {
|
|||||||
const resetPlaybackState = (clearAudio = true) => {
|
const resetPlaybackState = (clearAudio = true) => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
currentTime.value = 0
|
localCurrentTime.value = 0
|
||||||
duration.value = 0
|
duration.value = 0
|
||||||
playbackError.value = ''
|
localPlaybackError.value = ''
|
||||||
|
|
||||||
if (clearAudio) {
|
if (clearAudio) {
|
||||||
currentAudio.value = null
|
currentAudio.value = null
|
||||||
@@ -260,11 +280,11 @@ const playAudio = async (audio: AudioItem) => {
|
|||||||
|
|
||||||
const isNewAudio = currentAudio.value?.id !== audio.id
|
const isNewAudio = currentAudio.value?.id !== audio.id
|
||||||
currentAudio.value = audio
|
currentAudio.value = audio
|
||||||
playbackError.value = ''
|
localPlaybackError.value = ''
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
if (isNewAudio) {
|
if (isNewAudio) {
|
||||||
currentTime.value = 0
|
localCurrentTime.value = 0
|
||||||
duration.value = audio.duration || 0
|
duration.value = audio.duration || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +304,7 @@ const playAudio = async (audio: AudioItem) => {
|
|||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
|
|
||||||
if (isAutoplayBlocked(error)) {
|
if (isAutoplayBlocked(error)) {
|
||||||
playbackError.value = '点击播放按钮开始播放'
|
localPlaybackError.value = '点击播放按钮开始播放'
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +376,11 @@ const stopAudio = () => {
|
|||||||
|
|
||||||
// 切换播放/暂停
|
// 切换播放/暂停
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
|
if (props.controlled) {
|
||||||
|
emit('toggle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
pauseAudio()
|
pauseAudio()
|
||||||
} else {
|
} else {
|
||||||
@@ -374,13 +399,13 @@ const seekTo = (percent: number) => {
|
|||||||
|
|
||||||
if (h5AudioElement.value) {
|
if (h5AudioElement.value) {
|
||||||
h5AudioElement.value.currentTime = targetTime
|
h5AudioElement.value.currentTime = targetTime
|
||||||
currentTime.value = targetTime
|
localCurrentTime.value = targetTime
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (innerAudioContext) {
|
if (innerAudioContext) {
|
||||||
innerAudioContext.seek(targetTime)
|
innerAudioContext.seek(targetTime)
|
||||||
currentTime.value = targetTime
|
localCurrentTime.value = targetTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,19 +414,30 @@ const handleSeek = (e: any) => {
|
|||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
const x = e.detail.x || (e.touches && e.touches[0]?.clientX - rect.left) || 0
|
const x = e.detail.x || (e.touches && e.touches[0]?.clientX - rect.left) || 0
|
||||||
const percent = (x / rect.width) * 100
|
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 = () => {
|
const handleExpand = () => {
|
||||||
// 展开逻辑 (如果需要全屏播放器)
|
emit('expand')
|
||||||
console.log('展开播放器')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭播放器
|
// 关闭播放器
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
if (props.controlled) {
|
||||||
|
emit('close')
|
||||||
|
emit('update:visible', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stopAudio()
|
stopAudio()
|
||||||
emit('update:visible', false)
|
emit('update:visible', false)
|
||||||
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleH5LoadedMetadata = () => {
|
const handleH5LoadedMetadata = () => {
|
||||||
@@ -413,7 +449,7 @@ const handleH5LoadedMetadata = () => {
|
|||||||
const handleH5Play = () => {
|
const handleH5Play = () => {
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
playbackError.value = ''
|
localPlaybackError.value = ''
|
||||||
if (currentAudio.value) {
|
if (currentAudio.value) {
|
||||||
emit('play', currentAudio.value)
|
emit('play', currentAudio.value)
|
||||||
}
|
}
|
||||||
@@ -429,7 +465,7 @@ const handleH5Pause = () => {
|
|||||||
const handleH5Ended = () => {
|
const handleH5Ended = () => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
currentTime.value = 0
|
localCurrentTime.value = 0
|
||||||
if (currentAudio.value) {
|
if (currentAudio.value) {
|
||||||
emit('ended', currentAudio.value)
|
emit('ended', currentAudio.value)
|
||||||
}
|
}
|
||||||
@@ -438,9 +474,9 @@ const handleH5Ended = () => {
|
|||||||
const handleH5TimeUpdate = () => {
|
const handleH5TimeUpdate = () => {
|
||||||
const audio = h5AudioElement.value
|
const audio = h5AudioElement.value
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
currentTime.value = audio.currentTime || 0
|
localCurrentTime.value = audio.currentTime || 0
|
||||||
duration.value = Number.isFinite(audio.duration) ? audio.duration : duration.value
|
duration.value = Number.isFinite(audio.duration) ? audio.duration : duration.value
|
||||||
emit('timeUpdate', currentTime.value)
|
emit('timeUpdate', localCurrentTime.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleH5Waiting = () => {
|
const handleH5Waiting = () => {
|
||||||
@@ -458,6 +494,8 @@ const handleH5AudioError = () => {
|
|||||||
|
|
||||||
// 监听外部 audio 属性变化
|
// 监听外部 audio 属性变化
|
||||||
watch(() => props.audio, (newAudio) => {
|
watch(() => props.audio, (newAudio) => {
|
||||||
|
if (props.controlled) return
|
||||||
|
|
||||||
if (newAudio && props.visible && props.autoPlay && currentAudio.value?.id !== newAudio.id) {
|
if (newAudio && props.visible && props.autoPlay && currentAudio.value?.id !== newAudio.id) {
|
||||||
void playAudio(newAudio)
|
void playAudio(newAudio)
|
||||||
} else if (newAudio && currentAudio.value?.id !== newAudio.id) {
|
} else if (newAudio && currentAudio.value?.id !== newAudio.id) {
|
||||||
@@ -468,17 +506,23 @@ watch(() => props.audio, (newAudio) => {
|
|||||||
|
|
||||||
// 监听外部 visible 属性变化
|
// 监听外部 visible 属性变化
|
||||||
watch(() => props.visible, (visible) => {
|
watch(() => props.visible, (visible) => {
|
||||||
|
if (props.controlled) return
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
stopAudio()
|
stopAudio()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (props.controlled) return
|
||||||
|
|
||||||
initH5Audio()
|
initH5Audio()
|
||||||
initAudio()
|
initAudio()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (props.controlled) return
|
||||||
|
|
||||||
cleanupH5Audio?.()
|
cleanupH5Audio?.()
|
||||||
cleanupH5Audio = null
|
cleanupH5Audio = null
|
||||||
|
|
||||||
@@ -495,7 +539,7 @@ defineExpose({
|
|||||||
stop: stopAudio,
|
stop: stopAudio,
|
||||||
seekTo: seekTo,
|
seekTo: seekTo,
|
||||||
isPlaying: () => isPlaying.value,
|
isPlaying: () => isPlaying.value,
|
||||||
currentTime: () => currentTime.value,
|
currentTime: () => localCurrentTime.value,
|
||||||
duration: () => duration.value
|
duration: () => duration.value
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
@@ -332,27 +332,37 @@ const handleBack = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 2px;
|
gap: 6px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-back-icon {
|
.header-back-icon {
|
||||||
|
position: relative;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 24px;
|
height: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 27px;
|
font-size: 0;
|
||||||
line-height: 24px;
|
line-height: 0;
|
||||||
color: #151713;
|
color: #151713;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-back-icon::before {
|
||||||
|
content: '';
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
transform: translateX(2px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
.header-back-text {
|
.header-back-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 24px;
|
height: 20px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 24px;
|
line-height: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #151713;
|
color: #151713;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
<view class="guide-page-frame-body">
|
<view class="guide-page-frame-body">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</view>
|
</view>
|
||||||
|
<GlobalAudioPlayerHost />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue'
|
import GuideTopTabs from '@/components/navigation/GuideTopTabs.vue'
|
||||||
|
import GlobalAudioPlayerHost from '@/components/audio/GlobalAudioPlayerHost.vue'
|
||||||
import type { GuideTopTab } from '@/utils/guideTopTabs'
|
import type { GuideTopTab } from '@/utils/guideTopTabs'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
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>
|
<text class="location-text">查看位置</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<AudioPlayer
|
|
||||||
ref="audioPlayerRef"
|
|
||||||
:visible="showAudioPlayer"
|
|
||||||
:audio="currentAudio"
|
|
||||||
:auto-play="false"
|
|
||||||
@update:visible="handleAudioVisibleChange"
|
|
||||||
@play="handleAudioPlay"
|
|
||||||
@pause="handleAudioPause"
|
|
||||||
@ended="handleAudioEnded"
|
|
||||||
@error="handleAudioError"
|
|
||||||
/>
|
|
||||||
</view>
|
</view>
|
||||||
</GuidePageFrame>
|
</GuidePageFrame>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
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 {
|
import {
|
||||||
explainUseCase
|
explainUseCase
|
||||||
} from '@/usecases/explainUseCase'
|
} from '@/usecases/explainUseCase'
|
||||||
@@ -186,16 +175,8 @@ const defaultDetail: ExplainDetailPageViewModel = {
|
|||||||
relatedItems: []
|
relatedItems: []
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioPlayerExpose {
|
|
||||||
play: (audio: AudioItem) => void
|
|
||||||
pause: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
|
const exhibit = ref<ExplainDetailPageViewModel>(defaultDetail)
|
||||||
const audioPlayerRef = ref<AudioPlayerExpose | null>(null)
|
const globalAudioPlayer = useGlobalAudioPlayer()
|
||||||
const showAudioPlayer = ref(false)
|
|
||||||
const currentAudio = ref<AudioItem | null>(null)
|
|
||||||
const isPlaying = ref(false)
|
|
||||||
const activeTopTab = ref<GuideTopTab>('explain')
|
const activeTopTab = ref<GuideTopTab>('explain')
|
||||||
const resolvedHallId = ref('')
|
const resolvedHallId = ref('')
|
||||||
const retryingAudio = ref(false)
|
const retryingAudio = ref(false)
|
||||||
@@ -231,6 +212,18 @@ const visibleLinkedExhibits = computed(() => (
|
|||||||
[...(exhibit.value.linkedExhibits || [])]
|
[...(exhibit.value.linkedExhibits || [])]
|
||||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
.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(
|
const supportedDetailLanguages = computed(() => new Set(
|
||||||
(exhibit.value.audio.supportedLanguages || [selectedAudioLanguage.value])
|
(exhibit.value.audio.supportedLanguages || [selectedAudioLanguage.value])
|
||||||
.filter(isAudioLanguage)
|
.filter(isAudioLanguage)
|
||||||
@@ -284,10 +277,9 @@ const resolveHallGuidePoi = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resetAudioPlayer = () => {
|
const resetAudioPlayer = () => {
|
||||||
audioPlayerRef.value?.pause()
|
if (isCurrentDetailAudio.value) {
|
||||||
showAudioPlayer.value = false
|
globalAudioPlayer.close()
|
||||||
currentAudio.value = null
|
}
|
||||||
isPlaying.value = false
|
|
||||||
retryingAudio.value = false
|
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 () => {
|
const handlePlayAudio = async () => {
|
||||||
|
if (isCurrentDetailAudio.value) {
|
||||||
|
if (globalAudioPlayer.playing.value) {
|
||||||
|
globalAudioPlayer.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void globalAudioPlayer.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const selection = exhibit.value.id
|
const selection = exhibit.value.id
|
||||||
? await explainUseCase.selectAudioForExplainDetail({
|
? await explainUseCase.selectAudioForExplainDetail({
|
||||||
id: exhibit.value.id,
|
id: exhibit.value.id,
|
||||||
name: exhibit.value.title,
|
name: exhibit.value.title,
|
||||||
image: heroImage.value,
|
image: heroImage.value,
|
||||||
|
description: exhibit.value.summary,
|
||||||
|
guideText: exhibit.value.summary,
|
||||||
audioUrl: exhibit.value.audio.url,
|
audioUrl: exhibit.value.audio.url,
|
||||||
audioDuration: exhibit.value.audio.duration,
|
audioDuration: exhibit.value.audio.duration,
|
||||||
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
|
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
|
||||||
@@ -437,99 +509,20 @@ const handlePlayAudio = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const audio: AudioItem = {
|
const audio = toAudioItem(selection)
|
||||||
id: selection.media.id,
|
if (!audio) return
|
||||||
name: selection.playInfo?.title || selection.exhibit.name,
|
|
||||||
audioUrl: selection.media.url,
|
|
||||||
image: heroImage.value,
|
|
||||||
duration: selection.media.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSameAudio = currentAudio.value?.id === audio.id
|
await globalAudioPlayer.play(audio, {
|
||||||
if (showAudioPlayer.value && isSameAudio && isPlaying.value) {
|
source: {
|
||||||
audioPlayerRef.value?.pause()
|
exhibitId: exhibit.value.id,
|
||||||
return
|
targetType: currentAudioTarget.value.targetType,
|
||||||
}
|
targetId: currentAudioTarget.value.targetId,
|
||||||
|
lang: selectedAudioLanguage.value,
|
||||||
showAudioPlayer.value = true
|
title: audio.name,
|
||||||
currentAudio.value = audio
|
detailRoute: buildDetailRoute()
|
||||||
await nextTick()
|
},
|
||||||
audioPlayerRef.value?.play(audio)
|
retryOnError: refreshCurrentAudioOnce
|
||||||
}
|
|
||||||
|
|
||||||
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 || '音频加载失败,当前提供图文讲解。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNavigate = async () => {
|
const handleNavigate = async () => {
|
||||||
@@ -565,9 +558,14 @@ const handleNavigate = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackToExplainHome = () => {
|
const fallbackToExplainHome = () => {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: '/pages/index/index?tab=explain',
|
||||||
|
fail: () => {
|
||||||
uni.reLaunch({
|
uni.reLaunch({
|
||||||
url: '/pages/index/index?tab=explain'
|
url: '/pages/index/index?tab=explain'
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
|||||||
@@ -152,12 +152,17 @@ onLoad(() => {
|
|||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
uni.navigateBack({
|
uni.navigateBack({
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
fail: () => {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: '/pages/index/index?tab=explain',
|
||||||
fail: () => {
|
fail: () => {
|
||||||
uni.reLaunch({
|
uni.reLaunch({
|
||||||
url: '/pages/index/index?tab=explain'
|
url: '/pages/index/index?tab=explain'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||||
|
|||||||
@@ -239,12 +239,17 @@ const handleNavigate = async () => {
|
|||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
uni.navigateBack({
|
uni.navigateBack({
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
fail: () => {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: '/pages/index/index?tab=explain',
|
||||||
fail: () => {
|
fail: () => {
|
||||||
uni.reLaunch({
|
uni.reLaunch({
|
||||||
url: '/pages/index/index?tab=explain'
|
url: '/pages/index/index?tab=explain'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user