feat: 无缝切歌
This commit is contained in:
parent
61a99975b2
commit
197fb4011d
|
@ -2,7 +2,7 @@
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import MiniPlayer from './components/MiniPlayer.vue'
|
import MiniPlayer from './components/MiniPlayer.vue'
|
||||||
import PreferencePanel from './components/PreferencePanel.vue'
|
import PreferencePanel from './components/PreferencePanel.vue'
|
||||||
import Player from './components/Player.vue'
|
import PlayerWebAudio from './components/PlayerWebAudio.vue'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import LeftArrowIcon from './assets/icons/leftarrow.vue'
|
import LeftArrowIcon from './assets/icons/leftarrow.vue'
|
||||||
|
@ -71,7 +71,7 @@ watch(
|
||||||
<SearchIcon :size="4" />
|
<SearchIcon :size="4" />
|
||||||
</button> -->
|
</button> -->
|
||||||
|
|
||||||
<Player />
|
<PlayerWebAudio />
|
||||||
|
|
||||||
|
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
|
|
|
@ -144,7 +144,7 @@ async function playTheAlbum(from: number = 0) {
|
||||||
for (const track of album.value?.songs ?? []) {
|
for (const track of album.value?.songs ?? []) {
|
||||||
newQueue.push({
|
newQueue.push({
|
||||||
song: track,
|
song: track,
|
||||||
album: album.value
|
album: album.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await playQueue.replaceQueue(newQueue)
|
await playQueue.replaceQueue(newQueue)
|
||||||
|
|
|
@ -10,58 +10,64 @@ const resourcesUrl = ref<{ [key: string]: string }>({})
|
||||||
const audioRefs = ref<{ [key: string]: HTMLAudioElement }>({}) // audio 元素的引用
|
const audioRefs = ref<{ [key: string]: HTMLAudioElement }>({}) // audio 元素的引用
|
||||||
|
|
||||||
// 监听播放列表变化
|
// 监听播放列表变化
|
||||||
watch(() => playQueue.queue, async () => {
|
watch(
|
||||||
debugPlayer(playQueue.queue)
|
() => playQueue.queue,
|
||||||
let newResourcesUrl: { [key: string]: string } = {}
|
async () => {
|
||||||
for (const track of playQueue.queue) {
|
debugPlayer(playQueue.queue)
|
||||||
const res = await apis.getSong(track.song.cid)
|
let newResourcesUrl: { [key: string]: string } = {}
|
||||||
newResourcesUrl[track.song.cid] = track.song.sourceUrl
|
for (const track of playQueue.queue) {
|
||||||
}
|
const res = await apis.getSong(track.song.cid)
|
||||||
debugPlayer(newResourcesUrl)
|
newResourcesUrl[track.song.cid] = track.song.sourceUrl
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
debugPlayer(newResourcesUrl)
|
||||||
|
resourcesUrl.value = newResourcesUrl
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const newAudio = getAudioElement(newTrack.song.cid)
|
watch(
|
||||||
if (newAudio) {
|
() => playQueue.currentTrack,
|
||||||
try {
|
async (newTrack, oldTrack) => {
|
||||||
await newAudio.play()
|
if (!playQueue.currentTrack) return
|
||||||
debugPlayer(`开始播放: audio-${newTrack.song.cid}`)
|
|
||||||
} catch (error) {
|
// 更新元数据
|
||||||
console.error(`播放失败: audio-${newTrack.song.cid}`, error)
|
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[]) {
|
function artistsOrganize(list: string[]) {
|
||||||
|
@ -100,8 +106,8 @@ function getAudioElement(cid: string): HTMLAudioElement | null {
|
||||||
|
|
||||||
// audio 元素结束播放事件
|
// audio 元素结束播放事件
|
||||||
function endOfPlay() {
|
function endOfPlay() {
|
||||||
debugPlayer("结束播放")
|
debugPlayer('结束播放')
|
||||||
if (playQueue.loopingMode !== "single") {
|
if (playQueue.loopingMode !== 'single') {
|
||||||
const next = playQueue.queue[playQueue.currentIndex + 1]
|
const next = playQueue.queue[playQueue.currentIndex + 1]
|
||||||
debugPlayer(next.song.cid)
|
debugPlayer(next.song.cid)
|
||||||
debugPlayer(audioRefs.value[next.song.cid])
|
debugPlayer(audioRefs.value[next.song.cid])
|
||||||
|
|
275
src/components/PlayerWebAudio.vue
Normal file
275
src/components/PlayerWebAudio.vue
Normal 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>
|
|
@ -101,7 +101,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
playProgress.value = progress
|
playProgress.value = progress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/************
|
/************
|
||||||
* 播放模式相关
|
* 播放模式相关
|
||||||
**********/
|
**********/
|
||||||
|
@ -190,6 +189,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
toggleQueuePlay,
|
toggleQueuePlay,
|
||||||
skipToNext,
|
skipToNext,
|
||||||
continueToNext,
|
continueToNext,
|
||||||
reportPlayProgress
|
reportPlayProgress,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
415
src/utils/webAudioPlayer.ts
Normal file
415
src/utils/webAudioPlayer.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user