@roitium/expo-orpheus 0.1.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.
Files changed (61) hide show
  1. package/.eslintrc.js +5 -0
  2. package/README.md +11 -0
  3. package/android/build.gradle +51 -0
  4. package/android/src/main/AndroidManifest.xml +21 -0
  5. package/android/src/main/java/expo/modules/orpheus/ExpoOrpheusModule.kt +365 -0
  6. package/android/src/main/java/expo/modules/orpheus/NetworkModule.kt +46 -0
  7. package/android/src/main/java/expo/modules/orpheus/OrpheusConfig.kt +5 -0
  8. package/android/src/main/java/expo/modules/orpheus/OrpheusService.kt +142 -0
  9. package/android/src/main/java/expo/modules/orpheus/TrackRecord.kt +28 -0
  10. package/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliApi.kt +24 -0
  11. package/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliModels.kt +68 -0
  12. package/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliRepository.kt +144 -0
  13. package/android/src/main/java/expo/modules/orpheus/bilibili/WbiUtil.kt +73 -0
  14. package/build/ExpoOrpheusModule.d.ts +98 -0
  15. package/build/ExpoOrpheusModule.d.ts.map +1 -0
  16. package/build/ExpoOrpheusModule.js +23 -0
  17. package/build/ExpoOrpheusModule.js.map +1 -0
  18. package/build/hooks/index.d.ts +7 -0
  19. package/build/hooks/index.d.ts.map +1 -0
  20. package/build/hooks/index.js +7 -0
  21. package/build/hooks/index.js.map +1 -0
  22. package/build/hooks/useCurrentTrack.d.ts +6 -0
  23. package/build/hooks/useCurrentTrack.d.ts.map +1 -0
  24. package/build/hooks/useCurrentTrack.js +41 -0
  25. package/build/hooks/useCurrentTrack.js.map +1 -0
  26. package/build/hooks/useIsPlaying.d.ts +2 -0
  27. package/build/hooks/useIsPlaying.d.ts.map +1 -0
  28. package/build/hooks/useIsPlaying.js +22 -0
  29. package/build/hooks/useIsPlaying.js.map +1 -0
  30. package/build/hooks/useOrpheus.d.ts +10 -0
  31. package/build/hooks/useOrpheus.d.ts.map +1 -0
  32. package/build/hooks/useOrpheus.js +20 -0
  33. package/build/hooks/useOrpheus.js.map +1 -0
  34. package/build/hooks/usePlaybackState.d.ts +3 -0
  35. package/build/hooks/usePlaybackState.d.ts.map +1 -0
  36. package/build/hooks/usePlaybackState.js +18 -0
  37. package/build/hooks/usePlaybackState.js.map +1 -0
  38. package/build/hooks/useProgress.d.ts +6 -0
  39. package/build/hooks/useProgress.d.ts.map +1 -0
  40. package/build/hooks/useProgress.js +59 -0
  41. package/build/hooks/useProgress.js.map +1 -0
  42. package/build/hooks/useShuffleMode.d.ts +2 -0
  43. package/build/hooks/useShuffleMode.d.ts.map +1 -0
  44. package/build/hooks/useShuffleMode.js +22 -0
  45. package/build/hooks/useShuffleMode.js.map +1 -0
  46. package/build/index.d.ts +3 -0
  47. package/build/index.d.ts.map +1 -0
  48. package/build/index.js +3 -0
  49. package/build/index.js.map +1 -0
  50. package/expo-module.config.json +6 -0
  51. package/package.json +44 -0
  52. package/src/ExpoOrpheusModule.ts +114 -0
  53. package/src/hooks/index.ts +6 -0
  54. package/src/hooks/useCurrentTrack.ts +46 -0
  55. package/src/hooks/useIsPlaying.ts +25 -0
  56. package/src/hooks/useOrpheus.ts +21 -0
  57. package/src/hooks/usePlaybackState.ts +21 -0
  58. package/src/hooks/useProgress.ts +71 -0
  59. package/src/hooks/useShuffleMode.ts +26 -0
  60. package/src/index.ts +2 -0
  61. package/tsconfig.json +9 -0
