Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,18 @@

<!-- 这个 PR 做了什么、为什么这么做(动机 + 主要变更),尽量描述清楚 -->



## 关联 Issue

<!-- 如有关联,填 #编号;写 "Closes #编号" 可在合并时自动关闭对应 Issue -->



## 测试情况

<!-- 如何验证这些改动?手动复现步骤、覆盖到的平台(Windows / macOS / Linux)等 -->



## 截图 / 录屏

<!-- 涉及 UI 改动请附上;无则可删除本节 -->



## 自查清单

- [ ] 本 PR 只包含**一个主要功能 / 修复**,没有夹带无关改动
Expand Down
134 changes: 119 additions & 15 deletions docs/plugins/control.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

阅读本文前请先了解 [插件总览与架构](/plugins/),其中的通用 API 对控制插件同样适用。

::: warning 需要 apiLevel 2
脚本头部必须声明 `@type control``@apiLevel 2`,否则控制能力在运行时不可用
::: warning 需要 apiLevel
脚本头部必须声明 `@type control`。监听播放事件、反向控制和设置项需要 `@apiLevel 2`;如果要在播放栏注册按钮,需要 `@apiLevel 3`
:::

## 快速开始
Expand All @@ -16,6 +16,7 @@
* @version 1.0.0
* @description 示例控制插件
* @author you
* @id your-plugin-id
* @type control
* @apiLevel 2
*/
Expand Down Expand Up @@ -55,11 +56,12 @@ splayer.register({
});
```

| 字段 | 类型 | 必填 | 说明 |
| ---------- | --------------------- | ---- | -------------------------------------------------- |
| `events` | `PlaybackEventKind[]` | | 要订阅的播放事件,未声明的事件不会下发 |
| `controls` | `boolean` | | 是否使用反向播放控制(`splayer.player.play()` 等) |
| `settings` | `PluginSettingItem[]` | | 用户可配置项,渲染到插件管理的设置弹窗 |
| 字段 | 类型 | 必填 | 说明 |
| ---------- | ---------------------- | ---- | -------------------------------------------------- |
| `events` | `PlaybackEventKind[]` | | 要订阅的播放事件,未声明的事件不会下发 |
| `controls` | `boolean` | | 是否使用反向播放控制(`splayer.player.play()` 等) |
| `settings` | `PluginSettingItem[]` | | 用户可配置项,渲染到插件管理的设置弹窗 |
| `ui` | `PluginUiContribution` | | UI 扩展声明;目前仅支持播放栏按钮,需 `apiLevel 3` |

::: tip 只发订阅的事件
宿主只会向插件推送它在 `events` 里声明过的事件,未声明的不会推送。插件启用时,宿主会立即补发一次当前状态快照(当前曲目、歌词、播放态、当前行),无需自己拉取初始值。
Expand All @@ -75,14 +77,16 @@ splayer.player.on(kind, (data) => { ... });

### `trackChange` — 曲目切换

| 字段 | 类型 | 说明 |
| ---------------- | ---------------- | ------------------------ |
| `track` | `object \| null` | 当前曲目,`null` 表示无 |
| `track.title` | `string` | 标题 |
| `track.artists` | `string` | 艺术家(已用 `, ` 拼接) |
| `track.album` | `string?` | 专辑名 |
| `track.duration` | `number` | 时长(毫秒) |
| `track.cover` | `string?` | 封面地址 |
| 字段 | 类型 | 说明 |
| ---------------- | ---------------- | ------------------------------------------------------- |
| `track` | `object \| null` | 当前曲目,`null` 表示无 |
| `track.id` | `string` | 平台或本地曲目 ID |
| `track.source` | `string` | `local` / `streaming` / `netease` / `qqmusic` / `kugou` |
| `track.title` | `string` | 标题 |
| `track.artists` | `string` | 艺术家(已用 `, ` 拼接) |
| `track.album` | `string?` | 专辑名 |
| `track.duration` | `number` | 时长(毫秒) |
| `track.cover` | `string?` | 封面地址 |

### `lyricChange` — 歌词整体变化

Expand Down Expand Up @@ -224,6 +228,59 @@ splayer.onSettingChange("enabled", (value) => {

宿主会按声明的 `type` 对写入值做校验/强转(如 `switch` 转布尔、`number` 按 `min`/`max` 夹取、`select` 校验合法选项),插件读到的始终是规范化后的值。

## 播放栏按钮

`apiLevel 3` 控制插件可以声明播放栏按钮。宿主负责渲染按钮,插件只处理命令;插件不能直接注入 Vue 组件或操作 DOM。

```js
splayer.register({
ui: {
playerBarButtons: [
{
id: "submit-current",
label: "投稿",
tooltip: "执行当前歌曲操作",
icon: "send",
placement: "track-actions",
},
],
},
});

