Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions docs/socket.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ const ws = new WebSocket("ws://127.0.0.1:14558/ws");

所有下行消息都带有 `kind` 字段:

| `kind` | 形态 | 说明 |
| ------- | ------------------------------------- | ---------------------------------- |
| `hello` | `{ "kind": "hello", "clients": N }` | 连接建立时发送,附当前连接数 |
| `event` | `{ "kind": "event", "type", "data" }` | 播放事件推送(状态、进度、切歌等) |
| `ack` | `{ "kind": "ack", "op" }` | 命令执行成功的回执 |
| `error` | `{ "kind": "error", "op", "error" }` | 命令失败,`error` 为原因 |
| `kind` | 形态 | 说明 |
| -------- | ---------------------------------------------------------- | ---------------------------------- |
| `hello` | `{ "kind": "hello", "clients": N }` | 连接建立时发送,附当前连接数 |
| `event` | `{ "kind": "event", "type", "data" }` | 播放事件推送(状态、进度、切歌等) |
| `ack` | `{ "kind": "ack", "op" }` | 命令执行成功的回执 |
| `error` | `{ "kind": "error", "op", "error" }` | 命令失败,`error` 为原因 |
| `result` | `{ "kind": "result", "op", "platform", "status", "body" }` | 查询结果;`body` 为网易云原始响应 |

## 客户端 → 服务器

Expand All @@ -34,18 +35,22 @@ const ws = new WebSocket("ws://127.0.0.1:14558/ws");
{ "op": "play" }
```

| `op` | 附加字段 | 说明 |
| ----------- | -------------------------- | ---------------- |
| `play` | — | 播放 |
| `pause` | — | 暂停 |
| `stop` | — | 停止 |
| `next` | — | 下一曲 |
| `prev` | — | 上一曲 |
| `seek` | `{ "positionMs": number }` | 跳转(毫秒,≥0) |
| `setVolume` | `{ "volume": number }` | 音量(0 ~ 1) |
| `op` | 附加字段 | 说明 |
| ------------- | ------------------------------------------------------------ | --------------------------------------------------------------- |
| `play` | — | 播放 |
| `pause` | — | 暂停 |
| `stop` | — | 停止 |
| `next` | — | 下一曲 |
| `prev` | — | 上一曲 |
| `seek` | `{ "positionMs": number }` | 跳转(毫秒,≥0) |
| `setVolume` | `{ "volume": number }` | 音量(0 ~ 1) |
| `searchSongs` | `{ "keyword": string, "offset"?: number, "limit"?: number }` | 搜索歌曲,固定使用网易云;默认 `offset=0`、`limit=30`,最大 100 |

非法 JSON 或未知 `op` 会收到 `{ "kind": "error", ... }`。

`searchSongs` 成功时返回网易云 `cloudsearch` 的原始响应体。网易云返回 `405` 时同样通过
`result` 原样返回其 `status` 和 `body`,不会切换到其它音源或改写错误内容。

## 示例

```javascript
Expand All @@ -56,6 +61,8 @@ ws.onopen = () => {
ws.send(JSON.stringify({ op: "pause" }));
// 跳转到 1 分钟处
ws.send(JSON.stringify({ op: "seek", positionMs: 60000 }));
// 搜索歌曲
ws.send(JSON.stringify({ op: "searchSongs", keyword: "Daft Punk", limit: 10 }));
};

