feat: 无缝切歌
This commit is contained in:
parent
61a99975b2
commit
197fb4011d
|
@ -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 />
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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])
|
||||
|
|
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>
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
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