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
50 changes: 44 additions & 6 deletions docs/plugins/control.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ splayer.player.on("playStateChange", ({ state, position }) => {

```js
splayer.register({
events: ["trackChange", "lyricChange", "lineChange", "playStateChange"],
events: [
"trackChange",
"trackUpdate",
"lyricChange",
"lineChange",
"playStateChange",
"positionSync",
],
controls: true,
settings: [
/* PluginSettingItem[] */
Expand Down Expand Up @@ -82,13 +89,20 @@ splayer.player.on(kind, (data) => { ... });

### `trackChange` — 曲目切换

`track` 为当前曲目 [`Track`](/types#track)(`artists` 是 [`Artist[]`](/types#artist)),`null` 表示无曲目。
`track` 为当前曲目 [`Track`](/types#track)(`artists` 是 [`Artist[]`](/types#artist)),`null` 表示无曲目;`revision` 是曲目元数据修订号。

### `trackUpdate` — 当前曲目元数据更新(apiLevel 4)

歌曲身份不变,但封面、时长或音质等延迟元数据补全时下发。载荷同样包含完整 `track` 与 `revision`,不会重复触发 `trackChange`。

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

| 字段 | 类型 | 说明 |
| ------- | ------------- | ---------------------- |
| `lines` | `LyricLine[]` | 当前曲目的完整解析歌词 |
| 字段 | 类型 | 说明 |
| ---------- | -------------------------------- | -------------------------------------- |
| `lines` | `LyricLine[]` | 当前曲目的完整解析歌词 |
| `source` | `LyricData` | 歌词来源、格式与在线平台(apiLevel 4) |
| `status` | `"loading" \| "ready" \| "none"` | 当前歌词加载状态(apiLevel 4) |
| `revision` | `number` | 歌词文档修订号(apiLevel 4) |

每行是一个 [`LyricLine`](/types#lyricline),逐字内容见 [`LyricWord`](/types#lyricword)。整行纯文本:`line.words.map((word) => word.word).join("")`;逐行(LRC 类)歌词通常每行只有一个 word,其始末时间与行时间一致。

Expand Down Expand Up @@ -119,6 +133,30 @@ splayer.player.on("lineChange", ({ index }) => {

`stopped` 与 `paused` 区分开:停止(如播放结束)为 `stopped`,暂停为 `paused`。

### `positionSync` — 播放位置锚点(apiLevel 4)

按播放器位置同步节奏下发,适合外部歌词窗口在本地插值;只有订阅了该事件的插件会收到。

| 字段 | 类型 | 说明 |
| --------------- | ------------------------------------ | -------------------------------------- |
| `position` | `number` | 播放进度(毫秒) |
| `state` | `"playing" \| "paused" \| "stopped"` | 播放态 |
| `speed` | `number` | 播放速度倍率 |
| `lyricOffsetMs` | `number` | 当前歌词偏移,正值表示歌词提前 |
| `sendTimestamp` | `number` | 该位置成立时的 `Date.now()` 毫秒时间戳 |

## 读取当前封面(apiLevel 4)

```js
const cover = await splayer.media.getCover();
if (cover) {
// cover.data 是适合小尺寸展示的 300px JPEG Uint8Array
splayer.log.info(cover.trackId, cover.hash, cover.data.byteLength);
}
```

返回值还包含 `source`、`mimeType` 与内容哈希;无可用封面时返回 `null`。此接口是只读能力,不需要 `@grant control`。

## 反向控制播放

在 `register` 中声明 `controls: true` 并在脚本头加 `@grant control` 后,可调用 `splayer.player` 控制播放器(两者缺一时,这些控制调用会被宿主忽略):
Expand All @@ -136,7 +174,7 @@ splayer.player.on("lineChange", ({ index }) => {
以上控制方法(除 `getPosition`)均为「即发即忘」,不返回结果;非法入参(如负的 `seek`、越界音量)会被宿主忽略。

::: tip getPosition 的正确用法
`getPosition()` 每次调用都有一次往返开销,**仅用于偶发的一次性查询**。需要持续跟踪进度时,请直接读 `lineChange` / `playStateChange` 载荷里已经带上的 `position`,不要高频轮询 `getPosition`。
`getPosition()` 每次调用都有一次往返开销,**仅用于偶发的一次性查询**。需要持续跟踪进度时,apiLevel 4 插件应订阅 `positionSync` 并在本地插值,不要高频轮询 `getPosition`。
:::

## 设置项
Expand Down
17 changes: 9 additions & 8 deletions docs/plugins/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应
| `@homepage` | | 主页 URL |
| `@grant` | | 控制插件声明权限:`network`(联网)/ `control`(控制播放器)/ `ui`(扩展界面,如菜单项),逗号分隔;音源插件自动获 `network` |
| `@type` | | `source`(音源,默认)或 `control`(控制);**建议显式声明**,决定插件类型与权限默认 |
| `@apiLevel` | | 声明兼容的 [API 级别与变更记录](#api-级别与变更记录),当前宿主为 `3`;具体能力所需级别以该表为准 |
| `@apiLevel` | | 声明兼容的 [API 级别与变更记录](#api-级别与变更记录),当前宿主为 `4`;具体能力所需级别以该表为准 |
| `@updateUrl` | | 更新检查地址,详见 [插件更新](/plugins/update) |
| `@changelog` | | 更新说明,详见 [插件更新](/plugins/update) |

Expand All @@ -139,16 +139,17 @@ SPlayer-Next 内置一套插件系统,允许用第三方 JavaScript 扩展应

`@apiLevel` 声明插件需要的宿主能力级别。能力是**累加**的:高级别包含低级别的全部能力,新增能力会提升级别。这里是插件 API 级别的唯一变更记录;其它页面只说明具体能力要求的最低级别。

| 级别 | 相对上一等级新增的能力 | 用到这些能力时 |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `1` | 基础音源能力:`register({ sources })`、`musicUrl` 处理器;元数据兜底处理器:`musicSearch` / `musicLyric` / `musicPic`;通用 API:`request` / `storage` / `log` / `getSetting` / `utils` | 播放地址、歌词、封面插件声明 `@apiLevel 1` 即可 |
| `2` | 控制能力:`register({ events, controls, settings })`、`splayer.player` 事件订阅与反向控制、`onSettingChange`;界面能力:`register({ menus })`、`menuClick` 处理器(需 `@grant ui`) | 控制插件或菜单扩展声明 `@apiLevel 2` |
| `3` | 评论能力:`musicComment` 处理器。宿主先用 `musicSearch` 匹配曲目,再向声明了 `musicComment` 的源请求热门 / 最新评论 | 评论插件能力声明 `@apiLevel 3` |
| 级别 | 相对上一等级新增的能力 | 用到这些能力时 |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `1` | 基础音源能力:`register({ sources })`、`musicUrl` 处理器;元数据兜底处理器:`musicSearch` / `musicLyric` / `musicPic`;通用 API:`request` / `storage` / `log` / `getSetting` / `utils` | 播放地址、歌词、封面插件声明 `@apiLevel 1` 即可 |
| `2` | 控制能力:`register({ events, controls, settings })`、`splayer.player` 事件订阅与反向控制、`onSettingChange`;界面能力:`register({ menus })`、`menuClick` 处理器(需 `@grant ui`) | 控制插件或菜单扩展声明 `@apiLevel 2` |
| `3` | 评论能力:`musicComment` 处理器。宿主先用 `musicSearch` 匹配曲目,再向声明了 `musicComment` 的源请求热门 / 最新评论 | 评论插件能力声明 `@apiLevel 3` |
| `4` | 高级播放同步:`trackUpdate` / `positionSync`,带来源、加载状态和修订号的 `lyricChange`,以及只读 `splayer.media.getCover()` | 高级歌词、封面或精确时间轴联动声明 `@apiLevel 4` |

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

- 声明值**必须 ≤ 当前宿主级别**,否则拒绝加载并报 `PLUGIN_API_LEVEL_MISMATCH`(需等应用升级);
- 声明你实际用到的**最低**级别即可——只做播放地址 / 歌词 / 封面写 `1`,用到任何控制能力写 `2`,用到评论能力写 `3`;
- 声明你实际用到的**最低**级别即可——只做播放地址 / 歌词 / 封面写 `1`,用到基础控制能力写 `2`,用到评论能力写 `3`,用到高级播放同步写 `4`;
- 控制插件(`@type control`)必须声明 `2`,否则控制能力在运行时不可用。

::: tip
Expand Down
6 changes: 5 additions & 1 deletion electron/main/ipc/nowPlaying.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { NowPlayingUpdatePayload } from "@shared/types/nowPlaying";
export const registerNowPlayingIpc = (): void => {
// 渲染进程同步当前播放状态到主进程
ipcMain.on("nowPlaying:update", (_event, payload: NowPlayingUpdatePayload) => {
nowPlaying.update(payload.track, payload.lyric, payload.source);
nowPlaying.update(payload.track, payload.lyric, payload.source, payload.lyricStatus);
});

// 渲染进程写入指定曲目的歌词偏移
Expand All @@ -24,6 +24,10 @@ export const registerNowPlayingIpc = (): void => {
broadcast("nowPlaying:track-change", data);
wsBroadcast({ type: "track", data });
});
nowPlaying.onTrackUpdate((data) => {
broadcast("nowPlaying:track-update", data);
wsBroadcast({ type: "trackUpdate", data });
});
nowPlaying.onLyricChange((snap) => {
broadcast("nowPlaying:lyric-change", snap);
wsBroadcast({ type: "lyric", data: { source: snap.source, lyric: snap.lyric } });
Expand Down
4 changes: 4 additions & 0 deletions electron/main/plugins/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
pluginStorageSet,
} from "./storage";
import { playerControl } from "@main/services/playerControl";
import { getCurrentCover } from "./media";

/** 处理一次 plugin→host 调用 */
export const dispatchHostCall = async (
Expand Down Expand Up @@ -92,6 +93,9 @@ export const dispatchHostCall = async (
case "player.getPosition":
data = playerControl.getPosition();
break;
case "media.getCover":
data = await getCurrentCover();
break;
default:
throw Object.assign(new Error(`unknown host method: ${method}`), {
code: PluginErrorCodes.UNKNOWN,
Expand Down
18 changes: 14 additions & 4 deletions electron/main/plugins/host.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ if (!parentPort) {

/**
* 深度剥离不可克隆字段
* 保留 string/number/bool/null/Uint8Array/纯字典/数组;丢函数/symbol;
* Buffer 转 Uint8Array、普通对象用 Object.create(null) 重建以脱掉 vm.Context 原型链
* 保留 string/number/bool/null/二进制视图/纯字典/数组;丢函数/symbol;
* 二进制统一复制为当前 realm 的 Uint8Array,普通对象重建以脱掉 vm.Context 原型链
* @param value - 任意值
* @param depth - 递归深度上限
*/
Expand All @@ -51,8 +51,13 @@ const sanitizeForIpc = (value: unknown, depth = 0): unknown => {
const t = typeof value;
if (t === "string" || t === "number" || t === "boolean" || t === "bigint") return value;
if (t === "function" || t === "symbol") return undefined;
if (Buffer.isBuffer(value)) return new Uint8Array(value);
if (value instanceof Uint8Array || value instanceof ArrayBuffer) return value;
if (ArrayBuffer.isView(value)) {
const view = value as ArrayBufferView;
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength).slice();
}
if (Object.prototype.toString.call(value) === "[object ArrayBuffer]") {
return new Uint8Array(value as ArrayBuffer).slice().buffer;
}
if (Array.isArray(value)) {
return value
.map((item) => sanitizeForIpc(item, depth + 1))
Expand Down Expand Up @@ -283,6 +288,11 @@ const buildSplayer = (record: PluginContextRecord, spec: LoadSpec): HostApi => (
getPosition: () => hostCall(record, "player.getPosition", []) as Promise<number>,
},

media: {
getCover: () =>
hostCall(record, "media.getCover", []) as ReturnType<HostApi["media"]["getCover"]>,
},

onSettingChange: (key: string, handler: (value: unknown) => void) => {
const list = record.settingChangeHandlers.get(key) ?? [];
list.push(handler);
Expand Down
114 changes: 114 additions & 0 deletions electron/main/plugins/media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { nativeImage, net } from "electron";
import type { PluginCoverData } from "@shared/types/plugin";
import * as nowPlaying from "@main/services/nowPlaying";
import { resolveCacheUrlPath } from "@main/utils/protocol";
import { pluginLog } from "@main/utils/logger";

const MAX_COVER_INPUT_BYTES = 4 * 1024 * 1024;
const MAX_COVER_BASE64_LENGTH = Math.ceil(MAX_COVER_INPUT_BYTES / 3) * 4;
const COVER_SIZE = 300;
const COVER_TIMEOUT_MS = 10_000;

/** 按上限校验封面字节 */
const ensureBounded = (data: Uint8Array): Uint8Array | null =>
data.byteLength > 0 && data.byteLength <= MAX_COVER_INPUT_BYTES ? data : null;

/** 读取 data URL */
const readDataUrl = (url: string): Uint8Array | null => {
const separator = url.indexOf(",");
if (separator < 0 || !/^data:image\/[a-z0-9.+-]+;base64$/i.test(url.slice(0, separator)))
return null;
const encoded = url.slice(separator + 1);
if (
encoded.length === 0 ||
encoded.length > MAX_COVER_BASE64_LENGTH ||
encoded.length % 4 === 1 ||
!/^[a-z0-9+/]*={0,2}$/i.test(encoded)
)
return null;
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
if (padding > 0 && encoded.length % 4 !== 0) return null;
const decodedLength = Math.floor((encoded.length - padding) * 0.75);
if (decodedLength === 0 || decodedLength > MAX_COVER_INPUT_BYTES) return null;
return ensureBounded(Buffer.from(encoded, "base64"));
};

/** 读取 HTTP(S) 封面 */
const readRemoteCover = async (url: string): Promise<Uint8Array | null> => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), COVER_TIMEOUT_MS);
try {
const response = await net.fetch(url, {
method: "GET",
redirect: "follow",
signal: controller.signal,
});
if (!response.ok || !response.body) return null;
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const length = Number(contentLength);
if (Number.isFinite(length) && length > MAX_COVER_INPUT_BYTES) return null;
}

const chunks: Uint8Array[] = [];
const reader = response.body.getReader();
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_COVER_INPUT_BYTES) {
controller.abort();
return null;
}
chunks.push(value);
}
if (total === 0) return null;
return Buffer.concat(chunks, total);
} finally {
clearTimeout(timer);
}
};

/** 读取当前 Track 的小尺寸封面来源 */
const readCoverSource = async (url: string): Promise<Uint8Array | null> => {
if (url.startsWith("cache://")) {
const filePath = resolveCacheUrlPath(url);
if (!filePath) return null;
return ensureBounded(await readFile(filePath));
}
if (url.startsWith("data:image/")) return readDataUrl(url);
if (/^https?:\/\//i.test(url)) return readRemoteCover(url);
return null;
};

/**
* 获取适合插件小尺寸展示的当前封面
* @returns 统一为 300px JPEG;无封面或读取失败返回 null
*/
export const getCurrentCover = async (): Promise<PluginCoverData | null> => {
const track = nowPlaying.snapshot().track;
const coverUrl = track?.cover ?? track?.coverOriginal;
if (!track || !coverUrl) return null;
try {
const source = await readCoverSource(coverUrl);
if (!source) return null;
const image = nativeImage.createFromBuffer(Buffer.from(source));
if (image.isEmpty()) return null;
const data = image
.resize({ width: COVER_SIZE, height: COVER_SIZE, quality: "good" })
.toJPEG(84);
return {
trackId: track.id,
source: track.source,
mimeType: "image/jpeg",
hash: createHash("sha256").update(data).digest("hex"),
data: new Uint8Array(data),
};
} catch (error) {
pluginLog.debug("读取插件封面失败", error instanceof Error ? error.message : String(error));
return null;
}
};
13 changes: 9 additions & 4 deletions electron/main/plugins/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,15 @@ export const hostRequest = async (
let body: BodyInit | undefined;
if (opts.body != null) {
if (typeof opts.body === "string") body = opts.body;
else if (opts.body instanceof ArrayBuffer) body = opts.body;
else {
const u8 = opts.body as Uint8Array;
body = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
else if (ArrayBuffer.isView(opts.body)) {
const view = opts.body as ArrayBufferView;
body = new Uint8Array(view.buffer, view.byteOffset, view.byteLength).slice().buffer;
} else if (Object.prototype.toString.call(opts.body) === "[object ArrayBuffer]") {
body = new Uint8Array(opts.body as ArrayBuffer).slice().buffer;
} else {
throw Object.assign(new Error("request body must be string or binary data"), {
code: PluginErrorCodes.UNKNOWN,
});
}
}

Expand Down
Loading