initial commit

This commit is contained in:
2026-08-08 18:34:12 +08:00
commit 0d7438e368
82 changed files with 17407 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
out
.DS_Store
*.log*
.vite
.idea
.vscode/*
!.vscode/extensions.json

2
.npmrc Normal file
View 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
View 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 |
| 局域网通信 | wsWebSocket 服务端)+ expressHTTP 文件服务),**运行在主进程** |
| 二维码 | qrcode.react |
| 音频转码 | ffmpeg-staticiOS 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 / libraryStoreZustand
└── lib/ # repositoryDB 访问)/ 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
View 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
View 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

File diff suppressed because it is too large Load Diff

66
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

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
View 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
View 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
View 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
View 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
View 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
})
}

View 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
}
}

View 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()
}
}

View 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」
* - Androidflac 原样拷贝;
* - iOSflac → 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 }
}

View 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 }
}

View 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
}

View 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 }
}

View 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 = []
}

View 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
}

View 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)
}

View 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
}
}

View 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 返回 -1a>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 }
}
}

View 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 */
}
}

View 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 })
}

View 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
View 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
View 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
View 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
View 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>
)
}

View 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>
)
}

View 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
)}
</>
)
}

View 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>
)
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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: '随机播放'
}

View File

@@ -0,0 +1,4 @@
/** 渲染进程内自定义事件名 */
/** 数据同步写入歌单后触发,供歌单页刷新列表 */
export const MENU_SYNCED_EVENT = 'llmp:menus-synced'

View 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
}

View 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
}

View 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 }
}

View 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
View 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>
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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对齐原项目 calcSmallAtIntArrPC 歌单) */
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>
)
}

View 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>
)
}

View 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>
)
}

View 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&gt;100</li>
</ul>
<p className="text-xs text-[var(--text-secondary)]">
PC
</p>
</div>
</div>
</div>
)
}

View 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>
)
}

View 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 })
}))

View 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
}
}))

View 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) })
}
}))

View 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)) })
}
}))

View 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) }))
})
}
}))

View 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
View File

@@ -0,0 +1,3 @@
export * from './protocol'
export * from './models'
export * from './ipc/channels'

View 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
View 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
}

View 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'
}

View File

@@ -0,0 +1,4 @@
export * from './ports'
export * from './version'
export * from './commands'
export * from './types'

View 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

View 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
}
/** 对应移动端 LovemusicId + timestamp */
export interface TransLove {
musicId: string
timestamp: number
id?: number
}
/** 对应移动端 TransMenu */
export interface TransMenu {
/** 歌单 idPC 歌单 ≤100手机歌单 >100 */
menuId: number
name: string
date: string
musicList: string[]
}
/** 握手信息(重构增强,向后兼容:旧端仅发 version 数字) */
export interface HandshakeInfo {
transVer: number
minCompatVer?: number
capabilities?: string[]
system?: string
}

View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}

17
tsconfig.node.json Normal file
View 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/*"]
}
}
}

File diff suppressed because one or more lines are too long

17
tsconfig.web.json Normal file
View 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

File diff suppressed because one or more lines are too long