Compare commits

..

32 Commits
v0.0.5 ... main

Author SHA1 Message Date
1bd0073e24 Merge pull request '0.0.7' (#11) from dev into main
Some checks failed
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 58s
构建扩展程序 / 构建 Safari 扩展程序 (push) Has been cancelled
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 54s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Successful in 36s
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Successful in 1m9s
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Successful in 6m21s
Reviewed-on: #11
2025-06-25 03:25:25 +00:00
c57d7bc42d add: 更新日志面板添加文本
Some checks failed
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 54s
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 58s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been skipped
构建扩展程序 / 构建 Safari 扩展程序 (push) Has been cancelled
2025-06-25 13:20:24 +10:00
dd500b75e4 update: 更新版本号 2025-06-25 13:19:54 +10:00
d8b284d54e feat: 更新偏好设置面板的反馈渠道信息
Some checks are pending
构建扩展程序 / 构建 Safari 扩展程序 (push) Waiting to run
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 1m6s
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 51s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been skipped
- 更新 GitHub 仓库链接描述,明确提及 Issue 功能
- 新增 Discord 社群链接,提供额外的交流和反馈渠道
- 新增 Trello 看板链接,让用户了解开发进度

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 13:12:29 +10:00
afe21c0cf6 fix: 修复 Windows 和其他系统中偏好设置面板滚动条显示异常
将 overflow-scroll 更改为 overflow-y-auto 以确保在不同操作系统中
滚动条的一致性显示,避免在 Windows 等系统中出现横向滚动条。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 13:11:34 +10:00
db881da671 feat: 重构更新弹窗为智能版本检测系统
Some checks are pending
构建扩展程序 / 构建 Safari 扩展程序 (push) Waiting to run
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 1m7s
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 58s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been skipped
- 重命名 updated.vue 为 UpdatePopup.vue
- 新增 useUpdatePopup store 进行版本检测和存储管理
- 实现基于版本变化的智能弹窗显示逻辑
- 支持 Chrome storage API 和 localStorage 双重存储方案
- 添加弹窗关闭后的状态记录,避免重复显示
- 更新 App.vue 中的组件引用

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 12:56:59 +10:00
c0e26c0c80 feat: 更新弹窗添加 Safari 兼容性说明
- 添加 select-none 防止文本选择
- 更新说明内容,增加 Safari 兼容性支持描述

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 12:07:13 +10:00
195f3eced9 Merge branch 'dev' into feature/updated-popup 2025-06-25 12:05:07 +10:00
c262149d51 feat: 添加更新弹窗组件
- 在 App.vue 中引入并使用 Updated 组件
- 新增 src/components/updated.vue 组件文件

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-25 12:02:36 +10:00
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
fa8bd3d2af
fix: 修正 Safari 扩展构建的 scheme 名称
All checks were successful
构建扩展程序 / 构建 Safari 扩展程序 (push) Successful in 2m40s
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 5m37s
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 2m47s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been skipped
- 使用正确的 scheme 名称 'MSR Mod (macOS)' 而不是 'MSR Mod'
- safari-web-extension-converter 生成的 scheme 包含平台标识符
2025-06-05 16:12:43 +10:00
9852d7bef4
chore: 更新 Safari 扩展的 bundle ID
Some checks failed
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been cancelled
构建扩展程序 / 构建 Firefox 附加组件 (push) Has been cancelled
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been cancelled
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been cancelled
构建扩展程序 / 构建 Chrome 扩展程序 (push) Has been cancelled
构建扩展程序 / 构建 Safari 扩展程序 (push) Failing after 30s
- 将 bundle ID 从 com.astrian.msrmod 改为 moe.astrian.ext-msrmod
- 使用反向域名规范,更符合项目命名规范
2025-06-05 15:43:56 +10:00
e62ef27a10
fix: 修复 Safari 扩展构建路径问题
Some checks are pending
构建扩展程序 / 构建 Chrome 扩展程序 (push) Has started running
构建扩展程序 / 构建 Firefox 附加组件 (push) Waiting to run
构建扩展程序 / 构建 Safari 扩展程序 (push) Waiting to run
构建扩展程序 / 发布至 Chrome 应用商店 (push) Blocked by required conditions
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Blocked by required conditions
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Blocked by required conditions
- 修正 xcodebuild 命令的工作目录
- safari-web-extension-converter 会在指定目录下创建以应用名命名的子目录
2025-06-05 15:42:26 +10:00
e54361fb14
trigger CI rebuild
Some checks failed
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 2m16s
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 1m32s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been skipped
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been skipped
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been skipped
构建扩展程序 / 构建 Safari 扩展程序 (push) Failing after 1m23s
2025-06-05 12:05:40 +10:00
581d294cee
Merge branch 'feature/safari_compatibility' into dev
Some checks failed
构建扩展程序 / 构建 Chrome 扩展程序 (push) Waiting to run
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Has been cancelled
构建扩展程序 / 构建 Firefox 附加组件 (push) Waiting to run
构建扩展程序 / 构建 Safari 扩展程序 (push) Has been cancelled
构建扩展程序 / 发布至 Chrome 应用商店 (push) Has been cancelled
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Has been cancelled
2025-06-05 10:54:33 +10:00
acc3af1214
feat: add Safari extension build workflow for CI/CD
Add macOS runner support for building Safari extensions including:
- Safari-specific build process with npm run build:safari
- Xcode setup and safari-web-extension-converter integration
- Native Safari extension compilation with xcodebuild
- Artifact upload for Safari extension builds

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-05 10:54:13 +10:00
1b4a8ed3b4
feat: add Safari extension support with auto-redirect functionality
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 10m58s
构建扩展程序 / 构建 Firefox 附加组件 (push) Failing after 10m50s
- Add Safari-specific prebuild script to handle background page requirements
- Create background.html wrapper for Safari's background page architecture
- Fix TypeScript compilation errors in browserDetection.ts and PlayListItem.vue
- Add preference-aware auto-redirect via content script messaging
- Support cross-platform extension building with npm run build:safari

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-04 23:20:48 +10:00
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
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
19 changed files with 1537 additions and 240 deletions

View File

@ -61,6 +61,48 @@ jobs:
name: firefox-addon
path: dist/
build-for-safari:
name: 构建 Safari 扩展程序
runs-on: macos
env:
VITE_RUN_ID: ${{ gitea.run_number }}
VITE_HASH_ID: ${{ gitea.sha }}
steps:
- uses: actions/checkout@v3
name: 检出代码
- name: 设置 Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: 安装依赖
run: npm install
- name: 构建扩展程序
run: npm run build:safari
- name: 设置 Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: 创建 Safari 扩展项目
run: |
xcrun safari-web-extension-converter dist --project-location safari-extension --app-name "MSR Mod" --bundle-identifier "moe.astrian.ext-msrmod" --swift --no-open
- name: 构建 Safari 扩展
run: |
cd "safari-extension/MSR Mod"
xcodebuild -project "MSR Mod.xcodeproj" -scheme "MSR Mod (macOS)" -configuration Release -destination "generic/platform=macOS" build
- name: 上传构建工件
uses: actions/upload-artifact@v3
with:
name: safari-extension
path: safari-extension/
publish-to-chrome-webstore:
name: 发布至 Chrome 应用商店
runs-on: ubuntu-latest

159
CLAUDE.md Normal file
View File

@ -0,0 +1,159 @@
# 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:safari # Build for Safari (uses background.html)
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
- **Safari**: Uses background page (background.html) instead of service worker
- **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
- **prebuild-safari.js**: Creates background.html and adapts manifest for Safari
### `/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
## Safari Extension Considerations
### Background Script Handling
Safari Web Extensions have different requirements for background scripts:
1. **Background Page vs Service Worker**: Safari uses `background.page` instead of `service_worker`
2. **Background HTML**: The prebuild script creates `background.html` that loads `background.js`
3. **Manifest Configuration**: Uses `"background": { "page": "background.html", "persistent": false }`
### Auto-redirect Functionality
The auto-redirect feature in Safari may require special handling due to:
- Different WebKit extension APIs
- Safari's stricter security policies
- Tab management differences from Chromium
### Building for Safari
```bash
npm run build:safari # Creates background.html and Safari-specific manifest
```
The Safari build process:
1. Removes localhost development configurations
2. Converts `service_worker` to `background.page`
3. Creates `background.html` wrapper for `background.js`
4. Adds Safari-specific browser settings

View File

@ -8,6 +8,7 @@
"build": "echo 'No platform specified, will build for Chromium.' && npm run build-chrome",
"build:chrome": "npm run prebuild:chrome && vue-tsc -b && vite build && cp -r public/* dist/",
"build:firefox": "npm run prebuild:firefox && vue-tsc -b && vite build && cp -r public/* dist/",
"build:safari": "npm run prebuild:safari && vue-tsc -b && vite build && cp -r public/* dist/",
"dev:refresh": "vue-tsc -b && vite build && cp -r public/* dist/",
"build:watch": "vite build --watch",
"preview": "vite preview",
@ -15,7 +16,8 @@
"quality-check": "biome ci",
"qc": "npm run quality-check",
"prebuild:chrome": "node scripts/prebuild-chrome.js",
"prebuild:firefox": "node scripts/prebuild-firefox.js"
"prebuild:firefox": "node scripts/prebuild-firefox.js",
"prebuild:safari": "node scripts/prebuild-safari.js"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.7",

View File

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

359
scripts/prebuild-safari.js Normal file
View File

@ -0,0 +1,359 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 处理 manifest.json for Safari
function processManifest() {
const manifestPath = path.join(__dirname, '../public/manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// 移除本地调试相关的配置
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
permission => !permission.includes('localhost')
);
}
if (manifest.content_security_policy && manifest.content_security_policy.extension_pages) {
// 移除 CSP 中的本地开发相关配置
manifest.content_security_policy.extension_pages = manifest.content_security_policy.extension_pages
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
.replace(/;\s+/g, '; ') // 标准化分号后的空格
.replace(/\s+/g, ' ') // 合并多个空格为一个
.trim();
}
// Safari 特殊处理:添加 appShell.html 到 content scripts 匹配
if (manifest.content_scripts && manifest.content_scripts[0]) {
// 添加 appShell.html 的匹配规则
const existingMatches = manifest.content_scripts[0].matches;
if (!existingMatches.includes("https://monster-siren.hypergryph.com/")) {
existingMatches.push("https://monster-siren.hypergryph.com/");
}
}
// Safari 特殊处理:使用 background.page 而不是 service_worker
if (manifest.background && manifest.background.service_worker) {
// Safari 扩展在 Manifest V3 中必须使用 persistent: false
// 但为了调试,我们暂时设为 true 来确保页面加载
manifest.background = {
page: "background.html",
persistent: true
};
}
// 创建 background.html 文件用于 Safari
const backgroundHtmlPath = path.join(__dirname, '../public/background.html');
const backgroundHtmlContent = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>MSR Mod Background</title>
</head>
<body>
<h1>MSR Mod Background Page</h1>
<p>If you can see this page, the background page is loaded!</p>
<div id="log"></div>
<script>
// 创建日志函数,同时显示在页面和控制台
function log(message) {
console.log(message);
const logDiv = document.getElementById('log');
if (logDiv) {
logDiv.innerHTML += '<div>' + message + '</div>';
}
}
log('=== SAFARI BACKGROUND PAGE LOADED ===');
log('Document ready state: ' + document.readyState);
log('Location: ' + location.href);
log('Time: ' + new Date().toISOString());
// 确保在 Safari 中正确加载脚本
try {
log('Safari extension context: ' + JSON.stringify({
chrome: typeof chrome,
browser: typeof browser,
safari: typeof safari
}));
} catch (e) {
log('Error in background.html: ' + e.message);
}
// 监听事件
document.addEventListener('DOMContentLoaded', function() {
log('=== DOMContentLoaded fired ===');
});
window.addEventListener('load', function() {
log('=== Window load fired ===');
});
log('About to load background.js...');
</script>
<script src="background.js" onload="log('background.js loaded successfully')" onerror="log('Failed to load background.js')"></script>
<script>
log('=== After background.js script tag ===');
</script>
</body>
</html>`;
fs.writeFileSync(backgroundHtmlPath, backgroundHtmlContent);
// 创建 Safari 兼容的 background.js
const backgroundJsPath = path.join(__dirname, '../public/background.js');
let backgroundJsContent = fs.readFileSync(backgroundJsPath, 'utf8');
// 检查是否已经添加过 Safari 代码,避免重复
if (backgroundJsContent.includes('=== Safari background.js starting ===')) {
console.log('Safari background.js already processed, skipping...');
} else {
// 在开头添加 Safari 调试信息(只添加一次)
const safariDebugCode = `
console.log("=== Safari background.js starting ===");
console.log("Available APIs:", {
chrome: typeof chrome,
browser: typeof browser,
safari: typeof safari
});
// Safari 特殊处理
if (typeof chrome === 'undefined' && typeof browser === 'undefined') {
console.log("No extension APIs available in Safari");
// 如果没有扩展 API创建一个空的对象避免错误
window.chrome = {
webRequest: { onBeforeRequest: { addListener: () => {} } },
storage: { sync: { get: () => Promise.resolve({}) } },
tabs: { create: () => {}, remove: () => {}, update: () => {} },
runtime: {
getURL: (path) => path,
onMessage: { addListener: () => {} }
}
};
}
// Safari 消息监听器:处理来自 content script 的重定向请求
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('Background received message:', message);
if (message.action === 'redirect_to_extension') {
console.log('Processing redirect request from content script');
try {
// 创建新标签页并打开扩展
const extensionUrl = chrome.runtime.getURL('index.html');
chrome.tabs.create({ url: extensionUrl }, (newTab) => {
console.log('New extension tab created:', newTab.id);
// 关闭原始标签页
if (sender.tab && sender.tab.id) {
chrome.tabs.remove(sender.tab.id);
}
sendResponse({ success: true, url: extensionUrl });
});
} catch (error) {
console.error('Failed to redirect:', error);
sendResponse({ success: false, error: error.message });
}
return true; // 保持消息通道开放
}
});
}
`;
// 替换 Safari 的重定向 URL 监听
backgroundJsContent = backgroundJsContent.replace(
/{ urls: \['https:\/\/monster-siren\.hypergryph\.com\/api\/fontset', 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json'\] }/g,
"{ urls: ['https://monster-siren.hypergryph.com/api/fontset', 'https://monster-siren.hypergryph.com/manifest.json', 'https://monster-siren.hypergryph.com/'] }"
);
// 替换 Safari 的重定向判断逻辑
backgroundJsContent = backgroundJsContent.replace(
/details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json'/g,
"(details.url === 'https://monster-siren.hypergryph.com/manifest.json' || details.url === 'https://monster-siren.hypergryph.com/')"
);
// 清理可能的重复条件
backgroundJsContent = backgroundJsContent.replace(
/\(\(details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json' \|\| details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/'\) \|\| details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/'\)/g,
"(details.url === 'https://monster-siren.hypergryph.com/manifest.json' || details.url === 'https://monster-siren.hypergryph.com/')"
);
backgroundJsContent = safariDebugCode + backgroundJsContent;
}
fs.writeFileSync(backgroundJsPath, backgroundJsContent);
console.log('✅ Safari-compatible background.js created');
// 创建 Safari 专用的 content.js
const contentJsPath = path.join(__dirname, '../public/content.js');
// 检查是否已经处理过 content.js
const existingContentJs = fs.existsSync(contentJsPath) ? fs.readFileSync(contentJsPath, 'utf8') : '';
if (existingContentJs.includes('checkRedirectPreference')) {
console.log('Safari content.js already processed, skipping...');
} else {
const contentJsContent = `
// Safari 扩展 content script for redirect
console.log('MSR Mod content script loaded on:', window.location.href);
// 兼容 Safari 的浏览器 API
const browserAPI = typeof browser !== 'undefined' ? browser : chrome;
// 异步函数:检查重定向偏好设置
async function checkRedirectPreference() {
try {
console.log('Checking redirect preferences...');
// 读取偏好设置
const pref = await browserAPI.storage.sync.get('preferences');
console.log('Retrieved preferences:', pref);
// 检查自动重定向设置(默认为 true
const shouldRedirect = pref === undefined ||
pref.preferences === undefined ||
pref.preferences.autoRedirect === undefined ||
pref.preferences.autoRedirect === true;
console.log('Should redirect:', shouldRedirect);
return shouldRedirect;
} catch (error) {
console.error('Error reading preferences:', error);
// 如果读取偏好设置失败,默认重定向
return true;
}
}
// 执行重定向的函数
function performRedirect() {
console.log('Performing redirect to extension...');
try {
// 对于 Safari我们需要使用消息传递来请求重定向
// 因为 content script 无法直接访问 chrome.runtime.getURL
// 方案1尝试通过消息传递
if (typeof chrome !== 'undefined' && chrome.runtime) {
chrome.runtime.sendMessage({action: 'redirect_to_extension'}, (response) => {
if (chrome.runtime.lastError) {
console.log('Message sending failed, trying direct redirect...');
// 方案2尝试直接重定向可能在某些情况下有效
window.location.href = 'safari-web-extension://[extension-id]/index.html';
}
});
} else {
console.log('Chrome runtime not available, trying alternative redirect...');
// 方案3显示提示让用户手动打开扩展
document.body.innerHTML = \`
<div style="
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1a1a;
color: white;
padding: 20px;
border-radius: 8px;
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
z-index: 10000;
">
<h2>MSR Mod Extension Detected</h2>
<p>Please click the MSR Mod extension icon in your Safari toolbar to open the app.</p>
<button onclick="window.close()" style="
background: #007AFF;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
">Close Tab</button>
</div>
\`;
}
} catch (error) {
console.error('Redirect failed:', error);
}
}
// 主逻辑:检查页面并根据偏好设置决定是否重定向
async function main() {
// 检查是否是目标页面
if (window.location.pathname === '/' || window.location.href.includes('appShell.html')) {
console.log('Detected target page, checking preferences...');
// 检查偏好设置
const shouldRedirect = await checkRedirectPreference();
if (shouldRedirect) {
console.log('Auto-redirect is enabled, proceeding with redirect...');
performRedirect();
} else {
console.log('Auto-redirect is disabled, skipping redirect.');
}
}
}
// 执行主逻辑
main().catch(error => {
console.error('Error in main function:', error);
});
`;
fs.writeFileSync(contentJsPath, contentJsContent);
}
console.log('✅ Safari-compatible content.js created');
// Safari 可能需要额外的权限
if (!manifest.permissions.includes('activeTab')) {
manifest.permissions.push('activeTab');
}
// 添加 Safari 特有配置
manifest.browser_specific_settings = {
safari: {
minimum_version: "14.0"
}
};
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log('✅ Safari Manifest.json processed');
console.log('✅ Background.html created for Safari');
}
// 处理 index.html
function processIndexHtml() {
const indexPath = path.join(__dirname, '../index.html');
let content = fs.readFileSync(indexPath, 'utf8');
// 替换脚本地址
content = content.replace(
/src="[^"]*\/src\/main\.ts"/g,
'src="./src/main.ts"'
);
// 移除 crossorigin 属性
content = content.replace(/\s+crossorigin/g, '');
fs.writeFileSync(indexPath, content);
console.log('✅ Index.html processed for Safari');
}
// 执行处理
try {
processManifest();
processIndexHtml();
console.log('🎉 Safari build preparation completed!');
} catch (error) {
console.error('❌ Error during Safari build preparation:', error);
process.exit(1);
}

