Skip to content
Merged
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
222 changes: 140 additions & 82 deletions CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,13 @@ declare module 'vue' {
SToast: typeof import('./src/components/ui/SToast.vue')['default']
STooltip: typeof import('./src/components/ui/STooltip.vue')['default']
StorageManager: typeof import('./src/components/settings/custom/StorageManager.vue')['default']
StreamingServerList: typeof import('./src/components/settings/custom/StreamingServerList.vue')['default']
STree: typeof import('./src/components/ui/STree.vue')['default']
SVirtualList: typeof import('./src/components/ui/SVirtualList.vue')['default']
SwitchRoot: typeof import('reka-ui')['SwitchRoot']
SwitchThumb: typeof import('reka-ui')['SwitchThumb']
Toolbar: typeof import('./src/components/player/Toolbar.vue')['default']
TrackInfo: typeof import('./src/components/player/TrackInfo.vue')['default']
Versions: typeof import('./src/components/Versions.vue')['default']
WindowControls: typeof import('./src/layouts/components/WindowControls.vue')['default']
}
}
2 changes: 2 additions & 0 deletions electron/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { registerApisIpc } from "./apis";
import { registerLyricsIpc } from "./lyrics";
import { registerHotkeyIpc } from "./hotkey";
import { registerThemeIpc } from "./theme";
import { registerStreamingIpc } from "./streaming";

/** 注册所有 IPC 处理 */
export const registerIpcHandlers = (): void => {
Expand All @@ -23,4 +24,5 @@ export const registerIpcHandlers = (): void => {
registerLyricsIpc();
registerHotkeyIpc();
registerThemeIpc();
registerStreamingIpc();
};
135 changes: 83 additions & 52 deletions electron/main/ipc/player.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { app, ipcMain, powerMonitor } from "electron";
import { sendToMain } from "@main/utils/broadcast";
import { toCacheUrl } from "@main/utils/protocol";
import { toMs } from "@main/utils/time";
import * as mediaService from "@main/services/media";
import * as nowPlaying from "@main/services/nowPlaying";
import { fetchBytes } from "@main/utils/fetchBytes";
import { getPlayer, resetPlayer, onPlayerCreated } from "@main/services/engine";
import { startDevicePolling, stopDevicePolling } from "@main/services/device";
import { getThumbar } from "@main/services/thumbar";
Expand All @@ -16,7 +16,7 @@ import { appName } from "@main/utils/config";
import { parseArtists, parseAlbum, formatArtists } from "@main/utils/metadata";
import { playerLog } from "@main/utils/logger";
import { ErrorCode } from "@shared/types/errors";
import type { RepeatMode, ShuffleMode } from "@shared/types/player";
import type { LoadOptions, RepeatMode, ShuffleMode } from "@shared/types/player";
import type { MediaEvent } from "@main/services/media";
import { JsPlayerEvent } from "@splayer/audio-engine";

Expand Down Expand Up @@ -109,13 +109,20 @@ const registerNativeEvents = (inst: InstanceType<AudioEngineModule["AudioPlayer"
});
};

/** 每次 player:load 自增 */
let loadSeq = 0;

