chore: initialize frontend miniapp repository
This commit is contained in:
390
src/components/audio/AudioPlayer.vue
Normal file
390
src/components/audio/AudioPlayer.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<template>
|
||||
<view class="audio-player" v-if="visible">
|
||||
<!-- 迷你播放器 (底部固定) -->
|
||||
<view class="mini-player" @tap="handleExpand">
|
||||
<!-- 封面图 -->
|
||||
<view class="player-cover">
|
||||
<image
|
||||
class="cover-image"
|
||||
:src="currentAudio?.image || '/static/exhibit-placeholder.jpg'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 播放信息 -->
|
||||
<view class="player-info">
|
||||
<text class="player-title">{{ currentAudio?.name || '讲解音频' }}</text>
|
||||
<text class="player-subtitle">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</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">
|
||||
<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>
|
||||
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="control-btn close-btn" @tap.stop="handleClose">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 播放进度条 -->
|
||||
<view class="progress-bar">
|
||||
<view class="progress-track" @tap="handleSeek">
|
||||
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
||||
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
export interface AudioItem {
|
||||
id: string
|
||||
name: string
|
||||
audioUrl: string
|
||||
image?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible?: boolean
|
||||
audio?: AudioItem | null
|
||||
autoPlay?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
play: [audio: AudioItem]
|
||||
pause: [audio: AudioItem]
|
||||
ended: [audio: AudioItem]
|
||||
timeUpdate: [currentTime: number]
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
const currentAudio = ref<AudioItem | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 内部音频上下文
|
||||
let innerAudioContext: any = null
|
||||
|
||||
// 计算进度百分比
|
||||
const progressPercent = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 初始化音频上下文
|
||||
const initAudio = () => {
|
||||
if (typeof uni !== 'undefined' && uni.createInnerAudioContext) {
|
||||
innerAudioContext = uni.createInnerAudioContext()
|
||||
|
||||
innerAudioContext.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
innerAudioContext.onPause(() => {
|
||||
isPlaying.value = false
|
||||
})
|
||||
|
||||
innerAudioContext.onStop(() => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
})
|
||||
|
||||
innerAudioContext.onEnded(() => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
if (currentAudio.value) {
|
||||
emit('ended', currentAudio.value)
|
||||
}
|
||||
})
|
||||
|
||||
innerAudioContext.onTimeUpdate(() => {
|
||||
currentTime.value = innerAudioContext.currentTime
|
||||
duration.value = innerAudioContext.duration || 0
|
||||
emit('timeUpdate', currentTime.value)
|
||||
})
|
||||
|
||||
innerAudioContext.onError((err: any) => {
|
||||
console.error('音频播放错误:', err)
|
||||
isPlaying.value = false
|
||||
isLoading.value = false
|
||||
uni.showToast({
|
||||
title: '音频加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
innerAudioContext.onWaiting(() => {
|
||||
isLoading.value = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
const playAudio = (audio: AudioItem) => {
|
||||
if (!innerAudioContext) {
|
||||
initAudio()
|
||||
}
|
||||
|
||||
if (innerAudioContext) {
|
||||
currentAudio.value = audio
|
||||
innerAudioContext.src = audio.audioUrl
|
||||
innerAudioContext.play()
|
||||
emit('play', audio)
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停音频
|
||||
const pauseAudio = () => {
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.pause()
|
||||
if (currentAudio.value) {
|
||||
emit('pause', currentAudio.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复播放
|
||||
const resumeAudio = () => {
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.play()
|
||||
}
|
||||
}
|
||||
|
||||
// 停止音频
|
||||
const stopAudio = () => {
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.stop()
|
||||
currentAudio.value = null
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 切换播放/暂停
|
||||
const togglePlay = () => {
|
||||
if (isPlaying.value) {
|
||||
pauseAudio()
|
||||
} else {
|
||||
if (currentAudio.value) {
|
||||
resumeAudio()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定位置
|
||||
const seekTo = (percent: number) => {
|
||||
if (innerAudioContext && duration.value > 0) {
|
||||
const targetTime = (percent / 100) * duration.value
|
||||
innerAudioContext.seek(targetTime)
|
||||
currentTime.value = targetTime
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条点击
|
||||
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 handleExpand = () => {
|
||||
// 展开逻辑 (如果需要全屏播放器)
|
||||
console.log('展开播放器')
|
||||
}
|
||||
|
||||
// 关闭播放器
|
||||
const handleClose = () => {
|
||||
stopAudio()
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 监听外部 audio 属性变化
|
||||
watch(() => props.audio, (newAudio) => {
|
||||
if (newAudio && props.visible) {
|
||||
playAudio(newAudio)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听外部 visible 属性变化
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (!visible) {
|
||||
stopAudio()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initAudio()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (innerAudioContext) {
|
||||
innerAudioContext.destroy()
|
||||
innerAudioContext = null
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法给外部调用
|
||||
defineExpose({
|
||||
play: playAudio,
|
||||
pause: pauseAudio,
|
||||
stop: stopAudio,
|
||||
seekTo: seekTo,
|
||||
isPlaying: () => isPlaying.value,
|
||||
currentTime: () => currentTime.value,
|
||||
duration: () => duration.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audio-player {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
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);
|
||||
}
|
||||
|
||||
.mini-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-cover {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-small);
|
||||
overflow: hidden;
|
||||
background-color: var(--museum-bg-light);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--museum-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: var(--museum-accent);
|
||||
border-radius: 50%;
|
||||
color: var(--museum-text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--museum-text-secondary);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--museum-border-light);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: var(--museum-accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.progress-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--museum-accent);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user