chore: 重构播放列表

This commit is contained in:
Astrian Zheng 2025-08-19 13:07:41 +10:00
parent a139d1278a
commit 210700bc0d
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
7 changed files with 177 additions and 251 deletions

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import Player from './components/Player.vue' import MiniPlayer from './components/MiniPlayer.vue'
import PreferencePanel from './components/PreferencePanel.vue' import PreferencePanel from './components/PreferencePanel.vue'
import { ref } from 'vue' import { ref } from 'vue'
@ -73,7 +73,7 @@ watch(() => presentPreferencePanel, (value) => {
<CorgIcon :size="4" /> <CorgIcon :size="4" />
</button> </button>
<Player /> <MiniPlayer />
</div> </div>
</div> </div>
</div> </div>

View File

@ -112,35 +112,19 @@ watch(() => props.albumCid, async () => {
const playQueue = usePlayQueueStore() const playQueue = usePlayQueueStore()
function playTheAlbum(from: number = 0) { async function playTheAlbum(from: number = 0) {
if (playQueue.queueReplaceLock) { await playQueue.replaceQueue(album.value?.songs ?? [])
if (!confirm("当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?")) { return }
playQueue.queueReplaceLock = false
}
let newPlayQueue = []
for (const track of album.value?.songs ?? []) {
console.log(track)
newPlayQueue.push({
song: track,
album: album.value
})
}
playQueue.list = newPlayQueue
playQueue.currentIndex = from
playQueue.isPlaying = true
playQueue.isBuffering = true
} }
function shuffle() { function shuffle() {
playTheAlbum() // playTheAlbum()
playQueue.shuffleCurrent = true // playQueue.shuffleCurrent = true
playQueue.playMode.shuffle = false // playQueue.playMode.shuffle = false
setTimeout(() => { // setTimeout(() => {
playQueue.playMode.shuffle = true // playQueue.playMode.shuffle = true
playQueue.isPlaying = true // playQueue.isPlaying = true
playQueue.isBuffering = true // playQueue.isBuffering = true
}, 100) // }, 100)
} }
</script> </script>

View File

@ -1,11 +1,17 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
</script>
<template> <template>
<div <RouterLink to="/playroom">
class="h-9 w-52 bg-neutral-800/80 border border-[#ffffff39] rounded-full backdrop-blur-3xl flex items-center justify-between select-none"> <div
<div class="flex items-center gap-2"> class="h-9 w-52 bg-neutral-800/80 border border-[#ffffff39] rounded-full backdrop-blur-3xl flex items-center justify-between select-none">
<div class="rounded-full w-9 h-9 bg-gray-600" /> <div class="flex items-center gap-2">
<div class="text-white">Song title</div> <div class="rounded-full w-9 h-9 bg-gray-600" />
<div class="text-white">Song title</div>
</div>
</div> </div>
</div> </RouterLink>
</template> </template>

View File

View File

@ -176,12 +176,12 @@ function updateAudioVolume() {
} }
function formatDetector() { function formatDetector() {
const format = playQueueStore.list[playQueueStore.currentIndex].song.sourceUrl?.split('.').pop() /* const format = playQueueStore.list[playQueueStore.currentIndex].sourceUrl?.split('.').pop()
if (format === 'mp3') { return 'MP3' } if (format === 'mp3') { return 'MP3' }
if (format === 'flac') { return 'FLAC' } if (format === 'flac') { return 'FLAC' }
if (format === 'm4a') { return 'M4A' } if (format === 'm4a') { return 'M4A' }
if (format === 'ape') { return 'APE' } if (format === 'ape') { return 'APE' }
if (format === 'wav') { return 'WAV' } if (format === 'wav') { return 'WAV' } */
return '未知格式' return '未知格式'
} }
@ -305,14 +305,11 @@ function makePlayQueueListDismiss() {
} }
function getCurrentTrack() { function getCurrentTrack() {
if (playQueueStore.list.length === 0) { console.log(playQueueStore.queue)
if (playQueueStore.queue.length === 0) {
return null return null
} }
if (playQueueStore.playMode.shuffle) { return playQueueStore.currentTrack
return playQueueStore.list[playQueueStore.shuffleList[playQueueStore.currentIndex]]
} else {
return playQueueStore.list[playQueueStore.currentIndex]
}
} }
function toggleMoreOptions() { function toggleMoreOptions() {
@ -826,22 +823,22 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="flex gap-2 mx-8 mb-4"> <div class="flex gap-2 mx-8 mb-4">
<button <button
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105" class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
:class="playQueueStore.playMode.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'" :class="playQueueStore.isShuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
@click="toggleShuffle"> @click="toggleShuffle">
<ShuffleIcon :size="4" /> <ShuffleIcon :size="4" />
</button> </button>
<button <button
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105" class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
:class="playQueueStore.playMode.repeat === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'" :class="playQueueStore.loopMode === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
@click="toggleRepeat"> @click="toggleRepeat">
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.playMode.repeat !== 'single'" /> <CycleTwoArrowsIcon :size="4" v-if="playQueueStore.loopMode !== 'single'" />
<CycleTwoArrowsWithNumOneIcon :size="4" v-else /> <CycleTwoArrowsWithNumOneIcon :size="4" v-else />
</button> </button>
</div> </div>
<hr class="border-[#ffffff39]" /> <hr class="border-[#ffffff39]" />
<div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2" v-if="playQueueStore.playMode.shuffle"> <div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2" v-if="playQueueStore.isShuffle">
<PlayQueueItem v-for="(oriIndex, shuffledIndex) in playQueueStore.shuffleList" <PlayQueueItem v-for="(oriIndex, shuffledIndex) in playQueueStore.shuffleList"
:queueItem="playQueueStore.list[oriIndex]" :isCurrent="playQueueStore.currentIndex === shuffledIndex" :queueItem="playQueueStore.list[oriIndex]" :isCurrent="playQueueStore.currentIndex === shuffledIndex"
:key="playQueueStore.list[oriIndex].song.cid" :index="shuffledIndex" /> :key="playQueueStore.list[oriIndex].song.cid" :index="shuffledIndex" />

View File

@ -1,217 +1,154 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { ref, computed } from 'vue'
import { checkAndRefreshSongResource } from '../utils' import apis from '../apis'
export const usePlayQueueStore = defineStore('queue', () => { export const usePlayQueueStore = defineStore('queue', () => {
const list = ref<QueueItem[]>([]) // 内部状态
const currentIndex = ref<number>(0) const queue = ref<QueueItem[]>([])
const isPlaying = ref<boolean>(false) const isShuffle = ref<boolean>(false)
const loopingMode = ref<'single' | 'all' | 'off'>('off')
const queueReplaceLock = ref<boolean>(false) const queueReplaceLock = ref<boolean>(false)
const isBuffering = ref<boolean>(false) const currentPlaying = ref<number>(0) // 当前播放指针,指针在 queueOrder 中寻址(无论是否开启了随机播放)
const currentTime = ref<number>(0) const queueOrder = ref<number[]>([]) // 播放队列顺序
const duration = ref<number>(0)
const updatedCurrentTime = ref<number | null>(null)
const visualizer = ref<number[]>([0, 0, 0, 0, 0, 0])
const shuffleList = ref<number[]>([])
const playMode = ref<{
shuffle: boolean
repeat: 'off' | 'single' | 'all'
}>({
shuffle: false,
repeat: 'off',
})
const shuffleCurrent = ref<boolean | undefined>(undefined)
// 预加载相关状态 // 暴露给外部的响应式只读引用
const preloadedAudio = ref<Map<string, HTMLAudioElement>>(new Map()) const queueState = computed(() =>
const isPreloading = ref<boolean>(false) // 按 queueOrder 的顺序排序输出队列
const preloadProgress = ref<number>(0) queueOrder.value.map(index => queue.value[index]).filter(Boolean)
)
const shuffleState = computed(() => isShuffle.value)
const loopModeState = computed(() => loopingMode.value)
// 获取下一首歌的索引 // 获取当前播放项
const getNextIndex = computed(() => { const currentTrack = computed(() => {
if (list.value.length === 0) return -1 const actualIndex = queueOrder.value[currentPlaying.value]
return queue.value[actualIndex] || null
if (playMode.value.repeat === 'single') {
return currentIndex.value
}
if (playMode.value.shuffle && shuffleList.value.length > 0) {
// 当前在 shuffleList 中的位置
const currentShuffleIndex = currentIndex.value
if (currentShuffleIndex < shuffleList.value.length - 1) {
// 返回下一个位置对应的原始 list 索引
return shuffleList.value[currentShuffleIndex + 1]
} else if (playMode.value.repeat === 'all') {
// 返回第一个位置对应的原始 list 索引
return shuffleList.value[0]
}
return -1
}
if (currentIndex.value < list.value.length - 1) {
return currentIndex.value + 1
} else if (playMode.value.repeat === 'all') {
return 0
}
return -1
}) })
// 预加载下一首歌 /************
const preloadNext = async () => { *
const nextIndex = getNextIndex.value ***********/
if (nextIndex === -1) { // 使用新队列替换老队列
return // 队列替换锁开启时启用确认,确认后重置该锁
async function replaceQueue(songs: Song[]) {
if (queueReplaceLock.value) {
if (!confirm("当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?")) { return }
// 重置队列替换锁
queueReplaceLock.value = false
} }
// 获取下一首歌曲对象 let newQueue: QueueItem[] = []
// nextIndex 已经是原始 list 中的索引
const nextSong = list.value[nextIndex]
if (!nextSong || !nextSong.song) { // 专辑信息缓存空间
return let albums: { [key: string]: Album } = {}
}
const songId = nextSong.song.cid for (let i in songs) {
// 写入新队列
// 如果已经预加载过,跳过 newQueue[newQueue.length] = {
if (preloadedAudio.value.has(songId)) { song: songs[i],
return album: await (async () => {
} if (albums[songs[i].albumCid ?? "0"]) return albums[songs[i].albumCid ?? "0"]
else {
// 检查是否有有效的音频源 const album = await apis.getAlbum(songs[i].albumCid ?? "0")
if (!nextSong.song.sourceUrl) { albums[songs[i].albumCid ?? "0"] = album
return return album
}
try {
isPreloading.value = true
preloadProgress.value = 0
// 在预加载前检查和刷新资源
console.log('[Store] 预加载前检查资源:', nextSong.song.name)
const updatedSong = await checkAndRefreshSongResource(
nextSong.song,
(updated) => {
// 更新播放队列中的歌曲信息
// nextIndex 已经是原始 list 中的索引
if (list.value[nextIndex]) {
list.value[nextIndex].song = updated
} }
})()
// 如果歌曲在收藏夹中,也更新收藏夹
// 注意:这里不直接导入 favourites store 以避免循环依赖
// 改为触发一个事件或者在调用方处理
console.log('[Store] 预加载时需要更新收藏夹:', updated.name)
},
)
const audio = new Audio()
audio.preload = 'auto'
audio.crossOrigin = 'anonymous'
// 监听加载进度
audio.addEventListener('progress', () => {
if (audio.buffered.length > 0) {
const buffered = audio.buffered.end(0)
const total = audio.duration || 1
preloadProgress.value = (buffered / total) * 100
}
})
// 监听加载完成
audio.addEventListener('canplaythrough', () => {
preloadedAudio.value.set(songId, audio)
isPreloading.value = false
preloadProgress.value = 100
console.log('[Store] 预加载完成:', updatedSong.name)
})
// 监听加载错误
audio.addEventListener('error', (e) => {
console.error(`[Store] 预加载音频失败: ${updatedSong.name}`, e)
isPreloading.value = false
preloadProgress.value = 0
})
// 使用更新后的音频源
audio.src = updatedSong.sourceUrl!
} catch (error) {
console.error('[Store] 预加载过程出错:', error)
isPreloading.value = false
}
}
// 获取预加载的音频对象
const getPreloadedAudio = (songId: string): HTMLAudioElement | null => {
const audio = preloadedAudio.value.get(songId) || null
return audio
}
// 清理预加载的音频
const clearPreloadedAudio = (songId: string) => {
const audio = preloadedAudio.value.get(songId)
if (audio) {
audio.pause()
audio.src = ''
preloadedAudio.value.delete(songId)
}
}
// 清理所有预加载的音频
const clearAllPreloadedAudio = () => {
preloadedAudio.value.forEach((_audio, songId) => {
clearPreloadedAudio(songId)
})
preloadedAudio.value.clear()
}
// 限制预加载缓存大小最多保留3首歌
const limitPreloadCache = () => {
while (preloadedAudio.value.size > 3) {
const oldestKey = preloadedAudio.value.keys().next().value
if (oldestKey) {
clearPreloadedAudio(oldestKey)
} else {
break
} }
} }
// 将新队列替换已有队列
queue.value = newQueue
// 初始化播放顺序
queueOrder.value = Array.from({ length: newQueue.length }, (_, i) => i)
currentPlaying.value = 0
// 关闭随机播放和循环(外部可在此方法执行完毕后再更新播放模式)
isShuffle.value = false
loopingMode.value = 'off'
} }
// 调试函数:打印当前状态 /************
const debugPreloadState = () => { *
console.log('[Store] 预加载状态:', { **********/
isPreloading: isPreloading.value, // 切换随机播放模式
progress: preloadProgress.value, const toggleShuffle = (turnTo?: boolean) => {
cacheSize: preloadedAudio.value.size, // 未指定随机状态时自动开关
cachedSongs: Array.from(preloadedAudio.value.keys()), const newShuffleState = turnTo ?? !isShuffle.value
nextIndex: getNextIndex.value,
}) if (newShuffleState === isShuffle.value) return // 状态未改变
/* if (newShuffleState) {
// 开启随机播放:保存当前顺序并打乱
const originalOrder = [...queueOrder.value]
const shuffled = [...queueOrder.value]
// Fisher-Yates 洗牌算法
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
// 确保当前播放的歌曲位置不变(可选)
const currentSongIndex = queueOrder.value[currentPlaying.value]
const newCurrentPos = shuffled.indexOf(currentSongIndex)
if (newCurrentPos !== -1 && newCurrentPos !== currentPlaying.value) {
[shuffled[currentPlaying.value], shuffled[newCurrentPos]] =
[shuffled[newCurrentPos], shuffled[currentPlaying.value]]
}
// 保存原始顺序以便恢复
queue.value.forEach((_, index) => {
queue.value[index]._originalOrderIndex = originalOrder.indexOf(index)
})
queueOrder.value = shuffled
} else {
// 关闭随机播放:恢复原始顺序
const restoredOrder = Array.from({ length: queue.value.length }, (_, i) => i)
// 找到当前播放歌曲在原始顺序中的位置
const currentSongIndex = queueOrder.value[currentPlaying.value]
const newCurrentPos = restoredOrder.indexOf(currentSongIndex)
queueOrder.value = restoredOrder
currentPlaying.value = newCurrentPos !== -1 ? newCurrentPos : 0
} */
isShuffle.value = newShuffleState
}
// 切换循环播放模式
const toggleLoop = (mode?: 'single' | 'all' | 'off') => {
// 如果指定了循环模式
if (mode) return loopingMode.value = mode
// 如果没有指定,那么按照「无 -> 列表循环 -> 单曲循环」的顺序轮换
switch (loopingMode.value) {
case 'off':
loopingMode.value = 'all'
break
case 'all':
loopingMode.value = 'single'
break
case 'single':
loopingMode.value = 'off'
break
}
} }
return { return {
list, // 响应式状态(只读)
currentIndex, queue: queueState,
isPlaying, isShuffle: shuffleState,
queueReplaceLock, loopMode: loopModeState,
isBuffering, currentTrack,
currentTime, currentIndex: currentPlaying,
duration,
updatedCurrentTime, // 修改方法
visualizer, replaceQueue,
shuffleList, toggleShuffle,
playMode, toggleLoop
shuffleCurrent,
// 预加载相关 - 确保所有函数都在返回对象中
preloadedAudio,
isPreloading,
preloadProgress,
getNextIndex,
preloadNext,
getPreloadedAudio,
clearPreloadedAudio,
clearAllPreloadedAudio,
limitPreloadCache,
debugPreloadState,
} }
}) })

6
src/vite-env.d.ts vendored
View File

@ -8,10 +8,10 @@ type Song = {
cid: string cid: string
name: string name: string
albumCid?: string albumCid?: string
sourceUrl?: string
lyricUrl?: string | null
mvUrl?: string | null mvUrl?: string | null
mvCoverUrl?: string | null mvCoverUrl?: string | null
sourceUrl?: string | null
lyricUrl?: string | null
artistes?: string[] artistes?: string[]
artists?: string[] artists?: string[]
} }
@ -38,6 +38,8 @@ interface ApiResponse {
interface QueueItem { interface QueueItem {
song: Song song: Song
album?: Album album?: Album
sourceUrl?: string
lyricUrl?: string | null
} }
interface LyricsLine { interface LyricsLine {