splayer.ui.onCommand("submit-current", async ({ track }) => {
if (!track) throw new Error("当前没有歌曲");
splayer.log.info("提交:", track.title, track.artists);
return { message: "已提交" };
});
```

按钮声明限制:

| 字段 | 规则 |
| ----------- | -------------------------------------------------------------------- |
| 数量 | 每个插件最多 4 个播放栏按钮 |
| `id` | 1-64 字符,仅允许字母、数字、`_`、`.`、`:`、`-` |
| `label` | 1-24 字符 |
| `tooltip` | 可选,最多 80 字符 |
| `icon` | `send` / `upload` / `radio` / `external-link` / `bookmark` / `heart` |
| `placement` | 目前只支持 `track-actions`;不填时按 `track-actions` 处理 |

无当前歌曲时按钮会自动禁用。命令执行中按钮显示加载态;处理器返回 `{ message: "..." }` 时宿主显示该消息,否则显示“已完成”。命令抛错或 20 秒内没有返回时,宿主显示错误提示。

命令上下文里的 `track` 与 `trackChange` 使用同一套安全字段,不包含本地路径、文件标签、音频详情等敏感或重型数据:

```js
{
id: "123",
source: "netease",
title: "Song",
artists: "Artist",
album: "Album",
duration: 180000,
cover: "https://...",
}
```

## 完整示例

比如这是一个把当前歌词(含翻译)推送到 [ClassIsland](https://github.com/ClassIsland/ClassIsland) 主界面的控制插件:订阅曲目/歌词/行变化,按用户设置决定端口、是否带翻译、无翻译时是否回退到下一行。
Expand All @@ -233,6 +290,7 @@ splayer.onSettingChange("enabled", (value) => {
* @name ClassIsland 联动
* @version 1.0.0
* @author imsyy
* @id your-plugin-id
* @type control
* @apiLevel 2
* @description 把当前歌词推送到 ClassIsland 主界面
Expand Down Expand Up @@ -300,6 +358,52 @@ splayer.player.on("lineChange", ({ index }) => {
});
```

## 播放栏按钮示例

下面示例演示控制插件如何注册一个播放栏按钮,并在点击时接收当前歌曲的安全快照。这个例子用的是更通用的“稍后听”场景,适合很多同步、收藏、待办类插件。

```js
/**
* @name 示例控制插件
* @version 1.0.0
* @id your-plugin-id
* @type control
* @apiLevel 3
*/
splayer.register({
events: ["trackChange"],
ui: {
playerBarButtons: [{ id: "listen-later", label: "稍后听", icon: "bookmark" }],
},
settings: [
{ key: "endpoint", type: "text", label: "服务地址", default: "" },
{ key: "token", type: "text", label: "访问令牌", default: "" },
],
});

splayer.ui.onCommand("listen-later", async ({ track }) => {
if (!track) throw new Error("当前没有歌曲");
const endpoint = String(splayer.getSetting("endpoint") || "").replace(/\/$/, "");
const token = splayer.getSetting("token");

const res = await splayer.request(`${endpoint}/api/open/listen-later`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": String(token || "") },
body: JSON.stringify({
title: track.title,
artists: track.artists,
cover: track.cover,
musicPlatform: track.source,
musicId: track.id,
}),
responseType: "json",
});

if (res.status < 200 || res.status >= 300) throw new Error("操作失败");
return { message: "已加入稍后听" };
});
```

