diff --git a/CLAUDE.md b/CLAUDE.md index 007dd2ce..9e6081df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,137 +4,195 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -SPlayer-Next is a desktop music player built with **Electron + Vue 3 + TypeScript**, using Rust native modules for audio decoding and system media integration. It is the successor to SPlayer, redesigned with a cleaner architecture. +SPlayer-Next — desktop music player on **Electron + Vue 3 + TypeScript**, with Rust native modules (NAPI-RS) for audio decoding, system media integration, and Windows taskbar lyric. Successor to SPlayer. ## Commands ```bash -pnpm install # Install dependencies -pnpm dev # Build native modules (debug) + start Electron dev server -pnpm build # Full production build (rimraf dist → native → typecheck → electron-vite) -pnpm build:win # Package for Windows (nsis + portable, x64/arm64) -pnpm build:mac # Package for macOS (dmg + zip, x64/arm64) -pnpm build:linux # Package for Linux (AppImage/deb/rpm/tar.gz, x64/arm64) -pnpm typecheck # TypeScript check (both node + web targets) -pnpm lint # ESLint check -pnpm format # Prettier format (--write) -pnpm build:native # Build Rust native modules only (add `-- --dev` for debug) +pnpm install # Install deps +pnpm dev # Build native (debug) + start Electron dev +pnpm build # Full build (rimraf → native → typecheck → electron-vite) +pnpm build:{win,mac,linux}# Platform packages +pnpm typecheck # tsc + vue-tsc (node + web targets) +pnpm lint / format # ESLint / Prettier +pnpm build:native # Rust only; add `-- --dev` for debug ``` -Set `SKIP_NATIVE_BUILD=true` to skip Rust compilation during dev. +`SKIP_NATIVE_BUILD=true` skips Rust during dev. + +### FFmpeg Setup (first-time native build) + +`audio-engine` static-links FFmpeg. Before first `pnpm dev` / `pnpm build:native`, download FFmpeg static libs (with `include` and `lib`) and set: + +```bash +# macOS / Linux +export FFMPEG_DIR=/path/to/ffmpeg +export PKG_CONFIG_PATH="$FFMPEG_DIR/lib/pkgconfig" +``` + +```powershell +# Windows +$env:FFMPEG_DIR="D:\ffmpeg" +$env:PKG_CONFIG_PATH="$env:FFMPEG_DIR\lib\pkgconfig" +``` ## Architecture ### Process Model -- **Main process** (`electron/main/`): Window management, IPC handlers, native module orchestration -- **Preload** (`electron/preload/`): Context bridge exposing `window.api` (player, config, system, library) to renderer -- **Renderer** (`src/`): Vue 3 SPA +- **Main** (`electron/main/`) — windows, IPC, native modules +- **Preload** (`electron/preload/`) — `contextBridge` exposing `window.api` (player/config/system/library/streaming/lyrics) +- **Renderer** (`src/`) — Vue 3 SPA +- **Lyric windows** (`windows/desktop-lyric`, `dynamic-island`, `taskbar-lyric`) — independent Vue entries sharing `windows/shared/` ### Native Modules (Rust + NAPI-RS) -Two `.node` modules in `native/`, built via `scripts/build-native.ts`: - -- **`audio-engine`** — FFmpeg-based audio decoding + rodio playback + FFT analysis + cover extraction. Pushes events (state changes, position updates, playback ended) to JS via ThreadsafeFunction. -- **`media-ctrl`** — Cross-platform system media controls (Windows SMTC, Linux MPRIS, macOS MPNowPlaying) + Discord RPC. Trait-based platform abstraction in `sys_media/`. +Three `.node` modules in `native/`, built via `scripts/build-native.ts`, lazy-loaded by `electron/main/utils/nativeLoader.ts`. NAPI-RS auto-generates `index.d.ts`, imported via path aliases `@splayer/audio-engine`, `@splayer/media-ctrl`, `@splayer/taskbar-lyric`. -Native modules are loaded lazily via `loadNativeModule()` in `electron/main/utils/nativeLoader.ts`. Types are auto-generated as `index.d.ts` by NAPI-RS and imported via path aliases `@splayer/audio-engine` and `@splayer/media-ctrl`. +- `audio-engine` — FFmpeg decode + rodio playback + FFT + cover extraction. Pushes events (state/position/ended/outputStalled) via ThreadsafeFunction. Has load_token race protection and `AVIOInterruptCB` for instant stop on blocking IO. +- `media-ctrl` — Cross-platform system media controls (Windows SMTC / Linux MPRIS / macOS MPNowPlaying) + Discord RPC. +- `taskbar-lyric` — Windows taskbar lyric text rendering with RegistryWatcher / UiaWatcher / TrayWatcher. -### Data Flow: Playback +### Playback Data Flow ``` User action → status store → IPC (player:load/play/pause/seek) - → main process player.ts → audio-engine native module - → Rust events (stateChanged/position/ended) - → main process broadcasts to renderer + syncs to media-ctrl + → main process player.ts → audio-engine + → Rust events (stateChanged/position/ended/outputStalled) + → main broadcasts to renderer + syncs to media-ctrl → status store updates reactive state → playback.ts updates non-reactive time source ``` ### State Management -Two-tier position tracking to separate high-frequency animation from low-frequency UI: +Two-tier position tracking — high-frequency animation vs. low-frequency UI: + +- `src/stores/status.ts` — Pinia reactive. `position / duration / state / volume`, pushed ~5Hz from main. Drives progress bar, time display, play button. +- `src/services/playback.ts` — Non-reactive plain vars. `getCurrentTime()` interpolates between pushes; `usePlaybackTime()` reads in RAF loop for 60fps lyrics/spectrum without Vue reactivity. +- `src/stores/media.ts` — Pinia + shallowRef. Current `Track` (lightweight) + `TrackDetail` (lyrics, quality). Only `track + activeLyric` persisted to sessionStorage; never persist `TrackDetail` (large lyric strings cause memory issues). + +### Streaming Subsystem + +Server protocol clients live in renderer (`src/services/streaming/`): subsonic / jellyfin / emby clients + unified dispatcher (`index.ts`). Subsonic family (Navidrome / OpenSubsonic / Airsonic / Gonic / LMS) shares `subsonic.ts`; types differ only as UI labels. -- **`src/stores/status.ts`** — Reactive (Pinia). Holds `position`, `duration`, `state`, `volume`. Updated on main process push (~5Hz). Drives progress bar, time display, play button. -- **`src/services/playback.ts`** — Non-reactive (plain variables). `getCurrentTime()` interpolates between pushes. Read by `usePlaybackTime()` composable in RAF loop for lyrics/spectrum at 60fps without triggering Vue reactivity. -- **`src/stores/media.ts`** — Reactive (Pinia, shallowRef). Holds current `Track` (lightweight) + `TrackDetail` (lyrics, quality info). Persisted to sessionStorage via pinia-plugin-persistedstate (only `track` + `activeLyric`, NOT `detail` to avoid memory issues). +- `services/streaming/transform.ts` — Server response → unified `Track / Album / Artist / Playlist`. Trusts server's artist field; no client-side splitting. +- `services/streaming/session.ts` — Jellyfin/Emby `/Sessions/Playing` heartbeat + PlaySessionId state machine; called from `core/player.ts`. +- `stores/streaming.ts` — Server list, active state, connection, browse cache (IndexedDB via localforage `streaming-cache`). `fetchSongs` returns first batch then keeps fetching in background. +- Credentials — main process `electron/main/ipc/streaming.ts` encrypts via Electron `safeStorage` to `{userData}/streaming.json`. `accessToken / userId` not persisted; re-acquired on connect. + +### Lyric Windows + +`windows/desktop-lyric`, `dynamic-island`, `taskbar-lyric` are independent Vue entries. Always use shared composables from `@windows/shared/`: + +- `useNowPlayingSync` — playback sync, lyric index, anchor interpolation +- `getNowPlayingCurrentMs()` — non-reactive current time for RAF char highlight +- Line selection: `pickPrimaryIndex` (desktop, considers overlap) vs. `pickLatestStartedIndex` (dynamic island, immediate switch) + +Don't reimplement these inside individual windows. ### Type System -- **`shared/types/player.ts`** — `Track` (playlist-friendly, lightweight), `TrackDetail` (on-demand), `Artist`, `Album`, `AudioQuality`, `PlayerState`, `PlayerStatus`, `PlayerEvent`, `LoadResult`, `PlayerApi`, `IpcResponse` -- **`shared/types/lyrics.ts`** — `LyricFormat`, `LyricSource`(`"external" | "embedded" | "online"`), `LyricData`(当前激活歌词描述:source + format + 可选 platform), `LyricLine`, `LyricWord`, `LyricSpan` -- **`shared/types/platform.ts`** — `Platform`(`"netease" | "qqmusic" | "kugou"`) +- `shared/types/player.ts` — `Track`, `TrackDetail`, `Artist`, `Album`, `AudioQuality`, `PlayerState`, `PlayerStatus`, `PlayerEvent`, `LoadOptions`, `LoadResult`, `IpcResponse` +- `shared/types/lyrics.ts` — `LyricFormat`, `LyricSource (external | embedded | online)`, `LyricData`, `LyricLine`, `LyricWord`, `LyricSpan` +- `shared/types/platform.ts` — `Platform (netease | qqmusic | kugou)` +- `shared/types/streaming.ts` — `StreamingServerType`, `StreamingServerConfig`, `StreamingPingResult`, `StreamingAuthResult` 等 + +`Track` is for queue storage (no heavy data); `TrackDetail` loads on demand. + +### Settings Schema -`Track` is designed for playlist storage (no lyrics/heavy data). `TrackDetail` is loaded on demand when a track becomes active. +Declarative — defined in `src/settings/schema.ts`, types in `src/types/settings-schema.ts` (`SettingCategory → SettingSection → SettingItem`). Items bind via `{ store: "settings"|"theme", path: "nested.path" }`; `system.*` paths route through IPC to main config. Tag support on section/item via `SettingTag = { text; type? }` for Beta/experimental badges. i18n keys: `settings.section.{id}` / `settings.{itemKey}.{label,description}`. ### Data Storage ``` {userData}/ -├── settings.json — 主进程配置(electron/main/store/) -├── app-cache/covers/ — 封面缩略图缓存(cover:// 协议) -├── Database/library.db — 音乐库(better-sqlite3,WAL 模式) -└── logs/ — 日志 +├── settings.json # Main config (electron/main/store/) +├── streaming.json # Streaming credentials (safeStorage encrypted) +├── app-cache/covers/ # Cover thumbnails (cover:// protocol) +├── Database/library.db # Music library (better-sqlite3, WAL) +└── logs/ ``` -**渲染进程(IndexedDB via localforage)**: +Renderer IndexedDB (localforage): `splayer/library`, `splayer/queue`, `splayer/playlists`, `splayer/streaming-cache`. -- `splayer/library` — 曲目缓存(加速首屏) -- `splayer/queue` — 播放队列持久化 -- `splayer/playlists` — 歌单数据 +### Cover Image -### Cover Image Handling +Rust extracts 300x300 JPEG thumbnail to `{userData}/app-cache/covers/` during decode; renderer reads via `cover://{filename}` protocol. Original via `getCoverRaw()` for SMTC, never cached. Streaming covers use remote URLs directly (browser cache). -Covers are extracted by Rust as 300x300 JPEG thumbnails cached to disk (`{userData}/app-cache/covers/`). Frontend accesses them via `cover://{filename}` custom protocol. Original high-res covers are extracted on-demand via `getCoverRaw()` for SMTC, never cached to disk. +### Config Store (Main) -### Config Store (Main Process) - -Custom implementation in `electron/main/store/` (not electron-store). Reads/writes `{userData}/settings.json`, merges with defaults from `shared/defaults/settings.ts`. Supports dot-path access (`store.get("system.taskbarProgress")`), atomic writes, and schema migrations. - -Settings UI uses a declarative schema (`src/settings/schema.ts`) with binding format `{ store: "settings"|"theme", path: "nested.path" }`. Paths starting with `system.` route through IPC to main process config. +`electron/main/store/` is custom (not electron-store). Reads/writes `{userData}/settings.json`, merges with defaults from `shared/defaults/settings.ts`. Supports dot-path access (`store.get("system.taskbarProgress")`), atomic writes, schema migrations. ### i18n -- **Renderer**: `vue-i18n` with locale files in `src/i18n/locales/{zh-CN,en-US}.json` -- **Main process**: Lightweight translation table in `electron/main/utils/i18n.ts` for tray menu and thumbar tooltips. Language synced from renderer via `system:setLocale` IPC. +Renderer uses `vue-i18n` with `src/i18n/locales/{zh-CN,en-US}.json`. Main process has a lightweight translation table (`electron/main/utils/i18n.ts`) for tray/thumbar; locale synced via `system:setLocale` IPC. -### Main Process Structure +### Path Aliases ``` -electron/main/ -├── core/index.ts — App init, cover:// protocol registration -├── ipc/player.ts — Player IPC handlers, event routing, media-ctrl sync, taskbar progress -├── ipc/config.ts — Config persistence with side effects (media, normalization, taskbar) -├── ipc/system.ts — System IPC (devtools, file explorer, locale sync) -├── services/tray.ts — System tray menu (i18n-aware) -├── services/thumbar.ts — Windows taskbar thumbnail buttons (i18n-aware) -├── services/media.ts — MediaService class wrapping media-ctrl native module -├── store/index.ts — Config store (read/write settings.json) -├── utils/broadcast.ts — Window broadcast helper -├── utils/i18n.ts — Main process i18n (tray/thumbar translations) -├── utils/nativeLoader.ts — Native .node module loader -├── utils/protocol.ts — cover:// URL conversion -└── utils/time.ts — Time unit conversion (seconds → milliseconds) +@/ → src/ (renderer, tsconfig.web.json) +@shared/ → shared/ (both processes) +@main/ → electron/main/ (main, tsconfig.node.json) +@windows/ → windows/ (lyric windows) +@splayer/audio-engine → native/audio-engine (main) +@splayer/media-ctrl → native/media-ctrl (main) +@splayer/taskbar-lyric → native/taskbar-lyric (main) ``` -### Path Aliases +## Conventions +### Comments — Chinese, with JSDoc + +All comments in Chinese. Methods use standard JSDoc with `@param 名 - 说明` and `@returns` when meaningful: + +```ts +/** + * 取或生成 PlaySessionId,trackId 不变则复用 + * @param trackId - Track 全局 id + * @returns PlaySessionId(UUID) + */ ``` -@/ → src/ (renderer, tsconfig.web.json) -@shared/ → shared/ (both renderer and main process) -@splayer/audio-engine → native/audio-engine (main process, tsconfig.node.json) -@splayer/media-ctrl → native/media-ctrl (main process, tsconfig.node.json) -``` -## Key Conventions - -- **Language**: Comments and commit messages in Chinese -- **Units**: All time values in the frontend are **milliseconds**. Rust engine uses seconds internally; conversion happens in `electron/main/ipc/player.ts` via `toMs()`. -- **Auto-imports**: `vue`, `pinia`, `vue-router`, `@vueuse/core` are auto-imported (no explicit imports needed in Vue components) -- **Prettier**: Double quotes, semicolons, 100 char width, trailing commas -- **Native module types**: Never hand-write — import from `@splayer/audio-engine` or `@splayer/media-ctrl` (auto-generated `index.d.ts`) -- **Store persist**: Only persist lightweight data to sessionStorage. Never persist `TrackDetail` (contains large lyric strings, causes memory issues). -- **IPC event listeners**: Always call `ipcRenderer.removeAllListeners()` before adding new listener in preload's `onEvent` to prevent HMR listener accumulation. -- **Auto-generated files**: Do not edit `auto-imports.d.ts`, `components.d.ts`, `native/*/index.d.ts` — they are regenerated by tooling. -- **Shared types**: `LocaleCode`, `SystemConfig`, etc. live in `shared/types/settings.ts` — used by both renderer and main process. -- **Reactivity & IDB**: Use `shallowRef` for Track arrays/collections to avoid deep proxy. Vue proxied objects cannot be cloned by IndexedDB (`DataCloneError`). +Forbidden: `// ───` separator lines (including ones with section titles), prose-style multi-paragraph comments, restating-the-obvious comments, numbered enumerations (`1. 2. 3.`) inside comments. Write comments only when the **why** is non-obvious. + +### Code Organization + +Split logic into files rather than separator comments. Don't extract a helper for one-place callers (3+ uses justify it). No "just in case" defensive code or fallbacks for impossible scenarios. No configurable knobs (timeouts / retries / buffer sizes) unless required — write constants. Don't break errors into per-case enums; `anyhow` or plain `Error` is usually enough. + +### Units + +Frontend time is **milliseconds** everywhere. Rust engine uses seconds internally; `toMs()` in `electron/main/ipc/player.ts` converts. + +### Types & Persistence + +Never hand-write native module types — import from `@splayer/*`. Use `shallowRef` for `Track` arrays/collections (avoid deep proxy). Vue proxied objects can't be cloned by IDB (`DataCloneError`); use `toRaw` before persisting. + +### Auto-generated Files + +Don't edit `auto-imports.d.ts`, `components.d.ts`, `native/*/index.d.ts` — regenerated by tooling. + +### Auto-imports + +In Vue components, `vue / pinia / vue-router / @vueuse/core / vue-i18n` are auto-imported, and UI components in `src/components/` are auto-registered. + +### Logging (Main Process) + +Use scoped loggers from `@main/utils/logger` (`coreLog / playerLog / mediaLog / trayLog / taskbarLog / nativeLog`, etc.). Don't import `electron-log` directly. + +### IPC Listeners + +In preload's `onEvent`, always `ipcRenderer.removeAllListeners()` before adding a new listener (HMR accumulates otherwise). Renderer composables call the returned `unsubscribe` in `onBeforeUnmount`. + +### Prettier + +Double quotes, semicolons, 100-char width, trailing commas. + +### Shared Types + +Put cross-process types (`LocaleCode / SystemConfig / StreamingServerType`, etc.) in `shared/types/`. + +### Commit Messages + +Single-line title in Chinese; no body/bullets unless explicitly requested. diff --git a/Cargo.lock b/Cargo.lock index 4d22d74a..c6dfc2ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,7 @@ dependencies = [ "rustfft", "signalsmith-stretch", "time", + "tokio", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/components.d.ts b/components.d.ts index 0c5cfd76..954a07e2 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] } } diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 7887400f..ea4f49d8 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -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 => { @@ -23,4 +24,5 @@ export const registerIpcHandlers = (): void => { registerLyricsIpc(); registerHotkeyIpc(); registerThemeIpc(); + registerStreamingIpc(); }; diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index fb71a35e..e2fe2348 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { app, ipcMain, powerMonitor } from "electron"; import { sendToMain } from "@main/utils/broadcast"; @@ -6,6 +5,7 @@ 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"; @@ -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"; @@ -109,13 +109,20 @@ const registerNativeEvents = (inst: InstanceType { // 注册实例创建/重建时的回调 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", { @@ -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); @@ -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()), @@ -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; diff --git a/electron/main/ipc/streaming.ts b/electron/main/ipc/streaming.ts new file mode 100644 index 00000000..f024569e --- /dev/null +++ b/electron/main/ipc/streaming.ts @@ -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); + } +}; + +/** + * 加密密码 + * @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"); +}; + +/** + * 解密密码 + * @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 }); + }, + ); +}; diff --git a/electron/main/ipc/system.ts b/electron/main/ipc/system.ts index 486bf4f0..550a7957 100644 --- a/electron/main/ipc/system.ts +++ b/electron/main/ipc/system.ts @@ -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 事件 @@ -27,7 +28,7 @@ export const registerSystemIpc = (): void => { shell.showItemInFolder(filePath); }); - // 切换主进程语言(托盘菜单、缩略图工具栏等) + // 切换主进程语言 ipcMain.on("system:setLocale", (_event, locale: LocaleCode) => { if (setLocale(locale)) { refreshTray(); @@ -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 }; + }); }; diff --git a/electron/main/utils/fetchBytes.ts b/electron/main/utils/fetchBytes.ts new file mode 100644 index 00000000..b0382d2e --- /dev/null +++ b/electron/main/utils/fetchBytes.ts @@ -0,0 +1,57 @@ +/** + * 把任意远端 URL 拉成字节,给 SMTC 高清封面用 + * + * 主进程要拿封面字节是因为系统媒体集成 API 接受 Buffer, + * 而渲染层的 Blob 跨进程传输不方便。其它 streaming 调用都在渲染层完成 + */ + +/** 默认 15s 超时 */ +const DEFAULT_TIMEOUT_MS = 15_000; +/** 最大允许 5MB */ +const MAX_BYTES = 5 * 1024 * 1024; + +/** + * 获取远端 URL 的字节 + * + * 流式累加并按 MAX_BYTES 提前 abort,避免恶意/异常服务器返回 GB 级 body 时 + * 把主进程内存吃光(res.arrayBuffer() 会无视 5MB 上限先全部缓冲) + * + * @param url 目标 URL + * @param timeoutMs 总超时(包含读 body) + */ +export const fetchBytes = async ( + url: string, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok || !res.body) return null; + + // Content-Length 优先:超 MAX_BYTES 直接拒绝,不开始读 + const contentLength = Number(res.headers.get("content-length") ?? ""); + if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) return null; + + // 流式读取,累计超 MAX_BYTES 时 abort 释放底层 socket + const chunks: Uint8Array[] = []; + let total = 0; + const reader = res.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > MAX_BYTES) { + controller.abort(); + return null; + } + chunks.push(value); + } + if (total === 0) return null; + return Buffer.concat(chunks, total); + } catch { + return null; + } finally { + clearTimeout(timer); + } +}; diff --git a/electron/main/utils/logger.ts b/electron/main/utils/logger.ts index b25b3a89..0573e934 100644 --- a/electron/main/utils/logger.ts +++ b/electron/main/utils/logger.ts @@ -83,3 +83,4 @@ export const ipcLog = log.scope("ipc"); export const libraryLog = log.scope("library"); export const taskbarLog = log.scope("taskbar-lyric"); export const nativeLog = log.scope("native"); +export const streamingLog = log.scope("streaming"); diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index bc7d4c8c..3a460c19 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -13,6 +13,8 @@ import { TaskbarLyricApi, } from "@shared/types/window"; import { HotkeyApi } from "@shared/types/hotkey"; +import { StreamingApi } from "@shared/types/streaming"; +import { IpcResponse } from "@shared/types/player"; declare global { interface Window { @@ -30,6 +32,7 @@ declare global { callback: (payload: { category?: string; highlight?: string }) => void, ) => () => void; listFonts: () => Promise; + fetchRemoteBytes: (url: string) => Promise>; }; library: LibraryApi; window: WindowApi; @@ -45,6 +48,7 @@ declare global { clearBackgroundImages: () => Promise; }; hotkey: HotkeyApi; + streaming: StreamingApi; }; } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 47a9dabc..ea42e78e 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -3,6 +3,8 @@ import { electronAPI } from "@electron-toolkit/preload"; import type { TaskbarLyricSettings } from "@shared/types/settings"; import type { PluginInfo, PluginResolveUrlArgs } from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; +import type { LoadOptions } from "@shared/types/player"; +import type { StreamingServerConfig } from "@shared/types/streaming"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -21,7 +23,8 @@ const api = { }, player: { // 加载音频(本地路径或网络地址) - load: (source: string, autoPlay = true) => ipcRenderer.invoke("player:load", source, autoPlay), + load: (source: string, options?: LoadOptions) => + ipcRenderer.invoke("player:load", source, options ?? {}), // 恢复播放 play: () => ipcRenderer.invoke("player:play"), // 暂停播放 @@ -101,6 +104,8 @@ const api = { subscribe<{ category?: string; highlight?: string }>("system:openSettings", callback), // 获取系统已安装字体 listFonts: () => ipcRenderer.invoke("system:listFonts"), + // 拉远端字节回渲染层 + fetchRemoteBytes: (url: string) => ipcRenderer.invoke("system:fetchRemoteBytes", url), }, library: { // 开始扫描(默认增量) @@ -302,6 +307,15 @@ const api = { // 清空已缓存的背景图 clearBackgroundImages: (): Promise => ipcRenderer.invoke("theme:clearBackgroundImages"), }, + streaming: { + // 加载服务器配置(密码已解密) + loadServers: () => ipcRenderer.invoke("streaming:loadServers"), + // 持久化服务器配置(密码经 safeStorage 加密) + saveServers: (payload: { + servers: StreamingServerConfig[]; + activeServerId: string | null; + }): Promise => ipcRenderer.invoke("streaming:saveServers", payload), + }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), set: (id: HotkeyActionId, binding: HotkeyBinding) => diff --git a/index.html b/index.html index 738ae2da..0ab392a1 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + SPlayer Next