Compare commits

...

7 Commits

Author SHA1 Message Date
be15a89ad6
fix: 恢复非 Safari 浏览器的音频可视化效果
Some checks failed
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Blocked by required conditions
构建扩展程序 / 构建 Chrome 扩展程序 (push) Failing after 1m0s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 构建 Firefox 附加组件 (push) Failing after 16m24s
- 只在 Safari 浏览器上使用静态图标替代可视化
- Chrome、Firefox 等浏览器保留原有的动态可视化效果
- Player.vue 和 PlayQueueItem.vue 现在都会检测浏览器支持情况

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 22:31:16 +10:00
282af70b74
fix: 替换音频可视化效果为静态图标
- Player.vue: 播放状态显示暂停图标而非可视化效果
- PlayQueueItem.vue: 当前播放项显示音波图标并带脉冲动画
- 创建新的 soundwave.vue 图标用于播放指示
- 避免在不支持的浏览器上显示空白或错误

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 22:29:22 +10:00
b0743178ed
fix: 修复 ref 未导入的错误
- 在 Player.vue 中添加缺失的 ref 导入
- 解决 "ReferenceError: Can't find variable: ref" 错误

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 22:25:24 +10:00
8ee2b928f9
fix: 解决 Safari 浏览器音频播放问题
- 创建浏览器检测工具,专门检测 Safari 和音频可视化支持
- 在 Safari 浏览器上禁用 AudioContext 连接,避免播放问题
- 保持其他浏览器的音频可视化功能正常工作

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 22:23:34 +10:00
5be5b4812f
docs: 更新 CLAUDE.md 添加代码风格和随机播放逻辑说明
- 添加项目使用 Tab 缩进的说明
- 添加随机播放模式的详细逻辑说明
- 说明 shuffleList 和 currentIndex 的关系

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 22:15:06 +10:00
ae2d8875ad
fix: 修复随机播放模式下预加载顺序错误的问题
- 修正 getNextIndex 在随机播放模式下返回正确的原始列表索引
- 简化预加载逻辑,因为 nextIndex 已经是正确的列表索引
- 保持 Player.vue 中更新歌曲信息时的索引计算逻辑

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 22:14:44 +10:00
dcf13b2f07
refactor: move resource URL refresh from favorites loading to playback/preload
Replace passive resource checking on playlist item mount with active checking
during playback and preload operations. This improves performance by reducing
unnecessary network requests and ensures resources are validated only when needed.

Changes:
- Create songResourceChecker utility for centralized resource validation
- Remove resource checking from PlayListItem component
- Add resource validation in Player component before playback
- Add resource validation in usePlayQueueStore before preload
- Maintain data consistency between play queue and favorites

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 21:22:39 +10:00
9 changed files with 562 additions and 227 deletions

View File

@ -83,6 +83,12 @@ npm run qc # Alias for quality-check
- **URL Refresh Logic**: Checks resource availability before playback/preload - **URL Refresh Logic**: Checks resource availability before playback/preload
- **Cache Invalidation**: Automatic cleanup when resource URLs change - **Cache Invalidation**: Automatic cleanup when resource URLs change
### Shuffle/Random Play Logic
- **shuffleList**: Array storing the shuffled order of original list indices
- **currentIndex**: In shuffle mode, this is the index within shuffleList
- **Accessing current song**: `list[shuffleList[currentIndex]]` in shuffle mode
- **getNextIndex**: Returns the actual list index of the next song to play
## File Structure Notes ## File Structure Notes
### `/src/utils/` ### `/src/utils/`
@ -99,6 +105,13 @@ npm run qc # Alias for quality-check
- **content.js**: Injects the Vue app into target websites - **content.js**: Injects the Vue app into target websites
- **background.js**: Extension background script - **background.js**: Extension background script
## Code Style and Formatting
### Indentation
- **This project uses Tab indentation (not spaces)**
- Ensure all code edits maintain consistent Tab indentation
- When editing files, preserve the existing Tab character formatting
## Development Considerations ## Development Considerations
### Extension Context ### Extension Context

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{
size: number
}>()
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" :class="`w-${size} h-${size}`">
<rect x="3" y="9" width="2" height="6" rx="1"></rect>
<rect x="7" y="5" width="2" height="14" rx="1"></rect>
<rect x="11" y="7" width="2" height="10" rx="1"></rect>
<rect x="15" y="4" width="2" height="16" rx="1"></rect>
<rect x="19" y="10" width="2" height="4" rx="1"></rect>
</svg>
</template>

View File

