@roitium/expo-orpheus 0.5.1 → 0.7.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/README.md CHANGED
@@ -21,6 +21,10 @@ Orpheus 内部有两层缓存:
21
21
 
22
22
  Orpheus 集成了 Media3 的 DownloadManager,抛弃了原先 BBPlayer 中繁琐的下载实现。
23
23
 
24
+ ## 响度均衡
25
+
26
+ 默认启用,只对未缓存的 b 站音频生效
27
+
24
28
  ## 使用
25
29
 
26
30
  虽然该包是公开的,但仍然主要供 BBPlayer 内部使用。可能不会有完整的文档覆盖。我们欢迎你 fork 后自行修改使用。
@@ -3,6 +3,7 @@ package expo.modules.orpheus
3
3
  import android.content.Context
4
4
  import androidx.media3.common.util.UnstableApi
5
5
  import androidx.media3.database.StandaloneDatabaseProvider
6
+ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
6
7
  import androidx.media3.datasource.cache.NoOpCacheEvictor
7
8
  import androidx.media3.datasource.cache.SimpleCache
8
9
  import java.io.File
@@ -10,6 +11,7 @@ import java.io.File
10
11
  @UnstableApi
11
12
  object DownloadCache {
12
13
  private var stableCache: SimpleCache? = null
14
+ private var lruCache: SimpleCache? = null
13
15
 
14
16
  @Synchronized
15
17
  fun getStableCache(context: Context): SimpleCache {
@@ -21,4 +23,15 @@ object DownloadCache {
21
23
  }
22
24
  return stableCache!!
23
25
  }
26
+
27
+ @Synchronized
28
+ fun getLruCache(context: Context): SimpleCache {
29
+ if (lruCache == null) {
30
+ val cacheDir = File(context.cacheDir, "media_cache_lru")
31
+ val evictor = LeastRecentlyUsedCacheEvictor(256 * 1024 * 1024)
32
+ val databaseProvider = StandaloneDatabaseProvider(context)
33
+ lruCache = SimpleCache(cacheDir, evictor, databaseProvider)
34
+ }
35
+ return lruCache!!
36
+ }
24
37
  }
@@ -1,7 +1,6 @@
1
1
  package expo.modules.orpheus
2
2
 
3
3
  import android.content.ComponentName
4
- import android.os.Bundle
5
4
  import android.os.Handler
6
5
  import android.os.Looper
7
6
  import android.util.Log
@@ -18,26 +17,24 @@ import androidx.media3.exoplayer.offline.DownloadManager
18
17
  import androidx.media3.exoplayer.offline.DownloadRequest
19
18
  import androidx.media3.exoplayer.offline.DownloadService
20
19
  import androidx.media3.session.MediaController
21
- import androidx.media3.session.SessionCommand
22
- import androidx.media3.session.SessionResult
23
20
  import androidx.media3.session.SessionToken
24
21
  import com.google.common.util.concurrent.ListenableFuture
25
- import com.google.common.util.concurrent.MoreExecutors
26
22
  import com.google.gson.Gson
27
- import expo.modules.kotlin.exception.CodedException
28
23
  import expo.modules.kotlin.functions.Queues
29
24
  import expo.modules.kotlin.modules.Module
30
25
  import expo.modules.kotlin.modules.ModuleDefinition
31
26
  import expo.modules.orpheus.models.TrackRecord
32
27
  import expo.modules.orpheus.utils.DownloadUtil
33
- import expo.modules.orpheus.utils.Storage
28
+ import expo.modules.orpheus.utils.GeneralStorage
29
+ import expo.modules.orpheus.utils.LoudnessStorage
34
30
  import expo.modules.orpheus.utils.toMediaItem
35
31
 
36
32
  @UnstableApi
