Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Commit ff10863

Browse files
authored
Merge branch 'dev' into pr/f
2 parents 8827b08 + eaf43b4 commit ff10863

52 files changed

Lines changed: 2116 additions & 947 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,7 @@ target
3636
docs/.vitepress/cache
3737
docs/.vitepress/dist
3838

39+
# AI
3940
.trae/*
41+
.cursor/*
42+
.claude/*

CLAUDE.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
SPlayer is a desktop music player built with **Electron + Vue 3 + TypeScript**. It uses Naive UI for components, Pinia for state management, and integrates with NetEase Cloud Music API, Last.fm, and Subsonic/Navidrome streaming services. Native Rust modules provide OS-level features (taskbar lyrics on Windows, MPRIS on Linux, SMTC on Windows, Discord RPC).
8+
9+
## Commands
10+
11+
```bash
12+
pnpm dev # Start dev environment (builds native modules + launches Electron)
13+
pnpm build # Full production build (typecheck + electron-vite build)
14+
pnpm build:win # Package for Windows
15+
pnpm build:mac # Package for macOS
16+
pnpm build:linux # Package for Linux
17+
pnpm lint # ESLint (--max-warnings=0, zero tolerance)
18+
pnpm format # Prettier
19+
pnpm typecheck # Full TypeScript check (node + web)
20+
pnpm typecheck:node # Main process + preload TypeScript check
21+
pnpm typecheck:web # Renderer process TypeScript check
22+
```
23+
24+
Set `SKIP_NATIVE_BUILD=true` to skip Rust native module compilation during dev.
25+
26+
## Architecture
27+
28+
### Process Model (Electron)
29+
30+
- **Main process** (`electron/main/`): Window management, IPC handlers, SQLite database, system tray, global shortcuts, Fastify API server, native module integration
31+
- **Preload** (`electron/preload/`): Context bridge exposing `window.api.store` and `window.logger` to renderer
32+
- **Renderer** (`src/`): Vue 3 SPA — the UI
33+
34+
### IPC Layer
35+
36+
18 IPC modules in `electron/main/ipc/` handle all main↔renderer communication: `ipc-cache`, `ipc-file`, `ipc-lyric`, `ipc-media`, `ipc-mpv`, `ipc-socket`, `ipc-store`, `ipc-taskbar`, `ipc-tray`, `ipc-window`, `ipc-system`, `ipc-shortcut`, `ipc-update`, `ipc-protocol`, `ipc-mac-statusbar`, `ipc-thumbar`, `ipc-renderer-log`.
37+
38+
### Renderer Architecture (`src/`)
39+
40+
- **Stores** (`stores/`): Pinia with persistedstate — `data` (songs/user), `status` (playback), `setting` (config), `local` (local music), `music`, `streaming`, `shortcut`
41+
- **Core** (`core/`): `audio-player/` (playback engine), `automix/`, `player/` (state), `resource/` (caching)
42+
- **API** (`api/`): Axios-based, organized by domain (song, playlist, login, streaming, lastfm)
43+
- **Composables** (`composables/`): `useInit`, `useSongMenu`, `useQualityControl`, etc.
44+
- **Components** (`components/`): AMLL (lyrics), Card, Common, Global, Layout, List, Menu, Modal, Player, Search, Setting, UI
45+
46+
### Native Modules (`native/`)
47+
48+
Rust-based, built via `scripts/build-native.ts`:
49+
- `taskbar-lyric` — Windows taskbar lyrics display
50+
- `external-media-integration` — OS media integration
51+
- `smtc-for-splayer` — Windows System Media Transport Controls
52+
- `mpris-for-splayer` — Linux MPRIS support
53+
- `discord-rpc-for-splayer` — Discord Rich Presence
54+
- `ferrous-opencc-wasm` — Chinese character conversion (WASM)
55+
56+
### Embedded Server
57+
58+
`electron/server/` runs a Fastify instance (port 25884 default) wrapping NetEase Cloud Music API, proxied via `/api` in dev.
59+
60+
## Path Aliases
61+
62+
```
63+
@/ → src/
64+
@emi/ → native/external-media-integration
65+
@shared/ → src/types/shared
66+
@opencc/ → native/ferrous-opencc-wasm/pkg
67+
@native/ → native/
68+
```
69+
70+
## Code Conventions
71+
72+
- **Language**: Comments and commit messages in Chinese
73+
- **Vue**: Composition API with `<script setup>`, TypeScript throughout
74+
- **Auto-imports**: Vue, vue-router, @vueuse/core, and naive-ui composables are auto-imported (no explicit imports needed)
75+
- **Naive UI components**: Auto-resolved via `unplugin-vue-components`
76+
- **Unused variables**: Prefix with `_` to suppress lint warnings
77+
- **Prettier**: Double quotes, trailing commas, 2-space indent, 100 char width
78+
- **Workers**: Heavy computation (audio analysis) runs in worker threads (`electron/main/workers/`)
79+
- **TypeScript**: Composite project — `tsconfig.node.json` (main/preload/scripts) and `tsconfig.web.json` (renderer)

electron/main/ipc/ipc-cache.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,17 @@ const initCacheIpc = (): void => {
115115
});
116116

117117
// 检查是否存在音乐缓存
118-
ipcMain.handle("music-cache-check", async (_event, id: number | string, quality?: string) => {
119-
try {
120-
return await musicCacheService.hasCache(id, quality);
121-
} catch (error) {
122-
processLog.error("Check music cache failed:", error);
123-
return null;
124-
}
125-
});
118+
ipcMain.handle(
119+
"music-cache-check",
120+
async (_event, id: number | string, quality?: string, md5?: string) => {
121+
try {
122+
return await musicCacheService.hasCache(id, quality, md5);
123+
} catch (error) {
124+
processLog.error("Check music cache failed:", error);
125+
return null;
126+
}
127+
},
128+
);
126129

127130
// 下载并缓存音乐
128131
ipcMain.handle(

electron/main/ipc/ipc-taskbar.ts

Lines changed: 30 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -8,56 +8,26 @@ import taskbarLyricManager from "../utils/taskbar-lyric-manager";
88

99
let cachedIsPlaying = false;
1010

11+
/** 读取完整任务栏配置 */
1112
const getTaskbarConfig = (): TaskbarConfig => {
12-
const store = useStore();
13-
return {
14-
mode: store.get("taskbar.mode", "taskbar"),
15-
maxWidth: store.get("taskbar.maxWidth", 300),
16-
position: store.get("taskbar.position", "automatic"),
17-
autoShrink: store.get("taskbar.autoShrink", false),
18-
margin: store.get("taskbar.margin", 10),
19-
minWidth: store.get("taskbar.minWidth", 10),
20-
enabled: store.get("taskbar.enabled", false),
21-
floatingAlign: store.get("taskbar.floatingAlign", "right"),
22-
floatingAutoWidth: store.get("taskbar.floatingAutoWidth", true),
23-
floatingWidth: store.get("taskbar.floatingWidth", 300),
24-
floatingHeight: store.get("taskbar.floatingHeight", 48),
25-
floatingAlwaysOnTop: store.get("taskbar.floatingAlwaysOnTop", false),
26-
27-
showWhenPaused: store.get("taskbar.showWhenPaused", true),
28-
showCover: store.get("taskbar.showCover", true),
29-
themeMode: store.get("taskbar.themeMode", "auto"),
30-
fontFamily: store.get("taskbar.fontFamily", ""),
31-
globalFont: store.get("taskbar.globalFont", ""),
32-
fontWeight: store.get("taskbar.fontWeight", 0),
33-
animationMode: store.get("taskbar.animationMode", "slide-blur"),
34-
singleLineMode: store.get("taskbar.singleLineMode", false),
35-
showTranslation: store.get("taskbar.showTranslation", true),
36-
showRomaji: store.get("taskbar.showRomaji", true),
37-
showWordLyrics: store.get("taskbar.showWordLyrics", true),
38-
};
13+
return useStore().get("taskbar");
3914
};
4015

