chore: freeze guide and explain update
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="audio-player" v-if="visible">
|
||||
<view class="audio-player-host">
|
||||
<view class="audio-player" v-if="visible && currentAudio">
|
||||
<!-- 迷你播放器 (底部固定) -->
|
||||
<view class="mini-player" @tap="handleExpand">
|
||||
<!-- 封面图 -->
|
||||
@@ -14,7 +15,7 @@
|
||||
<!-- 播放信息 -->
|
||||
<view class="player-info">
|
||||
<text class="player-title">{{ currentAudio?.name || '讲解音频' }}</text>
|
||||
<text class="player-subtitle">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</text>
|
||||
<text class="player-subtitle" :class="{ error: playbackError }">{{ playerSubtitle }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 播放/暂停按钮 -->
|
||||
@@ -46,6 +47,7 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -71,6 +73,7 @@ const emit = defineEmits<{
|
||||
pause: [audio: AudioItem]
|
||||
ended: [audio: AudioItem]
|
||||
timeUpdate: [currentTime: number]
|
||||
error: [audio: AudioItem | null, message: string]
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
@@ -79,14 +82,34 @@ const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const playbackError = ref('')
|
||||
const h5AudioElement = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// 内部音频上下文
|
||||
let innerAudioContext: any = null
|
||||
let isStoppingAudio = false
|
||||
let cleanupH5Audio: (() => void) | null = null
|
||||
|
||||
// 计算进度百分比
|
||||
const progressPercent = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
const totalDuration = resolvedDuration.value
|
||||
if (totalDuration === 0) return 0
|
||||
return (currentTime.value / totalDuration) * 100
|
||||
})
|
||||
|
||||
const resolvedDuration = computed(() => {
|
||||
if (duration.value > 0) return duration.value
|
||||
return currentAudio.value?.duration || 0
|
||||
})
|
||||
|
||||
const playerSubtitle = computed(() => {
|
||||
if (playbackError.value) return playbackError.value
|
||||
if (isLoading.value) return '音频加载中'
|
||||
if (!currentAudio.value) return '请选择讲解'
|
||||
if (resolvedDuration.value > 0) {
|
||||
return `${formatTime(currentTime.value)} / ${formatTime(resolvedDuration.value)}`
|
||||
}
|
||||
return isPlaying.value ? '正在播放' : '点击播放按钮开始'
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
@@ -98,12 +121,18 @@ const formatTime = (seconds: number): string => {
|
||||
|
||||
// 初始化音频上下文
|
||||
const initAudio = () => {
|
||||
if (h5AudioElement.value) return
|
||||
|
||||
if (typeof uni !== 'undefined' && uni.createInnerAudioContext) {
|
||||
innerAudioContext = uni.createInnerAudioContext()
|
||||
|
||||
innerAudioContext.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
isLoading.value = false
|
||||
playbackError.value = ''
|
||||
if (currentAudio.value) {
|
||||
emit('play', currentAudio.value)
|
||||
}
|
||||
})
|
||||
|
||||
innerAudioContext.onPause(() => {
|
||||
@@ -131,12 +160,7 @@ const initAudio = () => {
|
||||
|
||||
innerAudioContext.onError((err: any) => {
|
||||
console.error('音频播放错误:', err)
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
uni.showToast({
|
||||
title: '音频加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
handlePlaybackError('音频加载失败,当前提供图文讲解。', true)
|
||||
})
|
||||
|
||||
innerAudioContext.onWaiting(() => {
|
||||
@@ -145,22 +169,143 @@ const initAudio = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const initH5Audio = () => {
|
||||
if (h5AudioElement.value || typeof window === 'undefined' || !window.Audio) return
|
||||
|
||||
const audio = new window.Audio()
|
||||
audio.preload = 'metadata'
|
||||
audio.addEventListener('loadedmetadata', handleH5LoadedMetadata)
|
||||
audio.addEventListener('play', handleH5Play)
|
||||
audio.addEventListener('pause', handleH5Pause)
|
||||
audio.addEventListener('ended', handleH5Ended)
|
||||
audio.addEventListener('timeupdate', handleH5TimeUpdate)
|
||||
audio.addEventListener('waiting', handleH5Waiting)
|
||||
audio.addEventListener('canplay', handleH5CanPlay)
|
||||
audio.addEventListener('error', handleH5AudioError)
|
||||
h5AudioElement.value = audio
|
||||
|
||||
cleanupH5Audio = () => {
|
||||
audio.removeEventListener('loadedmetadata', handleH5LoadedMetadata)
|
||||
audio.removeEventListener('play', handleH5Play)
|
||||
audio.removeEventListener('pause', handleH5Pause)
|
||||
audio.removeEventListener('ended', handleH5Ended)
|
||||
audio.removeEventListener('timeupdate', handleH5TimeUpdate)
|
||||
audio.removeEventListener('waiting', handleH5Waiting)
|
||||
audio.removeEventListener('canplay', handleH5CanPlay)
|
||||
audio.removeEventListener('error', handleH5AudioError)
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
h5AudioElement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const isAutoplayBlocked = (error: unknown) => {
|
||||
const name = typeof error === 'object' && error && 'name' in error
|
||||
? String((error as { name?: unknown }).name)
|
||||
: ''
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
|
||||
return name === 'NotAllowedError' || /user didn't interact|not allowed|gesture/i.test(message)
|
||||
}
|
||||
|
||||
const showToast = (title: string) => {
|
||||
if (typeof uni !== 'undefined') {
|
||||
uni.showToast({
|
||||
title,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlePlaybackError = (message: string, shouldClose = false) => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
playbackError.value = message
|
||||
emit('error', currentAudio.value, message)
|
||||
showToast(message)
|
||||
|
||||
if (shouldClose) {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetPlaybackState = (clearAudio = true) => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
playbackError.value = ''
|
||||
|
||||
if (clearAudio) {
|
||||
currentAudio.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
const playAudio = (audio: AudioItem) => {
|
||||
const playAudio = async (audio: AudioItem) => {
|
||||
if (!audio.audioUrl) {
|
||||
currentAudio.value = audio
|
||||
handlePlaybackError('该讲解暂无已发布音频,当前提供图文讲解。', true)
|
||||
return false
|
||||
}
|
||||
|
||||
const isNewAudio = currentAudio.value?.id !== audio.id
|
||||
currentAudio.value = audio
|
||||
playbackError.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
if (isNewAudio) {
|
||||
currentTime.value = 0
|
||||
duration.value = audio.duration || 0
|
||||
}
|
||||
|
||||
const h5Audio = h5AudioElement.value
|
||||
if (h5Audio) {
|
||||
if (h5Audio.getAttribute('src') !== audio.audioUrl) {
|
||||
h5Audio.src = audio.audioUrl
|
||||
h5Audio.load()
|
||||
}
|
||||
|
||||
try {
|
||||
await h5Audio.play()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('H5 音频播放被拦截或失败:', error)
|
||||
isLoading.value = false
|
||||
isPlaying.value = false
|
||||
|
||||
if (isAutoplayBlocked(error)) {
|
||||
playbackError.value = '点击播放按钮开始播放'
|
||||
return false
|
||||
}
|
||||
|
||||
handlePlaybackError('音频暂时无法播放,当前提供图文讲解。', true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!innerAudioContext) {
|
||||
initAudio()
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
currentAudio.value = audio
|
||||
innerAudioContext.src = audio.audioUrl
|
||||
innerAudioContext.play()
|
||||
emit('play', audio)
|
||||
return true
|
||||
}
|
||||
|
||||
handlePlaybackError('当前环境暂不支持音频播放。', true)
|
||||
return false
|
||||
}
|
||||
|
||||
// 暂停音频
|
||||
const pauseAudio = () => {
|
||||
if (h5AudioElement.value) {
|
||||
h5AudioElement.value.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.pause()
|
||||
if (currentAudio.value) {
|
||||
@@ -171,6 +316,11 @@ const pauseAudio = () => {
|
||||
|
||||
// 恢复播放
|
||||
const resumeAudio = () => {
|
||||
if (currentAudio.value) {
|
||||
void playAudio(currentAudio.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.play()
|
||||
}
|
||||
@@ -178,12 +328,22 @@ const resumeAudio = () => {
|
||||
|
||||
// 停止音频
|
||||
const stopAudio = () => {
|
||||
const h5Audio = h5AudioElement.value
|
||||
if (h5Audio) {
|
||||
isStoppingAudio = true
|
||||
h5Audio.pause()
|
||||
h5Audio.removeAttribute('src')
|
||||
h5Audio.load()
|
||||
setTimeout(() => {
|
||||
isStoppingAudio = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.stop()
|
||||
currentAudio.value = null
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
}
|
||||
|
||||
resetPlaybackState()
|
||||
}
|
||||
|
||||
// 切换播放/暂停
|
||||
@@ -199,8 +359,18 @@ const togglePlay = () => {
|
||||
|
||||
// 跳转到指定位置
|
||||
const seekTo = (percent: number) => {
|
||||
if (innerAudioContext && duration.value > 0) {
|
||||
const targetTime = (percent / 100) * duration.value
|
||||
const totalDuration = resolvedDuration.value
|
||||
if (totalDuration <= 0) return
|
||||
|
||||
const targetTime = (percent / 100) * totalDuration
|
||||
|
||||
if (h5AudioElement.value) {
|
||||
h5AudioElement.value.currentTime = targetTime
|
||||
currentTime.value = targetTime
|
||||
return
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.seek(targetTime)
|
||||
currentTime.value = targetTime
|
||||
}
|
||||
@@ -226,10 +396,65 @@ const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleH5LoadedMetadata = () => {
|
||||
const audio = h5AudioElement.value
|
||||
if (!audio) return
|
||||
duration.value = Number.isFinite(audio.duration) ? audio.duration : currentAudio.value?.duration || 0
|
||||
}
|
||||
|
||||
const handleH5Play = () => {
|
||||
isPlaying.value = true
|
||||
isLoading.value = false
|
||||
playbackError.value = ''
|
||||
if (currentAudio.value) {
|
||||
emit('play', currentAudio.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5Pause = () => {
|
||||
isPlaying.value = false
|
||||
if (currentAudio.value) {
|
||||
emit('pause', currentAudio.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5Ended = () => {
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
currentTime.value = 0
|
||||
if (currentAudio.value) {
|
||||
emit('ended', currentAudio.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5TimeUpdate = () => {
|
||||
const audio = h5AudioElement.value
|
||||
if (!audio) return
|
||||
currentTime.value = audio.currentTime || 0
|
||||
duration.value = Number.isFinite(audio.duration) ? audio.duration : duration.value
|
||||
emit('timeUpdate', currentTime.value)
|
||||
}
|
||||
|
||||
const handleH5Waiting = () => {
|
||||
isLoading.value = true
|
||||
}
|
||||
|
||||
const handleH5CanPlay = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const handleH5AudioError = () => {
|
||||
if (isStoppingAudio || !currentAudio.value?.audioUrl) return
|
||||
handlePlaybackError('音频加载失败,当前提供图文讲解。', true)
|
||||
}
|
||||
|
||||
// 监听外部 audio 属性变化
|
||||
watch(() => props.audio, (newAudio) => {
|
||||
if (newAudio && props.visible) {
|
||||
playAudio(newAudio)
|
||||
if (newAudio && props.visible && props.autoPlay && currentAudio.value?.id !== newAudio.id) {
|
||||
void playAudio(newAudio)
|
||||
} else if (newAudio && currentAudio.value?.id !== newAudio.id) {
|
||||
currentAudio.value = newAudio
|
||||
duration.value = newAudio.duration || 0
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -241,10 +466,14 @@ watch(() => props.visible, (visible) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initH5Audio()
|
||||
initAudio()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupH5Audio?.()
|
||||
cleanupH5Audio = null
|
||||
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.destroy()
|
||||
innerAudioContext = null
|
||||
@@ -264,17 +493,39 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audio-player-host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.native-audio {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--museum-bg-surface);
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.audio-player {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 430px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.mini-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -319,6 +570,10 @@ defineExpose({
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.player-subtitle.error {
|
||||
color: #9b3d2e;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -30,9 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
isPlaying?: boolean
|
||||
showIndicator?: boolean
|
||||
}>(), {
|
||||
@@ -61,6 +59,12 @@ const handleClick = () => {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fab-container {
|
||||
right: calc((100vw - 430px) / 2 + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.playing-indicator {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user