feat: 流媒体播放#16
Merged
Merged
Conversation
- 类型层:TrackSource 增加 streaming;Track 增加 serverId/originalId; LoadResult 重构为 detail+mediaInfo,PlayerApi 新增 setNowPlayingMeta - 主进程 services/streaming:subsonic(覆盖 navidrome/opensubsonic)、 jellyfin、emby 三套客户端 + safeStorage 加密 - 主进程 store/ipc:服务器配置存 settings.json 的 streaming 节, IPC 暴露 listServers/addServer/testConnection/resolveUrl/getLyrics 等 - player IPC 契约重构:load 不再合成 Track,渲染层通过 setNowPlayingMeta 下发权威元数据用于 SMTC/托盘 - 渲染层接线:loadTrack 按 source 分发解析,载入后 enrichTrack 合并 引擎元数据;lyricLoader 接 streaming 分支取 LRC Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
该 PR 为 SPlayer-Next 引入「流媒体播放」能力:在渲染层实现 Subsonic/Jellyfin/Emby 等服务器的连接、浏览与播放 URL 生成,并打通播放器加载流程(含 SMTC/托盘元数据与歌词拉取),同时补齐对应的设置入口与 UI 页面。
Changes:
- 新增流媒体客户端层(Subsonic/Jellyfin/Emby)与统一分发入口,并将响应转换为统一的 Track/Album/Artist/Playlist 数据模型。
- 新增 streaming Pinia store:服务器配置管理、连接/重连、浏览列表缓存(localforage)与播放/歌词能力对接。
- 播放链路改造:player.load IPC 支持下发权威 meta,audio-engine load/seek 改为 async 三段式以减少主线程阻塞;新增 Streaming 页面路由与设置项。
Reviewed changes
Copilot reviewed 46 out of 50 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/navigate.ts | 扩展专辑/歌手跳转参数以支持 streaming 的原生 id 路由。 |
| src/utils/md5.ts | 新增 MD5 实现用于 Subsonic token 鉴权。 |
| src/stores/streaming.ts | 新增流媒体 store:服务器管理、连接状态、列表拉取、缓存水合、播放 URL/歌词获取。 |
| src/stores/media.ts | 增加 enrichTrack:将引擎解析的 MediaInfo 合并回当前 Track。 |
| src/settings/schema.ts | 将流媒体分类挂入设置 schema。 |
| src/settings/categories/streaming.ts | 新增“媒体源配置/流媒体管理”设置分类与自定义组件入口。 |
| src/services/streaming/transform.ts | 流媒体响应转换与 URL 生成(Subsonic cover/stream、Jellyfin image、时间戳格式化等)。 |
| src/services/streaming/subsonic.ts | Subsonic/OpenSubsonic/Navidrome 客户端实现(鉴权、列表、搜索、歌词)。 |
| src/services/streaming/jellyfin.ts | Jellyfin 客户端实现(登录、列表、搜索、歌词、universal 播放 URL)。 |
| src/services/streaming/index.ts | 流媒体客户端统一入口:按 server type 分发。 |
| src/services/streaming/http.ts | 渲染层 fetch 工具:超时与统一错误抛出。 |
| src/services/streaming/errors.ts | 流媒体错误类型与错误归类。 |
| src/services/streaming/emby.ts | Emby 复用 jellyfin API,并覆写 getStreamUrl 行为。 |
| src/services/lyricLoader.ts | 为 streaming 曲目新增歌词获取路径,并避开本地偏好刷新逻辑。 |
| src/services/audioLoader.ts | 调整 audioLoader 以适配新的 player.load options 与返回结构(detail+mediaInfo)。 |
| src/router/index.ts | 新增 /streaming 路由及子页面(songs/albums/artists/playlists)。 |
| src/pages/Streaming/Songs.vue | 新增流媒体歌曲页(分页触底、播放全部、搜索框)。 |
| src/pages/Streaming/Playlists.vue | 新增流媒体歌单页(CoverList 展示与跳转)。 |
| src/pages/Streaming/Index.vue | 新增流媒体入口页(服务器选择、连接状态、tabs、刷新/设置入口)。 |
| src/pages/Streaming/Artists.vue | 新增流媒体歌手页(CoverList 展示与跳转)。 |
| src/pages/Streaming/Albums.vue | 新增流媒体专辑页(CoverList 展示与跳转)。 |
| src/pages/Collection.vue | 支持 streaming 的专辑/歌单集合页数据加载与展示。 |
| src/pages/Artist.vue | 支持 streaming 歌手详情页(专辑+歌曲聚合、跳转专辑)。 |
| src/layouts/MainLayout.vue | 调整顶级 RouterView key 策略,避免静态嵌套路由切换导致外层重建。 |
| src/layouts/components/SideBar.vue | 侧边栏新增流媒体入口,并受 system.streaming.enabled 总开关控制。 |
| src/i18n/locales/zh-CN.json | 增加流媒体相关文案与统计文案。 |
| src/i18n/locales/en-US.json | 增加流媒体相关文案与统计文案(英文)。 |
| src/core/player.ts | 播放链路支持 streaming source 解析与 meta 下发;load 改为 enrichTrack。 |
| src/components/ui/SVirtualList.vue | 支持 footer slot(用于“没有更多/加载中”等尾部状态)。 |
| src/components/ui/SSelect.vue | 新增 round 形态与展开态箭头旋转效果。 |
| src/components/settings/custom/StreamingServerList.vue | 新增流媒体服务器管理 UI(增删改测连/连接/断开)。 |
| src/components/player/FullPlayer/PlayerData.vue | 专辑跳转支持 streaming albumId;来源标签新增 STREAMING。 |
| src/components/list/SongList.vue | 透出 reachBottom 事件与 footer slot;artist/album 跳转支持 streaming id。 |
| shared/types/streaming.ts | 新增跨进程 streaming 类型与 StreamingApi 定义。 |
| shared/types/settings.ts | 新增 StreamingSettings 并挂入 SystemConfig。 |
| shared/types/player.ts | 扩展 TrackSource=streaming;补充 Album/Artist/Playlist 字段;新增 MediaInfo/LoadOptions 并调整 PlayerApi.load。 |
| shared/defaults/settings.ts | 增加 system.streaming.enabled 默认值。 |
| native/audio-engine/src/player.rs | 为 async load/seek 提供 take/commit 分段 API 与线程 join 优化结构。 |
| native/audio-engine/src/lib.rs | audio-engine 的 load/seek 改为 async 三段式,避免主线程持锁被 IO 阻塞。 |
| native/audio-engine/index.d.ts | 更新 NAPI 导出的 TS 声明(load/seek 返回 Promise)。 |
| native/audio-engine/Cargo.toml | 启用 napi tokio_rt 特性并引入 tokio runtime。 |
| electron/preload/index.ts | player.load 改为 options 形式;新增 window.api.streaming IPC 暴露。 |
| electron/preload/index.d.ts | 增加 streaming API 类型声明。 |
| electron/main/utils/logger.ts | 新增 streamingLog scope。 |
| electron/main/utils/fetchBytes.ts | 新增主进程拉取远端字节工具(用于 SMTC 高清封面)。 |
| electron/main/ipc/streaming.ts | 新增 streaming IPC:封面字节获取、服务器配置加密持久化。 |
| electron/main/ipc/player.ts | player:load 支持 options/meta;流媒体封面字节走主进程拉取;seek 适配 async。 |
| electron/main/ipc/index.ts | 注册 streaming IPC handlers。 |
| components.d.ts | 更新自动组件声明(新增 StreamingServerList 等)。 |
| Cargo.lock | 锁文件更新(tokio 等依赖变化)。 |
Comment on lines
+264
to
+269
| if (track.source === "streaming") { | ||
| const text = await useStreamingStore().getLyrics(track); | ||
| if (token !== currentToken) return; | ||
| if (text && text.trim()) { | ||
| commit(token, { source: "external", format: "lrc" }, { content: text }); | ||
| return; |
Comment on lines
+159
to
+171
| let activeSession: { | ||
| cfg: StreamingServerConfig; | ||
| trackId: string; | ||
| originalId: string; | ||
| sessionId: string; | ||
| } | null = null; | ||
| let lastProgressAt = 0; | ||
| const PROGRESS_INTERVAL_MS = 10_000; | ||
|
|
||
| const findCfgForTrack = (track: Track): StreamingServerConfig | null => { | ||
| if (track.source !== "streaming" || !track.serverId) return null; | ||
| return useStreamingStore().servers.find((s) => s.id === track.serverId) ?? null; | ||
| }; |
Comment on lines
+307
to
+314
| streaming: { | ||
| // 把远端封面 URL 拉成字节,给 SMTC 高清封面用 | ||
| fetchCoverBytes: (url: string) => ipcRenderer.invoke("streaming:fetchCoverBytes", url), | ||
| // 加载服务器配置(密码已解密) | ||
| loadServers: () => ipcRenderer.invoke("streaming:loadServers"), | ||
| // 持久化服务器配置(密码经 safeStorage 加密) | ||
| saveServers: (payload: { servers: unknown[]; activeServerId: string | null }): Promise<void> => | ||
| ipcRenderer.invoke("streaming:saveServers", payload), |
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 59 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (1)
src/components/player/FullPlayer/PlayerData.vue:121
qualityLabel现在对media.detail?.quality为 undefined 的情况会回落到 "LQ",这里又移除了 v-if,因此在尚未拿到音质信息/不支持时也会显示可点击的音质标签,但弹层内容为空(v-if="media.detail?.quality")。建议:要么让getQualityLabel在缺少 quality 时返回 null 并在这里恢复条件渲染,要么把 trigger 的交互/显示与media.detail?.quality绑定,避免误导。
<SPopover side="top" :side-offset="8" cover trigger="hover">
<template #trigger>
<span
class="inline-flex items-center gap-1 leading-none px-1.5 py-1.2 rounded-md border border-solid border-cover/30 cursor-pointer transition-colors hover:border-cover/60"
>
<IconSpLossless v-if="showLosslessIcon" class="text-[1.4em] -my-[0.4em]" />
{{ qualityLabel }}
Comment on lines
+12
to
+22
| const refreshKey = inject<{ value: number }>("streamingRefreshKey", { value: 0 }); | ||
| const searchQuery = ref(""); | ||
|
|
||
| const refresh = (): void => { | ||
| if (!isConnected.value) return; | ||
| streaming.fetchSongs(); | ||
| }; | ||
|
|
||
| // 父组件按刷新按钮时触发 | ||
| watch(refreshKey, refresh); | ||
| // 连接成功时自动拉 |
Comment on lines
+12
to
+20
| const refreshKey = inject<{ value: number }>("streamingRefreshKey", { value: 0 }); | ||
|
|
||
| const refresh = (): void => { | ||
| if (!isConnected.value) return; | ||
| streaming.fetchAlbums(); | ||
| }; | ||
|
|
||
| watch(refreshKey, refresh); | ||
| watch(isConnected, (v) => v && refresh()); |
Comment on lines
+12
to
+20
| const refreshKey = inject<{ value: number }>("streamingRefreshKey", { value: 0 }); | ||
|
|
||
| const refresh = (): void => { | ||
| if (!isConnected.value) return; | ||
| streaming.fetchArtists(); | ||
| }; | ||
|
|
||
| watch(refreshKey, refresh); | ||
| watch(isConnected, (v) => v && refresh()); |
Comment on lines
+12
to
+20
| const refreshKey = inject<{ value: number }>("streamingRefreshKey", { value: 0 }); | ||
|
|
||
| const refresh = (): void => { | ||
| if (!isConnected.value) return; | ||
| streaming.fetchPlaylists(); | ||
| }; | ||
|
|
||
| watch(refreshKey, refresh); | ||
| watch(isConnected, (v) => v && refresh()); |
Comment on lines
+532
to
+536
| const needsConnect = isActive | ||
| ? !connectionStatus.value.connected | ||
| : needsAccessToken(cfg.type) && !cfg.accessToken; | ||
| if (needsConnect) { | ||
| const ok = await connectToServer(cfg.id); |
Comment on lines
+162
to
+168
| // 流媒体高清封面:fire-and-forget 异步抓取,不阻塞 load IPC 返回 | ||
| // 抓到后再补刷 SMTC metadata;切歌前完成不了不影响下首加载 | ||
| if (isStreaming && authoritative?.cover && /^https?:\/\//i.test(authoritative.cover)) { | ||
| const coverUrl = authoritative.cover; | ||
| void fetchBytes(coverUrl).then((buf) => { | ||
| if (!buf) return; | ||
| mediaService.setMetadata({ |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines
+95
to
+99
| const totalHeight = computed(() => { | ||
| if (props.itemFixed) { | ||
| return props.items.length * props.itemHeight + props.paddingTop + props.paddingBottom; | ||
| return props.items.length * props.itemHeight + props.paddingTop; | ||
| } | ||
| if (itemTops.value.length === 0) return props.paddingTop + props.paddingBottom; | ||
| if (itemTops.value.length === 0) return props.paddingTop; |
Comment on lines
+393
to
+399
| <div | ||
| v-if="$slots.footer && items.length > 0" | ||
| class="shrink-0" | ||
| :style="{ paddingBottom: `${paddingBottom}px` }" | ||
| > | ||
| <slot name="footer" /> | ||
| </div> |
| frame: false, | ||
| webPreferences: { | ||
| partition: MAIN_PARTITION, | ||
| webSecurity: false, |
Comment on lines
+157
to
+166
| export const getStreamUrl = async ( | ||
| cfg: StreamingServerConfig, | ||
| originalId: string, | ||
| playSessionId?: string, | ||
| ): Promise<string> => { | ||
| if (!cfg.accessToken) throw new StreamingAuthError("缺少 accessToken"); | ||
| const params = new URLSearchParams({ | ||
| UserId: cfg.userId ?? "", | ||
| DeviceId: deviceId(cfg), | ||
| Container: "mp3,m4a|aac,m4a|alac,m4b|aac,flac,webma|opus,webm|opus,ogg|opus,ogg|vorbis,wav,oga", |
Comment on lines
+32
to
+41
| export const getStreamUrl = async ( | ||
| cfg: StreamingServerConfig, | ||
| originalId: string, | ||
| playSessionId?: string, | ||
| ): Promise<string> => { | ||
| if (!cfg.accessToken) throw new StreamingAuthError("缺少 accessToken"); | ||
| const params = new URLSearchParams({ | ||
| UserId: cfg.userId ?? "", | ||
| DeviceId: deviceId(cfg), | ||
| PlaySessionId: playSessionId ?? crypto.randomUUID(), |
Comment on lines
+307
to
+312
| streaming: { | ||
| // 加载服务器配置(密码已解密) | ||
| loadServers: () => ipcRenderer.invoke("streaming:loadServers"), | ||
| // 持久化服务器配置(密码经 safeStorage 加密) | ||
| saveServers: (payload: { servers: unknown[]; activeServerId: string | null }): Promise<void> => | ||
| ipcRenderer.invoke("streaming:saveServers", payload), |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 61 out of 65 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/core/player/index.ts:87
- 流媒体 Track 在
loadTrack()里已经调用了session.notifyTrackChanged(track, ...)上报 Playing,但当window.api.player.load(...)返回失败进入此分支时没有对应的notifyTrackChanged(null, ...)清理,会导致 Jellyfin/Emby 的 Now Playing/会话可能一直停留在上一首(或显示在播但实际加载失败)。建议在确定本次 load 失败且仍是当前 token 时,补发一次 Stopped(并重置 session 状态),避免服务器侧状态卡死。
Comment on lines
+160
to
+165
| // SAFETY: InnerPlayer 持有 cpal::Stream(!Send),但 NAPI async fn 要求 | ||
| // `&AudioPlayer: Send` → `Arc<Mutex<InnerPlayer>>: Sync` → `InnerPlayer: Send`。 | ||
| // 这里仅为类型系统占位。**运行时严格保证 InnerPlayer 不跨线程访问**: | ||
| // - lib.rs async load/seek 中 spawn_blocking 闭包不持有 inner 引用,仅持有纯数据 | ||
| // - 所有 inner.lock() 都在主线程上执行,cpal Stream 不会跨线程被 mutate | ||
| unsafe impl Send for InnerPlayer {} |
Comment on lines
+61
to
+69
| // 把任意 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
+52
to
+57
| 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"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.