## 调试

在应用的 DevTools 控制台改设置、观察插件日志:
Expand Down
70 changes: 54 additions & 16 deletions docs/plugins/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应

**音源插件是「被调用方」**:当播放器需要某首歌的播放地址时,会选中一个已就绪、支持该音源的插件,调用你注册的 `musicUrl` 处理器,由你返回真实地址。

**控制插件是「被通知方」**:当播放状态(曲目、歌词、播放态)变化时,宿主把变化推送到你注册的事件回调;你也可以反过来调用 `splayer.player.*` 控制播放器。
**控制插件是「被通知方」**:当播放状态(曲目、歌词、播放态)变化时,宿主把变化推送到你注册的事件回调;你也可以反过来调用 `splayer.player.*` 控制播放器。若声明 `apiLevel 3`,还可以注册播放栏按钮,让宿主在点击时把当前歌曲安全快照交给你的命令处理器。

### 生命周期与状态

Expand Down Expand Up @@ -83,23 +83,24 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应
* @author you
* @homepage https://example.com
* @type source
* @apiLevel 2
* @apiLevel 3
*/
```

| 字段 | 必填 | 说明 |
| -------------- | ---- | -------------------------------------------------------------------- |
| `@name` | ✅ | 插件展示名(最长 24 字符) |
| `@version` | ✅ | 版本号 |
| `@description` | | 简介 |
| `@author` | | 作者 |
| `@homepage` | | 主页 URL |
| `@type` | | `source`(默认)或 `control`,决定插件类型 |
| `@platform` | | `splayer`(默认)或 `lx`;`gz_` 压缩脚本默认按 `lx` 处理 |
| `@apiLevel` | | 声明兼容的 [API 级别](#api-级别),当前宿主为 `2`;控制插件需声明 `2` |
| 字段 | 必填 | 说明 |
| -------------- | ---- | ------------------------------------------------------------------------------------------ |
| `@name` | ✅ | 插件展示名(最长 24 字符) |
| `@version` | ✅ | 版本号 |
| `@description` | | 简介 |
| `@author` | | 作者 |
| `@homepage` | | 主页 URL |
| `@id` | | 稳定插件 ID;填写后重导入同一 `@id` 会覆盖旧版本 |
| `@type` | | `source`(默认)或 `control`,决定插件类型 |
| `@platform` | | `splayer`(默认)或 `lx`;`gz_` 压缩脚本默认按 `lx` 处理 |
| `@apiLevel` | | 声明兼容的 [API 级别](#api-级别),当前宿主为 `3`;控制插件需声明 `2`,播放栏按钮需声明 `3` |

::: warning
缺少 `@name` 或 `@version` 会导致导入失败。插件 ID 由宿主依据「名称 + 源码哈希」自动生成,**无需也无法手动指定**——同一份脚本的 ID 始终一致,改动脚本会生成新 ID
缺少 `@name` 或 `@version` 会导致导入失败。插件 ID 默认由宿主依据「名称 + 源码哈希」自动生成;如果脚本声明了 `@id`,则会优先使用该稳定 ID,后续重导入同一个 `@id` 会替换旧版本
:::

## API 级别
Expand All @@ -110,12 +111,15 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应
| ---- | ---------------------------------------------------------------------------------------------------------------------------- |
| `1` | 音源能力:`register({ sources })`、`musicUrl` 处理器,以及通用 API(`request` / `storage` / `log` / `getSetting` / `utils`) |
| `2` | 控制能力:`register({ events, controls, settings })`、`splayer.player` 事件订阅与反向控制、`onSettingChange` |
| `3` | 声明式 UI 命令:控制插件可注册播放栏按钮,并通过 `splayer.ui.onCommand()` 接收当前歌曲安全快照 |

当前宿主级别为 **2**。规则:
当前宿主级别为 **3**。规则:

- 声明值**必须 ≤ 当前宿主级别**,否则拒绝加载并报 `PLUGIN_API_LEVEL_MISMATCH`(需等应用升级);
- 声明你实际用到的**最低**级别即可——只做音源写 `1`,用到任何控制能力写 `2`;
- 控制插件(`@type control`)必须声明 `2`,否则控制能力在运行时不可用。
- 声明你实际用到的**最低**级别即可——只做音源写 `1`,用到控制能力写 `2`,用到播放栏按钮写 `3`;
- 控制插件(`@type control`)必须声明 `2`,声明播放栏按钮时必须声明 `3`,否则对应能力不可用。
- 播放栏按钮只对控制插件开放,音源插件不会获得该能力。
- 建议给可升级的插件声明固定 `@id`,这样你重新导入脚本时会覆盖旧版本而不是新增一个同名插件。

::: tip
后续版本若新增插件能力,会提升宿主级别并在上表追加一行。你的插件声明的级别不变即可继续运行(向后兼容),用到新能力时再相应提高 `@apiLevel`。
Expand Down Expand Up @@ -216,6 +220,29 @@ console.log(resp.status, resp.body);
| `utils.base64` | `encode` / `decode` |
| `utils.zlib` | `inflate` / `deflate` / `gunzip` / `gzip` |

### `splayer.ui`

仅 `apiLevel 3` 控制插件可用。用于注册播放栏按钮的命令处理器。

| 方法 | 说明 |
| ------------------------------- | ------------------------------------------------------------------------------------------------ |
| `onCommand(commandId, handler)` | 注册一个按钮命令处理器。`handler` 接收 `{ track }` 安全快照,可返回 `{ message }` 作为成功提示。 |

按钮由宿主统一渲染在播放栏里,插件只负责:

- 声明按钮的 `id`、`label`、`icon` 和 `tooltip`
- 在 `onCommand()` 里处理点击事件
- 通过返回值告诉宿主是否要展示提示文案

宿主会在命令执行期间显示加载态;无当前歌曲时按钮自动禁用;命令失败时显示错误提示。

```js
splayer.ui.onCommand("submit", async ({ track }) => {
if (!track) throw new Error("当前没有歌曲");
return { message: "已处理" };
});
```

## 资源约束与安全

| 约束 | 值 | 说明 |
Expand Down Expand Up @@ -279,6 +306,17 @@ await window.api.plugins.resolveUrl({

// 修改某控制类插件的设置(会实时下发到插件)
await window.api.plugins.setSetting("my-plugin-xxxxxxxx", "someKey", true);

// 触发一次播放栏 UI 命令(调试 apiLevel 3 控制插件)
await window.api.plugins.invokeUiCommand("my-plugin-xxxxxxxx", "submit", {
track: {
id: "123",
source: "netease",
title: "Song",
artists: "Artist",
duration: 180000,
},
});
```

