@roitium/expo-orpheus 0.9.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.commitlintrc.js +3 -0
- package/.env +1 -0
- package/.release-it.json +19 -0
- package/CHANGELOG.md +10 -0
- package/README.md +10 -30
- package/android/build.gradle +7 -2
- package/android/src/main/java/expo/modules/orpheus/OrpheusMusicService.kt +1 -0
- package/docs/API-Events.md +35 -0
- package/docs/API-Methods.md +107 -0
- package/docs/API-Types.md +89 -0
- package/docs/Home.md +22 -0
- package/docs/RELEASING.md +34 -0
- package/expo-module.config.json +4 -1
- package/ios/BilibiliApi.swift +246 -0
- package/ios/ExpoOrpheus.podspec +29 -0
- package/ios/ExpoOrpheusModule.swift +228 -0
- package/ios/GeneralStorage.swift +98 -0
- package/ios/OrpheusDownloadManager.swift +360 -0
- package/ios/OrpheusModels.swift +122 -0
- package/ios/OrpheusPlayerManager.swift +797 -0
- package/ios/WbiUtil.swift +105 -0
- package/lefthook.yml +4 -0
- package/mise.toml +2 -0
- package/package.json +11 -5
package/.commitlintrc.js
ADDED
package/.env
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
GITHUB_TOKEN=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
|
package/.release-it.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"git": {
|
|
3
|
+
"commitMessage": "chore: release v${version}",
|
|
4
|
+
"tagName": "v${version}",
|
|
5
|
+
"requireCleanWorkingDir": true
|
|
6
|
+
},
|
|
7
|
+
"npm": {
|
|
8
|
+
"publish": true
|
|
9
|
+
},
|
|
10
|
+
"github": {
|
|
11
|
+
"release": true
|
|
12
|
+
},
|
|
13
|
+
"plugins": {
|
|
14
|
+
"@release-it/conventional-changelog": {
|
|
15
|
+
"preset": "angular",
|
|
16
|
+
"infile": "CHANGELOG.md"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# [0.10.0](https://github.com/bbplayer-app/orpheus/compare/v0.9.4...v0.10.0) (2026-01-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* 1 ([ef1f7ba](https://github.com/bbplayer-app/orpheus/commit/ef1f7ba5ad05d23f62b5c0d19b84639355b8280b))
|
|
9
|
+
* 1 ([f386be4](https://github.com/bbplayer-app/orpheus/commit/f386be4fd7a0ac05bff6d31e31ec87094c7bdccf))
|
|
10
|
+
* Add commitlint, husky hooks, and release-it, enhance the example application with new UI components and test data, and provide comprehensive API documentation. ([7b8aefd](https://github.com/bbplayer-app/orpheus/commit/7b8aefd94f0789a3c0686074e1c9f68bcae0b901))
|
package/README.md
CHANGED
|
@@ -4,38 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
这是一个为 BBPlayer 项目构建的高性能定制音频播放库。旨在替代 `react-native-track-player`,以提供与 Android Media3 (ExoPlayer) 更紧密的集成,并针对 Bilibili 音频流逻辑提供了原生层支持。
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 功能特性
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- **Bilibili 集成**: 自动处理 Bilibili 音频流,支持高码率(需 Cookie)。
|
|
10
|
+
- **双层缓存**: 包含独立的下载缓存和边下边播 LRU 缓存。
|
|
11
|
+
- **Android Media3**: 基于最新的 Media3 和 ExoPlayer 架构。
|
|
12
|
+
- **桌面歌词**: 支持系统级桌面歌词悬浮窗。
|
|
13
|
+
- **ExoPlayer 扩展**: 支持 `ffmpeg` 扩展(如需要)。
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
## 文档
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
详细的 API 文档和使用说明请参阅 [Wiki](docs/Home.md) 或直接查看 `docs/` 目录。
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
## 声明
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
2. 边下边播:LRU 缓存,256mb
|
|
19
|
-
|
|
20
|
-
## 下载系统
|
|
21
|
-
|
|
22
|
-
Orpheus 集成了 Media3 的 DownloadManager,抛弃了原先 BBPlayer 中繁琐的下载实现。
|
|
23
|
-
|
|
24
|
-
## 响度均衡
|
|
25
|
-
|
|
26
|
-
默认启用,只对未缓存的 b 站音频生效
|
|
27
|
-
|
|
28
|
-
## 桌面歌词
|
|
29
|
-
|
|
30
|
-
相信聪明的你去看一下公开方法名就知道怎么使用了!
|
|
31
|
-
(需要注意的是,在切歌时,会自动清空当前的歌词)
|
|
32
|
-
|
|
33
|
-
## 注意事项
|
|
34
|
-
|
|
35
|
-
该库一些修改比较随意,我怕后续我自己都忘了,所以在这里进行一下记录。
|
|
36
|
-
|
|
37
|
-
1. `onTrackStarted` 事件在 v0.9.0 版本后不再存在。需要使用 `registerOrpheusHeadlessTask` 注册事件并自行判断事件是否为 `onTrackStarted`
|
|
38
|
-
|
|
39
|
-
## 使用
|
|
40
|
-
|
|
41
|
-
虽然该包是公开的,但仍然主要供 BBPlayer 内部使用。可能不会有完整的文档覆盖。我们欢迎你 fork 后自行修改使用。
|
|
21
|
+
该库主要供 BBPlayer 内部使用,虽然开源,但可能不会处理外部的 Feature Request。
|
package/android/build.gradle
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
apply plugin: 'com.android.library'
|
|
2
2
|
|
|
3
|
+
import groovy.json.JsonSlurper
|
|
4
|
+
|
|
5
|
+
def packageJsonFile = new File(rootProject.projectDir, '../package.json')
|
|
6
|
+
def packageJson = new JsonSlurper().parseText(packageJsonFile.text)
|
|
7
|
+
|
|
3
8
|
group = 'expo.modules.orpheus'
|
|
4
|
-
version =
|
|
9
|
+
version = packageJson.version
|
|
5
10
|
|
|
6
11
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
12
|
apply from: expoModulesCorePlugin
|
|
@@ -35,7 +40,7 @@ android {
|
|
|
35
40
|
namespace "expo.modules.orpheus"
|
|
36
41
|
defaultConfig {
|
|
37
42
|
versionCode 1
|
|
38
|
-
versionName
|
|
43
|
+
versionName packageJson.version
|
|
39
44
|
}
|
|
40
45
|
lintOptions {
|
|
41
46
|
abortOnError false
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 事件与后台任务
|
|
2
|
+
|
|
3
|
+
## 事件监听 (Events)
|
|
4
|
+
|
|
5
|
+
使用 `Orpheus.addListener(eventName, callback)` 进行监听。
|
|
6
|
+
|
|
7
|
+
| 事件名 | 参数 | 描述 |
|
|
8
|
+
| :----------------------- | :------------------------------------- | :------------------------------------------- |
|
|
9
|
+
| `onPlaybackStateChanged` | `{ state: PlaybackState }` | 播放状态改变 (IDLE, BUFFERING, READY, ENDED) |
|
|
10
|
+
| `onIsPlayingChanged` | `{ status: boolean }` | 播放/暂停状态改变 |
|
|
11
|
+
| `onTrackFinished` | `{ trackId, finalPosition, duration }` | 歌曲播放完成 |
|
|
12
|
+
| `onPositionUpdate` | `{ position, duration, buffered }` | 进度更新 (约 500ms 一次) |
|
|
13
|
+
| `onPlayerError` | `{ code, message }` | 播放器报错 |
|
|
14
|
+
| `onDownloadUpdated` | `DownloadTask` | 下载进度更新 |
|
|
15
|
+
| `onPlaybackSpeedChanged` | `{ speed: number }` | 倍速改变 |
|
|
16
|
+
|
|
17
|
+
**注意**: `onTrackStarted` 事件在 v0.9.0+ 已移除,请使用 Headless Task。
|
|
18
|
+
|
|
19
|
+
## 后台任务 (Headless Task)
|
|
20
|
+
|
|
21
|
+
为了在 App 后台或被杀掉进程时仍能处理切歌等逻辑(如更新通知栏或以前的 `onTrackStarted` 逻辑),你需要注册 Headless Task。
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { registerOrpheusHeadlessTask } from "@roitium/expo-orpheus";
|
|
25
|
+
|
|
26
|
+
registerOrpheusHeadlessTask(async (event) => {
|
|
27
|
+
// 目前主要处理 TrackStarted 事件
|
|
28
|
+
if (event.eventName === "onTrackStarted") {
|
|
29
|
+
console.log("开始播放:", event.trackId);
|
|
30
|
+
console.log("原因:", event.reason); // 0: REPEAT, 1: AUTO, 2: SEEK, 3: PLAYLIST_CHANGED
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
必须在 `index.js` 或应用启动的最早时期注册。
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# API 方法
|
|
2
|
+
|
|
3
|
+
## 播放控制 (Playback Control)
|
|
4
|
+
|
|
5
|
+
- **`play(): Promise<void>`**
|
|
6
|
+
恢复播放。
|
|
7
|
+
|
|
8
|
+
- **`pause(): Promise<void>`**
|
|
9
|
+
暂停播放。
|
|
10
|
+
|
|
11
|
+
- **`skipToNext(): Promise<void>`**
|
|
12
|
+
跳至下一首。
|
|
13
|
+
|
|
14
|
+
- **`skipToPrevious(): Promise<void>`**
|
|
15
|
+
跳至上一首。
|
|
16
|
+
|
|
17
|
+
- **`skipTo(index: number): Promise<void>`**
|
|
18
|
+
跳至播放队列中的指定索引。
|
|
19
|
+
|
|
20
|
+
- **`seekTo(seconds: number): Promise<void>`**
|
|
21
|
+
跳转到当前曲目的指定时间(单位:秒)。
|
|
22
|
+
|
|
23
|
+
- **`setPlaybackSpeed(speed: number): Promise<void>`**
|
|
24
|
+
设置播放倍速 (如 1.0, 1.25, 2.0)。
|
|
25
|
+
|
|
26
|
+
- **`getPlaybackSpeed(): Promise<number>`**
|
|
27
|
+
获取当前倍速。
|
|
28
|
+
|
|
29
|
+
## 队列管理 (Queue Management)
|
|
30
|
+
|
|
31
|
+
- **`addToEnd(tracks: Track[], startFromId?: string, clearQueue?: boolean): Promise<void>`**
|
|
32
|
+
将歌曲添加到队列末尾。
|
|
33
|
+
- `tracks`: 歌曲列表。
|
|
34
|
+
- `startFromId` (可选): 添加后由该 ID 开始播放。
|
|
35
|
+
- `clearQueue` (可选): 是否先清空队列。
|
|
36
|
+
|
|
37
|
+
- **`playNext(track: Track): Promise<void>`**
|
|
38
|
+
插队播放(下一首)。
|
|
39
|
+
|
|
40
|
+
- **`clear(): Promise<void>`**
|
|
41
|
+
清空队列。
|
|
42
|
+
|
|
43
|
+
- **`removeTrack(index: number): Promise<void>`**
|
|
44
|
+
移除指定索引的歌曲。
|
|
45
|
+
|
|
46
|
+
- **`getQueue(): Promise<Track[]>`**
|
|
47
|
+
获取完整播放队列。
|
|
48
|
+
|
|
49
|
+
- **`getCurrentIndex(): Promise<number>`**
|
|
50
|
+
获取当前播放索引。
|
|
51
|
+
|
|
52
|
+
- **`getCurrentTrack(): Promise<Track | null>`**
|
|
53
|
+
获取当前播放对象。
|
|
54
|
+
|
|
55
|
+
- **`getIndexTrack(index: number): Promise<Track | null>`**
|
|
56
|
+
获取指定索引的对象。
|
|
57
|
+
|
|
58
|
+
## 下载管理 (Downloads)
|
|
59
|
+
|
|
60
|
+
Orpheus 使用 Media3 DownloadManager。
|
|
61
|
+
|
|
62
|
+
- **`downloadTrack(track: Track): Promise<void>`**
|
|
63
|
+
下载单曲。
|
|
64
|
+
|
|
65
|
+
- **`multiDownload(tracks: Track[]): Promise<void>`**
|
|
66
|
+
批量下载。
|
|
67
|
+
|
|
68
|
+
- **`removeDownload(id: string): Promise<void>`**
|
|
69
|
+
移除下载。
|
|
70
|
+
|
|
71
|
+
- **`removeAllDownloads(): Promise<void>`**
|
|
72
|
+
清空下载缓存。
|
|
73
|
+
|
|
74
|
+
- **`getDownloads(): Promise<DownloadTask[]>`**
|
|
75
|
+
获取所有下载任务。
|
|
76
|
+
|
|
77
|
+
- **`getDownloadStatusByIds(ids: string[]): Promise<Record<string, DownloadState>>`**
|
|
78
|
+
批量查询下载状态。
|
|
79
|
+
|
|
80
|
+
- **`getUncompletedDownloadTasks(): Promise<DownloadTask[]>`**
|
|
81
|
+
获取未完成任务。
|
|
82
|
+
|
|
83
|
+
- **`clearUncompletedDownloadTasks(): Promise<void>`**
|
|
84
|
+
清除未完成(失败/停止)的任务。
|
|
85
|
+
|
|
86
|
+
## 杂项与配置 (Misc)
|
|
87
|
+
|
|
88
|
+
- **`setSleepTimer(durationMs: number): Promise<void>`**
|
|
89
|
+
设置睡眠定时器(毫秒)。
|
|
90
|
+
|
|
91
|
+
- **`getSleepTimerEndTime(): Promise<number | null>`**
|
|
92
|
+
获取定时器结束时间戳。
|
|
93
|
+
|
|
94
|
+
- **`cancelSleepTimer(): Promise<void>`**
|
|
95
|
+
取消定时器。
|
|
96
|
+
|
|
97
|
+
- **`setBilibiliCookie(cookie: string): void`**
|
|
98
|
+
设置 Bilibili Cookie。
|
|
99
|
+
|
|
100
|
+
- **`showDesktopLyrics() / hideDesktopLyrics()`**
|
|
101
|
+
显示/隐藏桌面歌词。
|
|
102
|
+
|
|
103
|
+
- **`checkOverlayPermission() / requestOverlayPermission()`**
|
|
104
|
+
权限检查与请求。
|
|
105
|
+
|
|
106
|
+
- **`setDesktopLyrics(json: string)`**
|
|
107
|
+
更新歌词内容。
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# 数据类型 (Types)
|
|
2
|
+
|
|
3
|
+
## Track
|
|
4
|
+
|
|
5
|
+
核心音频对象结构。
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
export interface Track {
|
|
9
|
+
/** 唯一标识符 */
|
|
10
|
+
id: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 音频流地址。
|
|
14
|
+
* 特殊协议: orpheus://bilibili?bvid=...
|
|
15
|
+
*/
|
|
16
|
+
url: string;
|
|
17
|
+
|
|
18
|
+
/** 标题 */
|
|
19
|
+
title?: string;
|
|
20
|
+
|
|
21
|
+
/** 艺术家 */
|
|
22
|
+
artist?: string;
|
|
23
|
+
|
|
24
|
+
/** 封面图 URL */
|
|
25
|
+
artwork?: string;
|
|
26
|
+
|
|
27
|
+
/** 时长 (秒) */
|
|
28
|
+
duration?: number;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 响度标准化参数
|
|
32
|
+
* 用于 ReplayGain 或类似处理
|
|
33
|
+
*/
|
|
34
|
+
loudness?: {
|
|
35
|
+
measured_i: number; // 测量的各向同性响度 (LUFS)
|
|
36
|
+
target_i: number; // 目标响度
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## PlaybackState
|
|
42
|
+
|
|
43
|
+
播放器状态枚举。
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
export enum PlaybackState {
|
|
47
|
+
IDLE = 1, // 空闲 / 无资源
|
|
48
|
+
BUFFERING = 2, // 缓冲中
|
|
49
|
+
READY = 3, // 准备就绪 / 可播放
|
|
50
|
+
ENDED = 4, // 播放结束
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## RepeatMode
|
|
55
|
+
|
|
56
|
+
重复模式。
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
export enum RepeatMode {
|
|
60
|
+
OFF = 0, // 不重复
|
|
61
|
+
TRACK = 1, // 单曲循环
|
|
62
|
+
QUEUE = 2, // 列表循环
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## DownloadTask & DownloadState
|
|
67
|
+
|
|
68
|
+
下载任务详情。
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
export enum DownloadState {
|
|
72
|
+
QUEUED = 0,
|
|
73
|
+
STOPPED = 1,
|
|
74
|
+
DOWNLOADING = 2,
|
|
75
|
+
COMPLETED = 3,
|
|
76
|
+
FAILED = 4,
|
|
77
|
+
REMOVING = 5,
|
|
78
|
+
RESTARTING = 7,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DownloadTask {
|
|
82
|
+
id: string;
|
|
83
|
+
state: DownloadState;
|
|
84
|
+
percentDownloaded: number; // 0 - 100
|
|
85
|
+
bytesDownloaded: number;
|
|
86
|
+
contentLength: number;
|
|
87
|
+
track?: Track; // 关联的 Track 对象信息
|
|
88
|
+
}
|
|
89
|
+
```
|
package/docs/Home.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# 欢迎使用 Orpheus
|
|
2
|
+
|
|
3
|
+
**BBPlayer 内部音频模块**
|
|
4
|
+
|
|
5
|
+
Orpheus 是一个为 BBPlayer 构建的高性能音频播放库,基于 Android Media3 (ExoPlayer)。
|
|
6
|
+
|
|
7
|
+
## 目录
|
|
8
|
+
|
|
9
|
+
- [API 方法 (Methods)](API-Methods.md)
|
|
10
|
+
- [数据类型 (Types)](API-Types.md)
|
|
11
|
+
- [事件与后台任务 (Events)](API-Events.md)
|
|
12
|
+
- [发版指南 (Releasing)](RELEASING.md)
|
|
13
|
+
|
|
14
|
+
## 快速开始
|
|
15
|
+
|
|
16
|
+
Orpheus 主要用于处理复杂的音频播放需求,特别是 Bilibili 音频流和本地缓存管理。
|
|
17
|
+
|
|
18
|
+
### 核心特性
|
|
19
|
+
|
|
20
|
+
- **Bilibili 支持**: 如果提供了 Bilibili Cookie,Orpheus 可以自动获取高音质流。
|
|
21
|
+
- **缓存系统**: 内置 LRU 缓存(边下边播)和持久化下载管理。
|
|
22
|
+
- **桌面歌词**: Android 系统级悬浮窗歌词支持。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# 发版指南 (Releasing)
|
|
2
|
+
|
|
3
|
+
本项目使用 `release-it` 配合 Conventional Commits 进行自动化发版。
|
|
4
|
+
|
|
5
|
+
## 准备工作
|
|
6
|
+
|
|
7
|
+
1. 确保你的代码已提交并推送到远程分支。
|
|
8
|
+
2. 确保你有 `npm` 发布权限 (需 `npm login`)。
|
|
9
|
+
3. 建议配置 `GITHUB_TOKEN` 环境变量以支持自动创建 GitHub Release。
|
|
10
|
+
|
|
11
|
+
## 发版流程
|
|
12
|
+
|
|
13
|
+
在项目根目录运行:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm run release
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
该命令会自动执行以下步骤:
|
|
20
|
+
|
|
21
|
+
1. **版本升级**: 根据 commit message (feat/fix/breaking) 自动计算下一个版本号 (SemVer)。
|
|
22
|
+
2. **Changelog**: 生成/更新 `CHANGELOG.md`。
|
|
23
|
+
3. **Git**: 创建 git tag 并提交。
|
|
24
|
+
4. **Publish**: 发布到 NPM。
|
|
25
|
+
5. **GitHub**: 创建 GitHub Release (包含 Changelog)。
|
|
26
|
+
|
|
27
|
+
## 提交规范
|
|
28
|
+
|
|
29
|
+
请务必遵守 Conventional Commits 规范,否则 `commitlint` 会拦截提交,且无法生成正确的 Changelog。
|
|
30
|
+
|
|
31
|
+
- `feat: ...` -> Minor 版本 (0.1.0 -> 0.2.0)
|
|
32
|
+
- `fix: ...` -> Patch 版本 (0.1.0 -> 0.1.1)
|
|
33
|
+
- `chore: ...` -> 不触发版本更新 (除非手动指定)
|
|
34
|
+
- `BREAKING CHANGE: ...` -> Major 版本 (0.1.0 -> 1.0.0)
|
package/expo-module.config.json
CHANGED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum BilibiliError: Error {
|
|
4
|
+
case invalidUrl
|
|
5
|
+
case requestFailed
|
|
6
|
+
case decodingFailed
|
|
7
|
+
case navInfoMissing
|
|
8
|
+
case noData
|
|
9
|
+
case apiError(code: Int, message: String)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
struct BilibiliNavResponse: Codable {
|
|
13
|
+
let code: Int
|
|
14
|
+
let data: NavData?
|
|
15
|
+
|
|
16
|
+
struct NavData: Codable {
|
|
17
|
+
let wbi_img: WbiImg?
|
|
18
|
+
let isLogin: Bool?
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
struct WbiImg: Codable {
|
|
22
|
+
let img_url: String
|
|
23
|
+
let sub_url: String
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct BilibiliPlayUrlResponse: Codable {
|
|
28
|
+
let code: Int
|
|
29
|
+
let message: String?
|
|
30
|
+
let data: PlayUrlData?
|
|
31
|
+
|
|
32
|
+
struct PlayUrlData: Codable {
|
|
33
|
+
let durl: [Durl]?
|
|
34
|
+
let dash: Dash?
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
struct Durl: Codable {
|
|
38
|
+
let url: String
|
|
39
|
+
let backup_url: [String]?
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
struct Dash: Codable {
|
|
43
|
+
let audio: [DashAudio]?
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
struct DashAudio: Codable {
|
|
47
|
+
let id: Int
|
|
48
|
+
let baseUrl: String
|
|
49
|
+
let backupUrl: [String]?
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Response for PageList
|
|
54
|
+
struct BilibiliPageListResponse: Codable {
|
|
55
|
+
let code: Int
|
|
56
|
+
let message: String?
|
|
57
|
+
let data: [BilibiliPageListItem]?
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
struct BilibiliPageListItem: Codable {
|
|
61
|
+
let cid: Int
|
|
62
|
+
let page: Int
|
|
63
|
+
let part: String
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class BilibiliApi {
|
|
67
|
+
static let shared = BilibiliApi()
|
|
68
|
+
|
|
69
|
+
private let session = URLSession.shared
|
|
70
|
+
private var cookie: String?
|
|
71
|
+
|
|
72
|
+
// Wbi keys cache
|
|
73
|
+
private var imgKey: String?
|
|
74
|
+
private var subKey: String?
|
|
75
|
+
private var wbiKeysUpdatedAt: Date?
|
|
76
|
+
|
|
77
|
+
func setCookie(_ cookie: String) {
|
|
78
|
+
self.cookie = cookie
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func getPageList(bvid: String, completion: @escaping (Result<Int, Error>) -> Void) {
|
|
82
|
+
var components = URLComponents(string: "https://api.bilibili.com/x/player/pagelist")!
|
|
83
|
+
components.queryItems = [URLQueryItem(name: "bvid", value: bvid)]
|
|
84
|
+
|
|
85
|
+
var request = URLRequest(url: components.url!)
|
|
86
|
+
request.httpMethod = "GET"
|
|
87
|
+
if let cookie = cookie {
|
|
88
|
+
request.setValue(cookie, forHTTPHeaderField: "Cookie")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
print("[BilibiliApi] getPageList: Requesting \(request.url?.absoluteString ?? "nil")")
|
|
92
|
+
|
|
93
|
+
session.dataTask(with: request) { data, response, error in
|
|
94
|
+
if let error = error {
|
|
95
|
+
completion(.failure(error))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
guard let data = data else {
|
|
100
|
+
completion(.failure(BilibiliError.noData))
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
do {
|
|
105
|
+
let apiResponse = try JSONDecoder().decode(BilibiliPageListResponse.self, from: data)
|
|
106
|
+
if apiResponse.code != 0 {
|
|
107
|
+
print("[BilibiliApi] getPageList error: \(apiResponse.code) \(apiResponse.message ?? "")")
|
|
108
|
+
completion(.failure(BilibiliError.apiError(code: apiResponse.code, message: apiResponse.message ?? "Unknown error")))
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if let firstPage = apiResponse.data?.first {
|
|
113
|
+
completion(.success(firstPage.cid))
|
|
114
|
+
} else {
|
|
115
|
+
completion(.failure(BilibiliError.decodingFailed)) // Or specific "Empty List" error
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
print("[BilibiliApi] getPageList decode failed: \(error)")
|
|
119
|
+
completion(.failure(error))
|
|
120
|
+
}
|
|
121
|
+
}.resume()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func refreshNavInfo(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
125
|
+
let urlStr = "https://api.bilibili.com/x/web-interface/nav"
|
|
126
|
+
guard let url = URL(string: urlStr) else {
|
|
127
|
+
completion(.failure(BilibiliError.invalidUrl))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
var request = URLRequest(url: url)
|
|
132
|
+
if let cookie = cookie {
|
|
133
|
+
request.setValue(cookie, forHTTPHeaderField: "Cookie")
|
|
134
|
+
}
|
|
135
|
+
request.setValue("https://www.bilibili.com", forHTTPHeaderField: "Referer")
|
|
136
|
+
request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
|
|
137
|
+
|
|
138
|
+
session.dataTask(with: request) { [weak self] data, response, error in
|
|
139
|
+
if let error = error {
|
|
140
|
+
completion(.failure(error))
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
guard let data = data else {
|
|
145
|
+
completion(.failure(BilibiliError.requestFailed))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
do {
|
|
150
|
+
let navResponse = try JSONDecoder().decode(BilibiliNavResponse.self, from: data)
|
|
151
|
+
if let wbiImg = navResponse.data?.wbi_img {
|
|
152
|
+
self?.imgKey = WbiUtil.extractKey(url: wbiImg.img_url)
|
|
153
|
+
self?.subKey = WbiUtil.extractKey(url: wbiImg.sub_url)
|
|
154
|
+
completion(.success(()))
|
|
155
|
+
} else {
|
|
156
|
+
completion(.failure(BilibiliError.navInfoMissing))
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
completion(.failure(error))
|
|
160
|
+
}
|
|
161
|
+
}.resume()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func getPlayUrl(bvid: String, cid: String, completion: @escaping (Result<String, Error>) -> Void) {
|
|
165
|
+
guard let imgKey = imgKey, let subKey = subKey else {
|
|
166
|
+
// Refresh nav info first
|
|
167
|
+
refreshNavInfo { result in
|
|
168
|
+
switch result {
|
|
169
|
+
case .success:
|
|
170
|
+
self.getPlayUrl(bvid: bvid, cid: cid, completion: completion)
|
|
171
|
+
case .failure(let error):
|
|
172
|
+
completion(.failure(error))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let params: [String: Any] = [
|
|
179
|
+
"bvid": bvid,
|
|
180
|
+
"cid": cid,
|
|
181
|
+
"qn": 80, // 1080P
|
|
182
|
+
// fnval=1 requests MP4/FLV durl list which is better for AVPlayer
|
|
183
|
+
"fnval": 1,
|
|
184
|
+
"fnver": 0,
|
|
185
|
+
"fourk": 1,
|
|
186
|
+
"platform": "html5" // Explicitly request html5 compatible (mp4)
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
let signedParams = WbiUtil.sign(params: params, imgKey: imgKey, subKey: subKey)
|
|
190
|
+
|
|
191
|
+
var components = URLComponents(string: "https://api.bilibili.com/x/player/wbi/playurl")!
|
|
192
|
+
components.queryItems = signedParams.map { URLQueryItem(name: $0.key, value: $0.value) }
|
|
193
|
+
|
|
194
|
+
print("[BilibiliApi] getPlayUrl: Requesting \(components.url?.absoluteString ?? "nil")")
|
|
195
|
+
|
|
196
|
+
var request = URLRequest(url: components.url!)
|
|
197
|
+
if let cookie = cookie {
|
|
198
|
+
request.setValue(cookie, forHTTPHeaderField: "Cookie")
|
|
199
|
+
}
|
|
200
|
+
request.setValue("https://www.bilibili.com", forHTTPHeaderField: "Referer")
|
|
201
|
+
request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
|
|
202
|
+
|
|
203
|
+
session.dataTask(with: request) { data, response, error in
|
|
204
|
+
if let error = error {
|
|
205
|
+
print("[BilibiliApi] Request failed: \(error)")
|
|
206
|
+
completion(.failure(error))
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
guard let data = data else {
|
|
211
|
+
completion(.failure(BilibiliError.requestFailed))
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Debug print response
|
|
216
|
+
if let str = String(data: data, encoding: .utf8) {
|
|
217
|
+
print("[BilibiliApi] Response: \(str)")
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
do {
|
|
221
|
+
let playUrlResponse = try JSONDecoder().decode(BilibiliPlayUrlResponse.self, from: data)
|
|
222
|
+
|
|
223
|
+
if playUrlResponse.code != 0 {
|
|
224
|
+
print("[BilibiliApi] API returned error code: \(playUrlResponse.code), message: \(playUrlResponse.message ?? "nil")")
|
|
225
|
+
completion(.failure(BilibiliError.requestFailed)) // Create specific error?
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Prioritize Dash Audio, then Durl
|
|
230
|
+
if let audioUrl = playUrlResponse.data?.dash?.audio?.first?.baseUrl {
|
|
231
|
+
print("[BilibiliApi] Found DASH audio URL")
|
|
232
|
+
completion(.success(audioUrl))
|
|
233
|
+
} else if let mp4Url = playUrlResponse.data?.durl?.first?.url {
|
|
234
|
+
print("[BilibiliApi] Found MP4/DURL URL")
|
|
235
|
+
completion(.success(mp4Url))
|
|
236
|
+
} else {
|
|
237
|
+
print("[BilibiliApi] No playable URL found in response")
|
|
238
|
+
completion(.failure(BilibiliError.decodingFailed)) // Or specific "not found"
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
print("[BilibiliApi] Decode failed: \(error)")
|
|
242
|
+
completion(.failure(error))
|
|
243
|
+
}
|
|
244
|
+
}.resume()
|
|
245
|
+
}
|
|
246
|
+
}
|