From 52b275c6e7eb924fdc6d0cd3f8849ff9380ac5fd Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:09:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(plugin):=20=E6=89=A9=E5=B1=95=20API=20?= =?UTF-8?q?Level=204=EF=BC=8C=E6=94=AF=E6=8C=81=E9=AB=98=E7=BA=A7=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E4=B8=8E=E6=AD=8C=E6=9B=B2=E5=B0=81=E9=9D=A2=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增结构化歌词事件,传递逐字时间、翻译、音译、TTML 伴唱及对唱信息 - 增加歌词状态和修订号,支持播放过程中歌词重新匹配、更新与格式升级 - 增加曲目元数据修订及 trackUpdate 事件,支持延迟更新歌曲信息 - 新增 positionSync 事件,提供播放位置、状态、倍速、歌词偏移和时间锚点 - 新增 media.getCover API,向插件提供压缩后的当前歌曲封面及内容哈希 - 完善插件 Worker 与网络请求的二进制数据传输,兼容跨 VM Realm 的 ArrayBuffer 视图 - 优化高频播放状态广播与隐藏窗口处理,降低无效 IPC 和渲染开销 - 更新插件类型定义、默认 API 等级、开发文档及示例 --- docs/plugins/control.md | 50 ++++++++++-- docs/plugins/index.md | 17 +++-- electron/main/ipc/nowPlaying.ts | 6 +- electron/main/plugins/host.ts | 4 + electron/main/plugins/host.worker.ts | 18 ++++- electron/main/plugins/media.ts | 76 +++++++++++++++++++ electron/main/plugins/net.ts | 13 +++- electron/main/plugins/playbackBridge.ts | 55 ++++++++++++-- electron/main/services/nowPlaying.ts | 69 +++++++++++++---- electron/main/utils/protocol.ts | 33 +++++--- electron/preload/index.ts | 3 + shared/defaults/plugin-api.ts | 2 +- shared/types/lyrics.ts | 3 + shared/types/nowPlaying.ts | 15 +++- shared/types/plugin.ts | 50 ++++++++++-- src/components/layout/SideBar.vue | 4 +- src/stores/media.ts | 7 ++ .../shared/composables/useNowPlayingSync.ts | 8 ++ 18 files changed, 367 insertions(+), 66 deletions(-) create mode 100644 electron/main/plugins/media.ts diff --git a/docs/plugins/control.md b/docs/plugins/control.md index 5d90172d..080752f8 100644 --- a/docs/plugins/control.md +++ b/docs/plugins/control.md @@ -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[] */ @@ -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,其始末时间与行时间一致。 @@ -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` 控制播放器(两者缺一时,这些控制调用会被宿主忽略): @@ -136,7 +174,7 @@ splayer.player.on("lineChange", ({ index }) => { 以上控制方法(除 `getPosition`)均为「即发即忘」,不返回结果;非法入参(如负的 `seek`、越界音量)会被宿主忽略。 ::: tip getPosition 的正确用法 -`getPosition()` 每次调用都有一次往返开销,**仅用于偶发的一次性查询**。需要持续跟踪进度时,请直接读 `lineChange` / `playStateChange` 载荷里已经带上的 `position`,不要高频轮询 `getPosition`。 +`getPosition()` 每次调用都有一次往返开销,**仅用于偶发的一次性查询**。需要持续跟踪进度时,apiLevel 4 插件应订阅 `positionSync` 并在本地插值,不要高频轮询 `getPosition`。 ::: ## 设置项 diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 8f464a60..0f3e1e40 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -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) | @@ -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 diff --git a/electron/main/ipc/nowPlaying.ts b/electron/main/ipc/nowPlaying.ts index 467372cd..4aa2fa9d 100644 --- a/electron/main/ipc/nowPlaying.ts +++ b/electron/main/ipc/nowPlaying.ts @@ -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); }); // 渲染进程写入指定曲目的歌词偏移 @@ -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 } }); diff --git a/electron/main/plugins/host.ts b/electron/main/plugins/host.ts index fe84cc13..e1bca4c8 100644 --- a/electron/main/plugins/host.ts +++ b/electron/main/plugins/host.ts @@ -17,6 +17,7 @@ import { pluginStorageSet, } from "./storage"; import { playerControl } from "@main/services/playerControl"; +import { getCurrentCover } from "./media"; /** 处理一次 plugin→host 调用 */ export const dispatchHostCall = async ( @@ -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, diff --git a/electron/main/plugins/host.worker.ts b/electron/main/plugins/host.worker.ts index 38944f9d..e50a9677 100644 --- a/electron/main/plugins/host.worker.ts +++ b/electron/main/plugins/host.worker.ts @@ -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 - 递归深度上限 */ @@ -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)) @@ -283,6 +288,11 @@ const buildSplayer = (record: PluginContextRecord, spec: LoadSpec): HostApi => ( getPosition: () => hostCall(record, "player.getPosition", []) as Promise, }, + media: { + getCover: () => + hostCall(record, "media.getCover", []) as ReturnType, + }, + onSettingChange: (key: string, handler: (value: unknown) => void) => { const list = record.settingChangeHandlers.get(key) ?? []; list.push(handler); diff --git a/electron/main/plugins/media.ts b/electron/main/plugins/media.ts new file mode 100644 index 00000000..516f231f --- /dev/null +++ b/electron/main/plugins/media.ts @@ -0,0 +1,76 @@ +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 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 match = /^data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=\s]+)$/i.exec(url); + if (!match) return null; + return ensureBounded(Buffer.from(match[1], "base64")); +}; + +/** 读取 HTTP(S) 封面 */ +const readRemoteCover = async (url: string): Promise => { + const response = await net.fetch(url, { + method: "GET", + redirect: "follow", + signal: AbortSignal.timeout(COVER_TIMEOUT_MS), + }); + if (!response.ok) return null; + const length = Number(response.headers.get("content-length")); + if (Number.isFinite(length) && length > MAX_COVER_INPUT_BYTES) return null; + return ensureBounded(new Uint8Array(await response.arrayBuffer())); +}; + +/** 读取当前 Track 的小尺寸封面来源 */ +const readCoverSource = async (url: string): Promise => { + 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 => { + 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; + } +}; diff --git a/electron/main/plugins/net.ts b/electron/main/plugins/net.ts index c75e355d..14f30540 100644 --- a/electron/main/plugins/net.ts +++ b/electron/main/plugins/net.ts @@ -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, + }); } } diff --git a/electron/main/plugins/playbackBridge.ts b/electron/main/plugins/playbackBridge.ts index 1cdb6b49..5290bad4 100644 --- a/electron/main/plugins/playbackBridge.ts +++ b/electron/main/plugins/playbackBridge.ts @@ -37,10 +37,15 @@ const findIndex = (time: number): number => { return result; }; -const onTrackChange = (data: { track: Track | null }): void => { +const onTrackChange = (data: { track: Track | null; revision: number }): void => { lyricLines = []; currentIndex = -1; - pluginRegistry.broadcastPlaybackEvent("trackChange", { track: data.track }); + pluginRegistry.broadcastPlaybackEvent("trackChange", data); +}; + +/** 转发当前曲目的延迟元数据更新 */ +const onTrackUpdate = (data: { track: Track; revision: number }): void => { + pluginRegistry.broadcastPlaybackEvent("trackUpdate", data); }; /** 按给定进度重算当前行,与上次不同才补发;暂停态没有后续 position-sync,靠这里纠正 */ @@ -55,13 +60,37 @@ const onLyricChange = (snap: NowPlayingSnapshot): void => { lyricLines = snap.lyric; currentIndex = -1; lyricOffsetMs = snap.lyricOffsetMs; - pluginRegistry.broadcastPlaybackEvent("lyricChange", { lines: lyricLines }); + pluginRegistry.broadcastPlaybackEvent("lyricChange", { + lines: lyricLines, + source: snap.source, + status: snap.lyricStatus, + revision: snap.lyricRevision, + }); reEmitLine(snap.position); }; +/** 转发供插件本地插值的播放锚点 */ +const emitPositionSync = (data: NowPlayingPositionSync): void => { + pluginRegistry.broadcastPlaybackEvent("positionSync", { + position: data.position, + state: toPluginState(data.state), + speed: data.speed, + lyricOffsetMs, + sendTimestamp: data.sendTimestamp, + }); +}; + const onLyricOffsetChange = (data: NowPlayingLyricOffsetSync): void => { lyricOffsetMs = data.offsetMs; - reEmitLine(nowPlaying.snapshot().position); + const snap = nowPlaying.snapshot(); + reEmitLine(snap.position); + emitPositionSync({ + position: snap.position, + playing: snap.playing, + state: snap.state, + speed: snap.speed, + sendTimestamp: snap.sendTimestamp, + }); }; const onPositionSync = (data: NowPlayingPositionSync): void => { @@ -73,6 +102,7 @@ const onPositionSync = (data: NowPlayingPositionSync): void => { position: data.position, }); } + emitPositionSync(data); if (lyricLines.length === 0) return; const next = findIndex(data.position + lyricOffsetMs); if (next === currentIndex) return; @@ -94,9 +124,21 @@ const primePlugin = (id: string): void => { const snap = nowPlaying.snapshot(); const send = (event: K, data: PlaybackEventData[K]): void => pluginRegistry.sendPlaybackEventTo(id, event, data); - send("trackChange", { track: snap.track }); - send("lyricChange", { lines: snap.lyric }); + send("trackChange", { track: snap.track, revision: snap.trackRevision }); + send("lyricChange", { + lines: snap.lyric, + source: snap.source, + status: snap.lyricStatus, + revision: snap.lyricRevision, + }); send("playStateChange", { state: toPluginState(snap.state), position: snap.position }); + send("positionSync", { + position: snap.position, + state: toPluginState(snap.state), + speed: snap.speed, + lyricOffsetMs: snap.lyricOffsetMs, + sendTimestamp: snap.sendTimestamp, + }); const index = snap.lyric.length > 0 ? findIndex(snap.position + snap.lyricOffsetMs) : -1; if (index >= 0) send("lineChange", { index, position: snap.position }); }; @@ -106,6 +148,7 @@ const attach = (): void => { if (unsubscribers.length > 0) return; unsubscribers = [ nowPlaying.onTrackChange(onTrackChange), + nowPlaying.onTrackUpdate(onTrackUpdate), nowPlaying.onLyricChange(onLyricChange), nowPlaying.onLyricOffsetChange(onLyricOffsetChange), nowPlaying.onPositionSync(onPositionSync), diff --git a/electron/main/services/nowPlaying.ts b/electron/main/services/nowPlaying.ts index dd94bddf..58afb785 100644 --- a/electron/main/services/nowPlaying.ts +++ b/electron/main/services/nowPlaying.ts @@ -1,6 +1,7 @@ import { EventEmitter } from "node:events"; +import { isDeepStrictEqual } from "node:util"; import type { Track, PlayerState } from "@shared/types/player"; -import type { LyricLine, LyricData } from "@shared/types/lyrics"; +import type { LyricLine, LyricData, LyricLoadState } from "@shared/types/lyrics"; import type { NowPlayingSnapshot, NowPlayingPositionSync, @@ -10,7 +11,9 @@ import { store } from "@main/store"; type NowPlayingEvents = { /** 歌曲切换 */ - "track-change": [{ track: Track | null }]; + "track-change": [{ track: Track | null; revision: number }]; + /** 当前曲目的延迟元数据更新 */ + "track-update": [{ track: Track; revision: number }]; /** 歌词内容变化 */ "lyric-change": [NowPlayingSnapshot]; /** 播放位置锚点 */ @@ -21,10 +24,16 @@ type NowPlayingEvents = { /** 当前歌曲轻量信息 */ let currentTrack: Track | null = null; +/** 当前曲目元数据修订号 */ +let trackRevision = 0; /** 当前歌曲的完整解析歌词 */ let currentLyric: LyricLine[] = []; /** 当前激活的歌词源 */ let currentSource: LyricData = null; +/** 当前歌词加载状态 */ +let currentLyricStatus: LyricLoadState = "none"; +/** 当前歌词文档修订号 */ +let lyricRevision = 0; /** 最近一次播放位置(毫秒) */ let lastPosition = 0; /** lastPosition 真实成立的墙钟时刻(Date.now 毫秒),用于补偿其过期时长 */ @@ -73,16 +82,37 @@ let currentOffsetKey = ""; * @param lyric - 当前歌词 * @param source - 当前歌词源 */ -export const update = (track: Track | null, lyric: LyricLine[], source: LyricData): void => { - const trackChanged = (currentTrack?.id ?? null) !== (track?.id ?? null); +export const update = ( + track: Track | null, + lyric: LyricLine[], + source: LyricData, + lyricStatus: LyricLoadState, +): void => { + const previousIdentity = currentTrack ? `${currentTrack.source}:${currentTrack.id}` : null; + const nextIdentity = track ? `${track.source}:${track.id}` : null; + const trackChanged = previousIdentity !== nextIdentity; + const trackUpdated = !trackChanged && !isDeepStrictEqual(currentTrack, track); currentTrack = track; - currentLyric = lyric; - currentSource = source; if (trackChanged) { + trackRevision++; // 重置播放进度 lastPosition = 0; lastPositionAt = Date.now(); - emitter.emit("track-change", { track }); + emitter.emit("track-change", { track, revision: trackRevision }); + } else if (trackUpdated && track) { + trackRevision++; + emitter.emit("track-update", { track, revision: trackRevision }); + } + + const lyricChanged = + currentLyricStatus !== lyricStatus || + !isDeepStrictEqual(currentSource, source) || + !isDeepStrictEqual(currentLyric, lyric); + currentLyric = lyric; + currentSource = source; + currentLyricStatus = lyricStatus; + if (lyricChanged) { + lyricRevision++; } // 曲目或歌词源任一变化都重读偏移并广播 const key = track?.id ? offsetKey(track.id, source) : ""; @@ -94,7 +124,7 @@ export const update = (track: Track | null, lyric: LyricLine[], source: LyricDat offsetMs: currentLyricOffsetMs, }); } - emitter.emit("lyric-change", snapshot()); + if (lyricChanged) emitter.emit("lyric-change", snapshot()); }; /** @@ -182,8 +212,11 @@ export const setLyricOffset = (trackId: string, offsetMs: number): void => { /** 拉取当前完整状态 */ export const snapshot = (): NowPlayingSnapshot => ({ track: currentTrack, + trackRevision, lyric: currentLyric, source: currentSource, + lyricStatus: currentLyricStatus, + lyricRevision, position: lastPosition, playing, state: playState, @@ -195,21 +228,25 @@ export const snapshot = (): NowPlayingSnapshot => ({ /** 清空 */ export const clear = (): void => { - currentTrack = null; - currentLyric = []; - currentSource = null; - currentLyricOffsetMs = 0; - currentOffsetKey = ""; - emitter.emit("lyric-change", snapshot()); - emitter.emit("lyric-offset-change", { trackId: null, offsetMs: 0 }); + update(null, [], null, "none"); }; /** 订阅歌曲切换 */ -export const onTrackChange = (listener: (data: { track: Track | null }) => void): (() => void) => { +export const onTrackChange = ( + listener: (data: { track: Track | null; revision: number }) => void, +): (() => void) => { emitter.on("track-change", listener); return () => emitter.off("track-change", listener); }; +/** 订阅当前曲目的延迟元数据更新 */ +export const onTrackUpdate = ( + listener: (data: { track: Track; revision: number }) => void, +): (() => void) => { + emitter.on("track-update", listener); + return () => emitter.off("track-update", listener); +}; + /** 订阅歌词内容变化 */ export const onLyricChange = (listener: (snap: NowPlayingSnapshot) => void): (() => void) => { emitter.on("lyric-change", listener); diff --git a/electron/main/utils/protocol.ts b/electron/main/utils/protocol.ts index d3752f35..e9a6b09f 100644 --- a/electron/main/utils/protocol.ts +++ b/electron/main/utils/protocol.ts @@ -5,6 +5,27 @@ import { getAppCacheDir } from "./config"; /** cache:// 协议方案名 */ const SCHEME = "cache"; +/** + * 将 cache:// URL 解析为缓存目录内的磁盘路径 + * @param url - cache:// URL + * @returns 安全的绝对路径;非法或越界时返回 null + */ +export const resolveCacheUrlPath = (url: string): string | null => { + const withoutQuery = url.split("?")[0]; + if (!withoutQuery.startsWith(`${SCHEME}://`)) return null; + let relativePath: string; + try { + relativePath = decodeURIComponent(withoutQuery.slice(`${SCHEME}://`.length)); + } catch { + return null; + } + const root = getAppCacheDir(); + const resolved = path.resolve(root, relativePath); + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + if (resolved !== root && !resolved.startsWith(rootWithSep)) return null; + return resolved; +}; + /** * 注册 cache:// 协议方案 * 必须在 app.whenReady 之前调用 @@ -27,16 +48,8 @@ export const registerCacheScheme = (): void => { /** cache:// 协议的处理函数 */ const cacheHandler = (request: Request): Response | Promise => { - // 剥离查询串:?v=xxx 仅用于封面替换后的缓存失效,不参与路径解析 - const withoutQuery = request.url.split("?")[0]; - const relativePath = decodeURIComponent(withoutQuery.slice(`${SCHEME}://`.length)); - const root = getAppCacheDir(); - const resolved = path.resolve(root, relativePath); - // 防 cache://../ 逃逸:解析后的绝对路径必须仍在缓存根目录内 - const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; - if (resolved !== root && !resolved.startsWith(rootWithSep)) { - return new Response(null, { status: 403 }); - } + const resolved = resolveCacheUrlPath(request.url); + if (!resolved) return new Response(null, { status: 403 }); return net.fetch(`file://${resolved.replace(/\\/g, "/")}`); }; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 1fcf4b2d..b785ecfe 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -404,6 +404,9 @@ const api = { // 订阅歌曲切换事件 onTrackChange: (callback: (data: unknown) => void) => subscribe("nowPlaying:track-change", callback), + // 订阅当前曲目的延迟元数据更新 + onTrackUpdate: (callback: (data: unknown) => void) => + subscribe("nowPlaying:track-update", callback), // 订阅歌词内容变化事件 onLyricChange: (callback: (snapshot: unknown) => void) => subscribe("nowPlaying:lyric-change", callback), diff --git a/shared/defaults/plugin-api.ts b/shared/defaults/plugin-api.ts index c13b9235..491d073b 100644 --- a/shared/defaults/plugin-api.ts +++ b/shared/defaults/plugin-api.ts @@ -1,7 +1,7 @@ import type { PluginsConfig } from "../types/plugin"; /** 当前 Host API 级别;插件 `@apiLevel` 必须 ≤ 此值才加载 */ -export const HOST_API_LEVEL = 3; +export const HOST_API_LEVEL = 4; /** 各动作的默认超时(毫秒)。新增动作时在此追加。 */ export const ACTION_TIMEOUTS = { diff --git a/shared/types/lyrics.ts b/shared/types/lyrics.ts index a2bb6fca..d041290b 100644 --- a/shared/types/lyrics.ts +++ b/shared/types/lyrics.ts @@ -19,6 +19,9 @@ export const DEFAULT_LYRIC_FORMAT_ORDER: readonly LyricFormat[] = [ /** 歌词来源 */ export type LyricSource = "external" | "embedded" | "online"; +/** 当前歌词加载状态 */ +export type LyricLoadState = "loading" | "ready" | "none"; + /** 歌词数据 */ export type LyricData = { source: LyricSource; diff --git a/shared/types/nowPlaying.ts b/shared/types/nowPlaying.ts index 2e15447a..863a5455 100644 --- a/shared/types/nowPlaying.ts +++ b/shared/types/nowPlaying.ts @@ -1,18 +1,25 @@ import type { Track, PlayerState } from "./player"; -import type { LyricLine, LyricData } from "./lyrics"; +import type { LyricLine, LyricData, LyricLoadState } from "./lyrics"; /** 渲染进程 → 主进程:同步当前播放状态(track + 歌词 + 源) */ export interface NowPlayingUpdatePayload { track: Track | null; lyric: LyricLine[]; source: LyricData; + lyricStatus: LyricLoadState; } /** 主进程 → 窗口:当前播放的完整快照 */ export interface NowPlayingSnapshot { track: Track | null; + /** 当前曲目元数据修订号 */ + trackRevision: number; lyric: LyricLine[]; source: LyricData; + /** 当前歌词加载状态 */ + lyricStatus: LyricLoadState; + /** 当前歌词文档修订号 */ + lyricRevision: number; position: number; playing: boolean; /** 完整播放状态,区分 stopped 与 paused */ @@ -53,7 +60,11 @@ export interface NowPlayingApi { /** 写入指定曲目的歌词偏移(ms);0 视为清除 */ setLyricOffset: (trackId: string, offsetMs: number) => void; /** 订阅歌曲切换 */ - onTrackChange: (callback: (data: { track: Track | null }) => void) => () => void; + onTrackChange: ( + callback: (data: { track: Track | null; revision: number }) => void, + ) => () => void; + /** 订阅当前曲目的延迟元数据更新 */ + onTrackUpdate: (callback: (data: { track: Track; revision: number }) => void) => () => void; /** 订阅歌词内容变化 */ onLyricChange: (callback: (snapshot: NowPlayingSnapshot) => void) => () => void; /** 订阅播放位置锚点 */ diff --git a/shared/types/plugin.ts b/shared/types/plugin.ts index 865736e5..08bf3582 100644 --- a/shared/types/plugin.ts +++ b/shared/types/plugin.ts @@ -3,7 +3,7 @@ * 用于主进程、预加载、渲染进程、沙箱子进程之间的契约 */ -import type { LyricLine } from "./lyrics"; +import type { LyricData, LyricLine, LyricLoadState } from "./lyrics"; import type { Track } from "./player"; import type { CommentTab, MusicCommentItem } from "./comment"; @@ -40,18 +40,39 @@ export const PLUGIN_GRANTS = ["network", "control", "ui"] as const; export type PluginGrant = (typeof PLUGIN_GRANTS)[number]; /** 控制类插件可订阅的高层播放事件 */ -export type PlaybackEventKind = "trackChange" | "lyricChange" | "lineChange" | "playStateChange"; +export type PlaybackEventKind = + | "trackChange" + | "trackUpdate" + | "lyricChange" + | "lineChange" + | "playStateChange" + | "positionSync"; /** 各高层事件的载荷 */ export interface PlaybackEventData { /** 曲目切换 */ - trackChange: { track: Track | null }; + trackChange: { track: Track | null; revision: number }; + /** 当前曲目的封面、时长等延迟元数据更新 */ + trackUpdate: { track: Track; revision: number }; /** 歌词数据 */ - lyricChange: { lines: LyricLine[] }; + lyricChange: { + lines: LyricLine[]; + source: LyricData; + status: LyricLoadState; + revision: number; + }; /** 当前行索引变化 */ lineChange: { index: number; position: number }; /** 播放态变化 */ playStateChange: { state: "playing" | "paused" | "stopped"; position: number }; + /** 播放位置锚点;订阅者应按 speed 在本地插值 */ + positionSync: { + position: number; + state: "playing" | "paused" | "stopped"; + speed: number; + lyricOffsetMs: number; + sendTimestamp: number; + }; } /** 控制类插件注册的配置项类型(安全子集) */ @@ -115,6 +136,21 @@ export interface PluginPlayerApi { getPosition(): Promise; } +/** 插件按需读取的当前封面 */ +export interface PluginCoverData { + trackId: string; + source: Track["source"]; + mimeType: "image/jpeg"; + hash: string; + data: Uint8Array; +} + +/** 插件只读媒体数据面 */ +export interface PluginMediaApi { + /** 获取适合小尺寸展示的当前封面,不返回原始高清图 */ + getCover(): Promise; +} + /** 插件头部 JSDoc 元数据 */ export interface PluginManifest { /** 插件唯一 ID:作者声明的 @id 优先,否则按 `平台.名称` 兜底;跨版本/改源码均不变 */ @@ -377,6 +413,9 @@ export interface HostApi { /** 控制类播放面:监听高层事件 + 反向控制 */ player: PluginPlayerApi; + /** 只读媒体数据面 */ + media: PluginMediaApi; + /** 控制类设置变更回调:用户改设置后触发 */ onSettingChange: (key: string, handler: (value: unknown) => void) => void; } @@ -467,7 +506,8 @@ export type HostCallMethod = | "player.prev" | "player.seek" | "player.setVolume" - | "player.getPosition"; + | "player.getPosition" + | "media.getCover"; /* ========== 渲染端 ↔ 主进程的 IPC 请求参数 ========== */ diff --git a/src/components/layout/SideBar.vue b/src/components/layout/SideBar.vue index 6c5c68ee..48941a5f 100644 --- a/src/components/layout/SideBar.vue +++ b/src/components/layout/SideBar.vue @@ -58,9 +58,7 @@ const handleCreate = (): void => { /** 新建成功后跳转到该歌单 */ const handleCreated = (playlistId: string, scope: ContentScope): void => { - router.push( - `/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`, - ); + router.push(`/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`); }; /** 我的歌单分组头部 */ diff --git a/src/stores/media.ts b/src/stores/media.ts index 8b6b7b14..ce399377 100644 --- a/src/stores/media.ts +++ b/src/stores/media.ts @@ -46,6 +46,11 @@ export const useMediaStore = defineStore("media", () => { track: track.value ? toRaw(track.value) : null, lyric: toRaw(parsedLyric.value), source: activeLyric.value ? toRaw(activeLyric.value) : null, + lyricStatus: lyricLoading.value + ? ("loading" as const) + : parsedLyric.value.length > 0 + ? ("ready" as const) + : ("none" as const), }; window.api.nowPlaying.update(payload); } catch (error) { @@ -79,6 +84,7 @@ export const useMediaStore = defineStore("media", () => { quality: track.value.quality ?? info.quality, }; if (newDetail) detail.value = newDetail; + syncToMain(); }; /** @@ -94,6 +100,7 @@ export const useMediaStore = defineStore("media", () => { cover: track.value.cover || url, coverOriginal: track.value.coverOriginal || url, }; + syncToMain(); }; /** 重置歌词状态 */ diff --git a/windows/shared/composables/useNowPlayingSync.ts b/windows/shared/composables/useNowPlayingSync.ts index 107e9789..6c6ea7ca 100644 --- a/windows/shared/composables/useNowPlayingSync.ts +++ b/windows/shared/composables/useNowPlayingSync.ts @@ -120,6 +120,14 @@ export const useNowPlayingSync = (options: NowPlayingSyncOptions): NowPlayingSyn } unsubscribers.push( + window.api.nowPlaying.onTrackChange(({ track: nextTrack }) => { + track.value = nextTrack; + lyric.value = []; + primaryIndex.value = -1; + }), + window.api.nowPlaying.onTrackUpdate(({ track: nextTrack }) => { + track.value = nextTrack; + }), window.api.nowPlaying.onLyricChange((snap) => { applySnapshot(snap); kickTick(); From a412f01acd397e121ed97ea0b82ef4afe022515a Mon Sep 17 00:00:00 2001 From: LaoShui <79132480+laoshuikaixue@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:34:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(media):=20=E4=BF=AE=E5=A4=8D=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E5=9B=BE=E7=89=87=E5=A4=84=E7=90=86=E5=92=8C=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=90=8C=E6=AD=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了 BASE64 长度限制常量以控制封面大小 - 改进了 data URL 解析的安全性检查 - 优化了远程封面下载的流式处理 - 修复了播放进度同步中的速度计算错误 - 实现了更精确的播放位置锚定机制 - 添加了对封面下载超时的正确处理 --- electron/main/plugins/media.ts | 62 +++++++++++++++---- .../shared/composables/useNowPlayingSync.ts | 4 +- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/electron/main/plugins/media.ts b/electron/main/plugins/media.ts index 516f231f..aab0f559 100644 --- a/electron/main/plugins/media.ts +++ b/electron/main/plugins/media.ts @@ -7,6 +7,7 @@ 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; @@ -16,22 +17,59 @@ const ensureBounded = (data: Uint8Array): Uint8Array | null => /** 读取 data URL */ const readDataUrl = (url: string): Uint8Array | null => { - const match = /^data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=\s]+)$/i.exec(url); - if (!match) return null; - return ensureBounded(Buffer.from(match[1], "base64")); + 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 => { - const response = await net.fetch(url, { - method: "GET", - redirect: "follow", - signal: AbortSignal.timeout(COVER_TIMEOUT_MS), - }); - if (!response.ok) return null; - const length = Number(response.headers.get("content-length")); - if (Number.isFinite(length) && length > MAX_COVER_INPUT_BYTES) return null; - return ensureBounded(new Uint8Array(await response.arrayBuffer())); + 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 的小尺寸封面来源 */ diff --git a/windows/shared/composables/useNowPlayingSync.ts b/windows/shared/composables/useNowPlayingSync.ts index 6c6ea7ca..fc3b603b 100644 --- a/windows/shared/composables/useNowPlayingSync.ts +++ b/windows/shared/composables/useNowPlayingSync.ts @@ -49,7 +49,7 @@ export const useNowPlayingSync = (options: NowPlayingSyncOptions): NowPlayingSyn const resetAnchor = (positionMs: number, sendTimestamp: number): void => { const ipcDelay = Math.max(0, Date.now() - sendTimestamp); - anchorPos = positionMs + (playing.value ? ipcDelay : 0); + anchorPos = positionMs + (playing.value ? ipcDelay * speed : 0); anchorPerf = performance.now(); // currentNowPlayingMs 始终是「叠加 offset 后的歌词时间」,与 syncOnce 保持一致 currentNowPlayingMs = anchorPos + lyricOffsetMs; @@ -74,7 +74,7 @@ export const useNowPlayingSync = (options: NowPlayingSyncOptions): NowPlayingSyn return; } const ipcDelay = Math.max(0, Date.now() - sendTimestamp); - const candidate = positionMs + ipcDelay; + const candidate = positionMs + ipcDelay * speed; const projected = anchorPos + (performance.now() - anchorPerf) * speed; if (Math.abs(candidate - projected) > SYNC_DRIFT_THRESHOLD) { resetAnchor(positionMs, sendTimestamp);