From cf0bdce0d30c620548bf61d56816805c05970608 Mon Sep 17 00:00:00 2001 From: LiteZero Date: Mon, 19 Oct 2026 02:24:04 +0800 Subject: [PATCH] feat: add K20 GT device support with IPC integration and lyric display - Implement K20 GT service in main process to handle device connection and status updates. - Create preload script to expose K20 GT API to renderer process. - Develop K20 GT controller for managing HID device interactions. - Add utility functions for encoding reports and preparing captions. - Create Vue component for displaying K20 GT status in settings. - Write comprehensive tests for K20 GT functionality including controller and protocol. - Add HTML and main script for K20 GT dedicated window. --- docs/k20gt.md | 47 ++++ electron.vite.config.ts | 2 + electron/main/core/index.ts | 2 + electron/main/core/lifecycle.ts | 2 + electron/main/ipc/config.spec.ts | 1 + electron/main/ipc/config.ts | 7 + electron/main/ipc/index.ts | 2 + electron/main/services/k20gt.spec.ts | 203 ++++++++++++++++ electron/main/services/k20gt.ts | 229 ++++++++++++++++++ electron/preload/index.d.ts | 2 + electron/preload/index.ts | 16 ++ electron/preload/k20gt.ts | 24 ++ package.json | 1 + pnpm-lock.yaml | 8 + shared/defaults/settings.ts | 1 + shared/types/k20gt.ts | 43 ++++ shared/types/settings.ts | 2 + shared/utils/k20gt.ts | 115 +++++++++ .../settings/custom/K20GTStatus.vue | 65 +++++ src/i18n/locales/en-US.json | 23 +- src/i18n/locales/zh-CN.json | 23 +- src/settings/categories/externalLyric.ts | 32 +++ windows/k20gt/controller.spec.ts | 124 ++++++++++ windows/k20gt/controller.ts | 163 +++++++++++++ windows/k20gt/index.html | 11 + windows/k20gt/main.ts | 20 ++ windows/k20gt/protocol.spec.ts | 89 +++++++ 27 files changed, 1255 insertions(+), 2 deletions(-) create mode 100644 docs/k20gt.md create mode 100644 electron/main/services/k20gt.spec.ts create mode 100644 electron/main/services/k20gt.ts create mode 100644 electron/preload/k20gt.ts create mode 100644 shared/types/k20gt.ts create mode 100644 shared/utils/k20gt.ts create mode 100644 src/components/settings/custom/K20GTStatus.vue create mode 100644 windows/k20gt/controller.spec.ts create mode 100644 windows/k20gt/controller.ts create mode 100644 windows/k20gt/index.html create mode 100644 windows/k20gt/main.ts create mode 100644 windows/k20gt/protocol.spec.ts diff --git a/docs/k20gt.md b/docs/k20gt.md new file mode 100644 index 000000000..b3e1ac77d --- /dev/null +++ b/docs/k20gt.md @@ -0,0 +1,47 @@ +# 迈从 K20 GT 音箱歌词 + +SPlayer 可以通过 USB 将当前歌词发送到迈从 K20 GT 的屏幕,无需 Python 或额外的本地代理 + +## 使用 + +1. 通过 USB 连接 K20 GT +2. 在 MCHOSE HUB 中关闭歌词同步,避免两个程序覆盖屏幕内容 +3. 打开 SPlayer 的「设置 → 外部歌词 → 迈从 K20 GT 音箱」 +4. 开启「音箱歌词同步」,连接状态显示已连接后播放带有歌词的歌曲 + +支持原文、翻译和音译,缺少对应语言时显示原文。单页最多 52 个 UTF-8 字节,长句按当前行时长分成多页,保留完整 Unicode 字符。屏幕能否显示特殊符号取决于音箱字库 + +暂停时保留当前歌词,跳转、倍速和歌词偏移会更新显示。间奏、停止或无歌词时退出歌词显示通道。隐藏到托盘或离开设置页后继续同步,拔插设备会自动重连,通信失败时可点击「重新连接」。关闭功能或退出 SPlayer 时清屏并释放 USB 接口 + +## 实现 + +主进程订阅现有 `nowPlaying` 服务,仅保留当前曲目的分页文字与轻量播放锚点,播放期间每 100ms 选择当前页,只在内容变化时发送报文。暂停、无歌词、断开和关闭功能时停止定时任务 + +专用隐藏页面负责 WebHID 写入,使用独立的内存 session 和最小预加载接口。设备权限只授予该页面及 `3837:60C6` 设备,控制集合必须包含输出 Report `BC`。主窗口与其他歌词窗口继续沿用现有的隐藏窗口节流策略 + +设备写入串行执行,仅保留正在写入及最新待发的报文。关闭功能后销毁设备页面,取消播放订阅、设备监听和定时器。只有该功能启用时才创建额外的渲染进程 + +协议沿用「前期测试」中已验证的普通歌词命令 `0B`,没有更改设备固件、灯光或音量 + +## 验证记录 + +2026-10-19,Windows、Electron 43.2.0: + +- 14 项新增测试覆盖报文、Unicode 分页、歌词选择、暂停、倍速、偏移、慢写入合并、插拔重连、授权范围和退出清理 +- 现有及新增 Vitest 测试共 490 项通过,Node 测试共 50 项通过 +- 主进程和渲染进程类型检查、修改文件 ESLint、Electron Vite 生产构建通过 +- 使用实际主进程音箱服务、实际 `nowPlaying` 服务和生产构建的设备页面进行隔离 Electron 测试,主窗口与设备页面均隐藏 +- 实际 K20 GT 已连接,HID 写入成功返回,依次发送「SPlayer 歌词连接测试」、中文长句的两个分页及「后台歌词同步测试」,屏幕最终显示效果仍需目视确认 +- 清理后设备页面进程退出,取消所有同步订阅和定时器 + +使用 `app.getAppMetrics()`,分别等待 60 秒后采集工作集: + +| 场景 | 进程数 | 总工作集 | 设备页面工作集 | +| ---------- | ------ | -------- | -------------- | +| 未启用 | 4 | 238 MB | 无 | +| 已启用 | 5 | 319 MB | 76 MB | +| 关闭并释放 | 4 | 243 MB | 无 | + +上述内存数据来自空白主窗口加真实设备页面的隔离测试,用于确认功能的增量成本与释放情况,不代表完整播放器的总占用。完整播放器播放、10 分钟内存采样、真机拔插和休眠唤醒仍需在完整运行环境中验证 + +完整原生构建尝试因 `windows 0.62.2` 依赖包下载超时而未完成,本次未生成安装包,也未验证完整播放器的实际音频播放链路 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d0cde087c..c513cfb3b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -64,6 +64,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, "electron/preload/index.ts"), + k20gt: resolve(__dirname, "electron/preload/k20gt.ts"), }, }, }, @@ -100,6 +101,7 @@ export default defineConfig({ "desktop-lyric": resolve(__dirname, "windows/desktop-lyric/index.html"), "dynamic-island": resolve(__dirname, "windows/dynamic-island/index.html"), "taskbar-lyric": resolve(__dirname, "windows/taskbar-lyric/index.html"), + k20gt: resolve(__dirname, "windows/k20gt/index.html"), }, }, }, diff --git a/electron/main/core/index.ts b/electron/main/core/index.ts index 5b3f45d67..7d0f7d5db 100644 --- a/electron/main/core/index.ts +++ b/electron/main/core/index.ts @@ -32,6 +32,7 @@ import { captureOrpheusUrl, } from "@main/services/orpheus"; import { extractAudioFiles, captureAudioFiles } from "@main/services/externalFile"; +import { reloadK20GTConfig } from "@main/services/k20gt"; /** * 配置 Chromium 启动参数以优化内存占用 @@ -146,6 +147,7 @@ export const initApp = (): void => { initPlaybackBridge(); // 恢复歌词相关窗口 restoreLyricWindows(); + void reloadK20GTConfig(); // 注册全局快捷键 initGlobalHotkey(); initUpdater(); diff --git a/electron/main/core/lifecycle.ts b/electron/main/core/lifecycle.ts index 121ac8a6c..881fdfffc 100644 --- a/electron/main/core/lifecycle.ts +++ b/electron/main/core/lifecycle.ts @@ -8,8 +8,10 @@ import { stopMcpServer } from "@main/services/mcp/http"; import { stopStreamingPlayback } from "@main/services/streaming/playback"; import { disposeUpdater } from "@main/services/updater"; import { coreLog } from "@main/utils/logger"; +import { shutdownK20GT } from "@main/services/k20gt"; const cleanupTasks: Array<[string, () => void | Promise]> = [ + ["K20 GT 音箱", shutdownK20GT], ["外部 API", stopServer], ["MCP", stopMcpServer], ["插件", () => pluginRegistry.shutdown()], diff --git a/electron/main/ipc/config.spec.ts b/electron/main/ipc/config.spec.ts index 2b32fadce..5bbaa5eb7 100644 --- a/electron/main/ipc/config.spec.ts +++ b/electron/main/ipc/config.spec.ts @@ -34,6 +34,7 @@ vi.mock("@main/server", () => ({})); vi.mock("@main/services/mcp/http", () => ({})); vi.mock("@main/services/orpheus", () => ({})); vi.mock("@main/services/thumbnail", () => ({})); +vi.mock("@main/services/k20gt", () => ({ reloadK20GTConfig: vi.fn() })); import { registerConfigIpc } from "./config"; diff --git a/electron/main/ipc/config.ts b/electron/main/ipc/config.ts index a950cc7a6..154293d3a 100644 --- a/electron/main/ipc/config.ts +++ b/electron/main/ipc/config.ts @@ -36,6 +36,7 @@ import { setTaskbarThumbnailEnabled } from "@main/services/thumbnail"; import { getUpdateState, syncUpdateChannel } from "@main/services/updater"; import { UPDATE_CHANNELS } from "@shared/types/settings"; import { refreshPowerBlocker } from "@main/utils/powerBlocker"; +import { reloadK20GTConfig } from "@main/services/k20gt"; /** * 应用配置写入后的副作用 @@ -44,6 +45,10 @@ import { refreshPowerBlocker } from "@main/utils/powerBlocker"; */ const applyConfigChange = (keyPath: string, value: unknown): void => { switch (keyPath) { + case "k20gt.enabled": + case "k20gt.language": + void reloadK20GTConfig(); + break; case "player.keepScreenAwake": refreshPowerBlocker(); break; @@ -158,6 +163,7 @@ export const registerConfigIpc = (): void => { ipcMain.handle("config:reset", () => { if (getUpdateState().phase === "installing") throw new Error("安装启动中,无法重置配置"); store.clear(); + void reloadK20GTConfig(); syncUpdateChannel(); refreshPowerBlocker(); }); @@ -166,6 +172,7 @@ export const registerConfigIpc = (): void => { ipcMain.handle("config:replaceAll", (_event, payload: unknown) => { if (getUpdateState().phase === "installing") throw new Error("安装启动中,无法替换配置"); store.replaceAll(payload); + void reloadK20GTConfig(); syncUpdateChannel(); refreshPowerBlocker(); }); diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 77ceb1aa2..3657d3c94 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -23,6 +23,7 @@ import { registerAiModelIpc } from "./aiModel"; import { registerPlaylistIpc } from "./playlist"; import { registerRecognitionIpc } from "./recognition"; import { registerOpenccIpc } from "./opencc"; +import { registerK20GTIpc } from "@main/services/k20gt"; /** * 注册所有 IPC 处理 @@ -30,6 +31,7 @@ import { registerOpenccIpc } from "./opencc"; */ export const registerIpcHandlers = (onStartupComplete: () => void): void => { registerSystemIpc(); + registerK20GTIpc(); registerPlayerIpc(); registerConfigIpc(); registerLibraryIpc(); diff --git a/electron/main/services/k20gt.spec.ts b/electron/main/services/k20gt.spec.ts new file mode 100644 index 000000000..48f87287a --- /dev/null +++ b/electron/main/services/k20gt.spec.ts @@ -0,0 +1,203 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NowPlayingSnapshot } from "@shared/types/nowPlaying"; +import { encodeK20GTReport } from "@shared/utils/k20gt"; + +const mocks = vi.hoisted(() => ({ + settings: {} as Record, + handlers: new Map any>(), + listeners: new Map any>(), + subscriptions: new Map void>(), + windows: [] as any[], + snap: null as unknown as NowPlayingSnapshot, + sendToMain: vi.fn(), + deviceSession: { + setPermissionCheckHandler: vi.fn(), + setDevicePermissionHandler: vi.fn(), + setPermissionRequestHandler: vi.fn(), + }, +})); + +vi.mock("electron", () => ({ + BrowserWindow: class { + options: unknown; + destroyed = false; + webContents = Object.assign(new EventEmitter(), { + send: vi.fn(), + executeJavaScript: vi.fn().mockResolvedValue(undefined), + setWindowOpenHandler: vi.fn(), + getURL: () => "http://localhost:14558/windows/k20gt/index.html", + }); + /** + * 记录专用设备窗口 + * @param options - 窗口配置 + */ + constructor(options: unknown) { + this.options = options; + mocks.windows.push(this); + } + /** 模拟页面加载 */ + loadURL = vi.fn().mockResolvedValue(undefined); + /** 返回窗口销毁状态 */ + isDestroyed = () => this.destroyed; + /** 释放窗口 */ + destroy = () => { + this.destroyed = true; + }; + }, + session: { fromPartition: () => mocks.deviceSession }, + ipcMain: { + handle: (channel: string, callback: (...args: any[]) => any) => + mocks.handlers.set(channel, callback), + on: (channel: string, callback: (...args: any[]) => any) => + mocks.listeners.set(channel, callback), + }, +})); +vi.mock("@main/store", () => ({ store: { get: (key: string) => mocks.settings[key] } })); +vi.mock("@main/utils/logger", () => ({ coreLog: { warn: vi.fn(), debug: vi.fn() } })); +vi.mock("@main/utils/broadcast", () => ({ sendToMain: mocks.sendToMain })); +vi.mock("@main/services/nowPlaying", () => ({ + snapshot: () => mocks.snap, + onLyricChange: (callback: () => void) => { + mocks.subscriptions.set("lyric", callback); + return () => mocks.subscriptions.delete("lyric"); + }, + onPositionSync: (callback: () => void) => { + mocks.subscriptions.set("position", callback); + return () => mocks.subscriptions.delete("position"); + }, + onLyricOffsetChange: (callback: () => void) => { + mocks.subscriptions.set("offset", callback); + return () => mocks.subscriptions.delete("offset"); + }, +})); + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + vi.setSystemTime(10000); + vi.stubEnv("ELECTRON_RENDERER_URL", "http://localhost:14558"); + mocks.settings = { "k20gt.enabled": true, "k20gt.language": "original" }; + mocks.windows = []; + mocks.handlers.clear(); + mocks.listeners.clear(); + mocks.subscriptions.clear(); + mocks.snap = { + track: { id: "track", duration: 10000 }, + lyric: [ + { + startTime: 1000, + endTime: 5000, + words: [{ word: "你".repeat(18) }], + translatedLyric: "译文", + }, + ], + source: null, + position: 1000, + playing: true, + state: "playing", + speed: 1, + lyricOffsetMs: 0, + sendTimestamp: 10000, + } as NowPlayingSnapshot; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe("K20 GT 后台同步生命周期", () => { + it("隐藏播放时按歌词分页,内容不变不重发,暂停后停止定时任务", async () => { + const service = await import("./k20gt"); + service.registerK20GTIpc(); + await service.reloadK20GTConfig(); + const win = mocks.windows[0]; + expect(win.options.show).toBe(false); + mocks.listeners.get("k20gt:worker-status")!( + { sender: win.webContents }, + { state: "connected" }, + ); + expect(win.webContents.send).toHaveBeenLastCalledWith( + "k20gt:report", + encodeK20GTReport("你".repeat(17)), + ); + await vi.advanceTimersByTimeAsync(1900); + expect(win.webContents.send).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(100); + expect(win.webContents.send).toHaveBeenLastCalledWith("k20gt:report", encodeK20GTReport("你")); + mocks.snap = { + ...mocks.snap, + position: 3000, + state: "paused", + playing: false, + }; + mocks.subscriptions.get("position")!(mocks.snap); + expect(vi.getTimerCount()).toBe(0); + mocks.settings["k20gt.language"] = "translation"; + await service.reloadK20GTConfig(); + expect(win.webContents.send).toHaveBeenLastCalledWith( + "k20gt:report", + encodeK20GTReport("译文"), + ); + await service.shutdownK20GT(); + expect(win.destroyed).toBe(true); + expect(mocks.subscriptions.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("未启用时不创建窗口,快速切换只保留一个窗口并隔离旧页面事件", async () => { + const service = await import("./k20gt"); + service.registerK20GTIpc(); + mocks.settings["k20gt.enabled"] = false; + await service.reloadK20GTConfig(); + expect(mocks.windows).toHaveLength(0); + mocks.settings["k20gt.enabled"] = true; + await Promise.all([service.reloadK20GTConfig(), service.reloadK20GTConfig()]); + expect(mocks.windows).toHaveLength(1); + const old = mocks.windows[0]; + await mocks.handlers.get("k20gt:reconnect")!(); + expect(old.destroyed).toBe(true); + mocks.listeners.get("k20gt:worker-status")!( + { sender: old.webContents }, + { state: "connected" }, + ); + expect(mocks.handlers.get("k20gt:getStatus")!().state).toBe("connecting"); + await service.shutdownK20GT(); + }); + + it("仅向专用页面授权目标设备,其他来源和设备不获授权", async () => { + const service = await import("./k20gt"); + await service.reloadK20GTConfig(); + const check = mocks.deviceSession.setPermissionCheckHandler.mock.lastCall![0]; + const device = mocks.deviceSession.setDevicePermissionHandler.mock.lastCall![0]; + const win = mocks.windows[0]; + const details = { isMainFrame: true, requestingUrl: win.webContents.getURL() }; + expect(check(win.webContents, "hid", "", details)).toBe(true); + expect(check({}, "hid", "", details)).toBe(false); + expect( + check(win.webContents, "hid", "", { ...details, requestingUrl: "https://example.com" }), + ).toBe(false); + const permission = { + deviceType: "hid", + origin: "http://localhost:14558", + device: { vendorId: 0x3837, productId: 0x60c6 }, + }; + expect(device(permission)).toBe(true); + expect(device({ ...permission, origin: "https://example.com" })).toBe(false); + expect(device({ ...permission, device: { vendorId: 1, productId: 1 } })).toBe(false); + await service.shutdownK20GT(); + }); + + it("USB 清理卡住时限时销毁页面,不阻止应用退出", async () => { + const service = await import("./k20gt"); + await service.reloadK20GTConfig(); + const win = mocks.windows[0]; + win.webContents.executeJavaScript.mockImplementation(() => new Promise(() => {})); + const closing = service.shutdownK20GT(); + await vi.advanceTimersByTimeAsync(1500); + await closing; + expect(win.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/electron/main/services/k20gt.ts b/electron/main/services/k20gt.ts new file mode 100644 index 000000000..0a845b099 --- /dev/null +++ b/electron/main/services/k20gt.ts @@ -0,0 +1,229 @@ +import { BrowserWindow, ipcMain, session } from "electron"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { store } from "@main/store"; +import { coreLog } from "@main/utils/logger"; +import { sendToMain } from "@main/utils/broadcast"; +import * as nowPlaying from "@main/services/nowPlaying"; +import type { K20GTStatus } from "@shared/types/k20gt"; +import type { NowPlayingPositionSync } from "@shared/types/nowPlaying"; +import { + K20GT_VENDOR_ID, + K20GT_PRODUCT_ID, + encodeK20GTReport, + prepareK20GTCaptions, + selectK20GTCaption, + type K20GTCaption, +} from "@shared/utils/k20gt"; + +let worker: BrowserWindow | null = null; +let status: K20GTStatus = { state: "disabled" }; +let captions: K20GTCaption[] = []; +let anchor: NowPlayingPositionSync | null = null; +let offsetMs = 0; +let lastCaption: string | null = null; +let timer: ReturnType | null = null; +let unsubscribers: Array<() => void> = []; +let transition = Promise.resolve(); +let shuttingDown = false; + +/** + * 更新设置页状态并停止离线设备的定时任务 + * @param next - 最新连接状态 + */ +function setStatus(next: K20GTStatus): void { + status = next; + if (next.state !== "connected") { + if (timer) clearInterval(timer); + timer = null; + captions = []; + anchor = null; + lastCaption = null; + } + sendToMain("k20gt:status", next); + if (next.state === "error") coreLog.warn("K20 GT 设备错误", next.error); +} + +/** 根据当前锚点更新显示,仅向设备页面发送变化后的短报文 */ +function tick(): void { + if (!worker || status.state !== "connected" || !anchor) return; + const caption = selectK20GTCaption(captions, anchor, offsetMs); + if (caption === lastCaption) return; + lastCaption = caption; + worker.webContents.send("k20gt:report", encodeK20GTReport(caption)); +} + +/** + * 应用播放锚点,暂停和停止时取消定时计算 + * @param next - 主进程播放锚点 + */ +function updateAnchor(next: NowPlayingPositionSync): void { + if (status.state !== "connected") return; + anchor = { + position: next.position, + playing: next.playing, + state: next.state, + speed: next.speed, + sendTimestamp: next.sendTimestamp, + }; + if (next.playing && captions.length && !timer) timer = setInterval(tick, 100); + if ((!next.playing || !captions.length) && timer) { + clearInterval(timer); + timer = null; + } + tick(); +} + +/** 从统一播放服务重建当前曲目的分页,避免持有完整歌词副本 */ +function refreshCaptions(): void { + if (status.state !== "connected") return; + const snap = nowPlaying.snapshot(); + captions = snap.track + ? prepareK20GTCaptions(snap.lyric, store.get("k20gt.language"), snap.track.duration) + : []; + offsetMs = snap.lyricOffsetMs; + updateAnchor(snap); +} + +/** 清屏后销毁设备页面,并解除全部播放订阅 */ +async function stopWorker(): Promise { + const win = worker; + worker = null; + for (const off of unsubscribers) off(); + unsubscribers = []; + setStatus({ state: "disabled" }); + if (!win || win.isDestroyed()) return; + let timeout: ReturnType | undefined; + try { + // USB 写入异常时仍需允许退出,销毁页面会释放剩余的系统句柄 + await Promise.race([ + win.webContents.executeJavaScript("window.stopK20GT?.()"), + new Promise((resolve) => { + timeout = setTimeout(resolve, 1500); + }), + ]); + } catch (error) { + coreLog.debug("K20 GT 页面释放", error); + } finally { + if (timeout) clearTimeout(timeout); + if (!win.isDestroyed()) win.destroy(); + } +} + +/** 创建只负责 WebHID 写入的独立页面,不向隐藏界面广播播放进度 */ +async function startWorker(): Promise { + const deviceSession = session.fromPartition("k20gt-device"); + const pageUrl = process.env.ELECTRON_RENDERER_URL + ? new URL("windows/k20gt/index.html", `${process.env.ELECTRON_RENDERER_URL}/`).href + : pathToFileURL(join(__dirname, "../renderer/windows/k20gt/index.html")).href; + const origin = new URL(pageUrl).origin; + deviceSession.setPermissionCheckHandler((contents, permission, _origin, details) => { + return ( + permission === "hid" && + contents === worker?.webContents && + details.isMainFrame && + details.requestingUrl === pageUrl + ); + }); + deviceSession.setDevicePermissionHandler((details) => { + return ( + details.deviceType === "hid" && + details.origin === (origin === "null" ? "file://" : origin) && + worker?.webContents.getURL() === pageUrl && + details.device.vendorId === K20GT_VENDOR_ID && + details.device.productId === K20GT_PRODUCT_ID + ); + }); + deviceSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + const win = new BrowserWindow({ + title: "K20 GT", + width: 160, + height: 100, + show: false, + skipTaskbar: true, + webPreferences: { + session: deviceSession, + preload: join(__dirname, "../preload/k20gt.mjs"), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + webgl: false, + spellcheck: false, + // 设备页面需要在托盘播放时响应 USB 写入,不运行界面动画或进度定时器 + backgroundThrottling: false, + }, + }); + worker = win; + setStatus({ state: "connecting" }); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + win.webContents.on("will-navigate", (event) => event.preventDefault()); + win.webContents.on("render-process-gone", (_event, details) => { + if (worker !== win) return; + for (const off of unsubscribers) off(); + unsubscribers = []; + worker = null; + setStatus({ state: "error", error: details.reason }); + win.destroy(); + }); + unsubscribers = [ + nowPlaying.onLyricChange(refreshCaptions), + nowPlaying.onPositionSync(updateAnchor), + nowPlaying.onLyricOffsetChange((data) => { + offsetMs = data.offsetMs; + tick(); + }), + ]; + try { + await win.loadURL(pageUrl); + } catch (error) { + if (worker !== win) return; + await stopWorker(); + setStatus({ state: "error", error: String(error) }); + } +} + +/** + * 串行应用开关和语言配置,避免快速切换残留设备页面 + * @returns 配置应用完成后结束 + */ +export function reloadK20GTConfig(): Promise { + transition = transition + .then(async () => { + if (shuttingDown || !store.get("k20gt.enabled")) await stopWorker(); + else if (!worker) await startWorker(); + else refreshCaptions(); + }) + .catch((error) => { + setStatus({ state: "error", error: String(error) }); + }); + return transition; +} + +/** + * 关闭音箱功能并等待清屏释放 + * @returns 清理结束后完成 + */ +export async function shutdownK20GT(): Promise { + shuttingDown = true; + await transition; + await stopWorker(); +} + +/** 注册音箱设置接口及可信设备页面的状态回调 */ +export function registerK20GTIpc(): void { + ipcMain.handle("k20gt:getStatus", () => status); + ipcMain.handle("k20gt:reconnect", () => { + transition = transition + .then(async () => { + await stopWorker(); + if (!shuttingDown && store.get("k20gt.enabled")) await startWorker(); + }) + .catch((error) => setStatus({ state: "error", error: String(error) })); + return transition; + }); + ipcMain.on("k20gt:worker-status", (event, next: K20GTStatus) => { + if (event.sender !== worker?.webContents) return; + setStatus(next); + if (next.state === "connected") refreshCaptions(); + }); +} diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 44b477c92..f7fee660a 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -32,11 +32,13 @@ import { CommentsApi } from "@shared/types/comment"; import { AiModelApi } from "@shared/types/ai"; import { PlaylistApi } from "@shared/types/playlist"; import { OpenccApi } from "@shared/types/opencc"; +import type { K20GTApi } from "@shared/types/k20gt"; declare global { interface Window { electron: ElectronAPI; api: { + k20gt: K20GTApi; config: ConfigApi; player: PlayerApi; system: { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 3f81e68be..b625f9fdd 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -34,6 +34,7 @@ import type { } from "@shared/types/playlist"; import type { CjkTransformMode } from "@shared/types/opencc"; import type { DownloadTrackIdentity } from "@shared/types/download"; +import type { K20GTStatus } from "@shared/types/k20gt"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -56,6 +57,21 @@ const getInstallType = (): "nsis" | "portable" | "appx" | "dmg" | "appimage" => // 暴露给渲染进程的自定义 API const api = { + k20gt: { + /** 获取音箱连接状态 */ + getStatus: (): Promise => ipcRenderer.invoke("k20gt:getStatus"), + /** 重新连接音箱 */ + reconnect: (): Promise => ipcRenderer.invoke("k20gt:reconnect"), + /** + * 订阅音箱状态并在卸载时解除监听 + * @param callback - 状态处理函数 + * @returns 解除监听函数 + */ + onStatus: (callback: (status: K20GTStatus) => void): (() => void) => { + ipcRenderer.removeAllListeners("k20gt:status"); + return subscribe("k20gt:status", callback); + }, + }, config: { get: (keyPath: string) => ipcRenderer.invoke("config:get", keyPath), set: (keyPath: string, value: unknown) => ipcRenderer.invoke("config:set", keyPath, value), diff --git a/electron/preload/k20gt.ts b/electron/preload/k20gt.ts new file mode 100644 index 000000000..e715c0c6b --- /dev/null +++ b/electron/preload/k20gt.ts @@ -0,0 +1,24 @@ +import { contextBridge, ipcRenderer } from "electron"; +import type { K20GTWorkerApi } from "@shared/types/k20gt"; + +const api: K20GTWorkerApi = { + /** + * 上报音箱连接结果 + * @param status - 当前设备状态 + */ + status: (status) => ipcRenderer.send("k20gt:worker-status", status), + /** + * 订阅主进程编码的设备报文 + * @param callback - 报文处理函数 + * @returns 解除监听函数 + */ + onReport: (callback) => { + ipcRenderer.removeAllListeners("k20gt:report"); + const handler = (_event: Electron.IpcRendererEvent, report: Uint8Array): void => + callback(report); + ipcRenderer.on("k20gt:report", handler); + return () => ipcRenderer.removeListener("k20gt:report", handler); + }, +}; + +contextBridge.exposeInMainWorld("k20gtWorker", api); diff --git a/package.json b/package.json index 9bfdc999a..9fc500028 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/node": "^22.19.17", "@types/sortablejs": "^1.15.9", + "@types/w3c-web-hid": "^1.0.7", "@types/ws": "^8.18.1", "@vitejs/plugin-vue": "^6.0.8", "@vue/test-utils": "^2.4.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32212c109..784c8b36d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@types/sortablejs': specifier: ^1.15.9 version: 1.15.9 + '@types/w3c-web-hid': + specifier: ^1.0.7 + version: 1.0.7 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -2239,6 +2242,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/w3c-web-hid@1.0.7': + resolution: {integrity: sha512-/y97wBH7fYB5vKoDIn11O1ZDMNFLCAVqZ9af0OWDN7VSO2ClErEN2HlGbBuPgHBTUmZOVx1og2ZHX1U2gEJM4Q==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -7269,6 +7275,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/w3c-web-hid@1.0.7': {} + '@types/web-bluetooth@0.0.21': {} '@types/whatwg-mimetype@3.0.2': {} diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index adf5cb641..bb7ad72e5 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -12,6 +12,7 @@ export const DYNAMIC_ISLAND_BASE_HEIGHT = 40; /** 默认配置 */ export const defaultSystemConfig: SystemConfig = { + k20gt: { enabled: false, language: "original" }, player: { autoPlay: false, rememberLastTrack: true, diff --git a/shared/types/k20gt.ts b/shared/types/k20gt.ts new file mode 100644 index 000000000..19d4fea93 --- /dev/null +++ b/shared/types/k20gt.ts @@ -0,0 +1,43 @@ +/** K20 GT 歌词显示语言 */ +export type K20GTLanguage = "original" | "translation" | "romanization"; + +/** K20 GT 持久化配置 */ +export interface K20GTSettings { + enabled: boolean; + language: K20GTLanguage; +} + +/** 音箱连接状态 */ +export interface K20GTStatus { + state: "disabled" | "connecting" | "connected" | "disconnected" | "error"; + error?: string; +} + +/** 音箱设置页接口 */ +export interface K20GTApi { + /** 获取当前连接状态 */ + getStatus: () => Promise; + /** 重新连接音箱 */ + reconnect: () => Promise; + /** + * 订阅连接状态 + * @param callback - 状态处理函数 + * @returns 解除监听函数 + */ + onStatus: (callback: (status: K20GTStatus) => void) => () => void; +} + +/** 专用设备页面使用的最小预加载接口 */ +export interface K20GTWorkerApi { + /** + * 上报设备连接状态 + * @param status - 最新设备状态 + */ + status: (status: K20GTStatus) => void; + /** + * 接收已编码的设备报文 + * @param callback - 报文处理函数 + * @returns 解除监听函数 + */ + onReport: (callback: (report: Uint8Array) => void) => () => void; +} diff --git a/shared/types/settings.ts b/shared/types/settings.ts index a77425683..8fc9bf825 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -441,6 +441,8 @@ export type KugouLoginVersion = "standard" | "concept"; /** 后端配置汇总 */ export interface SystemConfig { + /** 迈从 K20 GT 音箱歌词 */ + k20gt: import("./k20gt").K20GTSettings; /** 播放器配置 */ player: PlayerSettings; /** 媒体集成配置 */ diff --git a/shared/utils/k20gt.ts b/shared/utils/k20gt.ts new file mode 100644 index 000000000..c487b096e --- /dev/null +++ b/shared/utils/k20gt.ts @@ -0,0 +1,115 @@ +import type { LyricLine } from "lyric-kit"; +import type { K20GTLanguage } from "../types/k20gt"; +import type { NowPlayingPositionSync } from "../types/nowPlaying"; + +export const K20GT_VENDOR_ID = 0x3837; +export const K20GT_PRODUCT_ID = 0x60c6; +export const K20GT_REPORT_ID = 0xbc; +const MAX_TEXT_BYTES = 52; +const encoder = new TextEncoder(); + +/** 仅保留当前曲目显示所需的时间与分页文字 */ +export interface K20GTCaption { + start: number; + end: number; + pages: string[]; +} + +/** + * 按设备容量分页,避免截断 UTF-8 字符 + * @param text - 完整文字 + * @returns 每页不超过 52 字节的文字 + */ +export function splitK20GTText(text: string): string[] { + const pages: string[] = []; + let page = ""; + let size = 0; + for (const char of text) { + const bytes = encoder.encode(char).length; + if (size + bytes > MAX_TEXT_BYTES) { + pages.push(page); + page = ""; + size = 0; + } + page += char; + size += bytes; + } + if (page) pages.push(page); + return pages; +} + +/** + * 编码普通歌词报文,空文字表示退出歌词显示 + * @param text - 不超过 52 个 UTF-8 字节的单页文字 + * @returns 不含 Report ID 的 63 字节报文 + */ +export function encodeK20GTReport(text: string): Uint8Array { + const bytes = encoder.encode(text); + if (bytes.length > MAX_TEXT_BYTES) throw new Error("K20 GT 单页文字超过 52 字节"); + const report = new Uint8Array(63); + report.set([0xaa, 1, 0, 4 + bytes.length, 0, 2, 0x0b, text ? 1 : 0, text ? 5 : 0, bytes.length]); + report.set(bytes, 10); + return report; +} + +/** + * 预先计算主歌词分页,只保留当前曲目的轻量显示数据 + * @param lyric - 已解析的歌词 + * @param language - 显示语言,缺失时回退原文 + * @param duration - 曲目时长,单位为毫秒 + * @returns 去除背景人声并限制结束时间的分页歌词 + */ +export function prepareK20GTCaptions( + lyric: LyricLine[], + language: K20GTLanguage, + duration: number, +): K20GTCaption[] { + const lines = lyric.filter((line) => !line.isBG); + return lines.map((line, index) => { + const original = line.words.map((word) => word.word).join(""); + const text = + language === "translation" + ? line.translatedLyric || original + : language === "romanization" + ? line.romanLyric || original + : original; + return { + start: line.startTime, + end: Math.min(line.endTime, lines[index + 1]?.startTime ?? Infinity, duration || Infinity), + pages: splitK20GTText(text.trim()), + }; + }); +} + +/** + * 根据播放锚点选择当前页,支持暂停、倍速、跳转和歌词偏移 + * @param captions - 当前曲目的预处理歌词 + * @param anchor - 最近播放锚点 + * @param offsetMs - 歌词偏移,正值为提前 + * @param now - 当前墙钟毫秒 + * @returns 当前应显示的单页文字,间奏或停止时为空 + */ +export function selectK20GTCaption( + captions: K20GTCaption[], + anchor: NowPlayingPositionSync, + offsetMs: number, + now = Date.now(), +): string { + if (["idle", "loading", "stopped"].includes(anchor.state)) return ""; + const elapsed = anchor.playing ? Math.max(0, now - anchor.sendTimestamp) * anchor.speed : 0; + const position = anchor.position + elapsed + offsetMs; + let low = 0; + let high = captions.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (captions[mid].start <= position) low = mid + 1; + else high = mid; + } + const line = captions[low - 1]; + if (!line || position >= line.end || !line.pages.length) return ""; + const index = Math.min( + line.pages.length - 1, + Math.floor(((position - line.start) / Math.max(1, line.end - line.start)) * line.pages.length), + ); + return line.pages[index]; +} diff --git a/src/components/settings/custom/K20GTStatus.vue b/src/components/settings/custom/K20GTStatus.vue new file mode 100644 index 000000000..c8a4b5d8e --- /dev/null +++ b/src/components/settings/custom/K20GTStatus.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 5da28c18f..2bb4563ed 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -804,7 +804,8 @@ "backupReset": "Backup & Reset", "debug": "Debug", "preset": "OCD Settings", - "envInfo": "Environment Info" + "envInfo": "Environment Info", + "k20gt": "MCHOSE K20 GT speaker" }, "about": { "checkUpdate": "Check for Updates", @@ -2238,6 +2239,26 @@ "button": "Clear All", "confirmTitle": "Clear all database caches?", "confirmDesc": "All database caches above will be cleared. This cannot be undone." + }, + "k20gtEnabled": { + "label": "Speaker lyric sync", + "description": "Display lyrics on K20 GT over USB, including while SPlayer is in the tray. Long lines are paginated. Turn off lyric sync in MCHOSE HUB to avoid competing updates." + }, + "k20gtLanguage": { + "label": "Speaker lyric language", + "description": "Use the original text when translation or romanization is unavailable", + "original": "Original", + "translation": "Translation", + "romanization": "Romanization" + }, + "k20gtStatus": { + "label": "Speaker connection", + "disabled": "Lyric sync is off", + "connecting": "Connecting to speaker", + "connected": "Speaker connected, following current lyrics", + "disconnected": "Speaker disconnected. Check USB connection; reconnects automatically when plugged in.", + "error": "Speaker communication failed. Try reconnecting.", + "reconnect": "Reconnect" } }, "collection": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ab69e6023..56da12811 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -804,7 +804,8 @@ "backupReset": "备份与重置", "debug": "调试", "preset": "强迫症配置", - "envInfo": "环境信息" + "envInfo": "环境信息", + "k20gt": "迈从 K20 GT 音箱" }, "about": { "checkUpdate": "检查更新", @@ -2238,6 +2239,26 @@ "button": "清空全部", "confirmTitle": "清空全部数据库缓存?", "confirmDesc": "将删除上方全部数据库缓存,操作不可恢复" + }, + "k20gtEnabled": { + "label": "音箱歌词同步", + "description": "通过 USB 向 K20 GT 显示当前歌词,隐藏到托盘后继续同步,长句自动分页;请关闭 MCHOSE HUB 的歌词同步以避免互相覆盖" + }, + "k20gtLanguage": { + "label": "音箱歌词语言", + "description": "翻译或音译缺失时显示原文", + "original": "原文", + "translation": "翻译", + "romanization": "音译" + }, + "k20gtStatus": { + "label": "音箱连接状态", + "disabled": "歌词同步已关闭", + "connecting": "正在连接音箱", + "connected": "音箱已连接,正在跟随当前歌词", + "disconnected": "未连接,请检查音箱 USB 连接,插入后会自动重连", + "error": "音箱通信失败,可尝试重新连接", + "reconnect": "重新连接" } }, "collection": { diff --git a/src/settings/categories/externalLyric.ts b/src/settings/categories/externalLyric.ts index baf8a815a..1ffcc4fc1 100644 --- a/src/settings/categories/externalLyric.ts +++ b/src/settings/categories/externalLyric.ts @@ -2,6 +2,37 @@ import type { SettingCategory, SettingSection } from "@/types/settings-schema"; import { useSettingsStore } from "@/stores/settings"; import { isMac } from "@/utils/config"; import IconLucideMonitor from "~icons/lucide/monitor"; +import K20GTStatus from "@/components/settings/custom/K20GTStatus.vue"; + +const k20gtSection: SettingSection = { + id: "k20gt", + tag: { text: "Beta" }, + items: [ + { + key: "k20gtEnabled", + type: "switch", + binding: { store: "settings", path: "system.k20gt.enabled" }, + defaultValue: false, + }, + { + key: "k20gtLanguage", + type: "select", + binding: { store: "settings", path: "system.k20gt.language" }, + defaultValue: "original", + options: [ + { value: "original", labelKey: "settings.k20gtLanguage.original" }, + { value: "translation", labelKey: "settings.k20gtLanguage.translation" }, + { value: "romanization", labelKey: "settings.k20gtLanguage.romanization" }, + ], + }, + { + key: "k20gtStatus", + type: "custom", + component: K20GTStatus, + fullWidth: true, + }, + ], +}; const desktopLyricSection: SettingSection = { id: "desktopLyric", @@ -392,6 +423,7 @@ const externalLyricCategory: SettingCategory = { id: "externalLyric", icon: IconLucideMonitor, sections: [ + k20gtSection, desktopLyricSection, dynamicIslandSection, // taskbarLyric 仅 Windows 可用 diff --git a/windows/k20gt/controller.spec.ts b/windows/k20gt/controller.spec.ts new file mode 100644 index 000000000..fa83331df --- /dev/null +++ b/windows/k20gt/controller.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import { K20GTController, isK20GT } from "./controller"; +import { encodeK20GTReport } from "@shared/utils/k20gt"; + +/** + * 创建可控制写入与插拔的 HID 测试环境 + * @returns 设备、事件源与状态回调 + */ +function fixture() { + const device = Object.assign(new EventTarget(), { + vendorId: 0x3837, + productId: 0x60c6, + collections: [{ children: [{ outputReports: [{ reportId: 0xbc }] }] }], + opened: false, + open: vi.fn(async () => { + device.opened = true; + }), + close: vi.fn(async () => { + device.opened = false; + }), + sendReport: vi.fn().mockResolvedValue(undefined), + }); + const hid = Object.assign(new EventTarget(), { + getDevices: vi.fn(async () => [device as unknown as HIDDevice]), + }); + const onStatus = vi.fn(); + const controller = new K20GTController(hid as unknown as HID, onStatus); + return { device, hid, onStatus, controller }; +} + +/** + * 构造设备插拔事件 + * @param type - 事件名称 + * @param device - 目标设备 + * @returns 具有设备字段的事件 + */ +function deviceEvent(type: string, device: unknown): Event { + return Object.assign(new Event(type), { device }); +} + +describe("K20 GT 设备写入与释放", () => { + it("只选择包含文字 Report 的目标设备,并合并并发连接", async () => { + const { controller, device } = fixture(); + expect(isK20GT(device as unknown as HIDDevice)).toBe(true); + expect(isK20GT({ ...device, productId: 1 } as unknown as HIDDevice)).toBe(false); + expect(isK20GT({ ...device, collections: [] } as unknown as HIDDevice)).toBe(false); + await Promise.all([controller.connect(), controller.connect()]); + expect(device.open).toHaveBeenCalledOnce(); + await controller.dispose(); + }); + + it("慢写入期间仅保留最新待发歌词,关闭时最后发送清屏", async () => { + const { controller, device } = fixture(); + await controller.connect(); + let finish!: () => void; + device.sendReport.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + controller.submit(encodeK20GTReport("第一句")); + controller.submit(encodeK20GTReport("旧句")); + controller.submit(encodeK20GTReport("最新句")); + expect(device.sendReport).toHaveBeenCalledTimes(1); + finish(); + await vi.waitFor(() => expect(device.sendReport).toHaveBeenCalledTimes(2)); + expect(device.sendReport.mock.calls[1]).toEqual([0xbc, encodeK20GTReport("最新句")]); + await controller.dispose(); + expect(device.sendReport).toHaveBeenLastCalledWith(0xbc, encodeK20GTReport("")); + expect(device.close).toHaveBeenCalledOnce(); + }); + + it("拔出设备丢弃待发内容,插回后重连且释放后不再响应插入", async () => { + const { controller, device, hid, onStatus } = fixture(); + await controller.connect(); + hid.dispatchEvent(deviceEvent("disconnect", device)); + device.opened = false; + controller.submit(encodeK20GTReport("旧句")); + expect(device.sendReport).not.toHaveBeenCalled(); + expect(onStatus).toHaveBeenLastCalledWith({ state: "disconnected" }); + hid.dispatchEvent(deviceEvent("connect", device)); + await controller.connect(); + expect(onStatus).toHaveBeenLastCalledWith({ state: "connected" }); + await controller.dispose(); + hid.dispatchEvent(deviceEvent("connect", device)); + expect(device.open).toHaveBeenCalledTimes(2); + }); + + it("设备写入失败后关闭句柄,并允许重新连接", async () => { + const { controller, device, onStatus } = fixture(); + await controller.connect(); + device.sendReport.mockRejectedValueOnce(new Error("USB failed")); + controller.submit(encodeK20GTReport("测试")); + await vi.waitFor(() => + expect(onStatus).toHaveBeenLastCalledWith({ state: "error", error: "Error: USB failed" }), + ); + expect(device.opened).toBe(false); + await controller.connect(); + expect(device.opened).toBe(true); + await controller.dispose(); + }); + + it("连接尚未完成时关闭,不遗留后来打开的句柄", async () => { + const { controller, device, onStatus } = fixture(); + let finish!: () => void; + device.open.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => { + device.opened = true; + resolve(); + }; + }), + ); + const connecting = controller.connect(); + await vi.waitFor(() => expect(device.open).toHaveBeenCalled()); + const closing = controller.dispose(); + finish(); + await Promise.all([connecting, closing]); + expect(device.opened).toBe(false); + expect(onStatus).not.toHaveBeenCalledWith({ state: "connected" }); + }); +}); diff --git a/windows/k20gt/controller.ts b/windows/k20gt/controller.ts new file mode 100644 index 000000000..d499859dd --- /dev/null +++ b/windows/k20gt/controller.ts @@ -0,0 +1,163 @@ +/// +import { + K20GT_PRODUCT_ID, + K20GT_REPORT_ID, + K20GT_VENDOR_ID, + encodeK20GTReport, +} from "@shared/utils/k20gt"; +import type { K20GTStatus } from "@shared/types/k20gt"; + +/** + * 判断是否为音箱的文字控制集合 + * @param device - 浏览器 HID 设备 + * @returns 是否包含目标输出报文 + */ +export function isK20GT(device: HIDDevice): boolean { + if (device.vendorId !== K20GT_VENDOR_ID || device.productId !== K20GT_PRODUCT_ID) return false; + const pending = [...device.collections]; + while (pending.length) { + const collection = pending.pop()!; + if (collection.outputReports?.some((report) => report.reportId === K20GT_REPORT_ID)) + return true; + pending.push(...(collection.children ?? [])); + } + return false; +} + +/** 串行发送,仅保留正在发送和最新待发的两条报文 */ +export class K20GTController { + private device: HIDDevice | null = null; + private pending: Uint8Array | null = null; + private draining: Promise | null = null; + private connecting: Promise | null = null; + private disposed = false; + private epoch = 0; + + /** + * 创建由专用设备页面持有的控制器 + * @param hid - 当前页面的 HID 入口 + * @param onStatus - 连接状态回调 + */ + constructor( + private readonly hid: HID, + private readonly onStatus: (status: K20GTStatus) => void, + ) { + hid.addEventListener("connect", this.onConnect); + hid.addEventListener("disconnect", this.onDisconnect); + } + + /** + * 目标设备插入后尝试恢复连接 + * @param event - 设备插入事件 + */ + private onConnect = (event: HIDConnectionEvent): void => { + if (isK20GT(event.device)) void this.connect(); + }; + + /** + * 拔出当前设备后丢弃旧歌词 + * @param event - 设备拔出事件 + */ + private onDisconnect = (event: HIDConnectionEvent): void => { + if (event.device !== this.device) return; + this.epoch++; + this.device = null; + this.pending = null; + this.onStatus({ state: "disconnected" }); + }; + + /** + * 连接宿主已授权的目标设备,合并并发连接请求 + * @returns 连接流程结束后完成 + */ + connect(): Promise { + if (this.disposed || this.device?.opened) return Promise.resolve(); + if (this.connecting) return this.connecting; + this.connecting = this.open().finally(() => { + this.connecting = null; + }); + return this.connecting; + } + + /** 获取已授权的控制集合并打开设备 */ + private async open(): Promise { + this.onStatus({ state: "connecting" }); + let device: HIDDevice | undefined; + try { + device = (await this.hid.getDevices()).find(isK20GT); + if (this.disposed) return; + if (!device) { + this.onStatus({ state: "disconnected" }); + return; + } + await device.open(); + if (this.disposed) { + await device.close(); + return; + } + this.device = device; + this.epoch++; + this.onStatus({ state: "connected" }); + } catch (error) { + if (device?.opened) await device.close().catch(() => {}); + if (!this.disposed) this.onStatus({ state: "error", error: String(error) }); + } + } + + /** + * 合并等待发送的旧文字 + * @param report - 不含 Report ID 的报文 + */ + submit(report: Uint8Array): void { + if (this.disposed || !this.device?.opened) return; + this.pending = report; + this.ensureDraining(); + } + + /** 启动写入,并接续完成边界到达的新报文 */ + private ensureDraining(): void { + if (this.draining) return; + this.draining = this.drain().finally(() => { + this.draining = null; + if (this.pending) this.ensureDraining(); + }); + } + + /** 串行写入,失败时关闭设备并等待重新连接 */ + private async drain(): Promise { + while (this.pending && this.device) { + const device = this.device; + const epoch = this.epoch; + const report = this.pending; + this.pending = null; + try { + await device.sendReport(K20GT_REPORT_ID, new Uint8Array(report)); + } catch (error) { + if (this.device !== device || epoch !== this.epoch) continue; + this.device = null; + this.pending = null; + await device.close().catch(() => {}); + if (!this.disposed) this.onStatus({ state: "error", error: String(error) }); + } + } + } + + /** 停止接收更新,清屏并释放 USB 接口和设备事件 */ + async dispose(): Promise { + this.disposed = true; + this.hid.removeEventListener("connect", this.onConnect); + this.hid.removeEventListener("disconnect", this.onDisconnect); + this.pending = null; + await this.connecting; + await this.draining; + const device = this.device; + this.device = null; + if (device?.opened) { + try { + await device.sendReport(K20GT_REPORT_ID, encodeK20GTReport("")); + } finally { + await device.close(); + } + } + } +} diff --git a/windows/k20gt/index.html b/windows/k20gt/index.html new file mode 100644 index 000000000..b3374adf7 --- /dev/null +++ b/windows/k20gt/index.html @@ -0,0 +1,11 @@ + + + + + + K20 GT + + + + + diff --git a/windows/k20gt/main.ts b/windows/k20gt/main.ts new file mode 100644 index 000000000..281192bd2 --- /dev/null +++ b/windows/k20gt/main.ts @@ -0,0 +1,20 @@ +import { K20GTController } from "./controller"; +import type { K20GTWorkerApi } from "@shared/types/k20gt"; + +declare global { + interface Window { + k20gtWorker: K20GTWorkerApi; + stopK20GT: () => Promise; + } +} + +const controller = new K20GTController(navigator.hid, window.k20gtWorker.status); +const unsubscribe = window.k20gtWorker.onReport((report) => controller.submit(report)); + +/** 停止设备页面并解除报文监听 */ +window.stopK20GT = async (): Promise => { + unsubscribe(); + await controller.dispose(); +}; + +void controller.connect(); diff --git a/windows/k20gt/protocol.spec.ts b/windows/k20gt/protocol.spec.ts new file mode 100644 index 000000000..360a9f2e2 --- /dev/null +++ b/windows/k20gt/protocol.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { LyricLine } from "lyric-kit"; +import type { NowPlayingPositionSync } from "@shared/types/nowPlaying"; +import { + encodeK20GTReport, + prepareK20GTCaptions, + selectK20GTCaption, + splitK20GTText, +} from "@shared/utils/k20gt"; + +/** + * 创建测试歌词行 + * @param text - 歌词原文 + * @param startTime - 开始毫秒 + * @param endTime - 结束毫秒 + * @returns 最小歌词行 + */ +function line(text: string, startTime = 1000, endTime = 5000): LyricLine { + return { startTime, endTime, words: [{ word: text }] } as LyricLine; +} + +const anchor: NowPlayingPositionSync = { + position: 1000, + sendTimestamp: 10000, + playing: true, + state: "playing", + speed: 1, +}; + +describe("K20 GT 协议与歌词选择", () => { + it("与已验证的中文报文及退出报文逐字节一致", () => { + const report = encodeK20GTReport("你好,世界"); + const bytes = new TextEncoder().encode("你好,世界"); + const expected = new Uint8Array(63); + expected.set([0xaa, 1, 0, 19, 0, 2, 0x0b, 1, 5, 15, ...bytes]); + expect(report).toEqual(expected); + const clear = new Uint8Array(63); + clear.set([0xaa, 1, 0, 4, 0, 2, 0x0b]); + expect(encodeK20GTReport("")).toEqual(clear); + expect(() => encodeK20GTReport("a".repeat(53))).toThrow(); + }); + + it("在 52 字节边界保留完整中文和非 BMP 字符", () => { + const text = `${"你".repeat(17)}😀abc`; + const pages = splitK20GTText(text); + expect(pages).toEqual(["你".repeat(17), "😀abc"]); + expect(pages.join("")).toBe(text); + expect(splitK20GTText("a".repeat(52))).toEqual(["a".repeat(52)]); + expect(splitK20GTText("")).toEqual([]); + }); + + it("跟随播放时间分页,并处理暂停、倍速、偏移与向后跳转", () => { + const captions = prepareK20GTCaptions([line("你".repeat(18))], "original", 10000); + expect(selectK20GTCaption(captions, anchor, 0, 10000)).toBe("你".repeat(17)); + expect(selectK20GTCaption(captions, anchor, 0, 12000)).toBe("你"); + expect(selectK20GTCaption(captions, { ...anchor, speed: 2 }, 0, 11000)).toBe("你"); + expect(selectK20GTCaption(captions, anchor, 2000, 10000)).toBe("你"); + expect( + selectK20GTCaption(captions, { ...anchor, playing: false, state: "paused" }, 0, 99999), + ).toBe("你".repeat(17)); + expect(selectK20GTCaption(captions, { ...anchor, position: 500 }, 0, 10000)).toBe(""); + }); + + it("忽略背景人声,间奏和结束时清屏,重叠行切到最新主行", () => { + const captions = prepareK20GTCaptions( + [ + line("前句", 1000, 4000), + { ...line("背景", 2000, 4000), isBG: true }, + line("后句", 3000, 5000), + ], + "original", + 4500, + ); + expect(selectK20GTCaption(captions, anchor, 0, 11000)).toBe("前句"); + expect(selectK20GTCaption(captions, anchor, 0, 12000)).toBe("后句"); + expect(selectK20GTCaption(captions, anchor, 0, 13500)).toBe(""); + for (const state of ["loading", "stopped", "idle"] as const) { + expect(selectK20GTCaption(captions, { ...anchor, state }, 0, 10000)).toBe(""); + } + }); + + it("翻译与音译独立选择,缺失时回退原文,切歌后只使用新歌词", () => { + const lyric = [{ ...line("原文"), translatedLyric: "译文", romanLyric: "音译" }]; + expect(prepareK20GTCaptions(lyric, "translation", 10000)[0].pages).toEqual(["译文"]); + expect(prepareK20GTCaptions(lyric, "romanization", 10000)[0].pages).toEqual(["音译"]); + expect(prepareK20GTCaptions([line("新曲")], "translation", 10000)[0].pages).toEqual(["新曲"]); + expect(selectK20GTCaption([], anchor, 0, 10000)).toBe(""); + }); +});