package/.eslintrc.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ['universe/native', 'universe/web'],
4
+ ignorePatterns: ['build'],
5
+ };
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # Orpheus
2
+
3
+ **BBPlayer 内部音频模块**
4
+
5
+ 这是一个为 BBPlayer 项目构建的高性能定制音频播放库。旨在替代 `react-native-track-player`,以提供与 Android Media3 (ExoPlayer) 更紧密的集成,并针对 Bilibili 音频流逻辑提供了原生层支持。
6
+
7
+ ## 与 B 站集成
8
+
9
+ 通过 `Orpheus.setBilibiliCookie()` 设置 cookie,稍后会自动用于音频流请求。(不设置也行,只是无法获取高码率的音频)
10
+
11
+ Orpheus 通过特殊的 uri 识别来自 bilibili 的资源,格式为 `orpheus://bilibili?bvid=xxx&cid=111&quality=30280&dolby=0&hires=0`,若不提供 cid 则默认请求第一个分 p。quality 参考 b 站 api。
@@ -0,0 +1,51 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.orpheus'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ // If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
13
+ // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
14
+ // Most of the time, you may like to manage the Android SDK versions yourself.
15
+ def useManagedAndroidSdkVersions = false
16
+ if (useManagedAndroidSdkVersions) {
17
+ useDefaultAndroidSdkVersions()
18
+ } else {
19
+ buildscript {
20
+ // Simple helper that allows the root project to override versions declared by this library.
21
+ ext.safeExtGet = { prop, fallback ->
22
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
23
+ }
24
+ }
25
+ project.android {
26
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
27
+ defaultConfig {
28
+ minSdkVersion safeExtGet("minSdkVersion", 24)
29
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
30
+ }
31
+ }
32
+ }
33
+
34
+ android {
35
+ namespace "expo.modules.orpheus"
36
+ defaultConfig {
37
+ versionCode 1
38
+ versionName "0.1.0"
39
+ }
40
+ lintOptions {
41
+ abortOnError false
42
+ }
43
+ }
44
+
45
+ dependencies {
46
+ implementation "androidx.media3:media3-exoplayer:1.8.0"
47
+ implementation "androidx.media3:media3-session:1.8.0"
48
+ implementation "com.squareup.retrofit2:retrofit:3.1.0-SNAPSHOT"
49
+ implementation "com.google.code.gson:gson:2.13.2"
50
+ implementation "com.squareup.retrofit2:converter-gson:3.1.0-SNAPSHOT"
51
+ }
@@ -0,0 +1,21 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+
3
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
4
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
5
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
6
+
7
+ <uses-permission android:name="android.permission.INTERNET" />
8
+
9
+ <application>
10
+ <service
11
+ android:name=".OrpheusService"
12
+ android:enabled="true"
13
+ android:exported="true"
14
+ android:foregroundServiceType="mediaPlayback">
15
+ <intent-filter>
16
+ <action android:name="androidx.media3.session.MediaLibraryService" />
17
+ <action android:name="android.media.browse.MediaBrowserService" />
18
+ </intent-filter>
19
+ </service>
20
+ </application>
21
+ </manifest>
@@ -0,0 +1,365 @@
1
+ package expo.modules.orpheus
2
+
3
+ import android.content.ComponentName
4
+ import android.os.Bundle
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import androidx.core.net.toUri
8
+ import androidx.media3.common.C
9
+ import androidx.media3.common.MediaItem
10
+ import androidx.media3.common.MediaMetadata
11
+ import androidx.media3.common.PlaybackException
12
+ import androidx.media3.common.Player
13
+ import androidx.media3.session.MediaController
14
+ import androidx.media3.session.SessionToken
15
+ import com.google.common.util.concurrent.ListenableFuture
16
+ import com.google.common.util.concurrent.MoreExecutors
17
+ import com.google.gson.Gson
18
+ import expo.modules.kotlin.functions.Queues
19
+ import expo.modules.kotlin.modules.Module
20
+ import expo.modules.kotlin.modules.ModuleDefinition
21
+
22
+ class ExpoOrpheusModule : Module() {
23
+ companion object {
24
+ val TAG = "Orpheus"
25
+ }
26
+
27
+ private var controllerFuture: ListenableFuture<MediaController>? = null
28
+
29
+ private var controller: MediaController? = null
30
+
31
+ private val mainHandler = Handler(Looper.getMainLooper())
32
+
33
+ // 记录上一首歌曲的 ID,用于在切歌时发送给 JS
34
+ private var lastMediaId: String? = null
35
+
36
+ val gson = Gson()
37
+
38
+ override fun definition() = ModuleDefinition {
39
+ Name("Orpheus")
40
+
41
+ Events(
42
+ "onPlaybackStateChanged",
43
+ "onTrackTransition",
44
+ "onPlayerError",
45
+ "onPositionUpdate",
46
+ "onIsPlayingChanged"
47
+ )
48
+
49
+ OnCreate {
50
+ val context = appContext.reactContext ?: return@OnCreate
51
+ val sessionToken = SessionToken(
52
+ context,
53
+ ComponentName(context, OrpheusService::class.java)
54
+ )
55
+ controllerFuture = MediaController.Builder(context, sessionToken)
56
+ .setApplicationLooper(Looper.getMainLooper()).buildAsync()
57
+
58
+ controllerFuture?.addListener({
59
+ try {
60
+ controller = controllerFuture?.get()
61
+ setupListeners()
62
+ } catch (e: Exception) {
63
+ e.printStackTrace()
64
+ }
65
+ }, MoreExecutors.directExecutor())
66
+ }
67
+
68
+ OnDestroy {
69
+ stopProgressUpdater()
70
+ controllerFuture?.let { MediaController.releaseFuture(it) }
71
+ }
72
+
73
+ AsyncFunction("getPosition") {
74
+ controller?.currentPosition?.toDouble()?.div(1000.0) ?: 0.0
75
+ }.runOnQueue(Queues.MAIN)
76
+
77
+ AsyncFunction("getDuration") {
78
+ val d = controller?.duration ?: C.TIME_UNSET
79
+ if (d == C.TIME_UNSET) 0.0 else d.toDouble() / 1000.0
80
+ }.runOnQueue(Queues.MAIN)
81
+
82
+ AsyncFunction("getIsPlaying") {
83
+ controller?.isPlaying ?: false
84
+ }.runOnQueue(Queues.MAIN)
85
+
86
+ AsyncFunction("getCurrentIndex") {
87
+ controller?.currentMediaItemIndex ?: -1
88
+ }.runOnQueue(Queues.MAIN)
89
+
90
+ AsyncFunction("getCurrentTrack") {
91
+ val player = controller ?: return@AsyncFunction null
92
+ val currentItem = player.currentMediaItem ?: return@AsyncFunction null
93
+
94
+ mediaItemToTrackRecord(currentItem)
95
+ }.runOnQueue(Queues.MAIN)
96
+
97
+ AsyncFunction("getShuffleMode") {
98
+ controller?.shuffleModeEnabled
99
+ }.runOnQueue(Queues.MAIN)
100
+
101
+ AsyncFunction("getIndexTrack") { index: Int ->
102
+ val player = controller ?: return@AsyncFunction null
103
+
104
+ if (index < 0 || index >= player.mediaItemCount) {
105
+ return@AsyncFunction null
106
+ }
107
+
108
+ val item = player.getMediaItemAt(index)
109
+
110
+ mediaItemToTrackRecord(item)
111
+ }.runOnQueue(Queues.MAIN)
112
+
113
+ Function("setBilibiliCookie") { cookie: String ->
114
+ OrpheusConfig.bilibiliCookie = cookie
115
+ }
116
+
117
+ AsyncFunction("play") {
118
+ val player = controller
119
+ if (player != null) {
120
+ // 获取 player 真正归属的 Looper
121
+ val playerLooper = player.applicationLooper
122
+
123
+ if (Looper.myLooper() == playerLooper) {
124
+ player.play()
125
+ } else {
126
+ Handler(playerLooper).post {
127
+ player.play()
128
+ }
129
+ }
130
+ }
131
+ }.runOnQueue(Queues.MAIN)
132
+
133
+ AsyncFunction("pause") {
134
+ controller?.pause()
135
+ }.runOnQueue(Queues.MAIN)
136
+
137
+ AsyncFunction("clear") {
138
+ controller?.clearMediaItems()
139
+ }.runOnQueue(Queues.MAIN)
140
+
141
+ AsyncFunction("skipTo") { index: Int ->
142
+ // 跳转到指定索引的开头
143
+ controller?.seekTo(index, C.TIME_UNSET)
144
+ }.runOnQueue(Queues.MAIN)
145
+
146
+ AsyncFunction("skipToNext") {
147
+ if (controller?.hasNextMediaItem() == true) {
148
+ controller?.seekToNextMediaItem()
149
+ }
150
+ }.runOnQueue(Queues.MAIN)
151
+
152
+ AsyncFunction("skipToPrevious") {
153
+ if (controller?.hasPreviousMediaItem() == true) {
154
+ controller?.seekToPreviousMediaItem()
155
+ }
156
+ }.runOnQueue(Queues.MAIN)
157
+
158
+ AsyncFunction("seekTo") { seconds: Double ->
159
+ val ms = (seconds * 1000).toLong()
160
+ controller?.seekTo(ms)
161
+ }.runOnQueue(Queues.MAIN)
162
+
163
+ AsyncFunction("setRepeatMode") { mode: Int ->
164
+ // mode: 0=OFF, 1=TRACK, 2=QUEUE
165
+ val repeatMode = when (mode) {
166
+ 1 -> Player.REPEAT_MODE_ONE
167
+ 2 -> Player.REPEAT_MODE_ALL
168
+ else -> Player.REPEAT_MODE_OFF
169
+ }
170
+ controller?.repeatMode = repeatMode
171
+ }.runOnQueue(Queues.MAIN)
172
+
173
+ AsyncFunction("setShuffleMode") { enabled: Boolean ->
174
+ controller?.shuffleModeEnabled = enabled
175
+ }.runOnQueue(Queues.MAIN)
176
+
177
+ AsyncFunction("getQueue") {
178
+ val player = controller ?: return@AsyncFunction emptyList<TrackRecord>()
179
+ val count = player.mediaItemCount
180
+ val queue = ArrayList<TrackRecord>(count)
181
+
182
+ for (i in 0 until count) {
183
+ val item = player.getMediaItemAt(i)
184
+ queue.add(mediaItemToTrackRecord(item))
185
+ }
186
+
187
+ return@AsyncFunction queue
188
+ }.runOnQueue(Queues.MAIN)
189
+
190
+ AsyncFunction("add") { tracks: List<TrackRecord> ->
191
+ // 1. 【后台线程】执行:JSON 转换、Metadata 构建 (耗时操作在这里做,不卡 UI)
192
+ // 这里不需要改,继续在当前线程跑
193
+ val mediaItems = tracks.mapNotNull { track ->
194
+ try {
195
+ val trackJson = gson.toJson(track)
196
+ val extras = Bundle()
197
+ extras.putString("track_json", trackJson)
198
+
199
+ val artUri =
200
+ if (!track.artwork.isNullOrEmpty()) track.artwork!!.toUri() else null
201
+
202
+ val metadata = MediaMetadata.Builder()
203
+ .setTitle(track.title)
204
+ .setArtist(track.artist)
205
+ .setArtworkUri(artUri)
206
+ .setExtras(extras)
207
+ .build()
208
+
209
+ MediaItem.Builder()
210
+ .setMediaId(track.id)
211
+ .setUri(track.url ?: "")
212
+ .setMediaMetadata(metadata)
213
+ .build()
214
+ } catch (e: Exception) {
215
+ e.printStackTrace()
216
+ null
217
+ }
218
+ }
219
+
220
+ val player = controller
221
+ if (player != null) {
222
+ // 获取 player 真正归属的 Looper
223
+ val playerLooper = player.applicationLooper
224
+
225
+ if (Looper.myLooper() == playerLooper) {
226
+ player.addMediaItems(mediaItems)
227
+ if (player.playbackState == Player.STATE_IDLE) {
228
+ player.prepare()
229
+ }
230
+ } else {
231
+ Handler(playerLooper).post {
232
+ player.addMediaItems(mediaItems)
233
+ if (player.playbackState == Player.STATE_IDLE) {
234
+ player.prepare()
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }.runOnQueue(Queues.MAIN)
240
+ }
241
+
242
+ private fun setupListeners() {
243
+ controller?.addListener(object : Player.Listener {
244
+
245
+ /**
246
+ * 核心:处理切歌、播放结束逻辑
247
+ */
248
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
249
+ val currentTrackId = mediaItem?.mediaId ?: ""
250
+ Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
251
+
252
+ sendEvent(
253
+ "onTrackTransition", mapOf(
254
+ "currentTrackId" to currentTrackId,
255
+ "previousTrackId" to lastMediaId, // 上一首歌是什么
256
+ "reason" to reason
257
+ )
258
+ )
259
+
260
+ // 更新本地记录,为下一次切歌做准备
261
+ lastMediaId = currentTrackId
262
+ }
263
+
264
+ /**
265
+ * 处理播放状态改变
266
+ */
267
+ override fun onPlaybackStateChanged(state: Int) {
268
+ // state: 1=IDLE, 2=BUFFERING, 3=READY, 4=ENDED
269
+ sendEvent(
270
+ "onPlaybackStateChanged", mapOf(
271
+ "state" to state
272
+ )
273
+ )
274
+
275
+ updateProgressRunnerState()
276
+ }
277
+
278
+ /**
279
+ * 处理播放/暂停状态
280
+ */
281
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
282
+ sendEvent(
283
+ "onIsPlayingChanged", mapOf(
284
+ "status" to isPlaying
285
+ )
286
+ )
287
+ updateProgressRunnerState()
288
+ }
289
+
290
+ /**
291
+ * 处理错误
292
+ */
293
+ override fun onPlayerError(error: PlaybackException) {
294
+ sendEvent(
295
+ "onPlayerError", mapOf(
296
+ "code" to error.errorCode.toString(),
297
+ "message" to (error.message ?: "Unknown Error")
298
+ )
299
+ )
300
+ }
301
+ })
302
+ }
303
+
304
+ private val progressRunnable = object : Runnable {
305
+ override fun run() {
306
+ val player = controller ?: return
307
+
308
+ if (player.isPlaying) {
309
+ val currentMs = player.currentPosition
310
+ val durationMs = player.duration
311
+
312
+ sendEvent(
313
+ "onPositionUpdate", mapOf(
314
+ "position" to currentMs / 1000.0,
315
+ "duration" to if (durationMs == C.TIME_UNSET) 0.0 else durationMs / 1000.0,
316
+ "buffered" to player.bufferedPosition / 1000.0
317
+ )
318
+ )
319
+ }
320
+
321
+ mainHandler.postDelayed(this, 200)
322
+ }
323
+ }
324
+
325
+ private fun updateProgressRunnerState() {
326
+ val player = controller
327
+ // 如果正在播放且状态是 READY,则开始轮询
328
+ if (player != null && player.isPlaying && player.playbackState == Player.STATE_READY) {
329
+ startProgressUpdater()
330
+ } else {
331
+ stopProgressUpdater()
332
+ }
333
+ }
334
+
335
+ private fun startProgressUpdater() {
336
+ mainHandler.removeCallbacks(progressRunnable)
337
+ mainHandler.post(progressRunnable)
338
+ }
339
+
340
+ private fun stopProgressUpdater() {
341
+ mainHandler.removeCallbacks(progressRunnable)
342
+ }
343
+
344
+ private fun mediaItemToTrackRecord(item: MediaItem): TrackRecord {
345
+ val extras = item.mediaMetadata.extras
346
+ val trackJson = extras?.getString("track_json")
347
+
348
+ if (trackJson != null) {
349
+ try {
350
+ return gson.fromJson(trackJson, TrackRecord::class.java)
351
+ } catch (e: Exception) {
352
+ e.printStackTrace()
353
+ }
354
+ }
355
+
356
+ val track = TrackRecord()
357
+ track.id = item.mediaId
358
+ track.url = item.localConfiguration?.uri?.toString() ?: ""
359
+ track.title = item.mediaMetadata.title?.toString()
360
+ track.artist = item.mediaMetadata.artist?.toString()
361
+ track.artwork = item.mediaMetadata.artworkUri?.toString()
362
+
363
+ return track
364
+ }
365
+ }
@@ -0,0 +1,46 @@
1
+ package expo.modules.orpheus
2
+
3
+ import okhttp3.Interceptor
4
+ import okhttp3.OkHttpClient
5
+ import okhttp3.Response
6
+ import retrofit2.Retrofit
7
+ import retrofit2.converter.gson.GsonConverterFactory
8
+ import java.util.concurrent.TimeUnit
9
+
10
+ object NetworkModule {
11
+ private const val BASE_URL = "https://api.bilibili.com"
12
+
13
+ private val client: OkHttpClient by lazy {
14
+ OkHttpClient.Builder()
15
+ .connectTimeout(10, TimeUnit.SECONDS)
16
+ .readTimeout(10, TimeUnit.SECONDS)
17
+ .writeTimeout(10, TimeUnit.SECONDS)
18
+
19
+ .addInterceptor(BilibiliHeaderInterceptor())
20
+ .build()
21
+ }
22
+
23
+ val retrofit: Retrofit by lazy {
24
+ Retrofit.Builder()
25
+ .baseUrl(BASE_URL)
26
+ .client(client)
27
+ .addConverterFactory(GsonConverterFactory.create()) // 自动 JSON 解析
28
+ .build()
29
+ }
30
+
31
+ private class BilibiliHeaderInterceptor : Interceptor {
32
+ override fun intercept(chain: Interceptor.Chain): Response {
33
+ val originalRequest = chain.request()
34
+
35
+ val newRequest = originalRequest.newBuilder()
36
+ .header(
37
+ "User-Agent",
38
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
39
+ )
40
+ .header("Referer", "https://www.bilibili.com/")
41
+ .build()
42
+
43
+ return chain.proceed(newRequest)
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,5 @@
1
+ package expo.modules.orpheus
2
+
3
+ object OrpheusConfig {
4
+ var bilibiliCookie: String? = null
5
+ }
@@ -0,0 +1,142 @@
1
+ package expo.modules.orpheus
2
+
3
+ import androidx.annotation.OptIn
4
+ import androidx.core.net.toUri
5
+ import androidx.media3.common.AudioAttributes
6
+ import androidx.media3.common.C
7
+ import androidx.media3.common.util.UnstableApi
8
+ import androidx.media3.datasource.DataSpec
9
+ import androidx.media3.datasource.DefaultHttpDataSource
10
+ import androidx.media3.datasource.ResolvingDataSource
11
+ import androidx.media3.exoplayer.ExoPlayer
12
+ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
13
+ import androidx.media3.session.MediaLibraryService
14
+ import androidx.media3.session.MediaSession
15
+ import com.google.common.util.concurrent.Futures
16
+ import com.google.common.util.concurrent.ListenableFuture
17
+ import expo.modules.orpheus.bilibili.BilibiliRepository
18
+ import java.io.IOException
19
+
20
+ class OrpheusService : MediaLibraryService() {
21
+
22
+ private var player: ExoPlayer? = null
23
+ private var mediaSession: MediaLibrarySession? = null
24
+
25
+ @OptIn(UnstableApi::class)
26
+ override fun onCreate() {
27
+ super.onCreate()
28
+ val httpDataSourceFactory = DefaultHttpDataSource.Factory()
29
+ .setUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
30
+ .setAllowCrossProtocolRedirects(true)
31
+
32
+ val resolvingDataSourceFactory = ResolvingDataSource.Factory(
33
+ httpDataSourceFactory,
34
+ object : ResolvingDataSource.Resolver {
35
+ // TODO: maybe we need to add a cache?
36
+ override fun resolveDataSpec(dataSpec: DataSpec): DataSpec {
37
+ val uri = dataSpec.uri
38
+
39
+ // orpheus://bilibili?bvid=bv123124&cid=114514&quality=30280&dolby=0&hires=0
40
+ if (uri.scheme == "orpheus" && uri.host == "bilibili") {
41
+ try {
42
+ val bvid = uri.getQueryParameter("bvid")
43
+ val cid = uri.getQueryParameter("cid")?.toLongOrNull()
44
+ val quality = uri.getQueryParameter("quality")?.toIntOrNull() ?: 30280
45
+ val enableDolby = uri.getQueryParameter("dolby") == "1"
46
+ val enableHiRes = uri.getQueryParameter("hires") == "1"
47
+
48
+ if (bvid == null) {
49
+ throw IOException("Invalid Bilibili Params: bvid=$bvid, cid=$cid")
50
+ }
51
+
52
+ val realUrl = BilibiliRepository.resolveAudioUrl(
53
+ bvid = bvid,
54
+ cid = cid,
55
+ audioQuality = quality,
56
+ enableDolby = enableDolby,
57
+ enableHiRes = enableHiRes,
58
+ cookie = OrpheusConfig.bilibiliCookie
59
+ )
60
+
61
+ val headers = HashMap<String, String>()
62
+ headers["Referer"] = "https://www.bilibili.com/"
63
+
64
+ return dataSpec.buildUpon()
65
+ .setUri(realUrl.toUri())
66
+ .setHttpRequestHeaders(headers)
67
+ .build()
68
+ } catch (e: Exception) {
69
+ throw IOException("Resolve Url Failed: ${e.message}", e)
70
+ }
71
+ }
72
+
73
+ return dataSpec
74
+ }
75
+ }
76
+ )
77
+
78
+ val mediaSourceFactory = DefaultMediaSourceFactory(this)
79
+ .setDataSourceFactory(resolvingDataSourceFactory)
80
+
81
+ player = ExoPlayer.Builder(this)
82
+ .setMediaSourceFactory(mediaSourceFactory)
83
+ .setAudioAttributes(
84
+ AudioAttributes.Builder()
85
+ .setUsage(C.USAGE_MEDIA)
86
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
87
+ .build(),
88
+ true
89
+ )
90
+ .build()
91
+
92
+ mediaSession = MediaLibrarySession.Builder(this, player!!, callback)
93
+ .setId("OrpheusSession")
94
+ .build()
95
+ }
96
+
97
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? {
98
+ return mediaSession
99
+ }
100
+
101
+ override fun onDestroy() {
102
+ mediaSession?.run {
103
+ player.release()
104
+ release()
105
+ mediaSession = null
106
+ }
107
+ super.onDestroy()
108
+ }
109
+
110
+ var callback: MediaLibrarySession.Callback = @UnstableApi
111
+ object : MediaLibrarySession.Callback {
112
+ @OptIn(UnstableApi::class)
113
+ override fun onConnect(
114
+ session: MediaSession,
115
+ controller: MediaSession.ControllerInfo
116
+ ): MediaSession.ConnectionResult {
117
+ val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
118
+ .build()
119
+
120
+ return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
121
+ .setAvailableSessionCommands(sessionCommands)
122
+ .build()
123
+ }
124
+
125
+ /**
126
+ * 修复 UnsupportedOperationException 的关键!
127
+ * 当系统尝试恢复播放(比如从“最近播放”卡片点击)时触发。
128
+ */
129
+ override fun onPlaybackResumption(
130
+ mediaSession: MediaSession,
131
+ controller: MediaSession.ControllerInfo
132
+ ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
133
+ return Futures.immediateFuture(
134
+ MediaSession.MediaItemsWithStartPosition(
135
+ emptyList(), // 没有媒体项
136
+ C.INDEX_UNSET, // 索引未定
137
+ C.TIME_UNSET // 进度未定
138
+ )
139
+ )
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,28 @@
1
+ package expo.modules.orpheus
2
+
3
+ import expo.modules.kotlin.records.Field
4
+ import expo.modules.kotlin.records.Record
5
+
6
+ class TrackRecord : Record {
7
+ @Field
8
+ var id: String = ""
9
+
10
+ @Field
11
+ var url: String = ""
12
+
13
+ @Field
14
+ var title: String? = null
15
+
16
+ @Field
17
+ var artist: String? = null
18
+
19
+ @Field
20
+ var artwork: String? = null
21
+
22
+ // unit: second
23
+ @Field
24
+ var duration: Double? = null
25
+
26
+ // @Field
27
+ // var loudness: Map<String, Any>? = null
28
+ }