@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.
- package/.eslintrc.js +5 -0
- package/README.md +11 -0
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +21 -0
- package/android/src/main/java/expo/modules/orpheus/ExpoOrpheusModule.kt +365 -0
- package/android/src/main/java/expo/modules/orpheus/NetworkModule.kt +46 -0
- package/android/src/main/java/expo/modules/orpheus/OrpheusConfig.kt +5 -0
- package/android/src/main/java/expo/modules/orpheus/OrpheusService.kt +142 -0
- package/android/src/main/java/expo/modules/orpheus/TrackRecord.kt +28 -0
- package/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliApi.kt +24 -0
- package/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliModels.kt +68 -0
- package/android/src/main/java/expo/modules/orpheus/bilibili/BilibiliRepository.kt +144 -0
- package/android/src/main/java/expo/modules/orpheus/bilibili/WbiUtil.kt +73 -0
- package/build/ExpoOrpheusModule.d.ts +98 -0
- package/build/ExpoOrpheusModule.d.ts.map +1 -0
- package/build/ExpoOrpheusModule.js +23 -0
- package/build/ExpoOrpheusModule.js.map +1 -0
- package/build/hooks/index.d.ts +7 -0
- package/build/hooks/index.d.ts.map +1 -0
- package/build/hooks/index.js +7 -0
- package/build/hooks/index.js.map +1 -0
- package/build/hooks/useCurrentTrack.d.ts +6 -0
- package/build/hooks/useCurrentTrack.d.ts.map +1 -0
- package/build/hooks/useCurrentTrack.js +41 -0
- package/build/hooks/useCurrentTrack.js.map +1 -0
- package/build/hooks/useIsPlaying.d.ts +2 -0
- package/build/hooks/useIsPlaying.d.ts.map +1 -0
- package/build/hooks/useIsPlaying.js +22 -0
- package/build/hooks/useIsPlaying.js.map +1 -0
- package/build/hooks/useOrpheus.d.ts +10 -0
- package/build/hooks/useOrpheus.d.ts.map +1 -0
- package/build/hooks/useOrpheus.js +20 -0
- package/build/hooks/useOrpheus.js.map +1 -0
- package/build/hooks/usePlaybackState.d.ts +3 -0
- package/build/hooks/usePlaybackState.d.ts.map +1 -0
- package/build/hooks/usePlaybackState.js +18 -0
- package/build/hooks/usePlaybackState.js.map +1 -0
- package/build/hooks/useProgress.d.ts +6 -0
- package/build/hooks/useProgress.d.ts.map +1 -0
- package/build/hooks/useProgress.js +59 -0
- package/build/hooks/useProgress.js.map +1 -0
- package/build/hooks/useShuffleMode.d.ts +2 -0
- package/build/hooks/useShuffleMode.d.ts.map +1 -0
- package/build/hooks/useShuffleMode.js +22 -0
- package/build/hooks/useShuffleMode.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +44 -0
- package/src/ExpoOrpheusModule.ts +114 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useCurrentTrack.ts +46 -0
- package/src/hooks/useIsPlaying.ts +25 -0
- package/src/hooks/useOrpheus.ts +21 -0
- package/src/hooks/usePlaybackState.ts +21 -0
- package/src/hooks/useProgress.ts +71 -0
- package/src/hooks/useShuffleMode.ts +26 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +9 -0
package/.eslintrc.js
ADDED
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,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
|
+
}
|