ws.onmessage = (evt) => {
Expand All @@ -73,6 +80,9 @@ ws.onmessage = (evt) => {
case "error":
console.warn("命令失败:", msg.op, msg.error);
break;
case "result":
console.log("网易云搜索结果:", msg.status, msg.body);
break;
}
};
```
59 changes: 59 additions & 0 deletions electron/main/ipc/externalPlaylists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ipcMain } from "electron";
import type {
ExternalPlaylistOperation,
ExternalPlaylistResult,
} from "@shared/types/externalPlaylist";
import { sendToMain } from "@main/utils/broadcast";

const REQUEST_TIMEOUT = 10 * 60_000;

interface PendingRequest {
resolve: (result: ExternalPlaylistResult) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}

const pending = new Map<string, PendingRequest>();
let rendererReady = false;

/** 请求渲染进程执行 IndexedDB 中的歌单操作 */
export const requestExternalPlaylist = (
task: ExternalPlaylistOperation,
): Promise<ExternalPlaylistResult> => {
if (!rendererReady) return Promise.reject(new Error("renderer unavailable"));
const requestId = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error("external playlist request timed out"));
}, REQUEST_TIMEOUT);
pending.set(requestId, { resolve, reject, timer });
sendToMain("externalPlaylists:request", { requestId, ...task });
});
};

/** 注册外部歌单操作 IPC */
export const registerExternalPlaylistsIpc = (): void => {
ipcMain.on("externalPlaylists:ready", () => {
rendererReady = true;
});

ipcMain.handle(
"externalPlaylists:complete",
(_event, requestId: string, result: ExternalPlaylistResult) => {
const entry = pending.get(requestId);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(requestId);
entry.resolve(result);
},
);

ipcMain.handle("externalPlaylists:fail", (_event, requestId: string, message: string) => {
const entry = pending.get(requestId);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(requestId);
entry.reject(new Error(message));
});
};
4 changes: 4 additions & 0 deletions electron/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { registerStatsIpc } from "./stats";
import { registerUpdateIpc } from "./update";
import { registerCloudIpc } from "./cloud";
import { registerCommentsIpc } from "./comments";
import { registerRecommendationsIpc } from "./recommendations";
import { registerExternalPlaylistsIpc } from "./externalPlaylists";

/** 注册所有 IPC 处理 */
export const registerIpcHandlers = (): void => {
Expand All @@ -31,6 +33,8 @@ export const registerIpcHandlers = (): void => {
registerApisIpc();
registerCloudIpc();
registerCommentsIpc();
registerRecommendationsIpc();
registerExternalPlaylistsIpc();
registerLyricsIpc();
registerHotkeyIpc();
registerThemeIpc();
Expand Down
7 changes: 6 additions & 1 deletion electron/main/ipc/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import * as songCache from "@main/services/songCache";
import { parseArtists, parseAlbum, formatArtists } from "@main/utils/metadata";
import { playerLog } from "@main/utils/logger";
import { ErrorCode } from "@shared/types/errors";
import type { LoadOptions, RepeatMode, ShuffleMode, PlayerState } from "@shared/types/player";
import type { LoadOptions, RepeatMode, ShuffleMode, PlayerState, Track } from "@shared/types/player";
import type { MediaEvent } from "@main/services/media";
import { JsPlayerEvent } from "@splayer/audio-engine";

Expand Down Expand Up @@ -603,6 +603,11 @@ export const registerPlayerIpc = (): void => {
getThumbar()?.updateLike(liked);
});

// 渲染进程在收藏成功后通知外部控制端
ipcMain.on("player:notifyLike", (_event, track: Track, liked: boolean) => {
wsBroadcast({ type: "like", data: { track, liked } });
});

// 转发渲染端发起的播放控制
ipcMain.on("player:dispatch", (_event, type: string) => {
sendToMain("player:event", { type });
Expand Down
59 changes: 59 additions & 0 deletions electron/main/ipc/recommendations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ipcMain } from "electron";
import type {
RecommendationImportRequest,
RecommendationImportResult,
} from "@shared/types/recommendation";
import { sendToMain } from "@main/utils/broadcast";

const IMPORT_TIMEOUT = 60_000;

interface PendingImport {
resolve: (result: RecommendationImportResult) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}

const pending = new Map<string, PendingImport>();
let rendererReady = false;

/** 请求渲染进程解析并导入外部推荐 */
export const importRecommendations = (
request: RecommendationImportRequest,
): Promise<RecommendationImportResult> => {
if (!rendererReady) return Promise.reject(new Error("renderer unavailable"));
const requestId = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error("renderer import timed out"));
}, IMPORT_TIMEOUT);
pending.set(requestId, { resolve, reject, timer });
sendToMain("recommendations:import", { requestId, request });
});
};

/** 注册推荐导入结果回传 IPC */
export const registerRecommendationsIpc = (): void => {
ipcMain.on("recommendations:ready", () => {
rendererReady = true;
});

ipcMain.handle(
"recommendations:complete",
(_event, requestId: string, result: RecommendationImportResult) => {
const entry = pending.get(requestId);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(requestId);
entry.resolve(result);
},
);

ipcMain.handle("recommendations:fail", (_event, requestId: string, message: string) => {
const entry = pending.get(requestId);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(requestId);
entry.reject(new Error(message));
});
};
Loading
Loading