/** 播放器相关 IPC */
export const registerPlayerIpc = (): void => {
// 注册实例创建/重建时的回调
onPlayerCreated(registerNativeEvents);
onPlayerCreated(() => startDevicePolling());
// 加载音频文件
ipcMain.handle("player:load", (_event, source: string, autoPlay = true) => {
ipcMain.handle("player:load", async (_event, source: string, options: LoadOptions = {}) => {
const autoPlay = options.autoPlay ?? true;
const authoritative = options.meta ?? null;
const isStreaming = authoritative?.source === "streaming";
const seq = ++loadSeq;
try {
const inst = getPlayer();
sendToMain("player:event", {
Expand All @@ -128,55 +135,77 @@ export const registerPlayerIpc = (): void => {
isFinished: false,
},
});
const meta = inst.load(source, autoPlay);
// 提前解析元数据
const artists = parseArtists(meta.artist ?? "");
const artistStr = formatArtists(artists);
const trackTitle = meta.title || source.split(/[/\\]/).pop() || source;
const trackAlbum = parseAlbum(meta.album ?? "");
// 写一次 SMTC/托盘/标题
const applyDisplay = (
title: string,
artist: string,
album: string,
coverData: Buffer | undefined,
durationMs: number,
): void => {
const header = artist ? `${title} - ${artist}` : title || appName;
mediaService.setMetadata({ title, artist, album, coverData, durationMs });
mediaService.setPlayState({ status: autoPlay ? "Playing" : "Paused" });
getMainWindow()?.setTitle(header);
setTraySongName(header);
setTrayPlayState(autoPlay ? "playing" : "paused");
};
// 流媒体乐观更新
if (authoritative) {
applyDisplay(
authoritative.title || source.split(/[/\\]/).pop() || source,
formatArtists(authoritative.artists ?? []),
authoritative.album?.name ?? "",
undefined,
authoritative.duration ?? 0,
);
}
const meta = await inst.load(source, autoPlay);
const durationMs = toMs(meta.duration);
const trackId = createHash("sha256").update(source).digest("hex").slice(0, 16);
// 高清封面更新系统媒体控件
const coverData = inst.getCoverRaw() ?? undefined;
mediaService.setMetadata({
title: trackTitle,
artist: artistStr,
album: trackAlbum?.name ?? "",
coverData,
durationMs,
});
const playState = autoPlay ? "Playing" : "Paused";
mediaService.setPlayState({ status: playState });
// 更新窗口标题和托盘
const displayTitle = artistStr ? `${trackTitle} - ${artistStr}` : trackTitle || appName;
getMainWindow()?.setTitle(displayTitle);
setTraySongName(displayTitle);
setTrayPlayState(autoPlay ? "playing" : "paused");
const fallbackTitle = meta.title || source.split(/[/\\]/).pop() || source;
const displayTitle = authoritative?.title ?? fallbackTitle;
const displayArtist = authoritative
? formatArtists(authoritative.artists ?? [])
: formatArtists(parseArtists(meta.artist ?? ""));
const displayAlbum = authoritative?.album?.name ?? parseAlbum(meta.album ?? "")?.name ?? "";
// 本地封面
const localCover = isStreaming ? null : (inst.getCoverRaw() ?? null);
applyDisplay(displayTitle, displayArtist, displayAlbum, localCover ?? undefined, durationMs);
// 流媒体高清封面
if (isStreaming && authoritative?.cover && /^https?:\/\//i.test(authoritative.cover)) {
const coverUrl = authoritative.cover;
void fetchBytes(coverUrl).then((buf) => {
if (!buf) return;
if (seq !== loadSeq) return;
mediaService.setMetadata({
title: displayTitle,
artist: displayArtist,
album: displayAlbum,
coverData: buf,
durationMs,
});
});
}
const quality = {
sampleRate: meta.originalSampleRate,
channels: meta.channels,
bitsPerSample: meta.bitsPerSample,
bitRate: meta.bitRate,
codec: meta.codec,
};
const data = {
track: {
id: trackId,
source: "local",
path: source,
title: trackTitle,
comment: meta.comment ?? undefined,
artists,
album: trackAlbum,
duration: durationMs,
cover: toCacheUrl(meta.cover),
},
detail: {
quality: {
sampleRate: meta.originalSampleRate,
channels: meta.channels,
bitsPerSample: meta.bitsPerSample,
bitRate: meta.bitRate,
codec: meta.codec,
},
quality,
embeddedLyric: meta.embeddedLyric,
externalLyrics: meta.externalLyrics,
},
mediaInfo: {
duration: durationMs,
cover: isStreaming ? undefined : toCacheUrl(meta.cover),
quality,
},
};
playerLog.debug(`加载成功: ${trackTitle}`);
playerLog.debug(`加载成功: ${displayTitle}`);
return { success: true, data };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -222,10 +251,10 @@ export const registerPlayerIpc = (): void => {
});

// 跳转到指定播放位置
ipcMain.handle("player:seek", (_event, positionMs: number) => {
ipcMain.handle("player:seek", async (_event, positionMs: number) => {
try {
const positionSecs = positionMs / 1000;
getPlayer().seek(positionSecs);
await getPlayer().seek(positionSecs);
mediaService.setTimeline({
currentMs: positionMs,
totalMs: toMs(getPlayer().getDuration()),
Expand Down Expand Up @@ -466,11 +495,13 @@ export const registerPlayerIpc = (): void => {
break;
case "Seek":
if (event.positionMs != null) {
inst.seek(event.positionMs / 1000);
mediaService.setTimeline({
currentMs: event.positionMs,
totalMs: toMs(inst.getDuration()),
seeked: true,
const targetMs = event.positionMs;
void inst.seek(targetMs / 1000).then(() => {
mediaService.setTimeline({
currentMs: targetMs,
totalMs: toMs(inst.getDuration()),
seeked: true,
});
});
}
break;
Expand Down
108 changes: 108 additions & 0 deletions electron/main/ipc/streaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* 流媒体相关 IPC:
* - loadServers / saveServers:服务器配置持久化
*/
import fs from "node:fs";
import path from "node:path";
import { app, ipcMain, safeStorage } from "electron";
import { writeFileSync as atomicWriteSync } from "atomically";
import { streamingLog } from "@main/utils/logger";
import type { StreamingServerConfig } from "@shared/types/streaming";

const STORAGE_FILE = path.join(app.getPath("userData"), "streaming.json");

/** 持久化形态:密码加密、accessToken/userId 不持久化(每次会话重新登录) */
interface PersistedServer extends Omit<
StreamingServerConfig,
"password" | "accessToken" | "userId"
> {
encryptedPassword: string;
}

interface PersistedState {
servers: PersistedServer[];
activeServerId: string | null;
}

const readPersisted = (): PersistedState => {
try {
const raw = JSON.parse(fs.readFileSync(STORAGE_FILE, "utf-8")) as PersistedState;
if (!Array.isArray(raw?.servers)) return { servers: [], activeServerId: null };
return { servers: raw.servers, activeServerId: raw.activeServerId ?? null };
} catch {
return { servers: [], activeServerId: null };
}
};

const writePersisted = (data: PersistedState): void => {
try {
const dir = path.dirname(STORAGE_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
atomicWriteSync(STORAGE_FILE, JSON.stringify(data, null, 2));
} catch (err) {
streamingLog.error("写入 streaming.json 失败:", err);
}
Comment thread
imsyy marked this conversation as resolved.
};

/**
* 加密密码
* @param plain 明文密码
* @returns 加密后的密码
*/
const encryptPassword = (plain: string): string => {
if (!plain) return "";
if (!safeStorage.isEncryptionAvailable()) {
return Buffer.from(plain, "utf-8").toString("base64");
}
return safeStorage.encryptString(plain).toString("base64");
Comment on lines +52 to +57
};

/**
* 解密密码
* @param encrypted 加密后的密码
* @returns 明文密码
*/
const decryptPassword = (encrypted: string): string => {
if (!encrypted) return "";
try {
const buf = Buffer.from(encrypted, "base64");
if (!safeStorage.isEncryptionAvailable()) {
return buf.toString("utf-8");
}
return safeStorage.decryptString(buf);
} catch {
return "";
}
};

export const registerStreamingIpc = (): void => {
ipcMain.handle("streaming:loadServers", () => {
const persisted = readPersisted();
const servers: StreamingServerConfig[] = persisted.servers.map((s) => ({
id: s.id,
name: s.name,
type: s.type,
url: s.url,
username: s.username,
password: decryptPassword(s.encryptedPassword),
lastConnected: s.lastConnected,
}));
return { servers, activeServerId: persisted.activeServerId };
});

ipcMain.handle(
"streaming:saveServers",
(_e, payload: { servers: StreamingServerConfig[]; activeServerId: string | null }): void => {
const servers: PersistedServer[] = (payload?.servers ?? []).map((s) => ({
id: s.id,
name: s.name,
type: s.type,
url: s.url,
username: s.username,
encryptedPassword: encryptPassword(s.password),
lastConnected: s.lastConnected,
}));
writePersisted({ servers, activeServerId: payload?.activeServerId ?? null });
},
);
};
13 changes: 12 additions & 1 deletion electron/main/ipc/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { systemLog } from "@main/utils/logger";
import { refreshTray } from "@main/services/tray";
import { getThumbar } from "@main/services/thumbar";
import { getMainWindow, focusMainWindow } from "@main/window";
import { fetchBytes } from "@main/utils/fetchBytes";

/**
* 注册系统相关的 IPC 事件
Expand All @@ -27,7 +28,7 @@ export const registerSystemIpc = (): void => {
shell.showItemInFolder(filePath);
});

// 切换主进程语言(托盘菜单、缩略图工具栏等)
// 切换主进程语言
ipcMain.on("system:setLocale", (_event, locale: LocaleCode) => {
if (setLocale(locale)) {
refreshTray();
Expand Down Expand Up @@ -56,4 +57,14 @@ export const registerSystemIpc = (): void => {
}
return fontsCache;
});

// 把任意 http(s) URL 拉成字节回渲染层
// 用于 canvas 取色等需要绕过跨域 tainted 的场景;不限流媒体
ipcMain.handle("system:fetchRemoteBytes", async (_event, url: string) => {
if (typeof url !== "string" || !/^https?:\/\//i.test(url)) {
return { success: false, error: "无效的 URL" };
}
const buf = await fetchBytes(url);
return { success: true, data: buf };
});
Comment on lines +61 to +69
};
Loading
Loading