基于旧项目 ai_music_v2.0 迁移,与 Painting/Video 统一架构:HTTP 轮询 + suanli 后端、 API 驱动配置、mode 独立 ref 驱动控件显隐。新增 AudioPlayer/CustomSlider 通用组件, dialogBox/set.vue/taskPolling/modelApi 完成集成适配。
268 lines
7.4 KiB
Vue
268 lines
7.4 KiB
Vue
<template>
|
|
<div class="audio-placeholder" :style="{ backgroundImage: `url(${backgroundImage})` }">
|
|
<div class="audio-left">
|
|
<img :src="coverImage" alt="音频封面" class="audio-cover" />
|
|
</div>
|
|
|
|
<div class="audio-center">
|
|
<div class="audio-title">{{ audioTitle }}</div>
|
|
<div class="audio-progress">
|
|
<div class="progress-bar" @click="handleProgressClick">
|
|
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
|
</div>
|
|
<div class="progress-time">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="audio-right">
|
|
<div class="play-button" @click.stop="handlePlayPause">
|
|
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M8 5V19L19 12L8 5Z" fill="white"/>
|
|
</svg>
|
|
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" fill="white"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import background1 from '@/assets/display/background1.png'
|
|
import background2 from '@/assets/display/background2.png'
|
|
import background3 from '@/assets/display/background3.png'
|
|
import background4 from '@/assets/display/background4.png'
|
|
import image1 from '@/assets/display/image1.png'
|
|
import image2 from '@/assets/display/image2.png'
|
|
import image3 from '@/assets/display/image3.png'
|
|
import image4 from '@/assets/display/image4.png'
|
|
|
|
const props = defineProps({
|
|
audioUrl: { type: String, default: '' },
|
|
audioTitle: { type: String, default: '我的音乐' },
|
|
cardIndex: { type: Number, default: 0 }
|
|
})
|
|
|
|
const emit = defineEmits(['play', 'pause', 'ended'])
|
|
|
|
const backgroundImages = [background1, background2, background3, background4]
|
|
const coverImages = [image1, image2, image3, image4]
|
|
|
|
const backgroundImage = computed(() => backgroundImages[props.cardIndex % 4])
|
|
const coverImage = computed(() => coverImages[props.cardIndex % 4])
|
|
|
|
const audioRef = ref(null)
|
|
const isPlaying = ref(false)
|
|
const isPlayPending = ref(false)
|
|
const currentTime = ref(0)
|
|
const duration = ref(0)
|
|
|
|
let timeupdateHandler = null
|
|
let loadedmetadataHandler = null
|
|
let endedHandler = null
|
|
|
|
const progressPercentage = computed(() => {
|
|
if (duration.value === 0) return 0
|
|
return (currentTime.value / duration.value) * 100
|
|
})
|
|
|
|
const formatTime = (seconds) => {
|
|
if (!seconds || isNaN(seconds)) return '00:00'
|
|
const mins = Math.floor(seconds / 60)
|
|
const secs = Math.floor(seconds % 60)
|
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
|
}
|
|
|
|
const handleProgressClick = (event) => {
|
|
if (!audioRef.value || duration.value === 0) return
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
const clickX = event.clientX - rect.left
|
|
const percentage = Math.max(0, Math.min(1, clickX / rect.width))
|
|
const newTime = percentage * duration.value
|
|
audioRef.value.currentTime = newTime
|
|
currentTime.value = newTime
|
|
}
|
|
|
|
const setupAudio = (url) => {
|
|
if (audioRef.value) {
|
|
audioRef.value.pause()
|
|
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
|
|
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
|
|
audioRef.value.removeEventListener('ended', endedHandler)
|
|
audioRef.value.src = ''
|
|
audioRef.value.load()
|
|
audioRef.value = null
|
|
}
|
|
isPlaying.value = false
|
|
currentTime.value = 0
|
|
duration.value = 0
|
|
if (!url) return
|
|
|
|
audioRef.value = new Audio(url)
|
|
audioRef.value.crossOrigin = 'anonymous'
|
|
timeupdateHandler = () => {
|
|
if (audioRef.value) currentTime.value = audioRef.value.currentTime
|
|
}
|
|
loadedmetadataHandler = () => {
|
|
if (audioRef.value) duration.value = audioRef.value.duration || 0
|
|
}
|
|
endedHandler = () => {
|
|
isPlaying.value = false
|
|
currentTime.value = 0
|
|
emit('ended')
|
|
}
|
|
audioRef.value.addEventListener('timeupdate', timeupdateHandler)
|
|
audioRef.value.addEventListener('loadedmetadata', loadedmetadataHandler)
|
|
audioRef.value.addEventListener('ended', endedHandler)
|
|
}
|
|
|
|
const handlePlayPause = () => {
|
|
if (!audioRef.value) {
|
|
setupAudio(props.audioUrl)
|
|
}
|
|
if (!audioRef.value) return
|
|
|
|
if (isPlaying.value) {
|
|
audioRef.value.pause()
|
|
isPlaying.value = false
|
|
isPlayPending.value = false
|
|
emit('pause')
|
|
} else if (!isPlayPending.value) {
|
|
isPlayPending.value = true
|
|
audioRef.value.play().then(() => {
|
|
isPlaying.value = true
|
|
isPlayPending.value = false
|
|
emit('play')
|
|
}).catch((error) => {
|
|
console.error('播放失败:', error)
|
|
isPlaying.value = false
|
|
isPlayPending.value = false
|
|
})
|
|
}
|
|
}
|
|
|
|
onMounted(() => { if (props.audioUrl) setupAudio(props.audioUrl) })
|
|
onUnmounted(() => {
|
|
if (audioRef.value) {
|
|
audioRef.value.pause()
|
|
audioRef.value.removeEventListener('timeupdate', timeupdateHandler)
|
|
audioRef.value.removeEventListener('loadedmetadata', loadedmetadataHandler)
|
|
audioRef.value.removeEventListener('ended', endedHandler)
|
|
audioRef.value.src = ''
|
|
audioRef.value.load()
|
|
audioRef.value = null
|
|
}
|
|
})
|
|
watch(() => props.audioUrl, (newUrl, oldUrl) => {
|
|
if (newUrl === oldUrl) return
|
|
setupAudio(newUrl)
|
|
})
|
|
</script>
|
|
|
|
<style lang="less" scoped>
|
|
.audio-placeholder {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 80px;
|
|
background-size: cover;
|
|
background-position: center;
|
|
background-repeat: no-repeat;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 12px 16px;
|
|
cursor: pointer;
|
|
|
|
.audio-left {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
.audio-cover {
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 5px;
|
|
object-fit: cover;
|
|
}
|
|
}
|
|
|
|
.audio-center {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
min-width: 0;
|
|
|
|
.audio-title {
|
|
color: white;
|
|
font-family: "Microsoft YaHei";
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.audio-progress {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 4px;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
&:hover {
|
|
height: 6px;
|
|
background: rgba(255, 255, 255, 0.4);
|
|
}
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: white;
|
|
border-radius: 2px;
|
|
transition: width 0.1s ease;
|
|
}
|
|
}
|
|
|
|
.progress-time {
|
|
color: rgba(255, 255, 255, 0.8);
|
|
font-family: "Microsoft YaHei";
|
|
font-size: 11px;
|
|
font-weight: 400;
|
|
}
|
|
}
|
|
}
|
|
|
|
.audio-right {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
.play-button {
|
|
width: 40px;
|
|
height: 40px;
|
|
background: rgba(255, 255, 255, 0.2);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
backdrop-filter: blur(10px);
|
|
transition: all 0.3s ease;
|
|
&:hover {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
transform: scale(1.1);
|
|
}
|
|
svg { width: 20px; height: 20px; fill: white; }
|
|
}
|
|
}
|
|
}
|
|
</style>
|