Skip to content
Closed
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
47 changes: 47 additions & 0 deletions docs/k20gt.md
Original file line number Diff line number Diff line change
@@ -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` 依赖包下载超时而未完成,本次未生成安装包,也未验证完整播放器的实际音频播放链路
2 changes: 2 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, "electron/preload/index.ts"),
k20gt: resolve(__dirname, "electron/preload/k20gt.ts"),
},
},
},
Expand Down Expand Up @@ -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"),
},
},
},
Expand Down
2 changes: 2 additions & 0 deletions electron/main/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
captureOrpheusUrl,
} from "@main/services/orpheus";
import { extractAudioFiles, captureAudioFiles } from "@main/services/externalFile";
import { reloadK20GTConfig } from "@main/services/k20gt";

/**
* 配置 Chromium 启动参数以优化内存占用
Expand Down Expand Up @@ -146,6 +147,7 @@ export const initApp = (): void => {
initPlaybackBridge();
// 恢复歌词相关窗口
restoreLyricWindows();
void reloadK20GTConfig();
// 注册全局快捷键
initGlobalHotkey();
initUpdater();
Expand Down
2 changes: 2 additions & 0 deletions electron/main/core/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>]> = [
["K20 GT 音箱", shutdownK20GT],
["外部 API", stopServer],
["MCP", stopMcpServer],
["插件", () => pluginRegistry.shutdown()],
Expand Down
1 change: 1 addition & 0 deletions electron/main/ipc/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
7 changes: 7 additions & 0 deletions electron/main/ipc/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
* 应用配置写入后的副作用
Expand All @@ -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;
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
});
Expand Down
2 changes: 2 additions & 0 deletions electron/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import { registerAiModelIpc } from "./aiModel";
import { registerPlaylistIpc } from "./playlist";
import { registerRecognitionIpc } from "./recognition";
import { registerOpenccIpc } from "./opencc";
import { registerK20GTIpc } from "@main/services/k20gt";

/**
* 注册所有 IPC 处理
* @param onStartupComplete - 主界面启动完成后执行的应用初始化回调
*/
export const registerIpcHandlers = (onStartupComplete: () => void): void => {
registerSystemIpc();
registerK20GTIpc();
registerPlayerIpc();
registerConfigIpc();
registerLibraryIpc();
Expand Down
203 changes: 203 additions & 0 deletions electron/main/services/k20gt.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
handlers: new Map<string, (...args: any[]) => any>(),
listeners: new Map<string, (...args: any[]) => any>(),
subscriptions: new Map<string, (...args: any[]) => 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);
});
});
Loading