feat: 无缝切歌

This commit is contained in:
Astrian Zheng 2025-08-21 16:32:35 +10:00
parent 61a99975b2
commit 197fb4011d
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
6 changed files with 754 additions and 59 deletions

View File

@ -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(
<SearchIcon :size="4" />
</button> -->
<Player />
<PlayerWebAudio />
<MiniPlayer />

View File

@ -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)

View File

@ -10,7 +10,9 @@ const resourcesUrl = ref<{ [key: string]: string }>({})
const audioRefs = ref<{ [key: string]: HTMLAudioElement }>({}) // audio
//
watch(() => playQueue.queue, async () => {
watch(
() => playQueue.queue,
async () => {
debugPlayer(playQueue.queue)
let newResourcesUrl: { [key: string]: string } = {}
for (const track of playQueue.queue) {
@ -19,9 +21,12 @@ watch(() => playQueue.queue, async () => {
}
debugPlayer(newResourcesUrl)
resourcesUrl.value = newResourcesUrl
})
},
)
watch(() => playQueue.currentTrack, async (newTrack, oldTrack) => {
watch(
() => playQueue.currentTrack,
async (newTrack, oldTrack) => {
if (!playQueue.currentTrack) return
//
@ -42,7 +47,7 @@ watch(() => playQueue.currentTrack, async (newTrack, oldTrack) => {
// audio
if (!playQueue.isPlaying) return
debugPlayer("正在播放,变更至下一首歌")
debugPlayer('正在播放,变更至下一首歌')
if (oldTrack) {
const oldAudio = getAudioElement(oldTrack.song.cid)
if (oldAudio && !oldAudio.paused) {
@ -61,7 +66,8 @@ watch(() => playQueue.currentTrack, async (newTrack, oldTrack) => {
} else {
console.warn(`找不到音频元素: audio-${newTrack.song.cid}`)
}
})
},
)
//
function artistsOrganize(list: string[]) {
@ -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])

View File

@ -0,0 +1,275 @@
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { WebAudioGaplessPlayer } from '../utils/webAudioPlayer'
import { debugPlayer } from '../utils/debug'
import { watch, ref, onMounted, onUnmounted } from 'vue'
import artistsOrganize from '../utils/artistsOrganize'
const playQueue = usePlayQueueStore()
const player = ref<WebAudioGaplessPlayer | null>(null)
const isInitialized = ref(false)
const currentTrackIndex = ref(-1)
// Web Audio
onMounted(() => {
player.value = new WebAudioGaplessPlayer()
//
player.value.onTrackStart((index) => {
debugPlayer(`Track ${index} started`)
currentTrackIndex.value = index
// store
playQueue.toggleQueuePlay(index)
//
const track = playQueue.queue[index]
if (track) {
updateMediaSession(track)
}
})
player.value.onTrackEnd((index) => {
debugPlayer(`Track ${index} ended`)
//
if (playQueue.loopMode === 'single') {
//
player.value?.seekToTrack(index)
} else if (index === playQueue.queue.length - 1) {
//
if (playQueue.loopMode === 'all') {
//
player.value?.seekToTrack(0)
} else {
//
playQueue.togglePlay(false)
}
} else {
//
player.value?.playNext()
}
})
isInitialized.value = true
//
setupMediaSessionHandlers()
})
//
onUnmounted(() => {
if (player.value) {
player.value.destroy()
player.value = null
}
})
//
watch(
() => playQueue.queue,
async (newQueue) => {
if (!player.value || !isInitialized.value) return
debugPlayer('Queue changed, rebuilding Web Audio queue')
//
player.value.clearQueue()
currentTrackIndex.value = -1
// Web Audio
for (const track of newQueue) {
if (track.song.sourceUrl) {
await player.value.addTrack(track.song.sourceUrl, {
cid: track.song.cid,
name: track.song.name,
artists: track.song.artists,
album: track.album,
})
}
}
debugPlayer(`Added ${newQueue.length} tracks to Web Audio queue`)
},
{ deep: true },
)
//
watch(
() => playQueue.isPlaying,
async (isPlaying) => {
if (!player.value || !isInitialized.value) return
if (isPlaying) {
debugPlayer('Starting playback')
//
const position = player.value.getCurrentPosition()
if (position && position.trackTime > 0) {
await player.value.resume()
} else {
//
await player.value.play(playQueue.currentIndex)
}
} else {
debugPlayer('Pausing playback')
player.value.pause()
}
},
)
//
watch(
() => playQueue.currentIndex,
async (newIndex, oldIndex) => {
if (!player.value || !isInitialized.value) return
// Web Audio
if (newIndex === currentTrackIndex.value) return
debugPlayer(`User requested track change: ${oldIndex} -> ${newIndex}`)
//
await player.value.seekToTrack(newIndex)
currentTrackIndex.value = newIndex
},
)
//
// Note: volume control not implemented in current store, default to 1
watch(
() => isInitialized.value,
(initialized) => {
if (initialized && player.value) {
player.value.setVolume(1)
}
},
{ immediate: true },
)
//
function updateMediaSession(track: QueueItem) {
if (!('mediaSession' in navigator)) return
console.log('Updating media session for:', track.song.name)
navigator.mediaSession.metadata = new MediaMetadata({
title: track.song.name,
artist: artistsOrganize(track.song.artists ?? []),
album: track.album?.name,
artwork: [
{
src: track.album?.coverUrl ?? '',
sizes: '500x500',
type: 'image/png',
},
],
})
//
navigator.mediaSession.playbackState = playQueue.isPlaying ? 'playing' : 'paused'
}
//
function setupMediaSessionHandlers() {
if (!('mediaSession' in navigator)) return
console.log('Setting up media session handlers')
navigator.mediaSession.setActionHandler('play', () => {
console.log('Media session: play')
playQueue.togglePlay(true)
})
navigator.mediaSession.setActionHandler('pause', () => {
console.log('Media session: pause')
playQueue.togglePlay(false)
})
navigator.mediaSession.setActionHandler('previoustrack', () => {
console.log('Media session: previous')
const prevIndex = Math.max(0, playQueue.currentIndex - 1)
playQueue.toggleQueuePlay(prevIndex)
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
console.log('Media session: next')
playQueue.skipToNext()
})
}
//
let progressInterval: number | null = null
watch(
() => playQueue.isPlaying,
(isPlaying) => {
//
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'
}
if (isPlaying) {
//
progressInterval = window.setInterval(() => {
if (!player.value) return
const position = player.value.getCurrentPosition()
if (position) {
playQueue.reportPlayProgress(position.trackTime)
//
if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
const currentTrack = playQueue.queue[position.trackIndex]
if (currentTrack) {
try {
navigator.mediaSession.setPositionState({
duration: currentTrack.song.duration || 0,
playbackRate: 1.0,
position: position.trackTime,
})
} catch (error) {
// setPositionState
console.debug('Media session setPositionState not supported:', error)
}
}
}
}
}, 100) // 100ms
} else {
//
if (progressInterval !== null) {
clearInterval(progressInterval)
progressInterval = null
}
}
},
)
//
onUnmounted(() => {
if (progressInterval !== null) {
clearInterval(progressInterval)
}
})
</script>
<template>
<!-- Web Audio Player 不需要 DOM 元素 -->
<div class="web-audio-player" v-show="false">
<div v-if="!isInitialized" class="text-white">
Initializing Web Audio Player...
</div>
<div v-else class="text-white">
Web Audio Player Ready ({{ playQueue.queue.length }} tracks)
</div>
</div>
</template>
<style scoped>
.web-audio-player {
position: absolute;
pointer-events: none;
opacity: 0;
z-index: -1;
}
</style>

View File

@ -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,
}
})

415
src/utils/webAudioPlayer.ts Normal file
View File

@ -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<string, AudioBuffer> = 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<AudioBuffer> {
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<number> {
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<void> {
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<void> {
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<void> {
await this.playTrack(startIndex)
}
/**
* Play next track
*/
async playNext(): Promise<void> {
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<void> {
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<void> {
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()
}
}
}