插件内的 `console.*` / `splayer.log.*` 输出会汇入应用主日志(`{userData}/app-data/logs/`)。修改脚本后重新导入一次即可,旧版本会被自动替换。
13 changes: 12 additions & 1 deletion electron/main/ipc/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import { ipcMain, dialog, net } from "electron";
import type { PluginInfo } from "@shared/types/plugin";
import type { PluginInfo, PluginUiCommandContext } from "@shared/types/plugin";
import { INSTALL_URL_MAX_SIZE, INSTALL_URL_TIMEOUT } from "@shared/defaults/plugin-api";
import { pluginRegistry } from "@main/plugins/registry";
import { resolveUrl } from "@main/plugins/router";
Expand Down Expand Up @@ -116,6 +116,17 @@ export const registerPluginIpc = (): void => {
return resolveUrl(args);
});

ipcMain.handle(
"plugin:invokeUiCommand",
async (_evt, pluginId: string, commandId: string, context: PluginUiCommandContext) => {
return pluginRegistry.invokeUiCommand(pluginId, commandId, context);
},
);

ipcMain.handle("plugin:notifySettingsOpen", async (_evt, pluginId: string) => {
pluginRegistry.notifyPluginSettingsOpen(pluginId);
});

// 状态变化广播
pluginRegistry.on("status", (info: PluginInfo) => {
broadcast("plugin:status", info);
Expand Down
Loading