From 1d3ded22c0fab275cf1b22752a7c344c74ca2241 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:06:55 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(control):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=92=AD=E6=94=BE=E6=A0=8F=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 apiLevel 3 支持播放栏按钮注册 - 实现插件安全曲目快照传递给按钮命令处理器 - 添加六种按钮图标支持 (send, upload, radio, external-link, bookmark, heart) - 限制每个插件最多4个播放栏按钮 - 实现按钮点击时的加载状态和结果提示 - 添加 UI 命令超时处理机制 - 更新文档说明播放栏按钮的使用方法和限制 --- docs/plugins/control.md | 133 ++++++++++++++++-- docs/plugins/index.md | 66 +++++++-- electron/main/ipc/plugin.ts | 9 +- electron/main/plugins/playbackBridge.ts | 6 +- electron/main/plugins/registry.ts | 179 +++++++++++++++++++++++- electron/main/plugins/sandbox.ts | 29 ++++ electron/main/plugins/sandbox.worker.ts | 55 ++++++++ electron/preload/index.ts | 9 +- shared/defaults/plugin-api.ts | 5 +- shared/types/plugin.ts | 93 ++++++++++-- src/components/player/PlayerBar.vue | 93 ++++++++++++ src/stores/plugins.ts | 34 ++++- 12 files changed, 662 insertions(+), 49 deletions(-) diff --git a/docs/plugins/control.md b/docs/plugins/control.md index 78de0391..92fb63f7 100644 --- a/docs/plugins/control.md +++ b/docs/plugins/control.md @@ -4,8 +4,8 @@ 阅读本文前请先了解 [插件总览与架构](/plugins/),其中的通用 API 对控制插件同样适用。 -::: warning 需要 apiLevel 2 -脚本头部必须声明 `@type control` 与 `@apiLevel 2`,否则控制能力在运行时不可用。 +::: warning 需要 apiLevel +脚本头部必须声明 `@type control`。监听播放事件、反向控制和设置项需要 `@apiLevel 2`;如果要在播放栏注册按钮,需要 `@apiLevel 3`。 ::: ## 快速开始 @@ -55,11 +55,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` 里声明过的事件,未声明的不会推送。插件启用时,宿主会立即补发一次当前状态快照(当前曲目、歌词、播放态、当前行),无需自己拉取初始值。 @@ -75,14 +76,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` — 歌词整体变化 @@ -224,6 +227,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: "投稿到 VoiceHub", + 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) 主界面的控制插件:订阅曲目/歌词/行变化,按用户设置决定端口、是否带翻译、无翻译时是否回退到下一行。 @@ -300,6 +356,53 @@ splayer.player.on("lineChange", ({ index }) => { }); ``` +## 播放栏按钮示例 + +下面示例演示控制插件如何注册一个播放栏按钮,并在点击时接收当前歌曲的安全快照。SPlayer 主体只提供通用按钮和命令能力,不内置具体业务逻辑。 + +```js +/** + * @name VoiceHub 投稿 + * @version 1.0.0 + * @type control + * @apiLevel 3 + */ +splayer.register({ + events: ["trackChange"], + ui: { + playerBarButtons: [{ id: "submit", label: "投稿", icon: "send" }], + }, + settings: [ + { key: "baseUrl", type: "text", label: "VoiceHub 地址", default: "" }, + { key: "token", type: "text", label: "个人令牌", default: "" }, + ], +}); + +splayer.ui.onCommand("submit", async ({ track }) => { + if (!track) throw new Error("当前没有歌曲"); + const baseUrl = String(splayer.getSetting("baseUrl") || "").replace(/\/$/, ""); + const token = splayer.getSetting("token"); + const platform = track.source === "qqmusic" ? "tencent" : track.source; + + const res = await splayer.request(`${baseUrl}/api/open/songs/request`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": String(token || "") }, + body: JSON.stringify({ + title: track.title, + artist: track.artists, + cover: track.cover, + musicPlatform: ["netease", "tencent"].includes(platform) ? platform : null, + musicId: ["netease", "tencent"].includes(platform) ? track.id : null, + submissionNote: "来自 SPlayer-NEXT", + }), + responseType: "json", + }); + + if (res.status < 200 || res.status >= 300) throw new Error("投稿失败"); + return { message: "已投稿到 VoiceHub" }; +}); +``` + ## 调试 在应用的 DevTools 控制台改设置、观察插件日志: diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 57c344d4..12e977ca 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -47,7 +47,7 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应 **音源插件是「被调用方」**:当播放器需要某首歌的播放地址时,会选中一个已就绪、支持该音源的插件,调用你注册的 `musicUrl` 处理器,由你返回真实地址。 -**控制插件是「被通知方」**:当播放状态(曲目、歌词、播放态)变化时,宿主把变化推送到你注册的事件回调;你也可以反过来调用 `splayer.player.*` 控制播放器。 +**控制插件是「被通知方」**:当播放状态(曲目、歌词、播放态)变化时,宿主把变化推送到你注册的事件回调;你也可以反过来调用 `splayer.player.*` 控制播放器。若声明 `apiLevel 3`,还可以注册播放栏按钮,让宿主在点击时把当前歌曲安全快照交给你的命令处理器。 ### 生命周期与状态 @@ -83,20 +83,20 @@ 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 | +| `@type` | | `source`(默认)或 `control`,决定插件类型 | +| `@platform` | | `splayer`(默认)或 `lx`;`gz_` 压缩脚本默认按 `lx` 处理 | +| `@apiLevel` | | 声明兼容的 [API 级别](#api-级别),当前宿主为 `3`;控制插件需声明 `2`,播放栏按钮需声明 `3` | ::: warning 缺少 `@name` 或 `@version` 会导致导入失败。插件 ID 由宿主依据「名称 + 源码哈希」自动生成,**无需也无法手动指定**——同一份脚本的 ID 始终一致,改动脚本会生成新 ID。 @@ -110,12 +110,14 @@ 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`,否则对应能力不可用。 +- 播放栏按钮只对控制插件开放,音源插件不会获得该能力。 ::: tip 后续版本若新增插件能力,会提升宿主级别并在上表追加一行。你的插件声明的级别不变即可继续运行(向后兼容),用到新能力时再相应提高 `@apiLevel`。 @@ -216,6 +218,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: "已处理" }; +}); +``` + ## 资源约束与安全 | 约束 | 值 | 说明 | @@ -279,6 +304,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/`)。修改脚本后重新导入一次即可,旧版本会被自动替换。 diff --git a/electron/main/ipc/plugin.ts b/electron/main/ipc/plugin.ts index 67e79d0a..3747d2cd 100644 --- a/electron/main/ipc/plugin.ts +++ b/electron/main/ipc/plugin.ts @@ -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"; @@ -116,6 +116,13 @@ 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); + }, + ); + // 状态变化广播 pluginRegistry.on("status", (info: PluginInfo) => { broadcast("plugin:status", info); diff --git a/electron/main/plugins/playbackBridge.ts b/electron/main/plugins/playbackBridge.ts index de14c830..20b3b87a 100644 --- a/electron/main/plugins/playbackBridge.ts +++ b/electron/main/plugins/playbackBridge.ts @@ -4,7 +4,7 @@ import type { Track, PlayerState } from "@shared/types/player"; import type { LyricLine } from "@shared/types/lyrics"; -import type { PlaybackEventData, PlaybackEventKind } from "@shared/types/plugin"; +import type { PlaybackEventData, PlaybackEventKind, PluginSafeTrack } from "@shared/types/plugin"; import type { NowPlayingSnapshot, NowPlayingPositionSync, @@ -28,9 +28,11 @@ const toPluginState = (state: PlayerState): PluginPlayState => state === "playing" ? "playing" : state === "stopped" ? "stopped" : "paused"; /** Track → 插件可见的精简载荷 */ -const trackPayload = (track: Track | null) => +const trackPayload = (track: Track | null): PluginSafeTrack | null => track ? { + id: track.id, + source: track.source, title: track.title, artists: track.artists.map((artist) => artist.name).join(", "), album: track.album?.name, diff --git a/electron/main/plugins/registry.ts b/electron/main/plugins/registry.ts index 5a6e5332..147e30a7 100644 --- a/electron/main/plugins/registry.ts +++ b/electron/main/plugins/registry.ts @@ -16,11 +16,20 @@ import type { PluginAction, PluginInfo, PluginManifest, + PluginPlayerBarButton, PluginSettingItem, PluginStatus, + PluginUiCommandContext, + PluginUiCommandResult, + PluginUiContribution, + PluginSafeTrack, PluginUpdateInfo, } from "@shared/types/plugin"; -import { PluginErrorCodes, RESTART_MAX_ATTEMPTS } from "@shared/defaults/plugin-api"; +import { + PluginErrorCodes, + RESTART_MAX_ATTEMPTS, + UI_COMMAND_TIMEOUT, +} from "@shared/defaults/plugin-api"; import { store } from "@main/store"; import { getLocale } from "@main/utils/i18n"; import { coreLog } from "@main/utils/logger"; @@ -75,6 +84,8 @@ interface PluginRuntime { controls: boolean; /** 控制类:声明的用户配置项 */ settings: PluginSettingItem[]; + /** 控制类:声明式 UI 扩展 */ + ui: PluginUiContribution; /** router 注册的 pending 调用 */ pending: Map< string, @@ -88,6 +99,76 @@ interface PluginRuntime { restartTimer: NodeJS.Timeout | null; } +const UI_BUTTON_ICONS = new Set(["send", "upload", "radio", "external-link", "bookmark", "heart"]); +const UI_BUTTON_ID_RE = /^[A-Za-z0-9_.:-]{1,64}$/; +const UI_TRACK_SOURCES = new Set(["local", "streaming", "netease", "qqmusic", "kugou"]); + +/** 过滤控制类插件声明的 UI 扩展,避免把任意结构暴露到渲染层 */ +const sanitizeUiContribution = ( + pluginId: string, + manifest: PluginManifest, + ui: PluginUiContribution | undefined, +): PluginUiContribution => { + if (!ui || manifest.type !== "control") return {}; + if (manifest.apiLevel < 3) { + coreLog.warn(`[plugin:${pluginId}] ui contribution requires apiLevel 3`); + return {}; + } + const buttons = Array.isArray(ui.playerBarButtons) ? ui.playerBarButtons : []; + const playerBarButtons: PluginPlayerBarButton[] = []; + for (const raw of buttons) { + if (playerBarButtons.length >= 4) break; + const button = raw as Partial; + const id = typeof button.id === "string" ? button.id.trim() : ""; + const label = typeof button.label === "string" ? button.label.trim() : ""; + const tooltip = typeof button.tooltip === "string" ? button.tooltip.trim() : undefined; + const icon = button.icon; + const placement = button.placement ?? "track-actions"; + const valid = + UI_BUTTON_ID_RE.test(id) && + label.length >= 1 && + label.length <= 24 && + (!tooltip || tooltip.length <= 80) && + typeof icon === "string" && + UI_BUTTON_ICONS.has(icon) && + placement === "track-actions"; + if (!valid) { + coreLog.warn(`[plugin:${pluginId}] invalid player bar button ignored`, raw); + continue; + } + playerBarButtons.push({ id, label, tooltip, icon, placement }); + } + return playerBarButtons.length > 0 ? { playerBarButtons } : {}; +}; + +/** UI 命令上下文跨 IPC 后再过滤一次,防止调试调用携带额外字段进入插件 */ +const sanitizeUiCommandContext = (context: PluginUiCommandContext): PluginUiCommandContext => { + const rawTrack = context?.track as Partial | null | undefined; + if (!rawTrack) return { track: null }; + const source = rawTrack.source; + if ( + typeof rawTrack.id !== "string" || + typeof source !== "string" || + !UI_TRACK_SOURCES.has(source) || + typeof rawTrack.title !== "string" || + typeof rawTrack.artists !== "string" || + typeof rawTrack.duration !== "number" + ) { + return { track: null }; + } + return { + track: { + id: rawTrack.id, + source: source as PluginSafeTrack["source"], + title: rawTrack.title, + artists: rawTrack.artists, + album: typeof rawTrack.album === "string" ? rawTrack.album : undefined, + duration: rawTrack.duration, + cover: typeof rawTrack.cover === "string" ? rawTrack.cover : undefined, + }, + }; +}; + /** 按 schema 校验/强转设置值 */ const sanitizeSettingValue = (item: PluginSettingItem, value: unknown): unknown => { switch (item.type) { @@ -144,6 +225,7 @@ class PluginRegistry extends EventEmitter { events: [], controls: false, settings: [], + ui: {}, pending: new Map(), restartTimer: null, }); @@ -165,6 +247,7 @@ class PluginRegistry extends EventEmitter { settingsValues: (store.get(`plugins.perPlugin.${rt.manifest.id}` as never) as Record) ?? {}, + ui: rt.ui, })); } @@ -246,12 +329,19 @@ class PluginRegistry extends EventEmitter { events: [], controls: false, settings: [], + ui: {}, pending: new Map(), restartTimer: null, }; this.runtimes.set(manifest.id, rt); await this.start(rt).catch(() => {}); - return { manifest, enabled: rt.enabled, status: rt.status, updateInfo: rt.updateInfo }; + return { + manifest, + enabled: rt.enabled, + status: rt.status, + updateInfo: rt.updateInfo, + ui: rt.ui, + }; } async uninstall(id: string): Promise { @@ -335,6 +425,7 @@ class PluginRegistry extends EventEmitter { events: rt.events, controls: rt.controls, settings: rt.settings, + ui: rt.ui, }); this.maybePrimeControl(rt); }, @@ -375,12 +466,38 @@ class PluginRegistry extends EventEmitter { rt.settings = settings; // ready 状态由此建立;"无→有"的 controlActivityChange 由 setStatus 内集中发出 if (rt.status.state === "ready") { - this.setStatus(rt, { ...rt.status, events, controls, settings }); + this.setStatus(rt, { ...rt.status, events, controls, settings, ui: rt.ui }); } else { - this.setStatus(rt, { state: "ready", sources: {}, events, controls, settings }); + this.setStatus(rt, { + state: "ready", + sources: {}, + events, + controls, + settings, + ui: rt.ui, + }); } this.maybePrimeControl(rt); }, + onUiRegistered: (ui) => { + rt.ui = sanitizeUiContribution(rt.manifest.id, rt.manifest, ui); + if (rt.status.state === "ready") { + this.setStatus(rt, { ...rt.status, ui: rt.ui }); + } + }, + onUiCommandResult: (requestId, ok, data, error) => { + const p = rt.pending.get(requestId); + if (!p) return; + rt.pending.delete(requestId); + clearTimeout(p.timer); + if (ok) p.resolve(data ?? {}); + else { + const err = new Error(error?.message ?? "ui command failed"); + + (err as any).code = error?.code ?? PluginErrorCodes.UNKNOWN; + p.reject(err); + } + }, onFatal: (error) => { if (rt.sandbox !== sandbox) return; // 过期实例,忽略 // 同时记录到主日志,避免错误只在 UI 卡片里可见 @@ -463,6 +580,7 @@ class PluginRegistry extends EventEmitter { settingsValues: (store.get(`plugins.perPlugin.${rt.manifest.id}` as never) as Record) ?? {}, + ui: rt.ui, } satisfies PluginInfo); this.notifyControlActivity(before); } @@ -551,6 +669,59 @@ class PluginRegistry extends EventEmitter { if (rt.sandbox?.isAlive()) rt.sandbox.sendSettingsUpdate({ [key]: sanitized }); } + /** + * 调用控制类插件声明的 UI 命令。 + * @param pluginId - 插件 ID + * @param commandId - 按钮命令 ID + * @param context - 渲染端传入的安全上下文 + * @returns 插件命令返回值 + */ + invokeUiCommand( + pluginId: string, + commandId: string, + context: PluginUiCommandContext, + ): Promise { + const rt = this.runtimes.get(pluginId); + if (!rt) { + throw Object.assign(new Error(`plugin ${pluginId} not found`), { + code: PluginErrorCodes.NOT_FOUND, + }); + } + if (!rt.enabled) { + throw Object.assign(new Error(`plugin ${pluginId} disabled`), { + code: PluginErrorCodes.DISABLED, + }); + } + if (rt.manifest.type !== "control" || rt.status.state !== "ready" || !rt.sandbox?.isAlive()) { + throw Object.assign(new Error(`plugin ${pluginId} not ready`), { + code: PluginErrorCodes.NOT_READY, + }); + } + const declared = rt.ui.playerBarButtons?.some((button) => button.id === commandId); + if (!declared) { + throw Object.assign(new Error(`ui command "${commandId}" not declared`), { + code: PluginErrorCodes.ACTION_UNSUPPORTED, + }); + } + const requestId = `ui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + rt.pending.delete(requestId); + reject( + Object.assign(new Error(`plugin ${pluginId} ui command timeout`), { + code: PluginErrorCodes.REQUEST_TIMEOUT, + }), + ); + }, UI_COMMAND_TIMEOUT); + rt.pending.set(requestId, { + resolve: (data) => resolve((data ?? {}) as PluginUiCommandResult), + reject, + timer, + }); + rt.sandbox!.sendUiCommand(requestId, commandId, sanitizeUiCommandContext(context)); + }); + } + /** 应用退出前调用 */ async shutdown(): Promise { await Promise.all(Array.from(this.runtimes.values()).map((rt) => this.stop(rt))); diff --git a/electron/main/plugins/sandbox.ts b/electron/main/plugins/sandbox.ts index c7dea3d7..b668a456 100644 --- a/electron/main/plugins/sandbox.ts +++ b/electron/main/plugins/sandbox.ts @@ -18,6 +18,9 @@ import type { PluginErrorPayload, PluginManifest, PluginSettingItem, + PluginUiCommandContext, + PluginUiCommandResult, + PluginUiContribution, PluginUpdateInfo, SandboxIn, SandboxOut, @@ -33,6 +36,12 @@ import { export interface SandboxEvents { onReady: (sources: Record) => void; onResult: (requestId: string, ok: boolean, data?: unknown, error?: PluginErrorPayload) => void; + onUiCommandResult: ( + requestId: string, + ok: boolean, + data?: PluginUiCommandResult, + error?: PluginErrorPayload, + ) => void; onHostCall: (callId: string, method: HostCallMethod, args: unknown[]) => void; onLog: (level: "debug" | "info" | "warn" | "error", args: unknown[]) => void; onFatal: (error: PluginErrorPayload) => void; @@ -46,6 +55,8 @@ export interface SandboxEvents { controls: boolean, settings: PluginSettingItem[], ) => void; + /** 控制类 UI 扩展注册上报 */ + onUiRegistered?: (ui: PluginUiContribution) => void; /** 子进程退出(可能是崩溃或主动 kill)。isCrash=true 表示非主动 kill */ onExit: (isCrash: boolean, code: number | null) => void; } @@ -204,6 +215,18 @@ export class Sandbox { this.post({ kind: "cancel", requestId }); } + /** 把播放栏 UI 命令下发到沙箱 */ + sendUiCommand(requestId: string, commandId: string, context: PluginUiCommandContext): void { + if (!this.child || !this.ready) { + this.events.onUiCommandResult(requestId, false, undefined, { + code: PluginErrorCodes.NOT_READY, + message: "plugin is not ready", + }); + return; + } + this.post({ kind: "uiCommand", requestId, commandId, context }); + } + sendHostResult(callId: string, ok: boolean, data?: unknown, error?: PluginErrorPayload): void { this.post({ kind: "hostResult", callId, ok, data, error }); } @@ -276,6 +299,9 @@ export class Sandbox { case "result": this.events.onResult(msg.requestId, msg.ok, msg.data, msg.error); return; + case "uiCommandResult": + this.events.onUiCommandResult(msg.requestId, msg.ok, msg.data, msg.error); + return; case "hostCall": this.events.onHostCall(msg.callId, msg.method, msg.args); return; @@ -288,6 +314,9 @@ export class Sandbox { case "registered": this.events.onRegistered?.(msg.events, msg.controls, msg.settings); return; + case "uiRegistered": + this.events.onUiRegistered?.(msg.ui); + return; case "log": this.events.onLog(msg.level, msg.args); return; diff --git a/electron/main/plugins/sandbox.worker.ts b/electron/main/plugins/sandbox.worker.ts index 1023c3dd..f3d1a263 100644 --- a/electron/main/plugins/sandbox.worker.ts +++ b/electron/main/plugins/sandbox.worker.ts @@ -22,6 +22,8 @@ import type { HostRequestResult, PluginAction, PluginErrorPayload, + PluginUiCommandContext, + PluginUiCommandResult, RegisterArgs, SandboxIn, SandboxOut, @@ -110,6 +112,14 @@ const playerEventHandlers = new Map void)[]>(); /** 控制类:设置变化回调表,key = setting key */ const settingChangeHandlers = new Map void)[]>(); +/** 控制类:声明式 UI 命令处理器,key = commandId */ +const uiCommandHandlers = new Map< + string, + ( + context: PluginUiCommandContext, + ) => PluginUiCommandResult | void | Promise +>(); + /** 已注册的 sources(等 script 执行完后随 ready 消息上报) */ let registeredSources: Record = {}; @@ -183,6 +193,9 @@ const buildSplayer = (init: Extract): HostApi => ({ settings: Array.isArray(args.settings) ? args.settings : [], }); } + if (args.ui) { + send({ kind: "uiRegistered", ui: args.ui }); + } }, on: ( @@ -231,6 +244,12 @@ const buildSplayer = (init: Extract): HostApi => ({ getPosition: () => hostCall("player.getPosition", []) as Promise, }, + ui: { + onCommand: (commandId, handler) => { + uiCommandHandlers.set(commandId, handler); + }, + }, + onSettingChange: (key: string, handler: (value: unknown) => void) => { const list = settingChangeHandlers.get(key) ?? []; list.push(handler); @@ -514,6 +533,42 @@ parentPort.on("message", async (event) => { } return; } + case "uiCommand": { + const handler = uiCommandHandlers.get(msg.commandId); + if (!handler) { + send({ + kind: "uiCommandResult", + requestId: msg.requestId, + ok: false, + error: { + code: "PLUGIN_ACTION_UNSUPPORTED", + message: `ui command "${msg.commandId}" not registered`, + }, + }); + return; + } + try { + const data = (await handler(msg.context)) ?? {}; + send({ + kind: "uiCommandResult", + requestId: msg.requestId, + ok: true, + data: sanitizeForIpc(data) as PluginUiCommandResult, + }); + } catch (err) { + const payload: PluginErrorPayload = { + code: ((err as any)?.code as string) ?? "PLUGIN_HANDLER_ERROR", + message: err instanceof Error ? err.message : String(err), + }; + send({ + kind: "uiCommandResult", + requestId: msg.requestId, + ok: false, + error: payload, + }); + } + return; + } } } catch (err) { send({ diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 4fe666e1..15e1f0a3 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1,7 +1,11 @@ import { contextBridge, ipcRenderer } from "electron"; import { electronAPI } from "@electron-toolkit/preload"; import type { TaskbarLyricSettings } from "@shared/types/settings"; -import type { PluginInfo, PluginResolveUrlArgs } from "@shared/types/plugin"; +import type { + PluginInfo, + PluginResolveUrlArgs, + PluginUiCommandContext, +} from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; import type { LoadOptions, TrackSource } from "@shared/types/player"; import type { StreamingServerConfig } from "@shared/types/streaming"; @@ -303,6 +307,9 @@ const api = { ipcRenderer.invoke("plugin:setSetting", id, key, value), // 解析播放 URL resolveUrl: (args: PluginResolveUrlArgs) => ipcRenderer.invoke("plugin:resolveUrl", args), + // 触发控制类插件声明的 UI 命令 + invokeUiCommand: (pluginId: string, commandId: string, context: PluginUiCommandContext) => + ipcRenderer.invoke("plugin:invokeUiCommand", pluginId, commandId, context), // 订阅插件状态变化 onStatus: (callback: (info: PluginInfo) => void) => subscribe("plugin:status", callback), diff --git a/shared/defaults/plugin-api.ts b/shared/defaults/plugin-api.ts index b20d944f..1c6f5974 100644 --- a/shared/defaults/plugin-api.ts +++ b/shared/defaults/plugin-api.ts @@ -1,13 +1,16 @@ import type { PluginsConfig } from "../types/plugin"; /** 当前 Host API 级别;插件 `@apiLevel` 必须 ≤ 此值才加载 */ -export const HOST_API_LEVEL = 2; +export const HOST_API_LEVEL = 3; /** 各动作的默认超时(毫秒)。新增动作时在此追加。 */ export const ACTION_TIMEOUTS = { musicUrl: 20_000, } as const; +/** 播放栏 UI 命令超时(毫秒) */ +export const UI_COMMAND_TIMEOUT = 20_000; + /** 网络请求最大超时 */ export const REQUEST_MAX_TIMEOUT = 60_000; diff --git a/shared/types/plugin.ts b/shared/types/plugin.ts index e68149bb..34f42d63 100644 --- a/shared/types/plugin.ts +++ b/shared/types/plugin.ts @@ -4,6 +4,7 @@ */ import type { LyricLine } from "./lyrics"; +import type { TrackSource } from "./player"; /** * 支持的插件动作 @@ -35,16 +36,21 @@ export type PluginType = (typeof PLUGIN_TYPES)[number]; /** 控制类插件可订阅的高层播放事件 */ export type PlaybackEventKind = "trackChange" | "lyricChange" | "lineChange" | "playStateChange"; +/** 插件可见的当前歌曲安全快照 */ +export interface PluginSafeTrack { + id: string; + source: TrackSource; + title: string; + artists: string; + album?: string; + duration: number; + cover?: string; +} + /** 各高层事件的载荷 */ export interface PlaybackEventData { trackChange: { - track: { - title: string; - artists: string; - album?: string; - duration: number; - cover?: string; - } | null; + track: PluginSafeTrack | null; }; /** 歌词数据 */ lyricChange: { lines: LyricLine[] }; @@ -74,12 +80,59 @@ export interface PluginSettingItem { options?: { label: string; value: string }[]; } +/** 播放栏按钮可用图标 */ +export type PluginPlayerBarButtonIcon = + | "send" + | "upload" + | "radio" + | "external-link" + | "bookmark" + | "heart"; + +/** 播放栏按钮挂载位置 */ +export type PluginPlayerBarButtonPlacement = "track-actions"; + +/** 控制类插件声明的播放栏按钮 */ +export interface PluginPlayerBarButton { + id: string; + label: string; + tooltip?: string; + icon: PluginPlayerBarButtonIcon; + placement?: PluginPlayerBarButtonPlacement; +} + +/** 控制类插件声明的 UI 扩展 */ +export interface PluginUiContribution { + playerBarButtons?: PluginPlayerBarButton[]; +} + +/** UI 命令处理器收到的上下文 */ +export interface PluginUiCommandContext { + track: PluginSafeTrack | null; +} + +/** UI 命令处理器可返回的结果 */ +export interface PluginUiCommandResult { + message?: string; +} + +/** 控制类插件可用的声明式 UI 命令 API */ +export interface PluginUiApi { + onCommand( + commandId: string, + handler: ( + context: PluginUiCommandContext, + ) => PluginUiCommandResult | void | Promise, + ): void; +} + /** register 入参:音源类用 sources,控制类用 events/controls/settings */ export interface RegisterArgs { sources?: Record; events?: PlaybackEventKind[]; controls?: boolean; settings?: PluginSettingItem[]; + ui?: PluginUiContribution; } /** 控制类插件可用的播放面 */ @@ -143,6 +196,7 @@ export type PluginStatus = events?: PlaybackEventKind[]; controls?: boolean; settings?: PluginSettingItem[]; + ui?: PluginUiContribution; } | { state: "error"; error: { code: string; message: string } } | { state: "disabled" }; @@ -168,6 +222,8 @@ export interface PluginInfo { updateInfo?: PluginUpdateInfo | null; /** 控制类插件的当前设置值 */ settingsValues?: Record; + /** 控制类插件声明的 UI 扩展 */ + ui?: PluginUiContribution; } /* ========== 调用请求 / 响应 ========== */ @@ -256,6 +312,9 @@ export interface HostApi { /** 控制类播放面:监听高层事件 + 反向控制 */ player: PluginPlayerApi; + /** 控制类声明式 UI 命令 */ + ui: PluginUiApi; + /** 控制类设置变更回调:用户改设置后触发 */ onSettingChange: (key: string, handler: (value: unknown) => void) => void; } @@ -297,7 +356,8 @@ export type SandboxIn = } | { kind: "ping" } | { kind: "event"; event: PlaybackEventKind; data: unknown } - | { kind: "settingsUpdate"; settings: Record }; + | { kind: "settingsUpdate"; settings: Record } + | { kind: "uiCommand"; requestId: string; commandId: string; context: PluginUiCommandContext }; /** worker → 主 */ export type SandboxOut = @@ -309,6 +369,13 @@ export type SandboxOut = data?: unknown; error?: PluginErrorPayload; } + | { + kind: "uiCommandResult"; + requestId: string; + ok: boolean; + data?: PluginUiCommandResult; + error?: PluginErrorPayload; + } | { kind: "hostCall"; callId: string; method: HostCallMethod; args: unknown[] } | { kind: "updateAvailable"; info: PluginUpdateInfo } | { @@ -326,7 +393,9 @@ export type SandboxOut = events: PlaybackEventKind[]; controls: boolean; settings: PluginSettingItem[]; - }; + } + /** UI 扩展增量上报 */ + | { kind: "uiRegistered"; ui: PluginUiContribution }; /** worker 调用回宿主的方法名 */ export type HostCallMethod = @@ -380,6 +449,12 @@ export interface PluginsApi { setSetting: (id: string, key: string, value: unknown) => Promise; /** 获取播放 URL */ resolveUrl: (args: PluginResolveUrlArgs) => Promise; + /** 触发控制类插件声明的 UI 命令 */ + invokeUiCommand: ( + pluginId: string, + commandId: string, + context: PluginUiCommandContext, + ) => Promise; /** 订阅插件状态变化 */ onStatus: (cb: (info: PluginInfo) => void) => () => void; } diff --git a/src/components/player/PlayerBar.vue b/src/components/player/PlayerBar.vue index 34a6f7c8..d0f66445 100644 --- a/src/components/player/PlayerBar.vue +++ b/src/components/player/PlayerBar.vue @@ -2,21 +2,32 @@ import { useStatusStore } from "@/stores/status"; import { useSettingsStore } from "@/stores/settings"; import { useMediaStore } from "@/stores/media"; +import { usePluginsStore } from "@/stores/plugins"; import { useFavorite } from "@/composables/useFavorite"; import { usePlaylistPicker } from "@/composables/usePlaylistPicker"; import { useTrackMenu } from "@/composables/useTrackMenu"; import { useDownload } from "@/composables/useDownload"; +import { toast } from "@/composables/useToast"; import * as player from "@/core/player"; import { formatTime } from "@/utils/time"; +import type { PluginPlayerBarButtonIcon, PluginSafeTrack } from "@shared/types/plugin"; import IconFavorite from "~icons/material-symbols/favorite-rounded"; import IconFavoriteOutline from "~icons/material-symbols/favorite-outline-rounded"; import IconLucideMoreHorizontal from "~icons/lucide/more-horizontal"; +import IconLucideBookmark from "~icons/lucide/bookmark"; +import IconLucideExternalLink from "~icons/lucide/external-link"; +import IconLucideHeart from "~icons/lucide/heart"; +import IconLucideRadio from "~icons/lucide/radio"; +import IconLucideSend from "~icons/lucide/send"; +import IconLucideUpload from "~icons/lucide/upload"; const status = useStatusStore(); const settings = useSettingsStore(); const media = useMediaStore(); +const plugins = usePluginsStore(); const fav = useFavorite(); const { position, duration } = storeToRefs(status); +const { playerBarButtons } = storeToRefs(plugins); /** 是否是浮动模式 */ const isFloating = computed(() => settings.appearance.layoutMode === "floating"); @@ -40,6 +51,52 @@ const { items: menuItems, handleSelect: onMenuSelect } = useTrackMenu(toRef(medi onAddToPlaylist: (track) => openPicker([track]), onDownload: (track, quality) => void enqueueDownload(track, { quality }), }); + +const pluginIconMap = { + send: IconLucideSend, + upload: IconLucideUpload, + radio: IconLucideRadio, + "external-link": IconLucideExternalLink, + bookmark: IconLucideBookmark, + heart: IconLucideHeart, +} satisfies Record; + +const commandLoading = ref(new Set()); + +const safeTrack = computed(() => { + const track = media.track; + if (!track) return null; + return { + id: track.id, + source: track.source, + title: track.title, + artists: track.artists.map((artist) => artist.name).join(", "), + album: track.album?.name, + duration: track.duration, + cover: track.cover, + }; +}); + +const commandKey = (pluginId: string, commandId: string): string => `${pluginId}:${commandId}`; + +const isCommandLoading = (pluginId: string, commandId: string): boolean => + commandLoading.value.has(commandKey(pluginId, commandId)); + +const invokePluginButton = async (pluginId: string, commandId: string): Promise => { + const key = commandKey(pluginId, commandId); + if (commandLoading.value.has(key)) return; + commandLoading.value = new Set(commandLoading.value).add(key); + try { + const result = await plugins.invokeUiCommand(pluginId, commandId, { track: safeTrack.value }); + toast.success(result.message || "已完成"); + } catch (err) { + toast.error(err instanceof Error && err.message ? err.message : "插件命令执行失败"); + } finally { + const next = new Set(commandLoading.value); + next.delete(key); + commandLoading.value = next; + } +}; + + + + + + { @@ -16,6 +26,19 @@ export const usePluginsStore = defineStore("plugins", () => { list.value.filter((info) => info.manifest.type === "control"), ); + /** 当前可渲染到播放栏的插件按钮 */ + const playerBarButtons = computed(() => + controlPlugins.value.flatMap((info) => { + if (!info.enabled || info.status.state !== "ready") return []; + const buttons = info.status.ui?.playerBarButtons ?? info.ui?.playerBarButtons ?? []; + return buttons.map((button) => ({ + ...button, + pluginId: info.manifest.id, + pluginName: info.manifest.name, + })); + }), + ); + /** 拉取列表并建立状态订阅 */ const load = async (): Promise => { list.value = await window.api.plugins.list(); @@ -105,6 +128,13 @@ export const usePluginsStore = defineStore("plugins", () => { await window.api.plugins.setSetting(id, key, value); }; + const invokeUiCommand = ( + pluginId: string, + commandId: string, + context: PluginUiCommandContext, + ): Promise => + window.api.plugins.invokeUiCommand(pluginId, commandId, context); + const dispose = (): void => { unsubscribe?.(); unsubscribe = null; @@ -115,12 +145,14 @@ export const usePluginsStore = defineStore("plugins", () => { loaded, sourcePlugins, controlPlugins, + playerBarButtons, load, pickAndInstall, installFromUrl, uninstall, setEnabled, setSetting, + invokeUiCommand, dispose, }; }); From ce9e6bb3fa2616194fea6ed6e4b846fb892ed907 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:12:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(plugin):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=8F=92=E4=BB=B6=E6=96=87=E6=A1=A3=E5=92=8C?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plugins/control.md | 25 +++++++++++-------------- docs/plugins/index.md | 4 ++-- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/docs/plugins/control.md b/docs/plugins/control.md index 92fb63f7..bba86fd7 100644 --- a/docs/plugins/control.md +++ b/docs/plugins/control.md @@ -238,7 +238,7 @@ splayer.register({ { id: "submit-current", label: "投稿", - tooltip: "投稿到 VoiceHub", + tooltip: "执行当前歌曲操作", icon: "send", placement: "track-actions", }, @@ -362,7 +362,7 @@ splayer.player.on("lineChange", ({ index }) => { ```js /** - * @name VoiceHub 投稿 + * @name 示例控制插件 * @version 1.0.0 * @type control * @apiLevel 3 @@ -370,36 +370,33 @@ splayer.player.on("lineChange", ({ index }) => { splayer.register({ events: ["trackChange"], ui: { - playerBarButtons: [{ id: "submit", label: "投稿", icon: "send" }], + playerBarButtons: [{ id: "submit", label: "操作", icon: "send" }], }, settings: [ - { key: "baseUrl", type: "text", label: "VoiceHub 地址", default: "" }, - { key: "token", type: "text", label: "个人令牌", default: "" }, + { key: "endpoint", type: "text", label: "服务地址", default: "" }, + { key: "token", type: "text", label: "访问令牌", default: "" }, ], }); splayer.ui.onCommand("submit", async ({ track }) => { if (!track) throw new Error("当前没有歌曲"); - const baseUrl = String(splayer.getSetting("baseUrl") || "").replace(/\/$/, ""); + const endpoint = String(splayer.getSetting("endpoint") || "").replace(/\/$/, ""); const token = splayer.getSetting("token"); - const platform = track.source === "qqmusic" ? "tencent" : track.source; - const res = await splayer.request(`${baseUrl}/api/open/songs/request`, { + const res = await splayer.request(`${endpoint}/api/example/action`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": String(token || "") }, body: JSON.stringify({ title: track.title, - artist: track.artists, + artists: track.artists, cover: track.cover, - musicPlatform: ["netease", "tencent"].includes(platform) ? platform : null, - musicId: ["netease", "tencent"].includes(platform) ? track.id : null, - submissionNote: "来自 SPlayer-NEXT", + trackId: track.id, }), responseType: "json", }); - if (res.status < 200 || res.status >= 300) throw new Error("投稿失败"); - return { message: "已投稿到 VoiceHub" }; + if (res.status < 200 || res.status >= 300) throw new Error("操作失败"); + return { message: "已完成" }; }); ``` diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 12e977ca..158cbcf8 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -222,8 +222,8 @@ console.log(resp.status, resp.body); 仅 `apiLevel 3` 控制插件可用。用于注册播放栏按钮的命令处理器。 -| 方法 | 说明 | -| ---- | ---- | +| 方法 | 说明 | +| ------------------------------- | ------------------------------------------------------------------------------------------------ | | `onCommand(commandId, handler)` | 注册一个按钮命令处理器。`handler` 接收 `{ track }` 安全快照,可返回 `{ message }` 作为成功提示。 | 按钮由宿主统一渲染在播放栏里,插件只负责: From b118e8168039cb913835bceaf0937e0c5458ea37 Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:18:03 +0800 Subject: [PATCH 3/5] =?UTF-8?q?style(docs):=20=E7=A7=BB=E9=99=A4=20PR=20?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E4=B8=AD=E7=9A=84=E5=A4=9A=E4=BD=99=E7=A9=BA?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 314b0aa5..e115a7ee 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,26 +22,18 @@ - - ## 关联 Issue - - ## 测试情况 - - ## 截图 / 录屏 - - ## 自查清单 - [ ] 本 PR 只包含**一个主要功能 / 修复**,没有夹带无关改动 From c01d2e8c52ddd7ad0c40375a57ed5f0fc676736d Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:21:02 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(plugin):=20=E6=B7=BB=E5=8A=A0=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=A8=B3=E5=AE=9A=20ID=20=E5=92=8C=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=89=93=E5=BC=80=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在插件头部元数据中添加 @id 字段支持,用于指定稳定插件 ID - 实现插件重导入时基于 @id 覆盖旧版本的功能 - 添加插件设置页打开时的通知机制,控制类插件可监听此事件 - 更新文档说明插件 ID 的生成规则和使用方式 - 修复 SSelect 组件空字符串值处理问题 - 优化弹窗组件关闭按钮实现方式 --- docs/plugins/control.md | 3 ++ docs/plugins/index.md | 4 +- electron/main/ipc/plugin.ts | 4 ++ electron/main/plugins/loader.ts | 20 ++++++++-- electron/main/plugins/registry.ts | 27 ++++++++++++- electron/main/plugins/sandbox.ts | 6 +++ electron/main/plugins/sandbox.worker.ts | 16 ++++++++ electron/preload/index.ts | 3 ++ shared/types/plugin.ts | 5 +++ .../settings/custom/PluginManager.vue | 1 + .../settings/custom/PluginSettingsForm.vue | 2 +- src/components/ui/SDialog.vue | 26 ++++++------- src/components/ui/SSelect.vue | 38 ++++++++++++++----- 13 files changed, 126 insertions(+), 29 deletions(-) diff --git a/docs/plugins/control.md b/docs/plugins/control.md index bba86fd7..2d333e7b 100644 --- a/docs/plugins/control.md +++ b/docs/plugins/control.md @@ -16,6 +16,7 @@ * @version 1.0.0 * @description 示例控制插件 * @author you + * @id your-plugin-id * @type control * @apiLevel 2 */ @@ -289,6 +290,7 @@ splayer.ui.onCommand("submit-current", async ({ track }) => { * @name ClassIsland 联动 * @version 1.0.0 * @author imsyy + * @id your-plugin-id * @type control * @apiLevel 2 * @description 把当前歌词推送到 ClassIsland 主界面 @@ -364,6 +366,7 @@ splayer.player.on("lineChange", ({ index }) => { /** * @name 示例控制插件 * @version 1.0.0 + * @id your-plugin-id * @type control * @apiLevel 3 */ diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 158cbcf8..e22de019 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -94,12 +94,13 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应 | `@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 级别 @@ -118,6 +119,7 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应 - 声明你实际用到的**最低**级别即可——只做音源写 `1`,用到控制能力写 `2`,用到播放栏按钮写 `3`; - 控制插件(`@type control`)必须声明 `2`,声明播放栏按钮时必须声明 `3`,否则对应能力不可用。 - 播放栏按钮只对控制插件开放,音源插件不会获得该能力。 +- 建议给可升级的插件声明固定 `@id`,这样你重新导入脚本时会覆盖旧版本而不是新增一个同名插件。 ::: tip 后续版本若新增插件能力,会提升宿主级别并在上表追加一行。你的插件声明的级别不变即可继续运行(向后兼容),用到新能力时再相应提高 `@apiLevel`。 diff --git a/electron/main/ipc/plugin.ts b/electron/main/ipc/plugin.ts index 3747d2cd..213d0cbe 100644 --- a/electron/main/ipc/plugin.ts +++ b/electron/main/ipc/plugin.ts @@ -123,6 +123,10 @@ export const registerPluginIpc = (): void => { }, ); + ipcMain.handle("plugin:notifySettingsOpen", async (_evt, pluginId: string) => { + pluginRegistry.notifyPluginSettingsOpen(pluginId); + }); + // 状态变化广播 pluginRegistry.on("status", (info: PluginInfo) => { broadcast("plugin:status", info); diff --git a/electron/main/plugins/loader.ts b/electron/main/plugins/loader.ts index 741bcfd1..1a9ac3b3 100644 --- a/electron/main/plugins/loader.ts +++ b/electron/main/plugins/loader.ts @@ -3,7 +3,7 @@ * * - 读取脚本文件(.js 或 gz_ 压缩文本) * - 解析头部 JSDoc 元数据(`@name` / `@version` / ...) - * - 生成稳定的 pluginId(name + sha1(source).slice(0,8)) + * - 生成稳定的 pluginId(优先使用 `@id`,否则回退到 name + sha1(source).slice(0,8)) * - 返回 { source, manifest } */ @@ -23,6 +23,7 @@ const GZ_PREFIX = "gz_"; /** 脚本头部字段长度上限 */ const FIELD_LIMITS: Record = { + id: 64, name: 24, description: 256, author: 56, @@ -46,6 +47,7 @@ export const decompressIfNeeded = (raw: string): string => { }; interface HeaderFields { + id?: string; name?: string; description?: string; version?: string; @@ -70,6 +72,7 @@ const parseHeader = (source: string): HeaderFields => { const limit = FIELD_LIMITS[key]; const val = limit && raw.length > limit ? raw.slice(0, limit) + "..." : raw; switch (key) { + case "id": case "name": case "description": case "version": @@ -104,6 +107,17 @@ const slugify = (name: string): string => .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") || "plugin"; +/** 规范化脚本声明的稳定 ID */ +const normalizeStableId = (value: string): string => { + const trimmed = value.trim(); + if (!/^[A-Za-z0-9_.:-]{1,64}$/.test(trimmed)) { + throw Object.assign(new Error(`invalid plugin id "${value}"`), { + code: PluginErrorCodes.INVALID_MANIFEST, + }); + } + return trimmed; +}; + export interface LoadedScript { /** 纯文本源码 */ source: string; @@ -121,7 +135,7 @@ export const loadScript = (rawOrPath: string, isPath: boolean, fileName?: string const header = parseHeader(source); const hash = sha1(source); - // 稳定兜底——同一脚本 hash 一致,id 就一致 + // 稳定兜底——同一脚本 hash 一致,id 就一致;若脚本显式声明 @id,则按 @id 替换升级 const name = header.name || `user_api_${hash.slice(0, 6)}`; const version = header.version || "0.0.0"; @@ -144,7 +158,7 @@ export const loadScript = (rawOrPath: string, isPath: boolean, fileName?: string ); } - const id = `${slugify(name)}-${hash.slice(0, 8)}`; + const id = header.id ? normalizeStableId(header.id) : `${slugify(name)}-${hash.slice(0, 8)}`; const finalFileName = fileName ?? (isPath ? path.basename(rawOrPath) : `${id}.js`); const manifest: PluginManifest = { diff --git a/electron/main/plugins/registry.ts b/electron/main/plugins/registry.ts index 147e30a7..92e9977a 100644 --- a/electron/main/plugins/registry.ts +++ b/electron/main/plugins/registry.ts @@ -285,6 +285,17 @@ class PluginRegistry extends EventEmitter { async installFromSource(raw: string): Promise { ensureDirs(); const { source, manifest } = loadScript(raw, false); + const existing = this.runtimes.get(manifest.id); + if (existing) { + await this.stop(existing); + try { + fs.unlinkSync(path.join(scriptsDir(), existing.manifest.fileName)); + } catch { + /* ignore */ + } + pluginStorageDrop(existing.manifest.id); + } + // 脚本落盘(明文) const fileName = `${manifest.id}.js`; fs.writeFileSync(path.join(scriptsDir(), fileName), source, "utf-8"); @@ -316,8 +327,6 @@ class PluginRegistry extends EventEmitter { store.set("plugins.enabled", enabledMap); // 放入运行时 - const existing = this.runtimes.get(manifest.id); - if (existing) await this.stop(existing); const rt: PluginRuntime = { manifest, enabled: true, @@ -648,6 +657,20 @@ class PluginRegistry extends EventEmitter { } } + /** 通知某个控制类插件的设置页已打开 */ + notifyPluginSettingsOpen(id: string): void { + const rt = this.runtimes.get(id); + if ( + rt && + rt.enabled && + rt.manifest.type === "control" && + rt.status.state === "ready" && + rt.sandbox?.isAlive() + ) { + rt.sandbox.sendUiSettingsOpen(); + } + } + /** * 写入某插件单个设置并实时下发沙箱 * @param id - 插件 ID diff --git a/electron/main/plugins/sandbox.ts b/electron/main/plugins/sandbox.ts index b668a456..fe2e8ac3 100644 --- a/electron/main/plugins/sandbox.ts +++ b/electron/main/plugins/sandbox.ts @@ -243,6 +243,12 @@ export class Sandbox { this.post({ kind: "settingsUpdate", settings }); } + /** 通知沙箱插件设置页已打开 */ + sendUiSettingsOpen(): void { + if (!this.ready) return; + this.post({ kind: "uiSettingsOpen" }); + } + isAlive(): boolean { return this.child != null && this.ready && !this.disposed; } diff --git a/electron/main/plugins/sandbox.worker.ts b/electron/main/plugins/sandbox.worker.ts index f3d1a263..6e331615 100644 --- a/electron/main/plugins/sandbox.worker.ts +++ b/electron/main/plugins/sandbox.worker.ts @@ -120,6 +120,9 @@ const uiCommandHandlers = new Map< ) => PluginUiCommandResult | void | Promise >(); +/** 控制类:设置页打开回调 */ +const uiSettingsOpenHandlers = new Set<() => void>(); + /** 已注册的 sources(等 script 执行完后随 ready 消息上报) */ let registeredSources: Record = {}; @@ -248,6 +251,9 @@ const buildSplayer = (init: Extract): HostApi => ({ onCommand: (commandId, handler) => { uiCommandHandlers.set(commandId, handler); }, + onSettingsOpen: (handler) => { + uiSettingsOpenHandlers.add(handler); + }, }, onSettingChange: (key: string, handler: (value: unknown) => void) => { @@ -475,6 +481,16 @@ parentPort.on("message", async (event) => { } return; } + case "uiSettingsOpen": { + for (const handler of uiSettingsOpenHandlers) { + try { + handler(); + } catch { + // 隔离插件回调异常 + } + } + return; + } case "hostResult": { const w = hostCallWaiters.get(msg.callId); if (!w) return; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 15e1f0a3..845bf26b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -310,6 +310,9 @@ const api = { // 触发控制类插件声明的 UI 命令 invokeUiCommand: (pluginId: string, commandId: string, context: PluginUiCommandContext) => ipcRenderer.invoke("plugin:invokeUiCommand", pluginId, commandId, context), + // 通知控制类插件设置页已打开 + notifySettingsOpen: (pluginId: string) => + ipcRenderer.invoke("plugin:notifySettingsOpen", pluginId), // 订阅插件状态变化 onStatus: (callback: (info: PluginInfo) => void) => subscribe("plugin:status", callback), diff --git a/shared/types/plugin.ts b/shared/types/plugin.ts index 34f42d63..37036cb2 100644 --- a/shared/types/plugin.ts +++ b/shared/types/plugin.ts @@ -124,6 +124,8 @@ export interface PluginUiApi { context: PluginUiCommandContext, ) => PluginUiCommandResult | void | Promise, ): void; + /** 插件设置页被宿主打开时触发 */ + onSettingsOpen(handler: () => void): void; } /** register 入参:音源类用 sources,控制类用 events/controls/settings */ @@ -357,6 +359,7 @@ export type SandboxIn = | { kind: "ping" } | { kind: "event"; event: PlaybackEventKind; data: unknown } | { kind: "settingsUpdate"; settings: Record } + | { kind: "uiSettingsOpen" } | { kind: "uiCommand"; requestId: string; commandId: string; context: PluginUiCommandContext }; /** worker → 主 */ @@ -455,6 +458,8 @@ export interface PluginsApi { commandId: string, context: PluginUiCommandContext, ) => Promise; + /** 通知控制类插件其设置页已打开 */ + notifySettingsOpen: (pluginId: string) => Promise; /** 订阅插件状态变化 */ onStatus: (cb: (info: PluginInfo) => void) => () => void; } diff --git a/src/components/settings/custom/PluginManager.vue b/src/components/settings/custom/PluginManager.vue index 9728e5ee..987ada72 100644 --- a/src/components/settings/custom/PluginManager.vue +++ b/src/components/settings/custom/PluginManager.vue @@ -111,6 +111,7 @@ const settingsDialogValues = computed(() => settingsDialogInfo.value?.settingsVa const openSettingsDialog = (id: string): void => { settingsDialogId.value = id; settingsDialogOpen.value = true; + void window.api.plugins.notifySettingsOpen?.(id); }; /** 弹窗内修改某设置项 */ diff --git a/src/components/settings/custom/PluginSettingsForm.vue b/src/components/settings/custom/PluginSettingsForm.vue index 1d2cbf93..8c07c4e6 100644 --- a/src/components/settings/custom/PluginSettingsForm.vue +++ b/src/components/settings/custom/PluginSettingsForm.vue @@ -52,7 +52,7 @@ const valueOf = (item: PluginSettingItem): unknown => props.values[item.key] ?? /> { - - - - - + + + diff --git a/src/components/ui/SSelect.vue b/src/components/ui/SSelect.vue index c46c1c10..c986c5df 100644 --- a/src/components/ui/SSelect.vue +++ b/src/components/ui/SSelect.vue @@ -28,22 +28,42 @@ const emit = defineEmits<{ "update:modelValue": [value: string | number | boolean]; }>(); -const selectedLabel = computed( - () => props.options.find((o) => o.value === props.modelValue)?.label ?? props.placeholder, +/** Reka Select 不允许空字符串 item value,内部改用哨兵值 */ +const EMPTY_ITEM_VALUE = "__splayer_select_empty__"; + +const normalizedOptions = computed(() => + props.options.map((option) => + option.value === "" ? { ...option, value: EMPTY_ITEM_VALUE } : option, + ), +); + +const selectedOption = computed(() => + props.options.find((o) => String(o.value) === String(props.modelValue ?? "")), ); +const selectValue = computed(() => { + if (selectedOption.value) { + return selectedOption.value.value === "" + ? EMPTY_ITEM_VALUE + : String(selectedOption.value.value); + } + return ""; +}); + +const selectedLabel = computed(() => selectedOption.value?.label ?? props.placeholder); + const handleChange = (val: string) => { + if (val === EMPTY_ITEM_VALUE) { + emit("update:modelValue", ""); + return; + } const opt = props.options.find((o) => String(o.value) === val); emit("update:modelValue", opt?.value ?? val); };