Compare commits

..

6 Commits

Author SHA1 Message Date
4everWZ 7759d9b23a perf: 长列表渐进式渲染优化与播放栏遮挡修复 (#589)
- 新增 useProgressiveRender composable,提取手工虚拟化逻辑(renderLimit + placeholderHeight)
- FavoritePage/DownloadPage 使用 composable 实现渐进式渲染,避免大量 DOM 一次性渲染
- MusicListPage 初始加载扩大至 200 首,工具栏按钮添加 n-tooltip,新增回到顶部按钮
- 播放栏动态底部间距替代 PlayBottom 组件,修复播放时列表底部被遮挡
- 下载页无下载任务时自动切换到已下载 tab
- i18n: 添加 scrollToTop/compactLayout/normalLayout 翻译(5 种语言)

Inspired-By: https://github.com/algerkong/AlgerMusicPlayer/pull/589
2026-04-08 20:04:40 +08:00
Vanilla-puree c889ac301b feat(download): 新增未保存下载设置时的确认对话框 (#507)
- feat(download): 关闭下载设置抽屉时检测未保存更改,提供取消/放弃/保存选项
- fix: 自动播放首次暂停无法暂停,移除不必要的 isFirstPlay 检查
- fix: 歌手详情路由添加 props key,修复跳转歌手详情不生效问题
- i18n: 添加 download.save.* 翻译(5 种语言)

Co-Authored-By: 心妄 <1661272893@qq.com>
2026-04-08 19:35:32 +08:00
alger 1e30a11881 fix(core): 修复事件监听器泄漏
- App.vue: offline 监听器添加 onUnmounted 清理,移除冗余 console.log
- MusicHook.ts: document.onkeyup 直接赋值改为 addEventListener + 防重复
- MusicHook.ts: audio-ready 监听器提取为命名函数,先移除再注册防堆叠
2026-03-29 14:22:33 +08:00
alger 34713430e1 fix(player): 修复迷你模式恢复后歌词页面空白偏移
迷你播放栏的 togglePlaylist 设置 document.body.style.height='64px'
和 overflow='hidden',恢复主窗口时未清理,导致歌词 drawer 高度被限制。
在 mini-mode 事件处理中添加 body 样式重置。
2026-03-29 14:04:55 +08:00
alger eaf1636505 refactor(player): 提取播放栏共享逻辑为 composable
- 新增 useVolumeControl:统一音量管理(volumeSlider、mute、滚轮调节)
- 新增 useFavorite:收藏状态与切换
- 新增 usePlaybackControl:播放/暂停、上/下一首
- PlayBar、MiniPlayBar、SimplePlayBar、MobilePlayBar 使用新 composable
- 修复音量存储不一致:MiniPlayBar/SimplePlayBar 原先绕过 playerStore 直接操作 localStorage
2026-03-29 14:04:39 +08:00
alger e032afeae8 docs: 更新 CLAUDE.md,反映播放系统重构(Howler.js → 原生 HTMLAudioElement) 2026-03-29 13:30:36 +08:00
127 changed files with 1463 additions and 3043 deletions
+3 -1
View File
@@ -1,2 +1,4 @@
# 你的接口地址 (必填) # 你的接口地址 (必填)
VITE_API = http://127.0.0.1:30488 VITE_API = http://127.0.0.1:30488
# 音乐破解接口地址 web端
VITE_API_MUSIC = ***
-83
View File
@@ -1,83 +0,0 @@
name: PR Check
on:
pull_request:
branches: [main]
types: [opened, edited, synchronize, reopened]
jobs:
# 检查 PR 标题是否符合 Conventional Commits 规范
pr-title:
name: PR Title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install commitlint
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
- name: Validate PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "$PR_TITLE" | npx commitlint
# 检查所有提交信息是否符合 Conventional Commits 规范
commit-messages:
name: Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install commitlint
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
- name: Validate commit messages
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
# 运行 lint 和类型检查
code-quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
# lint:i18n 脚本用 bun 跑(package.json: "bun scripts/check_i18n.ts"),
# CI 默认环境没有 bun 会报 sh: bun: not found
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm install
- name: Lint
run: npx eslint --max-warnings 0 "src/**/*.{ts,tsx,vue,js}"
# tsconfig.web.json 显式 require src/renderer/{auto-imports,components}.d.ts
# 这两个文件由 unplugin-auto-import / unplugin-vue-components 在 vite 启动时
# 生成,且被 .gitignore 排除(58922dc 维护者主动设置)。CI 直接跑 typecheck
# 拿不到 d.ts 会报 TS2688,先跑一次 build 触发 unplugin 生成
- name: Build (generates auto-import / components d.ts)
run: npm run build
- name: Type check
run: npm run typecheck
- name: I18n check
run: npm run lint:i18n
-3
View File
@@ -46,8 +46,5 @@ AGENTS.md
.auto-imports.d.ts .auto-imports.d.ts
.components.d.ts .components.d.ts
# TypeScript 增量编译缓存
*.tsbuildinfo
src/renderer/auto-imports.d.ts src/renderer/auto-imports.d.ts
src/renderer/components.d.ts src/renderer/components.d.ts
-1
View File
@@ -1 +0,0 @@
npx --no -- commitlint --edit "$1"
+423
View File
@@ -0,0 +1,423 @@
# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 提供项目指南。
## 项目概述
Alger Music Player 是基于 **Electron + Vue 3 + TypeScript** 构建的第三方网易云音乐播放器,支持桌面端(Windows/macOS/Linux)、Web 和移动端,具备本地 API 服务、桌面歌词、无损音乐下载、音源解锁、EQ 均衡器等功能。
## 技术栈
- **桌面端**: Electron 40 + electron-vite 5
- **前端框架**: Vue 3.5 (Composition API + `<script setup>`)
- **状态管理**: Pinia 3 + pinia-plugin-persistedstate
- **UI 框架**: naive-ui(自动导入)
- **样式**: Tailwind CSS 3(仅在模板中使用 class,禁止在 `<style>` 中使用 `@apply`
- **图标**: remixicon
- **音频**: 原生 HTMLAudioElement + Web Audio APIEQ 均衡器)
- **工具库**: VueUse, lodash
- **国际化**: vue-i18n5 种语言:zh-CN、en-US、ja-JP、ko-KR、zh-Hant
- **音乐 API**: netease-cloud-music-api-alger + @unblockneteasemusic/server
- **自动更新**: electron-updaterGitHub Releases
- **构建**: Vite 6, electron-builder
## 开发命令
```bash
# 安装依赖(推荐 Node 18+
npm install
# 桌面端开发(推荐)
npm run dev
# Web 端开发(需自建 netease-cloud-music-api 服务)
npm run dev:web
# 类型检查
npm run typecheck # 全部检查
npm run typecheck:node # 主进程
npm run typecheck:web # 渲染进程
# 代码规范
npm run lint # ESLint + i18n 检查
npm run format # Prettier 格式化
# 构建
npm run build # 构建渲染进程和主进程
npm run build:win # Windows 安装包
npm run build:mac # macOS DMG
npm run build:linux # AppImage, deb, rpm
npm run build:unpack # 仅构建不打包
```
## 项目架构
### 目录结构
```
src/
├── main/ # Electron 主进程
│ ├── index.ts # 入口,窗口生命周期
│ ├── modules/ # 功能模块(15 个文件)
│ │ ├── window.ts # 窗口管理(主窗口、迷你模式、歌词窗口)
│ │ ├── tray.ts # 系统托盘
│ │ ├── shortcuts.ts # 全局快捷键
│ │ ├── fileManager.ts # 下载管理
│ │ ├── remoteControl.ts # 远程控制 HTTP API
│ │ └── update.ts # 自动更新(electron-updater
│ ├── lyric.ts # 歌词窗口
│ ├── server.ts # 本地 API 服务
│ └── unblockMusic.ts # 音源解锁服务
├── preload/index.ts # IPC 桥接(暴露 window.api
├── shared/ # 主进程/渲染进程共享代码
│ └── appUpdate.ts # 更新状态类型定义
├── i18n/ # 国际化
│ ├── lang/ # 语言文件(5 语言 × 15 分类 = 75 个文件)
│ ├── main.ts # 主进程 i18n
│ ├── renderer.ts # 渲染进程 i18n
│ └── utils.ts # i18n 工具
└── renderer/ # Vue 应用
├── store/modules/ # Pinia 状态(15 个模块)
│ ├── playerCore.ts # 🔑 播放核心状态(纯状态:播放/暂停、音量、倍速)
│ ├── playlist.ts # 🔑 播放列表管理(上/下一首、播放模式)
│ ├── settings.ts # 应用设置
│ ├── user.ts # 用户认证与同步
│ ├── lyric.ts # 歌词状态
│ ├── music.ts # 音乐元数据
│ └── favorite.ts # 收藏管理
├── services/ # 服务层
│ ├── audioService.ts # 🔑 原生 HTMLAudioElement + Web Audio APIEQ、MediaSession
│ ├── playbackController.ts # 🔑 播放控制流(playTrack 入口、generation 取消、初始化恢复)
│ ├── playbackRequestManager.ts # 请求 ID 追踪(供 usePlayerHooks 内部取消检查)
│ ├── preloadService.ts # 下一首 URL 预验证
│ ├── SongSourceConfigManager.ts # 单曲音源配置
│ └── translation-engines/ # 翻译引擎策略
├── hooks/ # 组合式函数(9 个文件)
│ ├── MusicHook.ts # 🔑 音乐主逻辑(歌词、进度、快捷键)
│ ├── usePlayerHooks.ts # 播放器 hooks
│ ├── useDownload.ts # 下载功能
│ └── IndexDBHook.ts # IndexedDB 封装
├── api/ # API 层(16 个文件)
│ ├── musicParser.ts # 🔑 多音源 URL 解析(策略模式)
│ ├── music.ts # 网易云音乐 API
│ ├── bilibili.ts # B站音源
│ ├── gdmusic.ts # GD Music 平台
│ ├── lxMusicStrategy.ts # LX Music 音源策略
│ ├── donation.ts # 捐赠 API
│ └── parseFromCustomApi.ts # 自定义 API 解析
├── components/ # 组件(59+ 个文件)
│ ├── common/ # 通用组件(24 个)
│ ├── player/ # 播放器组件(10 个)
│ ├── settings/ # 设置弹窗组件(7 个)
│ └── ...
├── views/ # 页面(53 个文件)
│ ├── set/ # 设置页(已拆分为 Tab 组件)
│ │ ├── index.vue # 设置页壳组件(导航 + provide/inject
│ │ ├── keys.ts # InjectionKey 定义
│ │ ├── SBtn.vue # 自定义按钮组件
│ │ ├── SInput.vue # 自定义输入组件
│ │ ├── SSelect.vue # 自定义选择器组件
│ │ ├── SettingItem.vue
│ │ ├── SettingSection.vue
│ │ └── tabs/ # 7 个 Tab 组件
│ │ ├── BasicTab.vue
│ │ ├── PlaybackTab.vue
│ │ ├── ApplicationTab.vue
│ │ ├── NetworkTab.vue
│ │ ├── SystemTab.vue
│ │ ├── AboutTab.vue
│ │ └── DonationTab.vue
│ └── ...
├── router/ # Vue Router3 个文件)
├── types/ # TypeScript 类型(20 个文件)
├── utils/ # 工具函数(17 个文件)
├── directive/ # 自定义指令
├── const/ # 常量定义
└── assets/ # 静态资源
```
### 核心模块职责
| 模块 | 文件 | 职责 |
|------|------|------|
| 播放控制 | `services/playbackController.ts` | 🔑 播放入口(playTrack)、generation 取消、初始化恢复、URL 过期处理 |
| 音频服务 | `services/audioService.ts` | 原生 HTMLAudioElement + Web Audio API、EQ 滤波、MediaSession |
| 播放状态 | `store/playerCore.ts` | 纯状态:播放/暂停、音量、倍速、当前歌曲、音频设备 |
| 播放列表 | `store/playlist.ts` | 列表管理、播放模式、上/下一首 |
| 音源解析 | `api/musicParser.ts` | 多音源 URL 解析与缓存 |
| 音乐钩子 | `hooks/MusicHook.ts` | 歌词解析、进度跟踪、键盘快捷键 |
### 播放系统架构
```
用户操作 / 自动播放
playbackController.playTrack(song) ← 唯一入口,generation++ 取消旧操作
├─ 加载歌词 + 背景色
├─ 获取播放 URLgetSongDetail
└─ audioService.play(url, track)
├─ audio.src = url ← 单一 HTMLAudioElement,换歌改 src
├─ Web Audio API EQ 链 ← createMediaElementSource 只调一次
└─ 原生 DOM 事件 → emit
MusicHook 监听(进度、歌词同步、播放状态)
```
**关键设计**
- **Generation-based 取消**:每次 `playTrack()` 递增 generation,await 后检查是否过期,过期则静默退出
- **单一 HTMLAudioElement**:启动时创建,永不销毁。换歌改 `audio.src`EQ 链不重建
- **Seek**:直接 `audio.currentTime = time`,无 Howler.js 的 pause→play 问题
### 音源解析策略
`musicParser.ts` 使用 **策略模式** 从多个来源解析音乐 URL
**优先级顺序**(可通过 `SongSourceConfigManager` 按曲配置):
1. `custom` - 自定义 API
2. `bilibili` - B站音频
3. `gdmusic` - GD Music 平台
4. `lxmusic` - LX Music HTTP 源
5. `unblock` - UnblockNeteaseMusic 服务
**缓存策略**
- 成功的 URL 在 IndexedDB 缓存 30 分钟(`music_url_cache`
- 失败的尝试在内存中缓存 1 分钟(应用重启自动清除)
- 音源配置变更时缓存失效
### 设置页架构
设置页(`views/set/`)采用 **provide/inject** 模式拆分为 7 个 Tab 组件:
- `index.vue` 作为壳组件:管理 Tab 导航、`setData` 双向绑定与防抖保存
- `keys.ts` 定义类型化的 InjectionKey`SETTINGS_DATA_KEY``SETTINGS_MESSAGE_KEY``SETTINGS_DIALOG_KEY`
- 自定义 UI 组件(`SBtn``SInput``SSelect`)替代部分 naive-ui 组件
- 字体选择器保留 naive-ui `n-select`(需要 filterable + multiple + render-label
## 代码规范
### 命名
- **目录**: kebab-case`components/music-player`
- **组件**: PascalCase`MusicPlayer.vue`
- **组合式函数**: camelCase + `use` 前缀(`usePlayer.ts`
- **Store**: camelCase`playerCore.ts`
- **常量**: UPPER_SNAKE_CASE`MAX_RETRY_COUNT`
### TypeScript
- **优先使用 `type` 而非 `interface`**
- **禁止使用 `enum`,使用 `const` 对象 + `as const`**
- 所有导出函数必须有类型标注
```typescript
// ✅ 正确
type SongResult = { id: number; name: string };
const PlayMode = { ORDER: 'order', LOOP: 'loop' } as const;
// ❌ 避免
interface ISongResult { ... }
enum PlayMode { ... }
```
### Vue 组件结构
```vue
<script setup lang="ts">
// 1. 导入(按类型分组)
import { ref, computed, onMounted } from 'vue';
import { usePlayerStore } from '@/store';
import type { SongResult } from '@/types/music';
// 2. Props & Emits
const props = defineProps<{ id: number }>();
const emit = defineEmits<{ play: [id: number] }>();
// 3. Store
const playerStore = usePlayerStore();
// 4. 响应式状态(使用描述性命名:isLoading, hasError
const isLoading = ref(false);
// 5. 计算属性
const displayName = computed(() => /* ... */);
// 6. 方法(动词开头命名)
const handlePlay = () => { /* ... */ };
// 7. 生命周期钩子
onMounted(() => { /* ... */ });
</script>
<template>
<!-- naive-ui 组件 + Tailwind CSS -->
</template>
```
### 样式规范
- **禁止在 `<style>` 中使用 `@apply`**,所有 Tailwind 类直接写在模板中
- 如发现代码中有 `@apply` 用法,应优化为内联 Tailwind class
- `<style scoped>` 仅用于无法用 Tailwind 实现的 CSS(如 keyframes 动画、`:deep()` 穿透)
### 导入约定
- **naive-ui 组件**:自动导入,无需手动 import
- **Vue 组合式 API**`useDialog``useMessage``useNotification``useLoadingBar` 自动导入
- **路径别名**`@``src/renderer``@i18n``src/i18n`
## 关键实现模式
### 状态持久化
Store 使用 `pinia-plugin-persistedstate` 自动持久化:
```typescript
export const useXxxStore = defineStore('xxx', () => {
// store 逻辑
}, {
persist: {
key: 'xxx-store',
storage: localStorage,
pick: ['fieldsToPersist'] // 仅持久化指定字段
}
});
```
### IPC 通信
```typescript
// 主进程 (src/main/modules/*)
ipcMain.handle('channel-name', async (_, args) => {
return result;
});
// Preload (src/preload/index.ts)
const api = {
methodName: (args) => ipcRenderer.invoke('channel-name', args)
};
contextBridge.exposeInMainWorld('api', api);
// 渲染进程 (src/renderer/*)
const result = await window.api.methodName(args);
```
### IndexedDB 使用
使用 `IndexDBHook` 组合式函数:
```typescript
const db = await useIndexedDB('dbName', [
{ name: 'storeName', keyPath: 'id' }
], version);
const { saveData, getData, deleteData } = db;
await saveData('storeName', { id: 1, data: 'value' });
const data = await getData('storeName', 1);
```
### 新增页面
1. 创建 `src/renderer/views/xxx/index.vue`
2.`src/renderer/router/other.ts` 中添加路由
3.`src/i18n/lang/*/` 下所有 5 种语言中添加 i18n 键值
### 新增 Store
```typescript
// src/renderer/store/modules/xxx.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useXxxStore = defineStore('xxx', () => {
const state = ref(initialValue);
const action = () => { /* ... */ };
return { state, action };
});
// 在 src/renderer/store/index.ts 中导出
export * from './modules/xxx';
```
### 新增音源策略
编辑 `src/renderer/api/musicParser.ts`
```typescript
class NewStrategy implements MusicSourceStrategy {
name = 'new';
priority = 5;
canHandle(sources: string[]) { return sources.includes('new'); }
async parse(id: number, data: any): Promise<ParsedMusicResult> {
// 实现解析逻辑
}
}
// 在 ParserManager 构造函数中注册
this.strategies.push(new NewStrategy());
```
## 平台相关说明
### Web 端开发
运行 `npm run dev:web` 需要:
1. 自建 `netease-cloud-music-api` 服务
2. 在项目根目录创建 `.env.development.local`
```env
VITE_API=https://your-api-server.com
VITE_API_MUSIC=https://your-unblock-server.com
```
### Electron 功能
- **窗口管理**: `src/main/modules/window.ts`(主窗口、迷你模式、歌词窗口)
- **系统托盘**: `src/main/modules/tray.ts`
- **全局快捷键**: `src/main/modules/shortcuts.ts`
- **自动更新**: `src/main/modules/update.ts`electron-updater + GitHub Releases
- **远程控制**: `src/main/modules/remoteControl.ts`HTTP API 远程播放控制)
- **磁盘缓存**: 音乐和歌词文件缓存,支持可配置目录、容量上限、LRU/FIFO 清理策略
## API 请求注意事项
- **axios 响应结构**`request.get('/xxx')` 返回 axios response,实际数据在 `res.data` 中。若 API 本身也有 `data` 字段(如 `/personal_fm` 返回 `{data: [...], code: 200}`),则需要 `res.data.data` 才能拿到真正的数组,**不要** 直接用 `res.data` 当结果。
- **避免并发请求风暴**:首页不要一次性并发请求大量接口(如 15 个歌单详情),会导致本地 API 服务与 `music.163.com` 的 TLS 连接被 reset502)。应使用懒加载(hover 时加载)或严格限制并发数。
- **timestamp 参数**:对 `/personal_fm` 等需要实时数据的接口,传 `timestamp: Date.now()` 避免服务端缓存和 stale 连接。`request.ts` 拦截器已自动添加 timestamp,API 层无需重复添加。
### 本地 API 服务调试
- **地址**`http://127.0.0.1:{port}`,默认端口 `30488`,可在设置中修改
- **API 文档**:基于 [NeteaseCloudMusicApi](https://www.npmjs.com/package/NeteaseCloudMusicApi)v4.29),接口文档参见 node_modules/NeteaseCloudMusicApi/public/docs/home.md
- **调试方式**:可直接用 `curl` 测试接口,例如:
```bash
# 测试私人FM(需登录 cookie
curl "http://127.0.0.1:30488/personal_fm?timestamp=$(date +%s000)"
# 测试歌单详情
curl "http://127.0.0.1:30488/playlist/detail?id=12449928929"
# 测试FM不喜欢
curl -X POST "http://127.0.0.1:30488/fm_trash?id=歌曲ID&timestamp=$(date +%s000)"
```
- **502 排查**:通常是并发请求过多导致 TLS 连接 reset,用 curl 单独调用可验证接口本身是否正常
- **Cookie 传递**:渲染进程通过 `request.ts` 拦截器自动附加 `localStorage` 中的 token
## 重要注意事项
- **主分支**: `dev_electron`PR 目标分支,非 `main`
- **自动导入**: naive-ui 组件、Vue 组合式 API`ref`、`computed` 等)均已自动导入
- **代码风格**: 使用 ESLint + Prettier,通过 husky + lint-staged 在 commit 时自动执行
- **国际化**: 所有面向用户的文字必须翻译为 5 种语言
- **提交规范**: commit message 中禁止包含 `Co-Authored-By` 信息
- **IndexedDB 存储**:
- `music`: 歌曲元数据缓存
- `music_lyric`: 歌词缓存
- `api_cache`: 通用 API 响应缓存
- `music_url_cache`: 音乐 URL 缓存(30 分钟 TTL
+2
View File
@@ -16,5 +16,7 @@
<true/> <true/>
<key>com.apple.security.files.downloads.read-write</key> <key>com.apple.security.files.downloads.read-write</key>
<true/> <true/>
<key>com.apple.security.device.microphone</key>
<true/>
</dict> </dict>
</plist> </plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

-12
View File
@@ -1,12 +0,0 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'perf', 'refactor', 'docs', 'style', 'test', 'build', 'ci', 'chore', 'revert']
],
'subject-empty': [2, 'never'],
'type-empty': [2, 'never']
}
};
-11
View File
@@ -35,17 +35,6 @@ export default defineConfig({
}) })
], ],
publicDir: resolve('resources'), publicDir: resolve('resources'),
build: {
rollupOptions: {
output: {
// 全部代码打到 entry chunk,避免 Vite 默认按共享依赖拆分时
// 与 store/index.ts 的 `export *` 形成 chunk 间循环引用,
// 触发生产构建里的 TDZ(dev 不分包不会暴露此问题)。
// Electron 桌面端本地加载,无 CDN/首屏体积顾虑,单 chunk 合算。
manualChunks: () => 'index'
}
}
},
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 2389 port: 2389
+3 -5
View File
@@ -11,7 +11,7 @@ import globals from 'globals';
export default [ export default [
// 忽略文件配置 // 忽略文件配置
{ {
ignores: ['node_modules/**', '**/dist/**', 'out/**', '.gitignore'] ignores: ['node_modules/**', 'dist/**', 'out/**', '.gitignore']
}, },
// 基础 JavaScript 配置 // 基础 JavaScript 配置
@@ -55,8 +55,7 @@ export default [
defineEmits: 'readonly', defineEmits: 'readonly',
// TypeScript 全局类型 // TypeScript 全局类型
NodeJS: 'readonly', NodeJS: 'readonly',
ScrollBehavior: 'readonly', ScrollBehavior: 'readonly'
ScrollToOptions: 'readonly'
} }
}, },
plugins: { plugins: {
@@ -149,8 +148,7 @@ export default [
useMessage: 'readonly', useMessage: 'readonly',
// TypeScript 全局类型 // TypeScript 全局类型
NodeJS: 'readonly', NodeJS: 'readonly',
ScrollBehavior: 'readonly', ScrollBehavior: 'readonly'
ScrollToOptions: 'readonly'
} }
}, },
plugins: { plugins: {
+2 -10
View File
@@ -18,7 +18,6 @@
"dev:web": "vite dev", "dev:web": "vite dev",
"build": "electron-vite build", "build": "electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"fix-sandbox": "node scripts/fix-sandbox.js",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win --publish never", "build:win": "npm run build && electron-builder --win --publish never",
"build:mac": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml && node scripts/merge_latest_mac_yml.mjs dist/latest-mac-x64.yml dist/latest-mac-arm64.yml dist/latest-mac.yml", "build:mac": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml && node scripts/merge_latest_mac_yml.mjs dist/latest-mac-x64.yml dist/latest-mac-arm64.yml dist/latest-mac.yml",
@@ -37,7 +36,6 @@
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@httptoolkit/dbus-native": "^0.1.5",
"@unblockneteasemusic/server": "^0.27.10", "@unblockneteasemusic/server": "^0.27.10",
"cors": "^2.8.5", "cors": "^2.8.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
@@ -51,7 +49,6 @@
"form-data": "^4.0.5", "form-data": "^4.0.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"mpris-service": "^2.1.2",
"music-metadata": "^11.10.3", "music-metadata": "^11.10.3",
"netease-cloud-music-api-alger": "^4.30.0", "netease-cloud-music-api-alger": "^4.30.0",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
@@ -61,8 +58,6 @@
"vue-i18n": "^11.2.2" "vue-i18n": "^11.2.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@electron-toolkit/eslint-config": "^2.1.0", "@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
@@ -153,6 +148,7 @@
"entitlements": "build/entitlements.mac.plist", "entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist",
"extendInfo": { "extendInfo": {
"NSMicrophoneUsageDescription": "AlgerMusicPlayer needs access to the microphone for audio visualization.",
"NSCameraUsageDescription": "Application requests access to the device's camera.", "NSCameraUsageDescription": "Application requests access to the device's camera.",
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.", "NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder." "NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
@@ -180,7 +176,7 @@
"requestedExecutionLevel": "asInvoker" "requestedExecutionLevel": "asInvoker"
}, },
"linux": { "linux": {
"icon": "build/icons", "icon": "resources/icon.png",
"target": [ "target": [
{ {
"target": "AppImage", "target": "AppImage",
@@ -226,9 +222,5 @@
"electron", "electron",
"esbuild" "esbuild"
] ]
},
"optionalDependencies": {
"jsbi": "^4.3.2",
"x11": "^2.3.0"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+3 -20
View File
@@ -1,27 +1,10 @@
{ {
"name": "Alger Music Player", "name": "Alger Music PWA",
"short_name": "AlgerMusic",
"description": "AlgerMusicPlayer 音乐播放器,支持在线播放、歌词显示、音乐下载等功能。",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [ "icons": [
{ {
"src": "/icon-192.png", "src": "./icon.png",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "256x256"
},
{
"src": "/icon-512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon-512-maskable.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
} }
] ]
} }
-13
View File
@@ -1,13 +0,0 @@
// 最小 Service Worker:仅满足 PWA 可安装性要求(需存在 fetch 处理器)。
// 不做任何缓存拦截,所有请求保持浏览器默认网络行为。
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', () => {
// 特意留空:不拦截,默认走网络
});
-21
View File
@@ -1,21 +0,0 @@
/**
* 修复 Linux 下 Electron sandbox 权限问题
* chrome-sandbox 需要 root 拥有且权限为 4755
*
* 注意:此脚本需要 sudo 权限,仅在 CI 环境或手动执行时使用
* 用法:sudo npm run fix-sandbox
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
if (process.platform === 'linux') {
const sandboxPath = path.resolve(__dirname, '../node_modules/electron/dist/chrome-sandbox');
if (fs.existsSync(sandboxPath)) {
execSync(`sudo chown root:root ${sandboxPath}`);
execSync(`sudo chmod 4755 ${sandboxPath}`);
console.log('[fix-sandbox] chrome-sandbox permissions fixed');
} else {
console.log('[fix-sandbox] chrome-sandbox not found, skipping');
}
}
+1 -3
View File
@@ -9,7 +9,5 @@ export default {
emptyState: 'No local music found. Please select a folder to scan.', emptyState: 'No local music found. Please select a folder to scan.',
fileNotFound: 'File not found or has been moved', fileNotFound: 'File not found or has been moved',
rescan: 'Rescan', rescan: 'Rescan',
songCount: '{count} songs', songCount: '{count} songs'
removeFromLibrary: 'Remove from Library',
removedFromLibrary: 'Removed from library (file not deleted)'
}; };
-1
View File
@@ -59,7 +59,6 @@ export default {
eq: 'Equalizer', eq: 'Equalizer',
playList: 'Play List', playList: 'Play List',
reparse: 'Reparse', reparse: 'Reparse',
download: 'Download',
miniPlayBar: 'Mini Play Bar', miniPlayBar: 'Mini Play Bar',
playMode: { playMode: {
sequence: 'Sequence', sequence: 'Sequence',
-1
View File
@@ -400,7 +400,6 @@ export default {
themeColor: { themeColor: {
title: 'Lyric Theme Color', title: 'Lyric Theme Color',
presetColors: 'Preset Colors', presetColors: 'Preset Colors',
reset: 'Reset to Default',
customColor: 'Custom Color', customColor: 'Custom Color',
preview: 'Preview', preview: 'Preview',
previewText: 'Lyric Effect', previewText: 'Lyric Effect',
-2
View File
@@ -13,8 +13,6 @@ export default {
}, },
message: { message: {
downloading: 'Downloading, please wait...', downloading: 'Downloading, please wait...',
addToPlaylistNeedLogin:
'Please log in with Cookie or QR code to add songs to a playlist (not available for UID login)',
downloadFailed: 'Download failed', downloadFailed: 'Download failed',
downloadQueued: 'Added to download queue', downloadQueued: 'Added to download queue',
addedToNextPlay: 'Added to play next', addedToNextPlay: 'Added to play next',
+1 -3
View File
@@ -9,7 +9,5 @@ export default {
emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。', emptyState: 'ローカル音楽がありません。フォルダを選択してスキャンしてください。',
fileNotFound: 'ファイルが見つからないか、移動されました', fileNotFound: 'ファイルが見つからないか、移動されました',
rescan: '再スキャン', rescan: '再スキャン',
songCount: '{count} 曲', songCount: '{count} 曲'
removeFromLibrary: 'ライブラリから削除',
removedFromLibrary: 'ライブラリから削除しました(ファイルは削除されません)'
}; };
-1
View File
@@ -59,7 +59,6 @@ export default {
eq: 'イコライザー', eq: 'イコライザー',
playList: 'プレイリスト', playList: 'プレイリスト',
reparse: '再解析', reparse: '再解析',
download: 'ダウンロード',
playMode: { playMode: {
sequence: '順次再生', sequence: '順次再生',
loop: 'ループ再生', loop: 'ループ再生',
+7 -8
View File
@@ -128,26 +128,26 @@ export default {
lxMusic: { lxMusic: {
tabs: { tabs: {
sources: '音源選択', sources: '音源選択',
lxMusic: '雪音源', lxMusic: '雪音源',
customApi: 'カスタムAPI' customApi: 'カスタムAPI'
}, },
scripts: { scripts: {
title: 'インポート済みのスクリプト', title: 'インポート済みのスクリプト',
importLocal: 'ローカルインポート', importLocal: 'ローカルインポート',
importOnline: 'オンラインインポート', importOnline: 'オンラインインポート',
urlPlaceholder: '雪音源スクリプトのURLを入力', urlPlaceholder: '雪音源スクリプトのURLを入力',
importBtn: 'インポート', importBtn: 'インポート',
empty: 'インポート済みの雪音源はありません', empty: 'インポート済みの雪音源はありません',
notConfigured: '未設定(雪音源タブで設定してください)', notConfigured: '未設定(雪音源タブで設定してください)',
importHint: '互換性のあるカスタムAPIプラグインをインポートして音源を拡張します', importHint: '互換性のあるカスタムAPIプラグインをインポートして音源を拡張します',
noScriptWarning: '先に雪音源スクリプトをインポートしてください', noScriptWarning: '先に雪音源スクリプトをインポートしてください',
noSelectionWarning: '先に雪音源を選択してください', noSelectionWarning: '先に雪音源を選択してください',
notFound: '音源が存在しません', notFound: '音源が存在しません',
switched: '音源を切り替えました: {name}', switched: '音源を切り替えました: {name}',
deleted: '音源を削除しました: {name}', deleted: '音源を削除しました: {name}',
enterUrl: 'スクリプトURLを入力してください', enterUrl: 'スクリプトURLを入力してください',
invalidUrl: '無効なURL形式', invalidUrl: '無効なURL形式',
invalidScript: '無効な雪音源スクリプトです(globalThis.lxが見つかりません)', invalidScript: '無効な雪音源スクリプトです(globalThis.lxが見つかりません)',
nameRequired: '名前を空にすることはできません', nameRequired: '名前を空にすることはできません',
renameSuccess: '名前を変更しました' renameSuccess: '名前を変更しました'
} }
@@ -399,7 +399,6 @@ export default {
themeColor: { themeColor: {
title: '歌詞テーマカラー', title: '歌詞テーマカラー',
presetColors: 'プリセットカラー', presetColors: 'プリセットカラー',
reset: 'デフォルトに戻す',
customColor: 'カスタムカラー', customColor: 'カスタムカラー',
preview: 'プレビュー効果', preview: 'プレビュー効果',
previewText: '歌詞効果', previewText: '歌詞効果',
+33 -35
View File
@@ -1,35 +1,33 @@
export default { export default {
menu: { menu: {
play: '再生', play: '再生',
playNext: '次に再生', playNext: '次に再生',
download: '楽曲をダウンロード', download: '楽曲をダウンロード',
downloadLyric: '歌詞をダウンロード', downloadLyric: '歌詞をダウンロード',
addToPlaylist: 'プレイリストに追加', addToPlaylist: 'プレイリストに追加',
favorite: 'いいね', favorite: 'いいね',
unfavorite: 'いいね解除', unfavorite: 'いいね解除',
removeFromPlaylist: 'プレイリストから削除', removeFromPlaylist: 'プレイリストから削除',
dislike: '嫌い', dislike: '嫌い',
undislike: '嫌い解除' undislike: '嫌い解除'
}, },
message: { message: {
downloading: 'ダウンロード中です。しばらくお待ちください...', downloading: 'ダウンロード中です。しばらくお待ちください...',
addToPlaylistNeedLogin: downloadFailed: 'ダウンロードに失敗しました',
'プレイリストに追加するには Cookie または QR コードでログインしてください(UID ログインでは利用できません)', downloadQueued: 'ダウンロードキューに追加しました',
downloadFailed: 'ダウンロードに失敗しました', addedToNextPlay: '次の再生に追加しました',
downloadQueued: 'ダウンロードキューに追加しました', getUrlFailed:
addedToNextPlay: '次の再生に追加しました', '音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください',
getUrlFailed: noLyric: 'この楽曲には歌詞がありません',
'音楽ダウンロードアドレスの取得に失敗しました。ログインしているか確認してください', lyricDownloaded: '歌詞のダウンロードが完了しました',
noLyric: 'この楽曲には歌詞がありません', lyricDownloadFailed: '歌詞のダウンロードに失敗しました'
lyricDownloaded: '歌詞のダウンロードが完了しました', },
lyricDownloadFailed: '歌詞のダウンロードに失敗しました' dialog: {
}, dislike: {
dialog: { title: 'お知らせ!',
dislike: { content: 'この楽曲を嫌いにしますか?再度アクセスすると毎日のおすすめから除外されます。',
title: 'お知らせ!', positiveText: '嫌い',
content: 'この楽曲を嫌いにしますか?再度アクセスすると毎日のおすすめから除外されます。', negativeText: 'キャンセル'
positiveText: '嫌い', }
negativeText: 'キャンセル' }
} };
}
};
+1 -3
View File
@@ -9,7 +9,5 @@ export default {
emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.', emptyState: '로컬 음악이 없습니다. 폴더를 선택하여 스캔하세요.',
fileNotFound: '파일을 찾을 수 없거나 이동되었습니다', fileNotFound: '파일을 찾을 수 없거나 이동되었습니다',
rescan: '다시 스캔', rescan: '다시 스캔',
songCount: '{count}곡', songCount: '{count}곡'
removeFromLibrary: '라이브러리에서 제거',
removedFromLibrary: '라이브러리에서 제거했습니다 (파일은 삭제되지 않음)'
}; };
-1
View File
@@ -59,7 +59,6 @@ export default {
eq: '이퀄라이저', eq: '이퀄라이저',
playList: '재생 목록', playList: '재생 목록',
reparse: '재분석', reparse: '재분석',
download: '다운로드',
playMode: { playMode: {
sequence: '순차 재생', sequence: '순차 재생',
loop: '반복 재생', loop: '반복 재생',
-1
View File
@@ -400,7 +400,6 @@ export default {
themeColor: { themeColor: {
title: '가사 테마 색상', title: '가사 테마 색상',
presetColors: '미리 설정된 색상', presetColors: '미리 설정된 색상',
reset: '기본값으로 복원',
customColor: '사용자 정의 색상', customColor: '사용자 정의 색상',
preview: '미리보기 효과', preview: '미리보기 효과',
previewText: '가사 효과', previewText: '가사 효과',
+32 -34
View File
@@ -1,34 +1,32 @@
export default { export default {
menu: { menu: {
play: '재생', play: '재생',
playNext: '다음에 재생', playNext: '다음에 재생',
download: '곡 다운로드', download: '곡 다운로드',
downloadLyric: '가사 다운로드', downloadLyric: '가사 다운로드',
addToPlaylist: '플레이리스트에 추가', addToPlaylist: '플레이리스트에 추가',
favorite: '좋아요', favorite: '좋아요',
unfavorite: '좋아요 취소', unfavorite: '좋아요 취소',
removeFromPlaylist: '플레이리스트에서 삭제', removeFromPlaylist: '플레이리스트에서 삭제',
dislike: '싫어요', dislike: '싫어요',
undislike: '싫어요 취소' undislike: '싫어요 취소'
}, },
message: { message: {
downloading: '다운로드 중입니다. 잠시 기다려주세요...', downloading: '다운로드 중입니다. 잠시 기다려주세요...',
addToPlaylistNeedLogin: downloadFailed: '다운로드 실패',
'플레이리스트에 추가하려면 Cookie 또는 QR 코드로 로그인하세요 (UID 로그인은 사용 불가)', downloadQueued: '다운로드 대기열에 추가됨',
downloadFailed: '다운로드 실패', addedToNextPlay: '다음 재생에 추가됨',
downloadQueued: '다운로드 대기열에 추가됨', getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요',
addedToNextPlay: '다음 재생에 추가됨', noLyric: '이 곡에는 가사가 없습니다',
getUrlFailed: '음악 다운로드 주소 가져오기 실패, 로그인 상태를 확인하세요', lyricDownloaded: '가사 다운로드 완료',
noLyric: '이 곡에는 가사가 없습니다', lyricDownloadFailed: '가사 다운로드 실패'
lyricDownloaded: '가사 다운로드 완료', },
lyricDownloadFailed: '가사 다운로드 실패' dialog: {
}, dislike: {
dialog: { title: '알림!',
dislike: { content: '이 곡을 싫어한다고 확인하시겠습니까? 다시 들어가면 일일 추천에서 제외됩니다.',
title: '알림!', positiveText: '싫어요',
content: '이 곡을 싫어한다고 확인하시겠습니까? 다시 들어가면 일일 추천에서 제외됩니다.', negativeText: '취소'
positiveText: '싫어요', }
negativeText: '취소' }
} };
}
};
+1 -3
View File
@@ -9,7 +9,5 @@ export default {
emptyState: '暂无本地音乐,请先选择文件夹进行扫描', emptyState: '暂无本地音乐,请先选择文件夹进行扫描',
fileNotFound: '文件不存在或已被移动', fileNotFound: '文件不存在或已被移动',
rescan: '重新扫描', rescan: '重新扫描',
songCount: '{count} 首歌曲', songCount: '{count} 首歌曲'
removeFromLibrary: '从本地列表移除',
removedFromLibrary: '已从本地列表移除(不删除文件)'
}; };
-1
View File
@@ -58,7 +58,6 @@ export default {
eq: '均衡器', eq: '均衡器',
playList: '播放列表', playList: '播放列表',
reparse: '重新解析', reparse: '重新解析',
download: '下载',
playMode: { playMode: {
sequence: '顺序播放', sequence: '顺序播放',
loop: '循环播放', loop: '循环播放',
+7 -8
View File
@@ -128,26 +128,26 @@ export default {
lxMusic: { lxMusic: {
tabs: { tabs: {
sources: '音源选择', sources: '音源选择',
lxMusic: '雪音源', lxMusic: '雪音源',
customApi: '自定义API' customApi: '自定义API'
}, },
scripts: { scripts: {
title: '已导入的音源脚本', title: '已导入的音源脚本',
importLocal: '本地导入', importLocal: '本地导入',
importOnline: '在线导入', importOnline: '在线导入',
urlPlaceholder: '输入雪音源脚本 URL', urlPlaceholder: '输入雪音源脚本 URL',
importBtn: '导入', importBtn: '导入',
empty: '暂无已导入的雪音源', empty: '暂无已导入的雪音源',
notConfigured: '未配置 (请去雪音源Tab配置)', notConfigured: '未配置 (请去雪音源Tab配置)',
importHint: '导入兼容的自定义 API 插件以扩展音源', importHint: '导入兼容的自定义 API 插件以扩展音源',
noScriptWarning: '请先导入雪音源脚本', noScriptWarning: '请先导入雪音源脚本',
noSelectionWarning: '请先选择一个雪音源', noSelectionWarning: '请先选择一个雪音源',
notFound: '音源不存在', notFound: '音源不存在',
switched: '已切换到音源: {name}', switched: '已切换到音源: {name}',
deleted: '已删除音源: {name}', deleted: '已删除音源: {name}',
enterUrl: '请输入脚本 URL', enterUrl: '请输入脚本 URL',
invalidUrl: '无效的 URL 格式', invalidUrl: '无效的 URL 格式',
invalidScript: '无效的雪音源脚本,未找到 globalThis.lx 相关代码', invalidScript: '无效的雪音源脚本,未找到 globalThis.lx 相关代码',
nameRequired: '名称不能为空', nameRequired: '名称不能为空',
renameSuccess: '重命名成功' renameSuccess: '重命名成功'
} }
@@ -396,7 +396,6 @@ export default {
themeColor: { themeColor: {
title: '歌词主题色', title: '歌词主题色',
presetColors: '预设颜色', presetColors: '预设颜色',
reset: '恢复默认',
customColor: '自定义颜色', customColor: '自定义颜色',
preview: '预览效果', preview: '预览效果',
previewText: '歌词效果', previewText: '歌词效果',
-1
View File
@@ -13,7 +13,6 @@ export default {
}, },
message: { message: {
downloading: '正在下载中,请稍候...', downloading: '正在下载中,请稍候...',
addToPlaylistNeedLogin: '请使用 Cookie 或扫码登录后再添加到歌单(UID 登录无法使用此功能)',
downloadFailed: '下载失败', downloadFailed: '下载失败',
downloadQueued: '已加入下载队列', downloadQueued: '已加入下载队列',
addedToNextPlay: '已添加到下一首播放', addedToNextPlay: '已添加到下一首播放',
+1 -3
View File
@@ -9,7 +9,5 @@ export default {
emptyState: '暫無本地音樂,請先選擇資料夾進行掃描', emptyState: '暫無本地音樂,請先選擇資料夾進行掃描',
fileNotFound: '檔案不存在或已被移動', fileNotFound: '檔案不存在或已被移動',
rescan: '重新掃描', rescan: '重新掃描',
songCount: '{count} 首歌曲', songCount: '{count} 首歌曲'
removeFromLibrary: '從本機清單移除',
removedFromLibrary: '已從本機清單移除(不刪除檔案)'
}; };
-1
View File
@@ -58,7 +58,6 @@ export default {
eq: '等化器', eq: '等化器',
playList: '播放清單', playList: '播放清單',
reparse: '重新解析', reparse: '重新解析',
download: '下載',
playMode: { playMode: {
sequence: '順序播放', sequence: '順序播放',
loop: '循環播放', loop: '循環播放',
+7 -8
View File
@@ -124,26 +124,26 @@ export default {
lxMusic: { lxMusic: {
tabs: { tabs: {
sources: '音源選擇', sources: '音源選擇',
lxMusic: '雪音源', lxMusic: '雪音源',
customApi: '自訂API' customApi: '自訂API'
}, },
scripts: { scripts: {
title: '已匯入的音源腳本', title: '已匯入的音源腳本',
importLocal: '本機匯入', importLocal: '本機匯入',
importOnline: '線上匯入', importOnline: '線上匯入',
urlPlaceholder: '輸入雪音源腳本 URL', urlPlaceholder: '輸入雪音源腳本 URL',
importBtn: '匯入', importBtn: '匯入',
empty: '暫無已匯入的雪音源', empty: '暫無已匯入的雪音源',
notConfigured: '未設定 (請至雪音源分頁設定)', notConfigured: '未設定 (請至雪音源分頁設定)',
importHint: '匯入相容的自訂 API 外掛以擴充音源', importHint: '匯入相容的自訂 API 外掛以擴充音源',
noScriptWarning: '請先匯入雪音源腳本', noScriptWarning: '請先匯入雪音源腳本',
noSelectionWarning: '請先選擇一個雪音源', noSelectionWarning: '請先選擇一個雪音源',
notFound: '音源不存在', notFound: '音源不存在',
switched: '已切換到音源: {name}', switched: '已切換到音源: {name}',
deleted: '已刪除音源: {name}', deleted: '已刪除音源: {name}',
enterUrl: '請輸入腳本 URL', enterUrl: '請輸入腳本 URL',
invalidUrl: '無效的 URL 格式', invalidUrl: '無效的 URL 格式',
invalidScript: '無效的雪音源腳本,未找到 globalThis.lx 相關程式碼', invalidScript: '無效的雪音源腳本,未找到 globalThis.lx 相關程式碼',
nameRequired: '名稱不能為空', nameRequired: '名稱不能為空',
renameSuccess: '重新命名成功' renameSuccess: '重新命名成功'
} }
@@ -387,7 +387,6 @@ export default {
themeColor: { themeColor: {
title: '歌詞主題色', title: '歌詞主題色',
presetColors: '預設顏色', presetColors: '預設顏色',
reset: '恢復預設',
customColor: '自訂顏色', customColor: '自訂顏色',
preview: '預覽效果', preview: '預覽效果',
previewText: '歌詞效果', previewText: '歌詞效果',
-1
View File
@@ -13,7 +13,6 @@ export default {
}, },
message: { message: {
downloading: '正在下載中,請稍候...', downloading: '正在下載中,請稍候...',
addToPlaylistNeedLogin: '請使用 Cookie 或掃碼登入後再新增至播放清單(UID 登入無法使用此功能)',
downloadFailed: '下載失敗', downloadFailed: '下載失敗',
downloadQueued: '已加入下載佇列', downloadQueued: '已加入下載佇列',
addedToNextPlay: '已新增至下一首播放', addedToNextPlay: '已新增至下一首播放',
+2 -6
View File
@@ -18,13 +18,9 @@ const mainI18n = {
}, },
t(key: string) { t(key: string) {
const keys = key.split('.'); const keys = key.split('.');
// 未知/非法 locale 时回退默认语言,避免 messages[locale] 为 undefined 导致崩溃 let current: any = messages[this.currentLocale];
let current: any = messages[this.currentLocale] ?? messages[DEFAULT_LANGUAGE as Language];
if (current == null) {
return key;
}
for (const k of keys) { for (const k of keys) {
if (current == null || current[k] === undefined) { if (current[k] === undefined) {
// 如果找不到翻译,返回键名 // 如果找不到翻译,返回键名
return key; return key;
} }
+3 -53
View File
@@ -1,43 +1,7 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'; import { electronApp, optimizer } from '@electron-toolkit/utils';
import { app, dialog, ipcMain, nativeImage, protocol, session } from 'electron'; import { app, ipcMain, nativeImage, session } from 'electron';
import { join } from 'path'; import { join } from 'path';
// 全局兜底(#714):Windows 上 config.json 等文件可能被杀毒/云同步软件短暂锁定,
// electron-store 读写撞锁会抛 EBUSY 等未捕获异常,Electron 默认弹出致命错误框。
// 对带 path 的文件系统锁类错误仅记录日志;其余异常保留报错弹窗以免掩盖真 bug。
const FILE_LOCK_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'EAGAIN', 'EMFILE', 'ENFILE']);
process.on('uncaughtException', (error: NodeJS.ErrnoException) => {
if (error?.code && FILE_LOCK_ERROR_CODES.has(error.code) && typeof error.path === 'string') {
console.error('[main] 文件被占用/锁定,已忽略本次读写:', error.message);
return;
}
console.error('[main] 未捕获异常:', error);
dialog.showErrorBox(
'A JavaScript error occurred in the main process',
error?.stack || String(error)
);
});
process.on('unhandledRejection', (reason) => {
console.error('[main] 未处理的 Promise 拒绝:', reason);
});
// 必须在 app.whenReady() 之前注册自定义协议为特权协议,
// 否则 http(s) 页面(dev server、生产环境的 file://)无法把 local:// 当成
// 安全/可 fetch/可流式的资源加载,会触发 CORS 拦截或 net::ERR_UNKNOWN_URL_SCHEME
protocol.registerSchemesAsPrivileged([
{
scheme: 'local',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
bypassCSP: true,
corsEnabled: true
}
}
]);
import type { Language } from '../i18n/main'; import type { Language } from '../i18n/main';
import i18n from '../i18n/main'; import i18n from '../i18n/main';
import { loadLyricWindow } from './lyric'; import { loadLyricWindow } from './lyric';
@@ -49,7 +13,6 @@ import { initializeFonts } from './modules/fonts';
import { initializeLocalMusicScanner } from './modules/localMusicScanner'; import { initializeLocalMusicScanner } from './modules/localMusicScanner';
import { initializeLoginWindow } from './modules/loginWindow'; import { initializeLoginWindow } from './modules/loginWindow';
import { initLxMusicHttp } from './modules/lxMusicHttp'; import { initLxMusicHttp } from './modules/lxMusicHttp';
import { initializeMpris, updateMprisCurrentSong, updateMprisPlayState } from './modules/mpris';
import { initializeOtherApi } from './modules/otherApi'; import { initializeOtherApi } from './modules/otherApi';
import { initializeRemoteControl } from './modules/remoteControl'; import { initializeRemoteControl } from './modules/remoteControl';
import { initializeShortcuts } from './modules/shortcuts'; import { initializeShortcuts } from './modules/shortcuts';
@@ -119,9 +82,6 @@ function initialize(configStore: any) {
// 初始化远程控制服务 // 初始化远程控制服务
initializeRemoteControl(mainWindow); initializeRemoteControl(mainWindow);
// 初始化 MPRIS 服务 (Linux)
initializeMpris(mainWindow);
// 初始化更新处理程序 // 初始化更新处理程序
setupUpdateHandlers(mainWindow); setupUpdateHandlers(mainWindow);
} }
@@ -132,11 +92,6 @@ const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) { if (!isSingleInstance) {
app.quit(); app.quit();
} else { } else {
// 禁用 Chromium 内置的 MediaSession MPRIS 服务,避免重复显示
if (process.platform === 'linux') {
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
}
// 在应用准备就绪前初始化GPU加速设置 // 在应用准备就绪前初始化GPU加速设置
// 必须在 app.ready 之前调用 disableHardwareAcceleration // 必须在 app.ready 之前调用 disableHardwareAcceleration
try { try {
@@ -178,18 +133,15 @@ if (!isSingleInstance) {
// 初始化窗口大小管理器 // 初始化窗口大小管理器
initWindowSizeManager(); initWindowSizeManager();
// 媒体设备权限:应用没有任何录音功能,麦克风/摄像头采集一律拒绝, // 设置媒体设备权限 - 允许枚举音频输出设备
// 防止依赖库静默调用 getUserMedia 触发系统麦克风授权弹窗(#147/#246/#440/#639 防御性加固)。
// 输出设备切换走 speaker-selection / enumerateDevices,不受影响
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === ('media' as any) || permission === ('audioCapture' as any)) { if (permission === ('media' as any) || permission === ('audioCapture' as any)) {
callback(false); callback(true);
return; return;
} }
callback(true); callback(true);
}); });
// 保持放行:enumerateDevices 依赖它返回真实设备名(不访问麦克风硬件、不触发系统授权)
session.defaultSession.setPermissionCheckHandler(() => { session.defaultSession.setPermissionCheckHandler(() => {
return true; return true;
}); });
@@ -219,13 +171,11 @@ if (!isSingleInstance) {
// 监听播放状态变化 // 监听播放状态变化
ipcMain.on('update-play-state', (_, playing: boolean) => { ipcMain.on('update-play-state', (_, playing: boolean) => {
updatePlayState(playing); updatePlayState(playing);
updateMprisPlayState(playing);
}); });
// 监听当前歌曲变化 // 监听当前歌曲变化
ipcMain.on('update-current-song', (_, song: any) => { ipcMain.on('update-current-song', (_, song: any) => {
updateCurrentSong(song); updateCurrentSong(song);
updateMprisCurrentSong(song);
}); });
// 所有窗口关闭时的处理 // 所有窗口关闭时的处理
+6 -114
View File
@@ -1,92 +1,15 @@
import { BrowserWindow, IpcMain, screen } from 'electron'; import { BrowserWindow, IpcMain, screen } from 'electron';
import Store from 'electron-store';
import path, { join } from 'path'; import path, { join } from 'path';
import { getSharedStore } from './modules/config'; const store = new Store();
const store = getSharedStore();
let lyricWindow: BrowserWindow | null = null; let lyricWindow: BrowserWindow | null = null;
// 歌词窗口 bounds 防抖保存:拖动/缩放时高频触发,
// 直接写盘会加剧 config.json 文件争用(#714 EBUSY
let lyricBoundsSaveTimer: ReturnType<typeof setTimeout> | null = null;
const saveLyricWindowBounds = (bounds: Record<string, number>) => {
if (lyricBoundsSaveTimer) {
clearTimeout(lyricBoundsSaveTimer);
}
lyricBoundsSaveTimer = setTimeout(() => {
lyricBoundsSaveTimer = null;
try {
store.set('lyricWindowBounds', bounds);
} catch (error) {
console.error('保存歌词窗口位置失败:', error);
}
}, 500);
};
// 跟踪拖动状态 // 跟踪拖动状态
let isDragging = false; let isDragging = false;
// 添加窗口大小变化防护 // 添加窗口大小变化防护
let originalSize = { width: 0, height: 0 }; let originalSize = { width: 0, height: 0 };
// 鼠标位置轮询仅在"锁定 + 可见"时启用,解锁态下 DOM 事件已足够
let mousePresenceTimer: ReturnType<typeof setInterval> | null = null;
let lastMouseInside: boolean | null = null;
let isLyricLocked = false;
let isLyricWindowVisible = false;
const isPointInsideWindow = (
point: { x: number; y: number },
bounds: { x: number; y: number; width: number; height: number }
) => {
return (
point.x >= bounds.x &&
point.x < bounds.x + bounds.width &&
point.y >= bounds.y &&
point.y < bounds.y + bounds.height
);
};
const stopMousePresenceTracking = () => {
if (mousePresenceTimer) {
clearInterval(mousePresenceTimer);
mousePresenceTimer = null;
}
lastMouseInside = null;
};
const emitMousePresence = () => {
if (!lyricWindow || lyricWindow.isDestroyed()) return;
const mousePoint = screen.getCursorScreenPoint();
const bounds = lyricWindow.getBounds();
const isInside = isPointInsideWindow(mousePoint, bounds);
if (isInside === lastMouseInside) return;
lastMouseInside = isInside;
lyricWindow.webContents.send('lyric-mouse-presence', isInside);
};
const startMousePresenceTracking = () => {
if (mousePresenceTimer) return;
emitMousePresence();
mousePresenceTimer = setInterval(() => {
if (!lyricWindow || lyricWindow.isDestroyed()) {
stopMousePresenceTracking();
return;
}
emitMousePresence();
}, 50);
};
const syncMousePresenceTracking = () => {
if (isLyricLocked && isLyricWindowVisible && lyricWindow && !lyricWindow.isDestroyed()) {
startMousePresenceTracking();
} else {
stopMousePresenceTracking();
}
};
const createWin = () => { const createWin = () => {
console.log('Creating lyric window'); console.log('Creating lyric window');
@@ -179,32 +102,12 @@ const createWin = () => {
// 监听窗口关闭事件 // 监听窗口关闭事件
lyricWindow.on('closed', () => { lyricWindow.on('closed', () => {
stopMousePresenceTracking();
isLyricLocked = false;
isLyricWindowVisible = false;
if (lyricWindow) { if (lyricWindow) {
lyricWindow.destroy(); lyricWindow.destroy();
lyricWindow = null; lyricWindow = null;
} }
}); });
lyricWindow.on('show', () => {
isLyricWindowVisible = true;
syncMousePresenceTracking();
});
lyricWindow.on('hide', () => {
isLyricWindowVisible = false;
stopMousePresenceTracking();
});
lyricWindow.on('minimize', () => {
isLyricWindowVisible = false;
stopMousePresenceTracking();
});
lyricWindow.on('restore', () => {
isLyricWindowVisible = true;
syncMousePresenceTracking();
});
// 监听窗口大小变化事件,保存新的尺寸 // 监听窗口大小变化事件,保存新的尺寸
lyricWindow.on('resize', () => { lyricWindow.on('resize', () => {
// 如果正在拖动,忽略大小调整事件 // 如果正在拖动,忽略大小调整事件
@@ -214,8 +117,8 @@ const createWin = () => {
const [width, height] = lyricWindow.getSize(); const [width, height] = lyricWindow.getSize();
const [x, y] = lyricWindow.getPosition(); const [x, y] = lyricWindow.getPosition();
// 保存窗口位置和大小(防抖) // 保存窗口位置和大小
saveLyricWindowBounds({ x, y, width, height }); store.set('lyricWindowBounds', { x, y, width, height });
} }
}); });
@@ -302,17 +205,6 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
} }
}); });
ipcMain.on('set-lyric-lock-state', (_, isLocked: boolean) => {
isLyricLocked = isLocked;
if (lyricWindow && !lyricWindow.isDestroyed()) {
// 锁定时禁用 resize,避免鼠标移到边缘仍显示调整光标
lyricWindow.setResizable(!isLocked);
// 设置初始穿透状态,后续 polling 会按实际位置纠正
lyricWindow.setIgnoreMouseEvents(isLocked, { forward: true });
}
syncMousePresenceTracking();
});
// 处理鼠标事件 // 处理鼠标事件
ipcMain.on('mouseenter-lyric', () => { ipcMain.on('mouseenter-lyric', () => {
if (lyricWindow && !lyricWindow.isDestroyed()) { if (lyricWindow && !lyricWindow.isDestroyed()) {
@@ -375,7 +267,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
false false
); );
// 更新存储的位置(防抖,拖动结束后统一落盘) // 更新存储的位置
const windowBounds = { const windowBounds = {
x: newX, x: newX,
y: newY, y: newY,
@@ -383,7 +275,7 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
height: windowHeight, height: windowHeight,
displayId: currentDisplay.id // 记录当前显示器ID,有助于多屏幕处理 displayId: currentDisplay.id // 记录当前显示器ID,有助于多屏幕处理
}; };
saveLyricWindowBounds(windowBounds); store.set('lyricWindowBounds', windowBounds);
} catch (error) { } catch (error) {
console.error('Error during window drag:', error); console.error('Error during window drag:', error);
// 出错时尝试使用更简单的方法 // 出错时尝试使用更简单的方法
+3 -5
View File
@@ -6,7 +6,6 @@ import Store from 'electron-store';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { filePathToLocalUrl } from '../../shared/localUrl';
import { getStore } from './config'; import { getStore } from './config';
type CacheCleanupPolicy = 'lru' | 'fifo'; type CacheCleanupPolicy = 'lru' | 'fifo';
@@ -413,9 +412,7 @@ class DiskCacheManager {
cleanupPolicy cleanupPolicy
}; };
// 注意:getCacheConfig 是纯读取,处于播放/下载/歌词等多个热路径。 this.saveConfig(normalizedConfig);
// 此处不再落盘(electron-store.set 每次整文件写 config.json,会造成写放大与文件争用),
// 持久化交由 updateCacheConfig / setCacheDirectory 等真正的写操作完成。
return normalizedConfig; return normalizedConfig;
} }
@@ -538,7 +535,8 @@ class DiskCacheManager {
} }
private toLocalUrl(filePath: string): string { private toLocalUrl(filePath: string): string {
return filePathToLocalUrl(path.normalize(filePath)); const normalized = path.normalize(filePath).replace(/\\/g, '/');
return `local:///${encodeURIComponent(normalized)}`;
} }
private isRemoteAudioUrl(url: string): boolean { private isRemoteAudioUrl(url: string): boolean {
+24 -54
View File
@@ -38,63 +38,41 @@ interface StoreType {
shortcuts: ShortcutsConfig; shortcuts: ShortcutsConfig;
} }
// 模块级单例:主进程所有模块共享同一个 config.json Store 实例。 let store: Store<StoreType>;
// 多个独立 Store 实例并发读写同一文件,会在 Windows 上与杀毒/云同步的文件锁
// 叠加触发 EBUSY 未捕获异常(#714)
const store = new Store<StoreType>({
name: 'config',
defaults: {
set: set as SetConfig,
shortcuts: createDefaultShortcuts()
}
});
let initialized = false;
/** /**
* 初始化配置管理(幂等:重复调用不会重复注册 IPC 监听) * 初始化配置管理
*/ */
export function initializeConfig() { export function initializeConfig() {
if (initialized) { store = new Store<StoreType>({
return store; name: 'config',
} defaults: {
initialized = true; set: set as SetConfig,
shortcuts: createDefaultShortcuts()
}
});
try { store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads'));
store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads')); store.get('set.diskCacheDir') ||
store.get('set.diskCacheDir') || store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache'));
store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache')); if (store.get('set.diskCacheMaxSizeMB') === undefined) {
if (store.get('set.diskCacheMaxSizeMB') === undefined) { store.set('set.diskCacheMaxSizeMB', 4096);
store.set('set.diskCacheMaxSizeMB', 4096); }
} if (!store.get('set.diskCacheCleanupPolicy')) {
if (!store.get('set.diskCacheCleanupPolicy')) { store.set('set.diskCacheCleanupPolicy', 'lru');
store.set('set.diskCacheCleanupPolicy', 'lru'); }
} if (store.get('set.enableDiskCache') === undefined) {
if (store.get('set.enableDiskCache') === undefined) { store.set('set.enableDiskCache', true);
store.set('set.enableDiskCache', true);
}
} catch (error) {
console.error('[config] 初始化默认配置失败:', error);
} }
// 定义ipcRenderer监听事件 // 定义ipcRenderer监听事件
ipcMain.on('set-store-value', (_, key, value) => { ipcMain.on('set-store-value', (_, key, value) => {
try { store.set(key, value);
store.set(key, value);
} catch (error) {
// config.json 可能被杀毒/云同步短暂锁定,丢一次写入无害,避免主进程崩溃
console.error(`[config] 写入配置失败 key=${key}:`, error);
}
}); });
ipcMain.on('get-store-value', (event, key) => { ipcMain.on('get-store-value', (_, key) => {
try { const value = store.get(key);
const value = store.get(key); _.returnValue = value || '';
event.returnValue = value || '';
} catch (error) {
console.error(`[config] 读取配置失败 key=${key}:`, error);
event.returnValue = '';
}
}); });
// GPU加速设置更新处理 // GPU加速设置更新处理
@@ -120,11 +98,3 @@ export function initializeConfig() {
export function getStore() { export function getStore() {
return store; return store;
} }
/**
* 供主进程其他模块共享同一个 config.json Store 实例
* (不要再 new Store(),多实例并发读写是 #714 EBUSY 崩溃的诱因之一)
*/
export function getSharedStore(): Store<Record<string, unknown>> {
return store as unknown as Store<Record<string, unknown>>;
}
+2 -3
View File
@@ -1,10 +1,9 @@
import { app } from 'electron'; import { app } from 'electron';
import Store from 'electron-store';
import { machineIdSync } from 'node-machine-id'; import { machineIdSync } from 'node-machine-id';
import os from 'os'; import os from 'os';
import { getSharedStore } from './config'; const store = new Store();
const store = getSharedStore();
/** /**
* 获取设备唯一标识符 * 获取设备唯一标识符
+2 -8
View File
@@ -484,17 +484,13 @@ class DownloadManager {
} }
// Start download // Start download
// 注意:axios 默认只接受 2xx403/410 会直接抛错进入 catch,导致下方"直链过期重新解析"
// 分支永远走不到。这里放行 403/410,让过期直链能触发重新解析(尤其是重启后恢复队列时)。
const response = await axios({ const response = await axios({
url: task.url, url: task.url,
method: 'GET', method: 'GET',
responseType: 'stream', responseType: 'stream',
timeout: 30000, timeout: 30000,
signal: controller.signal, signal: controller.signal,
headers, headers
validateStatus: (status) =>
(status >= 200 && status < 300) || status === 403 || status === 410
}); });
// Handle response status // Handle response status
@@ -502,8 +498,6 @@ class DownloadManager {
if (status === 403 || status === 410) { if (status === 403 || status === 410) {
// URL expired, request re-resolution from renderer // URL expired, request re-resolution from renderer
// 排空未消费的错误响应流,避免连接悬挂
response.data?.destroy?.();
this.sendToRenderer('download:request-url', { this.sendToRenderer('download:request-url', {
taskId: task.taskId, taskId: task.taskId,
songInfo: task.songInfo songInfo: task.songInfo
@@ -529,7 +523,7 @@ class DownloadManager {
} else { } else {
// Full response (200) - start from beginning // Full response (200) - start from beginning
task.loaded = 0; task.loaded = 0;
const contentLength = response.headers['content-length'] as string; const contentLength = response.headers['content-length'];
task.total = contentLength ? parseInt(contentLength, 10) : 0; task.total = contentLength ? parseInt(contentLength, 10) : 0;
} }
+12 -56
View File
@@ -2,7 +2,6 @@ import { app, dialog, ipcMain, protocol, shell } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { Readable } from 'stream';
import { getStore } from './config'; import { getStore } from './config';
@@ -24,79 +23,36 @@ function sanitizeFilename(filename: string): string {
.trim(); .trim();
} }
// Electron net.fetch(file://) 在当前版本不会回 206audio seek 需要主进程自己处理 Range
function buildLocalFileResponse(
filePath: string,
total: number,
rangeHeader: string | null
): Response {
const range416 = () =>
new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
let start = 0;
let end = total - 1;
let partial = false;
if (rangeHeader) {
const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!m || (!m[1] && !m[2])) return range416();
if (m[1]) {
start = parseInt(m[1], 10);
if (m[2]) end = Math.min(parseInt(m[2], 10), end);
} else {
start = Math.max(0, total - parseInt(m[2], 10));
}
if (start > end || start >= total) return range416();
partial = true;
}
return new Response(
Readable.toWeb(fs.createReadStream(filePath, { start, end })) as ReadableStream,
{
status: partial ? 206 : 200,
headers: {
'Content-Length': String(end - start + 1),
'Accept-Ranges': 'bytes',
...(partial && { 'Content-Range': `bytes ${start}-${end}/${total}` })
}
}
);
}
/** /**
* 初始化文件管理相关的IPC监听 * 初始化文件管理相关的IPC监听
*/ */
export function initializeFileManager() { export function initializeFileManager() {
// 注册本地文件协议 // 注册本地文件协议
// Electron 25+ 起 registerFileProtocol 已弃用,改用 protocol.handle,并配合 main/index.ts protocol.registerFileProtocol('local', (request, callback) => {
// 中的 registerSchemesAsPrivileged,让 audio 元素能从 http(s) 页面跨协议加载本地文件
protocol.handle('local', async (request) => {
try { try {
// local:///<absolute-path> const url = request.url;
let filePath = decodeURIComponent(request.url.replace(/^local:\/\/\/?/, '')); // local://C:/Users/xxx.mp3
let filePath = decodeURIComponent(url.replace('local:///', ''));
// Windows: 协议解析后可能是 /C:/...,去掉前导斜杠 // 兼容 local:///C:/Users/xxx.mp3 这种情况
if (/^\/[a-zA-Z]:\//.test(filePath)) { if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1); filePath = filePath.slice(1);
} }
// macOS/Linux 上去掉前导斜杠后会丢失绝对路径标识,这里补回 // 还原为系统路径格式
if (process.platform !== 'win32' && !filePath.startsWith('/')) {
filePath = '/' + filePath;
}
filePath = path.normalize(filePath); filePath = path.normalize(filePath);
const stat = await fs.promises.stat(filePath).catch(() => null); // 检查文件是否存在
if (!stat?.isFile()) { if (!fs.existsSync(filePath)) {
console.error('File not found:', filePath); console.error('File not found:', filePath);
return new Response(null, { status: 404 }); callback({ error: -6 }); // net::ERR_FILE_NOT_FOUND
return;
} }
return buildLocalFileResponse(filePath, stat.size, request.headers.get('range')); callback({ path: filePath });
} catch (error) { } catch (error) {
console.error('Error handling local protocol:', error); console.error('Error handling local protocol:', error);
return new Response(null, { status: 500 }); callback({ error: -2 }); // net::FAILED
} }
}); });
+13 -51
View File
@@ -1,8 +1,7 @@
// 本地音乐扫描模块 // 本地音乐扫描模块
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程 // 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
import * as crypto from 'crypto'; import { ipcMain } from 'electron';
import { app, ipcMain } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as mm from 'music-metadata'; import * as mm from 'music-metadata';
import * as os from 'os'; import * as os from 'os';
@@ -11,30 +10,7 @@ import * as path from 'path';
/** 支持的音频文件格式 */ /** 支持的音频文件格式 */
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const; const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
const METADATA_PARSE_CONCURRENCY = Math.min(8, Math.max(2, os.cpus().length)); const METADATA_PARSE_CONCURRENCY = Math.min(8, Math.max(2, os.cpus().length));
const MAX_COVER_BYTES = 8 * 1024 * 1024; const MAX_COVER_BYTES = 1024 * 1024;
/** 封面缓存目录:userData/AudioCovers/<hash>.<ext> */
const COVER_DIR_NAME = 'AudioCovers';
let cachedCoverDir: string | null = null;
function getCoverDir(): string {
if (cachedCoverDir) return cachedCoverDir;
const dir = path.join(app.getPath('userData'), COVER_DIR_NAME);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (error) {
console.error('创建封面目录失败:', error);
}
cachedCoverDir = dir;
return dir;
}
/** 从 mime 类型推断文件扩展名 */
function extFromMime(mime: string | undefined): string {
const sub = mime?.split('/')[1]?.split(';')[0]?.trim().toLowerCase();
if (!sub) return 'bin';
return sub === 'jpeg' ? 'jpg' : sub;
}
/** /**
* 主进程返回的原始音乐元数据 * 主进程返回的原始音乐元数据
@@ -51,8 +27,8 @@ type LocalMusicMeta = {
album: string; album: string;
/** 时长(毫秒) */ /** 时长(毫秒) */
duration: number; duration: number;
/** 封面图片缓存文件绝对路径,无封面时为 null */ /** base64 Data URL 格式的封面图片,无封面时为 null */
coverPath: string | null; cover: string | null;
/** LRC 格式歌词文本,无歌词时为 null */ /** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null; lyrics: string | null;
/** 文件大小(字节) */ /** 文件大小(字节) */
@@ -90,37 +66,23 @@ function extractTitleFromFilename(filePath: string): string {
} }
/** /**
* 将封面图片落盘到 userData/AudioCovers/,返回绝对路径 * 将封面图片数据转换为 base64 Data URL
* 文件名按 sourceFilePath 的 sha256 + 推断扩展名拼成,幂等可覆盖
* @param picture music-metadata 解析出的封面图片对象 * @param picture music-metadata 解析出的封面图片对象
* @param sourceFilePath 音乐源文件绝对路径,用于生成稳定的封面文件名 * @returns base64 Data URL 字符串,转换失败返回 null
* @returns 封面文件绝对路径,无封面或写入失败返回 null
*/ */
async function extractCoverToFile( function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
picture: mm.IPicture | undefined,
sourceFilePath: string
): Promise<string | null> {
if (!picture) { if (!picture) {
return null; return null;
} }
try { try {
if (picture.data.length > MAX_COVER_BYTES) { if (picture.data.length > MAX_COVER_BYTES) {
console.warn(
`封面超过大小上限被跳过: ${sourceFilePath} (${picture.data.length} bytes > ${MAX_COVER_BYTES})`
);
return null; return null;
} }
const ext = extFromMime(picture.format); const mime = picture.format ?? 'image/jpeg';
const hash = crypto.createHash('sha256').update(sourceFilePath).digest('hex'); const base64 = Buffer.from(picture.data).toString('base64');
const coverFile = path.join(getCoverDir(), `${hash}.${ext}`); return `data:${mime};base64,${base64}`;
// 直接覆盖写:本函数只在文件 mtime 变更时被调用(见 scanFolders 的 parseTargets),
// 频率本就受守门;按 size 跳过会在"用户替换内嵌封面、新旧字节数恰好相等"时留旧图,
// 单张封面几十~几百 KB,覆盖代价可忽略。
await fs.promises.writeFile(coverFile, Buffer.from(picture.data));
return coverFile;
} catch (error) { } catch (error) {
console.error('封面落盘失败:', error); console.error('封面提取失败:', error);
return null; return null;
} }
} }
@@ -272,7 +234,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: '未知艺术家', artist: '未知艺术家',
album: '未知专辑', album: '未知专辑',
duration: 0, duration: 0,
coverPath: null, cover: null,
lyrics: null, lyrics: null,
fileSize, fileSize,
modifiedTime modifiedTime
@@ -288,7 +250,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: common.artist || fallback.artist, artist: common.artist || fallback.artist,
album: common.album || fallback.album, album: common.album || fallback.album,
duration: format.duration ? Math.round(format.duration * 1000) : 0, duration: format.duration ? Math.round(format.duration * 1000) : 0,
coverPath: await extractCoverToFile(common.picture?.[0], filePath), cover: extractCoverAsDataUrl(common.picture?.[0]),
lyrics: extractLyrics(common.lyrics), lyrics: extractLyrics(common.lyrics),
fileSize, fileSize,
modifiedTime modifiedTime
+2 -2
View File
@@ -6,6 +6,7 @@ import i18n from '../../i18n/main';
let loginWindow: BrowserWindow | null = null; let loginWindow: BrowserWindow | null = null;
const loginUrl = 'https://music.163.com/#/login/'; const loginUrl = 'https://music.163.com/#/login/';
const loginTitle = i18n.global.t('login.qrTitle');
/** /**
* Cookie * Cookie
@@ -28,8 +29,7 @@ const openLoginWindow = async (mainWin: BrowserWindow) => {
loginWindow = new BrowserWindow({ loginWindow = new BrowserWindow({
parent: mainWin, parent: mainWin,
// 在打开窗口时求值,确保跟随当前语言(模块加载时 i18n locale 可能尚未设置) title: loginTitle,
title: i18n.global.t('login.qrTitle'),
width: 1280, width: 1280,
height: 800, height: 800,
center: true, center: true,
+2 -8
View File
@@ -42,8 +42,6 @@ export const initLxMusicHttp = () => {
// 保存取消控制器 // 保存取消控制器
abortControllers.set(requestId, controller); abortControllers.set(requestId, controller);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try { try {
console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`); console.log(`[LxMusicHttp] 请求: ${options.method || 'GET'} ${url}`);
@@ -79,14 +77,13 @@ export const initLxMusicHttp = () => {
// 设置超时 // 设置超时
const timeout = options.timeout || 30000; const timeout = options.timeout || 30000;
timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
console.warn(`[LxMusicHttp] 请求超时: ${url}`); console.warn(`[LxMusicHttp] 请求超时: ${url}`);
controller.abort(); controller.abort();
}, timeout); }, timeout);
const response = await fetch(url, fetchOptions); const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = null;
console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`); console.log(`[LxMusicHttp] 响应: ${response.status} ${url}`);
@@ -125,10 +122,7 @@ export const initLxMusicHttp = () => {
console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message); console.error(`[LxMusicHttp] 请求失败: ${url}`, error.message);
throw error; throw error;
} finally { } finally {
// 清理超时定时器(fetch 出错时前面来不及 clear)与取消控制器 // 清理取消控制器
if (timeoutId) {
clearTimeout(timeoutId);
}
abortControllers.delete(requestId); abortControllers.delete(requestId);
} }
} }
-270
View File
@@ -1,270 +0,0 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import Player from 'mpris-service';
let dbusModule: any;
try {
dbusModule = require('@httptoolkit/dbus-native');
} catch {
// dbus-native 不可用(非 Linux 环境)
}
interface SongInfo {
id?: number | string;
name: string;
picUrl?: string;
ar?: Array<{ name: string }>;
artists?: Array<{ name: string }>;
al?: { name: string };
album?: { name: string };
duration?: number;
dt?: number;
song?: {
artists?: Array<{ name: string }>;
album?: { name: string };
duration?: number;
picUrl?: string;
};
[key: string]: any;
}
let mprisPlayer: Player | null = null;
let mainWindow: BrowserWindow | null = null;
let currentPosition = 0;
let trayLyricIface: any = null;
let trayLyricBus: any = null;
// 保存 IPC 处理函数引用,用于清理
let onPositionUpdate: ((event: any, position: number) => void) | null = null;
let onTrayLyricUpdate: ((event: any, lrcObj: string) => void) | null = null;
export function initializeMpris(mainWindowRef: BrowserWindow) {
if (process.platform !== 'linux') return;
if (mprisPlayer) {
return;
}
mainWindow = mainWindowRef;
try {
mprisPlayer = Player({
name: 'AlgerMusicPlayer',
identity: 'Alger Music Player',
supportedUriSchemes: ['file', 'http', 'https'],
supportedMimeTypes: [
'audio/mpeg',
'audio/mp3',
'audio/flac',
'audio/wav',
'audio/ogg',
'audio/aac',
'audio/m4a'
],
supportedInterfaces: ['player']
});
mprisPlayer.on('quit', () => {
app.quit();
});
mprisPlayer.on('raise', () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
mprisPlayer.on('next', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'nextPlay');
}
});
mprisPlayer.on('previous', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'prevPlay');
}
});
mprisPlayer.on('pause', () => {
if (mainWindow) {
mainWindow.webContents.send('mpris-pause');
}
});
mprisPlayer.on('play', () => {
if (mainWindow) {
mainWindow.webContents.send('mpris-play');
}
});
mprisPlayer.on('playpause', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
});
mprisPlayer.on('stop', () => {
if (mainWindow) {
mainWindow.webContents.send('mpris-pause');
}
});
mprisPlayer.getPosition = (): number => {
return currentPosition;
};
mprisPlayer.on('seek', (offset: number) => {
if (mainWindow) {
const newPosition = Math.max(0, currentPosition + offset / 1000000);
mainWindow.webContents.send('mpris-seek', newPosition);
}
});
mprisPlayer.on('position', (event: { trackId: string; position: number }) => {
if (mainWindow) {
mainWindow.webContents.send('mpris-set-position', event.position / 1000000);
}
});
onPositionUpdate = (_, position: number) => {
currentPosition = position * 1000 * 1000;
if (mprisPlayer) {
mprisPlayer.seeked(position * 1000 * 1000);
mprisPlayer.getPosition = () => position * 1000 * 1000;
mprisPlayer.position = position * 1000 * 1000;
}
};
ipcMain.on('mpris-position-update', onPositionUpdate);
onTrayLyricUpdate = (_, lrcObj: string) => {
sendTrayLyric(lrcObj);
};
ipcMain.on('tray-lyric-update', onTrayLyricUpdate);
initTrayLyric();
console.log('[MPRIS] Service initialized');
} catch (error) {
console.error('[MPRIS] Failed to initialize:', error);
}
}
export function updateMprisPlayState(playing: boolean) {
if (!mprisPlayer || process.platform !== 'linux') return;
mprisPlayer.playbackStatus = playing ? 'Playing' : 'Paused';
}
export function updateMprisCurrentSong(song: SongInfo | null) {
if (!mprisPlayer || process.platform !== 'linux') return;
if (!song) {
mprisPlayer.metadata = {};
mprisPlayer.playbackStatus = 'Stopped';
return;
}
const artists =
song.ar?.map((a) => a.name).join(', ') ||
song.artists?.map((a) => a.name).join(', ') ||
song.song?.artists?.map((a) => a.name).join(', ') ||
'';
const album = song.al?.name || song.album?.name || song.song?.album?.name || '';
const duration = song.duration || song.dt || song.song?.duration || 0;
mprisPlayer.metadata = {
'mpris:trackid': mprisPlayer.objectPath(`track/${song.id || 0}`),
'mpris:length': duration * 1000,
'mpris:artUrl': song.picUrl || '',
'xesam:title': song.name || '',
'xesam:album': album,
'xesam:artist': artists ? [artists] : []
};
}
export function updateMprisPosition(position: number) {
if (!mprisPlayer || process.platform !== 'linux') return;
mprisPlayer.seeked(position * 1000000);
}
export function destroyMpris() {
if (onPositionUpdate) {
ipcMain.removeListener('mpris-position-update', onPositionUpdate);
onPositionUpdate = null;
}
if (onTrayLyricUpdate) {
ipcMain.removeListener('tray-lyric-update', onTrayLyricUpdate);
onTrayLyricUpdate = null;
}
if (mprisPlayer) {
mprisPlayer.quit();
mprisPlayer = null;
}
}
function initTrayLyric() {
if (process.platform !== 'linux' || !dbusModule) return;
const serviceName = 'org.gnome.Shell.TrayLyric';
try {
const sessionBus = dbusModule.sessionBus({});
trayLyricBus = sessionBus;
const dbusPath = '/org/freedesktop/DBus';
const dbusInterface = 'org.freedesktop.DBus';
sessionBus.invoke(
{
path: dbusPath,
interface: dbusInterface,
member: 'GetNameOwner',
destination: 'org.freedesktop.DBus',
signature: 's',
body: [serviceName]
},
(err: any, result: any) => {
if (err || !result) {
console.log('[TrayLyric] Service not running');
} else {
onServiceAvailable();
}
}
);
} catch (err) {
console.error('[TrayLyric] Failed to init:', err);
}
function onServiceAvailable() {
if (!trayLyricBus) return;
const path = '/' + serviceName.replace(/\./g, '/');
trayLyricBus.getService(serviceName).getInterface(path, serviceName, (err: any, iface: any) => {
if (err) {
console.error('[TrayLyric] Failed to get service interface:', err);
return;
}
trayLyricIface = iface;
console.log('[TrayLyric] Service interface ready');
});
}
}
function sendTrayLyric(lrcObj: string) {
if (!trayLyricIface || !trayLyricBus) return;
trayLyricBus.invoke(
{
path: '/org/gnome/Shell/TrayLyric',
interface: 'org.gnome.Shell.TrayLyric',
member: 'UpdateLyric',
destination: 'org.gnome.Shell.TrayLyric',
signature: 's',
body: [lrcObj]
},
(err: any, _result: any) => {
if (err) {
console.error('[TrayLyric] Failed to invoke UpdateLyric:', err);
}
}
);
}
+13 -54
View File
@@ -1,9 +1,7 @@
import { app, BrowserWindow, ipcMain, screen } from 'electron'; import { app, BrowserWindow, ipcMain, screen } from 'electron';
import type Store from 'electron-store'; import Store from 'electron-store';
import { getSharedStore } from './config'; const store = new Store();
const store = getSharedStore();
// 默认窗口尺寸 // 默认窗口尺寸
export const DEFAULT_MAIN_WIDTH = 1200; export const DEFAULT_MAIN_WIDTH = 1200;
@@ -40,40 +38,16 @@ export interface WindowState {
* *
*/ */
class WindowSizeManager { class WindowSizeManager {
private store: Store<Record<string, unknown>>; private store: Store;
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
private savedState: WindowState | null = null; private savedState: WindowState | null = null;
private isInitialized: boolean = false; private isInitialized: boolean = false;
private saveStateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
this.store = store; this.store = store;
// 初始化时不做与screen相关的操作,等app ready后再初始化 // 初始化时不做与screen相关的操作,等app ready后再初始化
} }
/**
* move/resize
* config.json #714 EBUSY
*/
private scheduleSaveWindowState(win: BrowserWindow): void {
if (this.saveStateDebounceTimer) {
clearTimeout(this.saveStateDebounceTimer);
}
this.saveStateDebounceTimer = setTimeout(() => {
this.saveStateDebounceTimer = null;
if (!win.isDestroyed() && !win.isMinimized()) {
this.saveWindowState(win);
}
}, 500);
}
private flushScheduledSave(): void {
if (this.saveStateDebounceTimer) {
clearTimeout(this.saveStateDebounceTimer);
this.saveStateDebounceTimer = null;
}
}
/** /**
* *
* app ready后调用 * app ready后调用
@@ -143,17 +117,17 @@ class WindowSizeManager {
* *
*/ */
private setupEventListeners(win: BrowserWindow): void { private setupEventListeners(win: BrowserWindow): void {
// 监听窗口大小调整事件(防抖,拖动结束后统一落盘) // 监听窗口大小调整事件
win.on('resize', () => { win.on('resize', () => {
if (!win.isDestroyed() && !win.isMinimized()) { if (!win.isDestroyed() && !win.isMinimized()) {
this.scheduleSaveWindowState(win); this.saveWindowState(win);
} }
}); });
// 监听窗口移动事件(防抖,拖动结束后统一落盘) // 监听窗口移动事件
win.on('move', () => { win.on('move', () => {
if (!win.isDestroyed() && !win.isMinimized()) { if (!win.isDestroyed() && !win.isMinimized()) {
this.scheduleSaveWindowState(win); this.saveWindowState(win);
} }
}); });
@@ -171,9 +145,8 @@ class WindowSizeManager {
} }
}); });
// 监听窗口关闭事件,确保保存最终状态(取消挂起的防抖,立即落盘) // 监听窗口关闭事件,确保保存最终状态
win.on('close', () => { win.on('close', () => {
this.flushScheduledSave();
if (!win.isDestroyed()) { if (!win.isDestroyed()) {
this.saveWindowState(win); this.saveWindowState(win);
} }
@@ -381,12 +354,8 @@ class WindowSizeManager {
} }
// 检查是否是mini模式窗口(根据窗口大小判断) // 检查是否是mini模式窗口(根据窗口大小判断)
// 注意展开播放列表后的 mini 窗口是 340x400,也不能当普通窗口持久化,
// 否则污染 windowState 导致下次启动主窗口尺寸异常(#242)
const [currentWidth, currentHeight] = win.getSize(); const [currentWidth, currentHeight] = win.getSize();
const isMiniMode = const isMiniMode = currentWidth === DEFAULT_MINI_WIDTH && currentHeight === DEFAULT_MINI_HEIGHT;
currentWidth === DEFAULT_MINI_WIDTH &&
(currentHeight === DEFAULT_MINI_HEIGHT || currentHeight === DEFAULT_MINI_EXPANDED_HEIGHT);
const isMaximized = win.isMaximized(); const isMaximized = win.isMaximized();
let state: WindowState; let state: WindowState;
@@ -440,13 +409,9 @@ class WindowSizeManager {
return state; return state;
} }
// 保存状态到存储config.json 可能被外部程序短暂锁定,失败时丢弃本次写入即可) // 保存状态到存储
try { this.store.set(WINDOW_STATE_KEY, state);
this.store.set(WINDOW_STATE_KEY, state); console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
console.log(`已保存窗口状态: ${JSON.stringify(state)}`);
} catch (error) {
console.error('保存窗口状态失败:', error);
}
// 更新内部状态 // 更新内部状态
this.savedState = state; this.savedState = state;
@@ -459,13 +424,7 @@ class WindowSizeManager {
* *
*/ */
getWindowState(): WindowState | null { getWindowState(): WindowState | null {
let state: WindowState | undefined; const state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
try {
state = this.store.get(WINDOW_STATE_KEY) as WindowState | undefined;
} catch (error) {
console.error('读取窗口状态失败:', error);
return this.savedState;
}
if (!state) { if (!state) {
console.log('未找到保存的窗口状态,将使用默认值'); console.log('未找到保存的窗口状态,将使用默认值');
+2 -2
View File
@@ -9,9 +9,9 @@ import {
session, session,
shell shell
} from 'electron'; } from 'electron';
import Store from 'electron-store';
import { join } from 'path'; import { join } from 'path';
import { getSharedStore } from './config';
import { import {
applyContentZoom, applyContentZoom,
applyInitialState, applyInitialState,
@@ -27,7 +27,7 @@ import {
WindowState WindowState
} from './window-size'; } from './window-size';
const store = getSharedStore(); const store = new Store();
// 保存主窗口引用,以便在 activate 事件中使用 // 保存主窗口引用,以便在 activate 事件中使用
let mainWindowInstance: BrowserWindow | null = null; let mainWindowInstance: BrowserWindow | null = null;
+2 -2
View File
@@ -1,9 +1,9 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import Store from 'electron-store';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { getSharedStore } from './modules/config';
import { type Platform, unblockMusic } from './unblockMusic'; import { type Platform, unblockMusic } from './unblockMusic';
// 必须在 import netease-cloud-music-api-alger 之前创建 anonymous_token 文件 // 必须在 import netease-cloud-music-api-alger 之前创建 anonymous_token 文件
@@ -12,7 +12,7 @@ if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8'); fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
} }
const store = getSharedStore(); const store = new Store();
// 设置音乐解析的处理程序 // 设置音乐解析的处理程序
ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => { ipcMain.handle('unblock-music', async (_event, id, songData, enabledSources) => {
-23
View File
@@ -1,23 +0,0 @@
declare module 'mpris-service' {
interface PlayerOptions {
name: string;
identity: string;
supportedUriSchemes?: string[];
supportedMimeTypes?: string[];
supportedInterfaces?: string[];
}
interface Player {
on(event: string, callback: (...args: any[]) => void): void;
playbackStatus: string;
metadata: Record<string, any>;
position: number;
getPosition: () => number;
seeked(position: number): void;
objectPath(path: string): string;
quit(): void;
}
function Player(options: PlayerOptions): Player;
export = Player;
}
-4
View File
@@ -100,10 +100,6 @@ if (isElectron) {
window.api.onLanguageChanged(handleSetLanguage); window.api.onLanguageChanged(handleSetLanguage);
window.electron.ipcRenderer.on('mini-mode', (_, value) => { window.electron.ipcRenderer.on('mini-mode', (_, value) => {
settingsStore.setMiniMode(value); settingsStore.setMiniMode(value);
// /
// musicFull true ""
// #242
playerStore.setMusicFull(false);
if (value) { if (value) {
// //
localStorage.setItem('currentRoute', router.currentRoute.value.path); localStorage.setItem('currentRoute', router.currentRoute.value.path);
+13 -108
View File
@@ -56,18 +56,17 @@ export const parseFromGDMusic = async (
} }
const songName = data.name || ''; const songName = data.name || '';
let artistList: string[] = []; let artistNames = '';
// 处理不同的艺术家字段结构 // 处理不同的艺术家字段结构
if (data.artists && Array.isArray(data.artists)) { if (data.artists && Array.isArray(data.artists)) {
artistList = data.artists.map((artist) => artist?.name).filter(Boolean); artistNames = data.artists.map((artist) => artist.name).join(' ');
} else if (data.ar && Array.isArray(data.ar)) { } else if (data.ar && Array.isArray(data.ar)) {
artistList = data.ar.map((artist) => artist?.name).filter(Boolean); artistNames = data.ar.map((artist) => artist.name).join(' ');
} else if (data.artist && typeof data.artist === 'string') { } else if (data.artist) {
artistList = [data.artist]; artistNames = typeof data.artist === 'string' ? data.artist : '';
} }
const artistNames = artistList.join(' ');
const searchQuery = `${songName} ${artistNames}`.trim(); const searchQuery = `${songName} ${artistNames}`.trim();
if (!searchQuery || searchQuery.length < 2) { if (!searchQuery || searchQuery.length < 2) {
@@ -83,12 +82,7 @@ export const parseFromGDMusic = async (
// 依次尝试所有音源 // 依次尝试所有音源
for (const source of allSources) { for (const source of allSources) {
try { try {
const result = await searchAndGetUrl( const result = await searchAndGetUrl(source, searchQuery, quality);
source,
searchQuery,
{ name: songName, artists: artistList },
quality
);
if (result) { if (result) {
console.log(`GD音乐台成功通过 ${result.source} 解析音乐!`); console.log(`GD音乐台成功通过 ${result.source} 解析音乐!`);
// 返回符合原API格式的数据 // 返回符合原API格式的数据
@@ -138,124 +132,35 @@ interface GDMusicUrlResult {
source: string; source: string;
} }
type GDSearchItem = {
id: string | number;
name?: string;
artist?: unknown;
source?: string;
};
type ExpectedSong = {
name: string;
artists: string[];
};
const baseUrl = 'https://music-api.gdstudio.xyz/api.php'; const baseUrl = 'https://music-api.gdstudio.xyz/api.php';
/**
* Live//
*/
const normalizeText = (text: string): string => {
const stripped = text
.toLowerCase()
.replace(/[(【[].*?[))】\]]/g, '')
.replace(/[\s\-—_·・'"‘’“”!?.,,。&+]/g, '');
// 整个歌名都在括号里时退化为仅去标点,避免归一化成空串
return stripped || text.toLowerCase().replace(/[\s\-—_·・'"‘’“”!?.,,。&+]/g, '');
};
const getCandidateArtistText = (artist: unknown): string => {
if (Array.isArray(artist)) {
return artist
.map((item) => (typeof item === 'string' ? item : (item as any)?.name || ''))
.join(' ');
}
return typeof artist === 'string' ? artist : '';
};
const isNameMatched = (expectedName: string, candidateName: string): boolean => {
const expected = normalizeText(expectedName);
const candidate = normalizeText(candidateName);
if (!expected || !candidate) return false;
return expected === candidate || candidate.includes(expected) || expected.includes(candidate);
};
/**
*
* /
* "货不对版"#704
*
*/
const pickBestCandidate = (
candidates: GDSearchItem[],
expected: ExpectedSong
): GDSearchItem | null => {
let best: GDSearchItem | null = null;
let bestScore = 0;
for (const item of candidates) {
if (!item || !item.id) continue;
if (!isNameMatched(expected.name, item.name || '')) continue;
const candidateArtist = normalizeText(getCandidateArtistText(item.artist));
let score: number;
if (expected.artists.length === 0) {
// 原曲无歌手信息,歌名匹配即可
score = 2;
} else if (!candidateArtist) {
// 候选缺少歌手信息:保留为低优先级候选
score = 1;
} else {
const artistMatched = expected.artists.some((name) => {
const normalized = normalizeText(name);
return (
!!normalized &&
(candidateArtist.includes(normalized) || normalized.includes(candidateArtist))
);
});
// 有歌手信息但对不上 → 拒绝,这正是"货不对版"的来源
if (!artistMatched) continue;
score = 3;
}
if (score > bestScore) {
best = item;
bestScore = score;
}
}
return best;
};
/** /**
* URL * URL
* @param source * @param source
* @param searchQuery * @param searchQuery
* @param expected
* @param quality * @param quality
* @returns URL结果 * @returns URL结果
*/ */
async function searchAndGetUrl( async function searchAndGetUrl(
source: MusicSourceType, source: MusicSourceType,
searchQuery: string, searchQuery: string,
expected: ExpectedSong,
quality: string quality: string
): Promise<GDMusicUrlResult | null> { ): Promise<GDMusicUrlResult | null> {
// 1. 搜索歌曲(取前5条做校验,而不是盲取第一条) // 1. 搜索歌曲
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=5&pages=1`; const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=1&pages=1`;
console.log(`GD音乐台尝试音源 ${source} 搜索:`, searchUrl); console.log(`GD音乐台尝试音源 ${source} 搜索:`, searchUrl);
const searchResponse = await axios.get(searchUrl, { timeout: 5000 }); const searchResponse = await axios.get(searchUrl, { timeout: 5000 });
if (searchResponse.data && Array.isArray(searchResponse.data) && searchResponse.data.length > 0) { if (searchResponse.data && Array.isArray(searchResponse.data) && searchResponse.data.length > 0) {
const matchedResult = pickBestCandidate(searchResponse.data as GDSearchItem[], expected); const firstResult = searchResponse.data[0];
if (!matchedResult) { if (!firstResult || !firstResult.id) {
console.log(`GD音乐台 ${source} 搜索结果与原曲不匹配,已拒绝(避免货不对版)`); console.log(`GD音乐台 ${source} 搜索结果无效`);
return null; return null;
} }
const trackId = matchedResult.id; const trackId = firstResult.id;
const trackSource = matchedResult.source || source; const trackSource = firstResult.source || source;
// 2. 获取歌曲URL // 2. 获取歌曲URL
const songUrl = `${baseUrl}?types=url&source=${trackSource}&id=${trackId}&br=${quality}`; const songUrl = `${baseUrl}?types=url&source=${trackSource}&id=${trackId}&br=${quality}`;
+49 -53
View File
@@ -16,72 +16,68 @@ import { CacheManager } from './musicParser';
/** /**
* API URL URL * API URL URL
* API URL * API URL
*
* CORS null
* URL
* URL audio Format error
* unblockMusic
*/ */
const resolveAudioUrl = async (url: string): Promise<string | null> => { const resolveAudioUrl = async (url: string): Promise<string> => {
// 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
if (!isApiEndpoint) {
// 看起来像直接的音频 URL,直接返回
return url;
}
console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
// 非 Electron 环境无法绕过 CORS 验证,保持乐观返回
if (typeof window.api?.lxMusicHttpRequest !== 'function') {
return url;
}
try { try {
const requestId = `lx_resolve_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; // 检查是否看起来像 API 端点(包含 /api/ 且有查询参数)
const response = await window.api.lxMusicHttpRequest({ const isApiEndpoint = url.includes('/api/') || (url.includes('?') && url.includes('type=url'));
url,
options: {
method: 'GET',
// 端点若直接返回音频流,用 Range 避免整段下载;返回 JSON 时 8KB 也足够
headers: { Range: 'bytes=0-8191' },
timeout: 15000
},
requestId
});
const status = response?.statusCode ?? 0; if (!isApiEndpoint) {
const contentType = String(response?.headers?.['content-type'] || ''); // 看起来像直接的音频 URL,直接返回
if (status < 200 || status >= 400) {
console.warn(`[LxMusicStrategy] API 端点返回 ${status},判定解析失败`);
return null;
}
// 端点直接返回音频流(或重定向到音频,主进程已自动跟随),
// audio 元素可以直接播放原始 URL
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
console.log('[LxMusicStrategy] API 端点为音频流,直接使用原始 URL');
return url; return url;
} }
// JSON 响应:尝试提取常见字段中的音频 URL console.log('[LxMusicStrategy] 检测到 API 端点,尝试解析真实 URL:', url);
const body = response?.body;
if (body && typeof body === 'object') { // 尝试获取真实 URL
const audioUrl = body.url || body.data?.url || body.audio_url || body.link || body.src; const response = await fetch(url, {
method: 'HEAD',
redirect: 'manual' // 不自动跟随重定向
});
// 检查是否是重定向
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('Location');
if (location) {
console.log('[LxMusicStrategy] API 返回重定向 URL:', location);
return location;
}
}
// 如果 HEAD 请求没有重定向,尝试 GET 请求
const getResponse = await fetch(url, {
redirect: 'follow'
});
// 检查 Content-Type
const contentType = getResponse.headers.get('Content-Type') || '';
// 如果是音频类型,返回最终 URL
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
console.log('[LxMusicStrategy] 解析到音频 URL:', getResponse.url);
return getResponse.url;
}
// 如果是 JSON,尝试解析
if (contentType.includes('application/json') || contentType.includes('text/json')) {
const json = await getResponse.json();
console.log('[LxMusicStrategy] API 返回 JSON:', json);
// 尝试从 JSON 中提取 URL(常见字段)
const audioUrl = json.url || json.data?.url || json.audio_url || json.link || json.src;
if (audioUrl && typeof audioUrl === 'string') { if (audioUrl && typeof audioUrl === 'string') {
console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl); console.log('[LxMusicStrategy] 从 JSON 中提取音频 URL:', audioUrl);
return audioUrl; return audioUrl;
} }
} }
// 2xx 但既不是音频也提取不到 URL(如 HTML 错误页),视为不可播放 // 如果都不是,返回原始 URL(可能直接可用)
console.warn('[LxMusicStrategy] API 端点响应无法解析为音频,判定解析失败'); console.warn('[LxMusicStrategy] 无法解析 API 端点,返回原始 URL');
return null; return url;
} catch (error) { } catch (error) {
console.error('[LxMusicStrategy] URL 解析请求失败:', error); console.error('[LxMusicStrategy] URL 解析失败:', error);
return null; // 解析失败时返回原始 URL
return url;
} }
}; };
+50 -42
View File
@@ -5,6 +5,7 @@ import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import { useSettingsStore } from '@/store'; import { useSettingsStore } from '@/store';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
import { isElectron } from '@/utils'; import { isElectron } from '@/utils';
import requestMusic from '@/utils/request_music';
import type { ParsedMusicResult } from './gdmusic'; import type { ParsedMusicResult } from './gdmusic';
import { parseFromGDMusic } from './gdmusic'; import { parseFromGDMusic } from './gdmusic';
@@ -160,11 +161,21 @@ export class CacheManager {
// 清除URL缓存 // 清除URL缓存
await deleteData('music_url_cache', id); await deleteData('music_url_cache', id);
console.log(`清除歌曲 ${id} 的URL缓存`); console.log(`清除歌曲 ${id} 的URL缓存`);
// 清除失败缓存 - 需要遍历所有策略
const strategies = ['custom', 'gdmusic', 'unblockMusic'];
for (const strategy of strategies) {
const cacheKey = `${id}_${strategy}`;
try {
await deleteData('music_failed_cache', cacheKey);
} catch {
// 忽略删除不存在缓存的错误
}
}
console.log(`清除歌曲 ${id} 的失败缓存`);
} catch (error) { } catch (error) {
console.error('清除URL缓存失败:', error); console.error('清除缓存失败:', error);
} }
// 清除内存失败缓存(覆盖所有策略,含 lxMusic)
CacheManager.clearFailedCache(id);
} }
} }
@@ -227,19 +238,7 @@ const getGDMusicAudio = async (id: number, data: SongResult): Promise<ParsedMusi
const getUnblockMusicAudio = (id: number, data: SongResult, sources: any[]) => { const getUnblockMusicAudio = (id: number, data: SongResult, sources: any[]) => {
const filteredSources = sources.filter((source) => source !== 'gdmusic'); const filteredSources = sources.filter((source) => source !== 'gdmusic');
console.log(`使用unblockMusic解析,音源:`, filteredSources); console.log(`使用unblockMusic解析,音源:`, filteredSources);
// 整体超时兜底:unblock 全链路(IPC → match())本身无超时,底层请求挂起时会 return window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources));
// 导致渲染进程无限等待、起播/切歌长时间无响应。超时解析为 null 以走降级,
// 且不抛异常(避免触发 RetryHelper 的多次重试放大等待时间)。
const UNBLOCK_TIMEOUT = 15000;
return Promise.race([
window.api.unblockMusic(id, cloneDeep(data), cloneDeep(filteredSources)),
new Promise((resolve) => {
setTimeout(() => {
console.warn(`unblockMusic 解析超时(${UNBLOCK_TIMEOUT}ms),放弃等待`);
resolve(null);
}, UNBLOCK_TIMEOUT);
})
]);
}; };
/** /**
@@ -487,18 +486,6 @@ const getMusicConfig = (id: number, settingsStore?: any) => {
return { musicSources, quality }; return { musicSources, quality };
}; };
/**
*
* music_proxy 退
*/
const buildFailedResult = (message: string, code = 404): MusicParseResult => ({
data: {
code,
message,
data: undefined
}
});
/** /**
* *
*/ */
@@ -513,10 +500,10 @@ export class MusicParser {
const startTime = performance.now(); const startTime = performance.now();
try { try {
// 非Electron环境不支持本地解析 // 非Electron环境直接使用API请求
if (!isElectron) { if (!isElectron) {
console.log('非Electron环境,不支持音乐解析'); console.log('非Electron环境,使用API请求');
return buildFailedResult('当前环境不支持音乐解析'); return await requestMusic.get<any>('/music', { params: { id } });
} }
// 获取设置存储 // 获取设置存储
@@ -524,8 +511,8 @@ export class MusicParser {
try { try {
settingsStore = useSettingsStore(); settingsStore = useSettingsStore();
} catch (error) { } catch (error) {
console.error('无法获取设置存储:', error); console.error('无法获取设置存储,使用后备方案:', error);
return buildFailedResult('无法读取设置,音乐解析不可用'); return await requestMusic.get<any>('/music', { params: { id } });
} }
// 获取音源配置 // 获取音源配置
@@ -554,8 +541,8 @@ export class MusicParser {
} }
if (musicSources.length === 0) { if (musicSources.length === 0) {
console.warn('没有配置可用的音源'); console.warn('没有配置可用的音源,使用后备方案');
return buildFailedResult('没有配置可用的音源'); return await requestMusic.get<any>('/music', { params: { id } });
} }
// 获取可用的解析策略 // 获取可用的解析策略
@@ -565,8 +552,8 @@ export class MusicParser {
); );
if (availableStrategies.length === 0) { if (availableStrategies.length === 0) {
console.warn('没有可用的解析策略'); console.warn('没有可用的解析策略,使用后备方案');
return buildFailedResult('没有可用的解析策略'); return await requestMusic.get<any>('/music', { params: { id } });
} }
console.log( console.log(
@@ -596,13 +583,34 @@ export class MusicParser {
} }
} }
console.warn('所有解析策略都失败了'); console.warn('所有解析策略都失败了,使用后备方案');
} catch (error) { } catch (error) {
console.error('MusicParser.parseMusic 执行异常:', error); console.error('MusicParser.parseMusic 执行异常,使用后备方案:', error);
} }
const endTime = performance.now(); // 后备方案:使用API请求
console.log(`总耗时: ${(endTime - startTime).toFixed(2)}ms`); try {
return buildFailedResult('所有解析方式都失败了', 500); console.log('使用后备方案:API请求');
const result = await requestMusic.get<any>('/music', { params: { id } });
// 如果后备方案成功,也进行缓存
if (result?.data?.data?.url) {
console.log('后备方案成功,缓存结果');
await CacheManager.setCachedMusicUrl(id, result, []);
}
return result;
} catch (apiError) {
console.error('API请求也失败了:', apiError);
const endTime = performance.now();
console.log(`总耗时: ${(endTime - startTime).toFixed(2)}ms`);
return {
data: {
code: 500,
message: '所有解析方式都失败了',
data: undefined
}
};
}
} }
} }
+2 -13
View File
@@ -72,9 +72,7 @@ export const parseFromCustomApi = async (
response = await axios.post(plugin.apiUrl, finalParams, { timeout }); response = await axios.post(plugin.apiUrl, finalParams, { timeout });
} else { } else {
// 默认为 GET // 默认为 GET
// apiUrl 本身可能已带查询串(如 xxx/api.php?type=url),需按情况选择 ? 或 & const finalUrl = `${plugin.apiUrl}?${new URLSearchParams(finalParams).toString()}`;
const separator = plugin.apiUrl.includes('?') ? '&' : '?';
const finalUrl = `${plugin.apiUrl}${separator}${new URLSearchParams(finalParams).toString()}`;
console.log('自定义API:发送 GET 请求到:', finalUrl); console.log('自定义API:发送 GET 请求到:', finalUrl);
response = await axios.get(finalUrl, { timeout }); response = await axios.get(finalUrl, { timeout });
} }
@@ -85,20 +83,11 @@ export const parseFromCustomApi = async (
if (musicUrl && typeof musicUrl === 'string') { if (musicUrl && typeof musicUrl === 'string') {
console.log('自定义API:成功获取URL'); console.log('自定义API:成功获取URL');
// 5. 组装成应用所需的标准格式并返回 // 5. 组装成应用所需的标准格式并返回
// quality 是 'standard'/'higher'/'exhigh'/'lossless'/'hires' 等字符串,
// 直接 parseInt 会得到 NaN,这里映射为对应码率(bps)
const QUALITY_BITRATE: Record<string, number> = {
standard: 128000,
higher: 192000,
exhigh: 320000,
lossless: 999000,
hires: 1900000
};
return { return {
data: { data: {
data: { data: {
url: musicUrl, url: musicUrl,
br: QUALITY_BITRATE[quality] ?? 320000, br: parseInt(quality) * 1000,
size: 0, size: 0,
md5: '', md5: '',
platform: plugin.name.toLowerCase().replace(/\s/g, ''), platform: plugin.name.toLowerCase().replace(/\s/g, ''),
@@ -235,8 +235,6 @@ const handleAddToPlaylist = async (playlist: any) => {
if (res.status === 200) { if (res.status === 200) {
message.success(t('comp.playlistDrawer.addSuccess')); message.success(t('comp.playlistDrawer.addSuccess'));
emit('update:modelValue', false); emit('update:modelValue', false);
// /(trackCount)#508
store.initializePlaylist().catch(() => {});
} else { } else {
throw new Error(res.data?.msg || t('comp.playlistDrawer.addFailed')); throw new Error(res.data?.msg || t('comp.playlistDrawer.addFailed'));
} }
@@ -4,9 +4,7 @@
<div class="w-full pb-32"> <div class="w-full pb-32">
<!-- Page Header (scrolls away) --> <!-- Page Header (scrolls away) -->
<div ref="headerRef" class="page-padding pt-6 pb-2"> <div ref="headerRef" class="page-padding pt-6 pb-2">
<h1 <h1 class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white">
class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white"
>
{{ title }} {{ title }}
</h1> </h1>
<p v-if="description" class="text-neutral-500 dark:text-neutral-400"> <p v-if="description" class="text-neutral-500 dark:text-neutral-400">
@@ -4,7 +4,7 @@
@contextmenu.prevent="handleContextMenu" @contextmenu.prevent="handleContextMenu"
@mouseenter="handleMouseEnter" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave" @mouseleave="handleMouseLeave"
@dblclick.stop="handlePlay(item)" @dblclick.stop="playMusicEvent(item)"
> >
<slot name="index"></slot> <slot name="index"></slot>
<slot name="select" v-if="selectable"></slot> <slot name="select" v-if="selectable"></slot>
@@ -22,7 +22,7 @@
:is-dislike="isDislike" :is-dislike="isDislike"
:can-remove="canRemove" :can-remove="canRemove"
@update:show="showDropdown = $event" @update:show="showDropdown = $event"
@play="handlePlay(item)" @play="playMusicEvent(item)"
@play-next="handlePlayNext" @play-next="handlePlayNext"
@download="downloadMusic(item)" @download="downloadMusic(item)"
@download-lyric="downloadLyric(item)" @download-lyric="downloadLyric(item)"
@@ -83,12 +83,6 @@ const imageLoad = async (event: Event) => {
await handleImageLoad(target); await handleImageLoad(target);
}; };
// ""
const handlePlay = (song: SongResult) => {
emits('play', song);
playMusicEvent(song);
};
// //
const toggleSelect = () => { const toggleSelect = () => {
emits('select', props.item.id, !props.selected); emits('select', props.item.id, !props.selected);
@@ -41,11 +41,6 @@
:class="{ 'text-green-500': isPlaying }" :class="{ 'text-green-500': isPlaying }"
> >
{{ item.name }} {{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis> </n-ellipsis>
</div> </div>
<div class="song-item-content-compact-artist"> <div class="song-item-content-compact-artist">
@@ -33,11 +33,6 @@
:class="{ 'text-green-500': isPlaying }" :class="{ 'text-green-500': isPlaying }"
> >
{{ item.name }} {{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500 font-normal"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis> </n-ellipsis>
<n-ellipsis <n-ellipsis
class="artist-name text-xs md:text-sm text-neutral-500 dark:text-neutral-400 mt-0.5" class="artist-name text-xs md:text-sm text-neutral-500 dark:text-neutral-400 mt-0.5"
@@ -43,11 +43,6 @@
:class="{ 'text-green-500': isPlaying }" :class="{ 'text-green-500': isPlaying }"
> >
{{ item.name }} {{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis> </n-ellipsis>
<div class="song-item-content-divider">-</div> <div class="song-item-content-divider">-</div>
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1"> <n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
@@ -39,11 +39,6 @@
<div class="song-item-content-title"> <div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }"> <n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
{{ item.name }} {{ item.name }}
<span
v-if="item.tns?.length || item.alia?.length"
class="text-neutral-400 dark:text-neutral-500"
>{{ item.tns?.[0] || item.alia?.[0] }}</span
>
</n-ellipsis> </n-ellipsis>
</div> </div>
<div class="song-item-content-name"> <div class="song-item-content-name">
@@ -15,15 +15,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { MenuOption } from 'naive-ui'; import type { MenuOption } from 'naive-ui';
import { createDiscreteApi, NDropdown, NEllipsis, NImage } from 'naive-ui'; import { NDropdown, NEllipsis, NImage } from 'naive-ui';
import { computed, h, inject } from 'vue'; import { computed, h, inject } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
import { getImgUrl, isElectron } from '@/utils'; import { getImgUrl, isElectron } from '@/utils';
import { hasPermission } from '@/utils/auth';
const { message } = createDiscreteApi(['message']);
const { t } = useI18n(); const { t } = useI18n();
@@ -52,19 +50,6 @@ const emits = defineEmits([
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer'); const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
const userStore = useUserStore();
// Cookie/
// userStore localStorage
// /#706
const hasRealAuth = computed(() => !!userStore.user && userStore.loginType !== 'uid');
// """"#713
const isLocalSong = computed(
() =>
typeof props.item.playMusicUrl === 'string' && props.item.playMusicUrl.startsWith('local://')
);
// //
const renderSongPreview = () => { const renderSongPreview = () => {
return h( return h(
@@ -138,6 +123,8 @@ const renderSongPreview = () => {
// //
const dropdownOptions = computed<MenuOption[]>(() => { const dropdownOptions = computed<MenuOption[]>(() => {
const hasRealAuth = hasPermission(true);
const options: MenuOption[] = [ const options: MenuOption[] = [
{ {
key: 'header', key: 'header',
@@ -173,10 +160,10 @@ const dropdownOptions = computed<MenuOption[]>(() => {
icon: () => h('i', { class: 'iconfont ri-file-text-line' }) icon: () => h('i', { class: 'iconfont ri-file-text-line' })
}, },
{ {
// ""#706
label: t('songItem.menu.addToPlaylist'), label: t('songItem.menu.addToPlaylist'),
key: 'addToPlaylist', key: 'addToPlaylist',
icon: () => h('i', { class: 'iconfont ri-folder-add-line' }) icon: () => h('i', { class: 'iconfont ri-folder-add-line' }),
disabled: !hasRealAuth
}, },
{ {
label: props.isFavorite ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'), label: props.isFavorite ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
@@ -204,9 +191,7 @@ const dropdownOptions = computed<MenuOption[]>(() => {
key: 'd2' key: 'd2'
}, },
{ {
label: isLocalSong.value label: t('songItem.menu.removeFromPlaylist'),
? t('localMusic.removeFromLibrary')
: t('songItem.menu.removeFromPlaylist'),
key: 'remove', key: 'remove',
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' }) icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
} }
@@ -231,10 +216,6 @@ const handleSelect = (key: string | number) => {
emits('play-next'); emits('play-next');
break; break;
case 'addToPlaylist': case 'addToPlaylist':
if (!hasRealAuth.value) {
message.warning(t('songItem.message.addToPlaylistNeedLogin'));
break;
}
openPlaylistDrawer?.(props.item.id); openPlaylistDrawer?.(props.item.id);
break; break;
case 'favorite': case 'favorite':
@@ -37,13 +37,11 @@
<template #content> <template #content>
<div class="song-item-content"> <div class="song-item-content">
<div class="song-item-content-title"> <div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }" <n-ellipsis
>{{ item.name }} class="text-ellipsis"
<span line-clamp="1"
v-if="item.tns?.length || item.alia?.length" :class="{ 'text-green-500': isPlaying }"
class="text-neutral-400 dark:text-neutral-500" >{{ item.name }}</n-ellipsis
>{{ item.tns?.[0] || item.alia?.[0] }}</span
></n-ellipsis
> >
</div> </div>
<div class="song-item-content-name"> <div class="song-item-content-name">
@@ -48,8 +48,7 @@ const { t } = useI18n();
<style scoped lang="scss"> <style scoped lang="scss">
.lyric-correction { .lyric-correction {
/* bottom 需越过全屏态下钉底的 PlayBarh-20=80px, z-index:9999),否则被遮挡无法点击(#592) */ @apply absolute right-0 bottom-4 flex flex-col items-center space-y-1 z-50 select-none transition-opacity duration-200 opacity-0 pointer-events-none;
@apply absolute right-0 bottom-24 flex flex-col items-center space-y-1 z-50 select-none transition-opacity duration-200 opacity-0 pointer-events-none;
} }
.lyric-correction-btn { .lyric-correction-btn {
+51 -4
View File
@@ -202,18 +202,19 @@ import {
useLyricProgress useLyricProgress
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { useLyricBackground } from '@/hooks/useLyricBackground';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric'; import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
import { getImgUrl, isMobile } from '@/utils'; import { getImgUrl, isMobile } from '@/utils';
import { getTextColors } from '@/utils/linearColor'; import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
const { t } = useI18n(); const { t } = useI18n();
// refs // refs
const lrcSider = ref<any>(null); const lrcSider = ref<any>(null);
const isMouse = ref(false); const isMouse = ref(false);
const { currentBackground, applyBackground } = useLyricBackground(); const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
// //
const customBackgroundStyle = computed(() => { const customBackgroundStyle = computed(() => {
@@ -380,6 +381,42 @@ watch(
} }
); );
const setTextColors = (background: string) => {
if (!background) {
textColors.value = getTextColors();
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
return;
}
//
textColors.value = getTextColors(background);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(isDark.value)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
//
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
const result = animateGradient(currentBackground.value, background, (gradient) => {
currentBackground.value = gradient;
});
if (typeof result === 'number') {
animationFrame.value = result;
}
} else {
currentBackground.value = background;
}
};
const targetBackground = computed(() => { const targetBackground = computed(() => {
if (config.value.useCustomBackground && customBackgroundStyle.value) { if (config.value.useCustomBackground && customBackgroundStyle.value) {
if (typeof customBackgroundStyle.value === 'string') { if (typeof customBackgroundStyle.value === 'string') {
@@ -397,7 +434,7 @@ watch(
targetBackground, targetBackground,
(newBg) => { (newBg) => {
if (newBg) { if (newBg) {
applyBackground(newBg); setTextColors(newBg);
} }
}, },
{ immediate: true } { immediate: true }
@@ -486,6 +523,13 @@ const getWordStyle = (lineIndex: number, _wordIndex: number, word: any) => {
} }
}; };
//
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
});
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { navigateToArtist } = useArtist(); const { navigateToArtist } = useArtist();
@@ -582,6 +626,9 @@ onMounted(() => {
// //
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
if (lrcSider.value?.$el) { if (lrcSider.value?.$el) {
lrcSider.value.$el.removeEventListener('scroll', handleScroll); lrcSider.value.$el.removeEventListener('scroll', handleScroll);
} }
@@ -408,13 +408,12 @@ import {
useLyricProgress useLyricProgress
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { useLyricBackground } from '@/hooks/useLyricBackground';
import { usePlayMode } from '@/hooks/usePlayMode'; import { usePlayMode } from '@/hooks/usePlayMode';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { usePlayerStore } from '@/store/modules/player'; import { usePlayerStore } from '@/store/modules/player';
import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric'; import { DEFAULT_LYRIC_CONFIG, LyricConfig } from '@/types/lyric';
import { getImgUrl, secondToMinute } from '@/utils'; import { getImgUrl, secondToMinute } from '@/utils';
import { getTextColors } from '@/utils/linearColor'; import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
import { showBottomToast } from '@/utils/shortcutToast'; import { showBottomToast } from '@/utils/shortcutToast';
const { t } = useI18n(); const { t } = useI18n();
@@ -877,10 +876,10 @@ const handleThumbTouchEnd = (e: TouchEvent) => {
isThumbDragging.value = false; isThumbDragging.value = false;
}; };
// composable //
const { isDark, applyBackground } = useLyricBackground({ const currentBackground = ref('');
writeBgColor: () => playerStore.playMusic.primaryColor || undefined const animationFrame = ref<number | null>(null);
}); const isDark = ref(false);
const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG }); const config = ref<LyricConfig>({ ...DEFAULT_LYRIC_CONFIG });
// //
@@ -938,6 +937,49 @@ const isVisible = computed({
set: (value) => emit('update:modelValue', value) set: (value) => emit('update:modelValue', value)
}); });
//
const setTextColors = (background: string) => {
if (!background) {
textColors.value = getTextColors();
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
document.documentElement.style.setProperty('--bg-color', 'rgba(25, 25, 25, 1)');
return;
}
//
textColors.value = getTextColors(background);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(isDark.value)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
//
let bgColor = playerStore.playMusic.primaryColor || 'rgba(25, 25, 25, 1)';
document.documentElement.style.setProperty('--bg-color', bgColor);
//
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
const result = animateGradient(currentBackground.value, background, (gradient) => {
currentBackground.value = gradient;
});
if (typeof result === 'number') {
animationFrame.value = result;
}
} else {
currentBackground.value = background;
}
};
const targetBackground = computed(() => { const targetBackground = computed(() => {
if (config.value.theme !== 'default') { if (config.value.theme !== 'default') {
return themeMusic[config.value.theme] || props.background; return themeMusic[config.value.theme] || props.background;
@@ -950,24 +992,21 @@ watch(
targetBackground, targetBackground,
(newBg) => { (newBg) => {
if (newBg) { if (newBg) {
applyBackground(newBg); setTextColors(newBg);
} }
}, },
{ immediate: true } { immediate: true }
); );
// //
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
if (autoScrollTimer.value) { if (autoScrollTimer.value) {
clearTimeout(autoScrollTimer.value); clearTimeout(autoScrollTimer.value);
} }
// interval store
if (sleepTimerInterval) {
clearInterval(sleepTimerInterval);
sleepTimerInterval = null;
}
// //
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
@@ -1074,7 +1113,7 @@ watch(isVisible, (newVal) => {
if (newVal) { if (newVal) {
// //
if (targetBackground.value) { if (targetBackground.value) {
applyBackground(targetBackground.value); setTextColors(targetBackground.value);
} }
} else { } else {
showFullLyrics.value = false; showFullLyrics.value = false;
@@ -1090,7 +1129,7 @@ const { getLrcStyle: originalLrcStyle } = useLyricProgress();
// getLrcStyle // getLrcStyle
const getLrcStyle = (index: number) => { const getLrcStyle = (index: number) => {
const colors = textColors.value || getTextColors(); const colors = textColors.value || getTextColors;
const originalStyle = originalLrcStyle(index); const originalStyle = originalLrcStyle(index);
if (index === nowIndex.value) { if (index === nowIndex.value) {
@@ -7,14 +7,8 @@
> >
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">{{ t('settings.themeColor.title') }}</span> <span class="panel-title">{{ t('settings.themeColor.title') }}</span>
<div class="header-actions"> <div class="close-button" @click="handleClose">
<div class="reset-button" :title="t('settings.themeColor.reset')" @click="handleReset"> <i class="ri-close-line"></i>
<i class="ri-arrow-go-back-line"></i>
<span>{{ t('settings.themeColor.reset') }}</span>
</div>
<div class="close-button" @click="handleClose">
<i class="ri-close-line"></i>
</div>
</div> </div>
</div> </div>
@@ -117,7 +111,6 @@ interface Props {
interface Emits { interface Emits {
(e: 'colorChange', _color: string): void; (e: 'colorChange', _color: string): void;
(e: 'close'): void; (e: 'close'): void;
(e: 'reset'): void;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -167,12 +160,6 @@ const handleClose = () => {
emit('close'); emit('close');
}; };
// #591
const handleReset = () => {
showColorPicker.value = false;
emit('reset');
};
const handlePresetColorSelect = (color: LyricThemeColor) => { const handlePresetColorSelect = (color: LyricThemeColor) => {
const colorValue = getColorValue(color); const colorValue = getColorValue(color);
const optimizedColor = optimizeColorForTheme(colorValue, props.theme); const optimizedColor = optimizeColorForTheme(colorValue, props.theme);
@@ -314,35 +301,6 @@ watch(
opacity: 0.9; opacity: 0.9;
} }
.header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.reset-button {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
cursor: pointer;
border-radius: 6px;
color: var(--text-color);
font-size: 11px;
opacity: 0.8;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.15);
opacity: 1;
}
i {
font-size: 12px;
}
}
.close-button { .close-button {
width: 24px; width: 24px;
height: 24px; height: 24px;
+3 -12
View File
@@ -69,7 +69,6 @@
v-model:value="volumeSlider" v-model:value="volumeSlider"
:step="0.01" :step="0.01"
:tooltip="false" :tooltip="false"
:disabled="isMuted"
vertical vertical
@wheel.prevent="handleVolumeWheel" @wheel.prevent="handleVolumeWheel"
></n-slider> ></n-slider>
@@ -146,13 +145,7 @@ const { navigateToArtist } = useArtist();
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl(); const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// playerStore // playerStore
const { const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
isMuted,
volumeSlider,
volumeIcon: getVolumeIcon,
mute,
handleVolumeWheel
} = useVolumeControl();
// //
const { isFavorite, toggleFavorite } = useFavorite(); const { isFavorite, toggleFavorite } = useFavorite();
@@ -182,11 +175,9 @@ const palyListRef = useTemplateRef('palyListRef') as any;
const isPlaylistOpen = ref(false); const isPlaylistOpen = ref(false);
// openPlaylistDrawer // openPlaylistDrawer
// Mini 340px 420px
// AppLayout #504
provide('openPlaylistDrawer', (songId: number) => { provide('openPlaylistDrawer', (songId: number) => {
localStorage.setItem('pendingAddToPlaylistSongId', String(songId)); console.log('打开歌单抽屉', songId);
window.api.restore(); //
}); });
// / // /
+3 -39
View File
@@ -99,16 +99,8 @@
<i class="iconfont" :class="getVolumeIcon"></i> <i class="iconfont" :class="getVolumeIcon"></i>
</div> </div>
<div class="volume-slider"> <div class="volume-slider">
<div class="volume-percentage" :class="{ 'volume-percentage-disabled': isMuted }"> <div class="volume-percentage">{{ Math.round(volumeSlider) }}%</div>
{{ Math.round(volumeSlider) }}% <n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider>
</div>
<n-slider
v-model:value="volumeSlider"
:step="0.01"
:tooltip="false"
:disabled="isMuted"
vertical
></n-slider>
</div> </div>
</div> </div>
<n-tooltip v-if="!isMobile" trigger="hover" :z-index="9999999"> <n-tooltip v-if="!isMobile" trigger="hover" :z-index="9999999">
@@ -151,16 +143,6 @@
</template> </template>
{{ t('player.playBar.reparse') }} {{ t('player.playBar.reparse') }}
</n-tooltip> </n-tooltip>
<n-tooltip v-if="playMusic?.id && isElectron" trigger="hover" :z-index="9999999">
<template #trigger>
<i
class="iconfont ri-download-line"
:class="{ 'disabled-icon': isDownloading }"
@click="playMusic?.id && handleDownload()"
/>
</template>
{{ isDownloading ? t('songItem.message.downloading') : t('player.playBar.download') }}
</n-tooltip>
<!-- 高级控制菜单按钮整合了 EQ定时关闭播放速度 --> <!-- 高级控制菜单按钮整合了 EQ定时关闭播放速度 -->
<advanced-controls-popover /> <advanced-controls-popover />
@@ -199,7 +181,6 @@ import {
textColors textColors
} from '@/hooks/MusicHook'; } from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist'; import { useArtist } from '@/hooks/useArtist';
import { useDownload } from '@/hooks/useDownload';
import { useFavorite } from '@/hooks/useFavorite'; import { useFavorite } from '@/hooks/useFavorite';
import { usePlaybackControl } from '@/hooks/usePlaybackControl'; import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { usePlayMode } from '@/hooks/usePlayMode'; import { usePlayMode } from '@/hooks/usePlayMode';
@@ -217,24 +198,11 @@ const { t } = useI18n();
const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl(); const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackControl();
// //
const { const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
isMuted,
volumeSlider,
volumeIcon: getVolumeIcon,
mute,
handleVolumeWheel
} = useVolumeControl();
// //
const { isFavorite, toggleFavorite } = useFavorite(); const { isFavorite, toggleFavorite } = useFavorite();
//
const { downloadMusic, isDownloading } = useDownload();
const handleDownload = () => {
if (!playMusic.value || isDownloading.value) return;
downloadMusic(playMusic.value);
};
// //
const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode(); const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode();
@@ -414,10 +382,6 @@ const openPlayListDrawer = () => {
@apply border border-gray-200 dark:border-gray-700; @apply border border-gray-200 dark:border-gray-700;
@apply text-gray-800 dark:text-white; @apply text-gray-800 dark:text-white;
white-space: nowrap; white-space: nowrap;
&.volume-percentage-disabled {
@apply text-gray-400 dark:text-gray-500;
}
} }
} }
} }
@@ -68,7 +68,6 @@
v-model:value="volumeSlider" v-model:value="volumeSlider"
:step="1" :step="1"
:tooltip="false" :tooltip="false"
:disabled="isMuted"
@wheel.prevent="handleVolumeWheel" @wheel.prevent="handleVolumeWheel"
></n-slider> ></n-slider>
</div> </div>
@@ -108,13 +107,7 @@ const { isPlaying: play, playMusicEvent, handleNext, handlePrev } = usePlaybackC
const { playMode, playModeIcon, togglePlayMode } = usePlayMode(); const { playMode, playModeIcon, togglePlayMode } = usePlayMode();
// playerStore // playerStore
const { const { volumeSlider, volumeIcon: getVolumeIcon, mute, handleVolumeWheel } = useVolumeControl();
isMuted,
volumeSlider,
volumeIcon: getVolumeIcon,
mute,
handleVolumeWheel
} = useVolumeControl();
// //
const isDragging = ref(false); const isDragging = ref(false);
@@ -23,7 +23,11 @@ const goToDetail = () => {
</script> </script>
<template> <template>
<div class="group cursor-pointer animate-item" :style="{ animationDelay }" @click="goToDetail"> <div
class="group cursor-pointer animate-item"
:style="{ animationDelay }"
@click="goToDetail"
>
<!-- Cover --> <!-- Cover -->
<div <div
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500" class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
+27 -34
View File
@@ -2,46 +2,39 @@ import { createVNode, render, VNode } from 'vue';
import Loading from './index.vue'; import Loading from './index.vue';
// 每个使用 v-loading 的元素独立持有一个 Loading 实例, const vnode: VNode = createVNode(Loading) as VNode;
// 避免此前"模块级单例 vnode"导致的多个 v-loading 争用同一实例、
// spinner 只出现在最后挂载元素上的问题。
const instanceMap = new WeakMap<HTMLElement, VNode>();
const setLoading = (el: HTMLElement, visible: boolean) => { export const vLoading = {
const vnode = instanceMap.get(el); // 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
if (visible) { mounted: (el: HTMLElement) => {
vnode?.component?.exposed?.show(); render(vnode, el);
} else { },
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
updated: (el: HTMLElement, binding: any) => {
if (binding.value) {
vnode?.component?.exposed?.show();
} else {
vnode?.component?.exposed?.hide();
}
// 动态添加删除自定义class: loading-parent
formatterClass(el, binding);
},
// 绑定元素的父组件卸载后调用
unmounted: () => {
vnode?.component?.exposed?.hide(); vnode?.component?.exposed?.hide();
} }
}; };
export const vLoading = {
// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
mounted: (el: HTMLElement, binding: any) => {
const vnode = createVNode(Loading);
render(vnode, el);
instanceMap.set(el, vnode);
setLoading(el, !!binding.value);
formatterClass(el, binding);
},
// 在绑定元素的父组件及他自己的所有子节点都更新后调用
updated: (el: HTMLElement, binding: any) => {
setLoading(el, !!binding.value);
// 动态添加删除自定义class: loading-parent
formatterClass(el, binding);
},
// 绑定元素的父组件卸载后调用:真正卸载组件实例,释放资源
unmounted: (el: HTMLElement) => {
render(null, el);
instanceMap.delete(el);
}
};
function formatterClass(el: HTMLElement, binding: any) { function formatterClass(el: HTMLElement, binding: any) {
const classStr = el.getAttribute('class');
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
if (binding.value) { if (binding.value) {
el.classList.add('loading-parent'); if (tagetClass === -1) {
} else { el.setAttribute('class', `${classStr} loading-parent`);
el.classList.remove('loading-parent'); }
} else if (tagetClass > -1) {
const classArray: Array<string> = classStr?.split('') as string[];
classArray.splice(tagetClass - 1, tagetClass + 15);
el.setAttribute('class', classArray?.join(''));
} }
} }
+163 -190
View File
@@ -52,11 +52,6 @@ export const textColors = ref<any>(getTextColors());
export let playMusic: ComputedRef<SongResult>; export let playMusic: ComputedRef<SongResult>;
export let artistList: ComputedRef<Artist[]>; export let artistList: ComputedRef<Artist[]>;
let lastIndex = -1;
// 缓存平台信息,避免每次歌词变化时同步 IPC 调用
const cachedPlatform = isElectron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
export const musicDB = await useIndexedDB( export const musicDB = await useIndexedDB(
'musicDB', 'musicDB',
[ [
@@ -140,10 +135,7 @@ const parseLyricsString = async (
duration: line.duration duration: line.duration
}); });
// yrcParser 的 startTime 是毫秒;lrcTimeArray 全链路(nowTime 对比、 lrcTimeArray.push(line.startTime);
// setAudioTime seek)以秒为单位,必须换算,否则点击歌词会 seek 到
// 远超时长的位置被钳到末尾、直接触发切歌
lrcTimeArray.push(line.startTime / 1000);
} }
return { lrcArray, lrcTimeArray, hasWordByWord }; return { lrcArray, lrcTimeArray, hasWordByWord };
} catch (error) { } catch (error) {
@@ -152,130 +144,124 @@ const parseLyricsString = async (
} }
}; };
// 解析当前 playMusic.lyric 写入 lrcArray, 供 watcher / openLyric / onLyricWindowReady 共用 // 设置音乐相关的监听器
const ensureLyricsLoaded = async (force = false) => {
const songId = playMusic.value?.id;
if (!songId) {
lrcArray.value = [];
lrcTimeArray.value = [];
nowIndex.value = 0;
return;
}
if (!force && lrcArray.value.length > 0) return;
await nextTick();
const lyricData = playMusic.value.lyric;
if (lyricData && typeof lyricData === 'string') {
const {
lrcArray: parsedLrcArray,
lrcTimeArray: parsedTimeArray,
hasWordByWord
} = await parseLyricsString(lyricData);
lrcArray.value = parsedLrcArray;
lrcTimeArray.value = parsedTimeArray;
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
playMusic.value.lyric.hasWordByWord = hasWordByWord;
}
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
const rawLrc = lyricData.lrcArray || [];
lrcTimeArray.value = lyricData.lrcTimeArray || [];
try {
const { translateLyrics } = await import('@/services/lyricTranslation');
lrcArray.value = await translateLyrics(rawLrc as any);
} catch (e) {
console.error('翻译歌词失败,使用原始歌词:', e);
lrcArray.value = rawLrc as any;
}
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
try {
let filePath = decodeURIComponent(playMusic.value.playMusicUrl.replace('local:///', ''));
// 处理 Windows 路径:/C:/... → C:/...
if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath);
if (embeddedLyrics) {
const {
lrcArray: parsedLrcArray,
lrcTimeArray: parsedTimeArray,
hasWordByWord
} = await parseLyricsString(embeddedLyrics);
lrcArray.value = parsedLrcArray;
lrcTimeArray.value = parsedTimeArray;
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
(playMusic.value.lyric as any).hasWordByWord = hasWordByWord;
}
} else if (typeof songId === 'number') {
try {
const { getMusicLrc } = await import('@/api/music');
const res = await getMusicLrc(songId);
if (res?.data?.lrc?.lyric) {
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
res.data.lrc.lyric
);
lrcArray.value = apiLrcArray;
lrcTimeArray.value = apiTimeArray;
}
} catch (apiErr) {
console.error('API lyrics fallback failed:', apiErr);
}
}
} catch (err) {
console.error('Failed to extract embedded lyrics:', err);
}
} else if (typeof songId === 'number') {
// 在线歌曲但 lyric 字段尚未加载, 主动调 API 兜底
try {
const { getMusicLrc } = await import('@/api/music');
const res = await getMusicLrc(songId);
if (res?.data?.lrc?.lyric) {
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
res.data.lrc.lyric
);
lrcArray.value = apiLrcArray;
lrcTimeArray.value = apiTimeArray;
}
} catch (apiErr) {
console.error('API lyrics fallback failed:', apiErr);
}
}
if (isElectron && isLyricWindowOpen.value) {
sendLyricToWin();
setTimeout(() => sendLyricToWin(), 500);
}
};
const setupMusicWatchers = () => { const setupMusicWatchers = () => {
const store = getPlayerStore(); const store = getPlayerStore();
// 切歌时 id 变化, 强制重新解析 // 监听 playerStore.playMusic 的变化以更新歌词数据
watch( watch(
() => store.playMusic.id, () => store.playMusic.id,
async (newId, oldId) => { async (newId, oldId) => {
if (newId !== oldId) nowIndex.value = 0; // 如果没有歌曲ID,清空歌词
await ensureLyricsLoaded(true); if (!newId) {
lrcArray.value = [];
lrcTimeArray.value = [];
nowIndex.value = 0;
return;
}
// 避免相同ID的重复执行(但允许初始化时执行)
if (newId === oldId && lrcArray.value.length > 0) return;
// 歌曲切换时重置歌词索引
if (newId !== oldId) {
nowIndex.value = 0;
}
await nextTick(async () => {
console.log('歌曲切换,更新歌词数据');
// 检查是否有原始歌词字符串需要解析
const lyricData = playMusic.value.lyric;
if (lyricData && typeof lyricData === 'string') {
// 如果歌词是字符串格式,使用新的解析器
const {
lrcArray: parsedLrcArray,
lrcTimeArray: parsedTimeArray,
hasWordByWord
} = await parseLyricsString(lyricData);
lrcArray.value = parsedLrcArray;
lrcTimeArray.value = parsedTimeArray;
// 更新歌曲的歌词数据结构
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
playMusic.value.lyric.hasWordByWord = hasWordByWord;
}
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
// 使用现有的歌词数据结构
const rawLrc = lyricData.lrcArray || [];
lrcTimeArray.value = lyricData.lrcTimeArray || [];
try {
const { translateLyrics } = await import('@/services/lyricTranslation');
lrcArray.value = await translateLyrics(rawLrc as any);
} catch (e) {
console.error('翻译歌词失败,使用原始歌词:', e);
lrcArray.value = rawLrc as any;
}
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
// 从下载/本地文件的 ID3/FLAC 元数据中提取嵌入歌词
try {
let filePath = decodeURIComponent(
playMusic.value.playMusicUrl.replace('local:///', '')
);
// 处理 Windows 路径:/C:/... → C:/...
if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath);
if (embeddedLyrics) {
const {
lrcArray: parsedLrcArray,
lrcTimeArray: parsedTimeArray,
hasWordByWord
} = await parseLyricsString(embeddedLyrics);
lrcArray.value = parsedLrcArray;
lrcTimeArray.value = parsedTimeArray;
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
(playMusic.value.lyric as any).hasWordByWord = hasWordByWord;
}
} else {
// 无嵌入歌词 — 若有数字 ID,尝试 API 兜底
const songId = playMusic.value.id;
if (songId && typeof songId === 'number') {
try {
const { getMusicLrc } = await import('@/api/music');
const res = await getMusicLrc(songId);
if (res?.data?.lrc?.lyric) {
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } =
await parseLyricsString(res.data.lrc.lyric);
lrcArray.value = apiLrcArray;
lrcTimeArray.value = apiTimeArray;
}
} catch (apiErr) {
console.error('API lyrics fallback failed:', apiErr);
}
}
}
} catch (err) {
console.error('Failed to extract embedded lyrics:', err);
}
} else {
// 无歌词数据
lrcArray.value = [];
lrcTimeArray.value = [];
}
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
if (isElectron && isLyricWindowOpen.value) {
console.log('歌词窗口已打开,同步最新歌词数据');
// 不管歌词数组是否为空,都发送最新数据
sendLyricToWin();
// 再次延迟发送,确保歌词窗口已完全加载
setTimeout(() => {
sendLyricToWin();
}, 500);
}
});
}, },
{ immediate: true } { immediate: true }
); );
// 同一首歌但 lyric 字段后到 (播放后异步加载元数据 / 重启 + autoPlay 关闭场景)
watch(
() => playMusic.value?.lyric,
(newLyric) => {
if (!playMusic.value?.id) return;
// 完整歌词对象(含 yrc 逐字/翻译,时间单位为秒)后到时强制重新解析,
// 替换掉先行的 API 兜底纯 lrc 歌词
const isRichLyric =
!!newLyric && typeof newLyric === 'object' && (newLyric.lrcArray?.length ?? 0) > 0;
if (lrcArray.value.length === 0 || isRichLyric) {
ensureLyricsLoaded(isRichLyric);
}
}
);
}; };
const setupAudioListeners = () => { const setupAudioListeners = () => {
@@ -343,12 +329,6 @@ const setupAudioListeners = () => {
sendLyricToWin(); sendLyricToWin();
} }
} }
if (isElectron && lrcArray.value[nowIndex.value]) {
if (lastIndex !== nowIndex.value) {
sendTrayLyric(nowIndex.value);
lastIndex = nowIndex.value;
}
}
// === 逐字歌词行内进度 === // === 逐字歌词行内进度 ===
const { start, end } = currentLrcTiming.value; const { start, end } = currentLrcTiming.value;
@@ -392,15 +372,6 @@ const setupAudioListeners = () => {
); );
} }
} }
// === MPRIS 进度更新(每 ~1 秒)===
if (isElectron && lyricThrottleCounter % 20 === 0) {
try {
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
} catch {
// 忽略发送失败
}
}
} catch (error) { } catch (error) {
console.error('进度更新 interval 出错:', error); console.error('进度更新 interval 出错:', error);
// 出错时不清除 interval,让下一次 tick 继续尝试 // 出错时不清除 interval,让下一次 tick 继续尝试
@@ -449,11 +420,6 @@ const setupAudioListeners = () => {
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) { if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
nowTime.value = currentTime; nowTime.value = currentTime;
// === MPRIS seek 时同步进度 ===
if (isElectron) {
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
}
// 检查是否需要更新歌词 // 检查是否需要更新歌词
const newIndex = getLrcIndex(nowTime.value); const newIndex = getLrcIndex(nowTime.value);
if (newIndex !== nowIndex.value) { if (newIndex !== nowIndex.value) {
@@ -495,10 +461,7 @@ const setupAudioListeners = () => {
if (isElectron) { if (isElectron) {
window.api.sendSong(cloneDeep(getPlayerStore().playMusic)); window.api.sendSong(cloneDeep(getPlayerStore().playMusic));
} }
// 兜底: 重启后首次点播放时 lrcArray 仍为空则主动加载 // 启动进度更新
if (lrcArray.value.length === 0 && playMusic.value?.id) {
ensureLyricsLoaded();
}
startProgressInterval(); startProgressInterval();
}); });
@@ -543,12 +506,43 @@ const setupAudioListeners = () => {
if (getPlayerStore().playMode === 1) { if (getPlayerStore().playMode === 1) {
// 单曲循环模式 // 单曲循环模式
replayMusic(); replayMusic();
return; } else if (getPlayerStore().isFmPlaying) {
// 私人FM模式:自动获取下一首
try {
const { getPersonalFM } = await import('@/api/home');
const res = await getPersonalFM();
const songs = res.data?.data;
if (Array.isArray(songs) && songs.length > 0) {
const song = songs[0];
const fmSong = {
id: song.id,
name: song.name,
picUrl: song.al?.picUrl || song.album?.picUrl,
ar: song.artists || song.ar,
al: song.al || song.album,
source: 'netease' as const,
song,
...song,
playLoading: false
} as any;
const { usePlaylistStore } = await import('@/store/modules/playlist');
const playlistStore = usePlaylistStore();
playlistStore.setPlayList([fmSong], false, false);
getPlayerStore().isFmPlaying = true; // setPlayList 会清除,需重设
const { playTrack } = await import('@/services/playbackController');
await playTrack(fmSong, true);
} else {
getPlayerStore().setIsPlay(false);
}
} catch (error) {
console.error('FM自动播放下一首失败:', error);
getPlayerStore().setIsPlay(false);
}
} else {
// 顺序播放、列表循环、随机播放模式:歌曲自然结束
const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
} }
// 其他模式(FM/顺序/列表循环/随机):交给 playlist store 路由
const { usePlaylistStore } = await import('@/store/modules/playlist');
usePlaylistStore().nextPlayOnEnd();
}); });
audioService.on('previoustrack', () => { audioService.on('previoustrack', () => {
@@ -813,30 +807,6 @@ export const sendLyricToWin = () => {
} }
}; };
// 发送歌词到系统托盘歌词(TrayLyric)
const sendTrayLyric = (index: number) => {
if (!isElectron || cachedPlatform !== 'linux') return;
try {
const lyric = lrcArray.value[index];
if (!lyric) return;
const currentTime = lrcTimeArray.value[index] || 0;
const nextTime = lrcTimeArray.value[index + 1] || currentTime + 3;
const duration = nextTime - currentTime;
const lrcObj = JSON.stringify({
content: lyric.text || '',
time: duration.toFixed(1),
sender: 'AlgerMusicPlayer'
});
window.electron.ipcRenderer.send('tray-lyric-update', lrcObj);
} catch (error) {
console.error('[TrayLyric] Failed to send:', error);
}
};
// 歌词同步定时器 // 歌词同步定时器
let lyricSyncInterval: any = null; let lyricSyncInterval: any = null;
@@ -874,20 +844,28 @@ const stopLyricSync = () => {
} }
}; };
export const openLyric = async () => { // 修改openLyric函数,添加定时同步
export const openLyric = () => {
if (!isElectron) return; if (!isElectron) return;
// 检查是否有播放中的歌曲
if (!playMusic.value || !playMusic.value.id) { if (!playMusic.value || !playMusic.value.id) {
console.log('没有正在播放的歌曲,无法打开歌词窗口'); console.log('没有正在播放的歌曲,无法打开歌词窗口');
return; return;
} }
console.log('Opening lyric window with current song:', playMusic.value?.name);
isLyricWindowOpen.value = !isLyricWindowOpen.value; isLyricWindowOpen.value = !isLyricWindowOpen.value;
if (isLyricWindowOpen.value) { if (isLyricWindowOpen.value) {
// 立即打开窗口
window.api.openLyric(); window.api.openLyric();
// 先发"加载中"占位, 防止窗口启动期间显示"无歌词" // 确保有歌词数据,如果没有,则使用默认的"无歌词"提示
if (!lrcArray.value || lrcArray.value.length === 0) { if (!lrcArray.value || lrcArray.value.length === 0) {
// 如果当前播放的歌曲有ID但没有歌词,则尝试加载歌词
console.log('尝试加载歌词数据...');
// 发送默认的"无歌词"数据
const emptyLyricData = { const emptyLyricData = {
type: 'empty', type: 'empty',
nowIndex: 0, nowIndex: 0,
@@ -901,15 +879,12 @@ export const openLyric = async () => {
playMusic: playMusic.value playMusic: playMusic.value
}; };
window.api.sendLyric(JSON.stringify(emptyLyricData)); window.api.sendLyric(JSON.stringify(emptyLyricData));
// 关键: 主动加载歌词, 不依赖 watcher
// (重启场景下 playerCore.playMusic 整体替换可能未触发 lyric watcher)
await ensureLyricsLoaded(true);
} else { } else {
// 发送完整歌词数据
sendLyricToWin(); sendLyricToWin();
} }
// 延迟重发, 防窗口加载慢丢消息 // 延迟重发一次,以防窗口加载
setTimeout(() => { setTimeout(() => {
if (isLyricWindowOpen.value) { if (isLyricWindowOpen.value) {
sendLyricToWin(); sendLyricToWin();
@@ -1031,13 +1006,11 @@ export const initAudioListeners = async () => {
window.api.onLyricWindowClosed(() => { window.api.onLyricWindowClosed(() => {
isLyricWindowOpen.value = false; isLyricWindowOpen.value = false;
}); });
window.api.onLyricWindowReady(async () => { // 歌词窗口 Vue 加载完成后,发送完整歌词数据
if (!isLyricWindowOpen.value) return; window.api.onLyricWindowReady(() => {
// 窗口加载完成时再兜底加载一次, 防止 openLyric 阶段 lyric 字段尚未到位 if (isLyricWindowOpen.value) {
if (lrcArray.value.length === 0 && playMusic.value?.id) { sendLyricToWin();
await ensureLyricsLoaded(true);
} }
sendLyricToWin();
}); });
} }
+4 -12
View File
@@ -147,18 +147,10 @@ export const useDownload = () => {
lrcContent = mergeLrcWithTranslation(lyricData.lrc.lyric, lyricData.tlyric.lyric); lrcContent = mergeLrcWithTranslation(lyricData.lrc.lyric, lyricData.tlyric.lyric);
} }
// 与歌曲下载一致:使用设置中的文件名格式模板拼接歌词文件名(#655) const artistNames = (song.ar || song.song?.artists)
const nameFormat = ?.map((a: { name: string }) => a.name)
(ipcRenderer?.sendSync('get-store-value', 'set.downloadNameFormat') as string) || .join(',');
'{songName} - {artistName}'; const filename = `${song.name} - ${artistNames}`;
const artistNames =
(song.ar || song.song?.artists)?.map((a: { name: string }) => a.name).join('、') ||
'未知艺术家';
const albumName = song.al?.name || '未知专辑';
const filename = nameFormat
.replace(/\{songName\}/g, song.name || '')
.replace(/\{artistName\}/g, artistNames)
.replace(/\{albumName\}/g, albumName);
const result = await ipcRenderer?.invoke('save-lyric-file', { filename, lrcContent }); const result = await ipcRenderer?.invoke('save-lyric-file', { filename, lrcContent });
-76
View File
@@ -1,76 +0,0 @@
import { onBeforeUnmount, ref } from 'vue';
import { textColors } from '@/hooks/MusicHook';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
type UseLyricBackgroundOptions = {
/**
* --bg-color CSS
* - --bg-color
* - undefined DEFAULT_BG_COLOR
* DEFAULT_BG_COLOR
*/
writeBgColor?: () => string | undefined;
};
const DEFAULT_BG_COLOR = 'rgba(25, 25, 25, 1)';
export function useLyricBackground(options: UseLyricBackgroundOptions = {}) {
const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
const { writeBgColor } = options;
const root = document.documentElement;
const applyBackground = (background: string) => {
if (!background) {
textColors.value = getTextColors();
root.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
root.style.setProperty('--text-color-primary', textColors.value.primary);
root.style.setProperty('--text-color-active', textColors.value.active);
if (writeBgColor) {
root.style.setProperty('--bg-color', DEFAULT_BG_COLOR);
}
return;
}
textColors.value = getTextColors(background);
isDark.value = textColors.value.active === '#000000';
root.style.setProperty('--hover-bg-color', getHoverBackgroundColor(isDark.value));
root.style.setProperty('--text-color-primary', textColors.value.primary);
root.style.setProperty('--text-color-active', textColors.value.active);
if (writeBgColor) {
const bg = writeBgColor();
root.style.setProperty('--bg-color', bg || DEFAULT_BG_COLOR);
}
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
const result = animateGradient(currentBackground.value, background, (gradient) => {
currentBackground.value = gradient;
});
if (typeof result === 'number') {
animationFrame.value = result;
}
} else {
currentBackground.value = background;
}
};
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
});
return {
isDark,
currentBackground,
applyBackground
};
}
+16 -4
View File
@@ -6,7 +6,8 @@ import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { playbackRequestManager } from '@/services/playbackRequestManager'; import { playbackRequestManager } from '@/services/playbackRequestManager';
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager'; import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music'; import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
import { isElectron } from '@/utils'; import { getImgUrl, isElectron } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser'; import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
const { message } = createDiscreteApi(['message']); const { message } = createDiscreteApi(['message']);
@@ -371,9 +372,11 @@ export const useLyrics = () => {
}; };
/** /**
* - URL *
*/ */
export const useSongDetail = () => { export const useSongDetail = () => {
const { getSongUrl } = useSongUrl();
const getSongDetail = async (playMusic: SongResult, requestId?: string) => { const getSongDetail = async (playMusic: SongResult, requestId?: string) => {
// 验证请求 // 验证请求
if (requestId && !playbackRequestManager.isRequestValid(requestId)) { if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
@@ -402,10 +405,19 @@ export const useSongDetail = () => {
playMusic.createdAt = Date.now(); playMusic.createdAt = Date.now();
// 半小时后过期 // 半小时后过期
playMusic.expiredAt = playMusic.createdAt + 1800000; playMusic.expiredAt = playMusic.createdAt + 1800000;
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
// 验证请求
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log(`[getSongDetail] 背景色获取后请求已失效: ${requestId}`);
throw new Error('Request cancelled');
}
playMusic.playLoading = false; playMusic.playLoading = false;
// 返回歌曲信息,背景色和歌词将在播放后异步加载 return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult;
return { ...playMusic, playMusicUrl } as SongResult;
} catch (error) { } catch (error) {
if ((error as Error).message === 'Request cancelled') { if ((error as Error).message === 'Request cancelled') {
throw error; throw error;
+9 -9
View File
@@ -9,10 +9,7 @@ import { usePlayerStore } from '@/store/modules/player';
export function useVolumeControl() { export function useVolumeControl() {
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
/** 是否静音 */ /** 音量滑块值 (0-100) */
const isMuted = computed(() => playerStore.isMuted);
/** 音量滑块值 (0-100),静音时仍展示原始音量 */
const volumeSlider = computed({ const volumeSlider = computed({
get: () => playerStore.volume * 100, get: () => playerStore.volume * 100,
set: (value: number) => { set: (value: number) => {
@@ -22,17 +19,21 @@ export function useVolumeControl() {
/** 音量图标 class */ /** 音量图标 class */
const volumeIcon = computed(() => { const volumeIcon = computed(() => {
if (playerStore.isMuted || playerStore.volume === 0) return 'ri-volume-mute-line'; if (playerStore.volume === 0) return 'ri-volume-mute-line';
if (playerStore.volume <= 0.5) return 'ri-volume-down-line'; if (playerStore.volume <= 0.5) return 'ri-volume-down-line';
return 'ri-volume-up-line'; return 'ri-volume-up-line';
}); });
/** 切换静音(保留静音前的音量) */ /** 静音切换 (0 ↔ 30%) */
const mute = () => { const mute = () => {
playerStore.toggleMute(); if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
}; };
/** 鼠标滚轮调整音量 ±5%;静音时向上滚轮会自动解除静音 */ /** 鼠标滚轮调整音量 ±5% */
const handleVolumeWheel = (e: WheelEvent) => { const handleVolumeWheel = (e: WheelEvent) => {
const delta = e.deltaY < 0 ? 5 : -5; const delta = e.deltaY < 0 ? 5 : -5;
const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100); const newValue = Math.min(Math.max(volumeSlider.value + delta, 0), 100);
@@ -40,7 +41,6 @@ export function useVolumeControl() {
}; };
return { return {
isMuted,
volumeSlider, volumeSlider,
volumeIcon, volumeIcon,
mute, mute,
-1
View File
@@ -25,7 +25,6 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" /> <meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="manifest" href="/manifest.json" />
<!-- 资源预加载 --> <!-- 资源预加载 -->
<link rel="preload" href="./assets/icon/iconfont.css" as="style" /> <link rel="preload" href="./assets/icon/iconfont.css" as="style" />
-9
View File
@@ -122,15 +122,6 @@ const isPhone = computed(() => settingsStore.isMobile);
onMounted(() => { onMounted(() => {
settingsStore.initializeSettings(); settingsStore.initializeSettings();
settingsStore.initializeTheme(); settingsStore.initializeTheme();
// Mini ""#504
const pendingSongId = localStorage.getItem('pendingAddToPlaylistSongId');
if (pendingSongId) {
localStorage.removeItem('pendingAddToPlaylistSongId');
nextTick(() => {
openPlaylistDrawer(Number(pendingSongId));
});
}
}); });
const showPlaylistDrawer = ref(false); const showPlaylistDrawer = ref(false);
+1 -9
View File
@@ -382,22 +382,14 @@ const showSuggestions = ref(false);
const suggestionsLoading = ref(false); const suggestionsLoading = ref(false);
const highlightedIndex = ref(-1); const highlightedIndex = ref(-1);
//
let lastSuggestKeyword = '';
const debouncedSuggest = useDebounceFn(async (kw: string) => { const debouncedSuggest = useDebounceFn(async (kw: string) => {
if (!kw.trim()) { if (!kw.trim()) {
suggestions.value = []; suggestions.value = [];
showSuggestions.value = false; showSuggestions.value = false;
return; return;
} }
lastSuggestKeyword = kw;
suggestionsLoading.value = true; suggestionsLoading.value = true;
const result = await getSearchSuggestions(kw); suggestions.value = await getSearchSuggestions(kw);
//
if (kw !== lastSuggestKeyword) {
return;
}
suggestions.value = result;
suggestionsLoading.value = false; suggestionsLoading.value = false;
showSuggestions.value = suggestions.value.length > 0; showSuggestions.value = suggestions.value.length > 0;
highlightedIndex.value = -1; highlightedIndex.value = -1;
-10
View File
@@ -8,20 +8,10 @@ import { createApp } from 'vue';
import i18n from '@/../i18n/renderer'; import i18n from '@/../i18n/renderer';
import router from '@/router'; import router from '@/router';
import pinia from '@/store'; import pinia from '@/store';
import { isElectron } from '@/utils';
import App from './App.vue'; import App from './App.vue';
import directives from './directive'; import directives from './directive';
// Web 端注册最小 Service Worker,使站点满足 PWA 可安装条件(#640/#382
if (!isElectron && import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.warn('[PWA] Service Worker 注册失败:', error);
});
});
}
const app = createApp(App); const app = createApp(App);
Object.keys(directives).forEach((key: string) => { Object.keys(directives).forEach((key: string) => {
+1 -1
View File
@@ -366,7 +366,7 @@ export class LxMusicSourceRunner {
this.initResolver = resolve; this.initResolver = resolve;
this.initRejecter = reject; this.initRejecter = reject;
this.initTimeoutId = window.setTimeout(() => { this.initTimeoutId = window.setTimeout(() => {
this.rejectInitialization(new Error('脚本初始化超时10 秒内未调用 lx.send(inited)')); this.rejectInitialization(new Error('脚本初始化超时'));
}, 10000); }, 10000);
}); });
+11 -32
View File
@@ -1,6 +1,6 @@
import type { AudioOutputDevice } from '@/types/audio'; import type { AudioOutputDevice } from '@/types/audio';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
import { getImgUrl, isElectron } from '@/utils'; import { isElectron } from '@/utils';
class AudioService { class AudioService {
private audio: HTMLAudioElement; private audio: HTMLAudioElement;
@@ -19,11 +19,6 @@ class AudioService {
private operationLock = false; private operationLock = false;
private operationLockTimer: ReturnType<typeof setTimeout> | null = null; private operationLockTimer: ReturnType<typeof setTimeout> | null = null;
// 当前一次加载(play)挂载在共享 audio 元素上的 canplay/error 监听器的清理函数。
// 快速切歌时,上一首尚未结算的监听器必须在新一轮加载或 stop() 时移除,
// 否则新歌 canplay 会同时触发旧歌的回调,导致"过期回调 stop 掉正在播放的新歌"卡死。
private pendingLoadCleanup: (() => void) | null = null;
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
private defaultEQSettings: { [key: string]: number } = { private defaultEQSettings: { [key: string]: number } = {
@@ -150,10 +145,8 @@ class AudioService {
? track.ar.map((a) => a.name) ? track.ar.map((a) => a.name)
: track.song.artists?.map((a) => a.name); : track.song.artists?.map((a) => a.name);
const album = track.al ? track.al.name : track.song.album.name; const album = track.al ? track.al.name : track.song.album.name;
// 上限提到 1024 提升 SMTC/AMLL 等系统媒体控件的封面清晰度(#595); const artwork = ['96', '128', '192', '256', '384', '512'].map((size) => ({
// 走 getImgUrl 以正确处理 data:/local:// 封面与已带参数的 URL src: `${track.picUrl}?param=${size}y${size}`,
const artwork = ['96', '128', '192', '256', '384', '512', '1024'].map((size) => ({
src: getImgUrl(track.picUrl, `${size}y${size}`),
type: 'image/jpg', type: 'image/jpg',
sizes: `${size}x${size}` sizes: `${size}x${size}`
})); }));
@@ -439,12 +432,6 @@ class AudioService {
return Promise.resolve(this.audio); return Promise.resolve(this.audio);
} }
// 开始新一轮加载前,先移除上一轮尚未结算的加载监听器,避免污染新歌
if (this.pendingLoadCleanup) {
this.pendingLoadCleanup();
this.pendingLoadCleanup = null;
}
return new Promise<HTMLAudioElement>((resolve, reject) => { return new Promise<HTMLAudioElement>((resolve, reject) => {
let retryCount = 0; let retryCount = 0;
const maxRetries = 1; const maxRetries = 1;
@@ -495,11 +482,7 @@ class AudioService {
console.error('Audio load error:', error?.code, error?.message); console.error('Audio load error:', error?.code, error?.message);
this.emit('loaderror', { track, error }); this.emit('loaderror', { track, error });
// MEDIA_ERR_SRC_NOT_SUPPORTED(4):源本身无效(URL 已失效/返回了 if (retryCount < maxRetries) {
// 非音频内容),用同一 URL 重试毫无意义,直接走 url_expired 换新 URL
const isSrcNotSupported = error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED;
if (!isSrcNotSupported && retryCount < maxRetries) {
retryCount++; retryCount++;
console.log(`Retrying playback (${retryCount}/${maxRetries})...`); console.log(`Retrying playback (${retryCount}/${maxRetries})...`);
setTimeout(tryPlay, 1000 * retryCount); setTimeout(tryPlay, 1000 * retryCount);
@@ -513,14 +496,8 @@ class AudioService {
const cleanup = () => { const cleanup = () => {
this.audio.removeEventListener('canplay', onCanPlay); this.audio.removeEventListener('canplay', onCanPlay);
this.audio.removeEventListener('error', onError); this.audio.removeEventListener('error', onError);
if (this.pendingLoadCleanup === cleanup) {
this.pendingLoadCleanup = null;
}
}; };
// 记录本轮清理函数,供下一次 play()/stop() 在结算前主动移除
this.pendingLoadCleanup = cleanup;
this.audio.addEventListener('canplay', onCanPlay, { once: true }); this.audio.addEventListener('canplay', onCanPlay, { once: true });
this.audio.addEventListener('error', onError, { once: true }); this.audio.addEventListener('error', onError, { once: true });
@@ -546,11 +523,6 @@ class AudioService {
public stop() { public stop() {
this.forceResetOperationLock(); this.forceResetOperationLock();
// 移除尚未结算的加载监听器,避免 stop 后旧的 canplay/error 仍触发过期回调
if (this.pendingLoadCleanup) {
this.pendingLoadCleanup();
this.pendingLoadCleanup = null;
}
try { try {
this.audio.pause(); this.audio.pause();
this.audio.removeAttribute('src'); this.audio.removeAttribute('src');
@@ -633,6 +605,13 @@ class AudioService {
public async getAudioOutputDevices(): Promise<AudioOutputDevice[]> { public async getAudioOutputDevices(): Promise<AudioOutputDevice[]> {
try { try {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => track.stop());
} catch {
// Continue even if permission denied
}
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
const audioOutputs = devices.filter((d) => d.kind === 'audiooutput'); const audioOutputs = devices.filter((d) => d.kind === 'audiooutput');
+54 -124
View File
@@ -114,7 +114,7 @@ const loadAndPlayAudio = async (song: SongResult, shouldPlay: boolean): Promise<
}; };
/** /**
* / * /
*/ */
const triggerPreload = async (song: SongResult): Promise<void> => { const triggerPreload = async (song: SongResult): Promise<void> => {
try { try {
@@ -125,8 +125,9 @@ const triggerPreload = async (song: SongResult): Promise<void> => {
(item: SongResult) => item.id === song.id && item.source === song.source (item: SongResult) => item.id === song.id && item.source === song.source
); );
if (idx !== -1) { if (idx !== -1) {
// 立即触发预加载,不等待 setTimeout(() => {
playlistStore.preloadNextSongs(idx); playlistStore.preloadNextSongs(idx);
}, 3000);
} }
} }
} catch (e) { } catch (e) {
@@ -151,7 +152,7 @@ const updateDocumentTitle = (music: SongResult): void => {
// ==================== 导出函数 ==================== // ==================== 导出函数 ====================
/** /**
* *
* *
* @param music * @param music
* @param shouldPlay true * @param shouldPlay true
@@ -192,40 +193,32 @@ export const playTrack = async (
playerCore.isPlay = shouldPlay; playerCore.isPlay = shouldPlay;
playerCore.userPlayIntent = shouldPlay; playerCore.userPlayIntent = shouldPlay;
// 4. 先设置基本歌曲信息(立即显示UI // 4. 加载元数据(歌词 + 背景色
try {
const { lyrics, backgroundColor, primaryColor } = await loadMetadata(music);
// 检查 generation
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(加载元数据后),当前 gen=${generation}`);
return false;
}
music.lyric = lyrics;
music.backgroundColor = backgroundColor;
music.primaryColor = primaryColor;
} catch (error) {
if (gen !== generation) return false;
console.error('[playbackController] 加载元数据失败:', error);
// 元数据加载失败不阻塞播放,继续执行
}
// 5. 歌词已加载,现在设置 playMusic(触发 MusicHook 的歌词 watcher
music.playLoading = true; music.playLoading = true;
playerCore.playMusic = music; playerCore.playMusic = music;
updateDocumentTitle(music); updateDocumentTitle(music);
// 保存原始歌曲数据
const originalMusic = { ...music }; const originalMusic = { ...music };
// 4.5 立即并行加载元数据(歌词 + 背景色),与取 URL/加载音频同时进行:
// 不阻塞出声,但让全屏页的封面/歌词/背景色尽量在音频起播前就绪,
// 避免"先响歌、后换脸"的割裂感。预加载过的歌曲两者都是缓存命中,立即应用
let loadedMetadata: {
lyrics: SongResult['lyric'];
backgroundColor: string;
primaryColor: string;
} | null = null;
const applyLoadedMetadata = () => {
if (!loadedMetadata || gen !== generation) return;
playerCore.playMusic.lyric = loadedMetadata.lyrics;
playerCore.playMusic.backgroundColor = loadedMetadata.backgroundColor;
playerCore.playMusic.primaryColor = loadedMetadata.primaryColor;
// 触发 watcher 更新
playerCore.playMusic = { ...playerCore.playMusic };
};
loadMetadata(originalMusic)
.then((result) => {
loadedMetadata = result;
applyLoadedMetadata();
})
.catch((error) => {
// 元数据加载失败不阻塞播放
console.warn('[playbackController] 元数据加载失败:', error);
});
// 5. 添加到播放历史 // 5. 添加到播放历史
try { try {
const playHistoryStore = await getPlayHistoryStore(); const playHistoryStore = await getPlayHistoryStore();
@@ -240,7 +233,7 @@ export const playTrack = async (
console.warn('[playbackController] 添加播放历史失败:', e); console.warn('[playbackController] 添加播放历史失败:', e);
} }
// 6. 获取歌曲详情(解析 URL)- 这是必须的,不能跳过 // 6. 获取歌曲详情(解析 URL)
try { try {
const { getSongDetail } = useSongDetail(); const { getSongDetail } = useSongDetail();
const updatedPlayMusic = await getSongDetail(originalMusic, requestId); const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
@@ -251,11 +244,10 @@ export const playTrack = async (
return false; return false;
} }
updatedPlayMusic.lyric = music.lyric;
playerCore.playMusic = updatedPlayMusic; playerCore.playMusic = updatedPlayMusic;
playerCore.playMusicUrl = updatedPlayMusic.playMusicUrl as string; playerCore.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
music.playMusicUrl = updatedPlayMusic.playMusicUrl as string; music.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
// playMusic 被替换为新对象,若元数据已先行到达则重新应用,避免歌词/背景色丢失
applyLoadedMetadata();
} catch (error) { } catch (error) {
if (gen !== generation) return false; if (gen !== generation) return false;
console.error('[playbackController] 获取歌曲详情失败:', error); console.error('[playbackController] 获取歌曲详情失败:', error);
@@ -267,7 +259,10 @@ export const playTrack = async (
return false; return false;
} }
// 7. 加载并播放音频(优先播放 // 7. 触发预加载下一首(异步,不阻塞
triggerPreload(playerCore.playMusic);
// 8. 加载并播放音频
try { try {
const success = await loadAndPlayAudio(playerCore.playMusic, shouldPlay); const success = await loadAndPlayAudio(playerCore.playMusic, shouldPlay);
@@ -279,25 +274,18 @@ export const playTrack = async (
} }
if (success) { if (success) {
// 8. 元数据已在 4.5 步与播放并行加载,此处无需再处理 // 9. 播放成功
// 9. 播放成功,重置 URL 过期恢复计数
resetUrlExpiredRetry();
playerCore.playMusic.playLoading = false; playerCore.playMusic.playLoading = false;
playerCore.playMusic.isFirstPlay = false; playerCore.playMusic.isFirstPlay = false;
playbackRequestManager.completeRequest(requestId); playbackRequestManager.completeRequest(requestId);
console.log(`[playbackController] gen=${gen} 播放成功: ${music.name}`); console.log(`[playbackController] gen=${gen} 播放成功: ${music.name}`);
// 10. 触发预加载下一首(立即触发,不等待)
triggerPreload(playerCore.playMusic);
return true; return true;
} else { } else {
playbackRequestManager.failRequest(requestId); playbackRequestManager.failRequest(requestId);
return false; return false;
} }
} catch (error) { } catch (error) {
// 11. 播放失败 // 10. 播放失败
if (gen !== generation) { if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(播放异常),静默返回`); console.log(`[playbackController] gen=${gen} 已过期(播放异常),静默返回`);
return false; return false;
@@ -390,41 +378,9 @@ export const reparseCurrentSong = async (
} }
}; };
/**
* URL
* /
*
*/
const MAX_URL_EXPIRED_RETRIES = 1;
let urlExpiredRetrySongId: string | number | null = null;
let urlExpiredRetryCount = 0;
/** playTrack 成功后重置恢复计数(导出给 playTrack 内部使用) */
const resetUrlExpiredRetry = (): void => {
urlExpiredRetrySongId = null;
urlExpiredRetryCount = 0;
};
/**
* URL
* URL HTML
* Format error URL
*/
const clearParsedUrlCache = async (songId: string | number): Promise<void> => {
// 本地歌曲 id 为 hex 字符串,无解析缓存可清
if (!/^\d+$/.test(String(songId))) return;
try {
const { CacheManager } = await import('@/api/musicParser');
await CacheManager.clearMusicCache(Number(songId));
} catch (error) {
console.warn('[playbackController] 清除URL缓存失败:', error);
}
};
/** /**
* URL * URL
* audioService url_expired URL * audioService url_expired URL
*
*/ */
export const setupUrlExpiredHandler = (): void => { export const setupUrlExpiredHandler = (): void => {
audioService.on('url_expired', async (expiredTrack: SongResult) => { audioService.on('url_expired', async (expiredTrack: SongResult) => {
@@ -440,42 +396,6 @@ export const setupUrlExpiredHandler = (): void => {
return; return;
} }
// 当前歌曲已变更(例如 nextPlay 的失败跳歌已接管),不再恢复旧歌,
// 避免两个恢复驱动并发争抢 generation
if (playerCore.playMusic?.id !== expiredTrack.id) {
console.log('[playbackController] 当前歌曲已变更,跳过URL过期处理');
return;
}
// 统计同一首歌的连续恢复次数
if (urlExpiredRetrySongId === expiredTrack.id) {
urlExpiredRetryCount++;
} else {
urlExpiredRetrySongId = expiredTrack.id;
urlExpiredRetryCount = 1;
}
// 先清掉可能已损坏的解析 URL 缓存,让重试真正拿到新 URL
await clearParsedUrlCache(expiredTrack.id);
// 超过重试上限:不再原地循环,静默切下一首
if (urlExpiredRetryCount > MAX_URL_EXPIRED_RETRIES) {
console.warn(`[playbackController] ${expiredTrack.name} 恢复重试失败,切换下一首`);
resetUrlExpiredRetry();
try {
const playlistStore = await getPlaylistStore();
if (playlistStore.playList.length > 1 || playerCore.isFmPlaying) {
playlistStore.nextPlay();
} else {
playerCore.setIsPlay(false);
}
} catch (error) {
console.error('[playbackController] 切换下一首失败:', error);
playerCore.setIsPlay(false);
}
return;
}
// 保存当前播放位置 // 保存当前播放位置
const currentSound = audioService.getCurrentSound(); const currentSound = audioService.getCurrentSound();
let seekPosition = 0; let seekPosition = 0;
@@ -499,21 +419,31 @@ export const setupUrlExpiredHandler = (): void => {
playMusicUrl: undefined playMusicUrl: undefined
}; };
// 静默重试:成功恢复位置,失败交由下一次 url_expired 事件按上限切歌
const success = await playTrack(trackToPlay, true); const success = await playTrack(trackToPlay, true);
if (success && seekPosition > 0) { if (success) {
// 延迟一小段时间确保音频已就绪 // 恢复播放位置
setTimeout(() => { if (seekPosition > 0) {
try { // 延迟一小段时间确保音频已就绪
audioService.seek(seekPosition); setTimeout(() => {
} catch { try {
console.warn('[playbackController] 恢复播放位置失败'); audioService.seek(seekPosition);
} } catch {
}, 300); console.warn('[playbackController] 恢复播放位置失败');
}
}, 300);
}
message.success(i18n.global.t('player.autoResumed'));
} else {
// 检查歌曲是否仍然是当前歌曲
const currentPlayerCore = await getPlayerCoreStore();
if (currentPlayerCore.playMusic?.id === expiredTrack.id) {
message.error(i18n.global.t('player.resumeFailed'));
}
} }
} catch (error) { } catch (error) {
console.error('[playbackController] 处理URL过期事件失败:', error); console.error('[playbackController] 处理URL过期事件失败:', error);
message.error(i18n.global.t('player.resumeFailed'));
} }
}); });
}; };
+3 -19
View File
@@ -10,9 +10,6 @@ class PreloadService {
private validatedUrls: Map<string | number, string> = new Map(); private validatedUrls: Map<string | number, string> = new Map();
private loadingPromises: Map<string | number, Promise<string>> = new Map(); private loadingPromises: Map<string | number, Promise<string>> = new Map();
// 已验证 URL 缓存的最大条目数,超出后按插入顺序淘汰最旧项,避免长会话内无界增长
private static readonly MAX_VALIDATED_URLS = 100;
/** /**
* URL * URL
* HEAD URL 访 * HEAD URL 访
@@ -47,13 +44,6 @@ class PreloadService {
try { try {
const validatedUrl = await loadPromise; const validatedUrl = await loadPromise;
this.validatedUrls.set(song.id, validatedUrl); this.validatedUrls.set(song.id, validatedUrl);
// 超出容量上限时淘汰最旧插入的条目
if (this.validatedUrls.size > PreloadService.MAX_VALIDATED_URLS) {
const oldestKey = this.validatedUrls.keys().next().value;
if (oldestKey !== undefined) {
this.validatedUrls.delete(oldestKey);
}
}
return validatedUrl; return validatedUrl;
} finally { } finally {
this.loadingPromises.delete(song.id); this.loadingPromises.delete(song.id);
@@ -69,13 +59,7 @@ class PreloadService {
testAudio.crossOrigin = 'anonymous'; testAudio.crossOrigin = 'anonymous';
testAudio.preload = 'metadata'; testAudio.preload = 'metadata';
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => { const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
testAudio.removeEventListener('loadedmetadata', onLoaded); testAudio.removeEventListener('loadedmetadata', onLoaded);
testAudio.removeEventListener('error', onError); testAudio.removeEventListener('error', onError);
testAudio.src = ''; testAudio.src = '';
@@ -120,12 +104,12 @@ class PreloadService {
testAudio.src = url; testAudio.src = url;
testAudio.load(); testAudio.load();
// 3秒超时(优化预加载速度) // 5秒超时
timeoutId = setTimeout(() => { setTimeout(() => {
cleanup(); cleanup();
// 超时不算失败,URL 可能是可用的只是服务器慢 // 超时不算失败,URL 可能是可用的只是服务器慢
resolve(url); resolve(url);
}, 3000); }, 5000);
}); });
} }
@@ -67,6 +67,7 @@ type HostWorkerMessage =
| HostLogMessage; | HostLogMessage;
let requestHandler: ((data: any) => Promise<any>) | null = null; let requestHandler: ((data: any) => Promise<any>) | null = null;
let initialized = false;
let requestCounter = 0; let requestCounter = 0;
const pendingHttpCallbacks = new Map< const pendingHttpCallbacks = new Map<
@@ -86,90 +87,6 @@ const postLog = (level: HostLogMessage['level'], ...args: any[]) => {
}); });
}; };
/**
* Node Buffer
* lx-music-desktop Node
* buffer.from/bufToString encoding base64/hex
* Buffer .toString(encoding)
*/
class LxBuffer extends Uint8Array {
toString(encoding = 'utf-8'): string {
return bytesToString(this, encoding);
}
}
const toLxBuffer = (bytes: Uint8Array): LxBuffer =>
new LxBuffer(bytes.buffer as ArrayBuffer, bytes.byteOffset, bytes.byteLength);
const hexToBytes = (hex: string): Uint8Array => {
const clean = hex.length % 2 ? `0${hex}` : hex;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16) || 0;
}
return out;
};
const toBytes = (data: any, encoding = 'utf-8'): Uint8Array => {
if (typeof data === 'string') {
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Decode(data);
case 'hex':
return hexToBytes(data);
case 'binary':
case 'latin1': {
const out = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
out[i] = data.charCodeAt(i) & 0xff;
}
return out;
}
default:
return new TextEncoder().encode(data);
}
}
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (Array.isArray(data)) return Uint8Array.from(data);
return new Uint8Array(0);
};
const bytesToString = (data: any, encoding = 'utf-8'): string => {
const bytes = toBytes(data);
switch (encoding.toLowerCase()) {
case 'base64':
return lxCrypto.base64Encode(bytes);
case 'hex': {
let out = '';
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
return out;
}
case 'binary':
case 'latin1': {
let out = '';
for (const byte of bytes) out += String.fromCharCode(byte);
return out;
}
default:
return new TextDecoder(encoding).decode(bytes);
}
};
/**
* lx-music-desktop BrowserWindowwindow
* `window -> (process/require ? global : this)`
* module worker undefined访 `.console`
* "Cannot read properties of undefined (reading 'console')"
*/
const exposeNodeLikeGlobals = () => {
const g = globalThis as any;
if (!g.window) g.window = g;
if (!g.global) g.global = g;
};
const hardenGlobalScope = () => { const hardenGlobalScope = () => {
const blockedKeys: Array<keyof typeof globalThis> = [ const blockedKeys: Array<keyof typeof globalThis> = [
'fetch', 'fetch',
@@ -213,6 +130,7 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
}, },
send: (eventName: string, data: any) => { send: (eventName: string, data: any) => {
if (eventName === 'inited') { if (eventName === 'inited') {
initialized = true;
postToHost({ postToHost({
type: 'initialized', type: 'initialized',
data: data as LxInitedData data: data as LxInitedData
@@ -240,37 +158,27 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
}, },
utils: { utils: {
buffer: { buffer: {
from: (data: any, encoding?: string) => toLxBuffer(toBytes(data, encoding)), from: (data: any, _encoding?: string) => {
bufToString: (buffer: any, encoding?: string) => bytesToString(buffer, encoding) if (typeof data === 'string') {
return new TextEncoder().encode(data);
}
return new Uint8Array(data);
},
bufToString: (buffer: Uint8Array, encoding?: string) => {
return new TextDecoder(encoding || 'utf-8').decode(buffer);
}
}, },
crypto: { crypto: {
md5: lxCrypto.md5, md5: lxCrypto.md5,
sha1: lxCrypto.sha1, sha1: lxCrypto.sha1,
sha256: lxCrypto.sha256, sha256: lxCrypto.sha256,
// lx-music 中 randomBytes 返回 Node Buffer,脚本会对其调用 .toString(encoding) randomBytes: lxCrypto.randomBytes,
randomBytes: (size: number) => { aesEncrypt: lxCrypto.aesEncrypt,
const bytes = new Uint8Array(size); aesDecrypt: lxCrypto.aesDecrypt,
crypto.getRandomValues(bytes); rsaEncrypt: lxCrypto.rsaEncrypt,
return toLxBuffer(bytes); rsaDecrypt: lxCrypto.rsaDecrypt,
},
aesEncrypt: (
buffer: string | Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesEncrypt(buffer, mode, key as any, iv as any)),
aesDecrypt: (
buffer: Uint8Array,
mode: string,
key: string | Uint8Array,
iv: string | Uint8Array
) => toLxBuffer(lxCrypto.aesDecrypt(buffer, mode, key as any, iv as any)),
rsaEncrypt: (buffer: string | Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaEncrypt(buffer, key)),
rsaDecrypt: (buffer: Uint8Array, key: string) =>
toLxBuffer(lxCrypto.rsaDecrypt(buffer, key)),
base64Encode: lxCrypto.base64Encode, base64Encode: lxCrypto.base64Encode,
base64Decode: (str: string) => toLxBuffer(lxCrypto.base64Decode(str)) base64Decode: lxCrypto.base64Decode
}, },
zlib: { zlib: {
inflate: async (buffer: ArrayBuffer) => { inflate: async (buffer: ArrayBuffer) => {
@@ -332,6 +240,7 @@ const createLxApi = (scriptInfo: LxScriptInfo) => {
const resetWorkerState = () => { const resetWorkerState = () => {
requestHandler = null; requestHandler = null;
initialized = false;
pendingHttpCallbacks.clear(); pendingHttpCallbacks.clear();
requestCounter = 0; requestCounter = 0;
}; };
@@ -339,7 +248,6 @@ const resetWorkerState = () => {
const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => { const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
resetWorkerState(); resetWorkerState();
hardenGlobalScope(); hardenGlobalScope();
exposeNodeLikeGlobals();
(globalThis as any).lx = createLxApi(scriptInfo); (globalThis as any).lx = createLxApi(scriptInfo);
@@ -357,8 +265,9 @@ const initializeScript = async (script: string, scriptInfo: LxScriptInfo) => {
try { try {
await import(/* @vite-ignore */ scriptUrl); await import(/* @vite-ignore */ scriptUrl);
// 不在此处判定 initialized:脚本可能异步初始化(如先请求远端配置, if (!initialized) {
// 回调里才调用 lx.send(inited)),由 Runner 侧的初始化超时兜底 throw new Error('脚本未调用 lx.send(EVENT_NAMES.inited, data)');
}
} finally { } finally {
URL.revokeObjectURL(scriptUrl); URL.revokeObjectURL(scriptUrl);
} }
+2 -36
View File
@@ -127,9 +127,6 @@ export const useLocalMusicStore = defineStore(
// 磁盘上实际存在的文件路径集合(扫描时收集) // 磁盘上实际存在的文件路径集合(扫描时收集)
const diskFilePaths = new Set<string>(); const diskFilePaths = new Set<string>();
// 扫描失败的文件夹:其下的缓存条目不参与"已删除清理",
// 避免移动盘/网络盘暂时不可用时整个文件夹的歌曲被误删(#713)
const failedFolders: string[] = [];
// 遍历每个文件夹进行扫描 // 遍历每个文件夹进行扫描
for (const folderPath of folderPaths.value) { for (const folderPath of folderPaths.value) {
@@ -141,7 +138,6 @@ export const useLocalMusicStore = defineStore(
if ((result as any).error) { if ((result as any).error) {
console.error(`扫描文件夹失败: ${folderPath}`, (result as any).error); console.error(`扫描文件夹失败: ${folderPath}`, (result as any).error);
message.error(`扫描失败: ${(result as any).error}`); message.error(`扫描失败: ${(result as any).error}`);
failedFolders.push(folderPath);
continue; continue;
} }
@@ -154,15 +150,10 @@ export const useLocalMusicStore = defineStore(
} }
// 2. 增量扫描:基于修改时间筛选需重新解析的文件 // 2. 增量扫描:基于修改时间筛选需重新解析的文件
// 老条目(无 coverPath 字段)也视为需要重新解析,让数据自愈到统一格式
const parseTargets: string[] = []; const parseTargets: string[] = [];
for (const file of files) { for (const file of files) {
const cached = cachedMap.get(file.path); const cached = cachedMap.get(file.path);
if ( if (!cached || cached.modifiedTime !== file.modifiedTime) {
!cached ||
cached.modifiedTime !== file.modifiedTime ||
!('coverPath' in cached)
) {
parseTargets.push(file.path); parseTargets.push(file.path);
} }
} }
@@ -182,23 +173,12 @@ export const useLocalMusicStore = defineStore(
} catch (error) { } catch (error) {
console.error(`扫描文件夹出错: ${folderPath}`, error); console.error(`扫描文件夹出错: ${folderPath}`, error);
message.error(`扫描文件夹出错: ${folderPath}`); message.error(`扫描文件夹出错: ${folderPath}`);
failedFolders.push(folderPath);
} }
} }
/** 判断文件路径是否位于某个扫描失败的文件夹下 */
const isUnderFailedFolder = (filePath: string): boolean =>
failedFolders.some((folder) => {
if (!filePath.startsWith(folder)) return false;
if (folder.endsWith('/') || folder.endsWith('\\')) return true;
const next = filePath.charAt(folder.length);
return next === '/' || next === '\\';
});
// 4. 清理已删除文件:从 IndexedDB 移除磁盘上不存在的条目 // 4. 清理已删除文件:从 IndexedDB 移除磁盘上不存在的条目
// (扫描失败的文件夹跳过清理,其文件未被枚举并不代表已删除)
for (const [filePath, entry] of cachedMap) { for (const [filePath, entry] of cachedMap) {
if (!diskFilePaths.has(filePath) && !isUnderFailedFolder(filePath)) { if (!diskFilePaths.has(filePath)) {
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id); await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
} }
} }
@@ -228,19 +208,6 @@ export const useLocalMusicStore = defineStore(
} }
} }
/**
* #713
* @param id IDgenerateId hex
*/
async function removeEntry(id: string): Promise<void> {
const localDB = await getDB();
await localDB.deleteData(LOCAL_MUSIC_STORE, id);
const index = musicList.value.findIndex((entry) => entry.id === id);
if (index !== -1) {
musicList.value.splice(index, 1);
}
}
/** /**
* *
*/ */
@@ -299,7 +266,6 @@ export const useLocalMusicStore = defineStore(
removeFolder, removeFolder,
scanFolders, scanFolders,
loadFromCache, loadFromCache,
removeEntry,
clearCache clearCache
}; };
}, },
+104 -161
View File
@@ -3,37 +3,6 @@ import { ref } from 'vue';
import type { SongResult } from '@/types/music'; import type { SongResult } from '@/types/music';
import type { DjProgram } from '@/types/podcast'; import type { DjProgram } from '@/types/podcast';
import { debouncedLocalStorage, flushDebouncedStorage } from '@/utils/debouncedStorage';
import type { MusicHistoryItem } from '@/utils/persistedSong';
import {
minifyHistoryEntry,
minifyHistoryList,
stripBase64CoversList
} from '@/utils/persistedSong';
export type { MusicHistoryItem };
// 一次性清理 v1 时代遗留的 localStorage key。
// 旧版本以 5 个独立 key 单独存历史,新版本合并到 PERSIST_KEY 由 persistedstate 管。
// 不做数据迁移:历史是低关键性衍生数据,老用户升级后看到空"最近播放",重新听几次即可。
// 仅清掉旧 key 释放配额,避免和新 key 双倍占用
const LEGACY_KEYS = [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory',
// v1 迁移方案的 flag,已随 e53a035 发布到用户机器上
'playHistory-migrated',
// v1 时代独立持久化的播放模式,现已并入 player-core-store
'playMode'
];
export const cleanupLegacyPlayHistoryStorage = (): void => {
if (localStorage.getItem('playHistory-cleaned-v1')) return;
LEGACY_KEYS.forEach((key) => localStorage.removeItem(key));
localStorage.setItem('playHistory-cleaned-v1', '1');
};
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
@@ -85,43 +54,6 @@ export type PodcastRadioHistoryItem = {
// 历史记录最大条数 // 历史记录最大条数
const MAX_HISTORY_SIZE = 500; const MAX_HISTORY_SIZE = 500;
// persistedstate 的 storage key
const PERSIST_KEY = 'play-history-store';
// pick 出来的持久化状态,给 persistedstate.serializer 与 clearAll 同步落盘共用
type PersistedPlayHistoryState = {
musicHistory: MusicHistoryItem[];
podcastHistory: DjProgram[];
playlistHistory: PlaylistHistoryItem[];
albumHistory: AlbumHistoryItem[];
podcastRadioHistory: PodcastRadioHistoryItem[];
};
// 序列化层兜底:哪怕有代码绕过 addMusic 直接 push 完整 SongResult,也能在 serialize 时
// 再过一遍 minifyHistoryList,确保 localStorage 里的 musicHistory 不混入 lyric/song/base64 封面
//
// podcast/playlist/album/podcastRadio 等历史也走 stripBase64CoversList 兜底:
// 它们的图片字段(picUrl / coverImgUrl / coverUrl)若被注入 base64 Data URL
// 同样会撑爆 5MB 配额;保持四类历史的防御对称,避免某一处漏掉变成隐患
//
// 入参用 any 是为了同时兼容 persistedstate 的 StateTree 与 clearAll 手工构造的 PersistedPlayHistoryState
const serializePlayHistoryState = (state: any): string => {
const s = state as PersistedPlayHistoryState;
return JSON.stringify({
...state,
musicHistory: minifyHistoryList(
s.musicHistory as unknown as (SongResult & {
count?: number;
lastPlayTime?: number;
})[]
),
podcastHistory: stripBase64CoversList(s.podcastHistory),
playlistHistory: stripBase64CoversList(s.playlistHistory),
albumHistory: stripBase64CoversList(s.albumHistory),
podcastRadioHistory: stripBase64CoversList(s.podcastRadioHistory)
});
};
/** /**
* Store * Store
* 使 Pinia * 使 Pinia
@@ -131,9 +63,7 @@ export const usePlayHistoryStore = defineStore(
'playHistory', 'playHistory',
() => { () => {
// ==================== 状态 ==================== // ==================== 状态 ====================
// musicHistory 用 MusicHistoryItem 而非完整 SongResultlyric/song/expiredAt 等 const musicHistory = ref<SongResult[]>([]);
// 派生字段不进 localStorage,避免 5MB 配额被撑爆。详见 utils/persistedSong.ts
const musicHistory = ref<MusicHistoryItem[]>([]);
const podcastHistory = ref<DjProgram[]>([]); const podcastHistory = ref<DjProgram[]>([]);
const playlistHistory = ref<PlaylistHistoryItem[]>([]); const playlistHistory = ref<PlaylistHistoryItem[]>([]);
const albumHistory = ref<AlbumHistoryItem[]>([]); const albumHistory = ref<AlbumHistoryItem[]>([]);
@@ -141,36 +71,20 @@ export const usePlayHistoryStore = defineStore(
// ==================== 音乐记录 ==================== // ==================== 音乐记录 ====================
// lastPlayTime 语义:每次播放都刷新为当前时间("最近一次播放"),不是"首次添加"。
// 与 playlistHistory/albumHistory 等其他历史保持一致;count 仍为累计播放次数
const addMusic = (music: SongResult): void => { const addMusic = (music: SongResult): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id); const index = musicHistory.value.findIndex((item) => item.id === music.id);
// 单步 ref 重赋值,避免 splice/pop + unshift 多次触发 watch 与持久化
let next: MusicHistoryItem[];
if (index !== -1) { if (index !== -1) {
// 命中已有条目:累加计数 + 刷新时间戳,picUrl/al 等用新数据覆盖(封面可能换了短引用) musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
const existing = musicHistory.value[index]; musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
const refreshed: MusicHistoryItem = {
...minifyHistoryEntry(music),
count: (existing.count || 0) + 1,
lastPlayTime: Date.now()
};
next = [
refreshed,
...musicHistory.value.slice(0, index),
...musicHistory.value.slice(index + 1)
];
} else { } else {
next = [ musicHistory.value.unshift({ ...music, count: 1 });
minifyHistoryEntry({ ...music, count: 1, lastPlayTime: Date.now() }), }
...musicHistory.value if (musicHistory.value.length > MAX_HISTORY_SIZE) {
]; musicHistory.value.pop();
} }
musicHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
}; };
// 删除入参允许 SongResult 或 MusicHistoryItem,仅按 id 匹配,类型放宽不影响逻辑 const delMusic = (music: SongResult): void => {
const delMusic = (music: { id: SongResult['id'] }): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id); const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) { if (index !== -1) {
musicHistory.value.splice(index, 1); musicHistory.value.splice(index, 1);
@@ -181,19 +95,14 @@ export const usePlayHistoryStore = defineStore(
const addPodcast = (program: DjProgram): void => { const addPodcast = (program: DjProgram): void => {
const index = podcastHistory.value.findIndex((item) => item.id === program.id); const index = podcastHistory.value.findIndex((item) => item.id === program.id);
// 单步 ref 重赋值,避免 splice/unshift/pop 多次触发 watch 与持久化(与 addMusic 一致)
let next: DjProgram[];
if (index !== -1) { if (index !== -1) {
next = [ podcastHistory.value.unshift(podcastHistory.value.splice(index, 1)[0]);
podcastHistory.value[index],
...podcastHistory.value.slice(0, index),
...podcastHistory.value.slice(index + 1)
];
} else { } else {
next = [program, ...podcastHistory.value]; podcastHistory.value.unshift(program);
}
if (podcastHistory.value.length > MAX_HISTORY_SIZE) {
podcastHistory.value.pop();
} }
podcastHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
}; };
const delPodcast = (program: DjProgram): void => { const delPodcast = (program: DjProgram): void => {
@@ -208,19 +117,16 @@ export const usePlayHistoryStore = defineStore(
const addPlaylist = (playlist: PlaylistHistoryItem): void => { const addPlaylist = (playlist: PlaylistHistoryItem): void => {
const index = playlistHistory.value.findIndex((item) => item.id === playlist.id); const index = playlistHistory.value.findIndex((item) => item.id === playlist.id);
const now = Date.now(); const now = Date.now();
let next: PlaylistHistoryItem[];
if (index !== -1) { if (index !== -1) {
const existing = playlistHistory.value[index]; playlistHistory.value[index].count = (playlistHistory.value[index].count || 0) + 1;
next = [ playlistHistory.value[index].lastPlayTime = now;
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now }, playlistHistory.value.unshift(playlistHistory.value.splice(index, 1)[0]);
...playlistHistory.value.slice(0, index),
...playlistHistory.value.slice(index + 1)
];
} else { } else {
next = [{ ...playlist, count: 1, lastPlayTime: now }, ...playlistHistory.value]; playlistHistory.value.unshift({ ...playlist, count: 1, lastPlayTime: now });
}
if (playlistHistory.value.length > MAX_HISTORY_SIZE) {
playlistHistory.value.pop();
} }
playlistHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
}; };
const delPlaylist = (playlist: PlaylistHistoryItem): void => { const delPlaylist = (playlist: PlaylistHistoryItem): void => {
@@ -235,18 +141,16 @@ export const usePlayHistoryStore = defineStore(
const addAlbum = (album: AlbumHistoryItem): void => { const addAlbum = (album: AlbumHistoryItem): void => {
const index = albumHistory.value.findIndex((item) => item.id === album.id); const index = albumHistory.value.findIndex((item) => item.id === album.id);
const now = Date.now(); const now = Date.now();
let next: AlbumHistoryItem[];
if (index !== -1) { if (index !== -1) {
const existing = albumHistory.value[index]; albumHistory.value[index].count = (albumHistory.value[index].count || 0) + 1;
next = [ albumHistory.value[index].lastPlayTime = now;
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now }, albumHistory.value.unshift(albumHistory.value.splice(index, 1)[0]);
...albumHistory.value.slice(0, index),
...albumHistory.value.slice(index + 1)
];
} else { } else {
next = [{ ...album, count: 1, lastPlayTime: now }, ...albumHistory.value]; albumHistory.value.unshift({ ...album, count: 1, lastPlayTime: now });
}
if (albumHistory.value.length > MAX_HISTORY_SIZE) {
albumHistory.value.pop();
} }
albumHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
}; };
const delAlbum = (album: AlbumHistoryItem): void => { const delAlbum = (album: AlbumHistoryItem): void => {
@@ -261,19 +165,17 @@ export const usePlayHistoryStore = defineStore(
const addPodcastRadio = (radio: PodcastRadioHistoryItem): void => { const addPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id); const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id);
const now = Date.now(); const now = Date.now();
let next: PodcastRadioHistoryItem[];
if (index !== -1) { if (index !== -1) {
const existing = podcastRadioHistory.value[index]; const existing = podcastRadioHistory.value.splice(index, 1)[0];
next = [ existing.count = (existing.count || 0) + 1;
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now }, existing.lastPlayTime = now;
...podcastRadioHistory.value.slice(0, index), podcastRadioHistory.value.unshift(existing);
...podcastRadioHistory.value.slice(index + 1)
];
} else { } else {
next = [{ ...radio, count: 1, lastPlayTime: now }, ...podcastRadioHistory.value]; podcastRadioHistory.value.unshift({ ...radio, count: 1, lastPlayTime: now });
}
if (podcastRadioHistory.value.length > MAX_HISTORY_SIZE) {
podcastRadioHistory.value.pop();
} }
podcastRadioHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
}; };
const delPodcastRadio = (radio: PodcastRadioHistoryItem): void => { const delPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
@@ -311,25 +213,72 @@ export const usePlayHistoryStore = defineStore(
clearPlaylistHistory(); clearPlaylistHistory();
clearAlbumHistory(); clearAlbumHistory();
clearPodcastRadioHistory(); clearPodcastRadioHistory();
// 同步落盘空状态:persistedstate 的 watch 异步触发,clearAll 同步代码返回时 };
// watch 还没把空状态送进 storage;再叠 2s 防抖窗口 → 立刻 kill -9 会让 PERSIST_KEY
// 留在旧历史。手动 setItem 让"clearAll 返回 = 落盘完成"成立。 // ==================== 数据迁移 ====================
// 前面 flushDebouncedStorage 是顺手清掉别的 store 排队的写入与防抖定时器,
// 避免后续 watch 异步把同一份空状态再写一次(无害但多余 I/O) /**
flushDebouncedStorage(); * localStorage Pinia store
*
*/
const migrateFromLocalStorage = (): void => {
const migrated = localStorage.getItem('playHistory-migrated');
if (migrated) return;
try { try {
localStorage.setItem( // 迁移音乐记录
PERSIST_KEY, const oldMusic = localStorage.getItem('musicHistory');
serializePlayHistoryState({ if (oldMusic) {
musicHistory: [], const parsed = JSON.parse(oldMusic);
podcastHistory: [], if (Array.isArray(parsed) && parsed.length > 0 && musicHistory.value.length === 0) {
playlistHistory: [], musicHistory.value = parsed;
albumHistory: [], }
podcastRadioHistory: [] }
})
); // 迁移播客记录
const oldPodcast = localStorage.getItem('podcastHistory');
if (oldPodcast) {
const parsed = JSON.parse(oldPodcast);
if (Array.isArray(parsed) && parsed.length > 0 && podcastHistory.value.length === 0) {
podcastHistory.value = parsed;
}
}
// 迁移歌单记录
const oldPlaylist = localStorage.getItem('playlistHistory');
if (oldPlaylist) {
const parsed = JSON.parse(oldPlaylist);
if (Array.isArray(parsed) && parsed.length > 0 && playlistHistory.value.length === 0) {
playlistHistory.value = parsed;
}
}
// 迁移专辑记录
const oldAlbum = localStorage.getItem('albumHistory');
if (oldAlbum) {
const parsed = JSON.parse(oldAlbum);
if (Array.isArray(parsed) && parsed.length > 0 && albumHistory.value.length === 0) {
albumHistory.value = parsed;
}
}
// 迁移播客电台记录
const oldRadio = localStorage.getItem('podcastRadioHistory');
if (oldRadio) {
const parsed = JSON.parse(oldRadio);
if (
Array.isArray(parsed) &&
parsed.length > 0 &&
podcastRadioHistory.value.length === 0
) {
podcastRadioHistory.value = parsed;
}
}
localStorage.setItem('playHistory-migrated', '1');
console.log('[PlayHistory] 数据迁移完成');
} catch (error) { } catch (error) {
console.error('[PlayHistory] 清空落盘失败:', error); console.error('[PlayHistory] 数据迁移失败:', error);
} }
}; };
@@ -367,27 +316,21 @@ export const usePlayHistoryStore = defineStore(
clearPodcastRadioHistory, clearPodcastRadioHistory,
// 通用 // 通用
clearAll clearAll,
migrateFromLocalStorage
}; };
}, },
{ {
persist: { persist: {
key: PERSIST_KEY, key: 'play-history-store',
// 共用防抖 storageaddMusic 在播放切换时会触发 mutation,避免每次都 stringify storage: localStorage,
// 整条 musicHistory(最多 500 条)走一遍 minifyHistoryList
storage: debouncedLocalStorage,
pick: [ pick: [
'musicHistory', 'musicHistory',
'podcastHistory', 'podcastHistory',
'playlistHistory', 'playlistHistory',
'albumHistory', 'albumHistory',
'podcastRadioHistory' 'podcastRadioHistory'
], ]
// 与 clearAll 的同步落盘共用同一个序列化函数,避免格式漂移
serializer: {
serialize: serializePlayHistoryState,
deserialize: JSON.parse
}
} }
} }
); );

Some files were not shown because too many files have changed in this diff Show More