Skip to content

feat: 流媒体播放#16

Merged
imsyy merged 14 commits into
devfrom
feat/streaming
May 11, 2026
Merged

feat: 流媒体播放#16
imsyy merged 14 commits into
devfrom
feat/streaming

Conversation

@imsyy
Copy link
Copy Markdown
Member

@imsyy imsyy commented May 9, 2026

No description provided.

imsyy and others added 6 commits May 8, 2026 18:48
- 类型层: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>
Copilot AI review requested due to automatic review settings May 9, 2026 11:11
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread src/stores/streaming.ts Outdated
Comment thread src/core/player.ts Outdated
Comment thread src/stores/streaming.ts
Comment thread src/pages/Artist.vue Outdated
Comment thread src/pages/Streaming/Songs.vue
Comment thread src/pages/Streaming/Albums.vue
Comment thread src/pages/Streaming/Artists.vue
Comment thread src/pages/Streaming/Playlists.vue
Comment thread electron/main/ipc/streaming.ts
Copilot AI review requested due to automatic review settings May 10, 2026 15:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 55 out of 59 changed files in this pull request and generated 3 comments.

Comment thread src/services/lyricLoader.ts Outdated
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 thread electron/preload/index.ts Outdated
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),
Copilot AI review requested due to automatic review settings May 10, 2026 16:11
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread src/stores/streaming.ts Outdated
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 thread electron/main/ipc/player.ts Outdated
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({
imsyy and others added 2 commits May 11, 2026 12:10
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 11, 2026 04:12
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 59 out of 63 changed files in this pull request and generated 6 comments.

Comment thread src/components/ui/SVirtualList.vue Outdated
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 thread src/components/ui/SVirtualList.vue Outdated
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>
Comment thread electron/main/window/main.ts Outdated
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 thread electron/preload/index.ts Outdated
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),
imsyy and others added 2 commits May 11, 2026 14:59
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 11, 2026 08:18
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
@imsyy imsyy merged commit 8a4dfdb into dev May 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants