Merge branch 'prepare-release/v0.0.6'
Some checks failed
构建扩展程序 / 构建 Chrome 扩展程序 (push) Failing after 59s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 构建 Firefox 附加组件 (push) Failing after 49s
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been skipped

This commit is contained in:
Astrian Zheng 2025-06-06 10:31:01 +10:00
commit fdd45f2c85
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
9 changed files with 647 additions and 223 deletions

130
CLAUDE.md Normal file
View File

@ -0,0 +1,130 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MSR Mod is a browser extension that provides an alternate frontend for Monster Siren Records (monster-siren.hypergryph.com). It's built with Vue 3, TypeScript, and Tailwind CSS, designed to work as both Chrome and Firefox extensions.
## Common Commands
### Development
```bash
npm run dev # Start development server with Vite
npm run dev:refresh # Build and refresh dist folder for extension development
npm i # Install dependencies
```
### Building
```bash
npm run build:chrome # Build for Chrome/Chromium browsers
npm run build:firefox # Build for Firefox
npm run build # Default build (Chrome)
```
### Code Quality
```bash
npm run lint # Format code with Biome
npm run quality-check # Run Biome CI checks
npm run qc # Alias for quality-check
```
### Extension Development Workflow
1. Run `npm run dev:refresh` to build initial dist folder
2. Load the `dist` folder as an unpacked extension in browser
3. Use `npm run dev` for hot-reload development
4. Use `npm run build:watch` for continuous builds
## Architecture
### Core Technologies
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety
- **Pinia** for state management
- **Vue Router** with hash history for extension compatibility
- **Tailwind CSS v4** for styling
- **GSAP** for animations
- **Axios** for API communication
### Browser Extension Structure
- **Manifest V3** with platform-specific builds
- **Content Scripts** inject the frontend on monster-siren.hypergryph.com
- **Background Service Worker** handles extension lifecycle
- **Cross-platform compatibility** via prebuild scripts
### State Management (Pinia Stores)
- **usePlayQueueStore**: Music playback queue, shuffle/repeat modes, audio preloading
- **useFavourites**: User favorites with cross-platform storage (Chrome storage API/localStorage)
- **usePreferences**: User settings and preferences
### Key Components
- **Player**: Main audio player with preloading and resource validation
- **Playroom**: Full-screen player interface with lyrics and visualizations
- **ScrollingLyrics**: Animated lyrics display with auto-scroll and user interaction
- **PlayListItem/TrackItem**: Reusable music track components
### API Integration
- **Monster Siren API**: Fetches songs, albums, and metadata via `src/apis/index.ts`
- **Resource URL Validation**: Automatic refresh of cached URLs when servers rotate resources
- **Preloading System**: Smart audio preloading with cache management
### Browser Compatibility
- **Chrome**: Uses service worker, full CSP support
- **Firefox**: Uses background scripts, modified CSP, specific gecko settings
- **Prebuild Scripts**: Automatically modify manifest.json and HTML for each platform
### Storage Strategy
- **Favorites**: Stored in Chrome storage API (fallback to localStorage)
- **Preferences**: Browser-specific storage with graceful degradation
- **Audio Cache**: In-memory preloading with size limits
### Resource Management
- **Audio Preloading**: Validates and preloads next track during playback
- **URL Refresh Logic**: Checks resource availability before playback/preload
- **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
### `/src/utils/`
- **songResourceChecker.ts**: Centralized resource validation and refresh logic
- **audioVisualizer.ts**: Real-time audio analysis for visual effects
- **artistsOrganize.ts**: Helper for formatting artist names
### `/scripts/`
- **prebuild-chrome.js**: Removes localhost dev configs for production
- **prebuild-firefox.js**: Adapts manifest for Firefox compatibility
### `/public/`
- **manifest.json**: Extension manifest (modified by prebuild scripts)
- **content.js**: Injects the Vue app into target websites
- **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
### Extension Context
- Uses hash routing for browser extension compatibility
- CSP restrictions require specific script and style handling
- Cross-origin requests limited to declared host permissions
### Performance
- Audio preloading system prevents playback interruptions
- Resource validation happens only when needed (playback/preload)
- GSAP animations with proper cleanup to prevent memory leaks
### Error Handling
- Graceful fallbacks for storage API unavailability
- Resource URL rotation handling with automatic refresh
- Cross-browser compatibility with feature detection

