Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
40052f6
feat: 顶栏搜索弹窗与网易云搜索结果页
imsyy May 13, 2026
d0b9547
fix: 流媒体散曲缺 albumId/artistId 时不允许跳转并淡显
imsyy May 13, 2026
877c5a3
fix: 修复搜索平台
imsyy May 13, 2026
464cd2d
fix: 修复平台搜索,对齐功能
imsyy May 14, 2026
5e51672
feat: 添加 API 服务
imsyy May 14, 2026
1a139d6
feat: 新增 NCM 基础播放支持
imsyy May 14, 2026
6568ea7
fix: 歌曲加载
imsyy May 14, 2026
878fc3d
fix: 修复插件 URL 解析与跨进程克隆兼容
imsyy May 14, 2026
0c72691
fix: 完善插件兼容、QM 歌词与 SMTC 在线封面
imsyy May 15, 2026
65f67e1
feat: 完善 NCM 平台相关播放
imsyy May 15, 2026
85a4f84
feat: 基础登录实现
imsyy May 16, 2026
0f94b40
feat: 完善平台列表内容
imsyy May 17, 2026
255520b
feat: 完善侧边栏 & 修复文件夹无法播放
imsyy May 17, 2026
a1948b1
feat: 歌曲缓存延后
imsyy May 18, 2026
a5da16b
feat: 新增我喜欢的音乐
imsyy May 18, 2026
ecc291b
feat: 新增播放历史
imsyy May 18, 2026
48bbda4
feat: 完善歌单添加与管理
imsyy May 18, 2026
82bc430
fix: 修复一些歌单请求
imsyy May 18, 2026
1ca89bd
fix: 修复设置变更触发无关 watch 重跑
imsyy May 18, 2026
398c3e5
feat: 优化历史记录
imsyy May 19, 2026
fa9ed16
feat: 添加我的收藏页面
imsyy May 19, 2026
fe11bd5
fix: 路由参数错误
imsyy May 19, 2026
e540062
feat: 侧边栏展示封面
imsyy May 19, 2026
23be55e
fix: 修复侧边栏展开动画异常
imsyy May 19, 2026
724179a
feat: 我的云盘
imsyy May 19, 2026
cf57310
fix: 修复外部歌词时间不同步
imsyy May 20, 2026
5e71a1b
feat: 添加音质切换
imsyy May 20, 2026
6e9a349
fix: 修复音频过期处理
imsyy May 20, 2026
0db1a40
style: 格式化
imsyy May 20, 2026
80e9a3a
feat: 优化登录相关
imsyy May 20, 2026
35f8703
fix: 修复歌曲播放错误处理
imsyy May 20, 2026
1be1e41
feat: 清理提前态
imsyy May 20, 2026
58920e1
fix: 处理lint
imsyy May 20, 2026
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
29 changes: 23 additions & 6 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ declare module 'vue' {
AutoCloseDialog: typeof import('./src/components/modals/AutoCloseDialog.vue')['default']
BackgroundImagePicker: typeof import('./src/components/settings/custom/BackgroundImagePicker.vue')['default']
BottomSpectrum: typeof import('./src/components/player/FullPlayer/BottomSpectrum.vue')['default']
CacheManager: typeof import('./src/components/settings/custom/CacheManager.vue')['default']
ComboboxAnchor: typeof import('reka-ui')['ComboboxAnchor']
ComboboxContent: typeof import('reka-ui')['ComboboxContent']
ComboboxEmpty: typeof import('reka-ui')['ComboboxEmpty']
Expand Down Expand Up @@ -58,10 +57,13 @@ declare module 'vue' {
DropdownMenuTrigger: typeof import('reka-ui')['DropdownMenuTrigger']
EqualizerDialog: typeof import('./src/components/modals/EqualizerDialog.vue')['default']
ExcludeLyricsConfig: typeof import('./src/components/settings/custom/ExcludeLyricsConfig.vue')['default']
ExternalApiPanel: typeof import('./src/components/settings/custom/ExternalApiPanel.vue')['default']
FileCacheManager: typeof import('./src/components/settings/custom/FileCacheManager.vue')['default']
FontConfig: typeof import('./src/components/settings/custom/FontConfig.vue')['default']
FullPlayer: typeof import('./src/components/player/FullPlayer/index.vue')['default']
HotkeyConfig: typeof import('./src/components/settings/custom/HotkeyConfig.vue')['default']
IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default']
IconLucideArrowUp: typeof import('~icons/lucide/arrow-up')['default']
IconLucideArrowUpCircle: typeof import('~icons/lucide/arrow-up-circle')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
Expand All @@ -71,19 +73,24 @@ declare module 'vue' {
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
IconLucideCircleX: typeof import('~icons/lucide/circle-x')['default']
IconLucideDatabase: typeof import('~icons/lucide/database')['default']
IconLucideDisc: typeof import('~icons/lucide/disc')['default']
IconLucideDisc3: typeof import('~icons/lucide/disc3')['default']
IconLucideEllipsis: typeof import('~icons/lucide/ellipsis')['default']
IconLucideExternalLink: typeof import('~icons/lucide/external-link')['default']
IconLucideFlame: typeof import('~icons/lucide/flame')['default']
IconLucideFolder: typeof import('~icons/lucide/folder')['default']
IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default']
IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default']
IconLucideGithub: typeof import('~icons/lucide/github')['default']
IconLucideHardDrive: typeof import('~icons/lucide/hard-drive')['default']
IconLucideHistory: typeof import('~icons/lucide/history')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default']
IconLucideLink: typeof import('~icons/lucide/link')['default']
IconLucideListMusic: typeof import('~icons/lucide/list-music')['default']
IconLucideLocate: typeof import('~icons/lucide/locate')['default']
IconLucideLock: typeof import('~icons/lucide/lock')['default']
IconLucideLogOut: typeof import('~icons/lucide/log-out')['default']
IconLucideMaximize: typeof import('~icons/lucide/maximize')['default']
IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
IconLucideMic: typeof import('~icons/lucide/mic')['default']
Expand All @@ -99,6 +106,7 @@ declare module 'vue' {
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
IconLucideRepeat: typeof import('~icons/lucide/repeat')['default']
IconLucideRepeat1: typeof import('~icons/lucide/repeat1')['default']
IconLucideScanLine: typeof import('~icons/lucide/scan-line')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideSearchX: typeof import('~icons/lucide/search-x')['default']
IconLucideSettings: typeof import('~icons/lucide/settings')['default']
Expand All @@ -115,11 +123,17 @@ declare module 'vue' {
IconLucideVolume2: typeof import('~icons/lucide/volume2')['default']
IconLucideVolumeX: typeof import('~icons/lucide/volume-x')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
IconLucideZap: typeof import('~icons/lucide/zap')['default']
IconMaterialSymbolsFavoriteOutlineRounded: typeof import('~icons/material-symbols/favorite-outline-rounded')['default']
IconSpLossless: typeof import('~icons/sp/lossless')['default']
LoginCookieDialog: typeof import('./src/components/modals/LoginCookieDialog.vue')['default']
LoginDialog: typeof import('./src/components/modals/LoginDialog.vue')['default']
LyricFormatOrderConfig: typeof import('./src/components/settings/custom/LyricFormatOrderConfig.vue')['default']
Lyrics: typeof import('./src/components/player/Lyrics/index.vue')['default']
LyricSourceOrderConfig: typeof import('./src/components/settings/custom/LyricSourceOrderConfig.vue')['default']
NavHeader: typeof import('./src/layouts/components/NavHeader.vue')['default']
NavHeader: typeof import('./src/components/layout/NavHeader.vue')['default']
NavSearch: typeof import('./src/components/layout/NavSearch.vue')['default']
NavUser: typeof import('./src/components/layout/NavUser.vue')['default']
NumberFieldDecrement: typeof import('reka-ui')['NumberFieldDecrement']
NumberFieldIncrement: typeof import('reka-ui')['NumberFieldIncrement']
NumberFieldInput: typeof import('reka-ui')['NumberFieldInput']
Expand All @@ -129,15 +143,19 @@ declare module 'vue' {
PlayerControls: typeof import('./src/components/player/PlayerControls.vue')['default']
PlayerCover: typeof import('./src/components/player/FullPlayer/PlayerCover.vue')['default']
PlayerData: typeof import('./src/components/player/FullPlayer/PlayerData.vue')['default']
PlaylistCreateDialog: typeof import('./src/components/modals/PlaylistCreateDialog.vue')['default']
PlaylistPanel: typeof import('./src/components/list/PlaylistPanel.vue')['default']
PlaylistPickerDialog: typeof import('./src/components/modals/PlaylistPickerDialog.vue')['default']
PluginManager: typeof import('./src/components/settings/custom/PluginManager.vue')['default']
PopoverArrow: typeof import('reka-ui')['PopoverArrow']
PopoverContent: typeof import('reka-ui')['PopoverContent']
PopoverPortal: typeof import('reka-ui')['PopoverPortal']
PopoverRoot: typeof import('reka-ui')['PopoverRoot']
PopoverTrigger: typeof import('reka-ui')['PopoverTrigger']
QualityControl: typeof import('./src/components/player/QualityControl.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SAlert: typeof import('./src/components/ui/SAlert.vue')['default']
SButton: typeof import('./src/components/ui/SButton.vue')['default']
SCard: typeof import('./src/components/ui/SCard.vue')['default']
SCheckbox: typeof import('./src/components/ui/SCheckbox.vue')['default']
Expand Down Expand Up @@ -167,16 +185,15 @@ declare module 'vue' {
SettingsSearch: typeof import('./src/components/settings/SettingsSearch.vue')['default']
SettingsSection: typeof import('./src/components/settings/SettingsSection.vue')['default']
SFormItem: typeof import('./src/components/ui/SFormItem.vue')['default']
SideBar: typeof import('./src/layouts/components/SideBar.vue')['default']
SideBarLogo: typeof import('./src/layouts/components/SideBarLogo.vue')['default']
SideBar: typeof import('./src/components/layout/SideBar.vue')['default']
SideBarLogo: typeof import('./src/components/layout/SideBarLogo.vue')['default']
SImg: typeof import('./src/components/ui/SImg.vue')['default']
SInput: typeof import('./src/components/ui/SInput.vue')['default']
SLoading: typeof import('./src/components/ui/SLoading.vue')['default']
SLogo: typeof import('./src/components/ui/SLogo.vue')['default']
SMarquee: typeof import('./src/components/ui/SMarquee.vue')['default']
SMenu: typeof import('./src/components/ui/SMenu.vue')['default']
SNumberInput: typeof import('./src/components/ui/SNumberInput.vue')['default']
SongCacheSizeLimit: typeof import('./src/components/settings/custom/SongCacheSizeLimit.vue')['default']
SongList: typeof import('./src/components/list/SongList.vue')['default']
SpeedDialog: typeof import('./src/components/modals/SpeedDialog.vue')['default']
SPopover: typeof import('./src/components/ui/SPopover.vue')['default']
Expand All @@ -198,6 +215,6 @@ declare module 'vue' {
SwitchThumb: typeof import('reka-ui')['SwitchThumb']
Toolbar: typeof import('./src/components/player/Toolbar.vue')['default']
TrackInfo: typeof import('./src/components/player/TrackInfo.vue')['default']
WindowControls: typeof import('./src/layouts/components/WindowControls.vue')['default']
WindowControls: typeof import('./src/components/layout/WindowControls.vue')['default']
}
}
118 changes: 62 additions & 56 deletions docs/plugins-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ splayer.on("lyric", async (req) => {

- **没有** Node 内置模块(`fs` / `net` / `child_process` 等),**没有** `require` / `import`
- **没有** DOM / Electron API(`window` 仅在 lx 兼容模式下作为 `{ lx }` 垫片)
- **有** 以下全局:`splayer`、`Buffer`、`URL` / `URLSearchParams`、`TextEncoder` / `TextDecoder`、`Promise`、`setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` / `setImmediate` / `clearImmediate` / `queueMicrotask`、`console`(重定向到 `splayer.log`)
- **有** 以下全局:`splayer`、`Buffer`、`URL` / `URLSearchParams`、`TextEncoder` / `TextDecoder`、`btoa` / `atob`、`Promise`、`setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` / `setImmediate` / `clearImmediate` / `queueMicrotask`、`console`(重定向到 `splayer.log`)

硬性约束:

Expand Down Expand Up @@ -101,7 +101,7 @@ splayer.register({
sources: {
[sourceKey: string]: {
name: string; // 展示名
actions: ("musicUrl" | "lyric" | "pic")[];
actions: "musicUrl"[]; // 当前 level 1 仅 musicUrl;lyric / pic 计划在 level 2 加入
qualities?: ("lq" | "sq" | "hq" | "lossless" | "hi-res")[];
};
};
Expand All @@ -118,29 +118,47 @@ splayer.register({
| `sq` | 有损 ≥ 192kbps |
| `lq` | 有损 < 192kbps |

lx 脚本声明的 `128k/192k/320k/flac/flac24bit/ape/wav` 会被垫片自动映射到上面的等级;handler 收到的 `info.type` 也会反向映射为 lx 原生值,老脚本无需改动。
lx 脚本声明的 `128k/192k/320k/flac/flac24bit/ape/wav` 会被垫片自动映射到上面的等级;**lx 脚本的** handler 经由 `lx.on('request')` 收到的 `info.type` 也会反向映射为 lx 原生值,老脚本无需改动。

**必须在脚本同步部分调用**。注册完后宿主才知道这个插件能做什么、支持哪些源
**建议在脚本同步部分调用**。注册完后 UI 才能显示插件支持的源/动作;handler 真正被分派依赖请求里的 `source` 字段,宿主不会因为没 register 就拒绝调用,但 UI 上会看不到能力声明

### 注册动作处理器

```ts
splayer.on("musicUrl", async (req) => res);
splayer.on("lyric", async (req) => res);
splayer.on("pic", async (req) => res);
```

每个 action 最多一个 handler,重复注册后者覆盖前者。目前仅这三个动作,搜索和元数据由宿主自身负责
每个 action 最多一个 handler,重复注册后者覆盖前者。**当前 level 1 只实现了 `musicUrl`,`lyric` / `pic` 在路线图(计划在 level 2 加入)**,搜索与元数据由宿主自身负责

#### 请求/响应形状

| Action | 请求 | 响应 | 默认超时 |
| ---------- | -------------------------------- | --------------------------------------- | -------- |
| `musicUrl` | `{ source, quality, musicInfo }` | `{ url, quality?, expire? }` | 20 s |
| `lyric` | `{ source, musicInfo }` | `{ lyric, tlyric?, rlyric?, lxlyric? }` | 15 s |
| `pic` | `{ source, musicInfo }` | `{ url }` | 15 s |
下表仅列 level 1 已实现的 `musicUrl`:

`musicInfo`:宿主会传至少 `{ songmid }`,可能还带 `name` / `singer` 等用于辅助识别。
| Action | 请求 | 响应 | 默认超时 |
| ---------- | -------------------------------- | ---------------------------- | -------- |
| `musicUrl` | `{ source, quality, musicInfo }` | `{ url, quality?, expire? }` | 20 s |

`musicInfo` 当前由宿主实际下发的字段集(splayer-native 与 lx 兼容路径共用同一份):

```ts
{
id: string, // Track 主键
songmid: string, // 与 id 同值,兼容旧脚本字段名
songId: string, // 与 id 同值,兼容更旧的字段名
name: string, // 歌曲名
singer: string, // "/" 拼接的艺术家串
source: string, // "wy" | "tx" | "kg" | ...,与请求外层 source 一致
interval: string|null,// "mm:ss" 格式时长
meta: {
songId: string,
albumName: string,
albumId?: string,
picUrl?: string,
},
}
```

如果脚本声明 `@platform lx`(或以 `gz_` 压缩分发),lx 垫片会原样把上述对象交给 `lx.on('request')` 的 handler —— 字段与 `LX.Music.MusicInfo` 形状基本对齐。

handler 抛出的异常会被宿主捕获,错误码透传到上层。超时未返回 → 被主进程 cancel。

Expand Down Expand Up @@ -194,7 +212,7 @@ splayer.storage.keys(): Promise<string[]>;
splayer.getSetting<T>(key: string): T | undefined;
```

同步读取用户在设置界面给此插件配置的值。设置 schema 通过 `register()` 扩展的能力将在后续 API level 加入;当前 level 1 下返回 `undefined`
同步读取用户给此插件配置的值(来源是 `settings.json` 里 `plugins.perPlugin.{id}.{key}`)。未配置时返回 `undefined`。设置 schema 通过 `register()` 声明的能力将在后续 API level 加入;当前只能从外部写入配置,UI 编辑入口尚未开放

### 工具(`splayer.utils`)

Expand Down Expand Up @@ -225,22 +243,23 @@ splayer.utils.zlib.gunzip(data) / gzip(data)

handler 抛异常时可通过 `err.code` 带上错误码;不带的话宿主默认 `PLUGIN_HANDLER_ERROR`。

| Code | 含义 |
| --------------------------- | ----------------------- |
| `PLUGIN_NOT_FOUND` | 找不到指定插件 |
| `PLUGIN_DISABLED` | 插件已禁用 |
| `PLUGIN_NOT_READY` | 插件未就绪 / 沙箱未启动 |
| `PLUGIN_ACTION_UNSUPPORTED` | 插件没注册该动作 |
| `PLUGIN_LOAD_TIMEOUT` | 加载超 10 秒 |
| `PLUGIN_SCRIPT_ERROR` | 脚本语法或运行错误 |
| `PLUGIN_INVALID_MANIFEST` | 头部字段缺失或不合法 |
| `PLUGIN_API_LEVEL_MISMATCH` | 声明 apiLevel 高于宿主 |
| `PLUGIN_REQUEST_TIMEOUT` | 动作或 request 超时 |
| `PLUGIN_CANCELLED` | 被上层取消 |
| `PLUGIN_NETWORK_ERROR` | 网络错误 |
| `PLUGIN_URL_NOT_ALLOWED` | URL 协议不在白名单 |
| `PLUGIN_HANDLER_ERROR` | handler 默认错误码 |
| `PLUGIN_WORKER_CRASHED` | 子进程崩溃 |
| Code | 含义 |
| --------------------------- | ------------------------------------------------------------------ |
| `PLUGIN_NOT_FOUND` | 找不到指定插件 |
| `PLUGIN_DISABLED` | 插件已禁用 |
| `PLUGIN_NOT_READY` | 插件未就绪 / 沙箱未启动 |
| `PLUGIN_ACTION_UNSUPPORTED` | 插件没注册该动作 |
| `PLUGIN_LOAD_TIMEOUT` | 加载超 10 秒 |
| `PLUGIN_SCRIPT_ERROR` | 脚本语法或运行错误 |
| `PLUGIN_INVALID_MANIFEST` | 头部字段缺失或不合法 |
| `PLUGIN_API_LEVEL_MISMATCH` | 声明 apiLevel 高于宿主 |
| `PLUGIN_REQUEST_TIMEOUT` | 动作或 request 超时 |
| `PLUGIN_CANCELLED` | 被上层取消 |
| `PLUGIN_NETWORK_ERROR` | 网络错误 |
| `PLUGIN_URL_NOT_ALLOWED` | URL 协议不在白名单 |
| `PLUGIN_HANDLER_ERROR` | handler 默认错误码 |
| `PLUGIN_WORKER_CRASHED` | 子进程崩溃 |
| `PLUGIN_INVALID_RESULT` | 插件返回的结果形状不合法(musicUrl 必须是字符串 URL 或 `{ url }`) |

## 完整示例结构

Expand All @@ -253,44 +272,31 @@ handler 抛异常时可通过 `err.code` 带上错误码;不带的话宿主默
* @apiLevel 1
*/

const SOURCES = ["sa", "sb"]; // 你内部的源标识

splayer.register({
sources: {
sa: { name: "SA 音源", actions: ["musicUrl", "lyric"], qualities: ["lq", "hq"] },
sb: { name: "SB 音源", actions: ["musicUrl", "pic"], qualities: ["lq", "hq", "lossless"] },
sa: { name: "SA 音源", actions: ["musicUrl"], qualities: ["lq", "hq"] },
sb: { name: "SB 音源", actions: ["musicUrl"], qualities: ["lq", "hq", "lossless"] },
},
});

const apis = {
sa: {
musicUrl: async ({ musicInfo, quality }) => {
/* ... */
},
lyric: async ({ musicInfo }) => {
/* ... */
},
sa: async ({ musicInfo, quality }) => {
/* 调用 SA 接口拿到 url */
return { url: "https://...", quality };
},
sb: {
musicUrl: async ({ musicInfo, quality }) => {
/* ... */
},
pic: async ({ musicInfo }) => {
/* ... */
},
sb: async ({ musicInfo, quality }) => {
/* 调用 SB 接口拿到 url */
return { url: "https://...", quality };
},
};

const dispatch = (action) => async (req) => {
const fn = apis[req.source]?.[action];
if (!fn)
splayer.on("musicUrl", async (req) => {
const fn = apis[req.source];
if (!fn) {
throw Object.assign(new Error("source not supported"), { code: "PLUGIN_ACTION_UNSUPPORTED" });
}
return fn(req);
};

splayer.on("musicUrl", dispatch("musicUrl"));
splayer.on("lyric", dispatch("lyric"));
splayer.on("pic", dispatch("pic"));
});
```

## 调试
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ SPlayer-Next 支持通过插件扩展音乐能力,包括 URL 解析、歌词
我们兼容 lx-music-desktop 的 user_api 脚本。绝大多数 lx 公开脚本可以直接拖入使用,SPlayer 会自动注入 `window.lx` 垫片。两点注意:

- lx 脚本默认只一个能启用(跟 lx 本身一致)
- 垫片覆盖了 `lx.request` / `lx.on('request')` / `lx.send('inited')` / `lx.utils`(crypto / buffer / zlib / base64)这些最常用的 API,如果脚本用了非主流或 lx 新版 API,可能出现"插件加载错误"
- 垫片覆盖了 `lx.request` / `lx.on('request')` / `lx.send('inited')` / `lx.utils`(crypto / buffer / zlib)这些最常用的 API,如果脚本用了非主流或 lx 新版 API,可能出现"插件加载错误"

## 常见问题

Expand Down
Loading
Loading