feat: 再重构
手作代码,从零重写,真正的犟人精神
This commit is contained in:
parent
197fb4011d
commit
488854f46b
|
@ -9,6 +9,7 @@ import { gsap } from 'gsap'
|
||||||
import apis from '../apis'
|
import apis from '../apis'
|
||||||
import { artistsOrganize } from '../utils'
|
import { artistsOrganize } from '../utils'
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
|
import { usePlayState } from '../stores/usePlayState'
|
||||||
import TrackItem from './TrackItem.vue'
|
import TrackItem from './TrackItem.vue'
|
||||||
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
|
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
|
||||||
import { debugUI } from '../utils/debug'
|
import { debugUI } from '../utils/debug'
|
||||||
|
@ -138,6 +139,7 @@ watch(
|
||||||
)
|
)
|
||||||
|
|
||||||
const playQueue = usePlayQueueStore()
|
const playQueue = usePlayQueueStore()
|
||||||
|
const playState = usePlayState()
|
||||||
|
|
||||||
async function playTheAlbum(from: number = 0) {
|
async function playTheAlbum(from: number = 0) {
|
||||||
let newQueue = []
|
let newQueue = []
|
||||||
|
@ -148,7 +150,7 @@ async function playTheAlbum(from: number = 0) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await playQueue.replaceQueue(newQueue)
|
await playQueue.replaceQueue(newQueue)
|
||||||
await playQueue.togglePlay(true)
|
await playState.togglePlay(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shuffle() {
|
function shuffle() {
|
||||||
|
|
|
@ -1,275 +1,180 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { WebAudioGaplessPlayer } from '../utils/webAudioPlayer'
|
import { usePlayState } from '../stores/usePlayState'
|
||||||
import { debugPlayer } from '../utils/debug'
|
import { debugPlayer } from '../utils/debug'
|
||||||
import { watch, ref, onMounted, onUnmounted } from 'vue'
|
import { watch, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import artistsOrganize from '../utils/artistsOrganize'
|
import artistsOrganize from '../utils/artistsOrganize'
|
||||||
|
|
||||||
const playQueue = usePlayQueueStore()
|
const playQueue = usePlayQueueStore()
|
||||||
const player = ref<WebAudioGaplessPlayer | null>(null)
|
const playState = usePlayState()
|
||||||
const isInitialized = ref(false)
|
const playerInstance = ref<WebAudioPlayer | null>(null)
|
||||||
const currentTrackIndex = ref(-1)
|
|
||||||
|
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() {
|
||||||
|
// 创建一个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 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 播放器
|
// 初始化 Web Audio 播放器
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
player.value = new WebAudioGaplessPlayer()
|
playerInstance.value = new WebAudioPlayer()
|
||||||
|
|
||||||
// 注册事件回调
|
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 销毁播放器
|
watch(() => playQueue.currentTrack, () => {
|
||||||
onUnmounted(() => {
|
debugPlayer(`检测到当前播放曲目更新`)
|
||||||
if (player.value) {
|
playerInstance.value?.loadResourceAndPlay()
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.web-audio-player {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { debugStore } from '../utils/debug'
|
import { debugStore } from '../utils/debug'
|
||||||
|
import apis from '../apis'
|
||||||
|
|
||||||
export const usePlayQueueStore = defineStore('queue', () => {
|
export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
// 内部状态
|
// 内部状态
|
||||||
|
@ -10,8 +11,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
const queueReplaceLock = ref(false)
|
const queueReplaceLock = ref(false)
|
||||||
const currentPlaying = ref(0) // 当前播放指针,指针在 queueOrder 中寻址(无论是否开启了随机播放)
|
const currentPlaying = ref(0) // 当前播放指针,指针在 queueOrder 中寻址(无论是否开启了随机播放)
|
||||||
const queueOrder = ref<number[]>([]) // 播放队列顺序
|
const queueOrder = ref<number[]>([]) // 播放队列顺序
|
||||||
const isPlaying = ref(false)
|
|
||||||
const playProgress = ref(0) // 当前曲目的播放时间指针
|
|
||||||
|
|
||||||
// 暴露给外部的响应式只读引用
|
// 暴露给外部的响应式只读引用
|
||||||
const queueState = computed(() =>
|
const queueState = computed(() =>
|
||||||
|
@ -29,18 +28,27 @@ export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
return queue.value[actualIndex] || null
|
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 (queueReplaceLock.value) {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
|
@ -53,13 +61,32 @@ export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
queueReplaceLock.value = false
|
queueReplaceLock.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将新队列替换已有队列
|
// 以空队列向外部监听器回报队列已被修改
|
||||||
queue.value = newQueue
|
queue.value = []
|
||||||
|
queueOrder.value = []
|
||||||
// 初始化播放顺序
|
|
||||||
queueOrder.value = Array.from({ length: newQueue.length }, (_, i) => i)
|
|
||||||
currentPlaying.value = 0
|
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
|
isShuffle.value = false
|
||||||
loopingMode.value = 'off'
|
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) => {
|
const toggleQueuePlay = (turnTo: number) => {
|
||||||
|
@ -90,15 +111,10 @@ export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
// 通常为当前曲目播放完毕,需要通过循环模式判断应该重置进度或队列指针 +1
|
// 通常为当前曲目播放完毕,需要通过循环模式判断应该重置进度或队列指针 +1
|
||||||
const continueToNext = () => {
|
const continueToNext = () => {
|
||||||
debugStore(loopingMode.value)
|
debugStore(loopingMode.value)
|
||||||
// TODO: 需要留意 progress seeking 相关
|
// 注意:单曲循环时的进度重置需要在播放状态管理中处理
|
||||||
if (loopingMode.value === 'single') playProgress.value = 0
|
if (loopingMode.value !== 'single') {
|
||||||
else currentPlaying.value = currentPlaying.value + 1
|
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,
|
loopMode: loopModeState,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
currentIndex: currentPlaying,
|
currentIndex: currentPlaying,
|
||||||
isPlaying: playingState,
|
previousTrack,
|
||||||
playProgress: playProgressState,
|
nextTrack,
|
||||||
|
|
||||||
// 修改方法
|
// 修改方法
|
||||||
replaceQueue,
|
replaceQueue,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
toggleLoop,
|
toggleLoop,
|
||||||
togglePlay,
|
|
||||||
toggleQueuePlay,
|
toggleQueuePlay,
|
||||||
skipToNext,
|
skipToNext,
|
||||||
continueToNext,
|
continueToNext,
|
||||||
reportPlayProgress,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
203
src/stores/usePlayState.ts
Normal file
203
src/stores/usePlayState.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
|
@ -1,415 +1,298 @@
|
||||||
interface AudioTrack {
|
class SimpleAudioPlayer {
|
||||||
url: string
|
context: AudioContext
|
||||||
buffer: AudioBuffer | null
|
currentSource: AudioBufferSourceNode | null
|
||||||
source: AudioBufferSourceNode | null
|
audioBuffer: AudioBuffer | null
|
||||||
gainNode: GainNode | null
|
playing: boolean
|
||||||
|
startTime: number
|
||||||
|
pauseTime: number
|
||||||
duration: number
|
duration: number
|
||||||
metadata?: any
|
dummyAudio: HTMLAudioElement
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
constructor() {
|
||||||
this.context = new (
|
this.context = new window.AudioContext()
|
||||||
window.AudioContext || (window as any).webkitAudioContext
|
this.currentSource = null
|
||||||
)()
|
this.audioBuffer = null
|
||||||
this.masterGain = this.context.createGain()
|
this.playing = false
|
||||||
this.masterGain.connect(this.context.destination)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
createSilentAudioBlob() {
|
||||||
* Load and decode audio from URL
|
// 创建一个1秒的静音WAV文件
|
||||||
*/
|
const sampleRate = 44100
|
||||||
private async loadAudio(url: string): Promise<AudioBuffer> {
|
const channels = 1
|
||||||
if (this.bufferCache.has(url)) {
|
const length = sampleRate * 1 // 1秒
|
||||||
return this.bufferCache.get(url)!
|
|
||||||
|
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 {
|
try {
|
||||||
const response = await fetch(url)
|
// 如果已经加载过,直接播放
|
||||||
if (!response.ok) {
|
if (this.audioBuffer) {
|
||||||
throw new Error(`Failed to fetch audio: ${response.status}`)
|
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 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({
|
||||||
return audioBuffer
|
title: 'Outfoxing the Fox',
|
||||||
} catch (error) {
|
artist: 'Kevin MacLeod',
|
||||||
console.error('Failed to load audio:', error)
|
album: 'YouTube Audio Library',
|
||||||
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
|
this.play()
|
||||||
*/
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to preload track ${index}:`, error)
|
console.error('播放失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async play() {
|
||||||
* Stop current track if playing
|
if (!this.audioBuffer) {
|
||||||
*/
|
this.loadResource()
|
||||||
private stopCurrentTrack(): void {
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (this.playing) {
|
||||||
* Play a specific track
|
console.log('Already playing, ignoring play request')
|
||||||
*/
|
return
|
||||||
async playTrack(index: number): Promise<void> {
|
}
|
||||||
if (index < 0 || index >= this.tracks.length) return
|
|
||||||
|
|
||||||
// Stop any currently playing track
|
console.log('Starting playback from position:', this.pauseTime)
|
||||||
this.stopCurrentTrack()
|
|
||||||
|
|
||||||
// Resume context if suspended
|
// 恢复 AudioContext(如果被暂停)
|
||||||
if (this.context.state === 'suspended') {
|
if (this.context.state === 'suspended') {
|
||||||
await this.context.resume()
|
await this.context.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure track is loaded
|
// 开始播放隐藏的 audio 元素
|
||||||
await this.preloadTrack(index)
|
try {
|
||||||
|
await this.dummyAudio.play()
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Dummy audio play failed (expected):', e)
|
||||||
|
}
|
||||||
|
|
||||||
const track = this.tracks[index]
|
// 创建新的源节点
|
||||||
if (!track?.buffer) {
|
this.currentSource = this.context.createBufferSource()
|
||||||
console.error(`Track ${index} not loaded`)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state
|
if (!this.currentSource) {
|
||||||
this.state.currentIndex = index
|
console.log('No current source, but updating media session state')
|
||||||
this.state.isPlaying = true
|
this.updateMediaSessionState()
|
||||||
this.state.pausedOffset = 0
|
return
|
||||||
|
|
||||||
// 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
|
console.log('Pausing playback at position:', this.getCurrentTime())
|
||||||
track.source = source
|
|
||||||
track.gainNode = gainNode
|
|
||||||
|
|
||||||
// Start playing immediately
|
// 计算当前播放位置
|
||||||
source.start(this.context.currentTime)
|
this.pauseTime = this.getCurrentTime()
|
||||||
|
|
||||||
// Notify track started
|
// 移除 onended 事件处理器,避免干扰
|
||||||
this.onTrackStartCallbacks.forEach((cb) => cb(index))
|
this.currentSource.onended = null
|
||||||
|
|
||||||
// Preload next tracks
|
// 停止当前源
|
||||||
for (let i = 1; i <= this.preloadAhead; i++) {
|
this.currentSource.stop()
|
||||||
const nextIndex = index + i
|
this.currentSource = null
|
||||||
if (nextIndex < this.tracks.length) {
|
this.playing = false
|
||||||
this.preloadTrack(nextIndex).catch(console.error)
|
|
||||||
|
// 更新媒体会话状态
|
||||||
|
this.updateMediaSessionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
console.log('Stopping playback')
|
||||||
|
|
||||||
|
// 停止隐藏的 audio 元素
|
||||||
|
this.dummyAudio.pause()
|
||||||
|
this.dummyAudio.currentTime = 0
|
||||||
|
|
||||||
|
if (this.currentSource) {
|
||||||
|
this.currentSource.stop()
|
||||||
|
this.currentSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playing = false
|
||||||
|
this.pauseTime = 0
|
||||||
|
this.startTime = 0
|
||||||
|
|
||||||
|
// 更新媒体会话状态
|
||||||
|
this.updateMediaSessionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlay() {
|
||||||
|
if (this.playing) {
|
||||||
|
this.pause()
|
||||||
|
} else {
|
||||||
|
this.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentTime(): number {
|
||||||
|
if (this.playing && this.currentSource) {
|
||||||
|
return Math.min(this.context.currentTime - this.startTime, this.duration)
|
||||||
|
}
|
||||||
|
return this.pauseTime
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMediaSessionState() {
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
let state = 'none'
|
||||||
|
if (this.playing) {
|
||||||
|
state = 'playing'
|
||||||
|
} else if (this.audioBuffer) {
|
||||||
|
// 只要有音频缓冲区就应该是暂停状态
|
||||||
|
state = 'paused'
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 定期更新播放位置
|
||||||
* Handle track ended event
|
startPositionUpdates() {
|
||||||
*/
|
setInterval(() => {
|
||||||
private handleTrackEnded(index: number): void {
|
if (this.audioBuffer) {
|
||||||
const track = this.tracks[index]
|
this.updateMediaSessionState()
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}, 1000)
|
||||||
|
|
||||||
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)
|
destroy() {
|
||||||
*/
|
|
||||||
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.stop()
|
||||||
this.tracks = []
|
if (this.dummyAudio) {
|
||||||
this.bufferCache.clear()
|
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') {
|
if (this.context.state !== 'closed') {
|
||||||
this.context.close()
|
this.context.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default SimpleWebAudioPlayer
|
Loading…
Reference in New Issue
Block a user