View File

@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "MSR Mod", "name": "MSR Mod",
"version": "0.0.5", "version": "0.0.6",
"description": "塞壬唱片Monster Siren Records官网的替代前端。", "description": "塞壬唱片Monster Siren Records官网的替代前端。",
"content_scripts": [ "content_scripts": [
{ {

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,15 +1,16 @@
<!-- Player.vue - 添加预加载功能 --> <!-- Player.vue - 添加预加载功能 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, 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 { audioVisualizer, checkAndRefreshSongResource } 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,16 +18,21 @@ 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 &&
} else { playQueueStore.shuffleList.length > 0
return playQueueStore.list[playQueueStore.currentIndex] ) {
return playQueueStore.list[
playQueueStore.shuffleList[playQueueStore.currentIndex]
]
} }
return playQueueStore.list[playQueueStore.currentIndex]
}) })
// //
@ -35,16 +41,22 @@ const currentAudioSrc = computed(() => {
return track ? track.song.sourceUrl : '' return track ? track.song.sourceUrl : ''
}) })
watch(() => playQueueStore.isPlaying, (newValue) => { watch(
() => playQueueStore.isPlaying,
(newValue) => {
if (newValue) { if (newValue) {
player.value?.play() player.value?.play()
setMetadata() setMetadata()
} else {
player.value?.pause()
} }
else { player.value?.pause() } },
}) )
// //
watch(() => playQueueStore.currentIndex, async () => { watch(
() => playQueueStore.currentIndex,
async () => {
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex) console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
// 使 // 使
@ -53,9 +65,31 @@ watch(() => playQueueStore.currentIndex, async () => {
const songId = track.song.cid const songId = track.song.cid
try { try {
//
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)
},
)
// 使
const preloadedAudio = playQueueStore.getPreloadedAudio(songId) const preloadedAudio = playQueueStore.getPreloadedAudio(songId)
if (preloadedAudio) { if (preloadedAudio && updatedSong.sourceUrl === track.song.sourceUrl) {
console.log(`[Player] 使用预加载的音频: ${track.song.name}`) console.log(`[Player] 使用预加载的音频: ${track.song.name}`)
// 使 // 使
@ -78,6 +112,11 @@ watch(() => playQueueStore.currentIndex, async () => {
} else { } else {
console.log(`[Player] 正常加载音频: ${track.song.name}`) console.log(`[Player] 正常加载音频: ${track.song.name}`)
playQueueStore.isBuffering = true playQueueStore.isBuffering = true
//
if (updatedSong.sourceUrl !== track.song.sourceUrl) {
playQueueStore.clearPreloadedAudio(songId)
}
} }
} catch (error) { } catch (error) {
console.error('[Player] 处理预加载音频时出错:', error) console.error('[Player] 处理预加载音频时出错:', error)
@ -88,13 +127,21 @@ watch(() => playQueueStore.currentIndex, async () => {
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.list.forEach((item) => {
if (favourites.isFavourite(item.song.cid)) {
favourites.updateSongInFavourites(item.song.cid, item.song)
}
})
playQueueStore.limitPreloadCache() playQueueStore.limitPreloadCache()
} else { } else {
console.error('[Player] preloadNext 不是一个函数') console.error('[Player] preloadNext 不是一个函数')
@ -103,18 +150,23 @@ watch(() => playQueueStore.currentIndex, async () => {
console.error('[Player] 预加载失败:', error) console.error('[Player] 预加载失败:', error)
} }
}, 1000) }, 1000)
}) },
)
function artistsOrganize(list: string[]) { function artistsOrganize(list: string[]) {
if (list.length === 0) { return '未知音乐人' } if (list.length === 0) {
return list.map((artist) => { return '未知音乐人'
}
return list
.map((artist) => {
return artist return artist
}).join(' / ') })
.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 +174,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 +189,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,
(newValue) => {
if (newValue === null) {
return
}
if (player.value) player.value.currentTime = newValue if (player.value) player.value.currentTime = newValue
playQueueStore.updatedCurrentTime = null 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 +219,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 +246,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()
@ -202,13 +271,18 @@ const { barHeights, connectAudio, isAnalyzing, error } = audioVisualizer({
bassBoost: 0.8, bassBoost: 0.8,
midBoost: 1.2, midBoost: 1.2,
trebleBoost: 1.4, trebleBoost: 1.4,
threshold: 0 threshold: 0,
}) })
console.log('[Player] audioVisualizer 返回值:', { barHeights: barHeights.value, isAnalyzing: isAnalyzing.value }) console.log('[Player] audioVisualizer 返回值:', {
barHeights: barHeights.value,
isAnalyzing: isAnalyzing.value,
})
// //
watch(() => playQueueStore.list.length, async (newLength) => { watch(
() => playQueueStore.list.length,
async (newLength) => {
console.log('[Player] 播放列表长度变化:', newLength) console.log('[Player] 播放列表长度变化:', newLength)
if (newLength === 0) { if (newLength === 0) {
console.log('[Player] 播放列表为空,跳过连接') console.log('[Player] 播放列表为空,跳过连接')
@ -223,7 +297,7 @@ watch(() => playQueueStore.list.length, async (newLength) => {
console.log('[Player] 音频元素状态:', { console.log('[Player] 音频元素状态:', {
src: player.value.src?.substring(0, 50) + '...', src: player.value.src?.substring(0, 50) + '...',
readyState: player.value.readyState, readyState: player.value.readyState,
paused: player.value.paused paused: player.value.paused,
}) })
connectAudio(player.value) connectAudio(player.value)
} else { } else {
@ -241,29 +315,42 @@ watch(() => playQueueStore.list.length, async (newLength) => {
if (player.value) { if (player.value) {
initializeVolume() initializeVolume()
} }
}) },
)
// //
watch(() => player.value, (audioElement) => { watch(
() => player.value,
(audioElement) => {
if (audioElement && playQueueStore.list.length > 0) { if (audioElement && playQueueStore.list.length > 0) {
connectAudio(audioElement) connectAudio(audioElement)
} }
}) },
)
// //
watch(() => barHeights.value, (newHeights) => { watch(
() => barHeights.value,
(newHeights) => {
playQueueStore.visualizer = newHeights playQueueStore.visualizer = newHeights
}, { deep: true }) },
{ deep: true },
)
// //
watch(() => error.value, (newError) => { watch(
() => error.value,
(newError) => {
if (newError) { if (newError) {
console.error('[Player] 可视化器错误:', newError) console.error('[Player] 可视化器错误:', newError)
} }
}) },
)
// //
watch(() => playQueueStore.playMode.shuffle, (isShuffle) => { watch(
() => playQueueStore.playMode.shuffle,
(isShuffle) => {
if (isShuffle) { if (isShuffle) {
const currentIndex = playQueueStore.currentIndex const currentIndex = playQueueStore.currentIndex
const trackCount = playQueueStore.list.length const trackCount = playQueueStore.list.length
@ -272,8 +359,10 @@ watch(() => playQueueStore.playMode.shuffle, (isShuffle) => {
let shuffledList = [...Array(currentIndex).keys()] let shuffledList = [...Array(currentIndex).keys()]
// 2. // 2.
let shuffleSpace = [...Array(trackCount).keys()].filter(index => const shuffleSpace = [...Array(trackCount).keys()].filter((index) =>
playQueueStore.shuffleCurrent ? index >= currentIndex : index > currentIndex playQueueStore.shuffleCurrent
? index >= currentIndex
: index > currentIndex,
) )
// 3. // 3.
@ -294,7 +383,8 @@ watch(() => playQueueStore.playMode.shuffle, (isShuffle) => {
playQueueStore.shuffleCurrent = undefined playQueueStore.shuffleCurrent = undefined
} else { } else {
// 退 // 退
playQueueStore.currentIndex = playQueueStore.shuffleList[playQueueStore.currentIndex] playQueueStore.currentIndex =
playQueueStore.shuffleList[playQueueStore.currentIndex]
} }
// //
@ -302,7 +392,8 @@ watch(() => playQueueStore.playMode.shuffle, (isShuffle) => {
playQueueStore.clearAllPreloadedAudio() playQueueStore.clearAllPreloadedAudio()
playQueueStore.preloadNext() playQueueStore.preloadNext()
}, 500) }, 500)
}) },
)
function getCurrentTrack() { function getCurrentTrack() {
return currentTrack.value return currentTrack.value
@ -313,7 +404,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 +430,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
} }
@ -395,7 +486,7 @@ setInterval(syncVolumeFromStorage, 100)
<RouterLink to="/playroom"> <RouterLink to="/playroom">
<div class="flex items-center w-32 h-9"> <div class="flex items-center w-32 h-9">
<span class="truncate">{{ getCurrentTrack()?.song.name }}</span> <span class="truncate text-xs">{{ getCurrentTrack()?.song.name }}</span>
</div> </div>
</RouterLink> </RouterLink>

View File

@ -543,8 +543,39 @@ watch(() => props.lrcSrc, async (newSrc) => {
} }
}, { immediate: true }) }, { immediate: true })
//
let handleVisibilityChange: (() => void) | null = null
//
function setupPageFocusHandlers() {
handleVisibilityChange = () => {
if (document.hidden) {
//
if (scrollTween) scrollTween.pause()
if (highlightTween) highlightTween.pause()
} else {
//
if (scrollTween && scrollTween.paused()) scrollTween.resume()
if (highlightTween && highlightTween.paused()) highlightTween.resume()
//
nextTick(() => {
if (currentLineIndex.value >= 0 && autoScroll.value && !userScrolling.value) {
scrollToLine(currentLineIndex.value, false) // 使
}
})
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
}
// //
onMounted(() => { onMounted(() => {
//
setupPageFocusHandlers()
// //
if (controlPanel.value) { if (controlPanel.value) {
gsap.fromTo(controlPanel.value, gsap.fromTo(controlPanel.value,
@ -577,6 +608,11 @@ onUnmounted(() => {
if (scrollTween) scrollTween.kill() if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill() if (highlightTween) highlightTween.kill()
if (userScrollTimeout) clearTimeout(userScrollTimeout) if (userScrollTimeout) clearTimeout(userScrollTimeout)
//
if (handleVisibilityChange) {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}) })
// //

View File

@ -83,6 +83,9 @@ onMounted(async () => {
thumbUpdate() thumbUpdate()
setupEntranceAnimations() setupEntranceAnimations()
//
setupPageFocusHandlers()
}) })
function timeFormatter(time: number) { function timeFormatter(time: number) {
@ -90,7 +93,7 @@ function timeFormatter(time: number) {
if (timeInSeconds < 0) { return '-:--' } if (timeInSeconds < 0) { return '-:--' }
const minutes = Math.floor(timeInSeconds / 60) const minutes = Math.floor(timeInSeconds / 60)
const seconds = Math.floor(timeInSeconds % 60) const seconds = Math.floor(timeInSeconds % 60)
if (isNaN(minutes) || isNaN(seconds)) { return '-:--' } if (Number.isNaN(minutes) || Number.isNaN(seconds)) { return '-:--' }
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}` return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
} }
@ -155,7 +158,7 @@ function createVolumeDraggable() {
// localStorage // localStorage
localStorage.setItem('audioVolume', newVolume.toString()) localStorage.setItem('audioVolume', newVolume.toString())
}, },
onDragEnd: function () { onDragEnd: () => {
// //
localStorage.setItem('audioVolume', volume.value.toString()) localStorage.setItem('audioVolume', volume.value.toString())
} }
@ -419,9 +422,98 @@ watch(() => [preferences.presentLyrics, getCurrentTrack()?.song.lyricUrl], (newV
} }
}, { immediate: true }) }, { immediate: true })
//
let handleVisibilityChange: (() => void) | null = null
let handlePageFocus: (() => void) | null = null
onUnmounted(() => { onUnmounted(() => {
//
if (handleVisibilityChange) {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
if (handlePageFocus) {
window.removeEventListener('focus', handlePageFocus)
}
}) })
//
function setupPageFocusHandlers() {
handleVisibilityChange = () => {
if (document.hidden) {
//
console.log('[Playroom] 页面失去焦点,暂停动画')
} else {
//
console.log('[Playroom] 页面重新获得焦点,同步状态')
nextTick(() => {
resyncLyricsState()
})
}
}
handlePageFocus = () => {
console.log('[Playroom] 窗口获得焦点,同步状态')
nextTick(() => {
resyncLyricsState()
})
}
//
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('focus', handlePageFocus)
}
//
function resyncLyricsState() {
const currentTrack = getCurrentTrack()
if (!currentTrack) { return }
console.log('[Playroom] 重新同步歌词状态')
//
if (controllerRef.value) {
gsap.set(controllerRef.value, {
marginLeft: '0rem',
marginRight: '0rem'
})
}
if (lyricsSection.value) {
gsap.set(lyricsSection.value, {
opacity: 1,
x: 0,
y: 0,
scale: 1
})
}
//
const shouldShowLyrics = preferences.presentLyrics && currentTrack.song.lyricUrl ? true : false
if (shouldShowLyrics !== presentLyrics.value) {
console.log(`[Playroom] 歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`)
//
presentLyrics.value = shouldShowLyrics
//
if (shouldShowLyrics) {
nextTick(() => {
const tl = gsap.timeline()
tl.from(controllerRef.value, {
marginRight: '-40rem',
duration: 0.4,
ease: "power2.out"
}).fromTo(lyricsSection.value,
{ opacity: 0, x: 50, scale: 0.95 },
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: "power2.out" },
"-=0.2"
)
})
}
}
}
// New: Watch for track changes and animate // New: Watch for track changes and animate
watch(() => playQueueStore.currentIndex, () => { watch(() => playQueueStore.currentIndex, () => {
if (albumCover.value) { if (albumCover.value) {
@ -513,9 +605,9 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="w-full flex justify-between"> <div class="w-full flex justify-between">
<!-- ...existing time display code... --> <!-- ...existing time display code... -->
<div class="font-medium flex-1 text-left relative"> <div class="font-medium flex-1 text-left text-xs relative">
<span <span
class="text-black blur-lg absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span> class="text-black blur-lg absolute top-0 text-xs">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
<span <span
class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span> class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
</div> </div>
@ -526,7 +618,7 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="flex flex-1"> <div class="flex flex-1">
<div class="flex-1" /> <div class="flex-1" />
<button <button
class="text-white/90 font-medium text-right relative transition-colors duration-200 hover:text-white" class="text-white/90 text-xs font-medium text-right relative transition-colors duration-200 hover:text-white"
@click="preferences.displayTimeLeft = !preferences.displayTimeLeft"> @click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
<span <span
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span> class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>

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

@ -1,5 +1,6 @@
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"
export { artistsOrganize, audioVisualizer, cicdInfo } export { artistsOrganize, audioVisualizer, cicdInfo, checkAndRefreshSongResource, checkAndRefreshMultipleSongs }

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
}