Compare commits

...

12 Commits
v0.0.5 ... main

Author SHA1 Message Date
38f37bba08
fix: 移除未使用的 props 变量声明
All checks were successful
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 1m15s
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 1m30s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Successful in 57s
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Successful in 1m49s
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Successful in 1m59s
修复 TypeScript 编译错误 TS6133

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-06 10:37:34 +10:00
fdd45f2c85
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
2025-06-06 10:31:01 +10:00
33ed04bb35
chore: 发布 v0.0.6 版本
更新 manifest.json 版本号

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-06 10:26:19 +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
fcf8362a15
docs: add CLAUDE.md for future Claude Code instances
Some checks failed
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Blocked by required conditions
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 1m33s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 构建 Firefox 附加组件 (push) Failing after 13m10s
Create comprehensive documentation covering project architecture,
development commands, and browser extension specifics to help
future Claude Code instances work effectively in this codebase.

Includes coverage of Vue 3 + TypeScript setup, Pinia state management,
cross-browser extension building, audio preloading system, and
resource URL validation architecture.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 21:21:09 +10:00
612d673cbb
style: add text-xs classes to playroom time displays for consistency
Some checks failed
构建扩展程序 / 发布至 Chrome 应用商店 (push) Blocked by required conditions
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Blocked by required conditions
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Blocked by required conditions
构建扩展程序 / 构建 Chrome 扩展程序 (push) Failing after 11m32s
构建扩展程序 / 构建 Firefox 附加组件 (push) Failing after 11m28s
Ensure consistent font sizing across different browsers by applying
Tailwind's text-xs utility classes to current time, format detector,
and duration display elements in the playroom interface.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 21:07:38 +10:00
1c5ee95086
refactor: improve code quality in Playroom.vue
- Use Number.isNaN instead of isNaN for better type safety
- Convert anonymous function to arrow function for consistency
- Add explicit braces to single-line if statement for clarity

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 21:07:19 +10:00
6461c0adac
style: add text-xs class to player title for non-Chrome browsers
Ensure consistent font size rendering across different browsers
by applying Tailwind's text-xs utility class to the song title
in the mini player component.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 21:03:09 +10:00
672b2d80d5
refactor: simplify currentTrack computed property in Player.vue
Remove unnecessary else clause for cleaner code structure.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 21:02:24 +10:00
92093ef80d
fix: resolve playroom UI state inconsistency when page loses focus
When lyrics are enabled and the page loses focus while playing a song without lyrics,
then plays a song with lyrics, returning to the page caused controller and lyrics
layout to become misaligned. Added page focus handlers to sync UI state correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 20:54:54 +10:00
9 changed files with 648 additions and 224 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,
"name": "MSR Mod",
"version": "0.0.5",
"version": "0.0.6",
"description": "塞壬唱片Monster Siren Records官网的替代前端。",
"content_scripts": [
{

View File

@ -2,17 +2,14 @@
import { artistsOrganize } from '../utils'
import { ref } from 'vue'
import { useFavourites } from '../stores/useFavourites'
import apis from '../apis'
import axios from 'axios'
import StarSlashIcon from '../assets/icons/starslash.vue'
import { onMounted } from 'vue'
const favourites = useFavourites()
const hover = ref(false)
const props = defineProps<{
defineProps<{
item: QueueItem
index: number
}>()
@ -20,27 +17,6 @@ const props = defineProps<{
const emit = defineEmits<{
(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>
<template>

View File

@ -1,15 +1,16 @@
<!-- Player.vue - 添加预加载功能 -->
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { useTemplateRef, watch, nextTick, computed } from 'vue'
import { computed, nextTick, useTemplateRef, watch } from 'vue'
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 { audioVisualizer } from '../utils'
import PlayIcon from '../assets/icons/play.vue'
import { audioVisualizer, checkAndRefreshSongResource } from '../utils'
const playQueueStore = usePlayQueueStore()
const favourites = useFavourites()
const route = useRoute()
const player = useTemplateRef('playerRef')
@ -17,16 +18,21 @@ const player = useTemplateRef('playerRef')
console.log('[Player] 检查 store 方法:', {
preloadNext: typeof playQueueStore.preloadNext,
getPreloadedAudio: typeof playQueueStore.getPreloadedAudio,
clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio
clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio,
})
//
const currentTrack = computed(() => {
if (playQueueStore.playMode.shuffle && playQueueStore.shuffleList.length > 0) {
return playQueueStore.list[playQueueStore.shuffleList[playQueueStore.currentIndex]]
} else {
return playQueueStore.list[playQueueStore.currentIndex]
if (
playQueueStore.playMode.shuffle &&
playQueueStore.shuffleList.length > 0
) {
return playQueueStore.list[
playQueueStore.shuffleList[playQueueStore.currentIndex]
]
}
return playQueueStore.list[playQueueStore.currentIndex]
})
//
@ -35,86 +41,132 @@ const currentAudioSrc = computed(() => {
return track ? track.song.sourceUrl : ''
})
watch(() => playQueueStore.isPlaying, (newValue) => {
if (newValue) {
player.value?.play()
setMetadata()
}
else { player.value?.pause() }
})
watch(
() => playQueueStore.isPlaying,
(newValue) => {
if (newValue) {
player.value?.play()
setMetadata()
} else {
player.value?.pause()
}
},
)
//
watch(() => playQueueStore.currentIndex, async () => {
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
watch(
() => playQueueStore.currentIndex,
async () => {
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
// 使
const track = currentTrack.value
if (track) {
const songId = track.song.cid
// 使
const track = currentTrack.value
if (track) {
const songId = track.song.cid
try {
const preloadedAudio = playQueueStore.getPreloadedAudio(songId)
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)
},
)
if (preloadedAudio) {
console.log(`[Player] 使用预加载的音频: ${track.song.name}`)
// 使
const preloadedAudio = playQueueStore.getPreloadedAudio(songId)
// 使
if (player.value) {
//
player.value.src = preloadedAudio.src
player.value.currentTime = 0
if (preloadedAudio && updatedSong.sourceUrl === track.song.sourceUrl) {
console.log(`[Player] 使用预加载的音频: ${track.song.name}`)
// 使
playQueueStore.clearPreloadedAudio(songId)
// 使
if (player.value) {
//
player.value.src = preloadedAudio.src
player.value.currentTime = 0
//
if (playQueueStore.isPlaying) {
await nextTick()
player.value.play().catch(console.error)
// 使
playQueueStore.clearPreloadedAudio(songId)
//
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 {
console.log(`[Player] 正常加载音频: ${track.song.name}`)
} catch (error) {
console.error('[Player] 处理预加载音频时出错:', error)
playQueueStore.isBuffering = true
}
} catch (error) {
console.error('[Player] 处理预加载音频时出错:', error)
playQueueStore.isBuffering = true
}
}
setMetadata()
setMetadata()
//
setTimeout(() => {
try {
console.log('[Player] 尝试预加载下一首歌')
//
setTimeout(async () => {
try {
console.log('[Player] 尝试预加载下一首歌')
//
if (typeof playQueueStore.preloadNext === 'function') {
playQueueStore.preloadNext()
playQueueStore.limitPreloadCache()
} else {
console.error('[Player] preloadNext 不是一个函数')
//
if (typeof playQueueStore.preloadNext === 'function') {
await playQueueStore.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) {
console.error('[Player] 预加载失败:', error)
}
}, 1000)
})
}, 1000)
},
)
function artistsOrganize(list: string[]) {
if (list.length === 0) { return '未知音乐人' }
return list.map((artist) => {
return artist
}).join(' / ')
if (list.length === 0) {
return '未知音乐人'
}
return list
.map((artist) => {
return artist
})
.join(' / ')
}
function setMetadata() {
if ('mediaSession' in navigator) {
let current = currentTrack.value
const current = currentTrack.value
if (!current) return
navigator.mediaSession.metadata = new MediaMetadata({
@ -122,8 +174,12 @@ function setMetadata() {
artist: artistsOrganize(current.song.artists ?? []),
album: current.album?.name,
artwork: [
{ src: current.album?.coverUrl ?? '', sizes: '500x500', type: 'image/png' },
]
{
src: current.album?.coverUrl ?? '',
sizes: '500x500',
type: 'image/png',
},
],
})
navigator.mediaSession.setActionHandler('previoustrack', playPrevious)
@ -133,16 +189,21 @@ function setMetadata() {
playQueueStore.currentTime = player.value?.currentTime ?? 0
}
watch(() => playQueueStore.updatedCurrentTime, (newValue) => {
if (newValue === null) { return }
if (player.value) player.value.currentTime = newValue
playQueueStore.updatedCurrentTime = null
})
watch(
() => playQueueStore.updatedCurrentTime,
(newValue) => {
if (newValue === null) {
return
}
if (player.value) player.value.currentTime = newValue
playQueueStore.updatedCurrentTime = null
},
)
}
function playNext() {
if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
console.log("at the bottom, pause")
console.log('at the bottom, pause')
playQueueStore.currentIndex = 0
if (playQueueStore.playMode.repeat === 'all') {
playQueueStore.currentIndex = 0
@ -158,11 +219,17 @@ function playNext() {
}
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.isPlaying = true
} 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 remainingTimeThreshold = config.remainingTimeThreshold || 30
if ((progress > preloadTrigger || remainingTime < remainingTimeThreshold) && !playQueueStore.isPreloading) {
if (
(progress > preloadTrigger || remainingTime < remainingTimeThreshold) &&
!playQueueStore.isPreloading
) {
try {
if (typeof playQueueStore.preloadNext === 'function') {
playQueueStore.preloadNext()
@ -202,107 +271,129 @@ const { barHeights, connectAudio, isAnalyzing, error } = audioVisualizer({
bassBoost: 0.8,
midBoost: 1.2,
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) => {
console.log('[Player] 播放列表长度变化:', newLength)
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)
watch(
() => playQueueStore.list.length,
async (newLength) => {
console.log('[Player] 播放列表长度变化:', newLength)
if (newLength === 0) {
console.log('[Player] 播放列表为空,跳过连接')
return
}
// 5. + +
shuffledList = shuffledList.concat(shuffleSpace)
// audio
await nextTick()
// 6. shuffleList
playQueueStore.shuffleList = shuffledList
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] ❌ 音频元素不存在')
}
// shuffleCurrent
playQueueStore.shuffleCurrent = undefined
} else {
// 退
playQueueStore.currentIndex = playQueueStore.shuffleList[playQueueStore.currentIndex]
}
playQueueStore.visualizer = barHeights.value
//
setTimeout(() => {
playQueueStore.clearAllPreloadedAudio()
playQueueStore.preloadNext()
}, 500)
})
//
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.
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() {
return currentTrack.value
@ -313,7 +404,7 @@ function initializeVolume() {
if (player.value) {
const savedVolume = localStorage.getItem('audioVolume')
if (savedVolume) {
const volumeValue = parseFloat(savedVolume)
const volumeValue = Number.parseFloat(savedVolume)
player.value.volume = volumeValue
console.log('[Player] 初始化音量:', volumeValue)
} else {
@ -339,7 +430,7 @@ function syncVolumeFromStorage() {
if (player.value) {
const savedVolume = localStorage.getItem('audioVolume')
if (savedVolume) {
const volumeValue = parseFloat(savedVolume)
const volumeValue = Number.parseFloat(savedVolume)
if (player.value.volume !== volumeValue) {
player.value.volume = volumeValue
}
@ -395,7 +486,7 @@ setInterval(syncVolumeFromStorage, 100)
<RouterLink to="/playroom">
<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>
</RouterLink>

View File

@ -543,8 +543,39 @@ watch(() => props.lrcSrc, async (newSrc) => {
}
}, { 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(() => {
//
setupPageFocusHandlers()
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value,
@ -577,6 +608,11 @@ onUnmounted(() => {
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
if (userScrollTimeout) clearTimeout(userScrollTimeout)
//
if (handleVisibilityChange) {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
})
//

View File

@ -83,6 +83,9 @@ onMounted(async () => {
thumbUpdate()
setupEntranceAnimations()
//
setupPageFocusHandlers()
})
function timeFormatter(time: number) {
@ -90,7 +93,7 @@ function timeFormatter(time: number) {
if (timeInSeconds < 0) { return '-:--' }
const minutes = 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}`
}
@ -155,7 +158,7 @@ function createVolumeDraggable() {
// localStorage
localStorage.setItem('audioVolume', newVolume.toString())
},
onDragEnd: function () {
onDragEnd: () => {
//
localStorage.setItem('audioVolume', volume.value.toString())
}
@ -419,9 +422,98 @@ watch(() => [preferences.presentLyrics, getCurrentTrack()?.song.lyricUrl], (newV
}
}, { immediate: true })
//
let handleVisibilityChange: (() => void) | null = null
let handlePageFocus: (() => void) | null = null
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
watch(() => playQueueStore.currentIndex, () => {
if (albumCover.value) {
@ -513,9 +605,9 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="w-full flex justify-between">
<!-- ...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
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
class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
</div>
@ -526,7 +618,7 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="flex flex-1">
<div class="flex-1" />
<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">
<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 { ref, computed } from "vue"
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { checkAndRefreshSongResource } from '../utils'
export const usePlayQueueStore = defineStore('queue', () => {
const list = ref<QueueItem[]>([])
@ -13,11 +14,11 @@ export const usePlayQueueStore = defineStore('queue', () => {
const visualizer = ref<number[]>([0, 0, 0, 0, 0, 0])
const shuffleList = ref<number[]>([])
const playMode = ref<{
shuffle: boolean,
shuffle: boolean
repeat: 'off' | 'single' | 'all'
}>({
shuffle: false,
repeat: 'off'
repeat: 'off',
})
const shuffleCurrent = ref<boolean | undefined>(undefined)
@ -35,10 +36,13 @@ export const usePlayQueueStore = defineStore('queue', () => {
}
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) {
// 返回下一个位置对应的原始 list 索引
return shuffleList.value[currentShuffleIndex + 1]
} else if (playMode.value.repeat === 'all') {
// 返回第一个位置对应的原始 list 索引
return shuffleList.value[0]
}
return -1
@ -55,19 +59,14 @@ export const usePlayQueueStore = defineStore('queue', () => {
// 预加载下一首歌
const preloadNext = async () => {
const nextIndex = getNextIndex.value
if (nextIndex === -1) {
return
}
// 获取下一首歌曲对象
let nextSong
if (playMode.value.shuffle && shuffleList.value.length > 0) {
nextSong = list.value[shuffleList.value[nextIndex]]
} else {
nextSong = list.value[nextIndex]
}
// nextIndex 已经是原始 list 中的索引
const nextSong = list.value[nextIndex]
if (!nextSong || !nextSong.song) {
return
@ -89,6 +88,24 @@ export const usePlayQueueStore = defineStore('queue', () => {
isPreloading.value = true
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()
audio.preload = 'auto'
audio.crossOrigin = 'anonymous'
@ -107,19 +124,20 @@ export const usePlayQueueStore = defineStore('queue', () => {
preloadedAudio.value.set(songId, audio)
isPreloading.value = false
preloadProgress.value = 100
console.log('[Store] 预加载完成:', updatedSong.name)
})
// 监听加载错误
audio.addEventListener('error', (e) => {
console.error(`[Store] 预加载音频失败: ${e}`)
console.error(`[Store] 预加载音频失败: ${updatedSong.name}`, e)
isPreloading.value = false
preloadProgress.value = 0
})
// 设置音频源并开始加载
audio.src = nextSong.song.sourceUrl
// 使用更新后的音频源
audio.src = updatedSong.sourceUrl!
} catch (error) {
console.error('[Store] 预加载过程出错:', error)
isPreloading.value = false
}
}
@ -167,7 +185,7 @@ export const usePlayQueueStore = defineStore('queue', () => {
progress: preloadProgress.value,
cacheSize: preloadedAudio.value.size,
cachedSongs: Array.from(preloadedAudio.value.keys()),
nextIndex: getNextIndex.value
nextIndex: getNextIndex.value,
})
}
@ -194,6 +212,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
clearPreloadedAudio,
clearAllPreloadedAudio,
limitPreloadCache,
debugPreloadState
debugPreloadState,
}
})
})

View File

@ -1,5 +1,6 @@
import artistsOrganize from "./artistsOrganize"
import { audioVisualizer } from "./audioVisualizer"
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
}