实现讲解全局播放器

This commit is contained in:
lyf
2026-07-07 23:35:03 +08:00
parent 1d930fa63a
commit 07beae95d0
7 changed files with 599 additions and 159 deletions

View File

@@ -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>

View 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>