1219 lines
30 KiB
Vue
1219 lines
30 KiB
Vue
<template>
|
|
<view class="audio-player-host">
|
|
<view
|
|
v-if="visible && displayAudio"
|
|
class="audio-player"
|
|
:class="[`mode-${displayMode}`, { 'avoid-bottom-nav': avoidBottomNav }]"
|
|
>
|
|
<view v-if="displayMode === 'expanded'" class="expanded-player">
|
|
<view class="player-handle"></view>
|
|
<view class="expanded-main">
|
|
<image
|
|
v-if="displayAudio.image"
|
|
class="expanded-cover"
|
|
:src="displayAudio.image"
|
|
mode="aspectFill"
|
|
/>
|
|
<view class="expanded-copy">
|
|
<text class="player-kicker">讲解音频</text>
|
|
<text class="expanded-title">{{ displayAudio.name }}</text>
|
|
<text class="expanded-subtitle" :class="{ error: displayPlaybackError }">{{ expandedSubtitle }}</text>
|
|
</view>
|
|
<view class="expanded-side">
|
|
<view
|
|
v-if="isLanguageSwitchable"
|
|
class="player-language-switch expanded-language"
|
|
@tap.stop
|
|
>
|
|
<view
|
|
v-for="option in audioLanguageOptions"
|
|
:key="option.value"
|
|
class="player-language-option"
|
|
:class="{
|
|
active: effectiveAudioLanguage === option.value,
|
|
pending: displayPendingLanguage === option.value,
|
|
disabled: isLanguagePending && displayPendingLanguage !== option.value
|
|
}"
|
|
@tap.stop="handleLanguageChange(option.value)"
|
|
>
|
|
<text class="player-language-text">{{ formatLanguageOptionText(option) }}</text>
|
|
</view>
|
|
</view>
|
|
<view v-else class="player-language-state expanded-language">
|
|
<text class="player-language-text">{{ displayAudioLanguageShort }}</text>
|
|
</view>
|
|
<view class="control-btn play-btn expanded-play" @tap.stop="togglePlay">
|
|
<svg v-if="displayPlaying" width="30" height="30" 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="30" height="30" viewBox="0 0 24 24" fill="none">
|
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
|
</svg>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="expanded-progress-row">
|
|
<text class="time-text active">{{ formattedCurrentTime }}</text>
|
|
<view class="progress-track expanded-track" @tap="handleSeek">
|
|
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
|
<view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
|
|
</view>
|
|
<text class="time-text">{{ formattedDuration }}</text>
|
|
</view>
|
|
|
|
<view class="expanded-actions">
|
|
<view class="action-btn collapse-action" @tap.stop="handleCollapse">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
<path d="M6 15L12 9L18 15" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
<text class="action-text">收起</text>
|
|
</view>
|
|
<view class="action-divider"></view>
|
|
<view class="action-btn stop-action" @tap.stop="handleStop">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
<rect x="7" y="7" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="2"/>
|
|
</svg>
|
|
<text class="action-text">停止播放</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-else-if="displayMode === 'floating'" class="floating-player" @tap="handleExpand">
|
|
<view class="floating-ring" :style="{ '--progress': progressPercent + '%' }">
|
|
<view class="control-btn floating-play" @tap.stop="togglePlay">
|
|
<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>
|
|
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
|
</svg>
|
|
</view>
|
|
<text class="floating-label">讲解</text>
|
|
<text class="floating-lang">{{ displayAudioLanguageShort }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-else class="mini-player" @tap="handleExpand">
|
|
<view class="control-btn play-btn mini-play" @tap.stop="togglePlay">
|
|
<svg v-if="displayPlaying" width="26" height="26" 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="26" height="26" viewBox="0 0 24 24" fill="none">
|
|
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
|
|
</svg>
|
|
</view>
|
|
|
|
<view class="mini-copy">
|
|
<text class="player-kicker">讲解音频</text>
|
|
<text class="mini-title">{{ displayAudio.name }}</text>
|
|
</view>
|
|
|
|
<view class="mini-progress">
|
|
<view class="progress-track" @tap.stop="handleSeek">
|
|
<view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="isLanguageSwitchable" class="player-language-switch mini-language" @tap.stop>
|
|
<view
|
|
v-for="option in audioLanguageOptions"
|
|
:key="option.value"
|
|
class="player-language-option"
|
|
:class="{
|
|
active: effectiveAudioLanguage === option.value,
|
|
pending: displayPendingLanguage === option.value,
|
|
disabled: isLanguagePending && displayPendingLanguage !== option.value
|
|
}"
|
|
@tap.stop="handleLanguageChange(option.value)"
|
|
>
|
|
<text class="player-language-text">{{ formatLanguageOptionText(option) }}</text>
|
|
</view>
|
|
</view>
|
|
<view v-else class="player-language-state mini-language">
|
|
<text class="player-language-text">{{ displayAudioLanguageShort }}</text>
|
|
</view>
|
|
|
|
<view class="expand-action">
|
|
<text class="expand-text">展开</text>
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none">
|
|
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</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
|
|
language?: AudioLanguage
|
|
supportedLanguages?: AudioLanguage[]
|
|
}
|
|
|
|
export type AudioLanguage = 'zh-CN' | 'en-US'
|
|
export type AudioDisplayMode = 'expanded' | 'mini' | 'floating'
|
|
|
|
const props = defineProps<{
|
|
visible?: boolean
|
|
audio?: AudioItem | null
|
|
autoPlay?: boolean
|
|
avoidBottomNav?: boolean
|
|
mode?: AudioDisplayMode
|
|
controlled?: boolean
|
|
playing?: boolean
|
|
loading?: boolean
|
|
playbackError?: string
|
|
currentTime?: number
|
|
durationValue?: number
|
|
pendingLanguage?: AudioLanguage | ''
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:visible': [value: boolean]
|
|
play: [audio: AudioItem]
|
|
pause: [audio: AudioItem]
|
|
ended: [audio: AudioItem]
|
|
timeUpdate: [currentTime: number]
|
|
error: [audio: AudioItem | null, message: string]
|
|
toggle: []
|
|
close: []
|
|
collapse: []
|
|
stop: []
|
|
expand: []
|
|
seek: [percent: number]
|
|
'language-change': [language: AudioLanguage]
|
|
}>()
|
|
|
|
// 状态
|
|
const currentAudio = ref<AudioItem | null>(null)
|
|
const isPlaying = ref(false)
|
|
const localCurrentTime = ref(0)
|
|
const duration = ref(0)
|
|
const isLoading = ref(false)
|
|
const localPlaybackError = ref('')
|
|
const h5AudioElement = ref<HTMLAudioElement | null>(null)
|
|
|
|
// 内部音频上下文
|
|
let innerAudioContext: any = null
|
|
let isStoppingAudio = false
|
|
let cleanupH5Audio: (() => void) | null = null
|
|
|
|
// 计算进度百分比
|
|
const progressPercent = computed(() => {
|
|
const totalDuration = resolvedDuration.value
|
|
if (totalDuration === 0) return 0
|
|
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 displayMode = computed<AudioDisplayMode>(() => props.mode || 'mini')
|
|
const audioLanguageOptions: Array<{ value: AudioLanguage; label: string; shortLabel: string }> = [
|
|
{ value: 'zh-CN', label: '中文', shortLabel: '中文' },
|
|
{ value: 'en-US', label: 'English', shortLabel: 'EN' }
|
|
]
|
|
const displayAudioLanguage = computed<AudioLanguage>(() => (
|
|
displayAudio.value?.language === 'en-US' ? 'en-US' : 'zh-CN'
|
|
))
|
|
const supportedAudioLanguages = computed(() => {
|
|
const languages = displayAudio.value?.supportedLanguages?.length
|
|
? displayAudio.value.supportedLanguages
|
|
: [displayAudioLanguage.value]
|
|
|
|
return Array.from(new Set(languages.filter((language): language is AudioLanguage => (
|
|
language === 'zh-CN' || language === 'en-US'
|
|
))))
|
|
})
|
|
const isLanguageSwitchable = computed(() => supportedAudioLanguages.value.length > 1)
|
|
const displayPendingLanguage = computed<AudioLanguage | ''>(() => props.pendingLanguage || '')
|
|
const effectiveAudioLanguage = computed<AudioLanguage>(() => (
|
|
displayPendingLanguage.value || displayAudioLanguage.value
|
|
))
|
|
const isLanguagePending = computed(() => Boolean(displayPendingLanguage.value))
|
|
const displayAudioLanguageShort = computed(() => (
|
|
displayAudioLanguage.value === 'en-US' ? 'EN' : '中文'
|
|
))
|
|
const formattedCurrentTime = computed(() => formatTime(displayCurrentTime.value))
|
|
const formattedDuration = computed(() => formatTime(resolvedDuration.value))
|
|
const expandedSubtitle = computed(() => {
|
|
if (displayPlaybackError.value) return displayPlaybackError.value
|
|
if (displayLoading.value) return '音频加载中'
|
|
if (!displayAudio.value?.audioUrl) return '当前语言暂无语音讲解'
|
|
return displayAudioLanguage.value === 'en-US' ? '英文语音讲解' : '中文语音讲解'
|
|
})
|
|
|
|
// 格式化时间
|
|
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 formatLanguageOptionText = (option: { shortLabel: string; value: AudioLanguage }) => (
|
|
displayPendingLanguage.value === option.value ? `${option.shortLabel}...` : option.shortLabel
|
|
)
|
|
|
|
// 初始化音频上下文
|
|
const initAudio = () => {
|
|
if (h5AudioElement.value) return
|
|
|
|
if (typeof uni !== 'undefined' && uni.createInnerAudioContext) {
|
|
innerAudioContext = uni.createInnerAudioContext()
|
|
|
|
innerAudioContext.onPlay(() => {
|
|
isPlaying.value = true
|
|
isLoading.value = false
|
|
localPlaybackError.value = ''
|
|
if (currentAudio.value) {
|
|
emit('play', currentAudio.value)
|
|
}
|
|
})
|
|
|
|
innerAudioContext.onPause(() => {
|
|
isPlaying.value = false
|
|
})
|
|
|
|
innerAudioContext.onStop(() => {
|
|
isPlaying.value = false
|
|
localCurrentTime.value = 0
|
|
})
|
|
|
|
innerAudioContext.onEnded(() => {
|
|
isPlaying.value = false
|
|
localCurrentTime.value = 0
|
|
if (currentAudio.value) {
|
|
emit('ended', currentAudio.value)
|
|
}
|
|
})
|
|
|
|
innerAudioContext.onTimeUpdate(() => {
|
|
localCurrentTime.value = innerAudioContext.currentTime
|
|
duration.value = innerAudioContext.duration || 0
|
|
emit('timeUpdate', localCurrentTime.value)
|
|
})
|
|
|
|
innerAudioContext.onError((err: any) => {
|
|
console.error('音频播放错误:', err)
|
|
handlePlaybackError('音频加载失败,当前提供图文讲解。', true)
|
|
})
|
|
|
|
innerAudioContext.onWaiting(() => {
|
|
isLoading.value = true
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
localPlaybackError.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
|
|
localCurrentTime.value = 0
|
|
duration.value = 0
|
|
localPlaybackError.value = ''
|
|
|
|
if (clearAudio) {
|
|
currentAudio.value = null
|
|
}
|
|
}
|
|
|
|
// 播放音频
|
|
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
|
|
localPlaybackError.value = ''
|
|
isLoading.value = true
|
|
|
|
if (isNewAudio) {
|
|
localCurrentTime.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)) {
|
|
localPlaybackError.value = '点击播放按钮开始播放'
|
|
return false
|
|
}
|
|
|
|
handlePlaybackError('音频暂时无法播放,当前提供图文讲解。', true)
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (!innerAudioContext) {
|
|
initAudio()
|
|
}
|
|
|
|
if (innerAudioContext) {
|
|
innerAudioContext.src = audio.audioUrl
|
|
innerAudioContext.play()
|
|
return true
|
|
}
|
|
|
|
handlePlaybackError('当前环境暂不支持音频播放。', true)
|
|
return false
|
|
}
|
|
|
|
// 暂停音频
|
|
const pauseAudio = () => {
|
|
if (h5AudioElement.value) {
|
|
h5AudioElement.value.pause()
|
|
return
|
|
}
|
|
|
|
if (innerAudioContext) {
|
|
innerAudioContext.pause()
|
|
if (currentAudio.value) {
|
|
emit('pause', currentAudio.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 恢复播放
|
|
const resumeAudio = () => {
|
|
if (currentAudio.value) {
|
|
void playAudio(currentAudio.value)
|
|
return
|
|
}
|
|
|
|
if (innerAudioContext) {
|
|
innerAudioContext.play()
|
|
}
|
|
}
|
|
|
|
// 停止音频
|
|
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()
|
|
}
|
|
|
|
resetPlaybackState()
|
|
}
|
|
|
|
// 切换播放/暂停
|
|
const togglePlay = () => {
|
|
if (props.controlled) {
|
|
emit('toggle')
|
|
return
|
|
}
|
|
|
|
if (isPlaying.value) {
|
|
pauseAudio()
|
|
} else {
|
|
if (currentAudio.value) {
|
|
resumeAudio()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 跳转到指定位置
|
|
const seekTo = (percent: number) => {
|
|
const totalDuration = resolvedDuration.value
|
|
if (totalDuration <= 0) return
|
|
|
|
const targetTime = (percent / 100) * totalDuration
|
|
|
|
if (h5AudioElement.value) {
|
|
h5AudioElement.value.currentTime = targetTime
|
|
localCurrentTime.value = targetTime
|
|
return
|
|
}
|
|
|
|
if (innerAudioContext) {
|
|
innerAudioContext.seek(targetTime)
|
|
localCurrentTime.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
|
|
const nextPercent = Math.max(0, Math.min(100, percent))
|
|
if (props.controlled) {
|
|
emit('seek', nextPercent)
|
|
return
|
|
}
|
|
seekTo(nextPercent)
|
|
}
|
|
|
|
// 展开播放器
|
|
const handleExpand = () => {
|
|
emit('expand')
|
|
}
|
|
|
|
const handleCollapse = () => {
|
|
emit('collapse')
|
|
}
|
|
|
|
const handleStop = () => {
|
|
if (props.controlled) {
|
|
emit('stop')
|
|
return
|
|
}
|
|
|
|
stopAudio()
|
|
emit('stop')
|
|
}
|
|
|
|
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
|
|
localPlaybackError.value = ''
|
|
if (currentAudio.value) {
|
|
emit('play', currentAudio.value)
|
|
}
|
|
}
|
|
|
|
const handleLanguageChange = (language: AudioLanguage) => {
|
|
if (isLanguagePending.value) return
|
|
if (!supportedAudioLanguages.value.includes(language) || language === displayAudioLanguage.value) return
|
|
emit('language-change', language)
|
|
}
|
|
|
|
const handleH5Pause = () => {
|
|
isPlaying.value = false
|
|
if (currentAudio.value) {
|
|
emit('pause', currentAudio.value)
|
|
}
|
|
}
|
|
|
|
const handleH5Ended = () => {
|
|
isPlaying.value = false
|
|
isLoading.value = false
|
|
localCurrentTime.value = 0
|
|
if (currentAudio.value) {
|
|
emit('ended', currentAudio.value)
|
|
}
|
|
}
|
|
|
|
const handleH5TimeUpdate = () => {
|
|
const audio = h5AudioElement.value
|
|
if (!audio) return
|
|
localCurrentTime.value = audio.currentTime || 0
|
|
duration.value = Number.isFinite(audio.duration) ? audio.duration : duration.value
|
|
emit('timeUpdate', localCurrentTime.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 (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) {
|
|
currentAudio.value = newAudio
|
|
duration.value = newAudio.duration || 0
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// 监听外部 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
|
|
|
|
if (innerAudioContext) {
|
|
innerAudioContext.destroy()
|
|
innerAudioContext = null
|
|
}
|
|
})
|
|
|
|
// 暴露方法给外部调用
|
|
defineExpose({
|
|
play: playAudio,
|
|
pause: pauseAudio,
|
|
stop: stopAudio,
|
|
seekTo: seekTo,
|
|
isPlaying: () => isPlaying.value,
|
|
currentTime: () => localCurrentTime.value,
|
|
duration: () => duration.value
|
|
})
|
|
</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 {
|
|
--audio-accent: #e0e100;
|
|
--audio-surface: #050505;
|
|
--audio-text: #ffffff;
|
|
--audio-muted: rgba(255, 255, 255, 0.68);
|
|
position: fixed;
|
|
left: 50%;
|
|
right: auto;
|
|
bottom: calc(12px + env(safe-area-inset-bottom));
|
|
z-index: 2200;
|
|
width: calc(min(100vw, 430px) - 28px);
|
|
box-sizing: border-box;
|
|
transform: translateX(-50%);
|
|
color: var(--audio-text);
|
|
font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
}
|
|
|
|
.audio-player.avoid-bottom-nav {
|
|
bottom: calc(82px + env(safe-area-inset-bottom));
|
|
}
|
|
|
|
.audio-player.mode-floating {
|
|
left: auto;
|
|
right: max(16px, calc((100vw - 430px) / 2 + 16px));
|
|
bottom: calc(102px + env(safe-area-inset-bottom));
|
|
width: 116px;
|
|
transform: none;
|
|
}
|
|
|
|
.audio-player.mode-floating.avoid-bottom-nav {
|
|
bottom: calc(104px + env(safe-area-inset-bottom));
|
|
}
|
|
|
|
.expanded-player,
|
|
.mini-player,
|
|
.floating-ring {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background:
|
|
radial-gradient(circle at 14% 18%, rgba(224, 225, 0, 0.08), transparent 28%),
|
|
linear-gradient(135deg, #161714 0%, #050505 100%);
|
|
border: 1px solid rgba(224, 225, 0, 0.2);
|
|
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28);
|
|
}
|
|
|
|
.expanded-player::after,
|
|
.mini-player::after,
|
|
.floating-ring::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: auto -24px -42px auto;
|
|
width: 150px;
|
|
height: 110px;
|
|
border: 1px solid rgba(224, 225, 0, 0.13);
|
|
border-radius: 50%;
|
|
transform: rotate(-16deg);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.mini-player::after {
|
|
inset: auto 8px 8px auto;
|
|
width: 120px;
|
|
height: 78px;
|
|
}
|
|
|
|
.expanded-player {
|
|
min-height: 292px;
|
|
padding: 28px 22px 16px;
|
|
box-sizing: border-box;
|
|
border-radius: 24px;
|
|
}
|
|
|
|
.player-handle {
|
|
position: absolute;
|
|
top: 11px;
|
|
left: 50%;
|
|
width: 54px;
|
|
height: 8px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.22);
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.expanded-main {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: grid;
|
|
grid-template-columns: 92px minmax(0, 1fr);
|
|
gap: 14px 16px;
|
|
align-items: center;
|
|
}
|
|
|
|
.expanded-cover {
|
|
width: 92px;
|
|
height: 92px;
|
|
border-radius: 14px;
|
|
background: #262421;
|
|
}
|
|
|
|
.expanded-copy,
|
|
.mini-copy {
|
|
min-width: 0;
|
|
}
|
|
|
|
.player-kicker,
|
|
.expanded-title,
|
|
.expanded-subtitle,
|
|
.mini-title,
|
|
.time-text,
|
|
.action-text,
|
|
.floating-label,
|
|
.floating-lang,
|
|
.expand-text {
|
|
display: block;
|
|
}
|
|
|
|
.player-kicker {
|
|
font-size: 17px;
|
|
line-height: 23px;
|
|
font-weight: 900;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.expanded-title {
|
|
margin-top: 8px;
|
|
font-size: 30px;
|
|
line-height: 37px;
|
|
font-weight: 900;
|
|
color: #ffffff;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.expanded-subtitle {
|
|
margin-top: 3px;
|
|
font-size: 15px;
|
|
line-height: 21px;
|
|
color: var(--audio-muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.expanded-subtitle.error {
|
|
color: #ffb0a2;
|
|
}
|
|
|
|
.expanded-side {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.control-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
|
|
|
&:active {
|
|
transform: scale(0.92);
|
|
opacity: 0.84;
|
|
}
|
|
}
|
|
|
|
.play-btn,
|
|
.floating-play {
|
|
flex-shrink: 0;
|
|
background: var(--audio-accent);
|
|
color: #050505;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.expanded-play {
|
|
width: 64px;
|
|
height: 64px;
|
|
}
|
|
|
|
.expanded-progress-row {
|
|
position: relative;
|
|
z-index: 1;
|
|
margin-top: 22px;
|
|
display: grid;
|
|
grid-template-columns: 66px minmax(0, 1fr) 58px;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.time-text {
|
|
font-size: 18px;
|
|
line-height: 24px;
|
|
font-weight: 700;
|
|
color: rgba(255, 255, 255, 0.66);
|
|
}
|
|
|
|
.time-text.active {
|
|
color: var(--audio-accent);
|
|
}
|
|
|
|
.progress-track {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 5px;
|
|
background: rgba(255, 255, 255, 0.24);
|
|
border-radius: 999px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.progress-fill {
|
|
position: absolute;
|
|
inset: 0 auto 0 0;
|
|
height: 100%;
|
|
background: var(--audio-accent);
|
|
border-radius: inherit;
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
.progress-thumb {
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 18px;
|
|
height: 18px;
|
|
background: var(--audio-accent);
|
|
border-radius: 50%;
|
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.28);
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
.expanded-actions {
|
|
position: relative;
|
|
z-index: 1;
|
|
margin-top: 24px;
|
|
padding-top: 15px;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1px 1fr;
|
|
align-items: center;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.14);
|
|
}
|
|
|
|
.action-btn {
|
|
min-width: 0;
|
|
height: 42px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 9px;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.stop-action {
|
|
color: rgba(255, 255, 255, 0.92);
|
|
}
|
|
|
|
.action-divider {
|
|
width: 1px;
|
|
height: 26px;
|
|
background: rgba(255, 255, 255, 0.16);
|
|
}
|
|
|
|
.action-text {
|
|
font-size: 17px;
|
|
line-height: 22px;
|
|
font-weight: 800;
|
|
color: currentColor;
|
|
}
|
|
|
|
.mini-player {
|
|
min-height: 116px;
|
|
padding: 12px 14px 14px;
|
|
display: grid;
|
|
grid-template-columns: 56px minmax(0, 1fr) auto auto;
|
|
grid-template-areas:
|
|
"play spacer language expand"
|
|
"copy copy copy copy"
|
|
"progress progress progress progress";
|
|
column-gap: 10px;
|
|
row-gap: 7px;
|
|
align-items: center;
|
|
box-sizing: border-box;
|
|
border-radius: 30px;
|
|
}
|
|
|
|
.mini-play {
|
|
grid-area: play;
|
|
width: 56px;
|
|
height: 56px;
|
|
}
|
|
|
|
.mini-copy {
|
|
grid-area: copy;
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.mini-copy .player-kicker {
|
|
flex: 0 0 auto;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.mini-title {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
margin-top: 2px;
|
|
font-size: 22px;
|
|
line-height: 28px;
|
|
font-weight: 900;
|
|
color: #ffffff;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.mini-progress {
|
|
grid-area: progress;
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.mini-progress .progress-track {
|
|
height: 4px;
|
|
}
|
|
|
|
.player-language-state,
|
|
.player-language-switch {
|
|
flex-shrink: 0;
|
|
min-width: 56px;
|
|
height: 34px;
|
|
padding: 0 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(224, 225, 0, 0.52);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
color: var(--audio-accent);
|
|
}
|
|
|
|
.mini-language {
|
|
grid-area: language;
|
|
justify-self: end;
|
|
}
|
|
|
|
.player-language-switch {
|
|
min-width: 92px;
|
|
padding: 3px;
|
|
justify-content: flex-start;
|
|
gap: 2px;
|
|
}
|
|
|
|
.player-language-option {
|
|
min-width: 40px;
|
|
height: 26px;
|
|
padding: 0 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
border-radius: 999px;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.player-language-option.active {
|
|
background: var(--audio-accent);
|
|
color: #050505;
|
|
}
|
|
|
|
.player-language-option.pending {
|
|
background: rgba(224, 225, 0, 0.76);
|
|
color: #050505;
|
|
box-shadow: 0 0 0 1px rgba(224, 225, 0, 0.26);
|
|
}
|
|
|
|
.player-language-option.disabled {
|
|
opacity: 0.52;
|
|
}
|
|
|
|
.player-language-text {
|
|
font-size: 14px;
|
|
line-height: 18px;
|
|
font-weight: 900;
|
|
color: currentColor;
|
|
}
|
|
|
|
.expand-action {
|
|
grid-area: expand;
|
|
min-width: 70px;
|
|
height: 42px;
|
|
padding-left: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 5px;
|
|
box-sizing: border-box;
|
|
border-left: 1px solid rgba(255, 255, 255, 0.16);
|
|
color: #ffffff;
|
|
}
|
|
|
|
.expand-text {
|
|
font-size: 18px;
|
|
line-height: 22px;
|
|
font-weight: 900;
|
|
color: currentColor;
|
|
}
|
|
|
|
.floating-player {
|
|
width: 116px;
|
|
height: 116px;
|
|
}
|
|
|
|
.floating-ring {
|
|
width: 116px;
|
|
height: 116px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.floating-ring::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 8px;
|
|
border-radius: 50%;
|
|
background:
|
|
conic-gradient(var(--audio-accent) 0 var(--progress), rgba(255, 255, 255, 0.24) var(--progress) 100%);
|
|
mask: radial-gradient(circle, transparent 65%, #000 66%);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.floating-play {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 48px;
|
|
height: 48px;
|
|
}
|
|
|
|
.floating-label {
|
|
position: relative;
|
|
z-index: 1;
|
|
margin-top: 7px;
|
|
font-size: 18px;
|
|
line-height: 22px;
|
|
font-weight: 900;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.floating-lang {
|
|
position: relative;
|
|
z-index: 1;
|
|
min-width: 46px;
|
|
height: 23px;
|
|
margin-top: 2px;
|
|
padding: 0 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
border: 1px solid rgba(224, 225, 0, 0.7);
|
|
border-radius: 999px;
|
|
font-size: 13px;
|
|
line-height: 18px;
|
|
font-weight: 900;
|
|
color: var(--audio-accent);
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.audio-player:not(.mode-floating) {
|
|
width: 402px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 390px) {
|
|
.expanded-player {
|
|
padding-left: 18px;
|
|
padding-right: 18px;
|
|
}
|
|
|
|
.expanded-main {
|
|
grid-template-columns: 76px minmax(0, 1fr);
|
|
}
|
|
|
|
.expanded-cover {
|
|
width: 76px;
|
|
height: 76px;
|
|
}
|
|
|
|
.expanded-title {
|
|
font-size: 26px;
|
|
line-height: 32px;
|
|
}
|
|
|
|
.mini-player {
|
|
min-height: 110px;
|
|
padding: 10px 12px 12px;
|
|
grid-template-columns: 50px minmax(0, 1fr) auto;
|
|
grid-template-areas:
|
|
"play language expand"
|
|
"copy copy copy"
|
|
"progress progress progress";
|
|
column-gap: 8px;
|
|
row-gap: 6px;
|
|
}
|
|
|
|
.mini-play {
|
|
width: 50px;
|
|
height: 50px;
|
|
}
|
|
|
|
.mini-title {
|
|
font-size: 21px;
|
|
line-height: 27px;
|
|
}
|
|
}
|
|
</style>
|