chore: freeze guide and explain update

This commit is contained in:
lyf
2026-06-24 18:00:25 +08:00
parent feb7310a46
commit 67c6609ae6
104 changed files with 3203572 additions and 40713 deletions

View File

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

View File

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