@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.
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ extends: ['@commitlint/config-conventional'],
3
+ };
package/.env ADDED
@@ -0,0 +1 @@
1
+ GITHUB_TOKEN=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
@@ -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
- ## 与 B 站集成
7
+ ## 功能特性
8
8
 
9
- 通过 `Orpheus.setBilibiliCookie()` 设置 cookie,稍后会自动用于音频流请求。(不设置也行,只是无法获取高码率的音频)
9
+ - **Bilibili 集成**: 自动处理 Bilibili 音频流,支持高码率(需 Cookie)。
10
+ - **双层缓存**: 包含独立的下载缓存和边下边播 LRU 缓存。
11
+ - **Android Media3**: 基于最新的 Media3 和 ExoPlayer 架构。
12
+ - **桌面歌词**: 支持系统级桌面歌词悬浮窗。
13
+ - **ExoPlayer 扩展**: 支持 `ffmpeg` 扩展(如需要)。
10
14
 
11
- Orpheus 通过特殊的 uri 识别来自 bilibili 的资源,格式为 `orpheus://bilibili?bvid=xxx&cid=111&quality=30280&dolby=0&hires=0`,若不提供 cid 则默认请求第一个分 p。quality 参考 b 站 api。
15
+ ## 文档
12
16
 
13
- ## 缓存
17
+ 详细的 API 文档和使用说明请参阅 [Wiki](docs/Home.md) 或直接查看 `docs/` 目录。
14
18
 
15
- Orpheus 内部有两层缓存:
19
+ ## 声明
16
20
 
17
- 1. 用户手动下载的缓存
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。
@@ -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 = '0.1.0'
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 "0.1.0"
43
+ versionName packageJson.version
39
44
  }
40
45
  lintOptions {
41
46
  abortOnError false
@@ -173,6 +173,7 @@ class OrpheusMusicService : MediaLibraryService() {
173
173
  .build(),
174
174
  true
175
175
  )
176
+ .setHandleAudioBecomingNoisy(true)
176
177
  .build()
177
178
 
178
179
  floatingLyricsManager = FloatingLyricsManager(this, player)
@@ -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)
@@ -1,6 +1,9 @@
1
1
  {
2
- "platforms": ["android"],
2
+ "platforms": ["android", "ios"],
3
3
  "android": {
4
4
  "modules": ["expo.modules.orpheus.ExpoOrpheusModule"]
5
+ },
6
+ "ios": {
7
+ "modules": ["ExpoOrpheusModule"]
5
8
  }
6
9
  }
@@ -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
+ }