View File

@ -9,6 +9,8 @@ import LeftArrowIcon from './assets/icons/leftarrow.vue'
import CorgIcon from './assets/icons/corg.vue'
import { watch } from 'vue'
import UpdatePopup from './components/UpdatePopup.vue'
const presentPreferencePanel = ref(false)
const route = useRoute()
@ -21,6 +23,8 @@ watch(() => presentPreferencePanel, (value) => {
</script>
<template>
<UpdatePopup />
<div class="w-screen h-screen overflow-hidden bg-[#191919]">
<div class="flex flex-col w-full h-full overflow-y-auto">
<div class="py-8 px-4 md:px-8 w-screen bg-gradient-to-b from-[#00000080] to-transparent z-20 absolute top-0">

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,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,10 +1,11 @@
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { artistsOrganize } from '../utils'
import { artistsOrganize, supportsWebAudioVisualization } from '../utils'
import XIcon from '../assets/icons/x.vue'
import UpHyphenIcon from '../assets/icons/uphypen.vue'
import DownHyphenIcon from '../assets/icons/downhyphen.vue'
import SoundwaveIcon from '../assets/icons/soundwave.vue'
import { ref } from 'vue'
@ -18,6 +19,9 @@ const playQueueStore = usePlayQueueStore()
const hover = ref(false)
//
const isAudioVisualizationSupported = supportsWebAudioVisualization()
function moveUp() {
if (props.index === 0) return
@ -156,12 +160,14 @@ function removeItem() {
<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"
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"
:key="index" :style="{
height: `${Math.max(10, bar)}%`
}" />
</div>
<SoundwaveIcon v-else :size="6" class="text-white animate-pulse" />
</div>
</div>
<div class="flex flex-col text-left flex-auto w-0">

View File

@ -1,15 +1,17 @@
<!-- Player.vue - 添加预加载功能 -->
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { useTemplateRef, watch, nextTick, computed } from 'vue'
import { computed, nextTick, ref, 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 PauseIcon from '../assets/icons/pause.vue'
import { audioVisualizer, checkAndRefreshSongResource, supportsWebAudioVisualization } from '../utils'
const playQueueStore = usePlayQueueStore()
const favourites = useFavourites()
const route = useRoute()
const player = useTemplateRef('playerRef')
@ -17,16 +19,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 +42,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 +175,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 +190,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 +220,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 +247,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()
@ -194,115 +264,160 @@ function updateCurrentTime() {
}
}
console.log('[Player] 初始化 audioVisualizer')
const { barHeights, connectAudio, isAnalyzing, error } = audioVisualizer({
sensitivity: 1.5,
barCount: 6,
maxDecibels: -10,
bassBoost: 0.8,
midBoost: 1.2,
trebleBoost: 1.4,
threshold: 0
})
//
const isAudioVisualizationSupported = supportsWebAudioVisualization()
console.log('[Player] 音频可视化支持状态:', isAudioVisualizationSupported)
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) => {
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) {
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.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 && 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() {
return currentTrack.value
@ -313,7 +428,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 +454,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 +510,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>
@ -404,12 +519,14 @@ setInterval(syncVolumeFromStorage, 100)
}">
<div v-if="playQueueStore.isPlaying">
<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"
:key="index" :style="{
height: `${Math.max(10, bar)}%`
}" />
</div>
<PauseIcon v-else :size="4" />
</div>
<PlayIcon v-else :size="4" />
</button>

View File

@ -31,7 +31,7 @@ const version = computed(() => {
class="bg-black/30 w-screen h-screen absolute top-0 left-0 z-30 flex justify-center items-center select-none"
@click="$emit('dismiss')">
<div
class="bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl border border-[#ffffff39] rounded-lg w-[60rem] h-3/4 relative overflow-scroll modal-content"
class="bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl border border-[#ffffff39] rounded-lg w-[60rem] h-3/4 relative overflow-y-auto modal-content"
@click.stop>
<div
class="flex justify-between items-center p-8 sticky top-0 bg-gradient-to-b from-neutral-900 to-neutral-900/0 z-10">
@ -126,7 +126,27 @@ const version = computed(() => {
class="flex justify-between items-center px-6 py-4 w-full text-left hover:bg-neutral-300/10 transition-all">
<div class="flex flex-col">
<div class="text-base text-white">前往 GitHub 仓库</div>
<div class="text-sm text-white/80"> Bug 的事情就拜托了大佬桑鞠躬</div>
<div class="text-sm text-white/80"> Issue 中提交问题或建议或者 Bug 的事情就拜托了大佬桑鞠躬</div>
</div>
</a>
</li>
<li class="odd:bg-neutral-300/5">
<a href="https://discord.gg/QQUfeb2gzH" target="_blank"
class="flex justify-between items-center px-6 py-4 w-full text-left hover:bg-neutral-300/10 transition-all">
<div class="flex flex-col">
<div class="text-base text-white">前往 Discord 社群</div>
<div class="text-sm text-white/80">在社群中提交问题或建议或是来聊聊明日方舟的音乐吧</div>
</div>
</a>
</li>
<li class="odd:bg-neutral-300/5">
<a href="https://trello.com/b/Ju1TRXla" target="_blank"
class="flex justify-between items-center px-6 py-4 w-full text-left hover:bg-neutral-300/10 transition-all">
<div class="flex flex-col">
<div class="text-base text-white">前往 Trello 看板</div>
<div class="text-sm text-white/80">了解 MSR Mod 目前的开发进度</div>
</div>
</a>
</li>

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

@ -0,0 +1,57 @@
<script lang="ts" setup>
import XIcon from '../assets/icons/x.vue'
import { ref, onMounted } from 'vue'
import { useUpdatePopup } from '../stores/useUpdatePopup'
const updatePopupStore = useUpdatePopup()
const showPopup = ref(false)
const version = updatePopupStore.getCurrentVersion()
//
const handleDismiss = async () => {
showPopup.value = false
//
await updatePopupStore.markUpdatePopupShown()
}
//
onMounted(async () => {
// store
if (!updatePopupStore.isLoaded) {
await updatePopupStore.initializeUpdatePopup()
}
//
const shouldShow = await updatePopupStore.shouldShowUpdatePopup()
showPopup.value = shouldShow
})
</script>
<template>
<div v-if="showPopup" class="absolute top-0 left-0 w-screen h-screen bg-neutral-700/30 flex justify-center items-center select-none z-50">
<div class="bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl border border-[#ffffff39] rounded-lg w-[60rem] h-3/4 relative overflow-y-auto text-white">
<div
class="flex justify-between items-center p-8 sticky top-0 bg-gradient-to-b from-neutral-900 to-neutral-900/0 z-10">
<div class="text-white text-2xl font-semibold">MSR Mod 已更新至 {{version}}</div>
<button
class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:bg-neutral-700/80"
@click="handleDismiss">
<XIcon :size="4" />
</button>
</div>
<div class="flex flex-col gap-4 mb-8 px-8 text-lg">
<p>最近两周有家事同时我的 MacBook Pro MagSafe 出了故障还在 Genius Bar 维修所以开发进程受到了一些影响</p>
<p>MSR Mod 现在有两种渠道接收错误及意见反馈如果你对 MSR Mod 有任何的意见建议或是想要回报错误及体验困惑之处欢迎前往 <a href="https://github.com/Astrian/msr-mod/issues" target="_blank" class="underline">GitHub Issue</a> <a href="https://discord.gg/QQUfeb2gzH" target="_blank" class="underline">Discord 社群</a> 向我们反馈如果你的意见或错误回报被接受我们会将其放入 <a href="https://trello.com/b/Ju1TRXla" target="_blank" class="underline">Trello 看板</a> 中进行跟踪敬请留意</p>
<ul class="list-disc list-inside">
<li>新增版本更新提示对话框将在 MSR Mod 更新后首次启动显示</li>
<li>增强对 Apple Safari 浏览器的兼容性支持</li>
<li>修复 Windows 和其他操作系统中偏好设置面板滚动条显示异常的问题</li>
<li>在偏好设置面板中新增 Discord 社群和 Trello 看板链接</li>
</ul>
</div>
</div>
</div>
</template>

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

@ -0,0 +1,204 @@
import { defineStore } from "pinia"
import { ref } from "vue"
// 声明全局类型
declare global {
interface Window {
browser?: any
}
}
export const useUpdatePopup = defineStore('updatePopup', () => {
const isLoaded = ref(false)
const storageType = ref<'chrome' | 'localStorage' | 'memory'>('chrome')
// 获取当前版本号
const getCurrentVersion = (): string => {
try {
// 尝试从 Chrome 扩展 API 获取版本号
return chrome?.runtime?.getManifest?.()?.version || 'unknown'
} catch (error) {
return 'unknown'
}
}
// 检测可用的 API
const detectAvailableAPIs = () => {
// 检查原生 chrome API
try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
storageType.value = 'chrome'
return 'chrome'
}
} catch (error) {
// Silent fail
}
// 检查 window.chrome
try {
if (window.chrome && window.chrome.storage && window.chrome.storage.sync) {
storageType.value = 'chrome'
return 'chrome'
}
} catch (error) {
// Silent fail
}
// 检查 localStorage
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('msr_test', 'test')
localStorage.removeItem('msr_test')
storageType.value = 'localStorage'
return 'localStorage'
}
} catch (error) {
// Silent fail
}
// 都不可用,使用内存存储
storageType.value = 'memory'
return 'memory'
}
// 通用的获取存储值函数
const getStoredValue = async (key: string, defaultValue: any) => {
const type = detectAvailableAPIs()
try {
switch (type) {
case 'chrome':
return await new Promise((resolve) => {
const api = chrome?.storage?.sync || window.chrome?.storage?.sync
if (api) {
api.get({ [key]: defaultValue }, (result) => {
if (chrome.runtime.lastError) {
resolve(defaultValue)
} else {
resolve(result[key])
}
})
} else {
resolve(defaultValue)
}
})
case 'localStorage':
const stored = localStorage.getItem(`msr_${key}`)
const value = stored ? JSON.parse(stored) : defaultValue
return value
case 'memory':
default:
return defaultValue
}
} catch (error) {
return defaultValue
}
}
// 通用的设置存储值函数
const setStoredValue = async (key: string, value: any) => {
const type = storageType.value
try {
switch (type) {
case 'chrome':
return await new Promise<void>((resolve, reject) => {
const api = chrome?.storage?.sync || window.chrome?.storage?.sync
if (api) {
api.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message))
} else {
resolve()
}
})
} else {
reject(new Error('Chrome storage API 不可用'))
}
})
case 'localStorage':
localStorage.setItem(`msr_${key}`, JSON.stringify(value))
break
case 'memory':
// 内存存储(不持久化)
break
}
} catch (error) {
throw error
}
}
// 检查是否需要显示更新弹窗
const shouldShowUpdatePopup = async (): Promise<boolean> => {
try {
const currentVersion = getCurrentVersion()
// 如果无法获取当前版本,不显示弹窗
if (currentVersion === 'unknown') {
return false
}
// 获取上次显示弹窗的版本号
const lastShownVersion = await getStoredValue('lastUpdatePopupVersion', '')
// 如果版本号不同,需要显示弹窗并更新存储的版本号
if (lastShownVersion !== currentVersion) {
await setStoredValue('lastUpdatePopupVersion', currentVersion)
return true
}
return false
} catch (error) {
console.error('检查更新弹窗状态失败:', error)
return false
}
}
// 标记已显示过更新弹窗(手动关闭时调用)
const markUpdatePopupShown = async () => {
try {
const currentVersion = getCurrentVersion()
if (currentVersion !== 'unknown') {
await setStoredValue('lastUpdatePopupVersion', currentVersion)
}
} catch (error) {
console.error('标记更新弹窗已显示失败:', error)
}
}
// 获取当前存储的版本号
const getLastShownVersion = async (): Promise<string> => {
return await getStoredValue('lastUpdatePopupVersion', '')
}
// 异步初始化函数
const initializeUpdatePopup = async () => {
try {
// 初始化存储类型检测
detectAvailableAPIs()
isLoaded.value = true
} catch (error) {
console.error('初始化更新弹窗 store 失败:', error)
isLoaded.value = true
}
}
// 立即初始化
initializeUpdatePopup()
return {
isLoaded,
storageType,
getCurrentVersion,
shouldShowUpdatePopup,
markUpdatePopupShown,
getLastShownVersion,
initializeUpdatePopup,
getStoredValue,
setStoredValue
}
})

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 as any).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 as any).AudioContext && 'createAnalyser' in (window as any).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 { audioVisualizer } from "./audioVisualizer"
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
}