From 197fb4011d374fea29b0c45e5cc31cded047585f Mon Sep 17 00:00:00 2001 From: Astrian Zheng Date: Thu, 21 Aug 2025 16:32:35 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=A0=E7=BC=9D=E5=88=87=E6=AD=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 4 +- src/components/AlbumDetailDialog.vue | 2 +- src/components/Player.vue | 110 +++---- src/components/PlayerWebAudio.vue | 275 ++++++++++++++++++ src/stores/usePlayQueueStore.ts | 7 +- src/utils/webAudioPlayer.ts | 415 +++++++++++++++++++++++++++ 6 files changed, 754 insertions(+), 59 deletions(-) create mode 100644 src/components/PlayerWebAudio.vue create mode 100644 src/utils/webAudioPlayer.ts diff --git a/src/App.vue b/src/App.vue index d398050..c94a4bf 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,7 +2,7 @@ import { useRoute, useRouter } from 'vue-router' import MiniPlayer from './components/MiniPlayer.vue' import PreferencePanel from './components/PreferencePanel.vue' -import Player from './components/Player.vue' +import PlayerWebAudio from './components/PlayerWebAudio.vue' import { ref } from 'vue' import LeftArrowIcon from './assets/icons/leftarrow.vue' @@ -71,7 +71,7 @@ watch( --> - + diff --git a/src/components/AlbumDetailDialog.vue b/src/components/AlbumDetailDialog.vue index 092d5ab..7fc7d9e 100644 --- a/src/components/AlbumDetailDialog.vue +++ b/src/components/AlbumDetailDialog.vue @@ -144,7 +144,7 @@ async function playTheAlbum(from: number = 0) { for (const track of album.value?.songs ?? []) { newQueue.push({ song: track, - album: album.value + album: album.value, }) } await playQueue.replaceQueue(newQueue) diff --git a/src/components/Player.vue b/src/components/Player.vue index ae0fd37..fa9b792 100644 --- a/src/components/Player.vue +++ b/src/components/Player.vue @@ -10,58 +10,64 @@ const resourcesUrl = ref<{ [key: string]: string }>({}) const audioRefs = ref<{ [key: string]: HTMLAudioElement }>({}) // audio 元素的引用 // 监听播放列表变化 -watch(() => playQueue.queue, async () => { - debugPlayer(playQueue.queue) - let newResourcesUrl: { [key: string]: string } = {} - for (const track of playQueue.queue) { - const res = await apis.getSong(track.song.cid) - newResourcesUrl[track.song.cid] = track.song.sourceUrl - } - debugPlayer(newResourcesUrl) - resourcesUrl.value = newResourcesUrl -}) - -watch(() => playQueue.currentTrack, async (newTrack, oldTrack) => { - if (!playQueue.currentTrack) return - - // 更新元数据 - navigator.mediaSession.metadata = new MediaMetadata({ - title: playQueue.currentTrack.song.name, - artist: artistsOrganize(playQueue.currentTrack.song.artists ?? []), - album: playQueue.currentTrack.album?.name, - artwork: [ - { - src: playQueue.currentTrack.album?.coverUrl ?? '', - sizes: '500x500', - type: 'image/png', - }, - ], - }) - navigator.mediaSession.setActionHandler('previoustrack', () => {}) - navigator.mediaSession.setActionHandler('nexttrack', playQueue.skipToNext) - - // 如果目前歌曲变动时正在播放,则激活对应的 audio 组件,并将播放时间进度重置为零 - if (!playQueue.isPlaying) return - debugPlayer("正在播放,变更至下一首歌") - if (oldTrack) { - const oldAudio = getAudioElement(oldTrack.song.cid) - if (oldAudio && !oldAudio.paused) { - oldAudio.pause() +watch( + () => playQueue.queue, + async () => { + debugPlayer(playQueue.queue) + let newResourcesUrl: { [key: string]: string } = {} + for (const track of playQueue.queue) { + const res = await apis.getSong(track.song.cid) + newResourcesUrl[track.song.cid] = track.song.sourceUrl } - } + debugPlayer(newResourcesUrl) + resourcesUrl.value = newResourcesUrl + }, +) - const newAudio = getAudioElement(newTrack.song.cid) - if (newAudio) { - try { - await newAudio.play() - debugPlayer(`开始播放: audio-${newTrack.song.cid}`) - } catch (error) { - console.error(`播放失败: audio-${newTrack.song.cid}`, error) +watch( + () => playQueue.currentTrack, + async (newTrack, oldTrack) => { + if (!playQueue.currentTrack) return + + // 更新元数据 + navigator.mediaSession.metadata = new MediaMetadata({ + title: playQueue.currentTrack.song.name, + artist: artistsOrganize(playQueue.currentTrack.song.artists ?? []), + album: playQueue.currentTrack.album?.name, + artwork: [ + { + src: playQueue.currentTrack.album?.coverUrl ?? '', + sizes: '500x500', + type: 'image/png', + }, + ], + }) + navigator.mediaSession.setActionHandler('previoustrack', () => {}) + navigator.mediaSession.setActionHandler('nexttrack', playQueue.skipToNext) + + // 如果目前歌曲变动时正在播放,则激活对应的 audio 组件,并将播放时间进度重置为零 + if (!playQueue.isPlaying) return + debugPlayer('正在播放,变更至下一首歌') + if (oldTrack) { + const oldAudio = getAudioElement(oldTrack.song.cid) + if (oldAudio && !oldAudio.paused) { + oldAudio.pause() + } } - } else { - console.warn(`找不到音频元素: audio-${newTrack.song.cid}`) - } -}) + + const newAudio = getAudioElement(newTrack.song.cid) + if (newAudio) { + try { + await newAudio.play() + debugPlayer(`开始播放: audio-${newTrack.song.cid}`) + } catch (error) { + console.error(`播放失败: audio-${newTrack.song.cid}`, error) + } + } else { + console.warn(`找不到音频元素: audio-${newTrack.song.cid}`) + } + }, +) // 优化音乐人字符串显示 function artistsOrganize(list: string[]) { @@ -89,7 +95,7 @@ function isAutoPlay(cid: string) { // 再判断是否是目前曲目 if (playQueue.currentTrack.song.cid !== cid) return false - return true + return true } // 获取 audio 元素的 ref @@ -100,8 +106,8 @@ function getAudioElement(cid: string): HTMLAudioElement | null { // audio 元素结束播放事件 function endOfPlay() { - debugPlayer("结束播放") - if (playQueue.loopingMode !== "single") { + debugPlayer('结束播放') + if (playQueue.loopingMode !== 'single') { const next = playQueue.queue[playQueue.currentIndex + 1] debugPlayer(next.song.cid) debugPlayer(audioRefs.value[next.song.cid]) diff --git a/src/components/PlayerWebAudio.vue b/src/components/PlayerWebAudio.vue new file mode 100644 index 0000000..fb9845a --- /dev/null +++ b/src/components/PlayerWebAudio.vue @@ -0,0 +1,275 @@ + + + + + \ No newline at end of file diff --git a/src/stores/usePlayQueueStore.ts b/src/stores/usePlayQueueStore.ts index 010b570..dd55055 100644 --- a/src/stores/usePlayQueueStore.ts +++ b/src/stores/usePlayQueueStore.ts @@ -28,10 +28,10 @@ export const usePlayQueueStore = defineStore('queue', () => { const actualIndex = queueOrder.value[currentPlaying.value] return queue.value[actualIndex] || null }) - + // 获取当前播放时间 const playProgressState = computed(() => playProgress.value) - + // 获取当前是否正在播放 const playingState = computed(() => isPlaying.value) @@ -101,7 +101,6 @@ export const usePlayQueueStore = defineStore('queue', () => { playProgress.value = progress } - /************ * 播放模式相关 **********/ @@ -190,6 +189,6 @@ export const usePlayQueueStore = defineStore('queue', () => { toggleQueuePlay, skipToNext, continueToNext, - reportPlayProgress + reportPlayProgress, } }) diff --git a/src/utils/webAudioPlayer.ts b/src/utils/webAudioPlayer.ts new file mode 100644 index 0000000..ee6ac2e --- /dev/null +++ b/src/utils/webAudioPlayer.ts @@ -0,0 +1,415 @@ +interface AudioTrack { + url: string + buffer: AudioBuffer | null + source: AudioBufferSourceNode | null + gainNode: GainNode | null + duration: number + metadata?: any +} + +interface PlaybackState { + isPlaying: boolean + currentIndex: number + pausedAt: number + pausedOffset: number +} + +export class WebAudioGaplessPlayer { + private context: AudioContext + private masterGain: GainNode + private tracks: AudioTrack[] = [] + private state: PlaybackState = { + isPlaying: false, + currentIndex: 0, + pausedAt: 0, + pausedOffset: 0, + } + private bufferCache: Map = new Map() + private onTrackEndCallbacks: ((index: number) => void)[] = [] + private onTrackStartCallbacks: ((index: number) => void)[] = [] + private preloadAhead = 2 + private maxCacheSize = 5 + + constructor() { + this.context = new ( + window.AudioContext || (window as any).webkitAudioContext + )() + this.masterGain = this.context.createGain() + this.masterGain.connect(this.context.destination) + } + + /** + * Load and decode audio from URL + */ + private async loadAudio(url: string): Promise { + if (this.bufferCache.has(url)) { + return this.bufferCache.get(url)! + } + + try { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch audio: ${response.status}`) + } + + const arrayBuffer = await response.arrayBuffer() + const audioBuffer = await this.context.decodeAudioData(arrayBuffer) + + this.bufferCache.set(url, audioBuffer) + this.cleanupCache() + + return audioBuffer + } catch (error) { + console.error('Failed to load audio:', error) + throw error + } + } + + /** + * Clean up cache + */ + private cleanupCache(): void { + if (this.bufferCache.size <= this.maxCacheSize) return + + const currentUrls = new Set( + this.tracks + .slice( + Math.max(0, this.state.currentIndex - 1), + this.state.currentIndex + this.preloadAhead + 1, + ) + .map((t) => t.url), + ) + + for (const [url] of this.bufferCache) { + if (!currentUrls.has(url) && this.bufferCache.size > this.maxCacheSize) { + this.bufferCache.delete(url) + } + } + } + + /** + * Add a track to the queue + */ + async addTrack(url: string, metadata?: any): Promise { + const track: AudioTrack = { + url, + buffer: null, + source: null, + gainNode: null, + duration: 0, + metadata, + } + + this.tracks.push(track) + const index = this.tracks.length - 1 + + // Preload if within range + if (this.shouldPreload(index)) { + await this.preloadTrack(index) + } + + return index + } + + /** + * Check if a track should be preloaded + */ + private shouldPreload(index: number): boolean { + const distance = index - this.state.currentIndex + return distance >= 0 && distance <= this.preloadAhead + } + + /** + * Preload a track + */ + private async preloadTrack(index: number): Promise { + const track = this.tracks[index] + if (!track || track.buffer) return + + try { + track.buffer = await this.loadAudio(track.url) + track.duration = track.buffer.duration + } catch (error) { + console.error(`Failed to preload track ${index}:`, error) + } + } + + /** + * Stop current track if playing + */ + private stopCurrentTrack(): void { + const currentTrack = this.tracks[this.state.currentIndex] + if (currentTrack?.source) { + try { + currentTrack.source.stop() + } catch (e) { + // Source might have already ended + } + currentTrack.source = null + currentTrack.gainNode = null + } + } + + /** + * Play a specific track + */ + async playTrack(index: number): Promise { + if (index < 0 || index >= this.tracks.length) return + + // Stop any currently playing track + this.stopCurrentTrack() + + // Resume context if suspended + if (this.context.state === 'suspended') { + await this.context.resume() + } + + // Ensure track is loaded + await this.preloadTrack(index) + + const track = this.tracks[index] + if (!track?.buffer) { + console.error(`Track ${index} not loaded`) + return + } + + // Update state + this.state.currentIndex = index + this.state.isPlaying = true + this.state.pausedOffset = 0 + + // Create audio nodes + const source = this.context.createBufferSource() + source.buffer = track.buffer + + const gainNode = this.context.createGain() + source.connect(gainNode) + gainNode.connect(this.masterGain) + + // Set up callbacks + source.onended = () => { + this.handleTrackEnded(index) + } + + // Store references + track.source = source + track.gainNode = gainNode + + // Start playing immediately + source.start(this.context.currentTime) + + // Notify track started + this.onTrackStartCallbacks.forEach((cb) => cb(index)) + + // Preload next tracks + for (let i = 1; i <= this.preloadAhead; i++) { + const nextIndex = index + i + if (nextIndex < this.tracks.length) { + this.preloadTrack(nextIndex).catch(console.error) + } + } + } + + /** + * Handle track ended event + */ + private handleTrackEnded(index: number): void { + const track = this.tracks[index] + if (track) { + track.source = null + track.gainNode = null + } + + // Only notify if this is still the current track + if (index === this.state.currentIndex) { + this.onTrackEndCallbacks.forEach((cb) => cb(index)) + } + } + + /** + * Start playing from a specific index + */ + async play(startIndex = 0): Promise { + await this.playTrack(startIndex) + } + + /** + * Play next track + */ + async playNext(): Promise { + const nextIndex = this.state.currentIndex + 1 + if (nextIndex < this.tracks.length) { + await this.playTrack(nextIndex) + } + } + + /** + * Pause playback + */ + pause(): void { + if (!this.state.isPlaying) return + + this.state.pausedAt = this.context.currentTime + this.state.isPlaying = false + + // Calculate paused offset + const position = this.getCurrentPosition() + if (position) { + this.state.pausedOffset = position.trackTime + } + + // Stop current track + this.stopCurrentTrack() + + // Suspend context to save resources + this.context.suspend() + } + + /** + * Resume playback + */ + async resume(): Promise { + if (this.state.isPlaying) return + + await this.context.resume() + + const currentTrack = this.tracks[this.state.currentIndex] + if (!currentTrack?.buffer) return + + this.state.isPlaying = true + + // Create new source for resume + const source = this.context.createBufferSource() + source.buffer = currentTrack.buffer + + const gainNode = this.context.createGain() + source.connect(gainNode) + gainNode.connect(this.masterGain) + + // Calculate remaining duration + const remainingDuration = currentTrack.duration - this.state.pausedOffset + + // Resume from offset + source.start(this.context.currentTime, this.state.pausedOffset, remainingDuration) + + source.onended = () => this.handleTrackEnded(this.state.currentIndex) + + // Store references + currentTrack.source = source + currentTrack.gainNode = gainNode + + this.state.pausedOffset = 0 + } + + /** + * Stop playback + */ + stop(): void { + this.pause() + this.state.currentIndex = 0 + this.state.pausedOffset = 0 + } + + /** + * Seek to a specific track + */ + async seekToTrack(index: number): Promise { + if (index < 0 || index >= this.tracks.length) return + await this.playTrack(index) + } + + /** + * Get current playback position + */ + getCurrentPosition(): { + trackIndex: number + trackTime: number + totalTime: number + } | null { + if (!this.state.isPlaying) { + return { + trackIndex: this.state.currentIndex, + trackTime: this.state.pausedOffset, + totalTime: 0, + } + } + + const currentTrack = this.tracks[this.state.currentIndex] + if (!currentTrack?.source) return null + + // Estimate current time (not perfectly accurate but good enough) + const elapsed = this.context.currentTime - (this.state.pausedAt || 0) + const trackTime = Math.min(elapsed + this.state.pausedOffset, currentTrack.duration) + + return { + trackIndex: this.state.currentIndex, + trackTime, + totalTime: trackTime, + } + } + + /** + * Set volume (0.0 to 1.0) + */ + setVolume(volume: number): void { + this.masterGain.gain.value = Math.max(0, Math.min(1, volume)) + } + + /** + * Get volume + */ + getVolume(): number { + return this.masterGain.gain.value + } + + /** + * Clear all tracks + */ + clearQueue(): void { + this.stop() + this.tracks = [] + this.bufferCache.clear() + } + + /** + * Register callback for track end event + */ + onTrackEnd(callback: (index: number) => void): void { + this.onTrackEndCallbacks.push(callback) + } + + /** + * Register callback for track start event + */ + onTrackStart(callback: (index: number) => void): void { + this.onTrackStartCallbacks.push(callback) + } + + /** + * Get audio context + */ + getContext(): AudioContext { + return this.context + } + + /** + * Get master gain node + */ + getMasterGain(): GainNode { + return this.masterGain + } + + /** + * Destroy the player + */ + destroy(): void { + this.stop() + this.clearQueue() + this.onTrackEndCallbacks = [] + this.onTrackStartCallbacks = [] + + if (this.context.state !== 'closed') { + this.context.close() + } + } +} \ No newline at end of file