initial commit
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
.DS_Store
|
||||||
|
*.log*
|
||||||
|
.vite
|
||||||
|
.idea
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
86
README.md
Normal file
86
README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# LoveLiveMusicPlayer · 桌面端(Apple 风格重构版)
|
||||||
|
|
||||||
|
基于最新 **electron-vite** 脚手架重构的 LoveLive! 音乐播放器 PC 端,复刻原 Electron 项目全部功能,
|
||||||
|
UI 采用 **Apple / macOS 风格**(毛玻璃、圆角、SF 字体、系统色板、深浅色)。
|
||||||
|
|
||||||
|
> 设计目标之一:**为后续移动端(Flutter)重构预留最大兼容性与可扩展性**。
|
||||||
|
> 双端联动协议被抽取为独立、框架无关的共享模块 `src/shared/protocol`,移动端可直接对照复用。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 维度 | 选型 |
|
||||||
|
|------|------|
|
||||||
|
| 框架 | Electron 43 + Vite 7 + React 19 + TypeScript |
|
||||||
|
| 脚手架 | electron-vite |
|
||||||
|
| 状态管理 | Zustand |
|
||||||
|
| 样式 | Tailwind CSS 3 + 自研 Apple 设计系统(`src/renderer/src/styles/index.css`) |
|
||||||
|
| 本地数据库 | better-sqlite3(表结构与移动端 SQLite 对齐) |
|
||||||
|
| 配置存储 | electron-store |
|
||||||
|
| 局域网通信 | ws(WebSocket 服务端)+ express(HTTP 文件服务),**运行在主进程** |
|
||||||
|
| 二维码 | qrcode.react |
|
||||||
|
| 音频转码 | ffmpeg-static(iOS flac→wav,跨平台免编译) |
|
||||||
|
| 图标 | lucide-react |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── shared/ # ★ 双端共享(协议 + 领域模型 + IPC 通道常量)
|
||||||
|
│ ├── protocol/ # 命令枚举 / 类型 / 端口 / 版本协商(移动端可复用)
|
||||||
|
│ ├── models/ # Album/Music/Menu/Love/History/Lyric(对齐移动端实体)
|
||||||
|
│ └── ipc/channels.ts
|
||||||
|
├── main/ # 主进程
|
||||||
|
│ ├── index.ts # 入口(单例锁、生命周期)
|
||||||
|
│ ├── window/ # 主窗口 + 桌面歌词窗口
|
||||||
|
│ ├── ipc/ # IPC 注册
|
||||||
|
│ └── services/ # store / database / fileServer / musicServer(4388)
|
||||||
|
│ # / dataServer(4389) / transcode / network / lanInfo
|
||||||
|
├── preload/ # 类型安全的 window.api 桥接
|
||||||
|
└── renderer/ # 渲染进程(React)
|
||||||
|
└── src/
|
||||||
|
├── components/ # TitleBar / Sidebar / PlayerBar / MusicList / ...
|
||||||
|
├── pages/ # Home / Albums / AlbumDetail / Favorites / Playlists
|
||||||
|
│ # / History / Lyric / Transfer / Sync / Settings / DesktopLyric
|
||||||
|
├── stores/ # uiStore / playerStore / libraryStore(Zustand)
|
||||||
|
└── lib/ # repository(DB 访问)/ lrc(歌词解析)/ const
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已复刻的 PC 端功能
|
||||||
|
|
||||||
|
- 音乐馆(按企划分组)、专辑、专辑详情
|
||||||
|
- 播放器(顺序/列表循环/单曲/随机、进度、音量、媒体会话、上一首/下一首)
|
||||||
|
- 我喜欢、歌单(PC id≤100 / 手机 id>100)、最近播放
|
||||||
|
- 三语歌词(日/中/罗马音)+ 桌面歌词独立窗口
|
||||||
|
- **WiFi 传歌**(二维码配对 + WebSocket 4388 + HTTP 文件下载)
|
||||||
|
- **数据同步**(我喜欢/歌单双向,WebSocket 4389)
|
||||||
|
- 本地 HTTP 文件服务(端口探测、可配置)
|
||||||
|
- iOS flac→wav 转码
|
||||||
|
- 设置:曲库目录、HTTP 端口、主题(深/浅/跟随系统)、强调色
|
||||||
|
- 无边框窗口 + Apple 质感(Windows 自定义控制按钮 / macOS 红绿灯位)
|
||||||
|
|
||||||
|
## 与移动端的兼容性设计
|
||||||
|
|
||||||
|
- **协议单一来源**:`src/shared/protocol/commands.ts` 定义所有命令字(枚举,禁止裸字符串),
|
||||||
|
字符串值与旧移动端保持一致,保证新 PC 端可直接与现有 App 联动。
|
||||||
|
- **版本协商**:`version.ts` 在保留 `transVer=1` 兼容的同时引入 `minCompatVer` + `capabilities`,
|
||||||
|
支持向后兼容与能力位扩展。
|
||||||
|
- **数据结构对齐**:领域模型与移动端 Floor 实体一致,降低同步映射成本。
|
||||||
|
- 移动端重构时可把 `src/shared` 作为协议对照,甚至用代码生成同步。
|
||||||
|
|
||||||
|
## 开发与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # 安装依赖(postinstall 会为 Electron 重建原生模块)
|
||||||
|
npm run dev # 开发模式
|
||||||
|
npm run build # 类型检查 + 打包
|
||||||
|
npm run build:win # 打 Windows 安装包
|
||||||
|
```
|
||||||
|
|
||||||
|
> 首次如遇 `Electron uninstall`,执行 `node node_modules/electron/install.js` 下载 Electron 二进制;
|
||||||
|
> 原生模块(better-sqlite3)如报 ABI 不匹配,执行 `npx electron-builder install-app-deps`。
|
||||||
|
|
||||||
|
## 待接入(占位/后续)
|
||||||
|
|
||||||
|
- OSS `data.json` 曲库元数据拉取入库(`main/services/network.ts` 已备 `fetchJson`,UI 触发待接)
|
||||||
|
- 断点续传 / 完整性校验 / 配对 token 鉴权(协议 `capabilities` 已预留)
|
||||||
|
- 自动更新(electron-updater 接线)、导出 Excel、托盘
|
||||||
36
electron-builder.yml
Normal file
36
electron-builder.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
appId: com.zhushenwudi.lovelivemusicplayer
|
||||||
|
productName: LoveLiveMusicPlayer
|
||||||
|
directories:
|
||||||
|
buildResources: build
|
||||||
|
extraResources:
|
||||||
|
- from: resources/tray.png
|
||||||
|
to: tray.png
|
||||||
|
- from: resources/icon.png
|
||||||
|
to: icon.png
|
||||||
|
files:
|
||||||
|
- '!**/.vscode/*'
|
||||||
|
- '!src/*'
|
||||||
|
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||||
|
- '!{.eslintrc.cjs,.prettierrc,.prettierignore}'
|
||||||
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
|
- '!{tailwind.config.js,postcss.config.js}'
|
||||||
|
asarUnpack:
|
||||||
|
- resources/**
|
||||||
|
- '**/*.node'
|
||||||
|
win:
|
||||||
|
target:
|
||||||
|
- nsis
|
||||||
|
nsis:
|
||||||
|
artifactName: ${name}-${version}-setup.${ext}
|
||||||
|
shortcutName: ${productName}
|
||||||
|
oneClick: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
mac:
|
||||||
|
target:
|
||||||
|
- dmg
|
||||||
|
category: public.app-category.music
|
||||||
|
linux:
|
||||||
|
target:
|
||||||
|
- AppImage
|
||||||
|
category: Audio
|
||||||
|
npmRebuild: true
|
||||||
37
electron.vite.config.ts
Normal file
37
electron.vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@main': resolve('src/main'),
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
// 绑定 IPv4,避免仅监听 ::1 导致 Electron 用 localhost(127.0.0.1) 连接被拒
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer/src'),
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()]
|
||||||
|
}
|
||||||
|
})
|
||||||
8485
package-lock.json
generated
Normal file
8485
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
package.json
Normal file
66
package.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "lovelive-music-player-next",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "LoveLiveMusicPlayer 桌面端(Apple 风格重构版)",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"author": "zhushenwudi",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "npm run typecheck && electron-vite build",
|
||||||
|
"postinstall": "electron-builder install-app-deps",
|
||||||
|
"build:win": "npm run build && electron-builder --win",
|
||||||
|
"build:mac": "npm run build && electron-builder --mac",
|
||||||
|
"build:linux": "npm run build && electron-builder --linux",
|
||||||
|
"monkey": "node scripts/monkey-test.mjs --complex",
|
||||||
|
"monkey:build": "npx electron-vite build && node scripts/monkey-test.mjs --complex",
|
||||||
|
"monkey:heavy": "npx electron-vite build && node scripts/monkey-test.mjs --complex --actions=300 --storm=60"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/ip": "^1.1.3",
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"electron": "^43.1.1",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"playwright": "^1.51.0",
|
||||||
|
"postcss": "^8.5.19",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"electron-store": "^11.0.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"framer-motion": "^12.42.2",
|
||||||
|
"ip": "^2.0.1",
|
||||||
|
"lucide-react": "^1.25.0",
|
||||||
|
"music-metadata": "^11.14.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.1",
|
||||||
|
"ws": "^8.21.1",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
resources/icon.png
Normal file
BIN
resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 759 KiB |
BIN
resources/tray.png
Normal file
BIN
resources/tray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
94
scripts/gen-tray-icon.mjs
Normal file
94
scripts/gen-tray-icon.mjs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { deflateSync } from 'zlib'
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs'
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
/** 生成一个简洁的托盘图标(粉色唱片盘),输出到 resources/tray.png */
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
const SIZE = 32
|
||||||
|
const cx = SIZE / 2 - 0.5
|
||||||
|
const cy = SIZE / 2 - 0.5
|
||||||
|
const rOuter = 15
|
||||||
|
const rHole = 4
|
||||||
|
|
||||||
|
function px(x, y) {
|
||||||
|
const dx = x - cx
|
||||||
|
const dy = y - cy
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
// 抗锯齿边缘
|
||||||
|
const edge = (r) => Math.max(0, Math.min(1, r - d + 0.5))
|
||||||
|
const inDisc = edge(rOuter)
|
||||||
|
const inHole = edge(rHole)
|
||||||
|
if (inDisc <= 0) return [0, 0, 0, 0]
|
||||||
|
// 唱片主体粉色,中心孔洞白色
|
||||||
|
const pink = [255, 55, 95]
|
||||||
|
const white = [255, 255, 255]
|
||||||
|
const a = Math.round(255 * inDisc)
|
||||||
|
if (inHole > 0) {
|
||||||
|
const t = inHole
|
||||||
|
return [
|
||||||
|
Math.round(pink[0] * (1 - t) + white[0] * t),
|
||||||
|
Math.round(pink[1] * (1 - t) + white[1] * t),
|
||||||
|
Math.round(pink[2] * (1 - t) + white[2] * t),
|
||||||
|
a
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [pink[0], pink[1], pink[2], a]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造原始 RGBA 扫描行(每行前置 filter 0)
|
||||||
|
const raw = Buffer.alloc((SIZE * 4 + 1) * SIZE)
|
||||||
|
let p = 0
|
||||||
|
for (let y = 0; y < SIZE; y++) {
|
||||||
|
raw[p++] = 0
|
||||||
|
for (let x = 0; x < SIZE; x++) {
|
||||||
|
const [r, g, b, a] = px(x, y)
|
||||||
|
raw[p++] = r
|
||||||
|
raw[p++] = g
|
||||||
|
raw[p++] = b
|
||||||
|
raw[p++] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crcTable = (() => {
|
||||||
|
const t = []
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||||
|
t[n] = c >>> 0
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
})()
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff
|
||||||
|
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
|
||||||
|
return (c ^ 0xffffffff) >>> 0
|
||||||
|
}
|
||||||
|
function chunk(type, data) {
|
||||||
|
const len = Buffer.alloc(4)
|
||||||
|
len.writeUInt32BE(data.length, 0)
|
||||||
|
const typeBuf = Buffer.from(type, 'ascii')
|
||||||
|
const body = Buffer.concat([typeBuf, data])
|
||||||
|
const crc = Buffer.alloc(4)
|
||||||
|
crc.writeUInt32BE(crc32(body), 0)
|
||||||
|
return Buffer.concat([len, body, crc])
|
||||||
|
}
|
||||||
|
|
||||||
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||||
|
const ihdr = Buffer.alloc(13)
|
||||||
|
ihdr.writeUInt32BE(SIZE, 0)
|
||||||
|
ihdr.writeUInt32BE(SIZE, 4)
|
||||||
|
ihdr[8] = 8 // bit depth
|
||||||
|
ihdr[9] = 6 // color type RGBA
|
||||||
|
const png = Buffer.concat([
|
||||||
|
sig,
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(raw)),
|
||||||
|
chunk('IEND', Buffer.alloc(0))
|
||||||
|
])
|
||||||
|
|
||||||
|
const out = resolve(__dirname, '../resources/tray.png')
|
||||||
|
mkdirSync(dirname(out), { recursive: true })
|
||||||
|
writeFileSync(out, png)
|
||||||
|
console.log('wrote', out, png.length, 'bytes')
|
||||||
896
scripts/monkey-test.mjs
Normal file
896
scripts/monkey-test.mjs
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
/**
|
||||||
|
* LoveLiveMusicPlayer-Next · 复杂 Monkey 测试
|
||||||
|
*
|
||||||
|
* 层级:
|
||||||
|
* A. 协议 / 数据模型 fuzz
|
||||||
|
* B. 渲染进程 IPC 压力(经 Playwright evaluate)
|
||||||
|
* C. 场景流(歌单/搜索/传歌/设置/歌词)
|
||||||
|
* D. 混沌乱点(侧栏、hash 风暴、连点、滚轮、键盘)
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/monkey-test.mjs --actions=200 --seed=42 --complex
|
||||||
|
* npm run monkey:build -- --actions=200 --complex
|
||||||
|
*/
|
||||||
|
import { createRequire } from 'module'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const args = Object.fromEntries(
|
||||||
|
process.argv.slice(2).map((a) => {
|
||||||
|
const m = a.match(/^--([^=]+)=(.*)$/)
|
||||||
|
return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const COMPLEX = args.complex !== false && args.simple !== true
|
||||||
|
const ACTIONS = Number(args.actions || (COMPLEX ? 200 : 80))
|
||||||
|
const SEED = Number(args.seed || Date.now() % 1e9)
|
||||||
|
const STORM = Number(args.storm || (COMPLEX ? 40 : 10))
|
||||||
|
|
||||||
|
function mulberry32(a) {
|
||||||
|
return function () {
|
||||||
|
let t = (a += 0x6d2b79f5)
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rand = mulberry32(SEED)
|
||||||
|
const pick = (arr) => arr[Math.floor(rand() * arr.length)]
|
||||||
|
const chance = (p) => rand() < p
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
seed: SEED,
|
||||||
|
actions: ACTIONS,
|
||||||
|
complex: COMPLEX,
|
||||||
|
storm: STORM,
|
||||||
|
logic: { passed: 0, failed: 0, cases: [] },
|
||||||
|
ipc: { calls: 0, errors: [], samples: [] },
|
||||||
|
scenarios: { ran: [], failed: [] },
|
||||||
|
ui: {
|
||||||
|
launched: false,
|
||||||
|
actionsDone: 0,
|
||||||
|
consoleErrors: [],
|
||||||
|
pageErrors: [],
|
||||||
|
crashes: [],
|
||||||
|
navigation: [],
|
||||||
|
dialogs: { accepted: 0, dismissed: 0 },
|
||||||
|
notes: []
|
||||||
|
},
|
||||||
|
verdict: 'UNKNOWN'
|
||||||
|
}
|
||||||
|
|
||||||
|
function logicCase(name, fn) {
|
||||||
|
try {
|
||||||
|
fn()
|
||||||
|
report.logic.passed++
|
||||||
|
report.logic.cases.push({ name, ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
report.logic.failed++
|
||||||
|
report.logic.cases.push({ name, ok: false, error: String(e?.message || e).slice(0, 300) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scenario(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
report.scenarios.ran.push(name)
|
||||||
|
} catch (e) {
|
||||||
|
report.scenarios.failed.push({ name, error: String(e?.message || e).slice(0, 300) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== A. 协议 / 模型 ======================== */
|
||||||
|
|
||||||
|
const DOWNLOAD_BODY_SEPARATOR = ' === '
|
||||||
|
const encodeFtpCmd = (cmd, body = '') => JSON.stringify({ cmd, body: String(body) })
|
||||||
|
const decodeFtpCmd = (raw) => {
|
||||||
|
const obj = JSON.parse(raw)
|
||||||
|
return { cmd: String(obj.cmd), body: obj.body == null ? '' : String(obj.body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTransData(body) {
|
||||||
|
const raw = JSON.parse(body)
|
||||||
|
const love = (raw.love || [])
|
||||||
|
.map((o) => ({
|
||||||
|
musicId: String(o.musicId ?? o.musicUId ?? ''),
|
||||||
|
timestamp: Number(o.timestamp ?? o.createTime ?? Date.now()) || Date.now()
|
||||||
|
}))
|
||||||
|
.filter((l) => l.musicId)
|
||||||
|
const menu = (raw.menu || [])
|
||||||
|
.map((o) => {
|
||||||
|
let list = o.musicList ?? o.musicUIds ?? o.music ?? []
|
||||||
|
if (typeof list === 'string') {
|
||||||
|
try {
|
||||||
|
list = JSON.parse(list)
|
||||||
|
} catch {
|
||||||
|
list = [list]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
menuId: Number(o.menuId ?? o.id ?? 0),
|
||||||
|
name: String(o.name ?? o.title ?? ''),
|
||||||
|
date: String(o.date ?? ''),
|
||||||
|
musicList: (Array.isArray(list) ? list : []).map(String).filter(Boolean)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((m) => Number.isFinite(m.menuId) && m.menuId > 0)
|
||||||
|
return { love, menu, isCover: !!raw.isCover }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaAbsPath(rootDir, baseUrl, musicPath) {
|
||||||
|
const parts = [rootDir, baseUrl, musicPath]
|
||||||
|
.map((p) => String(p).replace(/[/\\]+/g, '/').replace(/^\/+|\/+$/g, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
const joined = parts.join('/')
|
||||||
|
return rootDir.includes('\\') ? joined.replace(/\//g, '\\') : joined
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLogicMonkey() {
|
||||||
|
logicCase('encode/decode roundtrip', () => {
|
||||||
|
const d = decodeFtpCmd(encodeFtpCmd('prepare', '[{"a":1}] === false'))
|
||||||
|
if (d.cmd !== 'prepare' || !d.body.includes(' === ')) throw new Error('mismatch')
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('unicode + emoji body', () => {
|
||||||
|
const body = JSON.stringify([{ musicName: '幻影ノメゾン🎤', path: 'a/b [c].flac' }])
|
||||||
|
JSON.parse(decodeFtpCmd(encodeFtpCmd('ready', body)).body)
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('prepare 3000 tracks', () => {
|
||||||
|
const list = Array.from({ length: 3000 }, (_, i) => ({
|
||||||
|
albumUId: `a${i}`,
|
||||||
|
musicUId: `m${i}`,
|
||||||
|
musicName: `曲${i}`,
|
||||||
|
musicPath: `${i}.flac`,
|
||||||
|
baseUrl: 'LoveLive/test/',
|
||||||
|
coverPath: 'c.jpg',
|
||||||
|
albumId: i % 50,
|
||||||
|
albumName: 'A',
|
||||||
|
date: '2020-01-01',
|
||||||
|
category: 'single',
|
||||||
|
group: "μ's",
|
||||||
|
musicId: i,
|
||||||
|
artist: 'x',
|
||||||
|
artistBin: '1e7',
|
||||||
|
totalTime: '03:00',
|
||||||
|
existFile: true
|
||||||
|
}))
|
||||||
|
const body = `${JSON.stringify(list)}${DOWNLOAD_BODY_SEPARATOR}true`
|
||||||
|
const [json, flag] = decodeFtpCmd(encodeFtpCmd('prepare', body)).body.split(
|
||||||
|
DOWNLOAD_BODY_SEPARATOR
|
||||||
|
)
|
||||||
|
if (JSON.parse(json).length !== 3000 || flag !== 'true') throw new Error('prepare broken')
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('download body separator edge', () => {
|
||||||
|
const uid = 'abc === def'
|
||||||
|
const raw = `${uid}${DOWNLOAD_BODY_SEPARATOR}false`
|
||||||
|
const parts = raw.split(DOWNLOAD_BODY_SEPARATOR)
|
||||||
|
// 协议本身对 uid 含分隔符不健壮——记录行为即可
|
||||||
|
if (parts.length < 2) throw new Error('split failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('transData phone wire format', () => {
|
||||||
|
const wire = JSON.stringify({
|
||||||
|
love: [{ musicId: 'm1', timestamp: 1 }],
|
||||||
|
menu: [{ menuId: 101, name: '手机歌单', date: '2024-01-01', musicList: ['m1', 'm2'] }],
|
||||||
|
isCover: false
|
||||||
|
})
|
||||||
|
const d = parseTransData(wire)
|
||||||
|
if (d.menu[0].menuId !== 101 || d.love[0].musicId !== 'm1') throw new Error('parse')
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('transData legacy field aliases', () => {
|
||||||
|
const wire = JSON.stringify({
|
||||||
|
love: [{ musicUId: 'x', createTime: 9 }],
|
||||||
|
menu: [{ id: 102, title: 'T', musicUIds: ['a'] }],
|
||||||
|
isCover: true
|
||||||
|
})
|
||||||
|
const d = parseTransData(wire)
|
||||||
|
if (d.love[0].musicId !== 'x' || d.menu[0].name !== 'T') throw new Error('alias')
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('transData musicList as JSON string', () => {
|
||||||
|
const wire = JSON.stringify({
|
||||||
|
love: [],
|
||||||
|
menu: [{ menuId: 103, name: 'S', date: '', musicList: '["u1","u2"]' }],
|
||||||
|
isCover: false
|
||||||
|
})
|
||||||
|
const d = parseTransData(wire)
|
||||||
|
if (d.menu[0].musicList.length !== 2) throw new Error('string list')
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('menu id boundaries', () => {
|
||||||
|
const ids = [1, 100, 101, 200, 0, -1, NaN, 1.5]
|
||||||
|
const kept = ids.map(Number).filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
if (!kept.includes(101) || kept.includes(0)) throw new Error('boundary')
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('path join windows/posix', () => {
|
||||||
|
const a = mediaAbsPath('E:\\Music', "LoveLive/μ's/", '01. song.flac')
|
||||||
|
const b = mediaAbsPath('/home/m', 'LoveLive/x/', 'a.flac')
|
||||||
|
// 实现会裁掉首尾分隔符,posix 根可能变成 home/m/...
|
||||||
|
if (!a.includes('01. song.flac') || !String(a).includes('Music')) throw new Error(`win:${a}`)
|
||||||
|
if (!b.includes('a.flac') || !b.includes('LoveLive')) throw new Error(`posix:${b}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
logicCase('malformed raw rejected', () => {
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
decodeFtpCmd('{bad')
|
||||||
|
} catch {
|
||||||
|
threw = true
|
||||||
|
}
|
||||||
|
if (!threw) throw new Error('expected throw')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 大批量随机 fuzz
|
||||||
|
const cmds = [
|
||||||
|
'version',
|
||||||
|
'prepare',
|
||||||
|
'ready',
|
||||||
|
'download',
|
||||||
|
'phone2pc',
|
||||||
|
'pc2phone',
|
||||||
|
'system',
|
||||||
|
'port',
|
||||||
|
'',
|
||||||
|
'💥',
|
||||||
|
'download success'
|
||||||
|
]
|
||||||
|
for (let i = 0; i < (COMPLEX ? 80 : 40); i++) {
|
||||||
|
logicCase(`fuzz#${i}`, () => {
|
||||||
|
const cmd = pick(cmds)
|
||||||
|
const body = pick([
|
||||||
|
'',
|
||||||
|
'1',
|
||||||
|
'true',
|
||||||
|
'[]',
|
||||||
|
'{}',
|
||||||
|
'null',
|
||||||
|
'a === true',
|
||||||
|
'm1 === false === extra',
|
||||||
|
JSON.stringify({
|
||||||
|
love: Array.from({ length: Math.floor(rand() * 5) }, (_, j) => ({
|
||||||
|
musicId: `m${j}`,
|
||||||
|
timestamp: j
|
||||||
|
})),
|
||||||
|
menu: Array.from({ length: Math.floor(rand() * 3) }, (_, j) => ({
|
||||||
|
menuId: 101 + j,
|
||||||
|
name: `歌单${j}`,
|
||||||
|
date: '',
|
||||||
|
musicList: [`m${j}`]
|
||||||
|
})),
|
||||||
|
isCover: chance(0.5)
|
||||||
|
}),
|
||||||
|
'\u0000\u0001',
|
||||||
|
'中文'.repeat(Math.floor(rand() * 30)),
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
"' OR 1=1 --",
|
||||||
|
'../'.repeat(20) + 'etc/passwd'
|
||||||
|
])
|
||||||
|
const d = decodeFtpCmd(encodeFtpCmd(cmd, body))
|
||||||
|
if (typeof d.cmd !== 'string' || typeof d.body !== 'string') throw new Error('type')
|
||||||
|
if (cmd === 'phone2pc' || cmd === 'pc2phone') {
|
||||||
|
try {
|
||||||
|
parseTransData(d.body)
|
||||||
|
} catch {
|
||||||
|
/* 畸形可解析失败 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 哈希稳定性 / 路由怪值
|
||||||
|
logicCase('hostile routes hash', () => {
|
||||||
|
for (const r of [
|
||||||
|
'/',
|
||||||
|
'/playlists',
|
||||||
|
'/../../../etc/passwd',
|
||||||
|
'/playlists?x=<script>',
|
||||||
|
'/' + 'a'.repeat(8000),
|
||||||
|
'/albums/' + encodeURIComponent("μ's"),
|
||||||
|
'/lyric#frag'
|
||||||
|
]) {
|
||||||
|
createHash('sha1').update(r).digest('hex')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Build ======================== */
|
||||||
|
|
||||||
|
async function ensureBuild() {
|
||||||
|
const mainJs = path.join(root, 'out', 'main', 'index.js')
|
||||||
|
if (fs.existsSync(mainJs) && !args.rebuild) {
|
||||||
|
report.ui.notes.push('reuse existing out/ build')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report.ui.notes.push('running electron-vite build…')
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const p = spawn('npx', ['electron-vite', 'build'], {
|
||||||
|
cwd: root,
|
||||||
|
shell: true,
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`build exit ${code}`))))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== UI helpers ======================== */
|
||||||
|
|
||||||
|
async function gotoHash(win, route) {
|
||||||
|
await win.evaluate((r) => {
|
||||||
|
window.location.hash = `#${r}`
|
||||||
|
}, route)
|
||||||
|
await sleep(180)
|
||||||
|
report.ui.navigation.push(`hash:${route}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickText(win, label) {
|
||||||
|
const el = win.locator(`text=${label}`).first()
|
||||||
|
if ((await el.count()) === 0) return false
|
||||||
|
await el.click({ timeout: 2500 })
|
||||||
|
report.ui.navigation.push(label)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function randomClickables(win, n = 1) {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const label = await win.evaluate(() => {
|
||||||
|
const nodes = [
|
||||||
|
...document.querySelectorAll(
|
||||||
|
'button, a, [role="button"], input[type="checkbox"], label, .sidebar-item'
|
||||||
|
)
|
||||||
|
].filter((el) => {
|
||||||
|
const s = getComputedStyle(el)
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
return (
|
||||||
|
s.visibility !== 'hidden' &&
|
||||||
|
s.display !== 'none' &&
|
||||||
|
!el.disabled &&
|
||||||
|
r.width > 2 &&
|
||||||
|
r.height > 2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (!nodes.length) return null
|
||||||
|
const el = nodes[Math.floor(Math.random() * nodes.length)]
|
||||||
|
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
|
||||||
|
return (el.innerText || el.getAttribute('aria-label') || el.tagName || '').slice(0, 48)
|
||||||
|
})
|
||||||
|
if (label) report.ui.navigation.push(`click:${label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== B. IPC 压力 ======================== */
|
||||||
|
|
||||||
|
async function runIpcMonkey(win) {
|
||||||
|
const results = await win.evaluate(async () => {
|
||||||
|
const out = { calls: 0, errors: [], samples: [] }
|
||||||
|
const api = window.api
|
||||||
|
if (!api) {
|
||||||
|
out.errors.push('window.api missing')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = async (name, fn) => {
|
||||||
|
out.calls++
|
||||||
|
try {
|
||||||
|
const r = await fn()
|
||||||
|
out.samples.push({ name, ok: true, preview: JSON.stringify(r)?.slice(0, 80) })
|
||||||
|
} catch (e) {
|
||||||
|
out.errors.push(`${name}: ${String(e).slice(0, 120)}`)
|
||||||
|
out.samples.push({ name, ok: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常读
|
||||||
|
await safe('store.get volume', () => api.store.get('volume'))
|
||||||
|
await safe('http.status', () => api.http.status())
|
||||||
|
await safe('net.lanIps', () => api.net.lanIps())
|
||||||
|
await safe('library.dataVersion', () => api.library.dataVersion())
|
||||||
|
await safe('db.query album', () => api.db.query('SELECT COUNT(*) as c FROM album'))
|
||||||
|
await safe('db.query music', () => api.db.query('SELECT COUNT(*) as c FROM music'))
|
||||||
|
await safe('db.query menu', () => api.db.query('SELECT * FROM menu ORDER BY id DESC LIMIT 20'))
|
||||||
|
|
||||||
|
// 写读往返(monkey userData)
|
||||||
|
const key = `__monkey_${Date.now()}`
|
||||||
|
await safe('store.set', () => api.store.set(key, { n: 1, s: '测' }))
|
||||||
|
await safe('store.get', () => api.store.get(key))
|
||||||
|
await safe('store.delete', () => api.store.delete(key))
|
||||||
|
|
||||||
|
// 歌单 CRUD 压力
|
||||||
|
const mid = 150 + Math.floor(Math.random() * 40)
|
||||||
|
await safe('menu upsert', () =>
|
||||||
|
api.db.exec('INSERT OR REPLACE INTO menu (id, title, cover, createTime) VALUES (?,?,?,?)', [
|
||||||
|
mid,
|
||||||
|
`Monkey歌单${mid}`,
|
||||||
|
null,
|
||||||
|
Date.now()
|
||||||
|
])
|
||||||
|
)
|
||||||
|
await safe('playlist link', () =>
|
||||||
|
api.db.exec(
|
||||||
|
'INSERT OR IGNORE INTO playlist_music (menuId, musicUId, "order") VALUES (?,?,?)',
|
||||||
|
[mid, 'nonexistent-uid-monkey', 0]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await safe('menu delete', async () => {
|
||||||
|
await api.db.exec('DELETE FROM playlist_music WHERE menuId = ?', [mid])
|
||||||
|
await api.db.exec('DELETE FROM menu WHERE id = ?', [mid])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 并发风暴
|
||||||
|
const storm = []
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
storm.push(api.db.query('SELECT musicUId FROM music LIMIT 5'))
|
||||||
|
storm.push(api.store.get('volume'))
|
||||||
|
storm.push(api.http.status())
|
||||||
|
}
|
||||||
|
out.calls += storm.length
|
||||||
|
try {
|
||||||
|
await Promise.all(storm)
|
||||||
|
out.samples.push({ name: 'concurrent x90', ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
out.errors.push(`concurrent: ${String(e).slice(0, 120)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 故意坏 SQL:期望抛错且进程不崩
|
||||||
|
out.calls++
|
||||||
|
try {
|
||||||
|
await api.db.query('SELECT * FROM not_a_table_monkey')
|
||||||
|
out.errors.push('bad sql: expected throw')
|
||||||
|
} catch {
|
||||||
|
out.samples.push({ name: 'bad sql expected fail', ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 传歌/同步服务启停连打
|
||||||
|
await safe('musicServer.start', () => api.musicServer.start())
|
||||||
|
await safe('musicServer.send offline', () => api.musicServer.send('prepare', '[] === false'))
|
||||||
|
await safe('musicServer.stop', () => api.musicServer.stop())
|
||||||
|
await safe('dataServer.start', () => api.dataServer.start())
|
||||||
|
await safe('dataServer.send offline', () =>
|
||||||
|
api.dataServer.send('phone2pc', JSON.stringify({ love: [], menu: [], isCover: false }))
|
||||||
|
)
|
||||||
|
await safe('dataServer.stop', () => api.dataServer.stop())
|
||||||
|
|
||||||
|
// 快速启停
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await safe(`music bounce ${i}`, async () => {
|
||||||
|
await api.musicServer.start()
|
||||||
|
await api.musicServer.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
report.ipc.calls += results.calls || 0
|
||||||
|
report.ipc.errors.push(...(results.errors || []))
|
||||||
|
report.ipc.samples = (results.samples || []).slice(0, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== C. 场景流 ======================== */
|
||||||
|
|
||||||
|
async function runScenarios(win) {
|
||||||
|
await scenario('home groups + play attempt', async () => {
|
||||||
|
await gotoHash(win, '/')
|
||||||
|
await sleep(300)
|
||||||
|
for (const g of ["μ's", 'Aqours', 'Liella!', '全部']) {
|
||||||
|
await clickText(win, g).catch(() => false)
|
||||||
|
await sleep(80)
|
||||||
|
}
|
||||||
|
await randomClickables(win, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('search hostile queries', async () => {
|
||||||
|
await gotoHash(win, '/search')
|
||||||
|
const box = win.locator('input[placeholder*="搜索"]').first()
|
||||||
|
if ((await box.count()) === 0) return
|
||||||
|
for (const q of [
|
||||||
|
'μ',
|
||||||
|
'Aqours',
|
||||||
|
'zzz_not_exist_9x',
|
||||||
|
'<script>',
|
||||||
|
"' OR 1=1",
|
||||||
|
' ',
|
||||||
|
'❤️',
|
||||||
|
'a'.repeat(200)
|
||||||
|
]) {
|
||||||
|
await box.fill(q)
|
||||||
|
await sleep(120)
|
||||||
|
}
|
||||||
|
await randomClickables(win, 2)
|
||||||
|
await box.fill('')
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('playlist create cancel escape', async () => {
|
||||||
|
await gotoHash(win, '/playlists')
|
||||||
|
await sleep(250)
|
||||||
|
const created = await clickText(win, '新建')
|
||||||
|
if (created) {
|
||||||
|
const input = win.locator('input[placeholder*="歌单"]').first()
|
||||||
|
if (await input.count()) {
|
||||||
|
await input.fill(`Monkey-${SEED}`)
|
||||||
|
await win.keyboard.press('Escape')
|
||||||
|
await sleep(100)
|
||||||
|
// 再开一次并确认
|
||||||
|
await clickText(win, '新建')
|
||||||
|
if (await input.count()) {
|
||||||
|
await input.fill(`Monkey-OK-${SEED % 1000}`)
|
||||||
|
await win.keyboard.press('Enter')
|
||||||
|
await sleep(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await randomClickables(win, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('favorites / history churn', async () => {
|
||||||
|
await gotoHash(win, '/favorites')
|
||||||
|
await sleep(200)
|
||||||
|
await randomClickables(win, 2)
|
||||||
|
await gotoHash(win, '/history')
|
||||||
|
await sleep(200)
|
||||||
|
await randomClickables(win, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('transfer checkbox spam', async () => {
|
||||||
|
await gotoHash(win, '/transfer')
|
||||||
|
await sleep(400)
|
||||||
|
// 勾选框连点
|
||||||
|
await win.evaluate(() => {
|
||||||
|
document.querySelectorAll('input[type="checkbox"]').forEach((el, i) => {
|
||||||
|
if (i < 25) el.click()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await clickText(win, '全选').catch(() => false)
|
||||||
|
await clickText(win, '仅推荐').catch(() => false)
|
||||||
|
await sleep(150)
|
||||||
|
await randomClickables(win, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('sync page connect UI', async () => {
|
||||||
|
await gotoHash(win, '/sync')
|
||||||
|
await sleep(400)
|
||||||
|
await randomClickables(win, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('settings source toggles', async () => {
|
||||||
|
await gotoHash(win, '/settings')
|
||||||
|
await sleep(300)
|
||||||
|
await clickText(win, '本地').catch(() => false)
|
||||||
|
await clickText(win, '远程').catch(() => false)
|
||||||
|
await clickText(win, '本地').catch(() => false)
|
||||||
|
const remote = win.locator('input[placeholder*="http"]').first()
|
||||||
|
if (await remote.count()) {
|
||||||
|
await remote.fill('http://127.0.0.1:9/')
|
||||||
|
await sleep(50)
|
||||||
|
await remote.fill('')
|
||||||
|
}
|
||||||
|
await randomClickables(win, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('lyric overlay open/close', async () => {
|
||||||
|
await gotoHash(win, '/')
|
||||||
|
await sleep(200)
|
||||||
|
// 尝试点播放条区域打开歌词(若有封面/标题)
|
||||||
|
await win.evaluate(() => {
|
||||||
|
const bar = document.querySelector('footer, [class*="Player"], [class*="player"]')
|
||||||
|
if (bar) bar.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await gotoHash(win, '/lyric')
|
||||||
|
await sleep(300)
|
||||||
|
await win.keyboard.press('Escape')
|
||||||
|
await sleep(100)
|
||||||
|
await gotoHash(win, '/')
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('hash storm', async () => {
|
||||||
|
const routes = [
|
||||||
|
'/',
|
||||||
|
'/playlists',
|
||||||
|
'/favorites',
|
||||||
|
'/history',
|
||||||
|
'/transfer',
|
||||||
|
'/sync',
|
||||||
|
'/settings',
|
||||||
|
'/search',
|
||||||
|
'/albums/not-exist-id'
|
||||||
|
]
|
||||||
|
for (let i = 0; i < STORM; i++) {
|
||||||
|
await gotoHash(win, pick(routes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await scenario('server start storm via pages', async () => {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
await gotoHash(win, chance(0.5) ? '/transfer' : '/sync')
|
||||||
|
await sleep(120)
|
||||||
|
await gotoHash(win, '/')
|
||||||
|
await sleep(80)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== D. 混沌循环 ======================== */
|
||||||
|
|
||||||
|
async function runChaosLoop(win) {
|
||||||
|
const sidebar = ['音乐馆', '我喜欢', '歌单', '最近播放', '传歌', '数据同步', '设置']
|
||||||
|
const routes = [
|
||||||
|
'/',
|
||||||
|
'/playlists',
|
||||||
|
'/favorites',
|
||||||
|
'/history',
|
||||||
|
'/transfer',
|
||||||
|
'/sync',
|
||||||
|
'/settings',
|
||||||
|
'/search'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < ACTIONS; i++) {
|
||||||
|
if (!win) break
|
||||||
|
const kind = pick([
|
||||||
|
'sidebar',
|
||||||
|
'hash',
|
||||||
|
'click',
|
||||||
|
'dblclick',
|
||||||
|
'scroll',
|
||||||
|
'keys',
|
||||||
|
'search',
|
||||||
|
'wheel',
|
||||||
|
'resize',
|
||||||
|
'burst'
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
if (kind === 'sidebar') {
|
||||||
|
await clickText(win, pick(sidebar))
|
||||||
|
} else if (kind === 'hash') {
|
||||||
|
await gotoHash(win, pick(routes))
|
||||||
|
} else if (kind === 'click') {
|
||||||
|
await randomClickables(win, 1)
|
||||||
|
} else if (kind === 'dblclick') {
|
||||||
|
await win.evaluate(() => {
|
||||||
|
const nodes = [...document.querySelectorAll('button, img, .apple-card')].filter((el) => {
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
return r.width > 8 && r.height > 8
|
||||||
|
})
|
||||||
|
if (!nodes.length) return
|
||||||
|
const el = nodes[Math.floor(Math.random() * nodes.length)]
|
||||||
|
el.dispatchEvent(
|
||||||
|
new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else if (kind === 'scroll') {
|
||||||
|
await win.evaluate(() => {
|
||||||
|
const el =
|
||||||
|
document.querySelector('[class*="overflow"]') ||
|
||||||
|
document.scrollingElement ||
|
||||||
|
document.body
|
||||||
|
el.scrollBy(0, (Math.random() - 0.5) * 800)
|
||||||
|
})
|
||||||
|
} else if (kind === 'wheel') {
|
||||||
|
await win.mouse.wheel(0, (rand() - 0.5) * 1200)
|
||||||
|
} else if (kind === 'keys') {
|
||||||
|
const combo = pick([
|
||||||
|
['Escape'],
|
||||||
|
['Tab'],
|
||||||
|
['ArrowDown'],
|
||||||
|
['ArrowUp'],
|
||||||
|
['Enter'],
|
||||||
|
['Space'],
|
||||||
|
['Control', 'a'],
|
||||||
|
['F5']
|
||||||
|
])
|
||||||
|
for (const k of combo) await win.keyboard.down(k)
|
||||||
|
for (const k of [...combo].reverse()) await win.keyboard.up(k)
|
||||||
|
} else if (kind === 'search') {
|
||||||
|
const box = win.locator('input[placeholder*="搜索"]').first()
|
||||||
|
if (await box.count()) {
|
||||||
|
await box.fill(pick(['μ', 'Liella', 'xxx', '!', ' ']))
|
||||||
|
}
|
||||||
|
} else if (kind === 'resize') {
|
||||||
|
const [w, h] = pick([
|
||||||
|
[1377, 887],
|
||||||
|
[1400, 900],
|
||||||
|
[1600, 1000],
|
||||||
|
[1377, 887]
|
||||||
|
])
|
||||||
|
await win.setViewportSize({ width: w, height: h }).catch(() => {})
|
||||||
|
} else if (kind === 'burst') {
|
||||||
|
await randomClickables(win, 5)
|
||||||
|
await gotoHash(win, pick(routes))
|
||||||
|
}
|
||||||
|
report.ui.actionsDone++
|
||||||
|
} catch (e) {
|
||||||
|
report.ui.pageErrors.push(`chaos#${i} ${kind}: ${String(e).slice(0, 180)}`)
|
||||||
|
}
|
||||||
|
if (i % 15 === 14) await sleep(80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Launch ======================== */
|
||||||
|
|
||||||
|
async function runUiMonkey() {
|
||||||
|
const { _electron: electron } = await import('playwright')
|
||||||
|
const electronPath = require('electron')
|
||||||
|
|
||||||
|
let app
|
||||||
|
try {
|
||||||
|
app = await electron.launch({
|
||||||
|
executablePath: electronPath,
|
||||||
|
args: [root],
|
||||||
|
cwd: root,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
|
||||||
|
LLMP_MONKEY: '1'
|
||||||
|
},
|
||||||
|
timeout: 90000
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
report.ui.crashes.push(`launch failed: ${e}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report.ui.launched = true
|
||||||
|
await sleep(1500)
|
||||||
|
|
||||||
|
const win = await app.firstWindow({ timeout: 60000 }).catch((e) => {
|
||||||
|
report.ui.crashes.push(`firstWindow: ${e}`)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!win) {
|
||||||
|
await app.close().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原生 confirm / alert
|
||||||
|
win.on('dialog', async (dialog) => {
|
||||||
|
try {
|
||||||
|
if (chance(0.55)) {
|
||||||
|
await dialog.accept()
|
||||||
|
report.ui.dialogs.accepted++
|
||||||
|
} else {
|
||||||
|
await dialog.dismiss()
|
||||||
|
report.ui.dialogs.dismissed++
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
win.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') report.ui.consoleErrors.push(msg.text())
|
||||||
|
})
|
||||||
|
win.on('pageerror', (err) => {
|
||||||
|
report.ui.pageErrors.push(String(err))
|
||||||
|
})
|
||||||
|
|
||||||
|
await sleep(2200)
|
||||||
|
try {
|
||||||
|
await win.waitForSelector('text=LoveLiveMusicPlayer', { timeout: 20000 })
|
||||||
|
} catch {
|
||||||
|
report.ui.notes.push('sidebar title slow; continue')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顺序:IPC → 场景 → 混沌
|
||||||
|
if (COMPLEX) {
|
||||||
|
await runIpcMonkey(win).catch((e) => report.ipc.errors.push(String(e)))
|
||||||
|
await runScenarios(win)
|
||||||
|
}
|
||||||
|
await runChaosLoop(win)
|
||||||
|
|
||||||
|
// 终局健康检查
|
||||||
|
try {
|
||||||
|
const health = await win.evaluate(async () => {
|
||||||
|
const api = window.api
|
||||||
|
return {
|
||||||
|
hasApi: !!api,
|
||||||
|
path: location.hash,
|
||||||
|
albumCount: api
|
||||||
|
? (await api.db.query('SELECT COUNT(*) as c FROM album'))[0]?.c
|
||||||
|
: null,
|
||||||
|
musicServerPing: api ? await api.http.status() : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
report.ui.notes.push(`health: ${JSON.stringify(health)}`)
|
||||||
|
} catch (e) {
|
||||||
|
report.ui.crashes.push(`health check: ${e}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alive = !!app.process() && !app.process().killed
|
||||||
|
if (!alive) report.ui.crashes.push('process dead after monkey')
|
||||||
|
await app.close()
|
||||||
|
} catch (e) {
|
||||||
|
report.ui.crashes.push(`close: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalize() {
|
||||||
|
report.ui.consoleErrors = [...new Set(report.ui.consoleErrors)].slice(0, 50)
|
||||||
|
report.ui.pageErrors = [...new Set(report.ui.pageErrors)].slice(0, 50)
|
||||||
|
report.ipc.errors = [...new Set(report.ipc.errors)].slice(0, 40)
|
||||||
|
|
||||||
|
const hardUi =
|
||||||
|
report.ui.crashes.length +
|
||||||
|
report.ui.pageErrors.filter((e) => !/Timeout|waiting for|Target closed/i.test(e)).length
|
||||||
|
const softIpc = report.ipc.errors.filter((e) => !/bad sql|no such table|not_a_table/i.test(e))
|
||||||
|
const scenarioFails = report.scenarios.failed.length
|
||||||
|
|
||||||
|
if (
|
||||||
|
report.logic.failed === 0 &&
|
||||||
|
report.ui.launched &&
|
||||||
|
hardUi === 0 &&
|
||||||
|
softIpc.length === 0 &&
|
||||||
|
scenarioFails === 0 &&
|
||||||
|
report.ui.actionsDone > 0
|
||||||
|
) {
|
||||||
|
report.verdict = 'PASS'
|
||||||
|
} else if (
|
||||||
|
report.logic.failed === 0 &&
|
||||||
|
report.ui.launched &&
|
||||||
|
hardUi === 0 &&
|
||||||
|
report.ui.actionsDone > 0
|
||||||
|
) {
|
||||||
|
report.verdict = 'PASS_WITH_WARNINGS'
|
||||||
|
} else if (!report.ui.launched && report.logic.failed === 0) {
|
||||||
|
report.verdict = 'LOGIC_PASS_UI_SKIP'
|
||||||
|
} else {
|
||||||
|
report.verdict = 'FAIL'
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(root, 'monkey-results')
|
||||||
|
fs.mkdirSync(outDir, { recursive: true })
|
||||||
|
const outFile = path.join(outDir, `monkey-complex-${Date.now()}.json`)
|
||||||
|
fs.writeFileSync(outFile, JSON.stringify(report, null, 2), 'utf8')
|
||||||
|
fs.writeFileSync(path.join(outDir, 'latest.json'), JSON.stringify(report, null, 2), 'utf8')
|
||||||
|
|
||||||
|
// 摘要
|
||||||
|
const summary = {
|
||||||
|
verdict: report.verdict,
|
||||||
|
seed: report.seed,
|
||||||
|
logic: `${report.logic.passed}/${report.logic.passed + report.logic.failed}`,
|
||||||
|
uiActions: report.ui.actionsDone,
|
||||||
|
ipcCalls: report.ipc.calls,
|
||||||
|
ipcErrors: report.ipc.errors.length,
|
||||||
|
scenariosOk: report.scenarios.ran.length,
|
||||||
|
scenariosFail: report.scenarios.failed.length,
|
||||||
|
crashes: report.ui.crashes.length,
|
||||||
|
consoleErrors: report.ui.consoleErrors.length,
|
||||||
|
pageErrors: report.ui.pageErrors.length,
|
||||||
|
dialogs: report.ui.dialogs,
|
||||||
|
outFile
|
||||||
|
}
|
||||||
|
console.log('\n========== COMPLEX MONKEY SUMMARY ==========')
|
||||||
|
console.log(JSON.stringify(summary, null, 2))
|
||||||
|
if (report.scenarios.failed.length) {
|
||||||
|
console.log('\nScenario failures:', report.scenarios.failed)
|
||||||
|
}
|
||||||
|
if (softIpc.length) console.log('\nIPC warnings:', softIpc.slice(0, 10))
|
||||||
|
if (report.ui.crashes.length) console.log('\nCrashes:', report.ui.crashes)
|
||||||
|
console.log(`\nFull report: ${outFile}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
runLogicMonkey()
|
||||||
|
try {
|
||||||
|
await ensureBuild()
|
||||||
|
await runUiMonkey()
|
||||||
|
} catch (e) {
|
||||||
|
report.ui.crashes.push(String(e?.stack || e))
|
||||||
|
}
|
||||||
|
finalize()
|
||||||
|
process.exit(report.verdict === 'FAIL' ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
10
src/main/appState.ts
Normal file
10
src/main/appState.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** 应用退出意图标志:区分「关闭到后台(隐藏)」与「真正退出」 */
|
||||||
|
let quitting = false
|
||||||
|
|
||||||
|
export function isQuitting(): boolean {
|
||||||
|
return quitting
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setQuitting(v: boolean): void {
|
||||||
|
quitting = v
|
||||||
|
}
|
||||||
68
src/main/index.ts
Normal file
68
src/main/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
|
import { createMainWindow, getMainWindow } from './window/mainWindow'
|
||||||
|
import { createTray, destroyTray } from './window/tray'
|
||||||
|
import { setQuitting } from './appState'
|
||||||
|
import { registerIpc } from './ipc'
|
||||||
|
import { initDatabase } from './services/database'
|
||||||
|
import { stopFileServer } from './services/fileServer'
|
||||||
|
import { stopMusicServer } from './services/musicServer'
|
||||||
|
import { stopDataServer } from './services/dataServer'
|
||||||
|
|
||||||
|
/** Monkey 测试:独立 userData,并跳过单例锁,避免与正在运行的实例冲突 */
|
||||||
|
const isMonkey = process.env.LLMP_MONKEY === '1'
|
||||||
|
if (isMonkey) {
|
||||||
|
app.setPath('userData', join(app.getPath('temp'), 'llmp-monkey-userdata'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例锁(monkey 模式跳过)
|
||||||
|
const gotLock = isMonkey ? true : app.requestSingleInstanceLock()
|
||||||
|
if (!gotLock) {
|
||||||
|
app.quit()
|
||||||
|
} else {
|
||||||
|
if (!isMonkey) {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
const win = getMainWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
electronApp.setAppUserModelId('com.zhushenwudi.lovelivemusicplayer')
|
||||||
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
optimizer.watchWindowShortcuts(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
initDatabase()
|
||||||
|
registerIpc()
|
||||||
|
createMainWindow()
|
||||||
|
createTray()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
const win = getMainWindow()
|
||||||
|
if (!win) createMainWindow()
|
||||||
|
else {
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 主窗口隐藏到托盘后不会触发真正退出;仅托盘「退出」才 quit
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// no-op
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
setQuitting(true)
|
||||||
|
destroyTray()
|
||||||
|
stopFileServer()
|
||||||
|
stopMusicServer()
|
||||||
|
stopDataServer()
|
||||||
|
})
|
||||||
|
}
|
||||||
164
src/main/ipc/index.ts
Normal file
164
src/main/ipc/index.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { ipcMain, dialog, BrowserWindow, shell } from 'electron'
|
||||||
|
import { IPC } from '@shared/ipc/channels'
|
||||||
|
import { getStore } from '../services/store'
|
||||||
|
import { dbQuery, dbExec } from '../services/database'
|
||||||
|
import {
|
||||||
|
startFileServer,
|
||||||
|
stopFileServer,
|
||||||
|
getFileServerStatus
|
||||||
|
} from '../services/fileServer'
|
||||||
|
import { getLanIps } from '../services/lanInfo'
|
||||||
|
import { fetchJson, fetchText } from '../services/network'
|
||||||
|
import { syncLibrary } from '../services/library'
|
||||||
|
import { convertFlacToWav, stopConvert } from '../services/transcode'
|
||||||
|
import {
|
||||||
|
startMusicServer,
|
||||||
|
stopMusicServer,
|
||||||
|
sendMusicCmd
|
||||||
|
} from '../services/musicServer'
|
||||||
|
import { startDataServer, stopDataServer, sendDataCmd } from '../services/dataServer'
|
||||||
|
import {
|
||||||
|
toggleDesktopLyric,
|
||||||
|
updateDesktopLyric,
|
||||||
|
setLyricIgnoreMouse
|
||||||
|
} from '../window/desktopLyricWindow'
|
||||||
|
import { checkUpdate } from '../services/update'
|
||||||
|
import { exportSongs, type ExportItem } from '../services/export'
|
||||||
|
import { cleanIosWavFile, ensureIosWavFiles, type WifiAudioRef } from '../services/wifiTransfer'
|
||||||
|
|
||||||
|
function broadcast(channel: string, payload: unknown): void {
|
||||||
|
BrowserWindow.getAllWindows().forEach((w) => w.webContents.send(channel, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerIpc(): void {
|
||||||
|
/* ---------- 配置 ---------- */
|
||||||
|
const store = getStore()
|
||||||
|
ipcMain.handle(IPC.STORE_GET, (_e, key: string) => store.get(key as never))
|
||||||
|
ipcMain.handle(IPC.STORE_SET, (_e, key: string, value: unknown) =>
|
||||||
|
store.set(key as never, value as never)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.STORE_DELETE, (_e, key: string) => store.delete(key as never))
|
||||||
|
|
||||||
|
/* ---------- 数据库 ---------- */
|
||||||
|
ipcMain.handle(IPC.DB_QUERY, (_e, sql: string, params: unknown[]) => dbQuery(sql, params))
|
||||||
|
ipcMain.handle(IPC.DB_EXEC, (_e, sql: string, params: unknown[]) => {
|
||||||
|
const r = dbExec(sql, params)
|
||||||
|
return { changes: r.changes, lastInsertRowid: Number(r.lastInsertRowid) }
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------- HTTP 文件服务 ---------- */
|
||||||
|
ipcMain.handle(IPC.HTTP_START, async (_e, root: string, port: number) =>
|
||||||
|
startFileServer(root, port)
|
||||||
|
)
|
||||||
|
ipcMain.handle(IPC.HTTP_STOP, async () => {
|
||||||
|
await stopFileServer()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.HTTP_STATUS, () => getFileServerStatus())
|
||||||
|
|
||||||
|
/* ---------- 局域网 ---------- */
|
||||||
|
ipcMain.handle(IPC.NET_LAN_IPS, () => getLanIps())
|
||||||
|
|
||||||
|
/* ---------- OSS 元数据 ---------- */
|
||||||
|
ipcMain.handle(IPC.NET_FETCH_JSON, (_e, url: string) => fetchJson(url))
|
||||||
|
ipcMain.handle(IPC.NET_FETCH_TEXT, (_e, url: string) => fetchText(url))
|
||||||
|
|
||||||
|
/* ---------- 曲库同步 ---------- */
|
||||||
|
ipcMain.handle(IPC.LIBRARY_SYNC, async () => {
|
||||||
|
const result = await syncLibrary()
|
||||||
|
store.set('dataVersion', String(result.version))
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.LIBRARY_DATA_VERSION, () => store.get('dataVersion'))
|
||||||
|
|
||||||
|
/* ---------- 转码 ---------- */
|
||||||
|
ipcMain.handle(IPC.CONVERT_START, async (_e, input: string, output: string) => {
|
||||||
|
await convertFlacToWav({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
onProgress: (p) => broadcast(IPC.CONVERT_PROGRESS, { input, progress: p })
|
||||||
|
})
|
||||||
|
broadcast(IPC.CONVERT_DONE, { input, output })
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.CONVERT_STOP, () => {
|
||||||
|
stopConvert()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.WIFI_ENSURE_IOS_WAV, async (_e, items: WifiAudioRef[]) => {
|
||||||
|
await ensureIosWavFiles(items || [])
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.WIFI_CLEAN_IOS_WAV, (_e, item: WifiAudioRef) => {
|
||||||
|
cleanIosWavFile(item)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------- 传歌服务(4388) ---------- */
|
||||||
|
ipcMain.handle(IPC.MUSIC_SERVER_START, () => {
|
||||||
|
startMusicServer((e) => broadcast(IPC.MUSIC_SERVER_EVENT, e))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.MUSIC_SERVER_STOP, () => {
|
||||||
|
stopMusicServer()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.MUSIC_SERVER_SEND, (_e, cmd: string, body: string) =>
|
||||||
|
sendMusicCmd(cmd, body)
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ---------- 数据同步服务(4389) ---------- */
|
||||||
|
ipcMain.handle(IPC.DATA_SERVER_START, () => {
|
||||||
|
startDataServer((e) => broadcast(IPC.DATA_SERVER_EVENT, e))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.DATA_SERVER_STOP, () => {
|
||||||
|
stopDataServer()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.DATA_SERVER_SEND, (_e, cmd: string, body: string) =>
|
||||||
|
sendDataCmd(cmd, body)
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ---------- 对话框 ---------- */
|
||||||
|
ipcMain.handle(IPC.DIALOG_OPEN_DIR, async () => {
|
||||||
|
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||||
|
return r.canceled ? null : r.filePaths[0]
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.DIALOG_OPEN_FILE, async () => {
|
||||||
|
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })
|
||||||
|
return r.canceled ? null : r.filePaths
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------- 桌面歌词 ---------- */
|
||||||
|
ipcMain.handle(IPC.LYRIC_TOGGLE, (_e, show: boolean) => {
|
||||||
|
toggleDesktopLyric(show)
|
||||||
|
broadcast(IPC.LYRIC_STATE, show)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ipcMain.on(IPC.LYRIC_UPDATE, (_e, payload) => updateDesktopLyric(payload))
|
||||||
|
ipcMain.on(IPC.LYRIC_SET_IGNORE_MOUSE, (_e, ignore: boolean) => setLyricIgnoreMouse(ignore))
|
||||||
|
|
||||||
|
/* ---------- 窗口控制 ---------- */
|
||||||
|
ipcMain.on(IPC.WIN_MIN, (e) => BrowserWindow.fromWebContents(e.sender)?.minimize())
|
||||||
|
ipcMain.on(IPC.WIN_MAX, (e) => {
|
||||||
|
const w = BrowserWindow.fromWebContents(e.sender)
|
||||||
|
if (!w) return
|
||||||
|
w.isMaximized() ? w.unmaximize() : w.maximize()
|
||||||
|
})
|
||||||
|
ipcMain.on(IPC.WIN_CLOSE, (e) => BrowserWindow.fromWebContents(e.sender)?.close())
|
||||||
|
|
||||||
|
/* ---------- 导出歌曲 ---------- */
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.EXPORT_SONGS,
|
||||||
|
async (_e, dest: string, platform: 'android' | 'ios', list: ExportItem[]) =>
|
||||||
|
exportSongs(dest, platform, list, (p) => broadcast(IPC.EXPORT_PROGRESS, p))
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ---------- 更新 ---------- */
|
||||||
|
ipcMain.handle(IPC.UPDATE_CHECK, () => checkUpdate())
|
||||||
|
ipcMain.handle(IPC.UPDATE_OPEN_DOWNLOAD, (_e, url: string) => {
|
||||||
|
if (url) shell.openExternal(url)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
92
src/main/services/dataServer.ts
Normal file
92
src/main/services/dataServer.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { WebSocketServer, WebSocket } from 'ws'
|
||||||
|
import {
|
||||||
|
PORTS,
|
||||||
|
DataCmd,
|
||||||
|
TRANS_VER,
|
||||||
|
encodeFtpCmd,
|
||||||
|
decodeFtpCmd,
|
||||||
|
isVersionCompatible
|
||||||
|
} from '@shared/protocol'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据同步 WebSocket 服务(端口 4389,服务端)。
|
||||||
|
* 处理版本握手,其余(connected/phone2pc/pc2phone/finish)转发渲染进程。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DataServerEvent =
|
||||||
|
| { type: 'connected' }
|
||||||
|
| { type: 'disconnected' }
|
||||||
|
| { type: 'versionMismatch'; remoteVer: string }
|
||||||
|
| { type: 'cmd'; cmd: string; body: string }
|
||||||
|
|
||||||
|
let wss: WebSocketServer | null = null
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
|
||||||
|
export function startDataServer(onEvent: (e: DataServerEvent) => void): void {
|
||||||
|
stopDataServer()
|
||||||
|
const port = process.env.LLMP_MONKEY === '1' ? PORTS.DATA_WS + 100 : PORTS.DATA_WS
|
||||||
|
wss = new WebSocketServer({ port, host: '0.0.0.0' })
|
||||||
|
wss.on('error', (err) => console.error('[dataServer]', err.message))
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
socket = ws
|
||||||
|
let verified = false
|
||||||
|
onEvent({ type: 'connected' })
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let command
|
||||||
|
try {
|
||||||
|
command = decodeFtpCmd(raw.toString())
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch (command.cmd) {
|
||||||
|
case DataCmd.VERSION: {
|
||||||
|
const compatible = isVersionCompatible(command.body)
|
||||||
|
verified = true
|
||||||
|
ws.send(encodeFtpCmd(DataCmd.VERSION, TRANS_VER))
|
||||||
|
if (!compatible) onEvent({ type: 'versionMismatch', remoteVer: command.body })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case DataCmd.CONNECTED: {
|
||||||
|
if (!verified) {
|
||||||
|
onEvent({ type: 'versionMismatch', remoteVer: '?' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEvent({ type: 'cmd', cmd: command.cmd, body: command.body })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
onEvent({ type: 'cmd', cmd: command.cmd, body: command.body })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
socket = null
|
||||||
|
onEvent({ type: 'disconnected' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendDataCmd(cmd: string, body: string): boolean {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(encodeFtpCmd(cmd, body))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopDataServer(): void {
|
||||||
|
if (socket) {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
if (wss) {
|
||||||
|
wss.close()
|
||||||
|
wss = null
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/main/services/database.ts
Normal file
279
src/main/services/database.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import type { Album, Music, Lyric, Menu, Love, History } from '@shared/models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地数据库(better-sqlite3,替代旧 NeDB)。
|
||||||
|
* 表结构对齐移动端 Floor 实体,降低双端同步映射成本。
|
||||||
|
*/
|
||||||
|
|
||||||
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
|
export function initDatabase(): Database.Database {
|
||||||
|
if (db) return db
|
||||||
|
const dbPath = join(app.getPath('userData'), 'llmp.db')
|
||||||
|
db = new Database(dbPath)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
createTables(db)
|
||||||
|
migrate(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轻量迁移:为已存在的库补齐新增列 */
|
||||||
|
function migrate(d: Database.Database): void {
|
||||||
|
const musicCols = d.prepare('PRAGMA table_info(music)').all() as { name: string }[]
|
||||||
|
const albumCols = d.prepare('PRAGMA table_info(album)').all() as { name: string }[]
|
||||||
|
const ensureMusic = (name: string, ddl: string): void => {
|
||||||
|
if (!musicCols.some((c) => c.name === name)) d.exec(ddl)
|
||||||
|
}
|
||||||
|
const ensureAlbum = (name: string, ddl: string): void => {
|
||||||
|
if (!albumCols.some((c) => c.name === name)) d.exec(ddl)
|
||||||
|
}
|
||||||
|
ensureMusic('recommend', 'ALTER TABLE music ADD COLUMN recommend INTEGER DEFAULT 0')
|
||||||
|
ensureMusic('artistBin', "ALTER TABLE music ADD COLUMN artistBin TEXT NOT NULL DEFAULT ''")
|
||||||
|
ensureMusic('time', "ALTER TABLE music ADD COLUMN time TEXT NOT NULL DEFAULT ''")
|
||||||
|
ensureMusic('musicId', 'ALTER TABLE music ADD COLUMN musicId INTEGER')
|
||||||
|
ensureAlbum('albumId', 'ALTER TABLE album ADD COLUMN albumId INTEGER')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (!db) return initDatabase()
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTables(d: Database.Database): void {
|
||||||
|
d.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS album (
|
||||||
|
albumUId TEXT PRIMARY KEY,
|
||||||
|
albumName TEXT NOT NULL DEFAULT '',
|
||||||
|
cover TEXT NOT NULL DEFAULT '',
|
||||||
|
category TEXT NOT NULL DEFAULT '',
|
||||||
|
"group" TEXT NOT NULL DEFAULT '',
|
||||||
|
releaseDate TEXT,
|
||||||
|
baseUrl TEXT NOT NULL DEFAULT '',
|
||||||
|
albumId INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS music (
|
||||||
|
musicUId TEXT PRIMARY KEY,
|
||||||
|
albumUId TEXT NOT NULL DEFAULT '',
|
||||||
|
musicName TEXT NOT NULL DEFAULT '',
|
||||||
|
artist TEXT NOT NULL DEFAULT '',
|
||||||
|
musicPath TEXT NOT NULL DEFAULT '',
|
||||||
|
coverPath TEXT NOT NULL DEFAULT '',
|
||||||
|
baseUrl TEXT NOT NULL DEFAULT '',
|
||||||
|
"group" TEXT NOT NULL DEFAULT '',
|
||||||
|
neteaseId TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
"index" INTEGER,
|
||||||
|
local INTEGER DEFAULT 0,
|
||||||
|
recommend INTEGER DEFAULT 0,
|
||||||
|
artistBin TEXT NOT NULL DEFAULT '',
|
||||||
|
time TEXT NOT NULL DEFAULT '',
|
||||||
|
musicId INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS artist (
|
||||||
|
artistId TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
avatar TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lyric (
|
||||||
|
musicUId TEXT PRIMARY KEY,
|
||||||
|
lyricJp TEXT,
|
||||||
|
lyricZh TEXT,
|
||||||
|
lyricRoma TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS menu (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
cover TEXT,
|
||||||
|
createTime INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_music (
|
||||||
|
menuId INTEGER NOT NULL,
|
||||||
|
musicUId TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (menuId, musicUId)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS love (
|
||||||
|
musicUId TEXT PRIMARY KEY,
|
||||||
|
createTime INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS history (
|
||||||
|
musicUId TEXT PRIMARY KEY,
|
||||||
|
playTime INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- 通用查询(供 IPC 调用) -------------------- */
|
||||||
|
|
||||||
|
export function dbQuery<T = unknown>(sql: string, params: unknown[] = []): T[] {
|
||||||
|
return getDb().prepare(sql).all(...params) as T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbExec(sql: string, params: unknown[] = []): Database.RunResult {
|
||||||
|
return getDb().prepare(sql).run(...params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- 领域 DAO 封装 -------------------- */
|
||||||
|
|
||||||
|
export const AlbumDao = {
|
||||||
|
all: (): Album[] => dbQuery<Album>('SELECT * FROM album'),
|
||||||
|
upsert: (a: Album): void => {
|
||||||
|
dbExec(
|
||||||
|
`INSERT INTO album (albumUId, albumName, cover, category, "group", releaseDate, baseUrl, albumId)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(albumUId) DO UPDATE SET
|
||||||
|
albumName=excluded.albumName, cover=excluded.cover, category=excluded.category,
|
||||||
|
"group"=excluded."group", releaseDate=excluded.releaseDate, baseUrl=excluded.baseUrl,
|
||||||
|
albumId=excluded.albumId`,
|
||||||
|
[
|
||||||
|
a.albumUId,
|
||||||
|
a.albumName,
|
||||||
|
a.cover,
|
||||||
|
a.category,
|
||||||
|
a.group,
|
||||||
|
a.releaseDate ?? null,
|
||||||
|
a.baseUrl,
|
||||||
|
a.albumId ?? null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MusicDao = {
|
||||||
|
all: (): Music[] => dbQuery<Music>('SELECT * FROM music'),
|
||||||
|
byIds: (ids: string[]): Music[] => {
|
||||||
|
if (ids.length === 0) return []
|
||||||
|
const placeholders = ids.map(() => '?').join(',')
|
||||||
|
return dbQuery<Music>(`SELECT * FROM music WHERE musicUId IN (${placeholders})`, ids)
|
||||||
|
},
|
||||||
|
byAlbum: (albumUId: string): Music[] =>
|
||||||
|
dbQuery<Music>('SELECT * FROM music WHERE albumUId = ? ORDER BY "index" ASC', [albumUId]),
|
||||||
|
upsert: (m: Music): void => {
|
||||||
|
dbExec(
|
||||||
|
`INSERT INTO music (musicUId, albumUId, musicName, artist, musicPath, coverPath, baseUrl, "group", neteaseId, duration, "index", local, recommend, artistBin, time, musicId)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(musicUId) DO UPDATE SET
|
||||||
|
albumUId=excluded.albumUId, musicName=excluded.musicName, artist=excluded.artist,
|
||||||
|
musicPath=excluded.musicPath, coverPath=excluded.coverPath, baseUrl=excluded.baseUrl,
|
||||||
|
"group"=excluded."group", neteaseId=excluded.neteaseId, duration=excluded.duration,
|
||||||
|
"index"=excluded."index", local=excluded.local, recommend=excluded.recommend,
|
||||||
|
artistBin=excluded.artistBin, time=excluded.time, musicId=excluded.musicId`,
|
||||||
|
[
|
||||||
|
m.musicUId,
|
||||||
|
m.albumUId,
|
||||||
|
m.musicName,
|
||||||
|
m.artist,
|
||||||
|
m.musicPath,
|
||||||
|
m.coverPath,
|
||||||
|
m.baseUrl,
|
||||||
|
m.group,
|
||||||
|
m.neteaseId ?? null,
|
||||||
|
m.duration ?? null,
|
||||||
|
m.index ?? null,
|
||||||
|
m.local ? 1 : 0,
|
||||||
|
m.recommend ? 1 : 0,
|
||||||
|
m.artistBin ?? '',
|
||||||
|
m.time ?? '',
|
||||||
|
m.musicId ?? m.index ?? null
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LyricDao = {
|
||||||
|
byId: (musicUId: string): Lyric | undefined =>
|
||||||
|
dbQuery<Lyric>('SELECT * FROM lyric WHERE musicUId = ?', [musicUId])[0],
|
||||||
|
upsert: (l: Lyric): void => {
|
||||||
|
dbExec(
|
||||||
|
`INSERT INTO lyric (musicUId, lyricJp, lyricZh, lyricRoma) VALUES (?,?,?,?)
|
||||||
|
ON CONFLICT(musicUId) DO UPDATE SET
|
||||||
|
lyricJp=excluded.lyricJp, lyricZh=excluded.lyricZh, lyricRoma=excluded.lyricRoma`,
|
||||||
|
[l.musicUId, l.lyricJp ?? null, l.lyricZh ?? null, l.lyricRoma ?? null]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoveDao = {
|
||||||
|
all: (): Love[] => dbQuery<Love>('SELECT * FROM love ORDER BY createTime DESC'),
|
||||||
|
add: (musicUId: string): void =>
|
||||||
|
void dbExec('INSERT OR IGNORE INTO love (musicUId, createTime) VALUES (?, ?)', [
|
||||||
|
musicUId,
|
||||||
|
Date.now()
|
||||||
|
]),
|
||||||
|
remove: (musicUId: string): void =>
|
||||||
|
void dbExec('DELETE FROM love WHERE musicUId = ?', [musicUId]),
|
||||||
|
has: (musicUId: string): boolean =>
|
||||||
|
dbQuery('SELECT 1 FROM love WHERE musicUId = ?', [musicUId]).length > 0,
|
||||||
|
replaceAll: (loves: Love[]): void => {
|
||||||
|
const d = getDb()
|
||||||
|
const tx = d.transaction((items: Love[]) => {
|
||||||
|
d.prepare('DELETE FROM love').run()
|
||||||
|
const stmt = d.prepare('INSERT OR IGNORE INTO love (musicUId, createTime) VALUES (?, ?)')
|
||||||
|
for (const it of items) stmt.run(it.musicUId, it.createTime ?? Date.now())
|
||||||
|
})
|
||||||
|
tx(loves)
|
||||||
|
},
|
||||||
|
merge: (loves: Love[]): void => {
|
||||||
|
const d = getDb()
|
||||||
|
const stmt = d.prepare('INSERT OR IGNORE INTO love (musicUId, createTime) VALUES (?, ?)')
|
||||||
|
const tx = d.transaction((items: Love[]) => {
|
||||||
|
for (const it of items) stmt.run(it.musicUId, it.createTime ?? Date.now())
|
||||||
|
})
|
||||||
|
tx(loves)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistoryDao = {
|
||||||
|
recent: (limit = 100): History[] =>
|
||||||
|
dbQuery<History>('SELECT * FROM history ORDER BY playTime DESC LIMIT ?', [limit]),
|
||||||
|
touch: (musicUId: string): void =>
|
||||||
|
void dbExec(
|
||||||
|
`INSERT INTO history (musicUId, playTime) VALUES (?, ?)
|
||||||
|
ON CONFLICT(musicUId) DO UPDATE SET playTime=excluded.playTime`,
|
||||||
|
[musicUId, Date.now()]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuDao = {
|
||||||
|
all: (): Menu[] => dbQuery<Menu>('SELECT * FROM menu ORDER BY createTime DESC'),
|
||||||
|
pcMenus: (): Menu[] => dbQuery<Menu>('SELECT * FROM menu WHERE id <= 100'),
|
||||||
|
phoneMenus: (): Menu[] => dbQuery<Menu>('SELECT * FROM menu WHERE id > 100'),
|
||||||
|
musicIds: (menuId: number): string[] =>
|
||||||
|
dbQuery<{ musicUId: string }>(
|
||||||
|
'SELECT musicUId FROM playlist_music WHERE menuId = ? ORDER BY "order" ASC',
|
||||||
|
[menuId]
|
||||||
|
).map((r) => r.musicUId),
|
||||||
|
upsert: (m: Menu, musicUIds: string[]): void => {
|
||||||
|
const d = getDb()
|
||||||
|
const tx = d.transaction(() => {
|
||||||
|
d.prepare(
|
||||||
|
`INSERT INTO menu (id, title, cover, createTime) VALUES (?,?,?,?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET title=excluded.title, cover=excluded.cover`
|
||||||
|
).run(m.id, m.title, m.cover ?? null, m.createTime)
|
||||||
|
d.prepare('DELETE FROM playlist_music WHERE menuId = ?').run(m.id)
|
||||||
|
const stmt = d.prepare(
|
||||||
|
'INSERT OR IGNORE INTO playlist_music (menuId, musicUId, "order") VALUES (?,?,?)'
|
||||||
|
)
|
||||||
|
musicUIds.forEach((id, i) => stmt.run(m.id, id, i))
|
||||||
|
})
|
||||||
|
tx()
|
||||||
|
},
|
||||||
|
remove: (id: number): void => {
|
||||||
|
const d = getDb()
|
||||||
|
const tx = d.transaction(() => {
|
||||||
|
d.prepare('DELETE FROM menu WHERE id = ?').run(id)
|
||||||
|
d.prepare('DELETE FROM playlist_music WHERE menuId = ?').run(id)
|
||||||
|
})
|
||||||
|
tx()
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/main/services/export.ts
Normal file
96
src/main/services/export.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { join, dirname, basename } from 'path'
|
||||||
|
import { existsSync, mkdirSync, copyFileSync } from 'fs'
|
||||||
|
import { getFileServerStatus } from './fileServer'
|
||||||
|
import { getStore } from './store'
|
||||||
|
import { convertFlacToWav } from './transcode'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出歌曲到用户选择的目录(对齐原项目「仅导出 Android / iOS」)。
|
||||||
|
* - Android:flac 原样拷贝;
|
||||||
|
* - iOS:flac → wav 转码(兼容性)。
|
||||||
|
* 目录结构保留 base_url 层级,统一放在所选目录下的 output/。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExportItem {
|
||||||
|
musicUId: string
|
||||||
|
baseUrl: string
|
||||||
|
musicPath: string
|
||||||
|
coverPath: string
|
||||||
|
musicName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportProgress {
|
||||||
|
musicUId: string
|
||||||
|
status: 'converting' | 'copying' | 'done' | 'fail'
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析本地曲库根目录(运行中的 http-server 根,或配置目录,兼容直接选中 LoveLive) */
|
||||||
|
function resolveRoot(): string {
|
||||||
|
const running = getFileServerStatus().root
|
||||||
|
if (running) return running
|
||||||
|
let root = (getStore().get('serverPath' as never) as string) || ''
|
||||||
|
if (root && basename(root).toLowerCase() === 'lovelive') root = dirname(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSongs(
|
||||||
|
dest: string,
|
||||||
|
platform: 'android' | 'ios',
|
||||||
|
list: ExportItem[],
|
||||||
|
onProgress: (p: ExportProgress) => void
|
||||||
|
): Promise<{ done: number; total: number; fail: number }> {
|
||||||
|
const root = resolveRoot()
|
||||||
|
if (!root) throw new Error('未配置本地曲库目录')
|
||||||
|
const outRoot = join(dest, 'output')
|
||||||
|
const total = list.length
|
||||||
|
let done = 0
|
||||||
|
let fail = 0
|
||||||
|
|
||||||
|
for (const it of list) {
|
||||||
|
const srcAudio = join(root, it.baseUrl, it.musicPath)
|
||||||
|
const isFlac = /\.flac$/i.test(it.musicPath)
|
||||||
|
const destName =
|
||||||
|
platform === 'ios' && isFlac ? it.musicPath.replace(/\.flac$/i, '.wav') : it.musicPath
|
||||||
|
const destAudio = join(outRoot, it.baseUrl, destName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(srcAudio)) {
|
||||||
|
fail++
|
||||||
|
done++
|
||||||
|
onProgress({ musicUId: it.musicUId, status: 'fail', done, total })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(destAudio), { recursive: true })
|
||||||
|
if (platform === 'ios' && isFlac) {
|
||||||
|
onProgress({ musicUId: it.musicUId, status: 'converting', done, total })
|
||||||
|
await convertFlacToWav({ input: srcAudio, output: destAudio })
|
||||||
|
} else {
|
||||||
|
onProgress({ musicUId: it.musicUId, status: 'copying', done, total })
|
||||||
|
copyFileSync(srcAudio, destAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封面一并导出
|
||||||
|
if (it.coverPath) {
|
||||||
|
const srcCover = join(root, it.baseUrl, it.coverPath)
|
||||||
|
const destCover = join(outRoot, it.baseUrl, it.coverPath)
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(destCover), { recursive: true })
|
||||||
|
if (existsSync(srcCover)) copyFileSync(srcCover, destCover)
|
||||||
|
} catch {
|
||||||
|
/* 封面失败不阻断 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done++
|
||||||
|
onProgress({ musicUId: it.musicUId, status: 'done', done, total })
|
||||||
|
} catch {
|
||||||
|
fail++
|
||||||
|
done++
|
||||||
|
onProgress({ musicUId: it.musicUId, status: 'fail', done, total })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { done, total, fail }
|
||||||
|
}
|
||||||
89
src/main/services/fileServer.ts
Normal file
89
src/main/services/fileServer.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import http from 'http'
|
||||||
|
import net from 'net'
|
||||||
|
import { basename, dirname } from 'path'
|
||||||
|
import { HTTP_PORT_RANGE } from '@shared/protocol'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地 HTTP 静态文件服务(托管曲库目录),供播放器与手机下载歌曲/封面/歌词。
|
||||||
|
* 相比旧版跑在渲染进程,这里迁到主进程更安全。
|
||||||
|
*/
|
||||||
|
|
||||||
|
let server: http.Server | null = null
|
||||||
|
let currentPort = 0
|
||||||
|
let currentRoot = ''
|
||||||
|
|
||||||
|
/** 探测可用端口(占用则 +1,直到上限) */
|
||||||
|
function findAvailablePort(start: number): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tryPort = (port: number): void => {
|
||||||
|
if (port > HTTP_PORT_RANGE.MAX) {
|
||||||
|
reject(new Error('没有可用端口'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tester = net.createServer()
|
||||||
|
tester.once('error', () => tryPort(port + 1))
|
||||||
|
tester.once('listening', () => {
|
||||||
|
tester.close(() => resolve(port))
|
||||||
|
})
|
||||||
|
tester.listen(port, '0.0.0.0')
|
||||||
|
}
|
||||||
|
tryPort(start)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileServerStatus {
|
||||||
|
running: boolean
|
||||||
|
port: number
|
||||||
|
root: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startFileServer(root: string, preferredPort: number): Promise<FileServerStatus> {
|
||||||
|
await stopFileServer()
|
||||||
|
// 曲库 base_url 以 "LoveLive/" 开头,根目录须为其父级。
|
||||||
|
// 兼容用户直接选中 LoveLive 目录的情况,自动上退一级。
|
||||||
|
if (basename(root).toLowerCase() === 'lovelive') {
|
||||||
|
root = dirname(root)
|
||||||
|
}
|
||||||
|
const port = await findAvailablePort(preferredPort)
|
||||||
|
const application = express()
|
||||||
|
application.use((_, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
// 允许浏览器缓存封面/音频:曲库文件按路径寻址、极少变动。
|
||||||
|
// 若禁缓存(no-store),切换页面会整批重拉,配合 Chromium 每主机 6 连接上限
|
||||||
|
// 与被中断的请求,容易把连接池打满导致图片全部加载失败。
|
||||||
|
application.use(express.static(root, { maxAge: '7d', etag: true, lastModified: true }))
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server = http.createServer(application)
|
||||||
|
// 关闭 keep-alive,避免被中断(切页/切歌)的请求占住 socket 导致后续加载卡死
|
||||||
|
server.keepAliveTimeout = 0
|
||||||
|
server.on('error', reject)
|
||||||
|
server.listen(port, '0.0.0.0', () => {
|
||||||
|
currentPort = port
|
||||||
|
currentRoot = root
|
||||||
|
resolve({ running: true, port, root })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopFileServer(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (server) {
|
||||||
|
server.close(() => {
|
||||||
|
server = null
|
||||||
|
currentPort = 0
|
||||||
|
currentRoot = ''
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileServerStatus(): FileServerStatus {
|
||||||
|
return { running: !!server, port: currentPort, root: currentRoot }
|
||||||
|
}
|
||||||
52
src/main/services/lanInfo.ts
Normal file
52
src/main/services/lanInfo.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import os from 'os'
|
||||||
|
|
||||||
|
export interface LanInterface {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIPv4(family: string | number): boolean {
|
||||||
|
return family === 'IPv4' || family === 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 枚举本机局域网 IPv4(传歌 / 数据同步共用,结果必须一致)。
|
||||||
|
*
|
||||||
|
* 规则(对齐原项目 QRDialog,并兼容 Node family 为数字 4 的情况):
|
||||||
|
* 1. 每个网卡只取第一个非内部 IPv4
|
||||||
|
* 2. 排除名称含 VMware 的网卡(原项目条件)
|
||||||
|
* 3. 排除 127.0.0.1
|
||||||
|
* 4. 按地址去重
|
||||||
|
* 5. 192.168.* 排前,其余按地址排序
|
||||||
|
*/
|
||||||
|
export function getLanIps(): LanInterface[] {
|
||||||
|
const ifaces = os.networkInterfaces()
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: LanInterface[] = []
|
||||||
|
|
||||||
|
// 按名称排序,保证每次遍历顺序稳定
|
||||||
|
const names = Object.keys(ifaces).sort((a, b) => a.localeCompare(b))
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
if (name.toLowerCase().includes('vmware')) continue
|
||||||
|
const addrs = ifaces[name]
|
||||||
|
if (!addrs) continue
|
||||||
|
|
||||||
|
for (const addr of addrs) {
|
||||||
|
if (!isIPv4(addr.family) || addr.internal) continue
|
||||||
|
if (addr.address === '127.0.0.1') continue
|
||||||
|
if (seen.has(addr.address)) continue
|
||||||
|
seen.add(addr.address)
|
||||||
|
result.push({ name, address: addr.address })
|
||||||
|
break // 每网卡仅 1 个,对齐原 ip.address(iface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const pa = a.address.startsWith('192.168.') ? 0 : 1
|
||||||
|
const pb = b.address.startsWith('192.168.') ? 0 : 1
|
||||||
|
if (pa !== pb) return pa - pb
|
||||||
|
return a.address.localeCompare(b.address)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
155
src/main/services/library.ts
Normal file
155
src/main/services/library.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { fetchJson } from './network'
|
||||||
|
import { getDb } from './database'
|
||||||
|
import type { Album, Music } from '@shared/models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 曲库元数据同步(OSS data.json)。
|
||||||
|
* 注意:OSS 只分发「元数据 + 歌词」,不含音频/封面实体文件;
|
||||||
|
* 封面与播放需配合本地 http-server(用户自有曲库)或远程文件源。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OSS_HEAD = 'https://llmp-oss.zhushenwudi.top/LLMP/'
|
||||||
|
export const DATA_URL = OSS_HEAD + 'data/v2/data.json'
|
||||||
|
|
||||||
|
interface RawAlbum {
|
||||||
|
_id: string
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
date: string
|
||||||
|
cover_path: string[]
|
||||||
|
category: string
|
||||||
|
music: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawMusic {
|
||||||
|
_id: string
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
album: number
|
||||||
|
cover_path: string
|
||||||
|
music_path: string
|
||||||
|
artist: string
|
||||||
|
artist_bin?: string
|
||||||
|
time: string
|
||||||
|
albumName: string
|
||||||
|
base_url: string
|
||||||
|
neteaseId: string
|
||||||
|
export?: boolean | number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawData {
|
||||||
|
version: number
|
||||||
|
album: Record<string, RawAlbum[]>
|
||||||
|
music: Record<string, RawMusic[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToSeconds(t: string): number {
|
||||||
|
if (!t) return 0
|
||||||
|
const p = t.split(':').map((n) => parseInt(n, 10))
|
||||||
|
if (p.length === 2) return p[0] * 60 + p[1]
|
||||||
|
if (p.length === 3) return p[0] * 3600 + p[1] * 60 + p[2]
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
version: number
|
||||||
|
albums: number
|
||||||
|
musics: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncLibrary(): Promise<SyncResult> {
|
||||||
|
const data = await fetchJson<RawData>(DATA_URL)
|
||||||
|
|
||||||
|
const albums: Album[] = []
|
||||||
|
// group|albumId -> albumUId,用于把 music.album(数字) 关联到专辑 _id
|
||||||
|
const albumIdMap = new Map<string, string>()
|
||||||
|
for (const [group, list] of Object.entries(data.album)) {
|
||||||
|
for (const a of list) {
|
||||||
|
albums.push({
|
||||||
|
albumUId: a._id,
|
||||||
|
albumName: a.name,
|
||||||
|
cover: a.cover_path?.[0] ?? '',
|
||||||
|
category: a.category ?? '',
|
||||||
|
group,
|
||||||
|
releaseDate: a.date,
|
||||||
|
baseUrl: '',
|
||||||
|
albumId: a.id
|
||||||
|
})
|
||||||
|
albumIdMap.set(`${group}|${a.id}`, a._id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const musics: Music[] = []
|
||||||
|
for (const [group, list] of Object.entries(data.music)) {
|
||||||
|
for (const m of list) {
|
||||||
|
musics.push({
|
||||||
|
musicUId: m._id,
|
||||||
|
albumUId: albumIdMap.get(`${group}|${m.album}`) ?? '',
|
||||||
|
musicName: m.name,
|
||||||
|
artist: m.artist,
|
||||||
|
musicPath: m.music_path,
|
||||||
|
coverPath: m.cover_path,
|
||||||
|
baseUrl: m.base_url,
|
||||||
|
group,
|
||||||
|
neteaseId: m.neteaseId,
|
||||||
|
duration: timeToSeconds(m.time),
|
||||||
|
time: m.time || '',
|
||||||
|
artistBin: m.artist_bin || '',
|
||||||
|
musicId: m.id,
|
||||||
|
index: m.id,
|
||||||
|
local: false,
|
||||||
|
recommend: !!m.export
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:OSS 专辑 cover_path 已是相对根的完整路径(含 LoveLive/...),
|
||||||
|
// 不能再拼 music.base_url,否则图片 404。album.baseUrl 保持空串。
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM album').run()
|
||||||
|
db.prepare('DELETE FROM music').run()
|
||||||
|
const aStmt = db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO album (albumUId, albumName, cover, category, "group", releaseDate, baseUrl, albumId)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)`
|
||||||
|
)
|
||||||
|
for (const a of albums)
|
||||||
|
aStmt.run(
|
||||||
|
a.albumUId,
|
||||||
|
a.albumName,
|
||||||
|
a.cover,
|
||||||
|
a.category,
|
||||||
|
a.group,
|
||||||
|
a.releaseDate ?? null,
|
||||||
|
a.baseUrl,
|
||||||
|
a.albumId ?? null
|
||||||
|
)
|
||||||
|
const mStmt = db.prepare(
|
||||||
|
`INSERT OR REPLACE INTO music (musicUId, albumUId, musicName, artist, musicPath, coverPath, baseUrl, "group", neteaseId, duration, "index", local, recommend, artistBin, time, musicId)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||||
|
)
|
||||||
|
for (const m of musics)
|
||||||
|
mStmt.run(
|
||||||
|
m.musicUId,
|
||||||
|
m.albumUId,
|
||||||
|
m.musicName,
|
||||||
|
m.artist,
|
||||||
|
m.musicPath,
|
||||||
|
m.coverPath,
|
||||||
|
m.baseUrl,
|
||||||
|
m.group,
|
||||||
|
m.neteaseId ?? null,
|
||||||
|
m.duration ?? null,
|
||||||
|
m.index ?? null,
|
||||||
|
0,
|
||||||
|
m.recommend ? 1 : 0,
|
||||||
|
m.artistBin ?? '',
|
||||||
|
m.time ?? '',
|
||||||
|
m.musicId ?? m.index ?? null
|
||||||
|
)
|
||||||
|
})
|
||||||
|
tx()
|
||||||
|
|
||||||
|
return { version: data.version, albums: albums.length, musics: musics.length }
|
||||||
|
}
|
||||||
154
src/main/services/musicServer.ts
Normal file
154
src/main/services/musicServer.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { WebSocketServer, WebSocket } from 'ws'
|
||||||
|
import {
|
||||||
|
PORTS,
|
||||||
|
MusicCmd,
|
||||||
|
TRANS_VER,
|
||||||
|
encodeFtpCmd,
|
||||||
|
decodeFtpCmd,
|
||||||
|
isVersionCompatible
|
||||||
|
} from '@shared/protocol'
|
||||||
|
import { getFileServerStatus, startFileServer } from './fileServer'
|
||||||
|
import { getStore } from './store'
|
||||||
|
import { cleanIosWavFile } from './wifiTransfer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WiFi 传歌 WebSocket 服务(端口 4388,服务端)。
|
||||||
|
* - 自动处理版本握手与 system→port 回复;
|
||||||
|
* - 其余业务命令(musicList/downloading/...)通过 onEvent 转发给渲染进程处理;
|
||||||
|
* - 渲染进程通过 send() 下发 prepare/ready/download/stop。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MusicServerEvent =
|
||||||
|
| { type: 'connected' }
|
||||||
|
| { type: 'disconnected' }
|
||||||
|
| { type: 'versionMismatch'; remoteVer: string }
|
||||||
|
| { type: 'system'; system: string }
|
||||||
|
| { type: 'portUnavailable'; reason: string }
|
||||||
|
| { type: 'cmd'; cmd: string; body: string }
|
||||||
|
|
||||||
|
/** 握手时确保本地 HTTP 文件服务可用(手机靠此端口拉歌) */
|
||||||
|
async function ensureFileServerPort(): Promise<{ port: number; error?: string }> {
|
||||||
|
const status = getFileServerStatus()
|
||||||
|
if (status.running && status.port > 0) return { port: status.port }
|
||||||
|
|
||||||
|
const store = getStore()
|
||||||
|
const root = (store.get('serverPath' as never) as string) || ''
|
||||||
|
const preferred = (store.get('serverPort' as never) as number) || 10000
|
||||||
|
if (!root) {
|
||||||
|
return { port: 0, error: '未配置本地曲库目录,手机无法下载文件。请到设置启动本地来源。' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const started = await startFileServer(root, preferred)
|
||||||
|
return { port: started.port }
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
port: 0,
|
||||||
|
error: `本地文件服务启动失败:${(e as Error).message || '未知错误'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wss: WebSocketServer | null = null
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let phoneSystem = ''
|
||||||
|
let lastReadyList: { musicUId: string; baseUrl: string; musicPath: string }[] = []
|
||||||
|
|
||||||
|
export function startMusicServer(onEvent: (e: MusicServerEvent) => void): void {
|
||||||
|
stopMusicServer()
|
||||||
|
// monkey 并行实例避开开发中的 4388
|
||||||
|
const port = process.env.LLMP_MONKEY === '1' ? PORTS.MUSIC_WS + 100 : PORTS.MUSIC_WS
|
||||||
|
wss = new WebSocketServer({ port, host: '0.0.0.0' })
|
||||||
|
wss.on('error', (err) => console.error('[musicServer]', err.message))
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
socket = ws
|
||||||
|
let verified = false
|
||||||
|
phoneSystem = ''
|
||||||
|
lastReadyList = []
|
||||||
|
onEvent({ type: 'connected' })
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let command
|
||||||
|
try {
|
||||||
|
command = decodeFtpCmd(raw.toString())
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch (command.cmd) {
|
||||||
|
case MusicCmd.VERSION: {
|
||||||
|
const compatible = isVersionCompatible(command.body)
|
||||||
|
verified = true
|
||||||
|
ws.send(encodeFtpCmd(MusicCmd.VERSION, TRANS_VER))
|
||||||
|
if (!compatible) onEvent({ type: 'versionMismatch', remoteVer: command.body })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case MusicCmd.SYSTEM: {
|
||||||
|
if (!verified) {
|
||||||
|
onEvent({ type: 'versionMismatch', remoteVer: '?' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phoneSystem = (command.body || '').toLowerCase()
|
||||||
|
onEvent({ type: 'system', system: command.body })
|
||||||
|
// 下发 HTTP 文件服务端口(未运行则尝试按配置自动启动)
|
||||||
|
void ensureFileServerPort().then(({ port, error }) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return
|
||||||
|
ws.send(encodeFtpCmd(MusicCmd.PORT, port))
|
||||||
|
if (error || port <= 0) {
|
||||||
|
onEvent({
|
||||||
|
type: 'portUnavailable',
|
||||||
|
reason: error || '本地文件服务端口无效'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if (command.cmd === MusicCmd.DOWNLOAD_SUCCESS && phoneSystem === 'ios') {
|
||||||
|
const hit = lastReadyList.find((m) => m.musicUId === command.body)
|
||||||
|
if (hit) cleanIosWavFile(hit)
|
||||||
|
}
|
||||||
|
onEvent({ type: 'cmd', cmd: command.cmd, body: command.body })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
socket = null
|
||||||
|
phoneSystem = ''
|
||||||
|
lastReadyList = []
|
||||||
|
onEvent({ type: 'disconnected' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendMusicCmd(cmd: string, body: string): boolean {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) return false
|
||||||
|
|
||||||
|
if (cmd === MusicCmd.READY) {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(body) as { musicUId: string; baseUrl: string; musicPath: string }[]
|
||||||
|
lastReadyList = Array.isArray(list) ? list : []
|
||||||
|
} catch {
|
||||||
|
lastReadyList = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(encodeFtpCmd(cmd, body))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMusicServer(): void {
|
||||||
|
if (socket) {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
if (wss) {
|
||||||
|
wss.close()
|
||||||
|
wss = null
|
||||||
|
}
|
||||||
|
phoneSystem = ''
|
||||||
|
lastReadyList = []
|
||||||
|
}
|
||||||
18
src/main/services/network.ts
Normal file
18
src/main/services/network.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
/** OSS 只读地址(曲库元数据、歌词、版本),与移动端一致 */
|
||||||
|
export const OSS = {
|
||||||
|
R2: 'https://llmp-oss.zhushenwudi.top/',
|
||||||
|
ALIYUN: 'https://zhushenwudi1.oss-cn-hangzhou.aliyuncs.com/LLMP-M/',
|
||||||
|
NETEASE_COVER: 'https://netease-backend.zhushenwudi.top/song/detail'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||||
|
const res = await axios.get<T>(url, { timeout: 15000 })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchText(url: string): Promise<string> {
|
||||||
|
const res = await axios.get<string>(url, { timeout: 15000, responseType: 'text' })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
67
src/main/services/store.ts
Normal file
67
src/main/services/store.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import Store from 'electron-store'
|
||||||
|
|
||||||
|
/** 应用配置结构(对应旧 electron-store 字段,做了类型化整理) */
|
||||||
|
export interface AppConfig {
|
||||||
|
/** 本地曲库根目录(http-server root) */
|
||||||
|
serverPath: string
|
||||||
|
/** HTTP 文件服务端口 */
|
||||||
|
serverPort: number
|
||||||
|
/** 歌曲源 URL(本地或远程),播放器与传输据此取流 */
|
||||||
|
url: string
|
||||||
|
/** 播放模式:order/repeat/single/shuffle */
|
||||||
|
playMode: 'order' | 'repeat' | 'single' | 'shuffle'
|
||||||
|
/** 音量 0-1 */
|
||||||
|
volume: number
|
||||||
|
/** 主题模式 */
|
||||||
|
theme: 'light' | 'dark' | 'system'
|
||||||
|
/** 主题强调色 */
|
||||||
|
accentColor: string
|
||||||
|
/** 数据版本(OSS data.json 版本) */
|
||||||
|
dataVersion: string
|
||||||
|
/** 上次播放列表(musicUId 数组) */
|
||||||
|
lastPlayList: string[]
|
||||||
|
/** 上次播放曲目 */
|
||||||
|
lastPlayId: string
|
||||||
|
/** 上次播放列表中的索引 */
|
||||||
|
lastPlayIndex: number
|
||||||
|
/** 上次播放进度(秒) */
|
||||||
|
lastProgress: number
|
||||||
|
/** 传输选中的歌曲 */
|
||||||
|
transMusic: string[]
|
||||||
|
/** 最近连接的手机系统 */
|
||||||
|
phoneSystem: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: AppConfig = {
|
||||||
|
serverPath: '',
|
||||||
|
serverPort: 10000,
|
||||||
|
url: '',
|
||||||
|
playMode: 'order',
|
||||||
|
volume: 0.8,
|
||||||
|
theme: 'system',
|
||||||
|
accentColor: '#0A84FF',
|
||||||
|
dataVersion: '',
|
||||||
|
lastPlayList: [],
|
||||||
|
lastPlayId: '',
|
||||||
|
lastPlayIndex: 0,
|
||||||
|
lastProgress: 0,
|
||||||
|
transMusic: [],
|
||||||
|
phoneSystem: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: Store<AppConfig> | null = null
|
||||||
|
|
||||||
|
export function getStore(): Store<AppConfig> {
|
||||||
|
if (!store) {
|
||||||
|
store = new Store<AppConfig>({ name: 'config', defaults })
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig<K extends keyof AppConfig>(key: K): AppConfig[K] {
|
||||||
|
return getStore().get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConfig<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
|
||||||
|
getStore().set(key, value)
|
||||||
|
}
|
||||||
50
src/main/services/transcode.ts
Normal file
50
src/main/services/transcode.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { spawn, ChildProcess } from 'child_process'
|
||||||
|
import ffmpegPath from 'ffmpeg-static'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS 音频转码:flac -> wav。
|
||||||
|
* 旧版用 flac-bindings + wav(原生编译),这里改用 ffmpeg-static(跨平台、免编译)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
let current: ChildProcess | null = null
|
||||||
|
|
||||||
|
export interface ConvertOptions {
|
||||||
|
input: string
|
||||||
|
output: string
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertFlacToWav(opts: ConvertOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!ffmpegPath) {
|
||||||
|
reject(new Error('ffmpeg 不可用'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopConvert()
|
||||||
|
current = spawn(ffmpegPath, ['-y', '-i', opts.input, opts.output])
|
||||||
|
|
||||||
|
current.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString()
|
||||||
|
// 粗略解析进度(ffmpeg 输出 time=xx:xx:xx)
|
||||||
|
const match = text.match(/time=(\d+):(\d+):(\d+)/)
|
||||||
|
if (match && opts.onProgress) {
|
||||||
|
const seconds = +match[1] * 3600 + +match[2] * 60 + +match[3]
|
||||||
|
opts.onProgress(seconds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
current.on('error', reject)
|
||||||
|
current.on('close', (code) => {
|
||||||
|
current = null
|
||||||
|
if (code === 0) resolve()
|
||||||
|
else reject(new Error(`ffmpeg 退出码 ${code}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopConvert(): void {
|
||||||
|
if (current) {
|
||||||
|
current.kill('SIGKILL')
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/services/update.ts
Normal file
65
src/main/services/update.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { fetchJson } from './network'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查软件更新(参考原项目:从 OSS 拉取 version.json 对比版本)。
|
||||||
|
* 原项目结构:{ pre: {version,url,message}, prod: {version,url,message} }。
|
||||||
|
* 这里默认取 prod(回退 pre / 根级),对比 app 当前版本。
|
||||||
|
*/
|
||||||
|
const VERSION_URL = 'https://llmp-oss.zhushenwudi.top/LLMP/version/version.json'
|
||||||
|
|
||||||
|
interface VersionChannel {
|
||||||
|
version?: string
|
||||||
|
url?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionJson {
|
||||||
|
pre?: VersionChannel
|
||||||
|
prod?: VersionChannel
|
||||||
|
version?: string
|
||||||
|
url?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
version: string
|
||||||
|
latest: string
|
||||||
|
hasUpdate: boolean
|
||||||
|
message: string
|
||||||
|
url: string
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语义化版本比较:a<b 返回 -1,a>b 返回 1,相等 0 */
|
||||||
|
function compareVersion(a: string, b: string): number {
|
||||||
|
const pa = a.split('.').map((n) => parseInt(n, 10) || 0)
|
||||||
|
const pb = b.split('.').map((n) => parseInt(n, 10) || 0)
|
||||||
|
const len = Math.max(pa.length, pb.length)
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const x = pa[i] ?? 0
|
||||||
|
const y = pb[i] ?? 0
|
||||||
|
if (x < y) return -1
|
||||||
|
if (x > y) return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkUpdate(): Promise<UpdateInfo> {
|
||||||
|
const version = app.getVersion()
|
||||||
|
try {
|
||||||
|
const json = await fetchJson<VersionJson>(VERSION_URL)
|
||||||
|
const info: VersionChannel = json.prod ?? json.pre ?? json
|
||||||
|
const latest = String(info.version ?? '')
|
||||||
|
const hasUpdate = latest !== '' && compareVersion(version, latest) < 0
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
latest,
|
||||||
|
hasUpdate,
|
||||||
|
message: info.message ?? '',
|
||||||
|
url: info.url ?? ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { version, latest: '', hasUpdate: false, message: '检查更新失败,请稍后重试', url: '', error: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/main/services/wifiTransfer.ts
Normal file
42
src/main/services/wifiTransfer.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync, unlinkSync } from 'fs'
|
||||||
|
import { getFileServerStatus } from './fileServer'
|
||||||
|
import { convertFlacToWav } from './transcode'
|
||||||
|
|
||||||
|
export interface WifiAudioRef {
|
||||||
|
baseUrl: string
|
||||||
|
musicPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAudioPath(ref: WifiAudioRef): string {
|
||||||
|
const root = getFileServerStatus().root
|
||||||
|
if (!root) throw new Error('本地文件服务未运行')
|
||||||
|
return join(root, ref.baseUrl, ref.musicPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为 iOS WiFi 下载在 flac 旁生成同名 wav(手机按 .wav URL 拉取) */
|
||||||
|
export async function ensureIosWavFiles(items: WifiAudioRef[]): Promise<void> {
|
||||||
|
for (const it of items) {
|
||||||
|
if (!/\.flac$/i.test(it.musicPath)) continue
|
||||||
|
const src = resolveAudioPath(it)
|
||||||
|
const dest = src.replace(/\.flac$/i, '.wav')
|
||||||
|
if (!existsSync(src)) continue
|
||||||
|
if (existsSync(dest)) continue
|
||||||
|
await convertFlacToWav({ input: src, output: dest })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 WiFi 传输产生的临时 wav */
|
||||||
|
export function cleanIosWavFile(ref: WifiAudioRef): void {
|
||||||
|
if (!/\.flac$/i.test(ref.musicPath) && !/\.wav$/i.test(ref.musicPath)) return
|
||||||
|
try {
|
||||||
|
const flacPath = resolveAudioPath({
|
||||||
|
baseUrl: ref.baseUrl,
|
||||||
|
musicPath: ref.musicPath.replace(/\.wav$/i, '.flac')
|
||||||
|
})
|
||||||
|
const wavPath = flacPath.replace(/\.flac$/i, '.wav')
|
||||||
|
if (existsSync(wavPath)) unlinkSync(wavPath)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/main/window/desktopLyricWindow.ts
Normal file
96
src/main/window/desktopLyricWindow.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { BrowserWindow, screen } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { is } from '@electron-toolkit/utils'
|
||||||
|
import { IPC } from '@shared/ipc/channels'
|
||||||
|
|
||||||
|
let lyricWindow: BrowserWindow | null = null
|
||||||
|
/** 缓存最近一次歌词推送,窗口新建/显示时立刻回放,避免先显示占位文案 */
|
||||||
|
let lastPayload: unknown = null
|
||||||
|
|
||||||
|
function pushLastPayload(): void {
|
||||||
|
if (lastPayload != null && lyricWindow && !lyricWindow.isDestroyed()) {
|
||||||
|
lyricWindow.webContents.send(IPC.LYRIC_UPDATE, lastPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleDesktopLyric(show: boolean): void {
|
||||||
|
if (show) {
|
||||||
|
if (lyricWindow) {
|
||||||
|
lyricWindow.showInactive()
|
||||||
|
// 已存在窗口再次打开时,立即补发当前歌词
|
||||||
|
pushLastPayload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||||
|
const winWidth = Math.min(820, Math.floor(width / 2))
|
||||||
|
lyricWindow = new BrowserWindow({
|
||||||
|
width: winWidth,
|
||||||
|
// 默认高度 > 140,进入双行 KTV;拖矮到 ≤140 自动回单行
|
||||||
|
height: 168,
|
||||||
|
minWidth: 480,
|
||||||
|
minHeight: 110,
|
||||||
|
maxHeight: 280,
|
||||||
|
x: Math.floor((width - winWidth) / 2),
|
||||||
|
y: height - 180,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: true,
|
||||||
|
movable: true,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
hasShadow: false,
|
||||||
|
thickFrame: false,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.mjs'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
backgroundThrottling: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
lyricWindow.setBackgroundColor('#00000000')
|
||||||
|
lyricWindow.setAlwaysOnTop(true, 'screen-saver')
|
||||||
|
lyricWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||||
|
|
||||||
|
// 在页面样式生效前强制透明,避免 body 默认 --bg 露出白底
|
||||||
|
lyricWindow.webContents.on('dom-ready', () => {
|
||||||
|
void lyricWindow?.webContents.insertCSS(
|
||||||
|
'html,body,#root{background:transparent!important;background-color:transparent!important}'
|
||||||
|
)
|
||||||
|
void lyricWindow?.webContents.executeJavaScript(
|
||||||
|
"document.documentElement.classList.add('desktop-lyric')"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hash = '#/desktop-lyric'
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
lyricWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + hash)
|
||||||
|
} else {
|
||||||
|
lyricWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash })
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricWindow.webContents.on('did-finish-load', () => pushLastPayload())
|
||||||
|
lyricWindow.once('ready-to-show', () => {
|
||||||
|
lyricWindow?.showInactive()
|
||||||
|
pushLastPayload()
|
||||||
|
})
|
||||||
|
lyricWindow.on('closed', () => {
|
||||||
|
lyricWindow = null
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
lyricWindow?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDesktopLyric(payload: unknown): void {
|
||||||
|
lastPayload = payload
|
||||||
|
lyricWindow?.webContents.send(IPC.LYRIC_UPDATE, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 锁定桌面歌词时开启鼠标穿透(forward 使窗口仍能收到 move 事件以便解锁) */
|
||||||
|
export function setLyricIgnoreMouse(ignore: boolean): void {
|
||||||
|
lyricWindow?.setIgnoreMouseEvents(ignore, { forward: true })
|
||||||
|
}
|
||||||
80
src/main/window/mainWindow.ts
Normal file
80
src/main/window/mainWindow.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { BrowserWindow, shell } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { is } from '@electron-toolkit/utils'
|
||||||
|
import { isQuitting } from '../appState'
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
export function createMainWindow(): BrowserWindow {
|
||||||
|
// 默认 / 最小尺寸对齐当前常用显示尺寸
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1377,
|
||||||
|
height: 887,
|
||||||
|
minWidth: 1377,
|
||||||
|
minHeight: 887,
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
titleBarStyle: 'hiddenInset',
|
||||||
|
// macOS 毛玻璃质感
|
||||||
|
vibrancy: 'under-window',
|
||||||
|
visualEffectState: 'active',
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
trafficLightPosition: { x: 16, y: 18 },
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.mjs'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => mainWindow?.show())
|
||||||
|
|
||||||
|
// 隐藏到托盘后仍需响应播放控制 / 音频,避免渲染进程被节流
|
||||||
|
mainWindow.webContents.setBackgroundThrottling(false)
|
||||||
|
|
||||||
|
// 调试:转发渲染进程控制台/崩溃到主进程终端
|
||||||
|
mainWindow.webContents.on('console-message', (event) => {
|
||||||
|
console.log('[renderer]', (event as unknown as { message?: string }).message)
|
||||||
|
})
|
||||||
|
mainWindow.webContents.on('render-process-gone', (_e, details) => {
|
||||||
|
console.error('[renderer-gone]', details)
|
||||||
|
})
|
||||||
|
mainWindow.webContents.on('preload-error', (_e, path, error) => {
|
||||||
|
console.error('[preload-error]', path, error)
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
|
shell.openExternal(details.url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭按钮不退出,隐藏到后台/托盘;仅托盘「退出」时放行
|
||||||
|
mainWindow.on('close', (e) => {
|
||||||
|
if (!isQuitting()) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// macOS:隐藏到 Dock,不销毁窗口
|
||||||
|
mainWindow?.hide()
|
||||||
|
} else {
|
||||||
|
mainWindow?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
121
src/main/window/tray.ts
Normal file
121
src/main/window/tray.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { app, Menu, Tray, nativeImage } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { IPC } from '@shared/ipc/channels'
|
||||||
|
import { setQuitting } from '../appState'
|
||||||
|
import { getMainWindow } from './mainWindow'
|
||||||
|
|
||||||
|
let tray: Tray | null = null
|
||||||
|
|
||||||
|
/** 解析托盘图标路径(开发 / 打包多路径回退) */
|
||||||
|
function resolveTrayIcon(): string {
|
||||||
|
const candidates = [
|
||||||
|
// electron-builder extraResources → process.resourcesPath/tray.png
|
||||||
|
join(process.resourcesPath, 'tray.png'),
|
||||||
|
// 开发态:项目根 resources/
|
||||||
|
join(app.getAppPath(), 'resources', 'tray.png'),
|
||||||
|
join(process.cwd(), 'resources', 'tray.png'),
|
||||||
|
// 相对主进程编译产物回退
|
||||||
|
join(__dirname, '../../resources/tray.png')
|
||||||
|
]
|
||||||
|
return candidates.find((p) => existsSync(p)) ?? candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function control(action: 'playpause' | 'next' | 'prev'): void {
|
||||||
|
const win = getMainWindow()
|
||||||
|
if (!win || win.isDestroyed()) return
|
||||||
|
// 隐藏窗口时也要能收到控制指令
|
||||||
|
win.webContents.send(IPC.PLAYER_CONTROL, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMain(): void {
|
||||||
|
const win = getMainWindow()
|
||||||
|
if (!win) return
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quitApp(): Promise<void> {
|
||||||
|
const win = getMainWindow()
|
||||||
|
// 退出前尽量把播放队列落盘(渲染进程 store.set)
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
try {
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`window.__flushPlayback && window.__flushPlayback()`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setQuitting(true)
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建系统托盘(跨平台):
|
||||||
|
* - Windows / Linux:托盘图标 + 右键菜单 + 单击显示主窗口
|
||||||
|
* - macOS:优先 Dock 菜单;若可用也挂托盘(部分 macOS 版本托盘体验一般)
|
||||||
|
*/
|
||||||
|
export function createTray(): void {
|
||||||
|
if (tray) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const path = resolveTrayIcon()
|
||||||
|
let img = nativeImage.createFromPath(path)
|
||||||
|
if (img.isEmpty()) {
|
||||||
|
console.warn('[tray] icon empty, fallback to empty nativeImage:', path)
|
||||||
|
img = nativeImage.createEmpty()
|
||||||
|
}
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
img = img.resize({ width: 18, height: 18 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS 以 Dock 菜单为主;Windows/Linux 必须有托盘,否则关闭进后台后无法找回
|
||||||
|
if (process.platform !== 'darwin' || !img.isEmpty()) {
|
||||||
|
tray = new Tray(img)
|
||||||
|
tray.setToolTip('LoveLive! Music Player')
|
||||||
|
|
||||||
|
const items: Electron.MenuItemConstructorOptions[] = [
|
||||||
|
{ label: '显示主界面', click: showMain },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '播放 / 暂停', click: () => control('playpause') },
|
||||||
|
{ label: '上一首', click: () => control('prev') },
|
||||||
|
{ label: '下一首', click: () => control('next') },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '退出', click: quitApp }
|
||||||
|
]
|
||||||
|
tray.setContextMenu(Menu.buildFromTemplate(items))
|
||||||
|
// Windows:左键单击显示;部分 Linux 桌面环境只响应右键菜单
|
||||||
|
tray.on('click', showMain)
|
||||||
|
tray.on('double-click', showMain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
app.dock?.setMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{ label: '显示主界面', click: showMain },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '播放 / 暂停', click: () => control('playpause') },
|
||||||
|
{ label: '上一首', click: () => control('prev') },
|
||||||
|
{ label: '下一首', click: () => control('next') },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '退出', click: quitApp }
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[tray] create failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 托盘 tooltip 跟随当前歌曲 */
|
||||||
|
export function setTrayTooltip(text: string): void {
|
||||||
|
tray?.setToolTip(text || 'LoveLive! Music Player')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyTray(): void {
|
||||||
|
tray?.destroy()
|
||||||
|
tray = null
|
||||||
|
}
|
||||||
168
src/preload/index.ts
Normal file
168
src/preload/index.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
|
||||||
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
|
import { IPC } from '@shared/ipc/channels'
|
||||||
|
|
||||||
|
/** 暴露给渲染进程的类型安全 API */
|
||||||
|
const api = {
|
||||||
|
store: {
|
||||||
|
get: <T>(key: string): Promise<T> => ipcRenderer.invoke(IPC.STORE_GET, key),
|
||||||
|
set: (key: string, value: unknown): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(IPC.STORE_SET, key, value),
|
||||||
|
delete: (key: string): Promise<void> => ipcRenderer.invoke(IPC.STORE_DELETE, key)
|
||||||
|
},
|
||||||
|
db: {
|
||||||
|
query: <T>(sql: string, params: unknown[] = []): Promise<T[]> =>
|
||||||
|
ipcRenderer.invoke(IPC.DB_QUERY, sql, params),
|
||||||
|
exec: (
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = []
|
||||||
|
): Promise<{ changes: number; lastInsertRowid: number }> =>
|
||||||
|
ipcRenderer.invoke(IPC.DB_EXEC, sql, params)
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
start: (root: string, port: number) => ipcRenderer.invoke(IPC.HTTP_START, root, port),
|
||||||
|
stop: () => ipcRenderer.invoke(IPC.HTTP_STOP),
|
||||||
|
status: () => ipcRenderer.invoke(IPC.HTTP_STATUS)
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
lanIps: () => ipcRenderer.invoke(IPC.NET_LAN_IPS),
|
||||||
|
fetchJson: <T>(url: string): Promise<T> => ipcRenderer.invoke(IPC.NET_FETCH_JSON, url),
|
||||||
|
fetchText: (url: string): Promise<string> => ipcRenderer.invoke(IPC.NET_FETCH_TEXT, url)
|
||||||
|
},
|
||||||
|
library: {
|
||||||
|
sync: (): Promise<{ version: number; albums: number; musics: number }> =>
|
||||||
|
ipcRenderer.invoke(IPC.LIBRARY_SYNC),
|
||||||
|
dataVersion: (): Promise<string> => ipcRenderer.invoke(IPC.LIBRARY_DATA_VERSION)
|
||||||
|
},
|
||||||
|
convert: {
|
||||||
|
start: (input: string, output: string) =>
|
||||||
|
ipcRenderer.invoke(IPC.CONVERT_START, input, output),
|
||||||
|
stop: () => ipcRenderer.invoke(IPC.CONVERT_STOP),
|
||||||
|
onProgress: (cb: (data: { input: string; progress: number }) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, data: { input: string; progress: number }): void =>
|
||||||
|
cb(data)
|
||||||
|
ipcRenderer.on(IPC.CONVERT_PROGRESS, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.CONVERT_PROGRESS, listener)
|
||||||
|
},
|
||||||
|
onDone: (cb: (data: { input: string; output: string }) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, data: { input: string; output: string }): void =>
|
||||||
|
cb(data)
|
||||||
|
ipcRenderer.on(IPC.CONVERT_DONE, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.CONVERT_DONE, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wifi: {
|
||||||
|
ensureIosWav: (items: { baseUrl: string; musicPath: string }[]) =>
|
||||||
|
ipcRenderer.invoke(IPC.WIFI_ENSURE_IOS_WAV, items),
|
||||||
|
cleanIosWav: (item: { baseUrl: string; musicPath: string }) =>
|
||||||
|
ipcRenderer.invoke(IPC.WIFI_CLEAN_IOS_WAV, item)
|
||||||
|
},
|
||||||
|
musicServer: {
|
||||||
|
start: () => ipcRenderer.invoke(IPC.MUSIC_SERVER_START),
|
||||||
|
stop: () => ipcRenderer.invoke(IPC.MUSIC_SERVER_STOP),
|
||||||
|
send: (cmd: string, body: string) => ipcRenderer.invoke(IPC.MUSIC_SERVER_SEND, cmd, body),
|
||||||
|
onEvent: (cb: (e: unknown) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, e: unknown): void => cb(e)
|
||||||
|
ipcRenderer.on(IPC.MUSIC_SERVER_EVENT, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.MUSIC_SERVER_EVENT, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataServer: {
|
||||||
|
start: () => ipcRenderer.invoke(IPC.DATA_SERVER_START),
|
||||||
|
stop: () => ipcRenderer.invoke(IPC.DATA_SERVER_STOP),
|
||||||
|
send: (cmd: string, body: string) => ipcRenderer.invoke(IPC.DATA_SERVER_SEND, cmd, body),
|
||||||
|
onEvent: (cb: (e: unknown) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, e: unknown): void => cb(e)
|
||||||
|
ipcRenderer.on(IPC.DATA_SERVER_EVENT, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.DATA_SERVER_EVENT, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
openDir: (): Promise<string | null> => ipcRenderer.invoke(IPC.DIALOG_OPEN_DIR),
|
||||||
|
openFile: (): Promise<string[] | null> => ipcRenderer.invoke(IPC.DIALOG_OPEN_FILE)
|
||||||
|
},
|
||||||
|
lyric: {
|
||||||
|
toggle: (show: boolean) => ipcRenderer.invoke(IPC.LYRIC_TOGGLE, show),
|
||||||
|
update: (payload: unknown) => ipcRenderer.send(IPC.LYRIC_UPDATE, payload),
|
||||||
|
setIgnoreMouse: (ignore: boolean) => ipcRenderer.send(IPC.LYRIC_SET_IGNORE_MOUSE, ignore),
|
||||||
|
onUpdate: (cb: (payload: unknown) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, p: unknown): void => cb(p)
|
||||||
|
ipcRenderer.on(IPC.LYRIC_UPDATE, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.LYRIC_UPDATE, listener)
|
||||||
|
},
|
||||||
|
onState: (cb: (show: boolean) => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, show: boolean): void => cb(show)
|
||||||
|
ipcRenderer.on(IPC.LYRIC_STATE, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.LYRIC_STATE, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
win: {
|
||||||
|
minimize: () => ipcRenderer.send(IPC.WIN_MIN),
|
||||||
|
maximize: () => ipcRenderer.send(IPC.WIN_MAX),
|
||||||
|
close: () => ipcRenderer.send(IPC.WIN_CLOSE)
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
onControl: (cb: (action: 'playpause' | 'next' | 'prev') => void) => {
|
||||||
|
const listener = (_: IpcRendererEvent, action: 'playpause' | 'next' | 'prev'): void =>
|
||||||
|
cb(action)
|
||||||
|
ipcRenderer.on(IPC.PLAYER_CONTROL, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.PLAYER_CONTROL, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exporter: {
|
||||||
|
run: (
|
||||||
|
dest: string,
|
||||||
|
platform: 'android' | 'ios',
|
||||||
|
list: unknown[]
|
||||||
|
): Promise<{ done: number; total: number; fail: number }> =>
|
||||||
|
ipcRenderer.invoke(IPC.EXPORT_SONGS, dest, platform, list),
|
||||||
|
onProgress: (
|
||||||
|
cb: (p: {
|
||||||
|
musicUId: string
|
||||||
|
status: 'converting' | 'copying' | 'done' | 'fail'
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
}) => void
|
||||||
|
) => {
|
||||||
|
const listener = (
|
||||||
|
_: IpcRendererEvent,
|
||||||
|
p: {
|
||||||
|
musicUId: string
|
||||||
|
status: 'converting' | 'copying' | 'done' | 'fail'
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
): void => cb(p)
|
||||||
|
ipcRenderer.on(IPC.EXPORT_PROGRESS, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.EXPORT_PROGRESS, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
check: (): Promise<{
|
||||||
|
version: string
|
||||||
|
latest: string
|
||||||
|
hasUpdate: boolean
|
||||||
|
message: string
|
||||||
|
url: string
|
||||||
|
error?: boolean
|
||||||
|
}> => ipcRenderer.invoke(IPC.UPDATE_CHECK),
|
||||||
|
openDownload: (url: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC.UPDATE_OPEN_DOWNLOAD, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiType = typeof api
|
||||||
|
|
||||||
|
if (process.contextIsolated) {
|
||||||
|
try {
|
||||||
|
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||||
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// @ts-ignore (define in dts)
|
||||||
|
window.electron = electronAPI
|
||||||
|
// @ts-ignore
|
||||||
|
window.api = api
|
||||||
|
}
|
||||||
24
src/renderer/index.html
Normal file
24
src/renderer/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; img-src 'self' data: http: https: blob:; media-src 'self' http: https: blob: file:; connect-src 'self' http: https: ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||||
|
/>
|
||||||
|
<title>LoveLiveMusicPlayer</title>
|
||||||
|
<!-- 桌面歌词 hash 窗口:尽早去掉默认底色,避免透明窗闪白 -->
|
||||||
|
<style>
|
||||||
|
html.desktop-lyric,
|
||||||
|
html.desktop-lyric body,
|
||||||
|
html.desktop-lyric #root {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
120
src/renderer/src/App.tsx
Normal file
120
src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Routes, Route, useLocation, type Location } from 'react-router-dom'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import AlbumDetail from './pages/AlbumDetail'
|
||||||
|
import Favorites from './pages/Favorites'
|
||||||
|
import Playlists from './pages/Playlists'
|
||||||
|
import History from './pages/History'
|
||||||
|
import Lyric from './pages/Lyric'
|
||||||
|
import Search from './pages/Search'
|
||||||
|
import Transfer from './pages/Transfer'
|
||||||
|
import Sync from './pages/Sync'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
import DesktopLyric from './pages/DesktopLyric'
|
||||||
|
import { useUIStore } from './stores/uiStore'
|
||||||
|
import { usePlayerStore } from './stores/playerStore'
|
||||||
|
|
||||||
|
type LyricLocationState = { background?: Location }
|
||||||
|
|
||||||
|
/** 歌词页作为叠层时,下层 Routes 使用的 location(保持原页面挂载,避免退出闪烁) */
|
||||||
|
function useLayoutLocation(): Location {
|
||||||
|
const location = useLocation()
|
||||||
|
const state = location.state as LyricLocationState | null
|
||||||
|
const lastRef = useRef<Location>(location)
|
||||||
|
|
||||||
|
if (location.pathname !== '/lyric') {
|
||||||
|
lastRef.current = location
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state?.background) return state.background
|
||||||
|
if (location.pathname === '/lyric') {
|
||||||
|
// 无 background 时(冷启动进歌词)回退到上一次内容页或首页
|
||||||
|
return lastRef.current.pathname === '/lyric'
|
||||||
|
? ({ ...location, pathname: '/', search: '', hash: '' } as Location)
|
||||||
|
: lastRef.current
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainRoutes(): JSX.Element {
|
||||||
|
const location = useLocation()
|
||||||
|
const layoutLocation = useLayoutLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes location={layoutLocation}>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
{/* 专辑详情嵌套在首页下,返回时不卸载专辑网格,避免整页重绘与封面闪烁 */}
|
||||||
|
<Route path="/" element={<Home />}>
|
||||||
|
<Route path="albums/:id" element={<AlbumDetail />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/favorites" element={<Favorites />} />
|
||||||
|
<Route path="/playlists" element={<Playlists />} />
|
||||||
|
<Route path="/history" element={<History />} />
|
||||||
|
<Route path="/search" element={<Search />} />
|
||||||
|
<Route path="/transfer" element={<Transfer />} />
|
||||||
|
<Route path="/sync" element={<Sync />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
|
||||||
|
{/* 歌词全屏叠层:下层页面不卸载 */}
|
||||||
|
<Routes location={location}>
|
||||||
|
<Route path="/lyric" element={<Lyric />} />
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App(): JSX.Element {
|
||||||
|
const initUI = useUIStore((s) => s.init)
|
||||||
|
const initPlayer = usePlayerStore((s) => s.init)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
// 桌面歌词是独立窗口,只需展示歌词,跳过主窗口的 http/播放器初始化
|
||||||
|
const isDesktopLyric =
|
||||||
|
typeof window !== 'undefined' && window.location.hash.startsWith('#/desktop-lyric')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDesktopLyric) return
|
||||||
|
const boot = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await initUI()
|
||||||
|
const root = await window.api.store.get<string>('serverPath')
|
||||||
|
const port = (await window.api.store.get<number>('serverPort')) || 10000
|
||||||
|
if (root) {
|
||||||
|
try {
|
||||||
|
await window.api.http.start(root, port)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[boot] http.start failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await initPlayer()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[boot] init failed', e)
|
||||||
|
} finally {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boot()
|
||||||
|
}, [initUI, initPlayer, isDesktopLyric])
|
||||||
|
|
||||||
|
if (isDesktopLyric) {
|
||||||
|
return <DesktopLyric />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full w-full place-items-center text-sm text-[var(--text-secondary)]">
|
||||||
|
正在启动…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/desktop-lyric" element={<DesktopLyric />} />
|
||||||
|
<Route path="*" element={<MainRoutes />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/renderer/src/components/AlbumCard.tsx
Normal file
75
src/renderer/src/components/AlbumCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Disc3, Play } from 'lucide-react'
|
||||||
|
import type { Album } from '@shared/models'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
import { buildFileUrl } from '../lib/repository'
|
||||||
|
import { rememberHomeScroll } from '../lib/homeScroll'
|
||||||
|
|
||||||
|
export default function AlbumCard({ album }: { album: Album }): JSX.Element {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const httpBase = usePlayerStore((s) => s.httpBase)
|
||||||
|
const playList = usePlayerStore((s) => s.playList)
|
||||||
|
const music = useLibraryStore((s) => s.music)
|
||||||
|
const first = music.find((m) => m.albumUId === album.albumUId)
|
||||||
|
// 封面优先用歌曲的 baseUrl+coverPath;专辑 cover 本身已是根相对完整路径
|
||||||
|
const cover = first
|
||||||
|
? buildFileUrl(httpBase, first.baseUrl, first.coverPath)
|
||||||
|
: album.cover
|
||||||
|
? buildFileUrl(httpBase, '', album.cover)
|
||||||
|
: ''
|
||||||
|
const subtitle = first?.artist || album.group
|
||||||
|
|
||||||
|
const playAlbum = (e: React.MouseEvent): void => {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
const songs = music
|
||||||
|
.filter((m) => m.albumUId === album.albumUId)
|
||||||
|
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
||||||
|
if (songs.length) playList(songs, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// 须在网格被 hidden 之前记录,否则 main.scrollTop 会变成 0
|
||||||
|
rememberHomeScroll()
|
||||||
|
navigate(`/albums/${encodeURIComponent(album.albumUId)}`)
|
||||||
|
}}
|
||||||
|
className="group flex w-full flex-col text-left"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-square w-full max-w-[200px] overflow-hidden rounded-2xl bg-black/10 shadow-apple transition-transform duration-300 group-hover:-translate-y-1 group-hover:shadow-apple-lg">
|
||||||
|
{cover ? (
|
||||||
|
<img
|
||||||
|
src={cover}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center text-[var(--text-secondary)]">
|
||||||
|
<Disc3 size={40} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 仅中间按钮可播放;遮罩不拦截点击,点封面仍进详情 */}
|
||||||
|
<span className="pointer-events-none absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition-all duration-200 group-hover:bg-black/25 group-hover:opacity-100">
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="播放此专辑"
|
||||||
|
onClick={playAlbum}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') playAlbum(e as unknown as React.MouseEvent)
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto grid h-12 w-12 place-items-center rounded-full bg-white text-black shadow-apple transition-transform hover:scale-110 active:scale-95"
|
||||||
|
>
|
||||||
|
<Play size={22} className="translate-x-[1px] fill-current" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 truncate text-sm font-semibold">{album.albumName}</p>
|
||||||
|
<p className="truncate text-xs text-[var(--text-secondary)]">{subtitle}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/renderer/src/components/IpSelect.tsx
Normal file
103
src/renderer/src/components/IpSelect.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义局域网 IP 下拉。
|
||||||
|
* 用 portal + fixed 定位,避免被父级 overflow:hidden 裁切(数据同步页曾因此只能看到一项)。
|
||||||
|
*/
|
||||||
|
export default function IpSelect({
|
||||||
|
ips,
|
||||||
|
value,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
ips: { name: string; address: string }[]
|
||||||
|
value: number
|
||||||
|
onChange: (i: number) => void
|
||||||
|
}): JSX.Element | null {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 240 })
|
||||||
|
const current = ips[value]
|
||||||
|
|
||||||
|
const place = (): void => {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect()
|
||||||
|
if (!r) return
|
||||||
|
setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 200) })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
place()
|
||||||
|
const onDown = (e: MouseEvent): void => {
|
||||||
|
const t = e.target as Node
|
||||||
|
if (btnRef.current?.contains(t) || menuRef.current?.contains(t)) return
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
const onReposition = (): void => place()
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
window.addEventListener('resize', onReposition)
|
||||||
|
window.addEventListener('scroll', onReposition, true)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
window.removeEventListener('resize', onReposition)
|
||||||
|
window.removeEventListener('scroll', onReposition, true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (ips.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (ips.length <= 1) return
|
||||||
|
setOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
className="no-drag flex w-full max-w-[240px] items-center justify-between gap-2 rounded-lg border border-[var(--separator)] bg-[var(--bg)] px-3 py-2 text-left text-xs text-[var(--text)] shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{current ? `${current.address}` : '选择网卡'}
|
||||||
|
{current ? (
|
||||||
|
<span className="text-[var(--text-secondary)]"> · {current.name}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={14} className="shrink-0 text-[var(--text-secondary)]" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
style={{ top: pos.top, left: pos.left, width: pos.width }}
|
||||||
|
className="fixed z-[9999] max-h-56 overflow-y-auto rounded-lg border border-[var(--separator)] bg-[var(--bg)] py-1 shadow-apple-lg"
|
||||||
|
>
|
||||||
|
{ips.map((ip, i) => (
|
||||||
|
<button
|
||||||
|
key={`${ip.address}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(i)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full flex-col px-3 py-2 text-left text-xs transition-colors',
|
||||||
|
i === value
|
||||||
|
? 'bg-apple-blue/15 text-apple-blue'
|
||||||
|
: 'text-[var(--text)] hover:bg-black/5 dark:hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{ip.address}</span>
|
||||||
|
<span className="truncate text-[10px] text-[var(--text-secondary)]">{ip.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/renderer/src/components/Layout.tsx
Normal file
35
src/renderer/src/components/Layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import TitleBar from './TitleBar'
|
||||||
|
import PlayerBar from './PlayerBar'
|
||||||
|
import LyricEngine from './LyricEngine'
|
||||||
|
|
||||||
|
export default function Layout(): JSX.Element {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const isTransfer = pathname.startsWith('/transfer')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
<LyricEngine />
|
||||||
|
{/* 上半:侧栏 + 内容 */}
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<TitleBar />
|
||||||
|
<main
|
||||||
|
className={clsx(
|
||||||
|
'min-h-0 flex-1 px-8 pb-6',
|
||||||
|
// 传歌页由内部 SoftScrollArea 滚动,外层禁止出原生滚动条
|
||||||
|
isTransfer ? 'flex flex-col overflow-hidden' : 'overflow-y-auto'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 播放栏贯穿整窗宽度 */}
|
||||||
|
<PlayerBar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/renderer/src/components/LyricEngine.tsx
Normal file
96
src/renderer/src/components/LyricEngine.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { useLyricStore, type TriLine } from '../stores/lyricStore'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
|
||||||
|
export type DesktopLrcLang = 'jp' | 'zh' | 'roma'
|
||||||
|
|
||||||
|
export interface DesktopLrcPack {
|
||||||
|
prevLrc: string
|
||||||
|
nextLrc: string
|
||||||
|
singleLrc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopLrcPayload {
|
||||||
|
jp: DesktopLrcPack
|
||||||
|
zh: DesktopLrcPack
|
||||||
|
roma: DesktopLrcPack
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对齐原项目 WorkUtils.parseTickLrc:日语双行 KTV 奇偶配对;中/罗马为原文+译文 */
|
||||||
|
function buildPack(lines: TriLine[], idx: number, mode: DesktopLrcLang): DesktopLrcPack {
|
||||||
|
if (idx < 0 || lines.length === 0) {
|
||||||
|
return { prevLrc: '', nextLrc: '', singleLrc: '' }
|
||||||
|
}
|
||||||
|
const cur = lines[idx]
|
||||||
|
if (mode === 'jp') {
|
||||||
|
const singleLrc = (cur.jp || '').trim()
|
||||||
|
if (idx % 2 === 0) {
|
||||||
|
return {
|
||||||
|
prevLrc: singleLrc,
|
||||||
|
nextLrc: (lines[idx + 1]?.jp || '').trim(),
|
||||||
|
singleLrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
prevLrc: (lines[idx - 1]?.jp || '').trim(),
|
||||||
|
nextLrc: singleLrc,
|
||||||
|
singleLrc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const singleLrc = (cur.jp || '').trim()
|
||||||
|
const prevLrc = singleLrc
|
||||||
|
const nextLrc = (mode === 'zh' ? cur.zh : cur.roma || '').trim()
|
||||||
|
return { prevLrc, nextLrc, singleLrc }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局歌词引擎(无 UI):
|
||||||
|
* - 跟随当前播放曲目加载三语歌词到 lyricStore(歌词页与桌面歌词共用)。
|
||||||
|
* - 桌面歌词开启时,推送单行/双行 KTV 所需的三语数据包。
|
||||||
|
*/
|
||||||
|
export default function LyricEngine(): null {
|
||||||
|
const current = usePlayerStore((s) => s.current)
|
||||||
|
const progress = usePlayerStore((s) => s.progress)
|
||||||
|
const desktopOn = usePlayerStore((s) => s.desktopLyricOn)
|
||||||
|
const lines = useLyricStore((s) => s.lines)
|
||||||
|
const load = useLyricStore((s) => s.load)
|
||||||
|
const musicMap = useLibraryStore((s) => s.music)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) return
|
||||||
|
const full = musicMap.find((m) => m.musicUId === current.musicUId) ?? current
|
||||||
|
load(full)
|
||||||
|
}, [current, musicMap, load])
|
||||||
|
|
||||||
|
const activeIdx = useMemo(() => {
|
||||||
|
let idx = -1
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].time <= progress) idx = i
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}, [lines, progress])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!desktopOn) return
|
||||||
|
const title = current?.musicName
|
||||||
|
const fallback = (title || '').trim()
|
||||||
|
const jp = buildPack(lines, activeIdx, 'jp')
|
||||||
|
const zh = buildPack(lines, activeIdx, 'zh')
|
||||||
|
const roma = buildPack(lines, activeIdx, 'roma')
|
||||||
|
|
||||||
|
// 无歌词时用歌名占位,避免空窗
|
||||||
|
if (!jp.singleLrc && fallback) {
|
||||||
|
const stub = { prevLrc: fallback, nextLrc: '', singleLrc: fallback }
|
||||||
|
window.api.lyric.update({ jp: stub, zh: stub, roma: stub, title } satisfies DesktopLrcPayload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!jp.singleLrc && !zh.singleLrc && !roma.singleLrc) return
|
||||||
|
|
||||||
|
window.api.lyric.update({ jp, zh, roma, title } satisfies DesktopLrcPayload)
|
||||||
|
}, [desktopOn, activeIdx, lines, current])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
190
src/renderer/src/components/MusicList.tsx
Normal file
190
src/renderer/src/components/MusicList.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { Play, Heart, Pause } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Music } from '@shared/models'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
|
||||||
|
function fmt(sec?: number): string {
|
||||||
|
if (!sec) return '--:--'
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.floor(sec % 60)
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近播放时间:刚刚 / N分钟前 / 当天时分 / 非当天日期 */
|
||||||
|
export function formatPlayTime(ts: number, now = Date.now()): string {
|
||||||
|
if (!ts || !Number.isFinite(ts)) return '—'
|
||||||
|
const diff = now - ts
|
||||||
|
if (diff < 60_000) return '刚刚'
|
||||||
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`
|
||||||
|
|
||||||
|
const d = new Date(ts)
|
||||||
|
const n = new Date(now)
|
||||||
|
const sameDay =
|
||||||
|
d.getFullYear() === n.getFullYear() &&
|
||||||
|
d.getMonth() === n.getMonth() &&
|
||||||
|
d.getDate() === n.getDate()
|
||||||
|
|
||||||
|
if (sameDay) {
|
||||||
|
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
if (d.getFullYear() === n.getFullYear()) {
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
list: Music[]
|
||||||
|
emptyHint?: string
|
||||||
|
/** 列表顶部分栏小标题 */
|
||||||
|
showHeader?: boolean
|
||||||
|
/** 专辑详情页隐藏「专辑」列(整表同属一张碟) */
|
||||||
|
hideAlbum?: boolean
|
||||||
|
/** 最近播放:musicUId → playTime(ms),传入后显示「播放时间」列 */
|
||||||
|
playTimeById?: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MusicList({
|
||||||
|
list,
|
||||||
|
emptyHint = '这里还没有歌曲',
|
||||||
|
showHeader = false,
|
||||||
|
hideAlbum = false,
|
||||||
|
playTimeById
|
||||||
|
}: Props): JSX.Element {
|
||||||
|
const { current, isPlaying, playList, toggle, loves, toggleLove } = usePlayerStore()
|
||||||
|
const albums = useLibraryStore((s) => s.albums)
|
||||||
|
const loadAll = useLibraryStore((s) => s.loadAll)
|
||||||
|
const showPlayTime = !!playTimeById
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showHeader) void loadAll()
|
||||||
|
}, [showHeader, loadAll])
|
||||||
|
|
||||||
|
const albumNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const a of albums) map.set(a.albumUId, a.albumName)
|
||||||
|
return map
|
||||||
|
}, [albums])
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="grid place-items-center py-24 text-sm text-[var(--text-secondary)]">
|
||||||
|
{emptyHint}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列:# | 歌曲 | 艺术家 | [专辑] | 喜欢 | 时长 | [播放时间]
|
||||||
|
const rowGrid = (() => {
|
||||||
|
if (!showHeader) return ''
|
||||||
|
if (hideAlbum && showPlayTime) {
|
||||||
|
return 'grid-cols-[28px_minmax(0,1.2fr)_minmax(0,0.7fr)_40px_48px_88px]'
|
||||||
|
}
|
||||||
|
if (hideAlbum) {
|
||||||
|
return 'grid-cols-[28px_minmax(0,1fr)_minmax(0,0.7fr)_40px_48px]'
|
||||||
|
}
|
||||||
|
if (showPlayTime) {
|
||||||
|
return 'grid-cols-[28px_minmax(0,1.1fr)_minmax(0,0.5fr)_minmax(0,0.65fr)_40px_48px_88px]'
|
||||||
|
}
|
||||||
|
return 'grid-cols-[28px_minmax(0,1.2fr)_minmax(0,0.55fr)_minmax(0,0.7fr)_40px_48px]'
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{showHeader && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'sticky top-0 z-[1] mb-1 grid items-center gap-x-3 border-b border-[var(--separator)] bg-[var(--bg)]/90 px-3 py-2.5 text-[11px] font-medium text-[var(--text-secondary)] backdrop-blur',
|
||||||
|
rowGrid
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center">#</div>
|
||||||
|
<div>歌曲</div>
|
||||||
|
<div className="truncate">艺术家</div>
|
||||||
|
{!hideAlbum && <div className="truncate">专辑</div>}
|
||||||
|
<div className="text-center">喜欢</div>
|
||||||
|
<div className="text-right">时长</div>
|
||||||
|
{showPlayTime && <div className="truncate text-right">播放时间</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.map((m, i) => {
|
||||||
|
const active = current?.musicUId === m.musicUId
|
||||||
|
const loved = loves.has(m.musicUId)
|
||||||
|
const albumName = albumNameById.get(m.albumUId) ?? ''
|
||||||
|
const playAt = playTimeById?.[m.musicUId]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.musicUId}
|
||||||
|
onDoubleClick={() => playList(list, i)}
|
||||||
|
className={clsx(
|
||||||
|
'group items-center gap-x-3 rounded-xl px-3 py-2.5 transition-colors',
|
||||||
|
showHeader ? clsx('grid', rowGrid) : 'flex gap-3',
|
||||||
|
active ? 'bg-black/[0.06] dark:bg-white/10' : 'hover:bg-black/[0.04] dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
<span className="group-hover:hidden">
|
||||||
|
{active && isPlaying ? (
|
||||||
|
<span className="text-apple-blue">♪</span>
|
||||||
|
) : (
|
||||||
|
i + 1
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => (active ? toggle() : playList(list, i))}
|
||||||
|
className="hidden group-hover:inline text-[var(--text)]"
|
||||||
|
>
|
||||||
|
{active && isPlaying ? <Pause size={15} /> : <Play size={15} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={clsx('truncate text-sm font-medium', active && 'text-apple-blue')}>
|
||||||
|
{m.musicName}
|
||||||
|
</p>
|
||||||
|
{!showHeader && (
|
||||||
|
<p className="truncate text-xs text-[var(--text-secondary)]">{m.artist}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHeader ? (
|
||||||
|
<span className="min-w-0 truncate text-xs text-[var(--text-secondary)]">
|
||||||
|
{m.artist}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showHeader && !hideAlbum ? (
|
||||||
|
<span className="min-w-0 truncate text-xs text-[var(--text-secondary)]">
|
||||||
|
{albumName || '—'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => toggleLove(m.musicUId)}
|
||||||
|
title={loved ? '取消喜欢' : '添加到我喜欢'}
|
||||||
|
className={clsx(
|
||||||
|
'flex justify-center text-[var(--text-secondary)] transition-colors hover:text-apple-pink',
|
||||||
|
!showHeader && 'opacity-0 group-hover:opacity-100',
|
||||||
|
!showHeader && loved && 'opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Heart size={16} className={clsx(loved && 'fill-apple-pink text-apple-pink')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-right text-xs tabular-nums text-[var(--text-secondary)]">
|
||||||
|
{fmt(m.duration)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showHeader && showPlayTime ? (
|
||||||
|
<span className="truncate text-right text-xs tabular-nums text-[var(--text-secondary)]">
|
||||||
|
{playAt != null ? formatPlayTime(playAt) : '—'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/renderer/src/components/PageHeader.tsx
Normal file
17
src/renderer/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
right?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({ title, subtitle, right }: Props): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-10 -mx-8 mb-4 flex items-end justify-between bg-[var(--bg)]/80 px-8 pb-4 pt-4 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">{title}</h2>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-[var(--text-secondary)]">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
371
src/renderer/src/components/PlayerBar.tsx
Normal file
371
src/renderer/src/components/PlayerBar.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Heart,
|
||||||
|
Volume2,
|
||||||
|
Repeat,
|
||||||
|
Repeat1,
|
||||||
|
Shuffle,
|
||||||
|
ListOrdered,
|
||||||
|
Mic2,
|
||||||
|
MonitorSpeaker,
|
||||||
|
ListPlus,
|
||||||
|
ListMusic,
|
||||||
|
Plus,
|
||||||
|
Check
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Menu } from '@shared/models'
|
||||||
|
import { isPcMenu, MENU_ID } from '@shared/models'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { useLyricStore } from '../stores/lyricStore'
|
||||||
|
import { Repo, buildFileUrl } from '../lib/repository'
|
||||||
|
import QueuePanel from './QueuePanel'
|
||||||
|
|
||||||
|
function fmt(sec: number): string {
|
||||||
|
if (!sec || Number.isNaN(sec)) return '0:00'
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.floor(sec % 60)
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_ICON = {
|
||||||
|
order: ListOrdered,
|
||||||
|
repeat: Repeat,
|
||||||
|
single: Repeat1,
|
||||||
|
shuffle: Shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HashRouter 下真实路径(播放条在 layoutLocation 内,useLocation 读不到 /lyric) */
|
||||||
|
function getHashPath(): string {
|
||||||
|
const raw = window.location.hash.replace(/^#/, '')
|
||||||
|
return (raw.split('?')[0] || '/') as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerBar(): JSX.Element {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
// 叠层模式下这里是下层页 location,正好用作打开歌词时的 background
|
||||||
|
const layoutLocation = useLocation()
|
||||||
|
// 依赖 layoutLocation 不够:打开歌词时它不变,但父级仍会重渲;再读 hash 判断是否已在歌词页
|
||||||
|
const onLyric = getHashPath() === '/lyric'
|
||||||
|
const {
|
||||||
|
current,
|
||||||
|
isPlaying,
|
||||||
|
progress,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
mode,
|
||||||
|
httpBase,
|
||||||
|
loves,
|
||||||
|
desktopLyricOn,
|
||||||
|
toggle,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
cycleMode,
|
||||||
|
toggleLove,
|
||||||
|
toggleDesktopLyric
|
||||||
|
} = usePlayerStore()
|
||||||
|
|
||||||
|
const ModeIcon = MODE_ICON[mode]
|
||||||
|
const isLoved = current ? loves.has(current.musicUId) : false
|
||||||
|
const toggleLyricPage = (): void => {
|
||||||
|
if (!current) return
|
||||||
|
// 以 hash 为准;收起时走歌词页下滑动画,避免直接 replace 无过渡
|
||||||
|
if (getHashPath() === '/lyric') {
|
||||||
|
useLyricStore.getState().requestClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 记下当前页为 background,歌词作为叠层打开,下层不卸载
|
||||||
|
navigate('/lyric', { state: { background: layoutLocation } })
|
||||||
|
}
|
||||||
|
const cover = current ? buildFileUrl(httpBase, current.baseUrl, current.coverPath) : ''
|
||||||
|
|
||||||
|
// 播放列表浮窗
|
||||||
|
const [queueOpen, setQueueOpen] = useState(false)
|
||||||
|
const queueRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 加入歌单浮层
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [pcMenus, setPcMenus] = useState<Menu[]>([])
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [toast, setToast] = useState('')
|
||||||
|
const popRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const loadMenus = async (): Promise<void> => setPcMenus(await Repo.pcMenus())
|
||||||
|
|
||||||
|
const openMenu = (): void => {
|
||||||
|
if (!current) return
|
||||||
|
loadMenus()
|
||||||
|
setCreating(false)
|
||||||
|
setNewName('')
|
||||||
|
setMenuOpen((v) => !v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showToast = (msg: string): void => {
|
||||||
|
setToast(msg)
|
||||||
|
setTimeout(() => setToast(''), 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTo = async (menuId: number, title: string): Promise<void> => {
|
||||||
|
if (!current) return
|
||||||
|
await Repo.addMusicToMenu(menuId, current.musicUId)
|
||||||
|
setMenuOpen(false)
|
||||||
|
showToast(`已添加到「${title}」`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCreateAndAdd = async (): Promise<void> => {
|
||||||
|
const title = newName.trim()
|
||||||
|
if (!title || !current) return
|
||||||
|
const used = new Set(pcMenus.filter((m) => isPcMenu(m.id)).map((m) => m.id))
|
||||||
|
let nextId = -1
|
||||||
|
for (let i = 1; i <= MENU_ID.PC_MAX; i++) {
|
||||||
|
if (!used.has(i)) {
|
||||||
|
nextId = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextId === -1) return
|
||||||
|
await Repo.createMenu(nextId, title)
|
||||||
|
await Repo.addMusicToMenu(nextId, current.musicUId)
|
||||||
|
setMenuOpen(false)
|
||||||
|
showToast(`已创建并添加到「${title}」`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击浮层外部关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen && !queueOpen) return
|
||||||
|
const onDown = (e: MouseEvent): void => {
|
||||||
|
const t = e.target as Node
|
||||||
|
if (menuOpen && popRef.current && !popRef.current.contains(t)) setMenuOpen(false)
|
||||||
|
if (queueOpen && queueRef.current && !queueRef.current.contains(t)) setQueueOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [menuOpen, queueOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass relative z-50 flex h-20 shrink-0 items-center border-t border-[var(--separator)] px-5">
|
||||||
|
{/* 左:封面 / 歌名 / 喜欢 / 加入歌单 */}
|
||||||
|
<div className="relative z-10 flex min-w-0 max-w-[36%] items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleLyricPage}
|
||||||
|
disabled={!current}
|
||||||
|
title={current ? (onLyric ? '收起歌词' : '查看歌词') : undefined}
|
||||||
|
className={clsx(
|
||||||
|
'flex min-w-0 items-center gap-3 rounded-xl text-left transition-opacity',
|
||||||
|
current ? 'cursor-pointer hover:opacity-80' : 'cursor-default'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-black/10 shadow-apple">
|
||||||
|
{cover ? (
|
||||||
|
<img src={cover} className="h-full w-full object-cover" alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center text-[var(--text-secondary)]">
|
||||||
|
<Mic2 size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold">
|
||||||
|
{current?.musicName ?? '未在播放'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-[var(--text-secondary)]">
|
||||||
|
{current?.artist ?? '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => current && toggleLove(current.musicUId)}
|
||||||
|
disabled={!current}
|
||||||
|
title={isLoved ? '取消喜欢' : '添加到我喜欢'}
|
||||||
|
className={clsx(
|
||||||
|
'grid h-8 w-8 shrink-0 place-items-center text-[var(--text-secondary)] transition-colors hover:text-apple-pink',
|
||||||
|
!current && 'opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Heart size={18} className={clsx(isLoved && 'fill-apple-pink text-apple-pink')} />
|
||||||
|
</button>
|
||||||
|
<div className="relative shrink-0" ref={popRef}>
|
||||||
|
<button
|
||||||
|
onClick={openMenu}
|
||||||
|
disabled={!current}
|
||||||
|
title="加入歌单"
|
||||||
|
className={clsx(
|
||||||
|
'grid h-8 w-8 place-items-center transition-colors',
|
||||||
|
menuOpen
|
||||||
|
? 'text-apple-blue'
|
||||||
|
: 'text-[var(--text-secondary)] hover:text-[var(--text)]',
|
||||||
|
!current && 'opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ListPlus size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-3 w-60 overflow-hidden rounded-2xl border border-[var(--separator)] bg-white shadow-apple-lg dark:bg-[#2c2c2e]">
|
||||||
|
<p className="px-4 pb-1 pt-3 text-xs font-semibold text-[var(--text-secondary)]">
|
||||||
|
加入歌单(PC)
|
||||||
|
</p>
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{pcMenus.length === 0 ? (
|
||||||
|
<p className="px-4 py-3 text-xs text-[var(--text-secondary)]">
|
||||||
|
还没有 PC 歌单,先新建一个吧
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
pcMenus.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => addTo(m.id, m.title)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<ListOrdered size={14} className="text-[var(--text-secondary)]" />
|
||||||
|
<span className="truncate">{m.title}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[var(--separator)] p-2">
|
||||||
|
{creating ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') confirmCreateAndAdd()
|
||||||
|
if (e.key === 'Escape') setCreating(false)
|
||||||
|
}}
|
||||||
|
placeholder="新歌单名"
|
||||||
|
className="min-w-0 flex-1 rounded-lg bg-black/5 px-2 py-1.5 text-sm outline-none dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={confirmCreateAndAdd}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-apple-blue text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Check size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-sm text-apple-blue hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Plus size={15} /> 新建歌单
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中:绝对定位居中于整条播放栏 */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-0 flex -translate-y-1/2 flex-col items-center gap-1 px-4">
|
||||||
|
<div className="pointer-events-auto flex h-10 items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={cycleMode}
|
||||||
|
title="播放模式"
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center text-[var(--text-secondary)] hover:text-[var(--text)]"
|
||||||
|
>
|
||||||
|
<ModeIcon size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={prev}
|
||||||
|
title="上一首"
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center hover:text-apple-blue"
|
||||||
|
>
|
||||||
|
<SkipBack size={18} className="fill-current" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
title={isPlaying ? '暂停' : '播放'}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[var(--text)] text-[var(--bg)] transition-transform active:scale-90"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause size={18} className="fill-current" />
|
||||||
|
) : (
|
||||||
|
<Play size={18} className="translate-x-[1px] fill-current" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={next}
|
||||||
|
title="下一首"
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center hover:text-apple-blue"
|
||||||
|
>
|
||||||
|
<SkipForward size={18} className="fill-current" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleDesktopLyric}
|
||||||
|
title={desktopLyricOn ? '关闭桌面歌词' : '开启桌面歌词'}
|
||||||
|
className={clsx(
|
||||||
|
'grid h-8 w-8 shrink-0 place-items-center transition-colors',
|
||||||
|
desktopLyricOn
|
||||||
|
? 'text-apple-blue'
|
||||||
|
: 'text-[var(--text-secondary)] hover:text-[var(--text)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MonitorSpeaker size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto flex w-[min(520px,46vw)] items-center gap-2 text-[11px] text-[var(--text-secondary)]">
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums">{fmt(progress)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
value={progress}
|
||||||
|
onChange={(e) => seek(Number(e.target.value))}
|
||||||
|
className="range-slider h-1 min-w-0 flex-1 cursor-pointer accent-apple-blue"
|
||||||
|
/>
|
||||||
|
<span className="w-9 shrink-0 tabular-nums">{fmt(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:音量 + 播放列表 */}
|
||||||
|
<div className="relative z-10 ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
<Volume2 size={16} className="shrink-0 text-[var(--text-secondary)]" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => setVolume(Number(e.target.value))}
|
||||||
|
className="h-1 w-24 cursor-pointer accent-apple-blue"
|
||||||
|
/>
|
||||||
|
<div className="relative shrink-0" ref={queueRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setQueueOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
title="播放列表"
|
||||||
|
className={clsx(
|
||||||
|
'grid h-8 w-8 place-items-center transition-colors',
|
||||||
|
queueOpen
|
||||||
|
? 'text-apple-blue'
|
||||||
|
: 'text-[var(--text-secondary)] hover:text-[var(--text)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ListMusic size={18} />
|
||||||
|
</button>
|
||||||
|
<QueuePanel open={queueOpen} onClose={() => setQueueOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="glass pointer-events-none absolute bottom-24 left-1/2 z-20 -translate-x-1/2 rounded-full border border-[var(--separator)] px-4 py-2 text-sm shadow-apple-lg">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/renderer/src/components/QueuePanel.tsx
Normal file
84
src/renderer/src/components/QueuePanel.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ListMusic, Trash2, X } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
|
||||||
|
/** 当前播放队列浮窗(不透明实底) */
|
||||||
|
export default function QueuePanel({
|
||||||
|
open,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}): JSX.Element | null {
|
||||||
|
const { queue, index, current, playAt, removeFromQueue, clearQueue } = usePlayerStore()
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-full right-0 mb-3 flex max-h-[60vh] w-80 flex-col overflow-hidden rounded-2xl border border-[var(--separator)] bg-[var(--bg)] shadow-apple-lg">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--separator)] bg-[var(--bg)] px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListMusic size={16} className="text-apple-blue" />
|
||||||
|
<p className="text-sm font-semibold">播放列表</p>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">{queue.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearQueue}
|
||||||
|
title="清空列表"
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-lg text-[var(--text-secondary)] hover:bg-black/5 hover:text-apple-red dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-lg text-[var(--text-secondary)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--bg)] p-1">
|
||||||
|
{queue.length === 0 ? (
|
||||||
|
<p className="grid place-items-center py-12 text-xs text-[var(--text-secondary)]">
|
||||||
|
播放列表为空
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
queue.map((m, i) => {
|
||||||
|
const active = current?.musicUId === m.musicUId && i === index
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${m.musicUId}-${i}`}
|
||||||
|
className={clsx(
|
||||||
|
'group flex items-center gap-2 rounded-xl px-3 py-2',
|
||||||
|
active ? 'bg-apple-blue/10' : 'hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button onClick={() => playAt(i)} className="min-w-0 flex-1 text-left">
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'truncate text-sm',
|
||||||
|
active ? 'font-semibold text-apple-blue' : 'font-medium'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.musicName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-[var(--text-secondary)]">{m.artist}</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFromQueue(i)}
|
||||||
|
className="opacity-0 transition-opacity group-hover:opacity-100 text-[var(--text-secondary)] hover:text-apple-red"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
src/renderer/src/components/Sidebar.tsx
Normal file
74
src/renderer/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { Home, Heart, ListMusic, Clock, Wifi, RefreshCw, Settings } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ to: '/', label: '专辑', icon: Home, end: true },
|
||||||
|
{ to: '/favorites', label: '我喜欢', icon: Heart },
|
||||||
|
{ to: '/playlists', label: '歌单', icon: ListMusic },
|
||||||
|
{ to: '/history', label: '最近播放', icon: Clock }
|
||||||
|
]
|
||||||
|
|
||||||
|
const LINK = [
|
||||||
|
{ to: '/transfer', label: '传歌', icon: Wifi },
|
||||||
|
{ to: '/sync', label: '数据同步', icon: RefreshCw },
|
||||||
|
{ to: '/settings', label: '设置', icon: Settings }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sidebar(): JSX.Element {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const loadAll = useLibraryStore((s) => s.loadAll)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="glass flex w-56 shrink-0 flex-col border-r border-[var(--separator)] px-3 pb-3">
|
||||||
|
<div className="drag h-12" />
|
||||||
|
<div className="mb-4 px-2">
|
||||||
|
<h1 className="text-base font-bold tracking-tight">LoveLiveMusicPlayer</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
<p className="px-3 pb-1 text-[11px] font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
|
资料库
|
||||||
|
</p>
|
||||||
|
{NAV.map(({ to, label, icon: Icon, end }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
onClick={() => {
|
||||||
|
// 已在专辑首页时再点「专辑」:手动刷新曲库
|
||||||
|
if (to === '/' && pathname === '/') {
|
||||||
|
void loadAll({ force: true })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx('sidebar-item', isActive && 'sidebar-item-active')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={17} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav className="mt-6 flex flex-col gap-1">
|
||||||
|
<p className="px-3 pb-1 text-[11px] font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
|
设备联动
|
||||||
|
</p>
|
||||||
|
{LINK.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx('sidebar-item', isActive && 'sidebar-item-active')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={17} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
src/renderer/src/components/SoftScrollArea.tsx
Normal file
163
src/renderer/src/components/SoftScrollArea.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
type UIEvent
|
||||||
|
} from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无原生滚动条的滚动容器:
|
||||||
|
* - 滚动时才显示半透明滑块,静止后自动隐藏
|
||||||
|
* - overflow-hidden 裁切在圆角边框内
|
||||||
|
*/
|
||||||
|
export default function SoftScrollArea({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
contentClassName
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
}): JSX.Element {
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const dragging = useRef(false)
|
||||||
|
const dragOffset = useRef(0)
|
||||||
|
const [thumb, setThumb] = useState({ top: 0, height: 0, needed: false })
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
const measure = useCallback((): void => {
|
||||||
|
const el = scrollerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = el
|
||||||
|
if (scrollHeight <= clientHeight + 1) {
|
||||||
|
setThumb({ top: 0, height: 0, needed: false })
|
||||||
|
setVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 轨道有 inset,优先用实际轨道高度,保证滑块落在边框内
|
||||||
|
const trackH = trackRef.current?.clientHeight || Math.max(0, clientHeight - 16)
|
||||||
|
const height = Math.max(28, (clientHeight / scrollHeight) * trackH)
|
||||||
|
const maxTop = Math.max(0, trackH - height)
|
||||||
|
const top =
|
||||||
|
maxTop <= 0 ? 0 : (scrollTop / (scrollHeight - clientHeight)) * maxTop
|
||||||
|
setThumb({ top, height, needed: true })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showThenHide = useCallback((): void => {
|
||||||
|
setVisible(true)
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||||
|
if (dragging.current) return
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
if (!dragging.current) setVisible(false)
|
||||||
|
}, 3800)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
measure()
|
||||||
|
const el = scrollerRef.current
|
||||||
|
if (!el) return
|
||||||
|
const ro = new ResizeObserver(() => measure())
|
||||||
|
ro.observe(el)
|
||||||
|
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||||
|
}
|
||||||
|
}, [measure, children])
|
||||||
|
|
||||||
|
const onScroll = (_e: UIEvent<HTMLDivElement>): void => {
|
||||||
|
measure()
|
||||||
|
showThenHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onThumbPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const thumbEl = e.currentTarget
|
||||||
|
dragging.current = true
|
||||||
|
dragOffset.current = e.clientY - thumbEl.getBoundingClientRect().top
|
||||||
|
thumbEl.setPointerCapture(e.pointerId)
|
||||||
|
setVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onThumbPointerMove = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
||||||
|
if (!dragging.current) return
|
||||||
|
const el = scrollerRef.current
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!el || !track) return
|
||||||
|
const trackRect = track.getBoundingClientRect()
|
||||||
|
const height = thumb.height
|
||||||
|
const maxTop = trackRect.height - height
|
||||||
|
let top = e.clientY - trackRect.top - dragOffset.current
|
||||||
|
top = Math.max(0, Math.min(maxTop, top))
|
||||||
|
const ratio = maxTop <= 0 ? 0 : top / maxTop
|
||||||
|
el.scrollTop = ratio * (el.scrollHeight - el.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onThumbPointerUp = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
||||||
|
dragging.current = false
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
showThenHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTrackPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
||||||
|
if (e.target !== trackRef.current) return
|
||||||
|
const el = scrollerRef.current
|
||||||
|
const track = trackRef.current
|
||||||
|
if (!el || !track) return
|
||||||
|
const rect = track.getBoundingClientRect()
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
const height = thumb.height
|
||||||
|
const maxTop = rect.height - height
|
||||||
|
const top = Math.max(0, Math.min(maxTop, y - height / 2))
|
||||||
|
const ratio = maxTop <= 0 ? 0 : top / maxTop
|
||||||
|
el.scrollTop = ratio * (el.scrollHeight - el.clientHeight)
|
||||||
|
showThenHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('relative min-h-0 overflow-hidden', className)}>
|
||||||
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
className={clsx(
|
||||||
|
'soft-scroll-native h-full min-h-0 overflow-y-auto overflow-x-hidden',
|
||||||
|
contentClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 始终占位以便 measure 取轨道高度;静止时完全透明 */}
|
||||||
|
{thumb.needed && (
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
onPointerDown={onTrackPointerDown}
|
||||||
|
className={clsx(
|
||||||
|
'pointer-events-none absolute inset-y-2 right-2 z-10 w-1.5 rounded-full transition-opacity duration-300',
|
||||||
|
visible ? 'pointer-events-auto opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onPointerDown={onThumbPointerDown}
|
||||||
|
onPointerMove={onThumbPointerMove}
|
||||||
|
onPointerUp={onThumbPointerUp}
|
||||||
|
onPointerCancel={onThumbPointerUp}
|
||||||
|
style={{ transform: `translateY(${thumb.top}px)`, height: thumb.height }}
|
||||||
|
className="w-full cursor-default rounded-full bg-[rgba(60,60,67,0.38)] dark:bg-[rgba(235,235,245,0.3)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/renderer/src/components/TitleBar.tsx
Normal file
80
src/renderer/src/components/TitleBar.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
|
||||||
|
import { Minus, Square, X, Search } from 'lucide-react'
|
||||||
|
|
||||||
|
/** 顶部标题栏:可拖拽 + 全局搜索 + Windows 窗口控制(Apple 风格极简) */
|
||||||
|
export default function TitleBar(): JSX.Element {
|
||||||
|
const isMac = navigator.platform.toLowerCase().includes('mac')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const [kw, setKw] = useState('')
|
||||||
|
// 进入搜索前记住左侧路由,清空搜索时回到该页
|
||||||
|
const prevPathRef = useRef(location.pathname === '/search' ? '/' : location.pathname)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== '/search') {
|
||||||
|
prevPathRef.current = location.pathname + location.search
|
||||||
|
setKw('')
|
||||||
|
} else {
|
||||||
|
setKw(params.get('q') ?? '')
|
||||||
|
}
|
||||||
|
}, [location.pathname, location.search, params])
|
||||||
|
|
||||||
|
const onChange = (v: string): void => {
|
||||||
|
setKw(v)
|
||||||
|
const t = v.trim()
|
||||||
|
if (t) {
|
||||||
|
navigate(`/search?q=${encodeURIComponent(t)}`, {
|
||||||
|
replace: location.pathname === '/search'
|
||||||
|
})
|
||||||
|
} else if (location.pathname === '/search') {
|
||||||
|
// 清空搜索:回到进入搜索前的侧栏页面
|
||||||
|
navigate(prevPathRef.current || '/', { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drag flex h-12 shrink-0 items-end justify-between px-3 pb-1.5 pt-3 select-none">
|
||||||
|
<div className={isMac ? 'w-20' : 'w-2'} />
|
||||||
|
|
||||||
|
<div className="no-drag mx-auto max-w-md flex-1">
|
||||||
|
<div className="flex h-7 items-center gap-2 rounded-md bg-black/5 px-2.5 text-xs text-[var(--text-secondary)] dark:bg-white/10">
|
||||||
|
<Search size={13} />
|
||||||
|
<input
|
||||||
|
value={kw}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="搜索歌曲、艺术家、专辑、歌单"
|
||||||
|
className="w-full bg-transparent text-[var(--text)] outline-none placeholder:text-[var(--text-secondary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMac ? (
|
||||||
|
<div className="no-drag flex items-center gap-1 self-end">
|
||||||
|
<button
|
||||||
|
onClick={() => window.api.win.minimize()}
|
||||||
|
className="grid h-7 w-8 place-items-center rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Minus size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.api.win.maximize()}
|
||||||
|
className="grid h-7 w-8 place-items-center rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Square size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.api.win.close()}
|
||||||
|
title="最小化到托盘"
|
||||||
|
className="grid h-7 w-8 place-items-center rounded-lg hover:bg-apple-red hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/renderer/src/lib/const.ts
Normal file
25
src/renderer/src/lib/const.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { GroupType } from '@shared/models'
|
||||||
|
|
||||||
|
/** 企划分组(展示用中文文案;key 与曲库 group 字段一致) */
|
||||||
|
export const GROUPS: { key: string; label: string; color: string }[] = [
|
||||||
|
{ key: GroupType.MUSE, label: "μ's", color: '#E4007F' },
|
||||||
|
{ key: GroupType.AQOURS, label: 'Aqours', color: '#00A0E9' },
|
||||||
|
{ key: GroupType.NIJIGASAKI, label: '虹咲学园', color: '#F39800' },
|
||||||
|
{ key: GroupType.LIELLA, label: 'Liella!', color: '#A6CE39' },
|
||||||
|
{ key: GroupType.HASUNOSORA, label: '莲之空女学院', color: '#8BC0B5' },
|
||||||
|
{ key: GroupType.YOHANE, label: '幻日夜羽', color: '#5B3E9E' },
|
||||||
|
{ key: GroupType.MUSICAL, label: '学园偶像音乐剧', color: '#C8A96A' },
|
||||||
|
{ key: GroupType.BLUEBIRD, label: '青鸟', color: '#4F9DDE' },
|
||||||
|
{ key: GroupType.COMBINE, label: '多团联动', color: '#FF6B6B' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 播放模式循环顺序 */
|
||||||
|
export const PLAY_MODES = ['order', 'repeat', 'single', 'shuffle'] as const
|
||||||
|
export type PlayMode = (typeof PLAY_MODES)[number]
|
||||||
|
|
||||||
|
export const PLAY_MODE_LABEL: Record<PlayMode, string> = {
|
||||||
|
order: '顺序播放',
|
||||||
|
repeat: '列表循环',
|
||||||
|
single: '单曲循环',
|
||||||
|
shuffle: '随机播放'
|
||||||
|
}
|
||||||
4
src/renderer/src/lib/events.ts
Normal file
4
src/renderer/src/lib/events.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** 渲染进程内自定义事件名 */
|
||||||
|
|
||||||
|
/** 数据同步写入歌单后触发,供歌单页刷新列表 */
|
||||||
|
export const MENU_SYNCED_EVENT = 'llmp:menus-synced'
|
||||||
34
src/renderer/src/lib/homeScroll.ts
Normal file
34
src/renderer/src/lib/homeScroll.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** 专辑首页滚动位置:须在进详情(网格 hidden)之前记下,返回后再还原 */
|
||||||
|
|
||||||
|
let savedTop = 0
|
||||||
|
|
||||||
|
function mainScroller(): HTMLElement | null {
|
||||||
|
return document.querySelector('main')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击专辑封面导航前调用 */
|
||||||
|
export function rememberHomeScroll(): void {
|
||||||
|
const main = mainScroller()
|
||||||
|
if (main) savedTop = main.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从详情回到网格后调用(网格需已去掉 hidden) */
|
||||||
|
export function restoreHomeScroll(): void {
|
||||||
|
const main = mainScroller()
|
||||||
|
if (!main) return
|
||||||
|
const y = savedTop
|
||||||
|
main.scrollTop = y
|
||||||
|
// 布局稳定后再写一次,避免首帧高度未就绪导致落回顶部
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
main.scrollTop = y
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
main.scrollTop = y
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入详情后把主区域滚到顶部(不影响已记住的 savedTop) */
|
||||||
|
export function resetMainScrollForDetail(): void {
|
||||||
|
const main = mainScroller()
|
||||||
|
if (main) main.scrollTop = 0
|
||||||
|
}
|
||||||
36
src/renderer/src/lib/lrc.ts
Normal file
36
src/renderer/src/lib/lrc.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface LrcLine {
|
||||||
|
time: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 LRC 文本为带时间戳的行 */
|
||||||
|
export function parseLrc(lrc: string): LrcLine[] {
|
||||||
|
const lines: LrcLine[] = []
|
||||||
|
const reg = /\[(\d{2}):(\d{2})(?:[.:](\d{2,3}))?\]/g
|
||||||
|
for (const raw of lrc.split('\n')) {
|
||||||
|
reg.lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
const times: number[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
while ((match = reg.exec(raw)) !== null) {
|
||||||
|
const min = parseInt(match[1], 10)
|
||||||
|
const sec = parseInt(match[2], 10)
|
||||||
|
const ms = match[3] ? parseInt(match[3].padEnd(3, '0'), 10) : 0
|
||||||
|
times.push(min * 60 + sec + ms / 1000)
|
||||||
|
lastIndex = reg.lastIndex
|
||||||
|
}
|
||||||
|
const text = raw.slice(lastIndex).trim()
|
||||||
|
if (times.length && text) times.forEach((t) => lines.push({ time: t, text }))
|
||||||
|
}
|
||||||
|
return lines.sort((a, b) => a.time - b.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找到当前时间对应的行索引 */
|
||||||
|
export function activeLineIndex(lines: LrcLine[], time: number): number {
|
||||||
|
let idx = -1
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].time <= time) idx = i
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
30
src/renderer/src/lib/oss.ts
Normal file
30
src/renderer/src/lib/oss.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Music } from '@shared/models'
|
||||||
|
|
||||||
|
/** OSS 歌词根地址(仅托管元数据与歌词,不含音频/封面实体) */
|
||||||
|
const LYRIC_HEAD = 'https://llmp-oss.zhushenwudi.top/lyric/'
|
||||||
|
|
||||||
|
type LyricLang = 'JP' | 'ZH' | 'ROMA'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造某首歌的三语歌词 OSS 地址。
|
||||||
|
* 规则:LYRIC_HEAD + <LANG>/ + base_url + music_path(后缀改 .lrc)
|
||||||
|
*/
|
||||||
|
export function buildLyricUrl(music: Music, lang: LyricLang): string {
|
||||||
|
const lrcPath = music.musicPath.replace(/\.[^.]+$/, '.lrc')
|
||||||
|
return LYRIC_HEAD + `${lang}/` + music.baseUrl + lrcPath
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 OSS 拉取三语歌词文本(失败返回空串) */
|
||||||
|
export async function fetchLyrics(
|
||||||
|
music: Music
|
||||||
|
): Promise<{ jp: string; zh: string; roma: string }> {
|
||||||
|
const get = async (lang: LyricLang): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await window.api.net.fetchText(encodeURI(buildLyricUrl(music, lang)))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [jp, zh, roma] = await Promise.all([get('JP'), get('ZH'), get('ROMA')])
|
||||||
|
return { jp, zh, roma }
|
||||||
|
}
|
||||||
124
src/renderer/src/lib/repository.ts
Normal file
124
src/renderer/src/lib/repository.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Album, Music, Menu, Love, History, Lyric } from '@shared/models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染进程数据仓储:通过 preload 暴露的 window.api.db 访问主进程 SQLite。
|
||||||
|
* 屏蔽 SQL 细节,页面/状态只调用语义化方法。
|
||||||
|
*/
|
||||||
|
export const Repo = {
|
||||||
|
// 专辑
|
||||||
|
allAlbums: () => window.api.db.query<Album>('SELECT * FROM album'),
|
||||||
|
|
||||||
|
// 歌曲
|
||||||
|
allMusic: () => window.api.db.query<Music>('SELECT * FROM music'),
|
||||||
|
musicByAlbum: (albumUId: string) =>
|
||||||
|
window.api.db.query<Music>('SELECT * FROM music WHERE albumUId = ? ORDER BY "index" ASC', [
|
||||||
|
albumUId
|
||||||
|
]),
|
||||||
|
musicByIds: async (ids: string[]): Promise<Music[]> => {
|
||||||
|
if (ids.length === 0) return []
|
||||||
|
const ph = ids.map(() => '?').join(',')
|
||||||
|
return window.api.db.query<Music>(`SELECT * FROM music WHERE musicUId IN (${ph})`, ids)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 歌词
|
||||||
|
lyric: async (musicUId: string): Promise<Lyric | undefined> => {
|
||||||
|
const rows = await window.api.db.query<Lyric>('SELECT * FROM lyric WHERE musicUId = ?', [
|
||||||
|
musicUId
|
||||||
|
])
|
||||||
|
return rows[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
// 我喜欢
|
||||||
|
allLoves: () => window.api.db.query<Love>('SELECT * FROM love ORDER BY createTime DESC'),
|
||||||
|
addLove: (musicUId: string) =>
|
||||||
|
window.api.db.exec('INSERT OR IGNORE INTO love (musicUId, createTime) VALUES (?, ?)', [
|
||||||
|
musicUId,
|
||||||
|
Date.now()
|
||||||
|
]),
|
||||||
|
removeLove: (musicUId: string) =>
|
||||||
|
window.api.db.exec('DELETE FROM love WHERE musicUId = ?', [musicUId]),
|
||||||
|
|
||||||
|
// 最近播放
|
||||||
|
recentHistory: (limit = 100) =>
|
||||||
|
window.api.db.query<History>('SELECT * FROM history ORDER BY playTime DESC LIMIT ?', [limit]),
|
||||||
|
touchHistory: (musicUId: string) =>
|
||||||
|
window.api.db.exec(
|
||||||
|
`INSERT INTO history (musicUId, playTime) VALUES (?, ?)
|
||||||
|
ON CONFLICT(musicUId) DO UPDATE SET playTime=excluded.playTime`,
|
||||||
|
[musicUId, Date.now()]
|
||||||
|
),
|
||||||
|
|
||||||
|
// 模糊搜索(歌曲名/艺术家、专辑名/团体、歌单名)
|
||||||
|
search: async (
|
||||||
|
q: string
|
||||||
|
): Promise<{ musics: Music[]; albums: Album[]; menus: Menu[] }> => {
|
||||||
|
const kw = q.trim()
|
||||||
|
if (!kw) return { musics: [], albums: [], menus: [] }
|
||||||
|
const like = `%${kw.replace(/[%_]/g, (c) => '\\' + c)}%`
|
||||||
|
const [musics, albums, menus] = await Promise.all([
|
||||||
|
window.api.db.query<Music>(
|
||||||
|
`SELECT * FROM music WHERE musicName LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY musicName LIMIT 100`,
|
||||||
|
[like, like]
|
||||||
|
),
|
||||||
|
window.api.db.query<Album>(
|
||||||
|
`SELECT * FROM album WHERE albumName LIKE ? ESCAPE '\\' OR "group" LIKE ? ESCAPE '\\'
|
||||||
|
ORDER BY albumName LIMIT 60`,
|
||||||
|
[like, like]
|
||||||
|
),
|
||||||
|
window.api.db.query<Menu>(`SELECT * FROM menu WHERE title LIKE ? ESCAPE '\\' LIMIT 30`, [like])
|
||||||
|
])
|
||||||
|
return { musics, albums, menus }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 歌单
|
||||||
|
allMenus: () => window.api.db.query<Menu>('SELECT * FROM menu ORDER BY createTime DESC'),
|
||||||
|
pcMenus: () =>
|
||||||
|
window.api.db.query<Menu>('SELECT * FROM menu WHERE id <= 100 ORDER BY createTime DESC'),
|
||||||
|
addMusicToMenu: async (menuId: number, musicUId: string): Promise<void> => {
|
||||||
|
const rows = await window.api.db.query<{ maxOrder: number | null }>(
|
||||||
|
'SELECT MAX("order") as maxOrder FROM playlist_music WHERE menuId = ?',
|
||||||
|
[menuId]
|
||||||
|
)
|
||||||
|
const nextOrder = (rows[0]?.maxOrder ?? -1) + 1
|
||||||
|
await window.api.db.exec(
|
||||||
|
'INSERT OR IGNORE INTO playlist_music (menuId, musicUId, "order") VALUES (?,?,?)',
|
||||||
|
[menuId, musicUId, nextOrder]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
menuMusicIds: async (menuId: number): Promise<string[]> => {
|
||||||
|
const rows = await window.api.db.query<{ musicUId: string }>(
|
||||||
|
'SELECT musicUId FROM playlist_music WHERE menuId = ? ORDER BY "order" ASC',
|
||||||
|
[menuId]
|
||||||
|
)
|
||||||
|
return rows.map((r) => r.musicUId)
|
||||||
|
},
|
||||||
|
createMenu: (id: number, title: string, cover?: string) =>
|
||||||
|
window.api.db.exec('INSERT OR REPLACE INTO menu (id, title, cover, createTime) VALUES (?,?,?,?)', [
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
cover ?? null,
|
||||||
|
Date.now()
|
||||||
|
]),
|
||||||
|
removeMenu: async (id: number) => {
|
||||||
|
await window.api.db.exec('DELETE FROM menu WHERE id = ?', [id])
|
||||||
|
await window.api.db.exec('DELETE FROM playlist_music WHERE menuId = ?', [id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接歌曲实体文件 URL(走本地 http-server 或远程源)。
|
||||||
|
* base_url / path 含空格、[]、中文、日文等,必须逐段 URL 编码,
|
||||||
|
* 否则 <img>/<audio> 无法加载。保留 '/' 作为分隔符。
|
||||||
|
*/
|
||||||
|
export function buildFileUrl(baseHttp: string, baseUrl: string, path: string): string {
|
||||||
|
if (!baseHttp) return ''
|
||||||
|
const encodeSegments = (s: string): string =>
|
||||||
|
s
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join('/')
|
||||||
|
const rel = [encodeSegments(baseUrl), encodeSegments(path)].filter(Boolean).join('/')
|
||||||
|
return `${baseHttp.replace(/\/+$/, '')}/${rel}`
|
||||||
|
}
|
||||||
27
src/renderer/src/main.tsx
Normal file
27
src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { HashRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './styles/index.css'
|
||||||
|
|
||||||
|
// 禁止把封面/链接等资源拖到窗口外(不影响 -webkit-app-region 窗口拖拽)
|
||||||
|
document.addEventListener(
|
||||||
|
'dragstart',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
// 桌面歌词窗口:在首帧前去掉 body 底色,否则透明窗口会露出白色背景
|
||||||
|
if (typeof location !== 'undefined' && location.hash.startsWith('#/desktop-lyric')) {
|
||||||
|
document.documentElement.classList.add('desktop-lyric')
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<HashRouter>
|
||||||
|
<App />
|
||||||
|
</HashRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
73
src/renderer/src/pages/AlbumDetail.tsx
Normal file
73
src/renderer/src/pages/AlbumDetail.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { ChevronLeft, Play, Disc3 } from 'lucide-react'
|
||||||
|
import type { Album, Music } from '@shared/models'
|
||||||
|
import { Repo, buildFileUrl } from '../lib/repository'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import MusicList from '../components/MusicList'
|
||||||
|
|
||||||
|
export default function AlbumDetail(): JSX.Element {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const albums = useLibraryStore((s) => s.albums)
|
||||||
|
const httpBase = usePlayerStore((s) => s.httpBase)
|
||||||
|
const playList = usePlayerStore((s) => s.playList)
|
||||||
|
const [songs, setSongs] = useState<Music[]>([])
|
||||||
|
|
||||||
|
const album: Album | undefined = albums.find((a) => a.albumUId === id)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) Repo.musicByAlbum(id).then(setSongs)
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
// 专辑 cover 已是根相对完整路径;无封面时回退首曲
|
||||||
|
const cover = album
|
||||||
|
? songs[0]
|
||||||
|
? buildFileUrl(httpBase, songs[0].baseUrl, songs[0].coverPath)
|
||||||
|
: album.cover
|
||||||
|
? buildFileUrl(httpBase, '', album.cover)
|
||||||
|
: ''
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="apple-btn-ghost mt-4 mb-6"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} /> 返回
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-8 flex items-center gap-6">
|
||||||
|
<div className="h-48 w-48 shrink-0 overflow-hidden rounded-3xl bg-black/10 shadow-apple-lg">
|
||||||
|
{album?.cover ? (
|
||||||
|
<img src={cover} className="h-full w-full object-cover" alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center text-[var(--text-secondary)]">
|
||||||
|
<Disc3 size={56} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-48 min-w-0 flex-1 flex-col justify-center gap-3">
|
||||||
|
<h2 className="truncate text-4xl font-bold tracking-tight">
|
||||||
|
{album?.albumName ?? '未知专辑'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
|
{album?.group} · {songs.length} 首歌曲
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => songs.length && playList(songs, 0)}
|
||||||
|
className="apple-btn-primary"
|
||||||
|
>
|
||||||
|
<Play size={16} className="fill-current" /> 播放全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MusicList list={songs} showHeader hideAlbum />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
254
src/renderer/src/pages/DesktopLyric.tsx
Normal file
254
src/renderer/src/pages/DesktopLyric.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||||
|
import { X, Minus, Plus, Lock, Unlock, Palette, Languages } from 'lucide-react'
|
||||||
|
import type { DesktopLrcLang, DesktopLrcPayload } from '../components/LyricEngine'
|
||||||
|
|
||||||
|
const MIN_FONT = 18
|
||||||
|
const MAX_FONT = 46
|
||||||
|
const DEFAULT_COLOR = '#3ce39b'
|
||||||
|
const COLORS = ['#3ce39b', '#0A84FF', '#FF375F', '#FFFFFF', '#FFD60A', '#BF5AF2']
|
||||||
|
/** 对齐原项目:高度 > 140 切双行 KTV,否则单行 */
|
||||||
|
const SINGLE_LINE_MAX_H = 140
|
||||||
|
|
||||||
|
const LANG_CYCLE: DesktopLrcLang[] = ['jp', 'zh', 'roma']
|
||||||
|
const LANG_LABEL: Record<DesktopLrcLang, string> = {
|
||||||
|
jp: '日文',
|
||||||
|
zh: '中文',
|
||||||
|
roma: '罗马音'
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = { WebkitAppRegion: 'drag' } as CSSProperties
|
||||||
|
const noDrag = { WebkitAppRegion: 'no-drag' } as CSSProperties
|
||||||
|
|
||||||
|
const emptyPack = { prevLrc: '', nextLrc: '', singleLrc: '' }
|
||||||
|
|
||||||
|
/** 独立桌面歌词窗口:单行 / 双行 KTV、语言切换、悬停工具条 */
|
||||||
|
export default function DesktopLyric(): JSX.Element {
|
||||||
|
const [payload, setPayload] = useState<DesktopLrcPayload | null>(null)
|
||||||
|
const [fontSize, setFontSize] = useState(30)
|
||||||
|
const [color, setColor] = useState(DEFAULT_COLOR)
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const [locked, setLocked] = useState(false)
|
||||||
|
const [showColors, setShowColors] = useState(false)
|
||||||
|
const [lang, setLang] = useState<DesktopLrcLang>('jp')
|
||||||
|
const [singleLine, setSingleLine] = useState(
|
||||||
|
() => (typeof window !== 'undefined' ? window.innerHeight <= SINGLE_LINE_MAX_H : true)
|
||||||
|
)
|
||||||
|
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.store.get<number>('lrcFontSize').then((v) => v && setFontSize(v))
|
||||||
|
window.api.store.get<string>('lrcColor').then((v) => v && setColor(v))
|
||||||
|
window.api.store.get<DesktopLrcLang>('lrcLanguage').then((v) => {
|
||||||
|
if (v === 'jp' || v === 'zh' || v === 'roma') setLang(v)
|
||||||
|
})
|
||||||
|
|
||||||
|
const off = window.api.lyric.onUpdate((p: unknown) => {
|
||||||
|
const next = p as DesktopLrcPayload & { text?: string; sub?: string }
|
||||||
|
// 兼容旧缓存:仅 text/sub 时转成新结构
|
||||||
|
if (next?.jp && next?.zh && next?.roma) {
|
||||||
|
setPayload(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next?.text) {
|
||||||
|
const pack = {
|
||||||
|
prevLrc: next.text,
|
||||||
|
nextLrc: next.sub ?? '',
|
||||||
|
singleLrc: next.text
|
||||||
|
}
|
||||||
|
setPayload({ jp: pack, zh: pack, roma: pack, title: next.title })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onResize = (): void => {
|
||||||
|
setSingleLine(window.innerHeight <= SINGLE_LINE_MAX_H)
|
||||||
|
}
|
||||||
|
onResize()
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
off()
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
if (leaveTimer.current) clearTimeout(leaveTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pack = useMemo(() => {
|
||||||
|
if (!payload) return emptyPack
|
||||||
|
return payload[lang] ?? emptyPack
|
||||||
|
}, [payload, lang])
|
||||||
|
|
||||||
|
const changeFont = (up: boolean): void => {
|
||||||
|
setFontSize((prev) => {
|
||||||
|
const next = Math.min(MAX_FONT, Math.max(MIN_FONT, prev + (up ? 2 : -2)))
|
||||||
|
window.api.store.set('lrcFontSize', next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickColor = (c: string): void => {
|
||||||
|
setColor(c)
|
||||||
|
window.api.store.set('lrcColor', c)
|
||||||
|
setShowColors(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleLock = (): void => {
|
||||||
|
const next = !locked
|
||||||
|
setLocked(next)
|
||||||
|
setShowColors(false)
|
||||||
|
window.api.lyric.setIgnoreMouse(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleLang = (): void => {
|
||||||
|
setLang((prev) => {
|
||||||
|
const i = LANG_CYCLE.indexOf(prev)
|
||||||
|
const next = LANG_CYCLE[(i + 1) % LANG_CYCLE.length]
|
||||||
|
void window.api.store.set('lrcLanguage', next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearLeaveTimer = (): void => {
|
||||||
|
if (leaveTimer.current) {
|
||||||
|
clearTimeout(leaveTimer.current)
|
||||||
|
leaveTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnter = (): void => {
|
||||||
|
clearLeaveTimer()
|
||||||
|
setHover(true)
|
||||||
|
if (locked) window.api.lyric.setIgnoreMouse(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLeave = (): void => {
|
||||||
|
clearLeaveTimer()
|
||||||
|
leaveTimer.current = setTimeout(() => {
|
||||||
|
setHover(false)
|
||||||
|
setShowColors(false)
|
||||||
|
if (locked) window.api.lyric.setIgnoreMouse(true)
|
||||||
|
leaveTimer.current = null
|
||||||
|
}, 160)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolVisible = hover || showColors
|
||||||
|
const showBg = toolVisible && !locked
|
||||||
|
// 日文双行:上行左、下行右(KTV);中/罗马双行居中
|
||||||
|
const ktvJp = !singleLine && lang === 'jp'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={onEnter}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
style={{
|
||||||
|
...(locked ? noDrag : drag),
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.01)'
|
||||||
|
}}
|
||||||
|
className="relative flex h-screen w-screen select-none flex-col items-center justify-center gap-1 p-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-1 rounded-2xl transition-colors duration-200"
|
||||||
|
style={{ background: showBg ? 'rgba(0,0,0,0.55)' : 'transparent' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...noDrag,
|
||||||
|
opacity: toolVisible ? 1 : 0,
|
||||||
|
pointerEvents: toolVisible ? 'auto' : 'none'
|
||||||
|
}}
|
||||||
|
className="absolute left-1/2 top-1 z-10 -translate-x-1/2 rounded-2xl bg-black/60 px-3 py-1.5 text-white/90 transition-opacity duration-150"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
title="关闭桌面歌词"
|
||||||
|
onClick={() => window.api.lyric.toggle(false)}
|
||||||
|
className="hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
<button title="减小字号" onClick={() => changeFont(false)} className="hover:text-white">
|
||||||
|
<Minus size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title={locked ? '解锁窗口' : '锁定窗口(点击穿透)'}
|
||||||
|
onClick={toggleLock}
|
||||||
|
className="hover:text-white"
|
||||||
|
>
|
||||||
|
{locked ? <Lock size={15} /> : <Unlock size={15} />}
|
||||||
|
</button>
|
||||||
|
<button title="增大字号" onClick={() => changeFont(true)} className="hover:text-white">
|
||||||
|
<Plus size={15} />
|
||||||
|
</button>
|
||||||
|
<button title="歌词颜色" onClick={() => setShowColors((v) => !v)} className="hover:text-white">
|
||||||
|
<Palette size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title={`切换歌词(当前:${LANG_LABEL[lang]})· 拉高窗口可双行 KTV`}
|
||||||
|
onClick={cycleLang}
|
||||||
|
className="hover:text-white"
|
||||||
|
>
|
||||||
|
<Languages size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showColors && (
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-1.5 border-t border-white/15 pt-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
title={c}
|
||||||
|
onClick={() => pickColor(c)}
|
||||||
|
className="h-5 w-5 rounded-full ring-2 ring-transparent hover:ring-white/70"
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 歌词区 */}
|
||||||
|
<div className="relative z-0 flex w-full max-w-full flex-col justify-center gap-1 px-4">
|
||||||
|
{singleLine ? (
|
||||||
|
<p
|
||||||
|
className="truncate text-center font-bold"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
fontSize,
|
||||||
|
textShadow: showBg ? 'none' : '0 2px 12px rgba(0,0,0,0.75)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pack.singleLrc || ' '}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
className="truncate font-bold"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
fontSize,
|
||||||
|
textAlign: ktvJp ? 'left' : 'center',
|
||||||
|
maxWidth: ktvJp ? '66%' : '100%',
|
||||||
|
alignSelf: ktvJp ? 'flex-start' : 'center',
|
||||||
|
textShadow: showBg ? 'none' : '0 2px 12px rgba(0,0,0,0.75)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pack.prevLrc || ' '}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="truncate font-bold"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
fontSize,
|
||||||
|
textAlign: ktvJp ? 'right' : 'center',
|
||||||
|
maxWidth: ktvJp ? '66%' : '100%',
|
||||||
|
alignSelf: ktvJp ? 'flex-end' : 'center',
|
||||||
|
textShadow: showBg ? 'none' : '0 2px 12px rgba(0,0,0,0.75)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pack.nextLrc || ' '}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/renderer/src/pages/Favorites.tsx
Normal file
51
src/renderer/src/pages/Favorites.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Play, Heart } from 'lucide-react'
|
||||||
|
import type { Music } from '@shared/models'
|
||||||
|
import { Repo } from '../lib/repository'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import MusicList from '../components/MusicList'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
|
||||||
|
export default function Favorites(): JSX.Element {
|
||||||
|
const { loves, playList, refreshLoves } = usePlayerStore()
|
||||||
|
const [songs, setSongs] = useState<Music[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshLoves()
|
||||||
|
}, [refreshLoves])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
const all = await Repo.allLoves()
|
||||||
|
const music = await Repo.musicByIds(all.map((l) => l.musicUId))
|
||||||
|
const order = new Map(all.map((l, i) => [l.musicUId, i]))
|
||||||
|
music.sort((a, b) => (order.get(a.musicUId) ?? 0) - (order.get(b.musicUId) ?? 0))
|
||||||
|
setSongs(music)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [loves])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="我喜欢"
|
||||||
|
subtitle={`${songs.length} 首歌曲`}
|
||||||
|
right={
|
||||||
|
songs.length ? (
|
||||||
|
<button onClick={() => playList(songs, 0)} className="apple-btn-primary">
|
||||||
|
<Play size={16} className="fill-current" /> 播放全部
|
||||||
|
</button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{songs.length === 0 ? (
|
||||||
|
<div className="grid place-items-center gap-2 py-24 text-sm text-[var(--text-secondary)]">
|
||||||
|
<Heart size={40} className="text-apple-pink/40" />
|
||||||
|
还没有收藏的歌曲
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MusicList list={songs} showHeader />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/renderer/src/pages/History.tsx
Normal file
36
src/renderer/src/pages/History.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Music } from '@shared/models'
|
||||||
|
import { Repo } from '../lib/repository'
|
||||||
|
import MusicList from '../components/MusicList'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
|
||||||
|
export default function History(): JSX.Element {
|
||||||
|
const [songs, setSongs] = useState<Music[]>([])
|
||||||
|
const [playTimeById, setPlayTimeById] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
const hist = await Repo.recentHistory(100)
|
||||||
|
const music = await Repo.musicByIds(hist.map((h) => h.musicUId))
|
||||||
|
const order = new Map(hist.map((h, i) => [h.musicUId, i]))
|
||||||
|
const times: Record<string, number> = {}
|
||||||
|
for (const h of hist) times[h.musicUId] = h.playTime
|
||||||
|
music.sort((a, b) => (order.get(a.musicUId) ?? 0) - (order.get(b.musicUId) ?? 0))
|
||||||
|
setPlayTimeById(times)
|
||||||
|
setSongs(music)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="最近播放" subtitle={`最近 ${songs.length} 首`} />
|
||||||
|
<MusicList
|
||||||
|
list={songs}
|
||||||
|
showHeader
|
||||||
|
playTimeById={playTimeById}
|
||||||
|
emptyHint="还没有播放记录"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
175
src/renderer/src/pages/Home.tsx
Normal file
175
src/renderer/src/pages/Home.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useOutlet } from 'react-router-dom'
|
||||||
|
import { ArrowDownWideNarrow, ArrowUpWideNarrow, Play } from 'lucide-react'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { GROUPS } from '../lib/const'
|
||||||
|
import { rememberHomeScroll, resetMainScrollForDetail, restoreHomeScroll } from '../lib/homeScroll'
|
||||||
|
import AlbumCard from '../components/AlbumCard'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type ReleaseSort = 'asc' | 'desc'
|
||||||
|
|
||||||
|
function releaseTime(date: string | undefined, missingAs: number): number {
|
||||||
|
if (!date) return missingAs
|
||||||
|
const t = Date.parse(date)
|
||||||
|
return Number.isFinite(t) ? t : missingAs
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home(): JSX.Element {
|
||||||
|
const outlet = useOutlet()
|
||||||
|
const showingDetail = !!outlet
|
||||||
|
const wasShowingDetail = useRef(false)
|
||||||
|
const { albums, music, loadAll, loading } = useLibraryStore()
|
||||||
|
const playList = usePlayerStore((s) => s.playList)
|
||||||
|
const [group, setGroup] = useState<string>('all')
|
||||||
|
const [releaseSort, setReleaseSort] = useState<ReleaseSort>('asc')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll()
|
||||||
|
}, [loadAll])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动记忆在 AlbumCard 点击时完成(网格 hidden 之前)。
|
||||||
|
* 此处不可在进详情时再读 scrollTop——hidden 后主区域高度塌缩,读到的已是 0。
|
||||||
|
*/
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (showingDetail && !wasShowingDetail.current) {
|
||||||
|
resetMainScrollForDetail()
|
||||||
|
} else if (!showingDetail && wasShowingDetail.current) {
|
||||||
|
restoreHomeScroll()
|
||||||
|
}
|
||||||
|
wasShowingDetail.current = showingDetail
|
||||||
|
}, [showingDetail])
|
||||||
|
|
||||||
|
// 捕获阶段再记一次,防止将来其它入口进详情时漏记
|
||||||
|
const onGridClickCapture = (): void => {
|
||||||
|
if (!showingDetail) rememberHomeScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const list = group === 'all' ? [...albums] : albums.filter((a) => a.group === group)
|
||||||
|
const missing = releaseSort === 'asc' ? Number.MAX_SAFE_INTEGER : Number.MIN_SAFE_INTEGER
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const da = releaseTime(a.releaseDate, missing)
|
||||||
|
const db = releaseTime(b.releaseDate, missing)
|
||||||
|
if (da !== db) return releaseSort === 'asc' ? da - db : db - da
|
||||||
|
return a.albumName.localeCompare(b.albumName, 'zh')
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
}, [albums, group, releaseSort])
|
||||||
|
|
||||||
|
// 当前分组下的全部歌曲(按当前专辑顺序 + 曲序),供「播放全部」
|
||||||
|
const groupMusics = useMemo(() => {
|
||||||
|
const order = new Map(filtered.map((a, i) => [a.albumUId, i]))
|
||||||
|
return music
|
||||||
|
.filter((m) =>
|
||||||
|
group === 'all' ? order.has(m.albumUId) : order.has(m.albumUId) || m.group === group
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(order.get(a.albumUId) ?? 9999) - (order.get(b.albumUId) ?? 9999) ||
|
||||||
|
(a.index ?? 0) - (b.index ?? 0)
|
||||||
|
)
|
||||||
|
}, [music, filtered, group])
|
||||||
|
|
||||||
|
const SortIcon = releaseSort === 'asc' ? ArrowUpWideNarrow : ArrowDownWideNarrow
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 保持挂载:进详情只隐藏,避免封面/列表整表卸载重绘 */}
|
||||||
|
<div
|
||||||
|
className={clsx(showingDetail && 'hidden')}
|
||||||
|
aria-hidden={showingDetail}
|
||||||
|
onClickCapture={onGridClickCapture}
|
||||||
|
>
|
||||||
|
<PageHeader
|
||||||
|
title="专辑"
|
||||||
|
subtitle={`共 ${filtered.length} 张专辑 · ${groupMusics.length} 首歌曲`}
|
||||||
|
right={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReleaseSort((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||||
|
title={
|
||||||
|
releaseSort === 'asc'
|
||||||
|
? '当前:发售时间正序,点击切换倒序'
|
||||||
|
: '当前:发售时间倒序,点击切换正序'
|
||||||
|
}
|
||||||
|
className="apple-btn-ghost"
|
||||||
|
>
|
||||||
|
<SortIcon size={16} />
|
||||||
|
发售{releaseSort === 'asc' ? '正序' : '倒序'}
|
||||||
|
</button>
|
||||||
|
{groupMusics.length > 0 ? (
|
||||||
|
<button onClick={() => playList(groupMusics, 0)} className="apple-btn-primary">
|
||||||
|
<Play size={16} className="fill-current" /> 播放全部
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 企划分组分段控件 */}
|
||||||
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
<Chip active={group === 'all'} onClick={() => setGroup('all')} label="全部" />
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<Chip
|
||||||
|
key={g.key}
|
||||||
|
active={group === g.key}
|
||||||
|
onClick={() => setGroup(g.key)}
|
||||||
|
label={g.label}
|
||||||
|
color={g.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && albums.length === 0 ? (
|
||||||
|
<div className="grid place-items-center py-24 text-sm text-[var(--text-secondary)]">
|
||||||
|
正在加载曲库…
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="grid place-items-center py-24 text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
<p>曲库还是空的。</p>
|
||||||
|
<p className="mt-1">请在「设置」中选择本地曲库目录,或从手机端传入歌曲。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,200px))] gap-5">
|
||||||
|
{filtered.map((a) => (
|
||||||
|
<AlbumCard key={a.albumUId} album={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{outlet}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
color
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
color?: string
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-full px-4 py-1.5 text-sm font-medium transition-all',
|
||||||
|
active
|
||||||
|
? 'bg-[var(--text)] text-[var(--bg)]'
|
||||||
|
: 'bg-black/5 dark:bg-white/10 text-[var(--text-secondary)] hover:text-[var(--text)]'
|
||||||
|
)}
|
||||||
|
style={active && color ? { backgroundColor: color, color: '#fff' } : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
408
src/renderer/src/pages/Lyric.tsx
Normal file
408
src/renderer/src/pages/Lyric.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
|
import { useLocation, useNavigate, type Location } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ChevronDown, Heart } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { buildFileUrl } from '../lib/repository'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
import { useLyricStore } from '../stores/lyricStore'
|
||||||
|
|
||||||
|
/** Apple Music 风格切行:偏软的弹簧 */
|
||||||
|
const LINE_SPRING = { type: 'spring' as const, stiffness: 140, damping: 22, mass: 0.85 }
|
||||||
|
const SIZE_SPRING = { type: 'spring' as const, stiffness: 160, damping: 24, mass: 0.8 }
|
||||||
|
|
||||||
|
type LyricLocationState = { background?: Location }
|
||||||
|
|
||||||
|
function lineVisual(dist: number, active: boolean): {
|
||||||
|
opacity: number
|
||||||
|
scale: number
|
||||||
|
blur: number
|
||||||
|
y: number
|
||||||
|
jpSize: number
|
||||||
|
subSize: number
|
||||||
|
} {
|
||||||
|
if (active) {
|
||||||
|
return { opacity: 1, scale: 1, blur: 0, y: 0, jpSize: 32, subSize: 17 }
|
||||||
|
}
|
||||||
|
if (dist === 1) {
|
||||||
|
return { opacity: 0.45, scale: 0.94, blur: 0.4, y: 2, jpSize: 22, subSize: 14 }
|
||||||
|
}
|
||||||
|
if (dist === 2) {
|
||||||
|
return { opacity: 0.28, scale: 0.9, blur: 0.9, y: 4, jpSize: 20, subSize: 13 }
|
||||||
|
}
|
||||||
|
return { opacity: 0.14, scale: 0.88, blur: 1.4, y: 6, jpSize: 18, subSize: 12 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跟唱滚动:ease-out,接近 Apple 切行时的列表滑动感 */
|
||||||
|
function animateScrollTo(el: HTMLElement, to: number, duration = 580): () => void {
|
||||||
|
const from = el.scrollTop
|
||||||
|
const delta = to - from
|
||||||
|
if (Math.abs(delta) < 1) return () => undefined
|
||||||
|
let raf = 0
|
||||||
|
const start = performance.now()
|
||||||
|
const tick = (now: number): void => {
|
||||||
|
const t = Math.min(1, (now - start) / duration)
|
||||||
|
const eased = 1 - (1 - t) ** 3
|
||||||
|
el.scrollTop = from + delta * eased
|
||||||
|
if (t < 1) raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全屏歌词页:
|
||||||
|
* - 左侧唱片转盘 + 曲目信息
|
||||||
|
* - 右侧可手动滚动;播放时自动跟唱(用户滚动后短暂暂停跟唱)
|
||||||
|
*/
|
||||||
|
export default function Lyric(): JSX.Element {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { current, progress, httpBase, isPlaying, loves, toggleLove, seek, play } =
|
||||||
|
usePlayerStore()
|
||||||
|
const lines = useLyricStore((s) => s.lines)
|
||||||
|
const load = useLyricStore((s) => s.load)
|
||||||
|
const closeTick = useLyricStore((s) => s.closeTick)
|
||||||
|
const musicMap = useLibraryStore((s) => s.music)
|
||||||
|
const [showZh, setShowZh] = useState(true)
|
||||||
|
const [showRoma, setShowRoma] = useState(true)
|
||||||
|
const [leaving, setLeaving] = useState(false)
|
||||||
|
const closingRef = useRef(false)
|
||||||
|
const prevCloseTick = useRef(closeTick)
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
const userScrollLock = useRef(false)
|
||||||
|
const resumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const cancelScroll = useRef<(() => void) | null>(null)
|
||||||
|
const isLoved = !!(current && loves.has(current.musicUId))
|
||||||
|
/** ≤1920 左栏居中;>1920 靠右,加宽只扩左侧空白 */
|
||||||
|
const [wideLayout, setWideLayout] = useState(
|
||||||
|
() => typeof window !== 'undefined' && window.innerWidth > 1920
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = (): void => setWideLayout(window.innerWidth > 1920)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => window.removeEventListener('resize', onResize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 每次进入歌词页重置,避免上次 leaving 残留导致「打不开」
|
||||||
|
useEffect(() => {
|
||||||
|
setLeaving(false)
|
||||||
|
closingRef.current = false
|
||||||
|
prevCloseTick.current = closeTick
|
||||||
|
}, [location.key])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) return
|
||||||
|
const full = musicMap.find((m) => m.musicUId === current.musicUId) ?? current
|
||||||
|
load(full)
|
||||||
|
}, [current, musicMap, load])
|
||||||
|
|
||||||
|
const activeIdx = useMemo(() => {
|
||||||
|
let idx = -1
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].time <= progress) idx = i
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}, [lines, progress])
|
||||||
|
|
||||||
|
const focusIdx = activeIdx < 0 ? 0 : activeIdx
|
||||||
|
|
||||||
|
const scrollToActive = useCallback(
|
||||||
|
(smooth: boolean, force = false): void => {
|
||||||
|
if (!force && userScrollLock.current) return
|
||||||
|
const viewport = viewportRef.current
|
||||||
|
if (!viewport || lines.length === 0) return
|
||||||
|
const activeEl = viewport.querySelector<HTMLElement>(`[data-line="${focusIdx}"]`)
|
||||||
|
if (!activeEl) return
|
||||||
|
// 焦点略低于视口中线,避免当前行视觉偏上
|
||||||
|
const top = Math.max(
|
||||||
|
0,
|
||||||
|
activeEl.offsetTop - viewport.clientHeight * 0.5 + activeEl.offsetHeight / 2
|
||||||
|
)
|
||||||
|
cancelScroll.current?.()
|
||||||
|
if (smooth) {
|
||||||
|
cancelScroll.current = animateScrollTo(viewport, top)
|
||||||
|
} else {
|
||||||
|
viewport.scrollTop = top
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[focusIdx, lines.length]
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 显隐中文/罗马音后立刻按新高度回中(清掉用户滚动锁定) */
|
||||||
|
const recenterAfterLayout = useCallback((): void => {
|
||||||
|
userScrollLock.current = false
|
||||||
|
if (resumeTimer.current) {
|
||||||
|
clearTimeout(resumeTimer.current)
|
||||||
|
resumeTimer.current = null
|
||||||
|
}
|
||||||
|
scrollToActive(false, true)
|
||||||
|
// 再等两帧,确保条件渲染后的 offsetTop 已稳定
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => scrollToActive(false, true))
|
||||||
|
})
|
||||||
|
}, [scrollToActive])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
recenterAfterLayout()
|
||||||
|
}, [lines, showZh, showRoma, recenterAfterLayout])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToActive(true)
|
||||||
|
}, [activeIdx, scrollToActive])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = (): void => scrollToActive(false, true)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current)
|
||||||
|
cancelScroll.current?.()
|
||||||
|
}
|
||||||
|
}, [scrollToActive])
|
||||||
|
|
||||||
|
/** 用户手动滚动时暂停自动跟唱,停止操作数秒后再恢复 */
|
||||||
|
const onUserScrollIntent = (): void => {
|
||||||
|
userScrollLock.current = true
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current)
|
||||||
|
resumeTimer.current = setTimeout(() => {
|
||||||
|
userScrollLock.current = false
|
||||||
|
scrollToActive(true, true)
|
||||||
|
}, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cover = current ? buildFileUrl(httpBase, current.baseUrl, current.coverPath) : ''
|
||||||
|
|
||||||
|
const collapse = (): void => {
|
||||||
|
if (leaving || closingRef.current) return
|
||||||
|
setLeaving(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放条封面等外部 requestClose → 与「收起」同一套下滑动画
|
||||||
|
useEffect(() => {
|
||||||
|
if (closeTick === prevCloseTick.current) return
|
||||||
|
prevCloseTick.current = closeTick
|
||||||
|
collapse()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [closeTick])
|
||||||
|
|
||||||
|
const finishClose = (): void => {
|
||||||
|
if (closingRef.current) return
|
||||||
|
closingRef.current = true
|
||||||
|
const bg = (location.state as LyricLocationState | null)?.background
|
||||||
|
if (bg) {
|
||||||
|
navigate(`${bg.pathname}${bg.search}${bg.hash}`, {
|
||||||
|
replace: true,
|
||||||
|
state: bg.state
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekToLine = (time: number): void => {
|
||||||
|
if (!current || !Number.isFinite(time) || time < 0) return
|
||||||
|
userScrollLock.current = false
|
||||||
|
seek(time)
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-x-0 top-0 bottom-20 z-40 flex flex-col bg-[var(--bg)]"
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: leaving ? '100%' : 0 }}
|
||||||
|
transition={{ duration: 0.38, ease: [0.32, 0.72, 0, 1] }}
|
||||||
|
onAnimationComplete={() => {
|
||||||
|
if (leaving) finishClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 顶栏:整条 drag;仅按钮 no-drag(勿给全宽层加 no-drag,否则 Electron 无法拖窗) */}
|
||||||
|
<div className="relative h-[72px] w-full shrink-0">
|
||||||
|
<div className="drag absolute inset-0" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-2 top-auto flex h-10 items-center justify-between px-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={collapse}
|
||||||
|
className="apple-btn-ghost pointer-events-auto h-9 min-w-[88px] px-4"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} /> 收起
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
flushSync(() => setShowRoma((v) => !v))
|
||||||
|
recenterAfterLayout()
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'no-drag pointer-events-auto flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
|
||||||
|
showRoma
|
||||||
|
? 'bg-[var(--text)] text-[var(--bg)]'
|
||||||
|
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
罗马音
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
flushSync(() => setShowZh((v) => !v))
|
||||||
|
recenterAfterLayout()
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
'no-drag pointer-events-auto flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
|
||||||
|
showZh
|
||||||
|
? 'bg-[var(--text)] text-[var(--bg)]'
|
||||||
|
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
约定:
|
||||||
|
1) 右侧歌词半区不改;
|
||||||
|
2) ≤1920 唱片在左栏居中;>1920 靠右,加宽只扩左侧空白;
|
||||||
|
3) 高度增高时唱片随 vh 变大。
|
||||||
|
*/}
|
||||||
|
<div className="grid min-h-0 flex-1 grid-cols-2 gap-8 px-10 pb-10 pt-4">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex min-w-0 flex-col justify-center',
|
||||||
|
wideLayout ? 'items-end pr-28 md:pr-36' : 'items-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 尺寸只跟高度走;>1920 时靠右并留固定右边距,避免贴中缝 */}
|
||||||
|
<div className="flex w-[clamp(260px,38vh,min(560px,48vh))] max-w-full flex-col items-center">
|
||||||
|
<div className="relative grid w-full place-items-center">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'relative aspect-square w-full rounded-full bg-gradient-to-br from-[#2a2a2a] via-[#111] to-[#000] shadow-apple-lg ring-1 ring-white/10',
|
||||||
|
'vinyl-spin',
|
||||||
|
!isPlaying && 'vinyl-spin-paused'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-[6%] rounded-full border border-white/5" />
|
||||||
|
<div className="absolute inset-[12%] rounded-full border border-white/5" />
|
||||||
|
<div className="absolute inset-[17%] overflow-hidden rounded-full bg-black/40 shadow-inner ring-2 ring-black/40">
|
||||||
|
{cover ? (
|
||||||
|
<img src={cover} className="h-full w-full object-cover" alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center text-sm text-white/40">
|
||||||
|
LoveLive!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#c0c0c0] shadow" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 flex w-full items-start justify-center gap-2 px-3">
|
||||||
|
<h3 className="min-w-0 max-w-full break-words text-center text-2xl font-bold">
|
||||||
|
{current?.musicName ?? '未在播放'}
|
||||||
|
</h3>
|
||||||
|
{current && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={isLoved ? '取消喜欢' : '添加到我喜欢'}
|
||||||
|
onClick={() => toggleLove(current.musicUId)}
|
||||||
|
className="no-drag mt-1 shrink-0 rounded-full p-1.5 transition-colors hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
size={20}
|
||||||
|
className={clsx(isLoved && 'fill-apple-pink text-apple-pink')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 w-full truncate text-center text-sm text-[var(--text-secondary)]">
|
||||||
|
{current?.artist}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 可滚动歌词区(右侧半区:保持既有结构,不改动) */}
|
||||||
|
<div
|
||||||
|
ref={viewportRef}
|
||||||
|
onWheel={onUserScrollIntent}
|
||||||
|
onTouchStart={onUserScrollIntent}
|
||||||
|
className="lyric-mask soft-scroll-native relative min-h-0 min-w-0 overflow-y-auto overflow-x-hidden px-2"
|
||||||
|
>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<div className="absolute inset-0 grid place-items-center">
|
||||||
|
<p className="text-sm text-[var(--text-secondary)]">暂无歌词</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col py-[28vh]">
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const active = i === activeIdx
|
||||||
|
const dist = activeIdx < 0 ? i : Math.abs(i - activeIdx)
|
||||||
|
const v = lineVisual(dist, active)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
data-line={i}
|
||||||
|
title="双击跳转到此处播放"
|
||||||
|
onDoubleClick={() => seekToLine(line.time)}
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
opacity: v.opacity,
|
||||||
|
scale: v.scale,
|
||||||
|
y: v.y,
|
||||||
|
filter: `blur(${v.blur}px)`
|
||||||
|
}}
|
||||||
|
transition={LINE_SPRING}
|
||||||
|
style={{ originX: 0, originY: 0.5, willChange: 'transform, opacity, filter' }}
|
||||||
|
className="cursor-pointer select-none py-3.5 text-left"
|
||||||
|
>
|
||||||
|
{line.jp && (
|
||||||
|
<motion.p
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
fontSize: v.jpSize,
|
||||||
|
color: active ? 'var(--text)' : 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
transition={SIZE_SPRING}
|
||||||
|
className="font-bold leading-snug tracking-tight"
|
||||||
|
>
|
||||||
|
{line.jp}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
{showRoma && line.roma && (
|
||||||
|
<motion.p
|
||||||
|
initial={false}
|
||||||
|
animate={{ fontSize: v.subSize }}
|
||||||
|
transition={SIZE_SPRING}
|
||||||
|
className="mt-1 font-medium text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
{line.roma}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
{showZh && line.zh && (
|
||||||
|
<motion.p
|
||||||
|
initial={false}
|
||||||
|
animate={{ fontSize: active ? v.subSize + 1 : v.subSize }}
|
||||||
|
transition={SIZE_SPRING}
|
||||||
|
className="mt-1 font-medium text-[var(--text-secondary)]"
|
||||||
|
>
|
||||||
|
{line.zh}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
264
src/renderer/src/pages/Playlists.tsx
Normal file
264
src/renderer/src/pages/Playlists.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { Plus, ListMusic, Trash2, Play, ChevronLeft, Smartphone, Monitor } from 'lucide-react'
|
||||||
|
import type { Menu, Music } from '@shared/models'
|
||||||
|
import { isPcMenu, MENU_ID } from '@shared/models'
|
||||||
|
import { Repo } from '../lib/repository'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import MusicList from '../components/MusicList'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
import { MENU_SYNCED_EVENT } from '../lib/events'
|
||||||
|
|
||||||
|
/** 分配 1..PC_MAX 中最小未使用的 id(对齐原项目 calcSmallAtIntArr,PC 歌单) */
|
||||||
|
function allocPcMenuId(menus: Menu[]): number {
|
||||||
|
const used = new Set(menus.filter((m) => isPcMenu(m.id)).map((m) => m.id))
|
||||||
|
for (let i = 1; i <= MENU_ID.PC_MAX; i++) {
|
||||||
|
if (!used.has(i)) return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Playlists(): JSX.Element {
|
||||||
|
const [menus, setMenus] = useState<Menu[]>([])
|
||||||
|
const [active, setActive] = useState<Menu | null>(null)
|
||||||
|
const [songs, setSongs] = useState<Music[]>([])
|
||||||
|
const [naming, setNaming] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const playList = usePlayerStore((s) => s.playList)
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const reload = async (): Promise<void> => setMenus(await Repo.allMenus())
|
||||||
|
|
||||||
|
// 进入页面 / 同步完成后都重新拉歌单(手机导入后才能立刻看见)
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== '/playlists') return
|
||||||
|
void reload()
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onSynced = (): void => {
|
||||||
|
void reload()
|
||||||
|
}
|
||||||
|
window.addEventListener(MENU_SYNCED_EVENT, onSynced)
|
||||||
|
return () => window.removeEventListener(MENU_SYNCED_EVENT, onSynced)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
void (async () => {
|
||||||
|
const ids = await Repo.menuMusicIds(active.id)
|
||||||
|
const list = await Repo.musicByIds(ids)
|
||||||
|
const order = new Map(ids.map((id, i) => [id, i]))
|
||||||
|
list.sort((a, b) => (order.get(a.musicUId) ?? 0) - (order.get(b.musicUId) ?? 0))
|
||||||
|
setSongs(list)
|
||||||
|
})()
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setName('')
|
||||||
|
setNaming(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCreate = async (): Promise<void> => {
|
||||||
|
const title = name.trim()
|
||||||
|
if (!title) return
|
||||||
|
const nextId = allocPcMenuId(menus)
|
||||||
|
if (nextId === -1) return
|
||||||
|
await Repo.createMenu(nextId, title)
|
||||||
|
setNaming(false)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: number): Promise<void> => {
|
||||||
|
if (!confirm('确认删除该歌单?')) return
|
||||||
|
await Repo.removeMenu(id)
|
||||||
|
if (active?.id === id) setActive(null)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setActive(null)} className="apple-btn-ghost mt-4 mb-6">
|
||||||
|
<ChevronLeft size={16} /> 全部歌单
|
||||||
|
</button>
|
||||||
|
<PageHeader
|
||||||
|
title={active.title}
|
||||||
|
subtitle={`${songs.length} 首 · ${isPcMenu(active.id) ? 'PC 歌单' : '手机歌单'}`}
|
||||||
|
right={
|
||||||
|
songs.length ? (
|
||||||
|
<button onClick={() => playList(songs, 0)} className="apple-btn-primary">
|
||||||
|
<Play size={16} className="fill-current" /> 播放
|
||||||
|
</button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MusicList
|
||||||
|
list={songs}
|
||||||
|
showHeader
|
||||||
|
emptyHint={
|
||||||
|
isPcMenu(active.id)
|
||||||
|
? '这个歌单还是空的'
|
||||||
|
: '歌单为空,或曲目尚未出现在 PC 曲库中(请先「设置 → 从云端更新曲库」)'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneMenus = menus.filter((m) => !isPcMenu(m.id)).sort((a, b) => a.id - b.id)
|
||||||
|
const pcMenus = menus.filter((m) => isPcMenu(m.id)).sort((a, b) => a.id - b.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="歌单"
|
||||||
|
subtitle={`共 ${menus.length} 个 · PC ${pcMenus.length} / 手机 ${phoneMenus.length}`}
|
||||||
|
right={
|
||||||
|
<button onClick={openCreate} className="apple-btn-primary">
|
||||||
|
<Plus size={16} /> 新建 PC 歌单
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<MenuSection
|
||||||
|
title="PC 歌单"
|
||||||
|
hint="本机创建 · id ≤ 100"
|
||||||
|
icon={<Monitor size={18} />}
|
||||||
|
tone="pc"
|
||||||
|
menus={pcMenus}
|
||||||
|
empty="还没有 PC 歌单,点击右上角新建"
|
||||||
|
onOpen={setActive}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
<MenuSection
|
||||||
|
title="手机歌单"
|
||||||
|
hint="由数据同步从手机导入 · id > 100"
|
||||||
|
icon={<Smartphone size={18} />}
|
||||||
|
tone="phone"
|
||||||
|
menus={phoneMenus}
|
||||||
|
empty="还没有手机歌单,请在「数据同步」中从手机导入"
|
||||||
|
onOpen={setActive}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{naming && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 grid place-items-center bg-black/40 backdrop-blur-sm"
|
||||||
|
onClick={() => setNaming(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-80 rounded-2xl bg-[var(--bg)] p-5 shadow-apple-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="mb-3 font-semibold">新建歌单</h3>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') confirmCreate()
|
||||||
|
if (e.key === 'Escape') setNaming(false)
|
||||||
|
}}
|
||||||
|
placeholder="请输入歌单名"
|
||||||
|
className="w-full rounded-lg bg-black/5 px-3 py-2 text-sm outline-none dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button onClick={() => setNaming(false)} className="apple-btn-ghost">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmCreate}
|
||||||
|
disabled={!name.trim()}
|
||||||
|
className="apple-btn-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuSection({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
icon,
|
||||||
|
tone,
|
||||||
|
menus,
|
||||||
|
empty,
|
||||||
|
onOpen,
|
||||||
|
onRemove
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
hint: string
|
||||||
|
icon: ReactNode
|
||||||
|
tone: 'pc' | 'phone'
|
||||||
|
menus: Menu[]
|
||||||
|
empty: string
|
||||||
|
onOpen: (m: Menu) => void
|
||||||
|
onRemove: (id: number) => void
|
||||||
|
}): JSX.Element {
|
||||||
|
const iconWrap =
|
||||||
|
tone === 'pc'
|
||||||
|
? 'bg-apple-blue/10 text-apple-blue'
|
||||||
|
: 'bg-apple-pink/10 text-apple-pink'
|
||||||
|
const headIcon =
|
||||||
|
tone === 'pc'
|
||||||
|
? 'bg-apple-blue/10 text-apple-blue'
|
||||||
|
: 'bg-apple-pink/10 text-apple-pink'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center gap-3">
|
||||||
|
<span className={`grid h-9 w-9 place-items-center rounded-xl ${headIcon}`}>{icon}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-base font-semibold tracking-tight">{title}</h3>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{hint} · {menus.length} 个
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menus.length === 0 ? (
|
||||||
|
<div className="apple-card grid place-items-center gap-2 px-4 py-10 text-sm text-[var(--text-secondary)]">
|
||||||
|
<ListMusic size={28} className="opacity-30" />
|
||||||
|
{empty}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{menus.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => onOpen(m)}
|
||||||
|
className="apple-card group flex cursor-default items-center gap-4 p-4"
|
||||||
|
>
|
||||||
|
<div className={`grid h-14 w-14 place-items-center rounded-xl ${iconWrap}`}>
|
||||||
|
{tone === 'pc' ? <Monitor size={22} /> : <Smartphone size={22} />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-semibold">{m.title || `歌单 ${m.id}`}</p>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">#{m.id}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove(m.id)
|
||||||
|
}}
|
||||||
|
className="text-[var(--text-secondary)] opacity-0 transition-opacity hover:text-apple-red group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
src/renderer/src/pages/Search.tsx
Normal file
102
src/renderer/src/pages/Search.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { SearchX } from 'lucide-react'
|
||||||
|
import type { Album, Music, Menu } from '@shared/models'
|
||||||
|
import { Repo } from '../lib/repository'
|
||||||
|
import MusicList from '../components/MusicList'
|
||||||
|
import AlbumCard from '../components/AlbumCard'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
|
||||||
|
export default function Search(): JSX.Element {
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const q = params.get('q')?.trim() ?? ''
|
||||||
|
const [result, setResult] = useState<{ musics: Music[]; albums: Album[]; menus: Menu[] }>({
|
||||||
|
musics: [],
|
||||||
|
albums: [],
|
||||||
|
menus: []
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
if (!q) {
|
||||||
|
setResult({ musics: [], albums: [], menus: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
Repo.search(q).then((r) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResult(r)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
const total = result.musics.length + result.albums.length + result.menus.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="搜索"
|
||||||
|
subtitle={q ? `“${q}” 的结果 · 共 ${total} 项` : '输入关键词以搜索曲库'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!q ? (
|
||||||
|
<div className="grid place-items-center gap-2 py-24 text-sm text-[var(--text-secondary)]">
|
||||||
|
<SearchX size={40} className="opacity-30" />
|
||||||
|
在顶部搜索框输入歌曲、艺术家、专辑或歌单名
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="grid place-items-center py-24 text-sm text-[var(--text-secondary)]">
|
||||||
|
正在搜索…
|
||||||
|
</div>
|
||||||
|
) : total === 0 ? (
|
||||||
|
<div className="grid place-items-center gap-2 py-24 text-sm text-[var(--text-secondary)]">
|
||||||
|
<SearchX size={40} className="opacity-30" />
|
||||||
|
没有找到与 “{q}” 相关的内容
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{result.albums.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-3 text-lg font-bold">专辑 · {result.albums.length}</h3>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,200px))] gap-5">
|
||||||
|
{result.albums.map((a) => (
|
||||||
|
<AlbumCard key={a.albumUId} album={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.menus.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-3 text-lg font-bold">歌单 · {result.menus.length}</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{result.menus.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => navigate('/playlists')}
|
||||||
|
className="apple-btn-ghost"
|
||||||
|
>
|
||||||
|
{m.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.musics.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-3 text-lg font-bold">歌曲 · {result.musics.length}</h3>
|
||||||
|
<MusicList list={result.musics} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
452
src/renderer/src/pages/Settings.tsx
Normal file
452
src/renderer/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
HardDrive,
|
||||||
|
Palette,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Monitor,
|
||||||
|
RefreshCw,
|
||||||
|
Info,
|
||||||
|
CloudDownload,
|
||||||
|
Link2,
|
||||||
|
Circle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
|
||||||
|
const ACCENTS = ['#0A84FF', '#FF375F', '#BF5AF2', '#30D158', '#FF9F0A', '#FF453A', '#64D2FF']
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="apple-card mb-4 p-5">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Icon size={18} className="text-apple-blue" />
|
||||||
|
<h3 className="font-semibold">{title}</h3>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings(): JSX.Element {
|
||||||
|
const { theme, accent, setTheme, setAccent } = useUIStore()
|
||||||
|
const loadLibrary = useLibraryStore((s) => s.loadAll)
|
||||||
|
const [root, setRoot] = useState('')
|
||||||
|
const [port, setPort] = useState(10000)
|
||||||
|
const [serverStatus, setServerStatus] = useState<{ running: boolean; port: number }>({
|
||||||
|
running: false,
|
||||||
|
port: 0
|
||||||
|
})
|
||||||
|
const [dataVersion, setDataVersion] = useState('')
|
||||||
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
const [syncMsg, setSyncMsg] = useState('')
|
||||||
|
const [remoteUrl, setRemoteUrl] = useState('')
|
||||||
|
/** 本地 / 远程互斥:决定封面与音频从哪加载 */
|
||||||
|
const [sourceMode, setSourceMode] = useState<'local' | 'remote'>('local')
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [update, setUpdate] = useState<{
|
||||||
|
version: string
|
||||||
|
latest: string
|
||||||
|
hasUpdate: boolean
|
||||||
|
message: string
|
||||||
|
url: string
|
||||||
|
error?: boolean
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
setRoot((await window.api.store.get<string>('serverPath')) || '')
|
||||||
|
setPort((await window.api.store.get<number>('serverPort')) || 10000)
|
||||||
|
setServerStatus(await window.api.http.status())
|
||||||
|
setDataVersion((await window.api.library.dataVersion()) || '未同步')
|
||||||
|
const url = (await window.api.store.get<string>('url')) || ''
|
||||||
|
setRemoteUrl(url)
|
||||||
|
setSourceMode(url.trim() ? 'remote' : 'local')
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const syncLibrary = async (): Promise<void> => {
|
||||||
|
setSyncing(true)
|
||||||
|
setSyncMsg('正在从云端拉取曲库数据…')
|
||||||
|
try {
|
||||||
|
const r = await window.api.library.sync()
|
||||||
|
setDataVersion(String(r.version))
|
||||||
|
setSyncMsg(`更新成功:${r.albums} 张专辑 / ${r.musics} 首歌曲`)
|
||||||
|
await loadLibrary({ force: true })
|
||||||
|
} catch (e) {
|
||||||
|
setSyncMsg('拉取失败,请检查网络后重试')
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkUpdate = async (): Promise<void> => {
|
||||||
|
setChecking(true)
|
||||||
|
try {
|
||||||
|
setUpdate(await window.api.update.check())
|
||||||
|
} finally {
|
||||||
|
setChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sourceBusy, setSourceBusy] = useState(false)
|
||||||
|
const [sourceMsg, setSourceMsg] = useState('')
|
||||||
|
|
||||||
|
const chooseDir = async (): Promise<void> => {
|
||||||
|
const dir = await window.api.dialog.openDir()
|
||||||
|
if (dir) {
|
||||||
|
setRoot(dir)
|
||||||
|
await window.api.store.set('serverPath', dir)
|
||||||
|
setSourceMsg('已选择目录,点击「应用并启动」即可提供封面与音频')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切到本地:清空远程 url,与远程互斥 */
|
||||||
|
const switchToLocal = async (): Promise<void> => {
|
||||||
|
if (sourceMode === 'local') return
|
||||||
|
setSourceMode('local')
|
||||||
|
setSourceMsg('')
|
||||||
|
await window.api.store.set('url', '')
|
||||||
|
setRemoteUrl('')
|
||||||
|
await usePlayerStore.getState().init()
|
||||||
|
setSourceMsg('已切换为本地文件服务(远程地址已停用)')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切到远程:停止本地 HTTP,与本地互斥 */
|
||||||
|
const switchToRemote = async (): Promise<void> => {
|
||||||
|
if (sourceMode === 'remote') return
|
||||||
|
setSourceMode('remote')
|
||||||
|
setSourceMsg('')
|
||||||
|
try {
|
||||||
|
await window.api.http.stop()
|
||||||
|
setServerStatus(await window.api.http.status())
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
setSourceMsg('已切换为远程地址,请填写并保存;本地服务已停止')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地:清远程 → 存目录/端口 → 启动服务 */
|
||||||
|
const applyAndStart = async (): Promise<void> => {
|
||||||
|
if (!root) {
|
||||||
|
setSourceMsg('请先选择本地曲库目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSourceBusy(true)
|
||||||
|
setSourceMsg('')
|
||||||
|
try {
|
||||||
|
await window.api.store.set('url', '')
|
||||||
|
setRemoteUrl('')
|
||||||
|
await window.api.store.set('serverPath', root)
|
||||||
|
await window.api.store.set('serverPort', port)
|
||||||
|
const status = await window.api.http.start(root, port)
|
||||||
|
setServerStatus(status)
|
||||||
|
await usePlayerStore.getState().init()
|
||||||
|
setSourceMsg(
|
||||||
|
status.running
|
||||||
|
? `本地服务已就绪 · http://127.0.0.1:${status.port}`
|
||||||
|
: '启动未成功,请检查目录与端口'
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setSourceMsg('启动失败,请检查目录是否可访问、端口是否被占用')
|
||||||
|
} finally {
|
||||||
|
setSourceBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 远程:保存 url,并确保本地服务已停 */
|
||||||
|
const saveRemoteUrl = async (): Promise<void> => {
|
||||||
|
const url = remoteUrl.trim()
|
||||||
|
if (!url) {
|
||||||
|
setSourceMsg('请填写远程曲库地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSourceBusy(true)
|
||||||
|
setSourceMsg('')
|
||||||
|
try {
|
||||||
|
await window.api.store.set('url', url.endsWith('/') ? url : `${url}/`)
|
||||||
|
setRemoteUrl(url.endsWith('/') ? url : `${url}/`)
|
||||||
|
try {
|
||||||
|
await window.api.http.stop()
|
||||||
|
setServerStatus(await window.api.http.status())
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
await usePlayerStore.getState().init()
|
||||||
|
setSourceMsg('已保存远程地址,播放将使用该来源')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setSourceMsg('保存失败')
|
||||||
|
} finally {
|
||||||
|
setSourceBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localReady = sourceMode === 'local' && serverStatus.running && !!root
|
||||||
|
const localEndpoint = serverStatus.running
|
||||||
|
? `http://127.0.0.1:${serverStatus.port}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="mb-6 mt-4 text-3xl font-bold tracking-tight">设置</h2>
|
||||||
|
|
||||||
|
<Section icon={CloudDownload} title="曲库数据">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={syncLibrary}
|
||||||
|
disabled={syncing}
|
||||||
|
className={clsx('apple-btn-primary', syncing && 'opacity-50')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={clsx(syncing && 'animate-spin')} />
|
||||||
|
{syncing ? '同步中…' : '从云端更新曲库'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
当前数据版本:{dataVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{syncMsg && <p className="mt-3 text-xs text-apple-blue">{syncMsg}</p>}
|
||||||
|
<p className="mt-3 text-xs text-[var(--text-secondary)]">
|
||||||
|
云端仅提供曲库「元数据 + 歌词」。封面与音频请在下方「封面与音频来源」中配置(本地 / 远程二选一)。
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section icon={HardDrive} title="封面与音频来源">
|
||||||
|
{/* 本地 / 远程互斥分段 */}
|
||||||
|
<div className="mb-5 flex rounded-xl bg-black/5 p-1 dark:bg-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void switchToLocal()}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-all',
|
||||||
|
sourceMode === 'local'
|
||||||
|
? 'bg-[var(--bg)] text-[var(--text)] shadow-sm'
|
||||||
|
: 'text-[var(--text-secondary)] hover:text-[var(--text)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HardDrive size={15} /> 本地文件服务
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void switchToRemote()}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-all',
|
||||||
|
sourceMode === 'remote'
|
||||||
|
? 'bg-[var(--bg)] text-[var(--text)] shadow-sm'
|
||||||
|
: 'text-[var(--text-secondary)] hover:text-[var(--text)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link2 size={15} /> 远程地址
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sourceMode === 'local' ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'mb-5 flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-sm',
|
||||||
|
localReady
|
||||||
|
? 'bg-apple-green/10 text-apple-green'
|
||||||
|
: root
|
||||||
|
? 'bg-apple-orange/10 text-apple-orange'
|
||||||
|
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
size={8}
|
||||||
|
className={clsx('fill-current', localReady ? 'text-apple-green' : 'opacity-50')}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">
|
||||||
|
{localReady ? '服务运行中' : root ? '目录已选,服务未启动' : '尚未配置本地曲库'}
|
||||||
|
</span>
|
||||||
|
{localEndpoint && (
|
||||||
|
<code className="ml-auto truncate text-xs opacity-90">{localEndpoint}</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm text-[var(--text-secondary)]">曲库目录</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={root}
|
||||||
|
readOnly
|
||||||
|
placeholder="选择包含 LoveLive 文件夹的目录"
|
||||||
|
className="min-w-0 flex-1 rounded-lg bg-black/5 px-3 py-2 text-sm dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={chooseDir} className="apple-btn-ghost shrink-0">
|
||||||
|
选择目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm text-[var(--text-secondary)]">HTTP 端口</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10000}
|
||||||
|
max={65535}
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(Number(e.target.value))}
|
||||||
|
className="w-28 rounded-lg bg-black/5 px-3 py-2 text-sm dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyAndStart}
|
||||||
|
disabled={sourceBusy || !root}
|
||||||
|
className={clsx('apple-btn-primary', (sourceBusy || !root) && 'opacity-50')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={clsx(sourceBusy && 'animate-spin')} />
|
||||||
|
{serverStatus.running ? '应用并重启' : '应用并启动'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-[var(--text-secondary)]">
|
||||||
|
选择包含{' '}
|
||||||
|
<code className="rounded bg-black/10 px-1 dark:bg-white/10">LoveLive</code>{' '}
|
||||||
|
的目录(也可直接选中 LoveLive)。启用本地模式时不会使用远程地址。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-5 flex items-center gap-2 rounded-xl bg-apple-blue/10 px-3.5 py-2.5 text-sm text-apple-blue">
|
||||||
|
<Link2 size={14} />
|
||||||
|
<span className="font-medium">使用远程 HTTP 提供封面与音频</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={remoteUrl}
|
||||||
|
onChange={(e) => setRemoteUrl(e.target.value)}
|
||||||
|
placeholder="例如 http://192.168.1.10:10000/"
|
||||||
|
className="min-w-0 flex-1 rounded-lg bg-black/5 px-3 py-2 text-sm dark:bg-white/10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveRemoteUrl}
|
||||||
|
disabled={sourceBusy}
|
||||||
|
className={clsx('apple-btn-primary shrink-0', sourceBusy && 'opacity-50')}
|
||||||
|
>
|
||||||
|
保存并启用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-[var(--text-secondary)]">
|
||||||
|
启用远程后将停止本机文件服务;切回「本地文件服务」会清空远程地址。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sourceMsg && <p className="mt-3 text-xs text-apple-blue">{sourceMsg}</p>}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section icon={Palette} title="外观">
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="mb-2 text-sm text-[var(--text-secondary)]">主题</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ key: 'light', label: '浅色', icon: Sun },
|
||||||
|
{ key: 'dark', label: '深色', icon: Moon },
|
||||||
|
{ key: 'system', label: '跟随系统', icon: Monitor }
|
||||||
|
].map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setTheme(key as 'light' | 'dark' | 'system')}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 rounded-xl px-4 py-2 text-sm transition-all',
|
||||||
|
theme === key
|
||||||
|
? 'bg-apple-blue text-white'
|
||||||
|
: 'bg-black/5 dark:bg-white/10 text-[var(--text-secondary)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={15} /> {label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm text-[var(--text-secondary)]">强调色</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{ACCENTS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setAccent(c)}
|
||||||
|
className={clsx(
|
||||||
|
'h-8 w-8 rounded-full transition-transform',
|
||||||
|
accent === c && 'ring-2 ring-offset-2 ring-offset-[var(--bg)] scale-110'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c, boxShadow: accent === c ? `0 0 0 2px ${c}` : undefined }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section icon={Info} title="关于与更新">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={checkUpdate}
|
||||||
|
disabled={checking}
|
||||||
|
className={clsx('apple-btn-primary', checking && 'opacity-50')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={clsx(checking && 'animate-spin')} />
|
||||||
|
{checking ? '检查中…' : '检查更新'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
当前版本 v{update?.version ?? '2.0.0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{update && !checking && (
|
||||||
|
<div className="mt-3 text-xs">
|
||||||
|
{update.error ? (
|
||||||
|
<p className="text-apple-red">{update.message}</p>
|
||||||
|
) : update.hasUpdate ? (
|
||||||
|
<div className="rounded-xl bg-black/5 p-3 dark:bg-white/10">
|
||||||
|
<p className="font-semibold text-apple-blue">
|
||||||
|
发现新版本 v{update.latest}
|
||||||
|
</p>
|
||||||
|
{update.message && (
|
||||||
|
<p className="mt-1 whitespace-pre-line text-[var(--text-secondary)]">
|
||||||
|
{update.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{update.url && (
|
||||||
|
<button
|
||||||
|
onClick={() => window.api.update.openDownload(update.url)}
|
||||||
|
className="apple-btn-primary mt-3"
|
||||||
|
>
|
||||||
|
<CloudDownload size={14} /> 前往下载
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-apple-green">已是最新版本</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-[var(--text-secondary)]">
|
||||||
|
LoveLiveMusicPlayer 桌面端 · Apple 风格重构版
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
|
Electron + Vite + React · 与移动端共享协议层(transVer 1)
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
353
src/renderer/src/pages/Sync.tsx
Normal file
353
src/renderer/src/pages/Sync.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { CheckCircle2, Smartphone, RefreshCw } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { TransData, TransLove, TransMenu } from '@shared/protocol'
|
||||||
|
import { DataCmd } from '@shared/protocol'
|
||||||
|
import { MENU_ID } from '@shared/models'
|
||||||
|
import { Repo } from '../lib/repository'
|
||||||
|
import { useLanStore } from '../stores/lanStore'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
import IpSelect from '../components/IpSelect'
|
||||||
|
import { MENU_SYNCED_EVENT } from '../lib/events'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据同步页(端口 4389)
|
||||||
|
* 与原项目一致:PC 只展示说明 + 二维码;方向与全量覆盖均在手机端发起。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseIdList(raw: unknown): string[] {
|
||||||
|
if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const s = raw.trim()
|
||||||
|
if (!s) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(s) as unknown
|
||||||
|
if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean)
|
||||||
|
} catch {
|
||||||
|
/* 单 id */
|
||||||
|
}
|
||||||
|
return [s]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTransData(body: string): TransData | null {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(body) as {
|
||||||
|
love?: unknown[]
|
||||||
|
menu?: unknown[]
|
||||||
|
isCover?: boolean
|
||||||
|
}
|
||||||
|
const love: TransLove[] = (raw.love || [])
|
||||||
|
.map((item) => {
|
||||||
|
const o = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
musicId: String(o.musicId ?? o.musicUId ?? ''),
|
||||||
|
timestamp: Number(o.timestamp ?? o.createTime ?? Date.now()) || Date.now(),
|
||||||
|
id: typeof o.id === 'number' ? o.id : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((l) => l.musicId)
|
||||||
|
const menu: TransMenu[] = (raw.menu || [])
|
||||||
|
.map((item) => {
|
||||||
|
const o = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
menuId: Number(o.menuId ?? o.id ?? 0),
|
||||||
|
name: String(o.name ?? o.title ?? ''),
|
||||||
|
date: String(o.date ?? ''),
|
||||||
|
musicList: parseIdList(o.musicList ?? o.musicUIds ?? o.music)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((m) => Number.isFinite(m.menuId) && m.menuId > 0)
|
||||||
|
return { love, menu, isCover: !!raw.isCover }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sync(): JSX.Element {
|
||||||
|
const ips = useLanStore((s) => s.ips)
|
||||||
|
const ipIndex = useLanStore((s) => s.ipIndex)
|
||||||
|
const setIpIndex = useLanStore((s) => s.setIpIndex)
|
||||||
|
const loadLan = useLanStore((s) => s.load)
|
||||||
|
const loadLibrary = useLibraryStore((s) => s.loadAll)
|
||||||
|
const refreshLoves = usePlayerStore((s) => s.refreshLoves)
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [status, setStatus] = useState('等待手机扫码连接…')
|
||||||
|
const connectedRef = useRef(false)
|
||||||
|
|
||||||
|
const notifyMenusChanged = async (): Promise<void> => {
|
||||||
|
await loadLibrary({ force: true })
|
||||||
|
await refreshLoves()
|
||||||
|
window.dispatchEvent(new CustomEvent(MENU_SYNCED_EVENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除手机歌单(id > 100)或全部歌单 —— 与原项目 removeAllMenu / deletePhoneMenu 一致 */
|
||||||
|
const clearMenus = async (all: boolean): Promise<void> => {
|
||||||
|
const menus = await Repo.allMenus()
|
||||||
|
for (const m of menus) {
|
||||||
|
if (all || m.id > MENU_ID.PC_MAX) await Repo.removeMenu(m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入手机下发的歌单。
|
||||||
|
* 原项目会对 payload 内每一项 insertPhoneMenu(含全量覆盖时的 PC 歌单),
|
||||||
|
* 此处不再按 id 过滤;歌曲关联尽量写入,缺曲库条目也不丢弃歌单本身。
|
||||||
|
*/
|
||||||
|
const importMenusFromPhone = async (
|
||||||
|
menus: TransMenu[]
|
||||||
|
): Promise<{ menus: number; tracks: number }> => {
|
||||||
|
let trackCount = 0
|
||||||
|
for (const menu of menus) {
|
||||||
|
const title = menu.name?.trim() || `歌单 ${menu.menuId}`
|
||||||
|
await Repo.createMenu(menu.menuId, title)
|
||||||
|
await window.api.db.exec('DELETE FROM playlist_music WHERE menuId = ?', [menu.menuId])
|
||||||
|
for (let i = 0; i < menu.musicList.length; i++) {
|
||||||
|
const uid = menu.musicList[i]
|
||||||
|
if (!uid) continue
|
||||||
|
await window.api.db.exec(
|
||||||
|
'INSERT OR IGNORE INTO playlist_music (menuId, musicUId, "order") VALUES (?,?,?)',
|
||||||
|
[menu.menuId, uid, i]
|
||||||
|
)
|
||||||
|
trackCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { menus: menus.length, tracks: trackCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并我喜欢:覆盖用对端;否则取并集,再整表重写 */
|
||||||
|
const mergeAndReplaceLoves = async (
|
||||||
|
phoneLoves: TransLove[],
|
||||||
|
isCover: boolean,
|
||||||
|
preferPhone: boolean
|
||||||
|
): Promise<TransLove[]> => {
|
||||||
|
const local = await Repo.allLoves()
|
||||||
|
let finalList: TransLove[]
|
||||||
|
|
||||||
|
if (isCover) {
|
||||||
|
if (preferPhone) {
|
||||||
|
finalList = phoneLoves
|
||||||
|
} else {
|
||||||
|
finalList = local.map((l) => ({
|
||||||
|
musicId: l.musicUId,
|
||||||
|
timestamp: l.createTime || Date.now()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const l of local) map.set(l.musicUId, l.createTime || Date.now())
|
||||||
|
for (const l of phoneLoves) {
|
||||||
|
if (!map.has(l.musicId)) map.set(l.musicId, l.timestamp || Date.now())
|
||||||
|
}
|
||||||
|
finalList = [...map.entries()].map(([musicId, timestamp]) => ({ musicId, timestamp }))
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.api.db.exec('DELETE FROM love', [])
|
||||||
|
for (const item of finalList) {
|
||||||
|
const rows = await window.api.db.query<{ musicUId: string }>(
|
||||||
|
'SELECT musicUId FROM music WHERE musicUId = ? LIMIT 1',
|
||||||
|
[item.musicId]
|
||||||
|
)
|
||||||
|
if (!rows.length) continue
|
||||||
|
await window.api.db.exec(
|
||||||
|
'INSERT OR IGNORE INTO love (musicUId, createTime) VALUES (?, ?)',
|
||||||
|
[item.musicId, item.timestamp || Date.now()]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = await Repo.allLoves()
|
||||||
|
return kept.map((l) => ({
|
||||||
|
musicId: l.musicUId,
|
||||||
|
timestamp: l.createTime || Date.now()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 电脑 → 手机:收集歌单(仅含 recommend 曲目,与原项目 export 过滤一致) */
|
||||||
|
const buildPcMenusForPhone = async (isCover: boolean): Promise<TransMenu[]> => {
|
||||||
|
const menus = isCover ? await Repo.allMenus() : await Repo.pcMenus()
|
||||||
|
const result: TransMenu[] = []
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (!isCover && menu.id > MENU_ID.PC_MAX) continue
|
||||||
|
const ids = await Repo.menuMusicIds(menu.id)
|
||||||
|
if (!ids.length) continue
|
||||||
|
const ph = ids.map(() => '?').join(',')
|
||||||
|
const rows = await window.api.db.query<{ musicUId: string; recommend: number }>(
|
||||||
|
`SELECT musicUId, recommend FROM music WHERE musicUId IN (${ph})`,
|
||||||
|
ids
|
||||||
|
)
|
||||||
|
const allowed = new Set(rows.filter((r) => r.recommend).map((r) => r.musicUId))
|
||||||
|
const musicList = ids.filter((id) => allowed.has(id))
|
||||||
|
if (!musicList.length) continue
|
||||||
|
result.push({
|
||||||
|
menuId: menu.id,
|
||||||
|
name: menu.title,
|
||||||
|
date: menu.createTime ? new Date(menu.createTime).toISOString().slice(0, 10) : '',
|
||||||
|
musicList
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = async (
|
||||||
|
cmd: string,
|
||||||
|
love: TransLove[],
|
||||||
|
menu: TransMenu[],
|
||||||
|
isCover: boolean
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const payload: TransData = { love, menu, isCover }
|
||||||
|
return window.api.dataServer.send(cmd, JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCmdRef = useRef<(cmd: string, body: string) => Promise<void>>(async () => {})
|
||||||
|
|
||||||
|
handleCmdRef.current = async (cmd: string, body: string): Promise<void> => {
|
||||||
|
if (cmd === DataCmd.CONNECTED) {
|
||||||
|
setConnected(true)
|
||||||
|
connectedRef.current = true
|
||||||
|
setStatus('设备已连接,请在手机上选择同步方向')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cmd === DataCmd.FINISH || cmd === DataCmd.STOP) {
|
||||||
|
setBusy(false)
|
||||||
|
setStatus(
|
||||||
|
connectedRef.current ? '设备已连接,可再次在手机上发起同步' : '等待手机扫码连接…'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd !== DataCmd.PHONE_TO_PC && cmd !== DataCmd.PC_TO_PHONE) return
|
||||||
|
|
||||||
|
const data = parseTransData(body)
|
||||||
|
if (!data) {
|
||||||
|
setStatus('解析手机数据失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (cmd === DataCmd.PHONE_TO_PC) {
|
||||||
|
setStatus('正在导入手机数据…')
|
||||||
|
await clearMenus(data.isCover)
|
||||||
|
const imported = await importMenusFromPhone(data.menu)
|
||||||
|
const love = await mergeAndReplaceLoves(data.love, data.isCover, true)
|
||||||
|
const ok = await reply(DataCmd.PHONE_TO_PC, love, data.menu, data.isCover)
|
||||||
|
await notifyMenusChanged()
|
||||||
|
setStatus(
|
||||||
|
ok
|
||||||
|
? `同步完成:已导入 ${imported.menus} 个歌单(${imported.tracks} 首关联),请到「歌单」查看`
|
||||||
|
: '回传失败,但本地歌单可能已写入,请到「歌单」查看'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setStatus('正在准备发送到手机…')
|
||||||
|
const menu = await buildPcMenusForPhone(data.isCover)
|
||||||
|
const love = await mergeAndReplaceLoves(data.love, data.isCover, false)
|
||||||
|
const ok = await reply(DataCmd.PC_TO_PHONE, love, menu, data.isCover)
|
||||||
|
await notifyMenusChanged()
|
||||||
|
setStatus(
|
||||||
|
ok
|
||||||
|
? `同步完成:已回传 ${menu.length} 个歌单到手机`
|
||||||
|
: '回传失败,连接已断开'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setStatus(`同步失败:${(e as Error).message || '未知错误'}`)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false
|
||||||
|
const setup = async (): Promise<void> => {
|
||||||
|
await loadLan()
|
||||||
|
if (!disposed) await window.api.dataServer.start()
|
||||||
|
}
|
||||||
|
setup()
|
||||||
|
|
||||||
|
const off = window.api.dataServer.onEvent((e: unknown) => {
|
||||||
|
const ev = e as { type: string; cmd?: string; body?: string }
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'connected':
|
||||||
|
setConnected(true)
|
||||||
|
connectedRef.current = true
|
||||||
|
setStatus('设备已连接,请在手机上选择同步方向')
|
||||||
|
break
|
||||||
|
case 'disconnected':
|
||||||
|
setConnected(false)
|
||||||
|
connectedRef.current = false
|
||||||
|
setBusy(false)
|
||||||
|
setStatus('等待手机扫码连接…')
|
||||||
|
break
|
||||||
|
case 'versionMismatch':
|
||||||
|
setStatus('PC 与 APP 版本不匹配,请更新')
|
||||||
|
break
|
||||||
|
case 'cmd':
|
||||||
|
void handleCmdRef.current(ev.cmd!, ev.body || '')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
off()
|
||||||
|
window.api.dataServer.stop()
|
||||||
|
}
|
||||||
|
}, [loadLan])
|
||||||
|
|
||||||
|
const qrValue = ips[ipIndex]?.address ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="数据同步" subtitle="我喜欢 / 歌单 · 端口 4389 · 由手机发起" />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<div className="apple-card flex w-72 shrink-0 flex-col items-center gap-4 p-6">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'grid h-9 w-9 place-items-center rounded-full',
|
||||||
|
connected ? 'bg-apple-green/15 text-apple-green' : 'bg-apple-blue/15 text-apple-blue'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{connected ? <CheckCircle2 size={20} /> : <RefreshCw size={20} />}
|
||||||
|
</div>
|
||||||
|
{connected ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-6">
|
||||||
|
<Smartphone size={48} className="text-apple-green" />
|
||||||
|
<p className="text-sm font-medium">设备已连接</p>
|
||||||
|
{busy && <p className="text-xs text-apple-blue">同步处理中…</p>}
|
||||||
|
</div>
|
||||||
|
) : qrValue ? (
|
||||||
|
<div className="rounded-2xl bg-white p-3">
|
||||||
|
<QRCodeSVG value={qrValue} size={180} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-[204px] w-[204px] place-items-center text-sm text-[var(--text-secondary)]">
|
||||||
|
未检测到局域网
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<IpSelect ips={ips} value={ipIndex} onChange={setIpIndex} />
|
||||||
|
<p className="w-full break-words text-center text-xs text-[var(--text-secondary)]">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="apple-card min-w-0 flex-1 space-y-4 p-6 text-sm leading-relaxed">
|
||||||
|
<p className="font-semibold">重要事项:数据同步用于同步「我喜欢」和歌单</p>
|
||||||
|
<ul className="list-disc space-y-2 pl-5 text-[var(--text-secondary)]">
|
||||||
|
<li>请在手机端扫码连接,并在手机上选择「手机 ≫ 电脑」或「电脑 ≫ 手机」</li>
|
||||||
|
<li>我喜欢:两端共存,默认融合双端数据;全量覆盖开关在手机端</li>
|
||||||
|
<li>歌单:手机与电脑相互独立;导入后请到左侧「歌单」查看(手机歌单 id>100)</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
PC 端无需点击发送;连接成功后操作全部在手机完成。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
682
src/renderer/src/pages/Transfer.tsx
Normal file
682
src/renderer/src/pages/Transfer.tsx
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import {
|
||||||
|
Wifi,
|
||||||
|
Smartphone,
|
||||||
|
CheckCircle2,
|
||||||
|
HardDrive,
|
||||||
|
Download
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type { Album, Music } from '@shared/models'
|
||||||
|
import type { DownloadMusic } from '@shared/protocol'
|
||||||
|
import { MusicCmd, DOWNLOAD_BODY_SEPARATOR } from '@shared/protocol'
|
||||||
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { useLanStore } from '../stores/lanStore'
|
||||||
|
import { buildFileUrl } from '../lib/repository'
|
||||||
|
import { GROUPS } from '../lib/const'
|
||||||
|
import PageHeader from '../components/PageHeader'
|
||||||
|
import IpSelect from '../components/IpSelect'
|
||||||
|
import SoftScrollArea from '../components/SoftScrollArea'
|
||||||
|
|
||||||
|
const GROUP_ORDER = new Map(GROUPS.map((g, i) => [g.key, i]))
|
||||||
|
|
||||||
|
function groupRank(group?: string): number {
|
||||||
|
if (!group) return 999
|
||||||
|
return GROUP_ORDER.get(group) ?? 999
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseRank(date?: string): number {
|
||||||
|
if (!date) return Number.MAX_SAFE_INTEGER
|
||||||
|
const t = Date.parse(date)
|
||||||
|
return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 秒数 → "mm:ss"(对齐移动端 totalTime) */
|
||||||
|
function formatTotalTime(sec?: number, fallback?: string): string {
|
||||||
|
if (fallback) return fallback
|
||||||
|
if (sec == null || !Number.isFinite(sec) || sec < 0) return '00:00'
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.floor(sec % 60)
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装移动端 DownloadMusic 全字段。
|
||||||
|
* 缺字段会导致 Flutter fromJson 抛错,手机永远不回 musicList。
|
||||||
|
*/
|
||||||
|
function toDownloadMusic(m: Music, album?: Album): DownloadMusic {
|
||||||
|
return {
|
||||||
|
albumUId: m.albumUId,
|
||||||
|
albumId: album?.albumId ?? 0,
|
||||||
|
albumName: album?.albumName ?? '',
|
||||||
|
coverPath: m.coverPath,
|
||||||
|
date: album?.releaseDate ?? '',
|
||||||
|
category: album?.category ?? '',
|
||||||
|
group: m.group || album?.group || '',
|
||||||
|
musicUId: m.musicUId,
|
||||||
|
musicId: m.musicId ?? m.index ?? 0,
|
||||||
|
musicName: m.musicName,
|
||||||
|
musicPath: m.musicPath,
|
||||||
|
artist: m.artist || '',
|
||||||
|
artistBin: m.artistBin || '',
|
||||||
|
totalTime: formatTotalTime(m.duration, m.time),
|
||||||
|
baseUrl: m.baseUrl,
|
||||||
|
neteaseId: m.neteaseId,
|
||||||
|
existFile: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手机 musicList body 可能是 string[](uid)或旧版对象数组 */
|
||||||
|
function parseMissingMusicIds(body: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as unknown
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
if (item && typeof item === 'object' && 'musicUId' in item) {
|
||||||
|
return String((item as { musicUId: string }).musicUId)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼本地文件绝对路径(兼容 Windows,供已有 convert.start 使用) */
|
||||||
|
function mediaAbsPath(root: string, baseUrl: string, musicPath: string): string {
|
||||||
|
const parts = [root, baseUrl, musicPath]
|
||||||
|
.map((p) => p.replace(/[/\\]+/g, '/').replace(/^\/+|\/+$/g, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
const joined = parts.join('/')
|
||||||
|
return root.includes('\\') ? joined.replace(/\//g, '\\') : joined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iOS:用已有 convert API 在 flac 旁生成 wav(不依赖 preload 新增命名空间) */
|
||||||
|
async function ensureIosWavWithConvert(list: DownloadMusic[]): Promise<void> {
|
||||||
|
const http = await window.api.http.status()
|
||||||
|
if (!http?.root) throw new Error('本地曲库根目录未知,请先启动本地文件服务')
|
||||||
|
for (const m of list) {
|
||||||
|
if (!/\.flac$/i.test(m.musicPath)) continue
|
||||||
|
const src = mediaAbsPath(http.root, m.baseUrl, m.musicPath)
|
||||||
|
const dest = src.replace(/\.flac$/i, '.wav')
|
||||||
|
await window.api.convert.start(src, dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Transfer(): JSX.Element {
|
||||||
|
const { music, albums, loadAll } = useLibraryStore()
|
||||||
|
const httpBase = usePlayerStore((s) => s.httpBase)
|
||||||
|
const ips = useLanStore((s) => s.ips)
|
||||||
|
const ipIndex = useLanStore((s) => s.ipIndex)
|
||||||
|
const setIpIndex = useLanStore((s) => s.setIpIndex)
|
||||||
|
const loadLan = useLanStore((s) => s.load)
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [phoneSystem, setPhoneSystem] = useState('')
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [overwrite, setOverwrite] = useState(false)
|
||||||
|
const [recommendOn, setRecommendOn] = useState(false)
|
||||||
|
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||||
|
/** 连接区文案(与导出/任务提示分离,避免导出结果盖住扫码状态) */
|
||||||
|
const [connStatus, setConnStatus] = useState('等待手机扫码连接…')
|
||||||
|
/** 导出 / WiFi 任务提示(显示在操作按钮旁) */
|
||||||
|
const [jobNotice, setJobNotice] = useState('')
|
||||||
|
const [progressMode, setProgressMode] = useState<'idle' | 'wifi' | 'export'>('idle')
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const finalListRef = useRef<DownloadMusic[]>([])
|
||||||
|
const overwriteRef = useRef(false)
|
||||||
|
const phoneSystemRef = useRef('')
|
||||||
|
const albumById = useMemo(() => {
|
||||||
|
const map = new Map<string, Album>()
|
||||||
|
for (const a of albums) map.set(a.albumUId, a)
|
||||||
|
return map
|
||||||
|
}, [albums])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
overwriteRef.current = overwrite
|
||||||
|
}, [overwrite])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
phoneSystemRef.current = phoneSystem
|
||||||
|
}, [phoneSystem])
|
||||||
|
|
||||||
|
const albumGroups = useMemo(() => {
|
||||||
|
const map = new Map<string, { album?: Album; songs: Music[] }>()
|
||||||
|
for (const m of music) {
|
||||||
|
const g = map.get(m.albumUId) ?? {
|
||||||
|
album: albums.find((a) => a.albumUId === m.albumUId),
|
||||||
|
songs: []
|
||||||
|
}
|
||||||
|
g.songs.push(m)
|
||||||
|
map.set(m.albumUId, g)
|
||||||
|
}
|
||||||
|
for (const g of map.values()) {
|
||||||
|
g.songs.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
||||||
|
}
|
||||||
|
// 企划顺序(GROUPS)→ 发售日期升序 → 专辑名
|
||||||
|
return [...map.values()].sort((a, b) => {
|
||||||
|
const ga = a.album?.group ?? a.songs[0]?.group ?? ''
|
||||||
|
const gb = b.album?.group ?? b.songs[0]?.group ?? ''
|
||||||
|
const byGroup = groupRank(ga) - groupRank(gb)
|
||||||
|
if (byGroup !== 0) return byGroup
|
||||||
|
const byDate = releaseRank(a.album?.releaseDate) - releaseRank(b.album?.releaseDate)
|
||||||
|
if (byDate !== 0) return byDate
|
||||||
|
return (a.album?.albumName ?? '').localeCompare(b.album?.albumName ?? '', 'zh')
|
||||||
|
})
|
||||||
|
}, [music, albums])
|
||||||
|
|
||||||
|
const recommendedIds = useMemo(
|
||||||
|
() => music.filter((m) => m.recommend).map((m) => m.musicUId),
|
||||||
|
[music]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 推荐勾选状态与原项目一致:所有 recommend 曲目是否都已选中
|
||||||
|
useEffect(() => {
|
||||||
|
if (recommendedIds.length === 0) {
|
||||||
|
setRecommendOn(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRecommendOn(recommendedIds.every((id) => selected.has(id)))
|
||||||
|
}, [selected, recommendedIds])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll()
|
||||||
|
window.api.store.get<string[]>('transMusic').then((ids) => {
|
||||||
|
if (ids?.length) setSelected(new Set(ids))
|
||||||
|
})
|
||||||
|
|
||||||
|
let disposed = false
|
||||||
|
const setup = async (): Promise<void> => {
|
||||||
|
await loadLan()
|
||||||
|
if (!disposed) await window.api.musicServer.start()
|
||||||
|
}
|
||||||
|
setup()
|
||||||
|
|
||||||
|
const off = window.api.musicServer.onEvent((e: unknown) => {
|
||||||
|
const ev = e as {
|
||||||
|
type: string
|
||||||
|
system?: string
|
||||||
|
cmd?: string
|
||||||
|
body?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'connected':
|
||||||
|
setConnected(true)
|
||||||
|
setConnStatus('设备已连接,正在握手…')
|
||||||
|
break
|
||||||
|
case 'disconnected':
|
||||||
|
setConnected(false)
|
||||||
|
setPhoneSystem('')
|
||||||
|
setConnStatus('等待手机扫码连接…')
|
||||||
|
setJobNotice('')
|
||||||
|
setProgressMode('idle')
|
||||||
|
setProgress({ current: 0, total: 0 })
|
||||||
|
setExporting(false)
|
||||||
|
finalListRef.current = []
|
||||||
|
break
|
||||||
|
case 'versionMismatch':
|
||||||
|
setConnStatus('PC 与 APP 版本不匹配,请更新')
|
||||||
|
break
|
||||||
|
case 'system':
|
||||||
|
setPhoneSystem(ev.system || '')
|
||||||
|
setConnStatus(`已连接 ${ev.system} 设备,可开始传输`)
|
||||||
|
break
|
||||||
|
case 'portUnavailable':
|
||||||
|
setConnStatus(ev.reason || '本地文件服务不可用,无法传歌')
|
||||||
|
setJobNotice(ev.reason || '请到「设置 → 封面与音频来源」启动本地曲库')
|
||||||
|
break
|
||||||
|
case 'cmd':
|
||||||
|
void handlePhoneCmd(ev.cmd!, ev.body!)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
off()
|
||||||
|
window.api.musicServer.stop()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const persistChoice = (ids: Set<string>): void => {
|
||||||
|
window.api.store.set('transMusic', [...ids])
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendDownloadCmds = (list: DownloadMusic[]): void => {
|
||||||
|
list.forEach((m, i) => {
|
||||||
|
const isLast = i === list.length - 1
|
||||||
|
window.api.musicServer.send(
|
||||||
|
MusicCmd.DOWNLOAD,
|
||||||
|
`${m.musicUId}${DOWNLOAD_BODY_SEPARATOR}${isLast}`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhoneCmd = async (cmd: string, body: string): Promise<void> => {
|
||||||
|
switch (cmd) {
|
||||||
|
case MusicCmd.MUSIC_LIST: {
|
||||||
|
const missingIds = parseMissingMusicIds(body)
|
||||||
|
let list: DownloadMusic[]
|
||||||
|
if (overwriteRef.current) {
|
||||||
|
list = finalListRef.current
|
||||||
|
} else if (missingIds.length === 0) {
|
||||||
|
window.api.musicServer.send(MusicCmd.BACK, '')
|
||||||
|
setJobNotice('没有任务需要传输(手机已有这些歌曲)')
|
||||||
|
setProgressMode('idle')
|
||||||
|
setProgress({ current: 0, total: 0 })
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
const idSet = new Set(missingIds)
|
||||||
|
list = finalListRef.current.filter((m) => idSet.has(m.musicUId))
|
||||||
|
}
|
||||||
|
if (list.length === 0) {
|
||||||
|
window.api.musicServer.send(MusicCmd.BACK, '')
|
||||||
|
setJobNotice('没有任务需要传输')
|
||||||
|
setProgressMode('idle')
|
||||||
|
setProgress({ current: 0, total: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finalListRef.current = list
|
||||||
|
setProgressMode('wifi')
|
||||||
|
setProgress({ current: 0, total: list.length })
|
||||||
|
// iOS 需先把 flac 转成 wav,手机按 .wav URL 下载(用已有 convert.start,避免 preload 未热更新)
|
||||||
|
if (phoneSystemRef.current.toLowerCase() === 'ios') {
|
||||||
|
setJobNotice('iOS:正在转换 flac→wav…')
|
||||||
|
try {
|
||||||
|
await ensureIosWavWithConvert(list)
|
||||||
|
} catch (e) {
|
||||||
|
setJobNotice(`iOS 转码失败:${(e as Error).message || '未知错误'}`)
|
||||||
|
setProgressMode('idle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const okReady = await window.api.musicServer.send(MusicCmd.READY, JSON.stringify(list))
|
||||||
|
if (!okReady) {
|
||||||
|
setJobNotice('发送就绪指令失败,请重新连接手机')
|
||||||
|
setProgressMode('idle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDownloadCmds(list)
|
||||||
|
setJobNotice('正在传输…')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case MusicCmd.DOWNLOAD_SUCCESS: {
|
||||||
|
setProgress((p) => ({ ...p, current: Math.min(p.current + 1, p.total) }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case MusicCmd.DOWNLOAD_FAIL:
|
||||||
|
setProgress((p) => ({ ...p, current: Math.min(p.current + 1, p.total) }))
|
||||||
|
break
|
||||||
|
case MusicCmd.FINISH:
|
||||||
|
case MusicCmd.STOP:
|
||||||
|
setJobNotice('传输完成')
|
||||||
|
setProgressMode('idle')
|
||||||
|
setProgress({ current: 0, total: 0 })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTransfer = async (): Promise<void> => {
|
||||||
|
const list = music
|
||||||
|
.filter((m) => selected.has(m.musicUId))
|
||||||
|
.map((m) => toDownloadMusic(m, albumById.get(m.albumUId)))
|
||||||
|
if (list.length === 0) {
|
||||||
|
setJobNotice('请先选择要传输的歌曲')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const http = await window.api.http.status()
|
||||||
|
if (!http?.running || !http.port) {
|
||||||
|
setJobNotice('本地文件服务未运行。请到「设置 → 封面与音频来源」选择本地曲库并启动(远程模式无法 WiFi 传歌)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistChoice(selected)
|
||||||
|
finalListRef.current = list
|
||||||
|
setProgressMode('wifi')
|
||||||
|
setProgress({ current: 0, total: list.length })
|
||||||
|
const ok = await window.api.musicServer.send(
|
||||||
|
MusicCmd.PREPARE,
|
||||||
|
`${JSON.stringify(list)}${DOWNLOAD_BODY_SEPARATOR}${overwrite}`
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
setJobNotice('发送失败:设备未连接或通道已断开')
|
||||||
|
setProgressMode('idle')
|
||||||
|
setProgress({ current: 0, total: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setJobNotice('已发送歌单,等待手机确认…')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportSongs = async (platform: 'android' | 'ios'): Promise<void> => {
|
||||||
|
const list = music.filter((m) => selected.has(m.musicUId))
|
||||||
|
if (list.length === 0) {
|
||||||
|
setJobNotice('请先选择要导出的歌曲')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dest = await window.api.dialog.openDir()
|
||||||
|
if (!dest) return
|
||||||
|
persistChoice(selected)
|
||||||
|
setExporting(true)
|
||||||
|
setProgressMode('export')
|
||||||
|
setProgress({ current: 0, total: list.length })
|
||||||
|
setJobNotice(`正在导出 ${platform === 'ios' ? 'iOS(flac→wav)' : 'Android'} 歌曲…`)
|
||||||
|
const off = window.api.exporter.onProgress((p) => {
|
||||||
|
setProgress({ current: p.done, total: p.total })
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const r = await window.api.exporter.run(
|
||||||
|
dest,
|
||||||
|
platform,
|
||||||
|
list.map((m) => ({
|
||||||
|
musicUId: m.musicUId,
|
||||||
|
baseUrl: m.baseUrl,
|
||||||
|
musicPath: m.musicPath,
|
||||||
|
coverPath: m.coverPath,
|
||||||
|
musicName: m.musicName
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const ok = Math.max(0, r.done - r.fail)
|
||||||
|
setJobNotice(`导出完成:成功 ${ok} / 失败 ${r.fail} · ${dest}/output`)
|
||||||
|
} catch (e) {
|
||||||
|
setJobNotice(`导出失败:${(e as Error).message || '未知错误'}`)
|
||||||
|
} finally {
|
||||||
|
off()
|
||||||
|
setExporting(false)
|
||||||
|
setProgressMode('idle')
|
||||||
|
setProgress({ current: 0, total: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSel = (id: string): void =>
|
||||||
|
setSelected((s) => {
|
||||||
|
const n = new Set(s)
|
||||||
|
n.has(id) ? n.delete(id) : n.add(id)
|
||||||
|
persistChoice(n)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectAll = (checked: boolean): void => {
|
||||||
|
const n = checked ? new Set(music.map((m) => m.musicUId)) : new Set<string>()
|
||||||
|
persistChoice(n)
|
||||||
|
setSelected(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原项目逻辑:只改动 recommend/export=true 的歌曲勾选,不影响其它已选项。
|
||||||
|
* checked=true → 勾选全部推荐;checked=false → 取消全部推荐。
|
||||||
|
*/
|
||||||
|
const onCheckRecommend = (checked: boolean): void => {
|
||||||
|
setSelected((s) => {
|
||||||
|
const n = new Set(s)
|
||||||
|
for (const id of recommendedIds) {
|
||||||
|
if (checked) n.add(id)
|
||||||
|
else n.delete(id)
|
||||||
|
}
|
||||||
|
persistChoice(n)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
setRecommendOn(checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAlbum = (songs: Music[]): void => {
|
||||||
|
setSelected((s) => {
|
||||||
|
const n = new Set(s)
|
||||||
|
const all = songs.every((m) => n.has(m.musicUId))
|
||||||
|
songs.forEach((m) => (all ? n.delete(m.musicUId) : n.add(m.musicUId)))
|
||||||
|
persistChoice(n)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const qrValue = ips[ipIndex]?.address ?? ''
|
||||||
|
const allSelected = music.length > 0 && selected.size === music.length
|
||||||
|
const busy =
|
||||||
|
exporting ||
|
||||||
|
(progressMode === 'wifi' && progress.total > 0 && progress.current < progress.total && connected)
|
||||||
|
const showProgress = progressMode !== 'idle' && progress.total > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<PageHeader title="传歌" subtitle="局域网 WiFi · 端口 4388" />
|
||||||
|
|
||||||
|
{/* 顶部连接区:横向留白,不拥挤 */}
|
||||||
|
<section className="apple-card mb-5 flex flex-wrap items-center gap-6 p-5">
|
||||||
|
<div className="flex shrink-0 items-center gap-5">
|
||||||
|
{connected ? (
|
||||||
|
<div className="flex h-[120px] w-[120px] flex-col items-center justify-center gap-2 rounded-2xl bg-apple-green/10">
|
||||||
|
<Smartphone size={36} className="text-apple-green" />
|
||||||
|
<p className="text-xs font-medium text-apple-green">
|
||||||
|
{phoneSystem || '已连接'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : qrValue ? (
|
||||||
|
<div className="rounded-2xl bg-white p-2 shadow-sm">
|
||||||
|
<QRCodeSVG value={qrValue} size={104} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-[120px] w-[120px] place-items-center rounded-2xl bg-black/5 text-xs text-[var(--text-secondary)] dark:bg-white/5">
|
||||||
|
无局域网
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-[160px] space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'grid h-8 w-8 place-items-center rounded-full',
|
||||||
|
connected ? 'bg-apple-green/15 text-apple-green' : 'bg-apple-blue/15 text-apple-blue'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{connected ? <CheckCircle2 size={16} /> : <Wifi size={16} />}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{connected ? '设备已连接' : '扫码连接手机'}</p>
|
||||||
|
<p className="max-w-[220px] text-xs text-[var(--text-secondary)]">{connStatus}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IpSelect ips={ips} value={ipIndex} onChange={setIpIndex} />
|
||||||
|
{showProgress && progressMode === 'wifi' && (
|
||||||
|
<div className="w-full max-w-[220px]">
|
||||||
|
<div className="mb-1 flex justify-between text-[10px] text-[var(--text-secondary)]">
|
||||||
|
<span>{progress.current}</span>
|
||||||
|
<span>{progress.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-black/10 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-apple-blue transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${progress.total ? (progress.current / progress.total) * 100 : 0}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex min-w-0 flex-1 flex-col items-end gap-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => exportSongs('android')}
|
||||||
|
disabled={busy || selected.size === 0}
|
||||||
|
className={clsx('apple-btn-ghost', (busy || !selected.size) && 'opacity-40')}
|
||||||
|
>
|
||||||
|
<HardDrive size={14} /> 导出 Android
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => exportSongs('ios')}
|
||||||
|
disabled={busy || selected.size === 0}
|
||||||
|
className={clsx('apple-btn-ghost', (busy || !selected.size) && 'opacity-40')}
|
||||||
|
title="iOS 将 flac 转为 wav"
|
||||||
|
>
|
||||||
|
<Download size={14} /> 导出 iOS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void startTransfer()}
|
||||||
|
disabled={!connected || busy || selected.size === 0}
|
||||||
|
className={clsx(
|
||||||
|
'apple-btn-primary',
|
||||||
|
(!connected || busy || !selected.size) && 'opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wifi size={16} /> WiFi 传输({selected.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(jobNotice || (showProgress && progressMode === 'export')) && (
|
||||||
|
<div className="w-full max-w-md text-right">
|
||||||
|
{jobNotice && (
|
||||||
|
<p className="break-all text-xs text-[var(--text-secondary)]">{jobNotice}</p>
|
||||||
|
)}
|
||||||
|
{showProgress && progressMode === 'export' && (
|
||||||
|
<div className="mt-1.5 ml-auto w-full max-w-[220px]">
|
||||||
|
<div className="mb-1 flex justify-between text-[10px] text-[var(--text-secondary)]">
|
||||||
|
<span>{progress.current}</span>
|
||||||
|
<span>{progress.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-black/10 dark:bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-apple-blue transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${progress.total ? (progress.current / progress.total) * 100 : 0}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 工具条 */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||||
|
<label className="flex items-center gap-2 text-[var(--text)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) el.indeterminate = selected.size > 0 && !allSelected
|
||||||
|
}}
|
||||||
|
onChange={(e) => selectAll(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
className="h-4 w-4 accent-apple-blue"
|
||||||
|
/>
|
||||||
|
{allSelected ? '取消全选' : '全选'}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2',
|
||||||
|
recommendedIds.length === 0 ? 'opacity-40' : 'text-[var(--text)]'
|
||||||
|
)}
|
||||||
|
title="勾选当前列表中标记为「推荐」的歌曲(手机存储友好),不影响其它已选项"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={recommendOn}
|
||||||
|
onChange={(e) => onCheckRecommend(e.target.checked)}
|
||||||
|
disabled={busy || recommendedIds.length === 0}
|
||||||
|
className="h-4 w-4 accent-apple-blue"
|
||||||
|
/>
|
||||||
|
{recommendOn ? '取消推荐' : '仅推荐(手机存储友好)'}
|
||||||
|
{recommendedIds.length > 0 && (
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">· {recommendedIds.length}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-[var(--text-secondary)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={overwrite}
|
||||||
|
onChange={(e) => setOverwrite(e.target.checked)}
|
||||||
|
className="h-4 w-4 accent-apple-blue"
|
||||||
|
/>
|
||||||
|
是否覆盖传输?(谨慎选择)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span className="ml-auto text-xs text-[var(--text-secondary)]">已选 {selected.size} 首</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recommendedIds.length === 0 && music.length > 0 && (
|
||||||
|
<p className="mb-3 text-xs text-apple-orange">
|
||||||
|
尚未加载推荐标记。请到「设置 → 从云端更新曲库」后,「仅推荐」即可勾选手机存储友好曲目。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 曲目列表:自定义滑块,彻底避开 Windows 原生滚动箭头 */}
|
||||||
|
<SoftScrollArea className="apple-card min-h-0 flex-1" contentClassName="p-4">
|
||||||
|
{albumGroups.length === 0 ? (
|
||||||
|
<div className="grid place-items-center py-20 text-sm text-[var(--text-secondary)]">
|
||||||
|
曲库为空
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{albumGroups.map(({ album, songs }) => {
|
||||||
|
const cover = songs[0]
|
||||||
|
? buildFileUrl(httpBase, songs[0].baseUrl, songs[0].coverPath)
|
||||||
|
: album?.cover
|
||||||
|
? buildFileUrl(httpBase, '', album.cover)
|
||||||
|
: ''
|
||||||
|
const allChecked = songs.every((m) => selected.has(m.musicUId))
|
||||||
|
return (
|
||||||
|
<div key={songs[0].albumUId} className="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAlbum(songs)}
|
||||||
|
className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded-xl bg-black/10 shadow-apple"
|
||||||
|
title={allChecked ? '取消本专辑' : '选中本专辑'}
|
||||||
|
>
|
||||||
|
{cover ? (
|
||||||
|
<img src={cover} className="h-full w-full object-cover" alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center text-xs text-[var(--text-secondary)]">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="mb-2 truncate text-sm font-semibold">
|
||||||
|
{album?.albumName ?? '未知专辑'}
|
||||||
|
<span className="ml-2 text-xs font-normal text-[var(--text-secondary)]">
|
||||||
|
{songs.length} 首
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-0.5 sm:grid-cols-2">
|
||||||
|
{songs.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.musicUId}
|
||||||
|
className="flex cursor-default items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(m.musicUId)}
|
||||||
|
onChange={() => toggleSel(m.musicUId)}
|
||||||
|
className="h-3.5 w-3.5 shrink-0 accent-apple-blue"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs">{m.musicName}</span>
|
||||||
|
{m.recommend ? (
|
||||||
|
<span className="shrink-0 rounded bg-apple-green/15 px-1.5 py-0.5 text-[10px] text-apple-green">
|
||||||
|
推荐
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SoftScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/renderer/src/stores/lanStore.ts
Normal file
33
src/renderer/src/stores/lanStore.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface LanIf {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanState {
|
||||||
|
ips: LanIf[]
|
||||||
|
ipIndex: number
|
||||||
|
loaded: boolean
|
||||||
|
load: () => Promise<void>
|
||||||
|
setIpIndex: (i: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 局域网 IP 全局状态:传歌 / 数据同步共用同一份列表与选中项,
|
||||||
|
* 避免两个页面各自拉取导致数量或顺序不一致的观感差异。
|
||||||
|
*/
|
||||||
|
export const useLanStore = create<LanState>((set, get) => ({
|
||||||
|
ips: [],
|
||||||
|
ipIndex: 0,
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
load: async () => {
|
||||||
|
const lan = await window.api.net.lanIps()
|
||||||
|
const prev = get().ipIndex
|
||||||
|
const ipIndex = lan.length === 0 ? 0 : Math.min(prev, lan.length - 1)
|
||||||
|
set({ ips: lan, ipIndex, loaded: true })
|
||||||
|
},
|
||||||
|
|
||||||
|
setIpIndex: (i) => set({ ipIndex: i })
|
||||||
|
}))
|
||||||
63
src/renderer/src/stores/libraryStore.ts
Normal file
63
src/renderer/src/stores/libraryStore.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Album, Music, Menu } from '@shared/models'
|
||||||
|
import { Repo } from '../lib/repository'
|
||||||
|
|
||||||
|
interface LibraryState {
|
||||||
|
albums: Album[]
|
||||||
|
music: Music[]
|
||||||
|
menus: Menu[]
|
||||||
|
loading: boolean
|
||||||
|
/** force:设置页同步后强制重拉;默认有缓存则跳过,避免点击进出页面反复查库 */
|
||||||
|
loadAll: (opts?: { force?: boolean }) => Promise<void>
|
||||||
|
musicMap: () => Map<string, Music>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进行中的 loadAll,供连点时复用同一 Promise */
|
||||||
|
let inflight: Promise<void> | null = null
|
||||||
|
|
||||||
|
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||||
|
albums: [],
|
||||||
|
music: [],
|
||||||
|
menus: [],
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
loadAll: async (opts) => {
|
||||||
|
const force = !!opts?.force
|
||||||
|
const hasCache = get().albums.length > 0 || get().music.length > 0
|
||||||
|
|
||||||
|
// 用户点击进出专辑等场景:已有数据则不再查库
|
||||||
|
if (!force && hasCache) return
|
||||||
|
if (inflight) return inflight
|
||||||
|
|
||||||
|
inflight = (async () => {
|
||||||
|
if (!hasCache) set({ loading: true })
|
||||||
|
try {
|
||||||
|
const [albums, rawMusic, menus] = await Promise.all([
|
||||||
|
Repo.allAlbums(),
|
||||||
|
Repo.allMusic(),
|
||||||
|
Repo.allMenus()
|
||||||
|
])
|
||||||
|
// SQLite 的 recommend 为 0/1,统一转成 boolean
|
||||||
|
const music = rawMusic.map((m) => ({
|
||||||
|
...m,
|
||||||
|
recommend: !!(m as Music & { recommend?: number | boolean }).recommend,
|
||||||
|
local: !!(m as Music & { local?: number | boolean }).local
|
||||||
|
}))
|
||||||
|
set({ albums, music, menus, loading: false })
|
||||||
|
} catch (e) {
|
||||||
|
set({ loading: false })
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
inflight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return inflight
|
||||||
|
},
|
||||||
|
|
||||||
|
musicMap: () => {
|
||||||
|
const map = new Map<string, Music>()
|
||||||
|
for (const m of get().music) map.set(m.musicUId, m)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
}))
|
||||||
82
src/renderer/src/stores/lyricStore.ts
Normal file
82
src/renderer/src/stores/lyricStore.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Music } from '@shared/models'
|
||||||
|
import { Repo } from '../lib/repository'
|
||||||
|
import { fetchLyrics } from '../lib/oss'
|
||||||
|
import { parseLrc, type LrcLine } from '../lib/lrc'
|
||||||
|
|
||||||
|
/** 一行三语歌词(按时间对齐) */
|
||||||
|
export interface TriLine {
|
||||||
|
time: number
|
||||||
|
jp: string
|
||||||
|
zh: string
|
||||||
|
roma: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LyricState {
|
||||||
|
musicUId: string
|
||||||
|
lines: TriLine[]
|
||||||
|
/** 递增以请求歌词页下滑退出(播放条封面等) */
|
||||||
|
closeTick: number
|
||||||
|
requestClose: () => void
|
||||||
|
load: (music: Music) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将三种语言的 lrc 行按索引/时间对齐成三语行 */
|
||||||
|
function combine(jp: LrcLine[], zh: LrcLine[], roma: LrcLine[]): TriLine[] {
|
||||||
|
const nearest = (arr: LrcLine[], t: number): string => {
|
||||||
|
let best = ''
|
||||||
|
let diff = Infinity
|
||||||
|
for (const l of arr) {
|
||||||
|
const d = Math.abs(l.time - t)
|
||||||
|
if (d < diff) {
|
||||||
|
diff = d
|
||||||
|
best = l.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff <= 0.6 ? best : ''
|
||||||
|
}
|
||||||
|
return jp.map((line, i) => ({
|
||||||
|
time: line.time,
|
||||||
|
jp: line.text,
|
||||||
|
// 行数一致时按索引对齐,否则按最近时间匹配
|
||||||
|
zh: zh.length === jp.length ? (zh[i]?.text ?? '') : nearest(zh, line.time),
|
||||||
|
roma: roma.length === jp.length ? (roma[i]?.text ?? '') : nearest(roma, line.time)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLyricStore = create<LyricState>((set, get) => ({
|
||||||
|
musicUId: '',
|
||||||
|
lines: [],
|
||||||
|
closeTick: 0,
|
||||||
|
requestClose: () => set((s) => ({ closeTick: s.closeTick + 1 })),
|
||||||
|
|
||||||
|
load: async (music) => {
|
||||||
|
if (get().musicUId === music.musicUId && get().lines.length > 0) return
|
||||||
|
set({ musicUId: music.musicUId, lines: [] })
|
||||||
|
|
||||||
|
// 优先本地缓存,缺失则从 OSS 拉取并写回
|
||||||
|
let ly = await Repo.lyric(music.musicUId)
|
||||||
|
if (!ly?.lyricJp && !ly?.lyricZh && !ly?.lyricRoma) {
|
||||||
|
const fetched = await fetchLyrics(music)
|
||||||
|
if (fetched.jp || fetched.zh || fetched.roma) {
|
||||||
|
await window.api.db.exec(
|
||||||
|
`INSERT OR REPLACE INTO lyric (musicUId, lyricJp, lyricZh, lyricRoma) VALUES (?,?,?,?)`,
|
||||||
|
[music.musicUId, fetched.jp, fetched.zh, fetched.roma]
|
||||||
|
)
|
||||||
|
ly = {
|
||||||
|
musicUId: music.musicUId,
|
||||||
|
lyricJp: fetched.jp,
|
||||||
|
lyricZh: fetched.zh,
|
||||||
|
lyricRoma: fetched.roma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 竞态保护:加载期间可能已切歌
|
||||||
|
if (get().musicUId !== music.musicUId) return
|
||||||
|
const jp = parseLrc(ly?.lyricJp ?? '')
|
||||||
|
const zh = parseLrc(ly?.lyricZh ?? '')
|
||||||
|
const roma = parseLrc(ly?.lyricRoma ?? '')
|
||||||
|
set({ lines: combine(jp, zh, roma) })
|
||||||
|
}
|
||||||
|
}))
|
||||||
351
src/renderer/src/stores/playerStore.ts
Normal file
351
src/renderer/src/stores/playerStore.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Music } from '@shared/models'
|
||||||
|
import { Repo, buildFileUrl } from '../lib/repository'
|
||||||
|
import type { PlayMode } from '../lib/const'
|
||||||
|
import { useLyricStore } from './lyricStore'
|
||||||
|
|
||||||
|
const audio = new Audio()
|
||||||
|
/** 防止 React StrictMode / 重复 boot 导致监听器注册两次(播放暂停会连点两次≈无效) */
|
||||||
|
let playerBooted = false
|
||||||
|
/** 是否已从本地配置恢复过播放队列(避免 Settings 再次 init 冲掉当前列表) */
|
||||||
|
let playbackRestored = false
|
||||||
|
let progressPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
interface PlayerState {
|
||||||
|
queue: Music[]
|
||||||
|
current: Music | null
|
||||||
|
index: number
|
||||||
|
isPlaying: boolean
|
||||||
|
mode: PlayMode
|
||||||
|
volume: number
|
||||||
|
progress: number
|
||||||
|
duration: number
|
||||||
|
httpBase: string
|
||||||
|
loves: Set<string>
|
||||||
|
desktopLyricOn: boolean
|
||||||
|
|
||||||
|
init: () => Promise<void>
|
||||||
|
toggleDesktopLyric: () => void
|
||||||
|
playList: (list: Music[], startIndex?: number) => void
|
||||||
|
playAt: (index: number) => void
|
||||||
|
removeFromQueue: (index: number) => void
|
||||||
|
clearQueue: () => void
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
toggle: () => void
|
||||||
|
next: () => void
|
||||||
|
prev: () => void
|
||||||
|
seek: (t: number) => void
|
||||||
|
setVolume: (v: number) => void
|
||||||
|
cycleMode: () => void
|
||||||
|
toggleLove: (musicUId: string) => Promise<void>
|
||||||
|
refreshLoves: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODES: PlayMode[] = ['order', 'repeat', 'single', 'shuffle']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化当前播放队列 / 曲目 / 进度。
|
||||||
|
* - immediate:队列变更时立刻写盘(旧 debounce 会在 timeupdate 中被不断重置,导致永远不落盘)
|
||||||
|
* - 进度:节流约 2s 写一次
|
||||||
|
*/
|
||||||
|
function persistPlayback(immediate = false): void {
|
||||||
|
const write = (): void => {
|
||||||
|
const { queue, current, index, progress } = usePlayerStore.getState()
|
||||||
|
void window.api.store.set(
|
||||||
|
'lastPlayList',
|
||||||
|
queue.map((m) => m.musicUId)
|
||||||
|
)
|
||||||
|
void window.api.store.set('lastPlayId', current?.musicUId ?? '')
|
||||||
|
void window.api.store.set('lastPlayIndex', index)
|
||||||
|
void window.api.store.set('lastProgress', progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
if (progressPersistTimer) {
|
||||||
|
clearTimeout(progressPersistTimer)
|
||||||
|
progressPersistTimer = null
|
||||||
|
}
|
||||||
|
write()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressPersistTimer) return
|
||||||
|
progressPersistTimer = setTimeout(() => {
|
||||||
|
progressPersistTimer = null
|
||||||
|
write()
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供托盘「退出」等场景同步落盘 */
|
||||||
|
async function flushPlayback(): Promise<void> {
|
||||||
|
if (progressPersistTimer) {
|
||||||
|
clearTimeout(progressPersistTimer)
|
||||||
|
progressPersistTimer = null
|
||||||
|
}
|
||||||
|
const { queue, current, index, progress } = usePlayerStore.getState()
|
||||||
|
await window.api.store.set(
|
||||||
|
'lastPlayList',
|
||||||
|
queue.map((m) => m.musicUId)
|
||||||
|
)
|
||||||
|
await window.api.store.set('lastPlayId', current?.musicUId ?? '')
|
||||||
|
await window.api.store.set('lastPlayIndex', index)
|
||||||
|
await window.api.store.set('lastProgress', progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
;(window as unknown as { __flushPlayback?: () => Promise<void> }).__flushPlayback = flushPlayback
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTrack(music: Music, httpBase: string, autoplay: boolean): void {
|
||||||
|
audio.src = buildFileUrl(httpBase, music.baseUrl, music.musicPath)
|
||||||
|
if (autoplay) {
|
||||||
|
audio.play().catch(() => undefined)
|
||||||
|
}
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: music.musicName,
|
||||||
|
artist: music.artist
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||||
|
queue: [],
|
||||||
|
current: null,
|
||||||
|
index: -1,
|
||||||
|
isPlaying: false,
|
||||||
|
mode: 'order',
|
||||||
|
volume: 0.8,
|
||||||
|
progress: 0,
|
||||||
|
duration: 0,
|
||||||
|
httpBase: '',
|
||||||
|
loves: new Set<string>(),
|
||||||
|
desktopLyricOn: false,
|
||||||
|
|
||||||
|
toggleDesktopLyric: () => {
|
||||||
|
const next = !get().desktopLyricOn
|
||||||
|
// 开启前先推送当前歌词行(或歌名),主进程会缓存,窗口 ready 后立刻显示
|
||||||
|
if (next) {
|
||||||
|
const { current, progress } = get()
|
||||||
|
const lines = useLyricStore.getState().lines
|
||||||
|
let idx = -1
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].time <= progress) idx = i
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
const line = lines[idx]
|
||||||
|
const text = (line?.jp || current?.musicName || '').trim()
|
||||||
|
if (text) {
|
||||||
|
const pack = {
|
||||||
|
prevLrc: text,
|
||||||
|
nextLrc: (line?.zh || '').trim(),
|
||||||
|
singleLrc: text
|
||||||
|
}
|
||||||
|
window.api.lyric.update({
|
||||||
|
jp: pack,
|
||||||
|
zh: pack,
|
||||||
|
roma: { ...pack, nextLrc: (line?.roma || '').trim() },
|
||||||
|
title: current?.musicName
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.api.lyric.toggle(next)
|
||||||
|
set({ desktopLyricOn: next })
|
||||||
|
},
|
||||||
|
|
||||||
|
init: async () => {
|
||||||
|
const status = await window.api.http.status()
|
||||||
|
const vol = (await window.api.store.get<number>('volume')) ?? 0.8
|
||||||
|
const mode = (await window.api.store.get<PlayMode>('playMode')) ?? 'order'
|
||||||
|
const remoteUrl = (await window.api.store.get<string>('url')) || ''
|
||||||
|
const httpBase = remoteUrl || (status.port ? `http://127.0.0.1:${status.port}` : '')
|
||||||
|
audio.volume = vol
|
||||||
|
set({ httpBase, volume: vol, mode })
|
||||||
|
|
||||||
|
if (!playerBooted) {
|
||||||
|
playerBooted = true
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
set({ progress: audio.currentTime, duration: audio.duration || 0 })
|
||||||
|
persistPlayback(false)
|
||||||
|
})
|
||||||
|
audio.addEventListener('ended', () => get().next())
|
||||||
|
|
||||||
|
window.api.lyric.onState((show) => set({ desktopLyricOn: show }))
|
||||||
|
|
||||||
|
window.api.player.onControl((action) => {
|
||||||
|
if (action === 'playpause') get().toggle()
|
||||||
|
else if (action === 'next') get().next()
|
||||||
|
else if (action === 'prev') get().prev()
|
||||||
|
})
|
||||||
|
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', () => get().next())
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', () => get().prev())
|
||||||
|
navigator.mediaSession.setActionHandler('play', () => get().play())
|
||||||
|
navigator.mediaSession.setActionHandler('pause', () => get().pause())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出 / 隐藏前立刻落盘,避免节流未触发导致重启后队列为空
|
||||||
|
const flush = (): void => persistPlayback(true)
|
||||||
|
window.addEventListener('pagehide', flush)
|
||||||
|
window.addEventListener('beforeunload', flush)
|
||||||
|
}
|
||||||
|
|
||||||
|
await get().refreshLoves()
|
||||||
|
|
||||||
|
// 仅启动时恢复一次;Settings 再次 init 只刷新 httpBase/音量,不覆盖当前队列
|
||||||
|
if (playbackRestored || get().queue.length > 0) return
|
||||||
|
playbackRestored = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = (await window.api.store.get<string[]>('lastPlayList')) || []
|
||||||
|
const lastId = (await window.api.store.get<string>('lastPlayId')) || ''
|
||||||
|
const lastIndex = (await window.api.store.get<number>('lastPlayIndex')) ?? 0
|
||||||
|
const lastProgress = (await window.api.store.get<number>('lastProgress')) ?? 0
|
||||||
|
if (ids.length === 0) return
|
||||||
|
|
||||||
|
const found = await Repo.musicByIds(ids)
|
||||||
|
const map = new Map(found.map((m) => [m.musicUId, m]))
|
||||||
|
const queue = ids.map((id) => map.get(id)).filter((m): m is Music => !!m)
|
||||||
|
if (queue.length === 0) return
|
||||||
|
|
||||||
|
let index = queue.findIndex((m) => m.musicUId === lastId)
|
||||||
|
if (index < 0) index = Math.min(Math.max(0, lastIndex), queue.length - 1)
|
||||||
|
const music = queue[index]
|
||||||
|
applyTrack(music, httpBase, false)
|
||||||
|
const onMeta = (): void => {
|
||||||
|
audio.removeEventListener('loadedmetadata', onMeta)
|
||||||
|
if (lastProgress > 0 && Number.isFinite(lastProgress)) {
|
||||||
|
audio.currentTime = Math.min(lastProgress, audio.duration || lastProgress)
|
||||||
|
set({ progress: audio.currentTime })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audio.addEventListener('loadedmetadata', onMeta)
|
||||||
|
set({ queue, current: music, index, isPlaying: false })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[player] restore playback failed', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
playList: (list, startIndex = 0) => {
|
||||||
|
if (list.length === 0) return
|
||||||
|
set({ queue: list, index: startIndex })
|
||||||
|
const music = list[startIndex]
|
||||||
|
const { httpBase } = get()
|
||||||
|
applyTrack(music, httpBase, true)
|
||||||
|
Repo.touchHistory(music.musicUId)
|
||||||
|
set({ current: music, isPlaying: true, progress: 0 })
|
||||||
|
persistPlayback(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
playAt: (index) => {
|
||||||
|
const { queue } = get()
|
||||||
|
if (index < 0 || index >= queue.length) return
|
||||||
|
get().playList(queue, index)
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromQueue: (index) => {
|
||||||
|
const { queue, index: cur, isPlaying } = get()
|
||||||
|
if (index < 0 || index >= queue.length) return
|
||||||
|
const nextQueue = queue.filter((_, i) => i !== index)
|
||||||
|
if (nextQueue.length === 0) {
|
||||||
|
audio.pause()
|
||||||
|
audio.removeAttribute('src')
|
||||||
|
set({ queue: [], current: null, index: -1, isPlaying: false, progress: 0 })
|
||||||
|
persistPlayback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (index === cur) {
|
||||||
|
const nextIndex = Math.min(index, nextQueue.length - 1)
|
||||||
|
get().playList(nextQueue, nextIndex)
|
||||||
|
if (!isPlaying) get().pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set({ queue: nextQueue, index: index < cur ? cur - 1 : cur })
|
||||||
|
persistPlayback(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
clearQueue: () => {
|
||||||
|
audio.pause()
|
||||||
|
audio.removeAttribute('src')
|
||||||
|
set({ queue: [], current: null, index: -1, isPlaying: false, progress: 0 })
|
||||||
|
persistPlayback(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
play: () => {
|
||||||
|
audio.play().catch(() => undefined)
|
||||||
|
set({ isPlaying: true })
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
audio.pause()
|
||||||
|
set({ isPlaying: false })
|
||||||
|
persistPlayback(true)
|
||||||
|
},
|
||||||
|
toggle: () => (get().isPlaying ? get().pause() : get().play()),
|
||||||
|
|
||||||
|
next: () => {
|
||||||
|
const { queue, index, mode } = get()
|
||||||
|
if (queue.length === 0) return
|
||||||
|
let nextIndex = index
|
||||||
|
if (mode === 'single') {
|
||||||
|
audio.currentTime = 0
|
||||||
|
audio.play().catch(() => undefined)
|
||||||
|
return
|
||||||
|
} else if (mode === 'shuffle') {
|
||||||
|
nextIndex = Math.floor(Math.random() * queue.length)
|
||||||
|
} else {
|
||||||
|
nextIndex = index + 1
|
||||||
|
if (nextIndex >= queue.length) {
|
||||||
|
if (mode === 'order') {
|
||||||
|
set({ isPlaying: false })
|
||||||
|
persistPlayback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get().playList(queue, nextIndex)
|
||||||
|
},
|
||||||
|
|
||||||
|
prev: () => {
|
||||||
|
const { queue, index } = get()
|
||||||
|
if (queue.length === 0) return
|
||||||
|
const prevIndex = index - 1 < 0 ? queue.length - 1 : index - 1
|
||||||
|
get().playList(queue, prevIndex)
|
||||||
|
},
|
||||||
|
|
||||||
|
seek: (t) => {
|
||||||
|
audio.currentTime = t
|
||||||
|
set({ progress: t })
|
||||||
|
persistPlayback(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
setVolume: (v) => {
|
||||||
|
audio.volume = v
|
||||||
|
window.api.store.set('volume', v)
|
||||||
|
set({ volume: v })
|
||||||
|
},
|
||||||
|
|
||||||
|
cycleMode: () => {
|
||||||
|
const { mode } = get()
|
||||||
|
const nextMode = MODES[(MODES.indexOf(mode) + 1) % MODES.length]
|
||||||
|
window.api.store.set('playMode', nextMode)
|
||||||
|
set({ mode: nextMode })
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleLove: async (musicUId) => {
|
||||||
|
const { loves } = get()
|
||||||
|
if (loves.has(musicUId)) {
|
||||||
|
await Repo.removeLove(musicUId)
|
||||||
|
} else {
|
||||||
|
await Repo.addLove(musicUId)
|
||||||
|
}
|
||||||
|
await get().refreshLoves()
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshLoves: async () => {
|
||||||
|
const loves = await Repo.allLoves()
|
||||||
|
set({ loves: new Set(loves.map((l) => l.musicUId)) })
|
||||||
|
}
|
||||||
|
}))
|
||||||
59
src/renderer/src/stores/uiStore.ts
Normal file
59
src/renderer/src/stores/uiStore.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
theme: ThemeMode
|
||||||
|
accent: string
|
||||||
|
isDark: boolean
|
||||||
|
setTheme: (t: ThemeMode) => void
|
||||||
|
setAccent: (c: string) => void
|
||||||
|
init: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: ThemeMode): boolean {
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
const isDark = theme === 'dark' || (theme === 'system' && prefersDark)
|
||||||
|
document.documentElement.classList.toggle('dark', isDark)
|
||||||
|
return isDark
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #RRGGBB -> "r g b"(供 tailwind rgb(var() / alpha) 使用) */
|
||||||
|
function hexToRgbTriplet(hex: string): string {
|
||||||
|
const m = hex.replace('#', '')
|
||||||
|
const r = parseInt(m.slice(0, 2), 16)
|
||||||
|
const g = parseInt(m.slice(2, 4), 16)
|
||||||
|
const b = parseInt(m.slice(4, 6), 16)
|
||||||
|
return `${r} ${g} ${b}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccent(color: string): void {
|
||||||
|
document.documentElement.style.setProperty('--accent', color)
|
||||||
|
document.documentElement.style.setProperty('--accent-rgb', hexToRgbTriplet(color))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
|
theme: 'system',
|
||||||
|
accent: '#0A84FF',
|
||||||
|
isDark: false,
|
||||||
|
setTheme: (t) => {
|
||||||
|
const isDark = applyTheme(t)
|
||||||
|
window.api.store.set('theme', t)
|
||||||
|
set({ theme: t, isDark })
|
||||||
|
},
|
||||||
|
setAccent: (c) => {
|
||||||
|
applyAccent(c)
|
||||||
|
window.api.store.set('accentColor', c)
|
||||||
|
set({ accent: c })
|
||||||
|
},
|
||||||
|
init: async () => {
|
||||||
|
const theme = (await window.api.store.get<ThemeMode>('theme')) || 'system'
|
||||||
|
const accent = (await window.api.store.get<string>('accentColor')) || '#0A84FF'
|
||||||
|
const isDark = applyTheme(theme)
|
||||||
|
applyAccent(accent)
|
||||||
|
set({ theme, accent, isDark })
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
set((s) => ({ isDark: applyTheme(s.theme) }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
157
src/renderer/src/styles/index.css
Normal file
157
src/renderer/src/styles/index.css
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--bg-elevated: rgba(255, 255, 255, 0.72);
|
||||||
|
--text: #1d1d1f;
|
||||||
|
--text-secondary: #6e6e73;
|
||||||
|
--separator: rgba(0, 0, 0, 0.08);
|
||||||
|
--sidebar: rgba(245, 245, 247, 0.7);
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent-rgb: 10 132 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--bg: #1c1c1e;
|
||||||
|
--bg-elevated: rgba(44, 44, 46, 0.72);
|
||||||
|
--text: #f5f5f7;
|
||||||
|
--text-secondary: #98989d;
|
||||||
|
--separator: rgba(255, 255, 255, 0.1);
|
||||||
|
--sidebar: rgba(28, 28, 30, 0.6);
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent-rgb: 10 132 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
a {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'PingFang SC',
|
||||||
|
'Helvetica Neue', Inter, system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 桌面歌词独立窗口:整页透明 */
|
||||||
|
html.desktop-lyric,
|
||||||
|
html.desktop-lyric body,
|
||||||
|
html.desktop-lyric #root {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 可拖拽标题栏区域 */
|
||||||
|
.drag {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
.no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple 毛玻璃 */
|
||||||
|
.glass {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
backdrop-filter: saturate(180%) blur(30px);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局隐藏原生滚动条(含滚动中的箭头);传歌页用 SoftScrollArea 自定义滑块 */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
display: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-button {
|
||||||
|
display: none !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
.soft-scroll-native {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.soft-scroll-native::-webkit-scrollbar {
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.apple-card {
|
||||||
|
@apply rounded-2xl bg-white/60 dark:bg-white/5 shadow-apple transition-all duration-300;
|
||||||
|
}
|
||||||
|
.apple-btn {
|
||||||
|
@apply no-drag inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2
|
||||||
|
text-sm font-medium transition-all duration-200 ease-apple active:scale-95;
|
||||||
|
}
|
||||||
|
.apple-btn-primary {
|
||||||
|
@apply apple-btn bg-apple-blue text-white hover:brightness-110 shadow-apple;
|
||||||
|
}
|
||||||
|
.apple-btn-ghost {
|
||||||
|
@apply apple-btn bg-black/5 dark:bg-white/10 text-[var(--text)] hover:bg-black/10 dark:hover:bg-white/[0.15];
|
||||||
|
}
|
||||||
|
.sidebar-item {
|
||||||
|
@apply no-drag flex items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium
|
||||||
|
text-[var(--text-secondary)] transition-all duration-200 cursor-default;
|
||||||
|
}
|
||||||
|
.sidebar-item-active {
|
||||||
|
@apply bg-black/[0.06] dark:bg-white/10 text-[var(--text)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网易云风格唱片转盘 */
|
||||||
|
@keyframes vinyl-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.vinyl-spin {
|
||||||
|
animation: vinyl-spin 20s linear infinite;
|
||||||
|
}
|
||||||
|
/* 歌词页上下羽化,接近 Apple Music 跟唱视口 */
|
||||||
|
.lyric-mask {
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
#000 10%,
|
||||||
|
#000 82%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0%,
|
||||||
|
#000 10%,
|
||||||
|
#000 82%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl-spin-paused {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
3
src/shared/index.ts
Normal file
3
src/shared/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './protocol'
|
||||||
|
export * from './models'
|
||||||
|
export * from './ipc/channels'
|
||||||
82
src/shared/ipc/channels.ts
Normal file
82
src/shared/ipc/channels.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* IPC 通道名(渲染进程 ↔ 主进程)
|
||||||
|
* 统一常量,preload 与 main 共同引用,避免字符串漂移。
|
||||||
|
*/
|
||||||
|
export const IPC = {
|
||||||
|
// ---- HTTP 文件服务 ----
|
||||||
|
HTTP_START: 'http:start',
|
||||||
|
HTTP_STOP: 'http:stop',
|
||||||
|
HTTP_STATUS: 'http:status',
|
||||||
|
|
||||||
|
// ---- 数据库 ----
|
||||||
|
DB_QUERY: 'db:query',
|
||||||
|
DB_EXEC: 'db:exec',
|
||||||
|
|
||||||
|
// ---- 配置 electron-store ----
|
||||||
|
STORE_GET: 'store:get',
|
||||||
|
STORE_SET: 'store:set',
|
||||||
|
STORE_DELETE: 'store:delete',
|
||||||
|
|
||||||
|
// ---- 网络(OSS 元数据) ----
|
||||||
|
NET_FETCH_JSON: 'net:fetchJson',
|
||||||
|
NET_FETCH_TEXT: 'net:fetchText',
|
||||||
|
|
||||||
|
// ---- 曲库同步(OSS data.json 元数据) ----
|
||||||
|
LIBRARY_SYNC: 'library:sync',
|
||||||
|
LIBRARY_DATA_VERSION: 'library:dataVersion',
|
||||||
|
|
||||||
|
// ---- 转码(iOS flac -> wav) ----
|
||||||
|
CONVERT_START: 'convert:start',
|
||||||
|
CONVERT_PROGRESS: 'convert:progress',
|
||||||
|
CONVERT_DONE: 'convert:done',
|
||||||
|
CONVERT_STOP: 'convert:stop',
|
||||||
|
/** WiFi 传歌:为 iOS 在曲库目录旁生成临时 wav */
|
||||||
|
WIFI_ENSURE_IOS_WAV: 'wifi:ensureIosWav',
|
||||||
|
/** WiFi 传歌:下载完成后删除临时 wav */
|
||||||
|
WIFI_CLEAN_IOS_WAV: 'wifi:cleanIosWav',
|
||||||
|
|
||||||
|
// ---- WiFi 传歌服务(4388) ----
|
||||||
|
MUSIC_SERVER_START: 'musicServer:start',
|
||||||
|
MUSIC_SERVER_STOP: 'musicServer:stop',
|
||||||
|
MUSIC_SERVER_SEND: 'musicServer:send',
|
||||||
|
MUSIC_SERVER_EVENT: 'musicServer:event',
|
||||||
|
|
||||||
|
// ---- 数据同步服务(4389) ----
|
||||||
|
DATA_SERVER_START: 'dataServer:start',
|
||||||
|
DATA_SERVER_STOP: 'dataServer:stop',
|
||||||
|
DATA_SERVER_SEND: 'dataServer:send',
|
||||||
|
DATA_SERVER_EVENT: 'dataServer:event',
|
||||||
|
|
||||||
|
// ---- 局域网信息 ----
|
||||||
|
NET_LAN_IPS: 'net:lanIps',
|
||||||
|
|
||||||
|
// ---- 对话框/文件系统 ----
|
||||||
|
DIALOG_OPEN_DIR: 'dialog:openDir',
|
||||||
|
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||||
|
FS_EXPORT_EXCEL: 'fs:exportExcel',
|
||||||
|
|
||||||
|
// ---- 窗口控制 ----
|
||||||
|
WIN_MIN: 'win:min',
|
||||||
|
WIN_MAX: 'win:max',
|
||||||
|
WIN_CLOSE: 'win:close',
|
||||||
|
|
||||||
|
// ---- 桌面歌词 ----
|
||||||
|
LYRIC_TOGGLE: 'lyric:toggle',
|
||||||
|
LYRIC_UPDATE: 'lyric:update',
|
||||||
|
LYRIC_SET_IGNORE_MOUSE: 'lyric:setIgnoreMouse',
|
||||||
|
LYRIC_STATE: 'lyric:state',
|
||||||
|
|
||||||
|
// ---- 更新 ----
|
||||||
|
UPDATE_CHECK: 'update:check',
|
||||||
|
UPDATE_EVENT: 'update:event',
|
||||||
|
UPDATE_OPEN_DOWNLOAD: 'update:openDownload',
|
||||||
|
|
||||||
|
// ---- 托盘/后台 → 渲染进程播放控制 ----
|
||||||
|
PLAYER_CONTROL: 'player:control',
|
||||||
|
|
||||||
|
// ---- 导出歌曲到本地目录(Android 原样 / iOS flac→wav) ----
|
||||||
|
EXPORT_SONGS: 'export:songs',
|
||||||
|
EXPORT_PROGRESS: 'export:progress'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type IpcChannel = (typeof IPC)[keyof typeof IPC]
|
||||||
119
src/shared/models/index.ts
Normal file
119
src/shared/models/index.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* 领域模型(与移动端 Floor/SQLite 实体对齐)
|
||||||
|
*
|
||||||
|
* 移动端实体:Album / Lyric / Music / PlayListMusic / Menu / Artist / Love / History
|
||||||
|
* PC 端沿用相同结构,降低双端同步映射成本。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 企划/团体分组 */
|
||||||
|
export enum GroupType {
|
||||||
|
MUSE = "μ's",
|
||||||
|
AQOURS = 'Aqours',
|
||||||
|
NIJIGASAKI = 'Nijigasaki',
|
||||||
|
LIELLA = 'Liella!',
|
||||||
|
HASUNOSORA = 'Hasunosora',
|
||||||
|
YOHANE = 'Yohane',
|
||||||
|
MUSICAL = 'Musical',
|
||||||
|
BLUEBIRD = 'BlueBird',
|
||||||
|
COMBINE = 'Combine'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
albumUId: string
|
||||||
|
albumName: string
|
||||||
|
cover: string
|
||||||
|
category: string
|
||||||
|
group: string
|
||||||
|
releaseDate?: string
|
||||||
|
/** 相对根路径 */
|
||||||
|
baseUrl: string
|
||||||
|
/** OSS 数字专辑 id(传歌协议需要) */
|
||||||
|
albumId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Music {
|
||||||
|
musicUId: string
|
||||||
|
albumUId: string
|
||||||
|
musicName: string
|
||||||
|
artist: string
|
||||||
|
/** 音频相对路径 */
|
||||||
|
musicPath: string
|
||||||
|
/** 封面相对路径 */
|
||||||
|
coverPath: string
|
||||||
|
baseUrl: string
|
||||||
|
group: string
|
||||||
|
neteaseId?: string
|
||||||
|
/** 时长(秒) */
|
||||||
|
duration?: number
|
||||||
|
/** 原始时长字符串,如 "05:23"(传歌协议 totalTime) */
|
||||||
|
time?: string
|
||||||
|
/** 歌手编码(传歌/手机解析艺人用,来源 OSS artist_bin) */
|
||||||
|
artistBin?: string
|
||||||
|
/** OSS 数字曲目 id(传歌协议 musicId;库内亦作 index) */
|
||||||
|
musicId?: number
|
||||||
|
/** 曲目序号 */
|
||||||
|
index?: number
|
||||||
|
/** 是否已下载到本地(PC 端一般为 true) */
|
||||||
|
local?: boolean
|
||||||
|
/** 是否为推荐传输曲目(手机存储友好,来源 data.json 的 export 字段) */
|
||||||
|
recommend?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artist {
|
||||||
|
artistId: string
|
||||||
|
name: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三语歌词:日/中/罗马音 */
|
||||||
|
export interface Lyric {
|
||||||
|
musicUId: string
|
||||||
|
/** 日文原文歌词(lrc 文本) */
|
||||||
|
lyricJp?: string
|
||||||
|
/** 中文翻译(lrc 文本) */
|
||||||
|
lyricZh?: string
|
||||||
|
/** 罗马音(lrc 文本) */
|
||||||
|
lyricRoma?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 歌单:id ≤100 为 PC 歌单,>100 为手机歌单 */
|
||||||
|
export interface Menu {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
cover?: string
|
||||||
|
createTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 歌单-歌曲关联 */
|
||||||
|
export interface PlayListMusic {
|
||||||
|
menuId: number
|
||||||
|
musicUId: string
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我喜欢 */
|
||||||
|
export interface Love {
|
||||||
|
musicUId: string
|
||||||
|
createTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近播放历史 */
|
||||||
|
export interface History {
|
||||||
|
musicUId: string
|
||||||
|
/** 最近播放时间戳 */
|
||||||
|
playTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 歌单 id 归属判定 */
|
||||||
|
export const MENU_ID = {
|
||||||
|
/** PC 端歌单 id 上限(含) */
|
||||||
|
PC_MAX: 100
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function isPcMenu(id: number): boolean {
|
||||||
|
return id <= MENU_ID.PC_MAX
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPhoneMenu(id: number): boolean {
|
||||||
|
return id > MENU_ID.PC_MAX
|
||||||
|
}
|
||||||
62
src/shared/protocol/commands.ts
Normal file
62
src/shared/protocol/commands.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket 指令命令字(PC ↔ 移动端)
|
||||||
|
*
|
||||||
|
* 关键兼容性说明:命令字的字符串值必须与旧移动端保持一致,
|
||||||
|
* 以保证新 PC 端能与现有移动端 App 联动。移动端重构时应对照本文件。
|
||||||
|
* 代码中一律使用枚举,禁止裸字符串,避免拼写漂移。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 传歌通道命令(端口 4388) */
|
||||||
|
export enum MusicCmd {
|
||||||
|
/** 握手:交换协议版本,双向 */
|
||||||
|
VERSION = 'version',
|
||||||
|
/** 手机上报系统类型 android/ios */
|
||||||
|
SYSTEM = 'system',
|
||||||
|
/** PC 下发 HTTP 文件服务端口 */
|
||||||
|
PORT = 'port',
|
||||||
|
/** PC 发送待传歌曲列表 */
|
||||||
|
PREPARE = 'prepare',
|
||||||
|
/** 手机回发需要下载(本地缺失)的歌曲列表 */
|
||||||
|
MUSIC_LIST = 'musicList',
|
||||||
|
/** PC 确认最终下载列表 */
|
||||||
|
READY = 'ready',
|
||||||
|
/** PC 逐曲下发下载指令,body = "<musicUId> === <isLast>" */
|
||||||
|
DOWNLOAD = 'download',
|
||||||
|
/** 手机上报下载进度 */
|
||||||
|
DOWNLOADING = 'downloading',
|
||||||
|
/** 单曲下载成功,body = musicUId */
|
||||||
|
DOWNLOAD_SUCCESS = 'download success',
|
||||||
|
/** 单曲下载失败,body = musicUId */
|
||||||
|
DOWNLOAD_FAIL = 'download fail',
|
||||||
|
/** 正常结束 */
|
||||||
|
FINISH = 'finish',
|
||||||
|
/** 中断 */
|
||||||
|
STOP = 'stop',
|
||||||
|
/** 返回/释放(移动端会发) */
|
||||||
|
BACK = 'back'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数据同步通道命令(端口 4389) */
|
||||||
|
export enum DataCmd {
|
||||||
|
/** 握手 */
|
||||||
|
VERSION = 'version',
|
||||||
|
/** 连接确认(须已通过版本校验) */
|
||||||
|
CONNECTED = 'connected',
|
||||||
|
/** 手机 → 电脑:合并我喜欢 + 替换手机歌单 */
|
||||||
|
PHONE_TO_PC = 'phone2pc',
|
||||||
|
/** 电脑 → 手机:替换我喜欢 / PC 歌单 */
|
||||||
|
PC_TO_PHONE = 'pc2phone',
|
||||||
|
/** 结束 */
|
||||||
|
FINISH = 'finish',
|
||||||
|
/** 中断 */
|
||||||
|
STOP = 'stop'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载指令 body 分隔符:`<musicUId> === <isLast>` */
|
||||||
|
export const DOWNLOAD_BODY_SEPARATOR = ' === '
|
||||||
|
|
||||||
|
/** 手机系统类型 */
|
||||||
|
export enum PhoneSystem {
|
||||||
|
ANDROID = 'android',
|
||||||
|
IOS = 'ios'
|
||||||
|
}
|
||||||
4
src/shared/protocol/index.ts
Normal file
4
src/shared/protocol/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './ports'
|
||||||
|
export * from './version'
|
||||||
|
export * from './commands'
|
||||||
|
export * from './types'
|
||||||
22
src/shared/protocol/ports.ts
Normal file
22
src/shared/protocol/ports.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 双端联动端口约定(PC ↔ 移动端)
|
||||||
|
*
|
||||||
|
* 说明:移动端(Flutter)可直接对照本文件同步端口常量。
|
||||||
|
* 这些端口在 PC 端由主进程监听(服务端角色)。
|
||||||
|
*/
|
||||||
|
export const PORTS = {
|
||||||
|
/** WiFi 传歌 WebSocket 指令通道 */
|
||||||
|
MUSIC_WS: 4388,
|
||||||
|
/** 数据同步(我喜欢/歌单)WebSocket 指令通道 */
|
||||||
|
DATA_WS: 4389,
|
||||||
|
/** HTTP 静态文件服务默认端口(可在设置中修改,占用时自动 +1 探测) */
|
||||||
|
HTTP_FILE_DEFAULT: 10000
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** HTTP 文件服务端口可配置范围 */
|
||||||
|
export const HTTP_PORT_RANGE = {
|
||||||
|
MIN: 10000,
|
||||||
|
MAX: 65535
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PortKey = keyof typeof PORTS
|
||||||
90
src/shared/protocol/types.ts
Normal file
90
src/shared/protocol/types.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 协议数据结构(PC ↔ 移动端)
|
||||||
|
*
|
||||||
|
* 与旧移动端 lib/models/ftp_cmd.dart / ftp_music.dart / trans_data.dart 字段一一对应,
|
||||||
|
* 保证向后兼容。新增字段一律可选,避免破坏旧端解析。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 指令载体:所有 WebSocket 消息均为该结构的 JSON 文本 */
|
||||||
|
export interface FtpCmd {
|
||||||
|
cmd: string
|
||||||
|
/**
|
||||||
|
* 字符串负载。可能是纯文本(版本号/系统名),
|
||||||
|
* 也可能是再次 JSON 序列化的复杂对象(歌曲列表/同步数据),接收方需二次 parse。
|
||||||
|
*/
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 序列化/反序列化辅助 */
|
||||||
|
export function encodeFtpCmd(cmd: string, body: string | number = ''): string {
|
||||||
|
return JSON.stringify({ cmd, body: String(body) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeFtpCmd(raw: string): FtpCmd {
|
||||||
|
const obj = JSON.parse(raw)
|
||||||
|
return { cmd: String(obj.cmd), body: obj.body == null ? '' : String(obj.body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC → 手机的歌曲下载描述(对应移动端 DownloadMusic)。
|
||||||
|
* 必填字段必须与 Flutter `ftp_music.dart` 一致,缺字段会导致手机
|
||||||
|
* `downloadMusicFromJson` 解析失败,从而永远不回 musicList。
|
||||||
|
*/
|
||||||
|
export interface DownloadMusic {
|
||||||
|
albumUId: string
|
||||||
|
albumId: number
|
||||||
|
albumName: string
|
||||||
|
coverPath: string
|
||||||
|
date: string
|
||||||
|
category: string
|
||||||
|
group: string
|
||||||
|
musicUId: string
|
||||||
|
musicId: number
|
||||||
|
musicName: string
|
||||||
|
musicPath: string
|
||||||
|
artist: string
|
||||||
|
artistBin: string
|
||||||
|
totalTime: string
|
||||||
|
baseUrl: string
|
||||||
|
neteaseId?: string
|
||||||
|
/** 手机侧判断本地是否已存在 */
|
||||||
|
existFile: boolean
|
||||||
|
/** 重构增强:完整性校验(可选,旧端忽略) */
|
||||||
|
size?: number
|
||||||
|
hash?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据同步载体(对应移动端 `trans_data.dart` / `love.dart`)。
|
||||||
|
* 字段名必须与 Flutter 一致,否则手机解析失败。
|
||||||
|
*/
|
||||||
|
export interface TransData {
|
||||||
|
love: TransLove[]
|
||||||
|
menu: TransMenu[]
|
||||||
|
/** 是否全量覆盖(由手机端开关决定并下发) */
|
||||||
|
isCover: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应移动端 Love:musicId + timestamp */
|
||||||
|
export interface TransLove {
|
||||||
|
musicId: string
|
||||||
|
timestamp: number
|
||||||
|
id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应移动端 TransMenu */
|
||||||
|
export interface TransMenu {
|
||||||
|
/** 歌单 id:PC 歌单 ≤100,手机歌单 >100 */
|
||||||
|
menuId: number
|
||||||
|
name: string
|
||||||
|
date: string
|
||||||
|
musicList: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 握手信息(重构增强,向后兼容:旧端仅发 version 数字) */
|
||||||
|
export interface HandshakeInfo {
|
||||||
|
transVer: number
|
||||||
|
minCompatVer?: number
|
||||||
|
capabilities?: string[]
|
||||||
|
system?: string
|
||||||
|
}
|
||||||
43
src/shared/protocol/version.ts
Normal file
43
src/shared/protocol/version.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 传输协议版本与能力协商(重构增强点)
|
||||||
|
*
|
||||||
|
* 现状(旧版):双端仅比对 transVer,不一致直接断开。
|
||||||
|
* 重构目标:保留 transVer 兼容旧移动端,同时引入 minCompatVer + capabilities,
|
||||||
|
* 支持向后兼容与能力位协商。移动端升级后可读取 capabilities 决定启用哪些新特性。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 当前传输协议版本(与旧移动端 transVer=1 兼容) */
|
||||||
|
export const TRANS_VER = 1
|
||||||
|
|
||||||
|
/** PC 端可兼容的最低移动端协议版本 */
|
||||||
|
export const MIN_COMPAT_VER = 1
|
||||||
|
|
||||||
|
/** 能力位:新增能力在此登记,双端据此协商 */
|
||||||
|
export const CAPABILITIES = {
|
||||||
|
/** 断点续传 */
|
||||||
|
RESUMABLE_DOWNLOAD: 'resumable_download',
|
||||||
|
/** 文件完整性校验(size/hash) */
|
||||||
|
INTEGRITY_CHECK: 'integrity_check',
|
||||||
|
/** 配对 token 鉴权 */
|
||||||
|
PAIRING_TOKEN: 'pairing_token',
|
||||||
|
/** 批量下载并发 */
|
||||||
|
CONCURRENT_DOWNLOAD: 'concurrent_download'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES]
|
||||||
|
|
||||||
|
/** PC 端当前支持的能力集合 */
|
||||||
|
export const SUPPORTED_CAPABILITIES: Capability[] = [
|
||||||
|
CAPABILITIES.INTEGRITY_CHECK,
|
||||||
|
CAPABILITIES.CONCURRENT_DOWNLOAD
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定对端版本是否兼容。
|
||||||
|
* 兼容宽松比较(旧移动端 body 可能是字符串)。
|
||||||
|
*/
|
||||||
|
export function isVersionCompatible(remoteVer: number | string): boolean {
|
||||||
|
const v = typeof remoteVer === 'string' ? parseInt(remoteVer, 10) : remoteVer
|
||||||
|
if (Number.isNaN(v)) return false
|
||||||
|
return v >= MIN_COMPAT_VER
|
||||||
|
}
|
||||||
52
tailwind.config.js
Normal file
52
tailwind.config.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./src/renderer/index.html', './src/renderer/src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sf: [
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"SF Pro Display"',
|
||||||
|
'"SF Pro Text"',
|
||||||
|
'"PingFang SC"',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Inter',
|
||||||
|
'system-ui',
|
||||||
|
'sans-serif'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
// Apple 系统色板
|
||||||
|
apple: {
|
||||||
|
// 主强调色跟随用户设置(--accent-rgb),支持 /透明度 语法
|
||||||
|
blue: 'rgb(var(--accent-rgb) / <alpha-value>)',
|
||||||
|
pink: '#FF375F',
|
||||||
|
purple: '#BF5AF2',
|
||||||
|
teal: '#64D2FF',
|
||||||
|
green: '#30D158',
|
||||||
|
orange: '#FF9F0A',
|
||||||
|
red: '#FF453A',
|
||||||
|
gray: '#8E8E93'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
xl: '14px',
|
||||||
|
'2xl': '20px',
|
||||||
|
'3xl': '28px'
|
||||||
|
},
|
||||||
|
backdropBlur: {
|
||||||
|
apple: '30px'
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
apple: '0 8px 30px rgba(0, 0, 0, 0.12)',
|
||||||
|
'apple-lg': '0 20px 60px rgba(0, 0, 0, 0.18)'
|
||||||
|
},
|
||||||
|
transitionTimingFunction: {
|
||||||
|
apple: 'cubic-bezier(0.25, 0.1, 0.25, 1)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||||
|
}
|
||||||
17
tsconfig.node.json
Normal file
17
tsconfig.node.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
|
"include": [
|
||||||
|
"electron.vite.config.*",
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/preload/**/*",
|
||||||
|
"src/shared/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["electron-vite/node", "node"],
|
||||||
|
"paths": {
|
||||||
|
"@main/*": ["./src/main/*"],
|
||||||
|
"@shared/*": ["./src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
17
tsconfig.web.json
Normal file
17
tsconfig.web.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
|
"include": [
|
||||||
|
"src/renderer/src/**/*",
|
||||||
|
"src/renderer/src/**/*.tsx",
|
||||||
|
"src/preload/*.d.ts",
|
||||||
|
"src/shared/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@renderer/*": ["./src/renderer/src/*"],
|
||||||
|
"@shared/*": ["./src/shared/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
tsconfig.web.tsbuildinfo
Normal file
1
tsconfig.web.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user