feat: 再重构

手作代码,从零重写,真正的犟人精神
This commit is contained in:
Astrian Zheng 2025-08-22 11:14:17 +10:00
parent 197fb4011d
commit 488854f46b
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
5 changed files with 664 additions and 657 deletions

View File

@ -9,6 +9,7 @@ import { gsap } from 'gsap'
import apis from '../apis'
import { artistsOrganize } from '../utils'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { usePlayState } from '../stores/usePlayState'
import TrackItem from './TrackItem.vue'
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
import { debugUI } from '../utils/debug'
@ -138,6 +139,7 @@ watch(
)
const playQueue = usePlayQueueStore()
const playState = usePlayState()
async function playTheAlbum(from: number = 0) {
let newQueue = []
@ -148,7 +150,7 @@ async function playTheAlbum(from: number = 0) {
})
}
await playQueue.replaceQueue(newQueue)
await playQueue.togglePlay(true)
await playState.togglePlay(true)
}
function shuffle() {

View File

@ -1,275 +1,180 @@
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { WebAudioGaplessPlayer } from '../utils/webAudioPlayer'
import { usePlayState } from '../stores/usePlayState'
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)
const playState = usePlayState()
const playerInstance = ref<WebAudioPlayer | null>(null)
class WebAudioPlayer {
context: AudioContext
audioBuffer: { [key: string]: AudioBuffer}
dummyAudio: HTMLAudioElement
constructor() {
this.context = new window.AudioContext()
this.audioBuffer = {}
// HTML Audio
this.dummyAudio = new Audio()
this.dummyAudio.style.display = 'none'
this.dummyAudio.loop = true
this.dummyAudio.volume = 0.001 //
// 使
this.createSilentAudioBlob()
document.body.appendChild(this.dummyAudio)
this.initMediaSession()
}
createSilentAudioBlob() {
// 1WAV
const sampleRate = 44100
const channels = 1
const length = sampleRate * 1 // 1
const arrayBuffer = new ArrayBuffer(44 + length * 2)
const view = new DataView(arrayBuffer)
// WAV
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
writeString(0, 'RIFF')
view.setUint32(4, 36 + length * 2, true)
writeString(8, 'WAVE')
writeString(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, channels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
writeString(36, 'data')
view.setUint32(40, length * 2, true)
//
for (let i = 0; i < length; i++) {
view.setInt16(44 + i * 2, 0, true)
}
const blob = new Blob([arrayBuffer], { type: 'audio/wav' })
this.dummyAudio.src = URL.createObjectURL(blob)
}
initMediaSession() {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
console.log('Media session: play requested')
this.play()
})
navigator.mediaSession.setActionHandler('pause', () => {
console.log('Media session: pause requested')
this.pause()
})
navigator.mediaSession.setActionHandler('stop', () => {
console.log('Media session: stop requested')
this.stop()
})
}
}
async loadResourceAndPlay() {
try {
debugPlayer("从播放器实例内部获取播放项目:")
debugPlayer(`目前播放:${playQueue.currentTrack?.song.cid ?? "空"}`)
debugPlayer(`上一首:${playQueue.previousTrack?.song.cid ?? "空"}`)
debugPlayer(`下一首:${playQueue.nextTrack?.song.cid ?? "空"}`)
if (playQueue.queue.length === 0) {
// TODO:
}
// buffer
const loadBuffer = async (track: QueueItem) => {
if (this.audioBuffer[track.song.cid]) return //
const response = await fetch(track.sourceUrl ?? "")
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await this.context.decodeAudioData(arrayBuffer)
this.audioBuffer[track.song.cid] = audioBuffer
}
if (playQueue.currentTrack){
await loadBuffer(playQueue.currentTrack)
this.play()
}
if (playQueue.nextTrack)
await loadBuffer(playQueue.nextTrack)
if (playQueue.previousTrack)
await loadBuffer(playQueue.previousTrack)
debugPlayer("缓存完成")
} catch (error) {
console.error('播放失败:', error)
}
}
//
async play() {
debugPlayer("开始播放")
const source = this.context.createBufferSource()
source.buffer = this.audioBuffer[playQueue.currentTrack.song.cid]
source.connect(this.context.destination)
source.start()
}
pause() {}
resume() {}
stop() {
}
togglePlay() {
}
getCurrentTime() {
}
updateMediaSessionState() {
}
//
startPositionUpdates() {
}
//
destroy() {
}
}
// 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)
}
playerInstance.value = new WebAudioPlayer()
})
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)
}
watch(() => playQueue.currentTrack, () => {
debugPlayer(`检测到当前播放曲目更新`)
playerInstance.value?.loadResourceAndPlay()
})
</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

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { debugStore } from '../utils/debug'
import apis from '../apis'
export const usePlayQueueStore = defineStore('queue', () => {
// 内部状态
@ -10,8 +11,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
const queueReplaceLock = ref(false)
const currentPlaying = ref(0) // 当前播放指针,指针在 queueOrder 中寻址(无论是否开启了随机播放)
const queueOrder = ref<number[]>([]) // 播放队列顺序
const isPlaying = ref(false)
const playProgress = ref(0) // 当前曲目的播放时间指针
// 暴露给外部的响应式只读引用
const queueState = computed(() =>
@ -29,18 +28,27 @@ export const usePlayQueueStore = defineStore('queue', () => {
return queue.value[actualIndex] || null
})
// 获取当前播放时间
const playProgressState = computed(() => playProgress.value)
// 获取上一曲目
const previousTrack = computed(() => {
const actualIndex = queueOrder.value[currentPlaying.value - 1]
return queue.value[actualIndex] || null
})
// 获取当前是否正在播放
const playingState = computed(() => isPlaying.value)
// 获取下一曲目
const nextTrack = computed(() => {
const actualIndex = queueOrder.value[currentPlaying.value + 1]
return queue.value[actualIndex] || null
})
/************
*
***********/
// 使用新队列替换老队列
// 队列替换锁开启时启用确认,确认后重置该锁
async function replaceQueue(newQueue: QueueItem[]) {
async function replaceQueue(newQueue: {
song: Song,
album?: Album
}[]) {
if (queueReplaceLock.value) {
if (
!confirm(
@ -53,13 +61,32 @@ export const usePlayQueueStore = defineStore('queue', () => {
queueReplaceLock.value = false
}
// 将新队列替换已有队列
queue.value = newQueue
// 初始化播放顺序
queueOrder.value = Array.from({ length: newQueue.length }, (_, i) => i)
// 以空队列向外部监听器回报队列已被修改
queue.value = []
queueOrder.value = []
currentPlaying.value = 0
// 获取最新资源地址
let newQueueWithUrl: QueueItem[] = []
for (const track of newQueue) {
const res = await apis.getSong(track.song.cid)
newQueueWithUrl[newQueueWithUrl.length] = {
song: track.song,
album: track.album,
sourceUrl: res.sourceUrl ?? "",
lyricUrl: res.lyricUrl
}
}
debugStore(newQueueWithUrl)
// 将新队列替换已有队列
queue.value = newQueueWithUrl
// 正式初始化播放顺序
queueOrder.value = Array.from({ length: newQueue.length }, (_, i) => i)
// 关闭随机播放和循环(外部可在此方法执行完毕后再更新播放模式)
isShuffle.value = false
loopingMode.value = 'off'
@ -68,12 +95,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
/***********
*
**********/
// 控制播放
const togglePlay = (turnTo?: boolean) => {
const newPlayState = turnTo ?? !isPlaying.value
if (newPlayState === isPlaying.value) return
isPlaying.value = newPlayState
}
// 跳转至队列的某首歌曲
const toggleQueuePlay = (turnTo: number) => {
@ -90,15 +111,10 @@ export const usePlayQueueStore = defineStore('queue', () => {
// 通常为当前曲目播放完毕,需要通过循环模式判断应该重置进度或队列指针 +1
const continueToNext = () => {
debugStore(loopingMode.value)
// TODO: 需要留意 progress seeking 相关
if (loopingMode.value === 'single') playProgress.value = 0
else currentPlaying.value = currentPlaying.value + 1
// 注意:单曲循环时的进度重置需要在播放状态管理中处理
if (loopingMode.value !== 'single') {
currentPlaying.value = currentPlaying.value + 1
}
// 回报播放进度
const reportPlayProgress = (progress: number) => {
debugStore(`进度更新回报: ${progress}`)
playProgress.value = progress
}
/************
@ -178,17 +194,15 @@ export const usePlayQueueStore = defineStore('queue', () => {
loopMode: loopModeState,
currentTrack,
currentIndex: currentPlaying,
isPlaying: playingState,
playProgress: playProgressState,
previousTrack,
nextTrack,
// 修改方法
replaceQueue,
toggleShuffle,
toggleLoop,
togglePlay,
toggleQueuePlay,
skipToNext,
continueToNext,
reportPlayProgress,
}
})

203
src/stores/usePlayState.ts Normal file
View File

@ -0,0 +1,203 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { debugStore } from '../utils/debug'
import artistsOrganize from '../utils/artistsOrganize'
export const usePlayState = defineStore('playState', () => {
// 播放状态
const isPlaying = ref(false)
const playProgress = ref(0) // 播放进度
const currentTrackDuration = ref(0) // 曲目总时长
const currentTrack = ref<QueueItem | null>(null) // 当前播放的曲目
const mediaSessionInitialized = ref(false)
// 外显播放状态方法
const playingState = computed(() => isPlaying.value)
const playProgressState = computed(() => playProgress.value)
const trackDurationState = computed(() => currentTrackDuration.value)
// 回报目前播放进度百分比
const playProgressPercent = computed(() => {
if (currentTrackDuration.value === 0) return 0
return Math.min(playProgress.value / currentTrackDuration.value, 1)
})
// 回报剩余时间
const remainingTime = computed(() => {
return Math.max(currentTrackDuration.value - playProgress.value, 0)
})
/***********
*
**********/
// 触发播放
const togglePlay = (turnTo?: boolean) => {
const newPlayState = turnTo ?? !isPlaying.value
if (newPlayState === isPlaying.value) return
isPlaying.value = newPlayState
debugStore(`播放状态更新: ${newPlayState}`)
}
// 回报播放位置
const reportPlayProgress = (progress: number) => {
debugStore(`播放位置回报: ${progress}`)
playProgress.value = progress
}
// 回报曲目进度
const setCurrentTrackDuration = (duration: number) => {
debugStore(`曲目进度回报: ${duration}`)
currentTrackDuration.value = duration
}
// 重置播放进度
const resetProgress = () => {
debugStore('重置播放进度')
playProgress.value = 0
}
// 用户触发进度条跳转
const seekTo = (time: number) => {
const clampedTime = Math.max(0, Math.min(time, currentTrackDuration.value))
debugStore(`进度条跳转: ${clampedTime}`)
playProgress.value = clampedTime
}
/***********
*
**********/
// 设置当前播放曲目
const setCurrentTrack = (track: QueueItem | null) => {
debugStore('设置当前曲目:', track?.song.name || 'null')
currentTrack.value = track
if (track) {
updateMediaSession(track)
}
}
// 初始化媒体会话处理器
const setupMediaSessionHandlers = () => {
if (!('mediaSession' in navigator) || mediaSessionInitialized.value) return
debugStore('设置媒体会话处理器')
navigator.mediaSession.setActionHandler('play', () => {
debugStore('媒体会话: 播放')
togglePlay(true)
})
navigator.mediaSession.setActionHandler('pause', () => {
debugStore('媒体会话: 暂停')
togglePlay(false)
})
// 上一首和下一首需要从外部传入回调
mediaSessionInitialized.value = true
}
// 设置上一首/下一首处理器
const setTrackNavigationHandlers = (
previousHandler: () => void,
nextHandler: () => void,
) => {
if (!('mediaSession' in navigator)) return
navigator.mediaSession.setActionHandler('previoustrack', () => {
debugStore('媒体会话: 上一首')
previousHandler()
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
debugStore('媒体会话: 下一首')
nextHandler()
})
}
// 更新媒体会话信息
const updateMediaSession = (track: QueueItem) => {
if (!('mediaSession' in navigator)) return
debugStore('更新媒体会话:', track.song.name)
try {
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',
},
],
})
} catch (error) {
console.error('更新媒体会话元数据失败:', error)
}
}
// 更新媒体会话播放状态
const updateMediaSessionPlaybackState = () => {
if (!('mediaSession' in navigator)) return
navigator.mediaSession.playbackState = isPlaying.value
? 'playing'
: 'paused'
debugStore('媒体会话状态更新:', navigator.mediaSession.playbackState)
}
// 更新媒体会话位置信息
const updateMediaSessionPosition = () => {
if (
!('mediaSession' in navigator) ||
!('setPositionState' in navigator.mediaSession)
)
return
try {
navigator.mediaSession.setPositionState({
duration: currentTrackDuration.value || 0,
playbackRate: 1.0,
position: playProgress.value,
})
} catch (error) {
debugStore('媒体会话位置更新失败:', error)
}
}
// 监听播放状态变化,自动更新媒体会话
watch(isPlaying, () => {
updateMediaSessionPlaybackState()
})
// 监听播放进度变化,定期更新位置信息
watch(playProgress, () => {
updateMediaSessionPosition()
})
return {
// 状态读取
isPlaying: playingState,
playProgress: playProgressState,
trackDuration: trackDurationState,
playProgressPercent,
remainingTime,
currentTrack: computed(() => currentTrack.value),
// 修改方法
togglePlay,
reportPlayProgress,
setCurrentTrackDuration,
resetProgress,
seekTo,
// 媒体会话方法
setCurrentTrack,
setupMediaSessionHandlers,
setTrackNavigationHandlers,
updateMediaSession,
updateMediaSessionPlaybackState,
updateMediaSessionPosition,
}
})

View File

@ -1,415 +1,298 @@
interface AudioTrack {
url: string
buffer: AudioBuffer | null
source: AudioBufferSourceNode | null
gainNode: GainNode | null
class SimpleAudioPlayer {
context: AudioContext
currentSource: AudioBufferSourceNode | null
audioBuffer: AudioBuffer | null
playing: boolean
startTime: number
pauseTime: number
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
dummyAudio: HTMLAudioElement
constructor() {
this.context = new (
window.AudioContext || (window as any).webkitAudioContext
)()
this.masterGain = this.context.createGain()
this.masterGain.connect(this.context.destination)
this.context = new window.AudioContext()
this.currentSource = null
this.audioBuffer = null
this.playing = false
this.startTime = 0
this.pauseTime = 0
this.duration = 0
// 创建一个隐藏的 HTML Audio 元素来帮助同步媒体会话状态
this.dummyAudio = new Audio()
this.dummyAudio.style.display = 'none'
this.dummyAudio.loop = true
this.dummyAudio.volume = 0.001 // 极小音量
// 使用一个很短的静音音频文件,或者生成一个
this.createSilentAudioBlob()
document.body.appendChild(this.dummyAudio)
this.initMediaSession()
}
/**
* Load and decode audio from URL
*/
private async loadAudio(url: string): Promise<AudioBuffer> {
if (this.bufferCache.has(url)) {
return this.bufferCache.get(url)!
createSilentAudioBlob() {
// 创建一个1秒的静音WAV文件
const sampleRate = 44100
const channels = 1
const length = sampleRate * 1 // 1秒
const arrayBuffer = new ArrayBuffer(44 + length * 2)
const view = new DataView(arrayBuffer)
// WAV 文件头
const writeString = (offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i))
}
}
writeString(0, 'RIFF')
view.setUint32(4, 36 + length * 2, true)
writeString(8, 'WAVE')
writeString(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, channels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
writeString(36, 'data')
view.setUint32(40, length * 2, true)
// 静音数据(全零)
for (let i = 0; i < length; i++) {
view.setInt16(44 + i * 2, 0, true)
}
const blob = new Blob([arrayBuffer], { type: 'audio/wav' })
this.dummyAudio.src = URL.createObjectURL(blob)
}
initMediaSession() {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
console.log('Media session: play requested')
this.play()
})
navigator.mediaSession.setActionHandler('pause', () => {
console.log('Media session: pause requested')
this.pause()
})
navigator.mediaSession.setActionHandler('stop', () => {
console.log('Media session: stop requested')
this.stop()
})
}
}
async loadResource() {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch audio: ${response.status}`)
// 如果已经加载过,直接播放
if (this.audioBuffer) {
this.play()
return
}
// 加载音频
const response = await fetch(
'https://s3-us-west-2.amazonaws.com/s.cdpn.io/858/outfoxing.mp3'
)
const arrayBuffer = await response.arrayBuffer()
const audioBuffer = await this.context.decodeAudioData(arrayBuffer)
this.audioBuffer = await this.context.decodeAudioData(arrayBuffer)
this.duration = this.audioBuffer.duration
this.bufferCache.set(url, audioBuffer)
this.cleanupCache()
// 设置媒体元数据
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: 'Outfoxing the Fox',
artist: 'Kevin MacLeod',
album: 'YouTube Audio Library',
})
}
return audioBuffer
// 开始播放
this.play()
} catch (error) {
console.error('Failed to load audio:', error)
throw error
console.error('播放失败:', 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)
}
}
async play() {
if (!this.audioBuffer) {
this.loadResource()
return
}
/**
* 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,
if (this.playing) {
console.log('Already playing, ignoring play request')
return
}
this.tracks.push(track)
const index = this.tracks.length - 1
console.log('Starting playback from position:', this.pauseTime)
// 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
// 恢复 AudioContext如果被暂停
if (this.context.state === 'suspended') {
await this.context.resume()
}
// Ensure track is loaded
await this.preloadTrack(index)
// 开始播放隐藏的 audio 元素
try {
await this.dummyAudio.play()
} catch (e) {
console.log('Dummy audio play failed (expected):', e)
}
const track = this.tracks[index]
if (!track?.buffer) {
console.error(`Track ${index} not loaded`)
// 创建新的源节点
this.currentSource = this.context.createBufferSource()
this.currentSource.buffer = this.audioBuffer
this.currentSource.connect(this.context.destination)
// 从暂停位置开始播放
const offset = this.pauseTime
this.currentSource.start(0, offset)
this.startTime = this.context.currentTime - offset
this.playing = true
// 播放结束处理 - 只在自然结束时触发
this.currentSource.onended = () => {
console.log('Audio naturally ended')
// 检查是否真的播放到了结尾
const currentTime = this.getCurrentTime()
if (currentTime >= this.duration - 0.1) { // 允许小误差
console.log('Natural end of track')
this.stop()
} else {
console.log('Audio ended prematurely (likely paused), current time:', currentTime)
// 这是由于暂停导致的结束,不做任何处理
}
}
// 更新媒体会话状态
this.updateMediaSessionState()
}
pause() {
console.log('Pause requested, current state - playing:', this.playing, 'hasSource:', !!this.currentSource)
// 暂停隐藏的 audio 元素
this.dummyAudio.pause()
if (!this.playing) {
console.log('Already paused, but updating media session state')
// 即使已经暂停,也要确保媒体会话状态正确
this.updateMediaSessionState()
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)
if (!this.currentSource) {
console.log('No current source, but updating media session state')
this.updateMediaSessionState()
return
}
// Store references
track.source = source
track.gainNode = gainNode
console.log('Pausing playback at position:', this.getCurrentTime())
// Start playing immediately
source.start(this.context.currentTime)
// 计算当前播放位置
this.pauseTime = this.getCurrentTime()
// Notify track started
this.onTrackStartCallbacks.forEach((cb) => cb(index))
// 移除 onended 事件处理器,避免干扰
this.currentSource.onended = null
// 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)
}
}
// 停止当前源
this.currentSource.stop()
this.currentSource = null
this.playing = false
// 更新媒体会话状态
this.updateMediaSessionState()
}
/**
* Handle track ended event
*/
private handleTrackEnded(index: number): void {
const track = this.tracks[index]
if (track) {
track.source = null
track.gainNode = null
stop() {
console.log('Stopping playback')
// 停止隐藏的 audio 元素
this.dummyAudio.pause()
this.dummyAudio.currentTime = 0
if (this.currentSource) {
this.currentSource.stop()
this.currentSource = null
}
// Only notify if this is still the current track
if (index === this.state.currentIndex) {
this.onTrackEndCallbacks.forEach((cb) => cb(index))
}
this.playing = false
this.pauseTime = 0
this.startTime = 0
// 更新媒体会话状态
this.updateMediaSessionState()
}
/**
* 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 {
togglePlay() {
if (this.playing) {
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,
} else {
this.play()
}
}
const currentTrack = this.tracks[this.state.currentIndex]
if (!currentTrack?.source) return null
getCurrentTime(): number {
if (this.playing && this.currentSource) {
return Math.min(this.context.currentTime - this.startTime, this.duration)
}
return this.pauseTime
}
// 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)
updateMediaSessionState() {
if ('mediaSession' in navigator) {
let state = 'none'
if (this.playing) {
state = 'playing'
} else if (this.audioBuffer) {
// 只要有音频缓冲区就应该是暂停状态
state = 'paused'
}
return {
trackIndex: this.state.currentIndex,
trackTime,
totalTime: trackTime,
console.log('Updating media session state to:', state, '(playing:', this.playing, ', hasBuffer:', !!this.audioBuffer, ')')
// 强制设置状态
try {
navigator.mediaSession.playbackState = state as any
// 更新位置信息
if ('setPositionState' in navigator.mediaSession && this.duration > 0) {
navigator.mediaSession.setPositionState({
duration: this.duration,
playbackRate: 1.0,
position: this.getCurrentTime()
})
}
} catch (error) {
console.error('Error updating media session:', error)
}
}
}
/**
* Set volume (0.0 to 1.0)
*/
setVolume(volume: number): void {
this.masterGain.gain.value = Math.max(0, Math.min(1, volume))
// 定期更新播放位置
startPositionUpdates() {
setInterval(() => {
if (this.audioBuffer) {
this.updateMediaSessionState()
}
}, 1000)
}
/**
* Get volume
*/
getVolume(): number {
return this.masterGain.gain.value
}
/**
* Clear all tracks
*/
clearQueue(): void {
// 清理资源
destroy() {
this.stop()
this.tracks = []
this.bufferCache.clear()
if (this.dummyAudio) {
document.body.removeChild(this.dummyAudio)
}
/**
* 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()
}
}
}
export default SimpleWebAudioPlayer