37
33
  class ExpoOrpheusModule : Module() {
34
+ // keep this controller only to make sure MediaLibraryService is init.
38
35
  private var controllerFuture: ListenableFuture<MediaController>? = null
39
36
 
40
- private var controller: MediaController? = null
37
+ private var player: Player? = null
41
38
 
42
39
  private val mainHandler = Handler(Looper.getMainLooper())
43
40
 
@@ -51,6 +48,124 @@ class ExpoOrpheusModule : Module() {
51
48
 
52
49
  val gson = Gson()
53
50
 
51
+ private val playerListener = object : Player.Listener {
52
+
53
+ /**
54
+ * 核心:处理切歌、播放结束逻辑
55
+ */
56
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
57
+ val newId = mediaItem?.mediaId ?: ""
58
+ Log.e("Orpheus", "onMediaItemTransition: $reason")
59
+
60
+ sendEvent(
61
+ "onTrackStarted", mapOf(
62
+ "trackId" to newId,
63
+ "reason" to reason
64
+ )
65
+ )
66
+
67
+ lastMediaId = newId
68
+ saveCurrentPosition()
69
+ }
70
+
71
+ override fun onTimelineChanged(timeline: Timeline, reason: Int) {
72
+ val p = player ?: return
73
+ val currentItem = p.currentMediaItem ?: return
74
+ val mediaId = currentItem.mediaId
75
+
76
+ val duration = p.duration
77
+ Log.d(
78
+ "Orpheus",
79
+ "onTimelineChanged: reason: $reason mediaId: $mediaId duration: $duration"
80
+ )
81
+
82
+ if (duration != C.TIME_UNSET && duration > 0) {
83
+ durationCache[mediaId] = duration
84
+ }
85
+ }
86
+
87
+ override fun onPositionDiscontinuity(
88
+ oldPosition: Player.PositionInfo,
89
+ newPosition: Player.PositionInfo,
90
+ reason: Int
91
+ ) {
92
+ val isAutoTransition = reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
93
+ val isIndexChanged = oldPosition.mediaItemIndex != newPosition.mediaItemIndex
94
+ val lastMediaItem = oldPosition.mediaItem ?: return
95
+ val currentTime = System.currentTimeMillis()
96
+ if ((currentTime - lastTrackFinishedAt) < 200) {
97
+ return
98
+ }
99
+
100
+ Log.d(
101
+ "Orpheus",
102
+ "onPositionDiscontinuity: isAutoTransition:$isAutoTransition isIndexChanged: $isIndexChanged durationCache:$durationCache"
103
+ )
104
+
105
+ if (isAutoTransition || isIndexChanged) {
106
+
107
+ val duration = durationCache[lastMediaItem.mediaId] ?: return
108
+ lastTrackFinishedAt = currentTime
109
+
110
+ sendEvent(
111
+ "onTrackFinished", mapOf(
112
+ "trackId" to lastMediaItem.mediaId,
113
+ "finalPosition" to oldPosition.positionMs / 1000.0,
114
+ "duration" to duration / 1000.0,
115
+ )
116
+ )
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 处理播放状态改变
122
+ */
123
+ override fun onPlaybackStateChanged(state: Int) {
124
+ // state: 1=IDLE, 2=BUFFERING, 3=READY, 4=ENDED
125
+ sendEvent(
126
+ "onPlaybackStateChanged", mapOf(
127
+ "state" to state
128
+ )
129
+ )
130
+
131
+ updateProgressRunnerState()
132
+ }
133
+
134
+ /**
135
+ * 处理播放/暂停状态
136
+ */
137
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
138
+ sendEvent(
139
+ "onIsPlayingChanged", mapOf(
140
+ "status" to isPlaying
141
+ )
142
+ )
143
+ updateProgressRunnerState()
144
+ }
145
+
146
+ /**
147
+ * 处理错误
148
+ */
149
+ override fun onPlayerError(error: PlaybackException) {
150
+ sendEvent(
151
+ "onPlayerError", mapOf(
152
+ "code" to error.errorCode.toString(),
153
+ "message" to (error.message ?: "Unknown Error")
154
+ )
155
+ )
156
+ }
157
+
158
+ override fun onRepeatModeChanged(repeatMode: Int) {
159
+ super.onRepeatModeChanged(repeatMode)
160
+ GeneralStorage.saveRepeatMode(repeatMode)
161
+ }
162
+
163
+ override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
164
+ super.onShuffleModeEnabledChanged(shuffleModeEnabled)
165
+ GeneralStorage.saveShuffleMode(shuffleModeEnabled)
166
+ }
167
+ }
168
+
54
169
  @OptIn(UnstableApi::class)
55
170
  override fun definition() = ModuleDefinition {
56
171
  Name("Orpheus")
@@ -67,7 +182,8 @@ class ExpoOrpheusModule : Module() {
67
182
 
68
183
  OnCreate {
69
184
  val context = appContext.reactContext ?: return@OnCreate
70
- Storage.initialize(context)
185
+ GeneralStorage.initialize(context)
186
+ LoudnessStorage.initialize(context)
71
187
  val sessionToken = SessionToken(
72
188
  context,
73
189
  ComponentName(context, OrpheusMusicService::class.java)
@@ -75,14 +191,16 @@ class ExpoOrpheusModule : Module() {
75
191
  controllerFuture = MediaController.Builder(context, sessionToken)
76
192
  .setApplicationLooper(Looper.getMainLooper()).buildAsync()
77
193
 
78
- controllerFuture?.addListener({
79
- try {
80
- controller = controllerFuture?.get()
81
- setupListeners()
82
- } catch (e: Exception) {
83
- e.printStackTrace()
194
+
195
+ OrpheusMusicService.addOnServiceReadyListener { service ->
196
+ mainHandler.post {
197
+ if (this@ExpoOrpheusModule.player != service.player) {
198
+ this@ExpoOrpheusModule.player?.removeListener(playerListener)
199
+ this@ExpoOrpheusModule.player = service.player
200
+ this@ExpoOrpheusModule.player?.addListener(playerListener)
201
+ }
84
202
  }
85
- }, MoreExecutors.directExecutor())
203
+ }
86
204
 
87
205
  downloadManager = DownloadUtil.getDownloadManager(context)
88
206
  downloadManager?.addListener(downloadListener)
@@ -94,14 +212,18 @@ class ExpoOrpheusModule : Module() {
94
212
  mainHandler.removeCallbacks(downloadProgressRunnable)
95
213
  controllerFuture?.let { MediaController.releaseFuture(it) }
96
214
  downloadManager?.removeListener(downloadListener)
215
+ player?.removeListener(playerListener)
216
+ OrpheusMusicService.removeOnServiceReadyListener { }
217
+ player = null
218
+ Log.d("Orpheus", "Destroy media controller")
97
219
  }
98
220
 
99
221
  Constant("restorePlaybackPositionEnabled") {
100
- Storage.isRestoreEnabled()
222
+ GeneralStorage.isRestoreEnabled()
101
223
  }
102
224
 
103
225
  Constant("loudnessNormalizationEnabled") {
104
- Storage.isLoudnessNormalizationEnabled()
226
+ GeneralStorage.isLoudnessNormalizationEnabled()
105
227
  }
106
228
 
107
229
  Function("setBilibiliCookie") { cookie: String ->
@@ -109,144 +231,143 @@ class ExpoOrpheusModule : Module() {
109
231
  }
110
232
 
111
233
  Function("setLoudnessNormalizationEnabled") { enabled: Boolean ->
112
- Storage.setLoudnessNormalizationEnabled(enabled)
234
+ GeneralStorage.setLoudnessNormalizationEnabled(enabled)
113
235
  }
114
236
 
115
237
  Function("setRestorePlaybackPositionEnabled") { enabled: Boolean ->
116
- Storage.setRestoreEnabled(enabled)
238
+ GeneralStorage.setRestoreEnabled(enabled)
117
239
  }
118
240
 
119
241
  AsyncFunction("getPosition") {
120
- checkController()
121
- controller?.currentPosition?.toDouble()?.div(1000.0) ?: 0.0
242
+ checkPlayer()
243
+ player?.currentPosition?.toDouble()?.div(1000.0) ?: 0.0
122
244
  }.runOnQueue(Queues.MAIN)
123
245
 
124
246
  AsyncFunction("getDuration") {
125
- checkController()
126
- val d = controller?.duration ?: C.TIME_UNSET
247
+ checkPlayer()
248
+ val d = player?.duration ?: C.TIME_UNSET
127
249
  if (d == C.TIME_UNSET) 0.0 else d.toDouble() / 1000.0
128
250
  }.runOnQueue(Queues.MAIN)
129
251
 
130
252
  AsyncFunction("getBuffered") {
131
- checkController()
132
- controller?.bufferedPosition?.toDouble()?.div(1000.0) ?: 0.0
253
+ checkPlayer()
254
+ player?.bufferedPosition?.toDouble()?.div(1000.0) ?: 0.0
133
255
  }.runOnQueue(Queues.MAIN)
134
256
 
135
257
  AsyncFunction("getIsPlaying") {
136
- checkController()
137
- controller?.isPlaying ?: false
258
+ checkPlayer()
259
+ player?.isPlaying ?: false
138
260
  }.runOnQueue(Queues.MAIN)
139
261
 
140
262
  AsyncFunction("getCurrentIndex") {
141
- checkController()
142
- controller?.currentMediaItemIndex ?: -1
263
+ checkPlayer()
264
+ player?.currentMediaItemIndex ?: -1
143
265
  }.runOnQueue(Queues.MAIN)
144
266
 
145
267
  AsyncFunction("getCurrentTrack") {
146
- checkController()
147
- val player = controller ?: return@AsyncFunction null
148
- val currentItem = player.currentMediaItem ?: return@AsyncFunction null
268
+ checkPlayer()
269
+ val p = player ?: return@AsyncFunction null
270
+ val currentItem = p.currentMediaItem ?: return@AsyncFunction null
149
271
 
150
272
  mediaItemToTrackRecord(currentItem)
151
273
  }.runOnQueue(Queues.MAIN)
152
274
 
153
275
  AsyncFunction("getShuffleMode") {
154
- checkController()
155
- controller?.shuffleModeEnabled
276
+ checkPlayer()
277
+ player?.shuffleModeEnabled
156
278
  }.runOnQueue(Queues.MAIN)
157
279
 
158
280
  AsyncFunction("getIndexTrack") { index: Int ->
159
- checkController()
160
- val player = controller ?: return@AsyncFunction null
281
+ checkPlayer()
282
+ val p = player ?: return@AsyncFunction null
161
283
 
162
- if (index < 0 || index >= player.mediaItemCount) {
284
+ if (index < 0 || index >= p.mediaItemCount) {
163
285
  return@AsyncFunction null
164
286
  }
165
287
 
166
- val item = player.getMediaItemAt(index)
288
+ val item = p.getMediaItemAt(index)
167
289
 
168
290
  mediaItemToTrackRecord(item)
169
291
  }.runOnQueue(Queues.MAIN)
170
292
 
171
293
  AsyncFunction("play") {
172
- checkController()
173
- controller?.play()
294
+ checkPlayer()
295
+ player?.play()
174
296
  }.runOnQueue(Queues.MAIN)
175
297
 
176
298
  AsyncFunction("pause") {
177
- checkController()
178
- controller?.pause()
299
+ checkPlayer()
300
+ player?.pause()
179
301
  }.runOnQueue(Queues.MAIN)
180
302
 
181
303
  AsyncFunction("clear") {
182
- checkController()
183
- controller?.clearMediaItems()
304
+ checkPlayer()
305
+ player?.clearMediaItems()
184
306
  durationCache.clear()
185
- DownloadUtil.itemVolumeMap.clear()
186
307
  }.runOnQueue(Queues.MAIN)
187
308
 
188
309
  AsyncFunction("skipTo") { index: Int ->
189
310
  // 跳转到指定索引的开头
190
- checkController()
191
- controller?.seekTo(index, C.TIME_UNSET)
311
+ checkPlayer()
312
+ player?.seekTo(index, C.TIME_UNSET)
192
313
  }.runOnQueue(Queues.MAIN)
193
314
 
194
315
  AsyncFunction("skipToNext") {
195
- checkController()
196
- if (controller?.hasNextMediaItem() == true) {
197
- controller?.seekToNextMediaItem()
316
+ checkPlayer()
317
+ if (player?.hasNextMediaItem() == true) {
318
+ player?.seekToNextMediaItem()
198
319
  }
199
320
  }.runOnQueue(Queues.MAIN)
200
321
 
201
322
  AsyncFunction("skipToPrevious") {
202
- checkController()
203
- if (controller?.hasPreviousMediaItem() == true) {
204
- controller?.seekToPreviousMediaItem()
323
+ checkPlayer()
324
+ if (player?.hasPreviousMediaItem() == true) {
325
+ player?.seekToPreviousMediaItem()
205
326
  }
206
327
  }.runOnQueue(Queues.MAIN)
207
328
 
208
329
  AsyncFunction("seekTo") { seconds: Double ->
209
- checkController()
330
+ checkPlayer()
210
331
  val ms = (seconds * 1000).toLong()
211
- controller?.seekTo(ms)
332
+ player?.seekTo(ms)
212
333
  }.runOnQueue(Queues.MAIN)
213
334
 
214
335
  AsyncFunction("setRepeatMode") { mode: Int ->
215
- checkController()
336
+ checkPlayer()
216
337
  // mode: 0=OFF, 1=TRACK, 2=QUEUE
217
338
  val repeatMode = when (mode) {
218
339
  1 -> Player.REPEAT_MODE_ONE
219
340
  2 -> Player.REPEAT_MODE_ALL
220
341
  else -> Player.REPEAT_MODE_OFF
221
342
  }
222
- controller?.repeatMode = repeatMode
343
+ player?.repeatMode = repeatMode
223
344
  }.runOnQueue(Queues.MAIN)
224
345
 
225
346
  AsyncFunction("setShuffleMode") { enabled: Boolean ->
226
- checkController()
227
- controller?.shuffleModeEnabled = enabled
347
+ checkPlayer()
348
+ player?.shuffleModeEnabled = enabled
228
349
  }.runOnQueue(Queues.MAIN)
229
350
 
230
351
  AsyncFunction("getRepeatMode") {
231
- checkController()
232
- controller?.repeatMode
352
+ checkPlayer()
353
+ player?.repeatMode
233
354
  }.runOnQueue(Queues.MAIN)
234
355
 
235
356
  AsyncFunction("removeTrack") { index: Int ->
236
- checkController()
237
- if (index >= 0 && index < (controller?.mediaItemCount ?: 0)) {
238
- controller?.removeMediaItem(index)
357
+ checkPlayer()
358
+ if (index >= 0 && index < (player?.mediaItemCount ?: 0)) {
359
+ player?.removeMediaItem(index)
239
360
  }
240
361
  }.runOnQueue(Queues.MAIN)
241
362
 
242
363
  AsyncFunction("getQueue") {
243
- checkController()
244
- val player = controller ?: return@AsyncFunction emptyList<TrackRecord>()
245
- val count = player.mediaItemCount
364
+ checkPlayer()
365
+ val p = player ?: return@AsyncFunction emptyList<TrackRecord>()
366
+ val count = p.mediaItemCount
246
367
  val queue = ArrayList<TrackRecord>(count)
247
368
 
248
369
  for (i in 0 until count) {
249
- val item = player.getMediaItemAt(i)
370
+ val item = p.getMediaItemAt(i)
250
371
  queue.add(mediaItemToTrackRecord(item))
251
372
  }
252
373
 
@@ -254,59 +375,31 @@ class ExpoOrpheusModule : Module() {
254
375
  }.runOnQueue(Queues.MAIN)
255
376
 
256
377
  AsyncFunction("setSleepTimer") { durationMs: Long ->
257
- checkController()
258
- val command = SessionCommand(CustomCommands.CMD_START_TIMER, Bundle.EMPTY)
259
- val args = Bundle().apply {
260
- putLong(CustomCommands.KEY_DURATION, durationMs)
261
- }
262
-
263
- controller?.sendCustomCommand(command, args)
378
+ OrpheusMusicService.instance?.startSleepTimer(durationMs)
264
379
  return@AsyncFunction null
265
380
  }.runOnQueue(Queues.MAIN)
266
381
 
267
382
  AsyncFunction("getSleepTimerEndTime") {
268
- checkController()
269
-
270
- val command = SessionCommand(CustomCommands.CMD_GET_REMAINING, Bundle.EMPTY)
271
- val future = controller!!.sendCustomCommand(command, Bundle.EMPTY)
272
-
273
- val result = try {
274
- future.get()
275
- } catch (e: Exception) {
276
- throw CodedException("ERR_EXECUTION_FAILED", e.message, e)
277
- }
278
-
279
- if (result.resultCode == SessionResult.RESULT_SUCCESS) {
280
- val extras = result.extras
281
- if (extras.containsKey(CustomCommands.KEY_STOP_TIME)) {
282
- val stopTime = extras.getLong(CustomCommands.KEY_STOP_TIME)
283
- return@AsyncFunction stopTime
284
- }
285
- }
286
-
287
- return@AsyncFunction null
383
+ return@AsyncFunction OrpheusMusicService.instance?.getSleepTimerRemaining()
288
384
  }.runOnQueue(Queues.MAIN)
289
385
 
290
386
  AsyncFunction("cancelSleepTimer") {
291
- checkController()
292
- val command = SessionCommand(CustomCommands.CMD_CANCEL_TIMER, Bundle.EMPTY)
293
- controller?.sendCustomCommand(command, Bundle.EMPTY)
387
+ OrpheusMusicService.instance?.cancelSleepTimer()
294
388
  return@AsyncFunction null
295
389
  }.runOnQueue(Queues.MAIN)
296
390
 
297
391
  AsyncFunction("addToEnd") { tracks: List<TrackRecord>, startFromId: String?, clearQueue: Boolean? ->
298
- checkController()
392
+ checkPlayer()
299
393
  val mediaItems = tracks.map { track ->
300
394
  track.toMediaItem(gson)
301
395
  }
302
- val player = controller ?: return@AsyncFunction
396
+ val p = player ?: return@AsyncFunction
303
397
  if (clearQueue == true) {
304
- player.clearMediaItems()
398
+ p.clearMediaItems()
305
399
  durationCache.clear()
306
- DownloadUtil.itemVolumeMap.clear()
307
400
  }
308
- val initialSize = player.mediaItemCount
309
- player.addMediaItems(mediaItems)
401
+ val initialSize = p.mediaItemCount
402
+ p.addMediaItems(mediaItems)
310
403
 
311
404
  if (!startFromId.isNullOrEmpty()) {
312
405
  val relativeIndex = tracks.indexOfFirst { it.id == startFromId }
@@ -314,50 +407,50 @@ class ExpoOrpheusModule : Module() {
314
407
  if (relativeIndex != -1) {
315
408
  val targetIndex = initialSize + relativeIndex
316
409
 
317
- player.seekTo(targetIndex, C.TIME_UNSET)
318
- player.prepare()
319
- player.play()
410
+ p.seekTo(targetIndex, C.TIME_UNSET)
411
+ p.prepare()
412
+ p.play()
320
413
 
321
414
  return@AsyncFunction
322
415
  }
323
416
  }
324
417
 
325
- if (player.playbackState == Player.STATE_IDLE) {
326
- player.prepare()
418
+ if (p.playbackState == Player.STATE_IDLE) {
419
+ p.prepare()
327
420
  }
328
421
  }.runOnQueue(Queues.MAIN)
329
422
 
330
423
  AsyncFunction("playNext") { track: TrackRecord ->
331
- checkController()
332
- val player = controller ?: return@AsyncFunction
424
+ checkPlayer()
425
+ val p = player ?: return@AsyncFunction
333
426
 
334
427
  val mediaItem = track.toMediaItem(gson)
335
- val targetIndex = player.currentMediaItemIndex + 1
428
+ val targetIndex = p.currentMediaItemIndex + 1
336
429
 
337
430
  var existingIndex = -1
338
- for (i in 0 until player.mediaItemCount) {
339
- if (player.getMediaItemAt(i).mediaId == track.id) {
431
+ for (i in 0 until p.mediaItemCount) {
432
+ if (p.getMediaItemAt(i).mediaId == track.id) {
340
433
  existingIndex = i
341
434
  break
342
435
  }
343
436
  }
344
437
 
345
438
  if (existingIndex != -1) {
346
- if (existingIndex == player.currentMediaItemIndex) {
439
+ if (existingIndex == p.currentMediaItemIndex) {
347
440
  return@AsyncFunction
348
441
  }
349
- val safeTargetIndex = targetIndex.coerceAtMost(player.mediaItemCount)
442
+ val safeTargetIndex = targetIndex.coerceAtMost(p.mediaItemCount)
350
443
 
351
- player.moveMediaItem(existingIndex, safeTargetIndex)
444
+ p.moveMediaItem(existingIndex, safeTargetIndex)
352
445
 
353
446
  } else {
354
- val safeTargetIndex = targetIndex.coerceAtMost(player.mediaItemCount)
447
+ val safeTargetIndex = targetIndex.coerceAtMost(p.mediaItemCount)
355
448
 
356
- player.addMediaItem(safeTargetIndex, mediaItem)
449
+ p.addMediaItem(safeTargetIndex, mediaItem)
357
450
  }
358
451
 
359
- if (player.playbackState == Player.STATE_IDLE) {
360
- player.prepare()
452
+ if (p.playbackState == Player.STATE_IDLE) {
453
+ p.prepare()
361
454
  }
362
455
  }.runOnQueue(Queues.MAIN)
363
456
 
@@ -554,139 +647,19 @@ class ExpoOrpheusModule : Module() {
554
647
  }
555
648
  }
556
649
 
557
- private fun setupListeners() {
558
- controller?.addListener(object : Player.Listener {
559
-
560
- /**
561
- * 核心:处理切歌、播放结束逻辑
562
- */
563
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
564
- val newId = mediaItem?.mediaId ?: ""
565
- Log.e("Orpheus", "onMediaItemTransition: $reason")
566
-
567
- sendEvent(
568
- "onTrackStarted", mapOf(
569
- "trackId" to newId,
570
- "reason" to reason
571
- )
572
- )
573
-
574
- lastMediaId = newId
575
- saveCurrentPosition()
576
- }
577
-
578
- override fun onTimelineChanged(timeline: Timeline, reason: Int) {
579
- val player = controller ?: return
580
- val currentItem = player.currentMediaItem ?: return
581
- val mediaId = currentItem.mediaId
582
-
583
- val duration = player.duration
584
- Log.d(
585
- "Orpheus",
586
- "onTimelineChanged: reason: $reason mediaId: $mediaId duration: $duration"
587
- )
588
-
589
- if (duration != C.TIME_UNSET && duration > 0) {
590
- durationCache[mediaId] = duration
591
- }
592
- }
593
-
594
- override fun onPositionDiscontinuity(
595
- oldPosition: Player.PositionInfo,
596
- newPosition: Player.PositionInfo,
597
- reason: Int
598
- ) {
599
- val isAutoTransition = reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION
600
- val isIndexChanged = oldPosition.mediaItemIndex != newPosition.mediaItemIndex
601
- val lastMediaItem = oldPosition.mediaItem ?: return
602
- val currentTime = System.currentTimeMillis()
603
- if ((currentTime - lastTrackFinishedAt) < 200) {
604
- return
605
- }
606
-
607
- Log.d(
608
- "Orpheus",
609
- "onPositionDiscontinuity: isAutoTransition:$isAutoTransition isIndexChanged: $isIndexChanged durationCache:$durationCache"
610
- )
611
-
612
- if (isAutoTransition || isIndexChanged) {
613
-
614
- val duration = durationCache[lastMediaItem.mediaId] ?: return
615
- lastTrackFinishedAt = currentTime
616
-
617
- sendEvent(
618
- "onTrackFinished", mapOf(
619
- "trackId" to lastMediaItem.mediaId,
620
- "finalPosition" to oldPosition.positionMs / 1000.0,
621
- "duration" to duration / 1000.0,
622
- )
623
- )
624
- }
625
- }
626
-
627
- /**
628
- * 处理播放状态改变
629
- */
630
- override fun onPlaybackStateChanged(state: Int) {
631
- // state: 1=IDLE, 2=BUFFERING, 3=READY, 4=ENDED
632
- sendEvent(
633
- "onPlaybackStateChanged", mapOf(
634
- "state" to state
635
- )
636
- )
637
-
638
- updateProgressRunnerState()
639
- }
640
-
641
- /**
642
- * 处理播放/暂停状态
643
- */
644
- override fun onIsPlayingChanged(isPlaying: Boolean) {
645
- sendEvent(
646
- "onIsPlayingChanged", mapOf(
647
- "status" to isPlaying
648
- )
649
- )
650
- updateProgressRunnerState()
651
- }
652
-
653
- /**
654
- * 处理错误
655
- */
656
- override fun onPlayerError(error: PlaybackException) {
657
- sendEvent(
658
- "onPlayerError", mapOf(
659
- "code" to error.errorCode.toString(),
660
- "message" to (error.message ?: "Unknown Error")
661
- )
662
- )
663
- }
664
-
665
- override fun onRepeatModeChanged(repeatMode: Int) {
666
- super.onRepeatModeChanged(repeatMode)
667
- Storage.saveRepeatMode(repeatMode)
668
- }
669
-
670
- override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
671
- super.onShuffleModeEnabledChanged(shuffleModeEnabled)
672
- Storage.saveShuffleMode(shuffleModeEnabled)
673
- }
674
- })
675
- }
676
-
677
650
  private val progressSendEventRunnable = object : Runnable {
678
651
  override fun run() {
679
- val player = controller ?: return
652
+ val p = player ?: return
680
653
 
681
- if (player.isPlaying) {
682
- val currentMs = player.currentPosition
683
- val durationMs = player.duration
654
+ if (p.isPlaying) {
655
+ val currentMs = p.currentPosition
656
+ val durationMs = p.duration
684
657
 
685
658
  sendEvent(
686
659
  "onPositionUpdate", mapOf(
687
660
  "position" to currentMs / 1000.0,
688
661
  "duration" to if (durationMs == C.TIME_UNSET) 0.0 else durationMs / 1000.0,
689
- "buffered" to player.bufferedPosition / 1000.0
662
+ "buffered" to p.bufferedPosition / 1000.0
690
663
  )
691
664
  )
692
665
  }
@@ -703,9 +676,9 @@ class ExpoOrpheusModule : Module() {
703
676
  }
704
677
 
705
678
  private fun updateProgressRunnerState() {
706
- val player = controller
679
+ val p = player
707
680
  // 如果正在播放且状态是 READY,则开始轮询
708
- if (player != null && player.isPlaying && player.playbackState == Player.STATE_READY) {
681
+ if (p != null && p.isPlaying && p.playbackState == Player.STATE_READY) {
709
682
  mainHandler.removeCallbacks(progressSendEventRunnable)
710
683
  mainHandler.removeCallbacks(progressSaveRunnable)
711
684
  mainHandler.post(progressSaveRunnable)
@@ -739,17 +712,17 @@ class ExpoOrpheusModule : Module() {
739
712
  }
740
713
 
741
714
  private fun saveCurrentPosition() {
742
- val player = controller ?: return
743
- if (player.playbackState != Player.STATE_IDLE) {
744
- Storage.savePosition(
745
- player.currentMediaItemIndex,
746
- player.currentPosition
715
+ val p = player ?: return
716
+ if (p.playbackState != Player.STATE_IDLE) {
717
+ GeneralStorage.savePosition(
718
+ p.currentMediaItemIndex,
719
+ p.currentPosition
747
720
  )
748
721
  }
749
722
  }
750
723
 
751
- private fun checkController() {
752
- if (controller == null) {
724
+ private fun checkPlayer() {
725
+ if (player == null) {
753
726
  throw ControllerNotInitializedException()
754
727
  }
755
728
  }
@@ -2,27 +2,24 @@ package expo.modules.orpheus
2
2
 
3
3
  import android.app.PendingIntent
4
4
  import android.content.Intent
5
- import android.os.Bundle
5
+ import android.util.Log
6
6
  import androidx.annotation.OptIn
7
7
  import androidx.core.net.toUri
8
8
  import androidx.media3.common.AudioAttributes
9
9
  import androidx.media3.common.C
10
10
  import androidx.media3.common.Player
11
11
  import androidx.media3.common.Timeline
12
- import androidx.media3.common.util.Log
13
12
  import androidx.media3.common.util.UnstableApi
14
13
  import androidx.media3.exoplayer.ExoPlayer
15
14
  import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
16
15
  import androidx.media3.session.MediaLibraryService
17
16
  import androidx.media3.session.MediaSession
18
- import androidx.media3.session.SessionCommand
19
- import androidx.media3.session.SessionResult
20
17
  import com.google.common.util.concurrent.Futures
21
18
  import com.google.common.util.concurrent.ListenableFuture
22
- import expo.modules.orpheus.bilibili.VolumeData
23
19
  import expo.modules.orpheus.utils.DownloadUtil
24
20
  import expo.modules.orpheus.utils.SleepTimeController
25
- import expo.modules.orpheus.utils.Storage
21
+ import expo.modules.orpheus.utils.GeneralStorage
22
+ import expo.modules.orpheus.utils.LoudnessStorage
26
23
  import expo.modules.orpheus.utils.calculateLoudnessGain
27
24
  import expo.modules.orpheus.utils.fadeInTo
28
25
  import kotlinx.coroutines.Job
@@ -33,17 +30,40 @@ import kotlin.math.abs
33
30
 
34
31
  class OrpheusMusicService : MediaLibraryService() {
35
32
 
36
- private var player: ExoPlayer? = null
33
+ var player: ExoPlayer? = null
37
34
  private var mediaSession: MediaLibrarySession? = null
38
35
  private var sleepTimerManager: SleepTimeController? = null
39
36
  private var volumeFadeJob: Job? = null
40
37
  private var scope = MainScope()
41
38
 
39
+ companion object {
40
+ var instance: OrpheusMusicService? = null
41
+ private set(value) {
42
+ field = value
43
+ if (value != null) {
44
+ listeners.forEach { it(value) }
45
+ }
46
+ }
47
+
48
+ private val listeners = mutableListOf<(OrpheusMusicService) -> Unit>()
49
+
50
+ fun addOnServiceReadyListener(listener: (OrpheusMusicService) -> Unit) {
51
+ instance?.let { listener(it) }
52
+ listeners.add(listener)
53
+ }
54
+
55
+ fun removeOnServiceReadyListener(listener: (OrpheusMusicService) -> Unit) {
56
+ listeners.remove(listener)
57
+ }
58
+ }
59
+
42
60
  @OptIn(UnstableApi::class)
43
61
  override fun onCreate() {
44
62
  super.onCreate()
63
+ instance = this
45
64
 
46
- Storage.initialize(this)
65
+ GeneralStorage.initialize(this)
66
+ LoudnessStorage.initialize(this)
47
67
 
48
68
 
49
69
  val dataSourceFactory = DownloadUtil.getPlayerDataSourceFactory(this)
@@ -93,18 +113,8 @@ class OrpheusMusicService : MediaLibraryService() {
93
113
  .setSessionActivity(contentIntent)
94
114
  .build()
95
115
 
96
- restorePlayerState(Storage.isRestoreEnabled())
116
+ restorePlayerState(GeneralStorage.isRestoreEnabled())
97
117
  sleepTimerManager = SleepTimeController(player!!)
98
-
99
- // 当有新的响度数据时,如果是当前这首歌的就直接应用,否则是预加载,等待 onMediaItemTransition 处理
100
- scope.launch {
101
- DownloadUtil.volumeResolvedEvent.collect { (uri, volumeData) ->
102
- val currentUri = player?.currentMediaItem?.localConfiguration?.uri?.toString()
103
- if (currentUri == uri) {
104
- applyVolumeForCurrentItem(volumeData)
105
- }
106
- }
107
- }
108
118
  }
109
119
 
110
120
  override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? {
@@ -113,6 +123,7 @@ class OrpheusMusicService : MediaLibraryService() {
113
123
 
114
124
  override fun onDestroy() {
115
125
  scope.cancel()
126
+ instance = null
116
127
 
117
128
  mediaSession?.run {
118
129
  player.release()
@@ -122,71 +133,30 @@ class OrpheusMusicService : MediaLibraryService() {
122
133
  super.onDestroy()
123
134
  }
124
135
 
136
+ fun startSleepTimer(durationMs: Long) {
137
+ sleepTimerManager?.start(durationMs)
138
+ }
139
+
140
+ fun cancelSleepTimer() {
141
+ sleepTimerManager?.cancel()
142
+ }
143
+
144
+ fun getSleepTimerRemaining(): Long? {
145
+ return sleepTimerManager?.getStopTimeMs()
146
+ }
147
+
125
148
  var callback: MediaLibrarySession.Callback = @UnstableApi
126
149
  object : MediaLibrarySession.Callback {
127
- private val customCommands = listOf(
128
- SessionCommand(CustomCommands.CMD_START_TIMER, Bundle.EMPTY),
129
- SessionCommand(CustomCommands.CMD_CANCEL_TIMER, Bundle.EMPTY),
130
- SessionCommand(CustomCommands.CMD_GET_REMAINING, Bundle.EMPTY)
131
- )
132
150
 
133
151
  @OptIn(UnstableApi::class)
134
152
  override fun onConnect(
135
153
  session: MediaSession,
136
154
  controller: MediaSession.ControllerInfo
137
155
  ): MediaSession.ConnectionResult {
138
- val availableCommandsBuilder =
139
- MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
140
-
141
- for (command in customCommands) {
142
- availableCommandsBuilder.add(command)
143
- }
144
-
145
156
  return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
146
- .setAvailableSessionCommands(availableCommandsBuilder.build())
147
157
  .build()
148
158
  }
149
159
 
150
- override fun onCustomCommand(
151
- session: MediaSession,
152
- controller: MediaSession.ControllerInfo,
153
- customCommand: SessionCommand,
154
- args: Bundle
155
- ): ListenableFuture<SessionResult> {
156
-
157
- Log.d("Orpheus", "onCustomCommand: ${customCommand.customAction}")
158
-
159
- when (customCommand.customAction) {
160
- CustomCommands.CMD_START_TIMER -> {
161
- val durationMs = args.getLong(CustomCommands.KEY_DURATION)
162
- sleepTimerManager?.start(durationMs)
163
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
164
- }
165
-
166
- CustomCommands.CMD_CANCEL_TIMER -> {
167
- sleepTimerManager?.cancel()
168
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
169
- }
170
-
171
- CustomCommands.CMD_GET_REMAINING -> {
172
- val stopTime = sleepTimerManager?.getStopTimeMs()
173
- val resultBundle = Bundle()
174
- if (stopTime != null) {
175
- resultBundle.putLong(CustomCommands.KEY_STOP_TIME, stopTime)
176
- }
177
-
178
- return Futures.immediateFuture(
179
- SessionResult(
180
- SessionResult.RESULT_SUCCESS,
181
- resultBundle
182
- )
183
- )
184
- }
185
- }
186
-
187
- return super.onCustomCommand(session, controller, customCommand, args)
188
- }
189
-
190
160
  /**
191
161
  * 修复 UnsupportedOperationException 的关键!
192
162
  * 当系统尝试恢复播放(比如从“最近播放”卡片点击)时触发。
@@ -209,15 +179,15 @@ class OrpheusMusicService : MediaLibraryService() {
209
179
  private fun restorePlayerState(restorePosition: Boolean) {
210
180
  val player = player ?: return
211
181
 
212
- val restoredItems = Storage.restoreQueue()
182
+ val restoredItems = GeneralStorage.restoreQueue()
213
183
 
214
184
  if (restoredItems.isNotEmpty()) {
215
185
  player.setMediaItems(restoredItems)
216
186
 
217
- val savedIndex = Storage.getSavedIndex()
218
- val savedPosition = Storage.getSavedPosition()
219
- val savedShuffleMode = Storage.getShuffleMode()
220
- val savedRepeatMode = Storage.getRepeatMode()
187
+ val savedIndex = GeneralStorage.getSavedIndex()
188
+ val savedPosition = GeneralStorage.getSavedPosition()
189
+ val savedShuffleMode = GeneralStorage.getShuffleMode()
190
+ val savedRepeatMode = GeneralStorage.getRepeatMode()
221
191
 
222
192
  if (savedIndex >= 0 && savedIndex < restoredItems.size) {
223
193
  player.seekTo(savedIndex, if (restorePosition) savedPosition else C.TIME_UNSET)
@@ -235,7 +205,6 @@ class OrpheusMusicService : MediaLibraryService() {
235
205
 
236
206
  private fun setupListeners() {
237
207
  player?.addListener(object : Player.Listener {
238
-
239
208
  @OptIn(UnstableApi::class)
240
209
  override fun onMediaItemTransition(
241
210
  mediaItem: androidx.media3.common.MediaItem?,
@@ -244,10 +213,8 @@ class OrpheusMusicService : MediaLibraryService() {
244
213
  saveCurrentQueue()
245
214
  val uri = mediaItem?.localConfiguration?.uri?.toString() ?: return
246
215
 
247
- val volumeData = DownloadUtil.itemVolumeMap[uri]
248
- if (volumeData != null) {
249
- applyVolumeForCurrentItem(volumeData)
250
- }
216
+ val volumeData = LoudnessStorage.getLoudnessData(uri)
217
+ applyVolumeForCurrentItem(volumeData)
251
218
  }
252
219
 
253
220
  override fun onTimelineChanged(timeline: Timeline, reason: Int) {
@@ -260,21 +227,20 @@ class OrpheusMusicService : MediaLibraryService() {
260
227
  val player = player ?: return
261
228
  val queue = List(player.mediaItemCount) { i -> player.getMediaItemAt(i) }
262
229
  if (queue.isNotEmpty()) {
263
- Storage.saveQueue(queue)
230
+ GeneralStorage.saveQueue(queue)
264
231
  }
265
232
  }
266
233
 
267
234
  @OptIn(UnstableApi::class)
268
- private fun applyVolumeForCurrentItem(volumeData: VolumeData) {
235
+ private fun applyVolumeForCurrentItem(measuredI: Double) {
236
+ Log.d("LoudnessNormalization", "measuredI: $measuredI")
269
237
  val player = player ?: return
270
238
  volumeFadeJob?.cancel()
271
- val isLoudnessNormalizationEnabled = Storage.isLoudnessNormalizationEnabled()
239
+ val isLoudnessNormalizationEnabled = GeneralStorage.isLoudnessNormalizationEnabled()
272
240
  if (!isLoudnessNormalizationEnabled) return
273
241
  val gain = run {
274
- val measured = volumeData.measuredI
275
- val target = volumeData.targetI
276
-
277
- if (measured == 0.0) 1.0f else calculateLoudnessGain(measured, target)
242
+ val target = -14.0 // bilibili 的这个值似乎是固定的
243
+ if (measuredI == 0.0) 1.0f else calculateLoudnessGain(measuredI, target)
278
244
  }
279
245
 
280
246
  val targetVol = 1.0f * gain
@@ -1,6 +1,7 @@
1
1
  package expo.modules.orpheus.utils
2
2
 
3
3
  import android.content.Context
4
+ import android.util.Log
4
5
  import androidx.core.net.toUri
5
6
  import androidx.media3.common.util.UnstableApi
6
7
  import androidx.media3.database.StandaloneDatabaseProvider
@@ -35,15 +36,6 @@ object DownloadUtil {
35
36
 
36
37
  private var downloadNotificationHelper: DownloadNotificationHelper? = null
37
38
 
38
- var itemVolumeMap: MutableMap<String, VolumeData> = mutableMapOf()
39
-
40
- private val _volumeResolvedEvent = MutableSharedFlow<Pair<String, VolumeData>>(
41
- replay = 0,
42
- extraBufferCapacity = 1,
43
- onBufferOverflow = BufferOverflow.DROP_OLDEST
44
- )
45
- val volumeResolvedEvent = _volumeResolvedEvent.asSharedFlow()
46
-
47
39
  @Synchronized
48
40
  fun getDownloadManager(context: Context): DownloadManager {
49
41
  if (downloadManager == null) {
@@ -68,10 +60,16 @@ object DownloadUtil {
68
60
  val upstreamFactory = getUpstreamFactory()
69
61
 
70
62
  val downloadCache = DownloadCache.getStableCache(context)
63
+ val lruCache = DownloadCache.getLruCache(context)
64
+
65
+ val cacheFactory = CacheDataSource.Factory()
66
+ .setCache(lruCache)
67
+ .setUpstreamDataSourceFactory(upstreamFactory)
68
+ .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
71
69
 
72
70
  val downloadFactory = CacheDataSource.Factory()
73
71
  .setCache(downloadCache)
74
- .setUpstreamDataSourceFactory(upstreamFactory)
72
+ .setUpstreamDataSourceFactory(cacheFactory)
75
73
  .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
76
74
  .setCacheWriteDataSinkFactory(null)
77
75
 
@@ -109,10 +107,6 @@ object DownloadUtil {
109
107
  return downloadNotificationHelper!!
110
108
  }
111
109
 
112
- suspend fun emitVolumeEvent(uri: String, data: VolumeData) {
113
- _volumeResolvedEvent.emit(uri to data)
114
- }
115
-
116
110
  private class BilibiliResolver :
117
111
  ResolvingDataSource.Resolver {
118
112
  override fun resolveDataSpec(dataSpec: DataSpec): DataSpec {
@@ -132,10 +126,8 @@ object DownloadUtil {
132
126
  )
133
127
  // 在这里保存响度均衡数据,并且直接发一个事件,在 OrpheusMusicService 监听
134
128
  if (volume !== null) {
135
- itemVolumeMap[dataSpec.uri.toString()] = volume
136
- CoroutineScope(Dispatchers.IO).launch {
137
- emitVolumeEvent(dataSpec.uri.toString(), volume)
138
- }
129
+ Log.d("LoudnessNormalization", "uri: ${dataSpec.uri}, measuredI: ${volume.measuredI}")
130
+ LoudnessStorage.setLoudnessData(dataSpec.uri.toString(), volume.measuredI)
139
131
  }
140
132
 
141
133
  val headers = HashMap<String, String>()
@@ -11,7 +11,7 @@ import com.tencent.mmkv.MMKV
11
11
  import expo.modules.orpheus.models.TrackRecord
12
12
 
13
13
  @OptIn(UnstableApi::class)
14
- object Storage {
14
+ object GeneralStorage {
15
15
  private var kv: MMKV? = null
16
16
  private val gson = Gson()
17
17
  private const val KEY_RESTORE_POSITION_ENABLED = "config_restore_position_enabled"
@@ -0,0 +1,40 @@
1
+ package expo.modules.orpheus.utils
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.tencent.mmkv.MMKV
6
+
7
+ object LoudnessStorage {
8
+ private var kv: MMKV? = null
9
+
10
+
11
+ @Synchronized
12
+ fun initialize(context: Context) {
13
+ if (kv == null) {
14
+ MMKV.initialize(context)
15
+ kv = MMKV.mmkvWithID("loudness_normalization_store")
16
+ }
17
+ }
18
+
19
+ private val safeKv: MMKV
20
+ get() = kv ?: throw IllegalStateException("LoudnessStorage not initialized")
21
+
22
+ fun setLoudnessData(key: String, measuredI: Double) {
23
+ try {
24
+ Log.d("LoudnessNormalization", "setLoudnessData: $key, $measuredI")
25
+ safeKv.encode(key, measuredI)
26
+ } catch (e: Exception) {
27
+ Log.e("LoudnessStorage", "Failed to set loudness data", e)
28
+ }
29
+ }
30
+
31
+ fun getLoudnessData(key: String): Double {
32
+ try {
33
+ Log.d("LoudnessNormalization", "getLoudnessData: $key")
34
+ return safeKv.decodeDouble(key)
35
+ } catch (e: Exception) {
36
+ Log.e("LoudnessStorage", "Failed to get loudness data", e)
37
+ return 0.0
38
+ }
39
+ }
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roitium/expo-orpheus",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "A player for bbplayer",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",