@ -2,11 +2,8 @@
import { artistsOrganize } from '../utils' import { artistsOrganize } from '../utils'
import { ref } from 'vue' import { ref } from 'vue'
import { useFavourites } from '../stores/useFavourites' import { useFavourites } from '../stores/useFavourites'
import apis from '../apis'
import axios from 'axios'
import StarSlashIcon from '../assets/icons/starslash.vue' import StarSlashIcon from '../assets/icons/starslash.vue'
import { onMounted } from 'vue'
const favourites = useFavourites() const favourites = useFavourites()
@ -20,27 +17,6 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'play', index: number): void (e: 'play', index: number): void
}>() }>()
onMounted(async () => {
try {
//
await axios.head(props.item.song.sourceUrl ?? '', {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
params: {
_t: Date.now() //
}
})
} catch (error) {
//
const updatedSong = await apis.getSong(props.item.song.cid)
console.log('Updated song:', updatedSong)
favourites.updateSongInFavourites(props.item.song.cid, updatedSong)
}
})
</script> </script>
<template> <template>

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore' import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { artistsOrganize } from '../utils' import { artistsOrganize, supportsWebAudioVisualization } from '../utils'
import XIcon from '../assets/icons/x.vue' import XIcon from '../assets/icons/x.vue'
import UpHyphenIcon from '../assets/icons/uphypen.vue' import UpHyphenIcon from '../assets/icons/uphypen.vue'
import DownHyphenIcon from '../assets/icons/downhyphen.vue' import DownHyphenIcon from '../assets/icons/downhyphen.vue'
import SoundwaveIcon from '../assets/icons/soundwave.vue'
import { ref } from 'vue' import { ref } from 'vue'
@ -18,6 +19,9 @@ const playQueueStore = usePlayQueueStore()
const hover = ref(false) const hover = ref(false)
//
const isAudioVisualizationSupported = supportsWebAudioVisualization()
function moveUp() { function moveUp() {
if (props.index === 0) return if (props.index === 0) return
@ -156,12 +160,14 @@ function removeItem() {
<img :src="queueItem.album?.coverUrl" /> <img :src="queueItem.album?.coverUrl" />
<div class="w-full h-full absolute top-0 left-0 bg-neutral-900/75 flex justify-center items-center" <div class="w-full h-full absolute top-0 left-0 bg-neutral-900/75 flex justify-center items-center"
v-if="isCurrent"> v-if="isCurrent">
<div style="height: 1rem;" class="flex justify-center items-center gap-[.125rem]"> <!-- 在支持的浏览器上显示可视化否则显示音波图标 -->
<div v-if="isAudioVisualizationSupported" style="height: 1rem;" class="flex justify-center items-center gap-[.125rem]">
<div class="bg-white w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer" <div class="bg-white w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer"
:key="index" :style="{ :key="index" :style="{
height: `${Math.max(10, bar)}%` height: `${Math.max(10, bar)}%`
}" /> }" />
</div> </div>
<SoundwaveIcon v-else :size="6" class="text-white animate-pulse" />
</div> </div>
</div> </div>
<div class="flex flex-col text-left flex-auto w-0"> <div class="flex flex-col text-left flex-auto w-0">

View File

@ -1,15 +1,17 @@
<!-- Player.vue - 添加预加载功能 --> <!-- Player.vue - 添加预加载功能 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { useTemplateRef, watch, nextTick, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useFavourites } from '../stores/useFavourites'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import PlayIcon from '../assets/icons/play.vue'
import LoadingIndicator from '../assets/icons/loadingindicator.vue' import LoadingIndicator from '../assets/icons/loadingindicator.vue'
import { audioVisualizer } from '../utils' import PlayIcon from '../assets/icons/play.vue'
import PauseIcon from '../assets/icons/pause.vue'
import { audioVisualizer, checkAndRefreshSongResource, supportsWebAudioVisualization } from '../utils'
const playQueueStore = usePlayQueueStore() const playQueueStore = usePlayQueueStore()
const favourites = useFavourites()
const route = useRoute() const route = useRoute()
const player = useTemplateRef('playerRef') const player = useTemplateRef('playerRef')
@ -17,15 +19,20 @@ const player = useTemplateRef('playerRef')
console.log('[Player] 检查 store 方法:', { console.log('[Player] 检查 store 方法:', {
preloadNext: typeof playQueueStore.preloadNext, preloadNext: typeof playQueueStore.preloadNext,
getPreloadedAudio: typeof playQueueStore.getPreloadedAudio, getPreloadedAudio: typeof playQueueStore.getPreloadedAudio,
clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio,
}) })
// //
const currentTrack = computed(() => { const currentTrack = computed(() => {
if (playQueueStore.playMode.shuffle && playQueueStore.shuffleList.length > 0) { if (
return playQueueStore.list[playQueueStore.shuffleList[playQueueStore.currentIndex]] playQueueStore.playMode.shuffle &&
playQueueStore.shuffleList.length > 0
) {
return playQueueStore.list[
playQueueStore.shuffleList[playQueueStore.currentIndex]
]
} }
return playQueueStore.list[playQueueStore.currentIndex] return playQueueStore.list[playQueueStore.currentIndex]
}) })
@ -35,86 +42,132 @@ const currentAudioSrc = computed(() => {
return track ? track.song.sourceUrl : '' return track ? track.song.sourceUrl : ''
}) })
watch(() => playQueueStore.isPlaying, (newValue) => { watch(
if (newValue) { () => playQueueStore.isPlaying,
player.value?.play() (newValue) => {
setMetadata() if (newValue) {
} player.value?.play()
else { player.value?.pause() } setMetadata()
}) } else {
player.value?.pause()
}
},
)
// //
watch(() => playQueueStore.currentIndex, async () => { watch(
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex) () => playQueueStore.currentIndex,
async () => {
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
// 使 // 使
const track = currentTrack.value const track = currentTrack.value
if (track) { if (track) {
const songId = track.song.cid const songId = track.song.cid
try { try {
const preloadedAudio = playQueueStore.getPreloadedAudio(songId) //
console.log('[Player] 检查当前歌曲资源:', track.song.name)
const updatedSong = await checkAndRefreshSongResource(
track.song,
(updated) => {
//
// currentIndex shuffleList
// shuffleList[currentIndex] list
const actualIndex =
playQueueStore.playMode.shuffle &&
playQueueStore.shuffleList.length > 0
? playQueueStore.shuffleList[playQueueStore.currentIndex]
: playQueueStore.currentIndex
if (playQueueStore.list[actualIndex]) {
playQueueStore.list[actualIndex].song = updated
}
//
favourites.updateSongInFavourites(songId, updated)
},
)
if (preloadedAudio) { // 使
console.log(`[Player] 使用预加载的音频: ${track.song.name}`) const preloadedAudio = playQueueStore.getPreloadedAudio(songId)
// 使 if (preloadedAudio && updatedSong.sourceUrl === track.song.sourceUrl) {
if (player.value) { console.log(`[Player] 使用预加载的音频: ${track.song.name}`)
//
player.value.src = preloadedAudio.src
player.value.currentTime = 0
// 使 // 使
playQueueStore.clearPreloadedAudio(songId) if (player.value) {
//
player.value.src = preloadedAudio.src
player.value.currentTime = 0
// // 使
if (playQueueStore.isPlaying) { playQueueStore.clearPreloadedAudio(songId)
await nextTick()
player.value.play().catch(console.error) //
if (playQueueStore.isPlaying) {
await nextTick()
player.value.play().catch(console.error)
}
playQueueStore.isBuffering = false
} }
} else {
console.log(`[Player] 正常加载音频: ${track.song.name}`)
playQueueStore.isBuffering = true
playQueueStore.isBuffering = false //
if (updatedSong.sourceUrl !== track.song.sourceUrl) {
playQueueStore.clearPreloadedAudio(songId)
}
} }
} else { } catch (error) {
console.log(`[Player] 正常加载音频: ${track.song.name}`) console.error('[Player] 处理预加载音频时出错:', error)
playQueueStore.isBuffering = true playQueueStore.isBuffering = true
} }
} catch (error) {
console.error('[Player] 处理预加载音频时出错:', error)
playQueueStore.isBuffering = true
} }
}
setMetadata() setMetadata()
// //
setTimeout(() => { setTimeout(async () => {
try { try {
console.log('[Player] 尝试预加载下一首歌') console.log('[Player] 尝试预加载下一首歌')
// //
if (typeof playQueueStore.preloadNext === 'function') { if (typeof playQueueStore.preloadNext === 'function') {
playQueueStore.preloadNext() await playQueueStore.preloadNext()
playQueueStore.limitPreloadCache()
} else { //
console.error('[Player] preloadNext 不是一个函数') playQueueStore.list.forEach((item) => {
if (favourites.isFavourite(item.song.cid)) {
favourites.updateSongInFavourites(item.song.cid, item.song)
}
})
playQueueStore.limitPreloadCache()
} else {
console.error('[Player] preloadNext 不是一个函数')
}
} catch (error) {
console.error('[Player] 预加载失败:', error)
} }
} catch (error) { }, 1000)
console.error('[Player] 预加载失败:', error) },
} )
}, 1000)
})
function artistsOrganize(list: string[]) { function artistsOrganize(list: string[]) {
if (list.length === 0) { return '未知音乐人' } if (list.length === 0) {
return list.map((artist) => { return '未知音乐人'
return artist }
}).join(' / ') return list
.map((artist) => {
return artist
})
.join(' / ')
} }
function setMetadata() { function setMetadata() {
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
let current = currentTrack.value const current = currentTrack.value
if (!current) return if (!current) return
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
@ -122,8 +175,12 @@ function setMetadata() {
artist: artistsOrganize(current.song.artists ?? []), artist: artistsOrganize(current.song.artists ?? []),
album: current.album?.name, album: current.album?.name,
artwork: [ artwork: [
{ src: current.album?.coverUrl ?? '', sizes: '500x500', type: 'image/png' }, {
] src: current.album?.coverUrl ?? '',
sizes: '500x500',
type: 'image/png',
},
],
}) })
navigator.mediaSession.setActionHandler('previoustrack', playPrevious) navigator.mediaSession.setActionHandler('previoustrack', playPrevious)
@ -133,16 +190,21 @@ function setMetadata() {
playQueueStore.currentTime = player.value?.currentTime ?? 0 playQueueStore.currentTime = player.value?.currentTime ?? 0
} }
watch(() => playQueueStore.updatedCurrentTime, (newValue) => { watch(
if (newValue === null) { return } () => playQueueStore.updatedCurrentTime,
if (player.value) player.value.currentTime = newValue (newValue) => {
playQueueStore.updatedCurrentTime = null if (newValue === null) {
}) return
}
if (player.value) player.value.currentTime = newValue
playQueueStore.updatedCurrentTime = null
},
)
} }
function playNext() { function playNext() {
if (playQueueStore.currentIndex === playQueueStore.list.length - 1) { if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
console.log("at the bottom, pause") console.log('at the bottom, pause')
playQueueStore.currentIndex = 0 playQueueStore.currentIndex = 0
if (playQueueStore.playMode.repeat === 'all') { if (playQueueStore.playMode.repeat === 'all') {
playQueueStore.currentIndex = 0 playQueueStore.currentIndex = 0
@ -158,11 +220,17 @@ function playNext() {
} }
function playPrevious() { function playPrevious() {
if (player.value && (player.value.currentTime ?? 0) < 5 && playQueueStore.currentIndex > 0) { if (
player.value &&
(player.value.currentTime ?? 0) < 5 &&
playQueueStore.currentIndex > 0
) {
playQueueStore.currentIndex-- playQueueStore.currentIndex--
playQueueStore.isPlaying = true playQueueStore.isPlaying = true
} else { } else {
if (player.value) { player.value.currentTime = 0 } if (player.value) {
player.value.currentTime = 0
}
} }
} }
@ -179,8 +247,10 @@ function updateCurrentTime() {
const preloadTrigger = (config.preloadTrigger || 50) / 100 // const preloadTrigger = (config.preloadTrigger || 50) / 100 //
const remainingTimeThreshold = config.remainingTimeThreshold || 30 const remainingTimeThreshold = config.remainingTimeThreshold || 30
if ((progress > preloadTrigger || remainingTime < remainingTimeThreshold) && !playQueueStore.isPreloading) { if (
(progress > preloadTrigger || remainingTime < remainingTimeThreshold) &&
!playQueueStore.isPreloading
) {
try { try {
if (typeof playQueueStore.preloadNext === 'function') { if (typeof playQueueStore.preloadNext === 'function') {
playQueueStore.preloadNext() playQueueStore.preloadNext()
@ -194,115 +264,160 @@ function updateCurrentTime() {
} }
} }
console.log('[Player] 初始化 audioVisualizer') //
const { barHeights, connectAudio, isAnalyzing, error } = audioVisualizer({ const isAudioVisualizationSupported = supportsWebAudioVisualization()
sensitivity: 1.5, console.log('[Player] 音频可视化支持状态:', isAudioVisualizationSupported)
barCount: 6,
maxDecibels: -10,
bassBoost: 0.8,
midBoost: 1.2,
trebleBoost: 1.4,
threshold: 0
})
console.log('[Player] audioVisualizer 返回值:', { barHeights: barHeights.value, isAnalyzing: isAnalyzing.value }) //
let barHeights = ref<number[]>([0, 0, 0, 0, 0, 0])
let connectAudio = (_audio: HTMLAudioElement) => {}
let isAnalyzing = ref(false)
let error = ref<string | null>(null)
if (isAudioVisualizationSupported) {
console.log('[Player] 初始化 audioVisualizer')
const visualizer = audioVisualizer({
sensitivity: 1.5,
barCount: 6,
maxDecibels: -10,
bassBoost: 0.8,
midBoost: 1.2,
trebleBoost: 1.4,
threshold: 0,
})
barHeights = visualizer.barHeights
connectAudio = visualizer.connectAudio
isAnalyzing = visualizer.isAnalyzing
error = visualizer.error
console.log('[Player] audioVisualizer 返回值:', {
barHeights: barHeights.value,
isAnalyzing: isAnalyzing.value,
})
} else {
console.log('[Player] 音频可视化被禁用Safari 或不支持的浏览器)')
}
// //
watch(() => playQueueStore.list.length, async (newLength) => { watch(
console.log('[Player] 播放列表长度变化:', newLength) () => playQueueStore.list.length,
if (newLength === 0) { async (newLength) => {
console.log('[Player] 播放列表为空,跳过连接') console.log('[Player] 播放列表长度变化:', newLength)
return if (newLength === 0) {
} console.log('[Player] 播放列表为空,跳过连接')
return
// audio
await nextTick()
if (player.value) {
console.log('[Player] 连接音频元素到可视化器')
console.log('[Player] 音频元素状态:', {
src: player.value.src?.substring(0, 50) + '...',
readyState: player.value.readyState,
paused: player.value.paused
})
connectAudio(player.value)
} else {
console.log('[Player] ❌ 音频元素不存在')
}
playQueueStore.visualizer = barHeights.value
//
setTimeout(() => {
playQueueStore.preloadNext()
}, 2000)
//
if (player.value) {
initializeVolume()
}
})
//
watch(() => player.value, (audioElement) => {
if (audioElement && playQueueStore.list.length > 0) {
connectAudio(audioElement)
}
})
//
watch(() => barHeights.value, (newHeights) => {
playQueueStore.visualizer = newHeights
}, { deep: true })
//
watch(() => error.value, (newError) => {
if (newError) {
console.error('[Player] 可视化器错误:', newError)
}
})
//
watch(() => playQueueStore.playMode.shuffle, (isShuffle) => {
if (isShuffle) {
const currentIndex = playQueueStore.currentIndex
const trackCount = playQueueStore.list.length
// 1.
let shuffledList = [...Array(currentIndex).keys()]
// 2.
let shuffleSpace = [...Array(trackCount).keys()].filter(index =>
playQueueStore.shuffleCurrent ? index >= currentIndex : index > currentIndex
)
// 3.
shuffleSpace.sort(() => Math.random() - 0.5)
// 4. currentIndex
if (!playQueueStore.shuffleCurrent) {
shuffledList.push(currentIndex)
} }
// 5. + + // audio
shuffledList = shuffledList.concat(shuffleSpace) await nextTick()
// 6. shuffleList if (player.value) {
playQueueStore.shuffleList = shuffledList if (isAudioVisualizationSupported) {
console.log('[Player] 连接音频元素到可视化器')
console.log('[Player] 音频元素状态:', {
src: player.value.src?.substring(0, 50) + '...',
readyState: player.value.readyState,
paused: player.value.paused,
})
connectAudio(player.value)
} else {
console.log('[Player] 跳过音频可视化连接(不支持的浏览器)')
}
} else {
console.log('[Player] ❌ 音频元素不存在')
}
// shuffleCurrent playQueueStore.visualizer = barHeights.value
playQueueStore.shuffleCurrent = undefined
} else {
// 退
playQueueStore.currentIndex = playQueueStore.shuffleList[playQueueStore.currentIndex]
}
// //
setTimeout(() => { setTimeout(() => {
playQueueStore.clearAllPreloadedAudio() playQueueStore.preloadNext()
playQueueStore.preloadNext() }, 2000)
}, 500)
}) //
if (player.value) {
initializeVolume()
}
},
)
//
watch(
() => player.value,
(audioElement) => {
if (audioElement && playQueueStore.list.length > 0 && isAudioVisualizationSupported) {
connectAudio(audioElement)
}
},
)
//
watch(
() => barHeights.value,
(newHeights) => {
playQueueStore.visualizer = newHeights
},
{ deep: true },
)
//
watch(
() => error.value,
(newError) => {
if (newError) {
console.error('[Player] 可视化器错误:', newError)
}
},
)
//
watch(
() => playQueueStore.playMode.shuffle,
(isShuffle) => {
if (isShuffle) {
const currentIndex = playQueueStore.currentIndex
const trackCount = playQueueStore.list.length
// 1.
let shuffledList = [...Array(currentIndex).keys()]
// 2.
const shuffleSpace = [...Array(trackCount).keys()].filter((index) =>
playQueueStore.shuffleCurrent
? index >= currentIndex
: index > currentIndex,
)
// 3.
shuffleSpace.sort(() => Math.random() - 0.5)
// 4. currentIndex
if (!playQueueStore.shuffleCurrent) {
shuffledList.push(currentIndex)
}
// 5. + +
shuffledList = shuffledList.concat(shuffleSpace)
// 6. shuffleList
playQueueStore.shuffleList = shuffledList
// shuffleCurrent
playQueueStore.shuffleCurrent = undefined
} else {
// 退
playQueueStore.currentIndex =
playQueueStore.shuffleList[playQueueStore.currentIndex]
}
//
setTimeout(() => {
playQueueStore.clearAllPreloadedAudio()
playQueueStore.preloadNext()
}, 500)
},
)
function getCurrentTrack() { function getCurrentTrack() {
return currentTrack.value return currentTrack.value
@ -313,7 +428,7 @@ function initializeVolume() {
if (player.value) { if (player.value) {
const savedVolume = localStorage.getItem('audioVolume') const savedVolume = localStorage.getItem('audioVolume')
if (savedVolume) { if (savedVolume) {
const volumeValue = parseFloat(savedVolume) const volumeValue = Number.parseFloat(savedVolume)
player.value.volume = volumeValue player.value.volume = volumeValue
console.log('[Player] 初始化音量:', volumeValue) console.log('[Player] 初始化音量:', volumeValue)
} else { } else {
@ -339,7 +454,7 @@ function syncVolumeFromStorage() {
if (player.value) { if (player.value) {
const savedVolume = localStorage.getItem('audioVolume') const savedVolume = localStorage.getItem('audioVolume')
if (savedVolume) { if (savedVolume) {
const volumeValue = parseFloat(savedVolume) const volumeValue = Number.parseFloat(savedVolume)
if (player.value.volume !== volumeValue) { if (player.value.volume !== volumeValue) {
player.value.volume = volumeValue player.value.volume = volumeValue
} }
@ -404,12 +519,14 @@ setInterval(syncVolumeFromStorage, 100)
}"> }">
<div v-if="playQueueStore.isPlaying"> <div v-if="playQueueStore.isPlaying">
<LoadingIndicator v-if="playQueueStore.isBuffering === true" :size="4" /> <LoadingIndicator v-if="playQueueStore.isBuffering === true" :size="4" />
<div v-else class="h-4 flex justify-center items-center gap-[.125rem]"> <!-- 在支持的浏览器上显示可视化否则显示暂停图标 -->
<div v-else-if="isAudioVisualizationSupported" class="h-4 flex justify-center items-center gap-[.125rem]">
<div class="bg-white/75 w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer" <div class="bg-white/75 w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer"
:key="index" :style="{ :key="index" :style="{
height: `${Math.max(10, bar)}%` height: `${Math.max(10, bar)}%`
}" /> }" />
</div> </div>
<PauseIcon v-else :size="4" />
</div> </div>
<PlayIcon v-else :size="4" /> <PlayIcon v-else :size="4" />
</button> </button>

View File

@ -1,5 +1,6 @@
import { defineStore } from "pinia" import { defineStore } from 'pinia'
import { ref, computed } from "vue" import { computed, ref } from 'vue'
import { checkAndRefreshSongResource } from '../utils'
export const usePlayQueueStore = defineStore('queue', () => { export const usePlayQueueStore = defineStore('queue', () => {
const list = ref<QueueItem[]>([]) const list = ref<QueueItem[]>([])
@ -13,11 +14,11 @@ export const usePlayQueueStore = defineStore('queue', () => {
const visualizer = ref<number[]>([0, 0, 0, 0, 0, 0]) const visualizer = ref<number[]>([0, 0, 0, 0, 0, 0])
const shuffleList = ref<number[]>([]) const shuffleList = ref<number[]>([])
const playMode = ref<{ const playMode = ref<{
shuffle: boolean, shuffle: boolean
repeat: 'off' | 'single' | 'all' repeat: 'off' | 'single' | 'all'
}>({ }>({
shuffle: false, shuffle: false,
repeat: 'off' repeat: 'off',
}) })
const shuffleCurrent = ref<boolean | undefined>(undefined) const shuffleCurrent = ref<boolean | undefined>(undefined)
@ -35,10 +36,13 @@ export const usePlayQueueStore = defineStore('queue', () => {
} }
if (playMode.value.shuffle && shuffleList.value.length > 0) { if (playMode.value.shuffle && shuffleList.value.length > 0) {
const currentShuffleIndex = shuffleList.value.indexOf(currentIndex.value) // 当前在 shuffleList 中的位置
const currentShuffleIndex = currentIndex.value
if (currentShuffleIndex < shuffleList.value.length - 1) { if (currentShuffleIndex < shuffleList.value.length - 1) {
// 返回下一个位置对应的原始 list 索引
return shuffleList.value[currentShuffleIndex + 1] return shuffleList.value[currentShuffleIndex + 1]
} else if (playMode.value.repeat === 'all') { } else if (playMode.value.repeat === 'all') {
// 返回第一个位置对应的原始 list 索引
return shuffleList.value[0] return shuffleList.value[0]
} }
return -1 return -1
@ -55,19 +59,14 @@ export const usePlayQueueStore = defineStore('queue', () => {
// 预加载下一首歌 // 预加载下一首歌
const preloadNext = async () => { const preloadNext = async () => {
const nextIndex = getNextIndex.value const nextIndex = getNextIndex.value
if (nextIndex === -1) { if (nextIndex === -1) {
return return
} }
// 获取下一首歌曲对象 // 获取下一首歌曲对象
let nextSong // nextIndex 已经是原始 list 中的索引
if (playMode.value.shuffle && shuffleList.value.length > 0) { const nextSong = list.value[nextIndex]
nextSong = list.value[shuffleList.value[nextIndex]]
} else {
nextSong = list.value[nextIndex]
}
if (!nextSong || !nextSong.song) { if (!nextSong || !nextSong.song) {
return return
@ -89,6 +88,24 @@ export const usePlayQueueStore = defineStore('queue', () => {
isPreloading.value = true isPreloading.value = true
preloadProgress.value = 0 preloadProgress.value = 0
// 在预加载前检查和刷新资源
console.log('[Store] 预加载前检查资源:', nextSong.song.name)
const updatedSong = await checkAndRefreshSongResource(
nextSong.song,
(updated) => {
// 更新播放队列中的歌曲信息
// nextIndex 已经是原始 list 中的索引
if (list.value[nextIndex]) {
list.value[nextIndex].song = updated
}
// 如果歌曲在收藏夹中,也更新收藏夹
// 注意:这里不直接导入 favourites store 以避免循环依赖
// 改为触发一个事件或者在调用方处理
console.log('[Store] 预加载时需要更新收藏夹:', updated.name)
},
)
const audio = new Audio() const audio = new Audio()
audio.preload = 'auto' audio.preload = 'auto'
audio.crossOrigin = 'anonymous' audio.crossOrigin = 'anonymous'
@ -107,19 +124,20 @@ export const usePlayQueueStore = defineStore('queue', () => {
preloadedAudio.value.set(songId, audio) preloadedAudio.value.set(songId, audio)
isPreloading.value = false isPreloading.value = false
preloadProgress.value = 100 preloadProgress.value = 100
console.log('[Store] 预加载完成:', updatedSong.name)
}) })
// 监听加载错误 // 监听加载错误
audio.addEventListener('error', (e) => { audio.addEventListener('error', (e) => {
console.error(`[Store] 预加载音频失败: ${e}`) console.error(`[Store] 预加载音频失败: ${updatedSong.name}`, e)
isPreloading.value = false isPreloading.value = false
preloadProgress.value = 0 preloadProgress.value = 0
}) })
// 设置音频源并开始加载 // 使用更新后的音频源
audio.src = nextSong.song.sourceUrl audio.src = updatedSong.sourceUrl!
} catch (error) { } catch (error) {
console.error('[Store] 预加载过程出错:', error)
isPreloading.value = false isPreloading.value = false
} }
} }
@ -167,7 +185,7 @@ export const usePlayQueueStore = defineStore('queue', () => {
progress: preloadProgress.value, progress: preloadProgress.value,
cacheSize: preloadedAudio.value.size, cacheSize: preloadedAudio.value.size,
cachedSongs: Array.from(preloadedAudio.value.keys()), cachedSongs: Array.from(preloadedAudio.value.keys()),
nextIndex: getNextIndex.value nextIndex: getNextIndex.value,
}) })
} }
@ -194,6 +212,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
clearPreloadedAudio, clearPreloadedAudio,
clearAllPreloadedAudio, clearAllPreloadedAudio,
limitPreloadCache, limitPreloadCache,
debugPreloadState debugPreloadState,
} }
}) })

View File

@ -0,0 +1,98 @@
/**
*
*/
/**
* Safari
* @returns {boolean} Safari true false
*/
export function isSafari(): boolean {
const ua = navigator.userAgent.toLowerCase()
// 检测 Safari 浏览器(包括 iOS 和 macOS
// Safari 的 User Agent 包含 'safari' 但不包含 'chrome' 或 'chromium'
const isSafariBrowser = ua.includes('safari') &&
!ua.includes('chrome') &&
!ua.includes('chromium') &&
!ua.includes('android')
// 额外检查:使用 Safari 特有的 API
const isSafariByFeature = 'safari' in window ||
/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
return isSafariBrowser || isSafariByFeature
}
/**
* Safari
* @returns {boolean} Safari true false
*/
export function isMobileSafari(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
}
/**
* Web Audio API
* @returns {boolean} true false
*/
export function supportsWebAudioVisualization(): boolean {
// Safari 在某些情况下对 AudioContext 的支持有限制
// 特别是在处理跨域音频资源时
if (isSafari()) {
console.log('[BrowserDetection] Safari detected, audio visualization disabled')
return false
}
// 检查基本的 Web Audio API 支持
const hasAudioContext = 'AudioContext' in window || 'webkitAudioContext' in window
const hasAnalyserNode = hasAudioContext && (
'AnalyserNode' in window ||
(window.AudioContext && 'createAnalyser' in AudioContext.prototype)
)
return hasAudioContext && hasAnalyserNode
}
/**
*
* @returns {object}
*/
export function getBrowserInfo() {
const ua = navigator.userAgent
let browserName = 'Unknown'
let browserVersion = 'Unknown'
if (isSafari()) {
browserName = 'Safari'
const versionMatch = ua.match(/Version\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
} else if (ua.includes('Chrome')) {
browserName = 'Chrome'
const versionMatch = ua.match(/Chrome\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
} else if (ua.includes('Firefox')) {
browserName = 'Firefox'
const versionMatch = ua.match(/Firefox\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
} else if (ua.includes('Edge')) {
browserName = 'Edge'
const versionMatch = ua.match(/Edge\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
}
return {
name: browserName,
version: browserVersion,
isSafari: isSafari(),
isMobileSafari: isMobileSafari(),
supportsAudioVisualization: supportsWebAudioVisualization()
}
}

View File

@ -1,5 +1,17 @@
import artistsOrganize from "./artistsOrganize" import artistsOrganize from "./artistsOrganize"
import { audioVisualizer } from "./audioVisualizer" import { audioVisualizer } from "./audioVisualizer"
import cicdInfo from "./cicdInfo" import cicdInfo from "./cicdInfo"
import { checkAndRefreshSongResource, checkAndRefreshMultipleSongs } from "./songResourceChecker"
import { isSafari, isMobileSafari, supportsWebAudioVisualization, getBrowserInfo } from "./browserDetection"
export { artistsOrganize, audioVisualizer, cicdInfo } export {
artistsOrganize,
audioVisualizer,
cicdInfo,
checkAndRefreshSongResource,
checkAndRefreshMultipleSongs,
isSafari,
isMobileSafari,
supportsWebAudioVisualization,
getBrowserInfo
}

View File

@ -0,0 +1,80 @@
import axios from 'axios'
import apis from '../apis'
/**
* URL
* @param song
* @param updateCallback
* @returns
*/
export const checkAndRefreshSongResource = async (
song: Song,
updateCallback?: (updatedSong: Song) => void
): Promise<Song> => {
if (!song.sourceUrl) {
console.warn('[ResourceChecker] 歌曲没有 sourceUrl:', song.name)
return song
}
try {
// 检查资源是否可用
await axios.head(song.sourceUrl, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
params: {
_t: Date.now() // 添加时间戳参数避免缓存
},
timeout: 5000 // 5秒超时
})
// 资源可用,返回原始歌曲
console.log('[ResourceChecker] 资源可用:', song.name)
return song
} catch (error) {
// 资源不可用,刷新歌曲信息
console.log('[ResourceChecker] 资源不可用,正在刷新:', song.name, error)
try {
const updatedSong = await apis.getSong(song.cid)
console.log('[ResourceChecker] 歌曲信息已刷新:', updatedSong.name)
// 调用更新回调(如果提供)
if (updateCallback) {
updateCallback(updatedSong)
}
return updatedSong
} catch (refreshError) {
console.error('[ResourceChecker] 刷新歌曲信息失败:', refreshError)
// 刷新失败,返回原始歌曲
return song
}
}
}
/**
*
* @param songs
* @param updateCallback
* @returns
*/
export const checkAndRefreshMultipleSongs = async (
songs: Song[],
updateCallback?: (updatedSong: Song, originalIndex: number) => void
): Promise<Song[]> => {
const results: Song[] = []
for (let i = 0; i < songs.length; i++) {
const originalSong = songs[i]
const updatedSong = await checkAndRefreshSongResource(
originalSong,
updateCallback ? (updated) => updateCallback(updated, i) : undefined
)
results.push(updatedSong)
}
return results
}