实现讲解全局播放器
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>
|
||||
Reference in New Issue
Block a user