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