Skip to content

Commit 0a4772c

Browse files
committed
✨ feat: 新增 Discord RPC
1 parent afdeac2 commit 0a4772c

11 files changed

Lines changed: 293 additions & 8 deletions

File tree

docs/.vitepress/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export default defineConfig({
3838
{ text: "WebSocket API", link: "/socket" },
3939
],
4040
},
41+
{
42+
text: "开发指南",
43+
items: [{ text: "原生插件", link: "/native" }],
44+
},
4145
],
4246

4347
outline: {

docs/native.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# 原生插件集成指南
2+
3+
SPlayer 使用 Rust 编写的原生插件 (`smtc-for-splayer`) 来实现更深度的系统集成,目前主要包括:
4+
5+
- **SMTC (System Media Transport Controls)**: Windows 系统原生的媒体控制支持(系统音量浮窗、锁屏控制、任务栏缩略图控制)。
6+
- **Discord RPC**: 支持在 Discord 状态中展示“正在播放”的歌曲信息。
7+
8+
## 环境准备
9+
10+
在开始开发或构建原生插件之前,您需要确保本地环境满足以下要求。
11+
12+
### 1. 安装 Rust 工具链
13+
14+
项目使用 Rust 编写,需要安装 Rust 编译器和 Cargo 包管理器。
15+
16+
- 访问 [Rust 官网](https://www.rust-lang.org/tools/install) 下载 `rustup-init.exe` 并安装。
17+
- 安装完成后,在终端运行以下命令验证安装:
18+
```bash
19+
rustc --version
20+
cargo --version
21+
```
22+
23+
### 2. 安装 C++ 构建工具 (Windows)
24+
25+
Rust 在 Windows 上通常依赖 MSVC 工具链进行链接。
26+
27+
- 安装 **Visual Studio Build Tools** (或者 Visual Studio Community)。
28+
- 在安装选项中,勾选 **"使用 C++ 的桌面开发" (Desktop development with C++)**
29+
- 确保勾选了 **MSVC v14x ... C++ x64/x86 build tools****Windows 10/11 SDK**
30+
31+
### 3. 安装 Node.js 依赖
32+
33+
项目使用 `@napi-rs/cli` 来构建 Node.js 扩展。通常在运行 `pnpm install` 时会自动安装所需的构建工具。
34+
35+
---
36+
37+
## 构建与安装
38+
39+
项目内置了方便的脚本来处理原生插件的编译和集成。
40+
41+
### 自动构建
42+
43+
在项目根目录下运行以下命令,会自动编译 Rust 代码并将生成的 `.node` 文件移动到正确的位置:
44+
45+
```bash
46+
pnpm build:native
47+
```
48+
49+
此命令会执行以下操作:
50+
51+
1. 调用 `script/build-native.mjs` 脚本。
52+
2. 进入 `native/smtc-for-splayer` 目录。
53+
3. 运行 `napi build --release` 进行优化的发布版编译。
54+
4. 生成的二进制文件会被放置在项目所需的位置,供 Electron 加载。
55+
56+
### 手动构建 (调试用)
57+
58+
如果您需要调试 Rust 代码,可以进入插件目录手动构建:
59+
60+
```bash
61+
cd native/smtc-for-splayer
62+
pnpm build # 构建 release 版本
63+
pnpm build:debug # 构建 debug 版本
64+
```
65+
66+
---
67+
68+
## 常见问题排查
69+
70+
### 1. `Error: The specified module could not be found`
71+
72+
如果在启动 Electron 时遇到此错误,通常是因为:
73+
74+
- **未编译插件**:请先运行 `pnpm build:native`
75+
- **架构不匹配**:确保您的 Node.js/Electron 架构(通常是 x64)与 Rust 编译目标一致。
76+
77+
### 2. `LINK : fatal error LNK1181: cannot open input file ...`
78+
79+
这是缺少 Windows SDK 或 C++ 构建工具的典型错误。
80+
81+
- 请检查 Visual Studio Build Tools 是否正确安装了 C++ 桌面开发组件。
82+
83+
### 3. 插件功能未生效
84+
85+
- **SMTC**: 仅在 Windows 10/11 上可用。请检查系统设置中的“系统 > 声音”或锁屏界面是否出现了媒体控件。
86+
- **Discord RPC**: 需要 Discord 客户端在后台运行。可以在设置中检查“显示 Discord 状态”开关是否开启。
87+
88+
### 4. 日志查看
89+
90+
原生插件的日志默认记录在应用数据目录下的 `logs/smtc/` 文件夹中。
91+
92+
- 开发环境日志路径参考: `native/smtc-for-splayer/smtc-for-splayer.log` (取决于具体配置)
93+
- 生产环境:`%APPDATA%/SPlayer/logs/smtc/`
94+
95+
---
96+
97+
## 开发指南
98+
99+
如果您希望贡献或修改原生插件代码,请参考以下结构:
100+
101+
- **入口**: `native/smtc-for-splayer/src/lib.rs` (定义了导出给 JS 的函数)
102+
- **核心逻辑**:
103+
- `smtc_core.rs`: SMTC 的核心实现,处理 Windows API 调用。
104+
- `discord.rs`: Discord RPC 的连接与状态更新逻辑,包含重连机制和防抖处理。
105+
- **类型定义**: `native/smtc-for-splayer/index.d.ts` (自动生成,供 TypeScript 使用)

electron/main/ipc/ipc-smtc.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,33 @@ export default function initSmtcIpc() {
7575
registerSmtcHandler("smtc-update-play-mode", (mod, payload) => {
7676
mod.updatePlayMode(payload);
7777
});
78+
79+
// Discord - 开启
80+
ipcMain.on("smtc-enable-discord", () => {
81+
if (nativeSmtc) {
82+
try {
83+
nativeSmtc.enableDiscordRpc();
84+
} catch (e) {
85+
processLog.error("[SMTC] 启用 Discord RPC 失败", e);
86+
}
87+
}
88+
});
89+
90+
// Discord - 关闭
91+
ipcMain.on("smtc-disable-discord", () => {
92+
if (nativeSmtc) {
93+
try {
94+
nativeSmtc.disableDiscordRpc();
95+
} catch (e) {
96+
processLog.error("[SMTC] 禁用 Discord RPC 失败", e);
97+
}
98+
}
99+
});
100+
101+
// Discord - 更新配置
102+
registerSmtcHandler("smtc-update-discord-config", (mod, payload) => {
103+
mod.updateDiscordConfig(payload);
104+
});
78105
}
79106

80107
export function shutdownSmtc() {

native/smtc-for-splayer/src/discord.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use crate::model::{
1414
TimelinePayload,
1515
};
1616

17-
const APP_ID: &str = "1427186361827594375";
18-
const NCM_ICON_ASSET_KEY: &str = "ncm_icon";
17+
const APP_ID: &str = "1454403710162698293";
18+
const NCM_ICON_ASSET_KEY: &str = "logo-icon";
1919

2020
// 主要用来应对跳转进度的更新
2121
const TIMESTAMP_UPDATE_THRESHOLD_MS: i64 = 100;
27.1 KB
Loading

src/components/Setting/ThirdSetting.vue

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
<n-text class="name">API Key</n-text>
1717
<n-text class="tip" :depth="3">
1818
19-
<n-a href="https://www.last.fm/zh/api/account/create" target="_blank">Last.fm 创建应用</n-a>
19+
<n-a href="https://www.last.fm/zh/api/account/create" target="_blank">
20+
Last.fm 创建应用
21+
</n-a>
2022
获取,只有「程序名称」是必要的
2123
</n-text>
2224
<n-text class="tip" :depth="3">
2325
如果已经创建过,则可以在
24-
<n-a href="https://www.last.fm/zh/api/accounts" target="_blank">Last.fm API 应用程序</n-a>
26+
<n-a href="https://www.last.fm/zh/api/accounts" target="_blank">
27+
Last.fm API 应用程序
28+
</n-a>
2529
处查看
2630
</n-text>
2731
</div>
@@ -92,7 +96,51 @@
9296
</n-card>
9397
</n-collapse-transition>
9498
</div>
95-
99+
<div v-if="isElectron && isWin" class="set-list">
100+
<n-h3 prefix="bar"> Discord RPC </n-h3>
101+
<n-card class="set-item">
102+
<div class="label">
103+
<n-text class="name">启用 Discord RPC</n-text>
104+
<n-text class="tip" :depth="3"> 在 Discord 状态中显示正在播放的歌曲 </n-text>
105+
</div>
106+
<n-switch
107+
class="set"
108+
v-model:value="settingStore.discordRpc.enabled"
109+
:round="false"
110+
@update:value="handleDiscordEnabledUpdate"
111+
/>
112+
</n-card>
113+
<n-collapse-transition :show="settingStore.discordRpc.enabled">
114+
<n-card class="set-item">
115+
<div class="label">
116+
<n-text class="name">暂停时显示</n-text>
117+
<n-text class="tip" :depth="3">暂停播放时是否保留 Discord 状态</n-text>
118+
</div>
119+
<n-switch
120+
class="set"
121+
v-model:value="settingStore.discordRpc.showWhenPaused"
122+
:round="false"
123+
@update:value="handleDiscordConfigUpdate"
124+
/>
125+
</n-card>
126+
<n-card class="set-item">
127+
<div class="label">
128+
<n-text class="name">显示模式</n-text>
129+
<n-text class="tip" :depth="3">选择在 Discord 状态中展示的内容层级</n-text>
130+
</div>
131+
<n-select
132+
class="set"
133+
v-model:value="settingStore.discordRpc.displayMode"
134+
:options="[
135+
{ label: '完整信息 (歌曲名/歌手)', value: 'details' },
136+
{ label: '仅歌曲名', value: 'name' },
137+
{ label: '仅播放状态', value: 'state' },
138+
]"
139+
@update:value="handleDiscordConfigUpdate"
140+
/>
141+
</n-card>
142+
</n-collapse-transition>
143+
</div>
96144
<div v-if="isElectron" class="set-list">
97145
<n-h3 prefix="bar"> WebSocket 配置 </n-h3>
98146
<n-card class="set-item">
@@ -143,7 +191,8 @@
143191
<script setup lang="ts">
144192
import { useSettingStore } from "@/stores";
145193
import { getAuthToken, getAuthUrl, getSession } from "@/api/lastfm";
146-
import { isElectron } from "@/utils/env";
194+
import { isElectron, isWin } from "@/utils/env";
195+
import { enableDiscordRpc, disableDiscordRpc, updateDiscordConfig } from "@/core/player/PlayerIpc";
147196
148197
const settingStore = useSettingStore();
149198
@@ -238,6 +287,25 @@ const disconnectLastfm = () => {
238287
});
239288
};
240289
290+
// Discord RPC
291+
const handleDiscordEnabledUpdate = (val: boolean) => {
292+
if (val) {
293+
enableDiscordRpc();
294+
// 启用时同步一次配置
295+
handleDiscordConfigUpdate();
296+
} else {
297+
disableDiscordRpc();
298+
}
299+
};
300+
301+
const handleDiscordConfigUpdate = () => {
302+
if (!settingStore.discordRpc.enabled) return;
303+
updateDiscordConfig({
304+
showWhenPaused: settingStore.discordRpc.showWhenPaused,
305+
displayMode: settingStore.discordRpc.displayMode,
306+
});
307+
};
308+
241309
// 初始化 socket 配置
242310
const initSocketConfig = async () => {
243311
if (!isElectron) return;

src/core/player/MediaSessionManager.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { msToS } from "@/utils/time";
66
import { type SmtcEvent } from "@native";
77
import { usePlayerController } from "./PlayerController";
88
import { SmtcEventType, PlaybackStatus } from "@/types/smtc";
9-
import { sendSmtcMetadata, sendSmtcTimeline, sendSmtcPlayState } from "./PlayerIpc";
9+
import {
10+
sendSmtcMetadata,
11+
sendSmtcTimeline,
12+
sendSmtcPlayState,
13+
enableDiscordRpc,
14+
updateDiscordConfig,
15+
} from "./PlayerIpc";
1016

1117
/**
1218
* 媒体会话管理器,负责控制媒体控件相关功能
@@ -65,6 +71,15 @@ class MediaSessionManager {
6571
});
6672

6773
player.syncSmtcPlayMode();
74+
75+
// 初始化 Discord RPC
76+
if (settingStore.discordRpc.enabled) {
77+
enableDiscordRpc();
78+
updateDiscordConfig({
79+
showWhenPaused: settingStore.discordRpc.showWhenPaused,
80+
displayMode: settingStore.discordRpc.displayMode,
81+
});
82+
}
6883
return;
6984
}
7085

src/core/player/PlayerIpc.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { throttle } from "lodash-es";
22
import { isElectron, isWin } from "@/utils/env";
33
import { getPlaySongData } from "@/utils/format";
44
import { type MetadataParam } from "@native";
5-
import { RepeatMode, PlaybackStatus } from "@/types/smtc";
5+
import { RepeatMode, PlaybackStatus, DiscordDisplayMode } from "@/types/smtc";
66

77
/**
88
* 发送播放状态
@@ -131,3 +131,39 @@ export const sendSmtcPlayMode = (isShuffling: boolean, repeatMode: RepeatMode) =
131131
if (isElectron && isWin)
132132
window.electron.ipcRenderer.send("smtc-update-play-mode", { isShuffling, repeatMode });
133133
};
134+
135+
/**
136+
* @description 启用 Discord RPC
137+
*/
138+
export const enableDiscordRpc = () => {
139+
if (isElectron && isWin) window.electron.ipcRenderer.send("smtc-enable-discord");
140+
};
141+
142+
/**
143+
* @description 禁用 Discord RPC
144+
*/
145+
export const disableDiscordRpc = () => {
146+
if (isElectron && isWin) window.electron.ipcRenderer.send("smtc-disable-discord");
147+
};
148+
149+
/**
150+
* @description 更新 Discord RPC 配置
151+
* @param payload 配置信息
152+
*/
153+
export const updateDiscordConfig = (payload: {
154+
showWhenPaused: boolean;
155+
displayMode: "name" | "state" | "details";
156+
}) => {
157+
if (isElectron && isWin) {
158+
const modeMap: Record<string, DiscordDisplayMode> = {
159+
name: DiscordDisplayMode.Name,
160+
state: DiscordDisplayMode.State,
161+
details: DiscordDisplayMode.Details,
162+
};
163+
164+
window.electron.ipcRenderer.send("smtc-update-discord-config", {
165+
showWhenPaused: payload.showWhenPaused,
166+
displayMode: modeMap[payload.displayMode] ?? DiscordDisplayMode.Name,
167+
});
168+
}
169+
};

src/stores/setting.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,15 @@ export interface SettingState {
274274
progressLyricShow: boolean;
275275
/** 是否使用自定义字体输入 */
276276
useCustomFont: boolean;
277+
/** Discord RPC 配置 */
278+
discordRpc: {
279+
/** 是否启用 Discord RPC */
280+
enabled: boolean;
281+
/** 暂停时显示 */
282+
showWhenPaused: boolean;
283+
/** 显示模式 */
284+
displayMode: "name" | "state" | "details";
285+
};
277286
}
278287

279288
export const useSettingStore = defineStore("setting", {
@@ -417,6 +426,11 @@ export const useSettingStore = defineStore("setting", {
417426
playerFollowCoverColor: true,
418427
progressLyricShow: true,
419428
useCustomFont: false,
429+
discordRpc: {
430+
enabled: false,
431+
showWhenPaused: true,
432+
displayMode: "name",
433+
},
420434
}),
421435
getters: {
422436
/**

src/types/global.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import type {
66
TimelinePayload,
77
PlayModePayload,
88
SmtcEvent,
9+
DiscordConfigPayload,
910
} from "@native";
1011

1112
export interface IpcChannelMap {
1213
"smtc-update-metadata": MetadataParam;
1314
"smtc-update-play-state": PlayStatePayload;
1415
"smtc-update-timeline": TimelinePayload;
1516
"smtc-update-play-mode": PlayModePayload;
17+
"smtc-enable-discord": void;
18+
"smtc-disable-discord": void;
19+
"smtc-update-discord-config": DiscordConfigPayload;
1620
}
1721

1822
declare global {

0 commit comments

Comments
 (0)