16+
/** 根据配置更新窗口可见性 */
4117
const updateWindowVisibility = (config: TaskbarConfig) => {
4218
const tray = getMainTray();
43-
44-
if (tray) {
45-
tray.setTaskbarLyricShow(config.enabled);
19+
if (tray) tray.setTaskbarLyricShow(config.enabled);
20+
if (!config.enabled) {
21+
taskbarLyricManager.close(false);
22+
return;
4623
}
47-
48-
const shouldBeVisible = config.enabled && (cachedIsPlaying || config.showWhenPaused);
49-
24+
taskbarLyricManager.create(config.mode);
25+
const shouldBeVisible = cachedIsPlaying || config.showWhenPaused;
5026
taskbarLyricManager.setVisibility(shouldBeVisible);
5127
};
5228

53-
const updateWindowLayout = (animate: boolean = true) => {
54-
taskbarLyricManager.updateLayout(animate);
55-
};
56-
5729
const initTaskbarIpc = () => {
58-
// 在函数内部获取 store,确保在 app ready 事件之后
5930
const store = useStore();
60-
6131
const initialConfig = getTaskbarConfig();
6232
if (initialConfig.enabled) {
6333
taskbarLyricManager.create(initialConfig.mode);
@@ -68,67 +38,30 @@ const initTaskbarIpc = () => {
6838
taskbarLyricManager.setContentWidth(width);
6939
});
7040

71-
ipcMain.on(
72-
TASKBAR_IPC_CHANNELS.UPDATE_CONFIG,
73-
(_event, partialConfig: Partial<TaskbarConfig>) => {
74-
const oldConfig = getTaskbarConfig();
41+
ipcMain.handle(TASKBAR_IPC_CHANNELS.GET_OPTION, () => getTaskbarConfig());
7542

76-
Object.entries(partialConfig).forEach(([key, value]) => {
43+
// 设置配置(增量合并)
44+
ipcMain.on(
45+
TASKBAR_IPC_CHANNELS.SET_OPTION,
46+
(_event, option: Partial<TaskbarConfig>, pushToWindow = true) => {
47+
if (!option) return;
48+
// 增量更新
49+
const prev = getTaskbarConfig();
50+
const next = { ...prev, ...option };
51+
Object.entries(option).forEach(([key, value]) => {
7752
store.set(`taskbar.${key}`, value);
7853
});
79-
80-
const newConfig = getTaskbarConfig();
81-
82-
const modeChanged = newConfig.mode !== oldConfig.mode;
83-
84-
if (modeChanged) {
85-
taskbarLyricManager.close(false);
54+
// 推送到歌词窗口
55+
if (pushToWindow) {
56+
taskbarLyricManager.send(TASKBAR_IPC_CHANNELS.SYNC_STATE, {
57+
type: "config-update",
58+
data: option,
59+
} as SyncStatePayload);
8660
}
87-
88-
if (newConfig.enabled && (!oldConfig.enabled || modeChanged)) {
89-
taskbarLyricManager.create(newConfig.mode);
61+
updateWindowVisibility(next);
62+
if (next.enabled) {
63+
taskbarLyricManager.updateLayout(false);
9064
}
91-
92-
if (
93-
newConfig.enabled !== oldConfig.enabled ||
94-
newConfig.showWhenPaused !== oldConfig.showWhenPaused ||
95-
modeChanged
96-
) {
97-
updateWindowVisibility(newConfig);
98-
}
99-
100-
if (newConfig.enabled) {
101-
if (newConfig.mode === "taskbar") {
102-
if (
103-
newConfig.maxWidth !== oldConfig.maxWidth ||
104-
newConfig.position !== oldConfig.position ||
105-
newConfig.autoShrink !== oldConfig.autoShrink ||
106-
newConfig.margin !== oldConfig.margin ||
107-
newConfig.minWidth !== oldConfig.minWidth
108-
) {
109-
updateWindowLayout(true);
110-
}
111-
} else {
112-
const floatingWidthChanged =
113-
newConfig.floatingAutoWidth === false && newConfig.floatingWidth !== oldConfig.floatingWidth;
114-
if (
115-
newConfig.maxWidth !== oldConfig.maxWidth ||
116-
newConfig.floatingAlign !== oldConfig.floatingAlign ||
117-
newConfig.floatingAutoWidth !== oldConfig.floatingAutoWidth ||
118-
floatingWidthChanged ||
119-
newConfig.floatingHeight !== oldConfig.floatingHeight ||
120-
newConfig.floatingAlwaysOnTop !== oldConfig.floatingAlwaysOnTop ||
121-
modeChanged
122-
) {
123-
updateWindowLayout(false);
124-
}
125-
}
126-
}
127-
128-
taskbarLyricManager.send(TASKBAR_IPC_CHANNELS.SYNC_STATE, {
129-
type: "config-update",
130-
data: partialConfig,
131-
} as SyncStatePayload);
13265
},
13366
);
13467

@@ -174,9 +107,8 @@ const initTaskbarIpc = () => {
174107
// 把事件发射到 app 里不太好,但是我觉得也没有必要为了这一个事件创建一个事件总线
175108
// TODO: 如果有了事件总线,通过那个事件总线发射这个事件
176109
(app as EventEmitter).on("explorer-restarted", () => {
177-
const currentEnabled = store.get("taskbar.enabled");
178-
const currentMode = store.get("taskbar.mode", "taskbar");
179-
if (currentEnabled && currentMode === "taskbar") {
110+
const currentConfig = getTaskbarConfig();
111+
if (currentConfig.enabled && currentConfig.mode === "taskbar") {
180112
taskbarLyricManager.close(false);
181113
setTimeout(() => {
182114
taskbarLyricManager.create("taskbar");

electron/main/ipc/ipc-window.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { app, BrowserWindow, ipcMain } from "electron";
2+
import { MpvService } from "../services/MpvService";
23
import { useStore } from "../store";
34
import { isDev } from "../utils/config";
45
import { initThumbar } from "../thumbar";
56
import { processProtocolFromCommand } from "../utils/protocol";
67
import mainWindow from "../windows/main-window";
78
import loadWindow from "../windows/load-window";
89
import loginWindow from "../windows/login-window";
10+
import { processLog } from "../logger";
911

1012
/** 是否已首次启动 */
1113
let isFirstLaunch = false;
@@ -137,9 +139,21 @@ const initWindowsIpc = (): void => {
137139
});
138140

139141
// 重启
140-
ipcMain.on("win-restart", () => {
142+
ipcMain.on("win-restart", async () => {
143+
// 先停止 MPV 服务,避免占用资源
144+
const mpvService = MpvService.getInstance();
145+
try {
146+
await mpvService.stop();
147+
processLog.info("MPV 进程已停止");
148+
} catch (err) {
149+
processLog.error("停止 MPV 进程失败", err);
150+
} finally {
151+
mpvService.terminate();
152+
}
153+
154+
// 重启应用
141155
app.relaunch();
142-
app.quit();
156+
app.exit(0);
143157
});
144158

145159
// 向主窗口发送事件

electron/main/services/MpvService.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export class MpvService {
6868
"--demuxer-max-bytes=120MiB", // 增大缓存容量
6969
"--demuxer-readahead-secs=120", // 增加预读时间
7070
`--audio-device=${this.currentAudioDevice}`, // 使用当前设置的音频设备
71+
"--audio-channels=auto", // 自动检测音频通道
72+
"--demuxer-lavf-o=fflags=+discardcorrupt", // 容错处理,忽略损坏的数据
7173
];
7274

7375
// 关闭自动播放时,用启动参数强制暂停,避免启动后短暂自动播放
@@ -83,12 +85,21 @@ export class MpvService {
8385
//processLog.info("正在启动 MPV 进程...", args);
8486

8587
try {
86-
this.mpvProcess = spawn("mpv", args, { stdio: "ignore" });
88+
this.mpvProcess = spawn("mpv", args, { stdio: ["ignore", "pipe", "pipe"] });
8789
// 将当前进程与本次播放请求关联,便于区分旧进程退出与新进程生命周期
8890
this.mpvProcessNonce = this.playNonce;
8991

90-
this.mpvProcess.on("exit", () => {
91-
//processLog.warn(`MPV 进程已退出`);
92+
// 捕获 stdout 和 stderr 用于调试
93+
this.mpvProcess.stdout?.on("data", (data) => {
94+
processLog.info("MPV stdout:", data.toString());
95+
});
96+
97+
this.mpvProcess.stderr?.on("data", (data) => {
98+
processLog.error("MPV stderr:", data.toString());
99+
});
100+
101+
this.mpvProcess.on("exit", (code, signal) => {
102+
processLog.warn(`MPV 进程已退出,退出码: ${code}, 信号: ${signal}`);
92103
this.mpvProcess = null;
93104
this.isConnected = false;
94105
this.client = null;

0 commit comments

Comments
 (0)