@rntp/player 5.0.0-beta.3 → 5.0.0-beta.5

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/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +107 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
  6. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  14. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  15. package/android/src/test/resources/robolectric.properties +1 -0
  16. package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
  17. package/ios/TrackPlayer.swift +46 -101
  18. package/ios/TrackPlayerBridge.mm +2 -0
  19. package/ios/player/AVPlayerEngine.swift +46 -32
  20. package/ios/player/AudioCache.swift +34 -0
  21. package/ios/player/AudioPlayer.swift +36 -21
  22. package/ios/player/CacheProxyServer.swift +429 -0
  23. package/ios/player/DownloadCoordinator.swift +242 -0
  24. package/ios/player/Preloader.swift +21 -90
  25. package/ios/player/SleepTimerController.swift +147 -0
  26. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  27. package/ios/tests/AudioPlayerTests.swift +6 -0
  28. package/ios/tests/CacheProxyServerTests.swift +403 -0
  29. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  30. package/ios/tests/LocalAudioServer.swift +171 -0
  31. package/ios/tests/MockPlayerEngine.swift +1 -0
  32. package/ios/tests/QueueManagerTests.swift +6 -0
  33. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  34. package/ios/tests/SleepTimerTests.swift +70 -0
  35. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  36. package/lib/commonjs/audio.js +39 -4
  37. package/lib/commonjs/audio.js.map +1 -1
  38. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  39. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  40. package/lib/module/NativeTrackPlayer.js.map +1 -1
  41. package/lib/module/audio.js +37 -4
  42. package/lib/module/audio.js.map +1 -1
  43. package/lib/module/interfaces/PlayerConfig.js +1 -1
  44. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  45. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  46. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  47. package/lib/typescript/src/audio.d.ts +16 -4
  48. package/lib/typescript/src/audio.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
  50. package/lib/typescript/src/interfaces/BrowseTree.d.ts.map +1 -1
  51. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  52. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  53. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
  54. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  55. package/package.json +4 -1
  56. package/src/NativeTrackPlayer.ts +4 -0
  57. package/src/audio.ts +37 -4
  58. package/src/interfaces/BrowseTree.ts +40 -5
  59. package/src/interfaces/MediaItem.ts +4 -1
  60. package/src/interfaces/PlayerConfig.ts +22 -3
  61. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -107,6 +107,13 @@ dependencies {
107
107
  implementation "androidx.media3:media3-session:1.9.2"
108
108
  implementation "androidx.media3:media3-cast:1.9.2"
109
109
  implementation "androidx.mediarouter:mediarouter:1.7.0"
110
+
111
+ // Testing
112
+ testImplementation "junit:junit:4.13.2"
113
+ testImplementation "org.robolectric:robolectric:4.11.1"
114
+ testImplementation "io.mockk:mockk:1.13.8"
115
+ testImplementation "androidx.test:core:1.5.0"
116
+ testImplementation "androidx.media3:media3-test-utils:1.9.2"
110
117
  }
111
118
 
112
119
  react {
@@ -0,0 +1,128 @@
1
+ /*
2
+ * Copyright (c) Double Symmetry GmbH
3
+ * Commercial use requires a license. See https://rntp.dev/pricing
4
+ */
5
+
6
+ package com.doublesymmetry.trackplayer
7
+
8
+ import androidx.media3.common.Player
9
+
10
+ class SleepTimerController(private val player: Player) {
11
+
12
+ var onTriggered: ((type: String) -> Unit)? = null
13
+ var onStateChanged: (() -> Unit)? = null
14
+
15
+ var sleepTimerType: String? = null
16
+ private set
17
+ var sleepTimerRemainingSeconds: Double = 0.0
18
+ private set
19
+ var sleepTimerFadeOutSeconds: Double = 0.0
20
+ private set
21
+ var sleepTimerTargetIndex: Int? = null
22
+ private set
23
+ var sleepTimerPreviousIndex: Int? = null
24
+ private set
25
+ private var sleepTimerPreFadeVolume: Float? = null
26
+
27
+ fun sleepAfterTime(seconds: Double, fadeOutSeconds: Double) {
28
+ cancelInternal(restoreVolume = true)
29
+ sleepTimerType = "time"
30
+ sleepTimerRemainingSeconds = seconds
31
+ sleepTimerFadeOutSeconds = fadeOutSeconds.coerceAtMost(seconds)
32
+ onStateChanged?.invoke()
33
+
34
+ if (seconds <= 0) {
35
+ player.pause()
36
+ onTriggered?.invoke("time")
37
+ cancelInternal(restoreVolume = true)
38
+ return
39
+ }
40
+ }
41
+
42
+ fun sleepAfterMediaItemAtIndex(index: Int) {
43
+ cancelInternal(restoreVolume = true)
44
+ sleepTimerType = "mediaItem"
45
+ sleepTimerTargetIndex = index
46
+ sleepTimerPreviousIndex = player.currentMediaItemIndex
47
+ onStateChanged?.invoke()
48
+ }
49
+
50
+ fun cancel() {
51
+ cancelInternal(restoreVolume = true)
52
+ }
53
+
54
+ fun getState(): Map<String, Any>? {
55
+ val type = sleepTimerType ?: return null
56
+ return if (type == "time") {
57
+ mapOf(
58
+ "type" to "time",
59
+ "remainingSeconds" to sleepTimerRemainingSeconds,
60
+ "fadeOutSeconds" to sleepTimerFadeOutSeconds
61
+ )
62
+ } else {
63
+ mapOf(
64
+ "type" to "mediaItem",
65
+ "index" to (sleepTimerTargetIndex ?: 0)
66
+ )
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Call when a media item transition occurs.
72
+ * Returns true if the timer fired.
73
+ */
74
+ fun handleItemTransition(newIndex: Int): Boolean {
75
+ if (sleepTimerType == "mediaItem") {
76
+ val targetIndex = sleepTimerTargetIndex
77
+ if (targetIndex != null && sleepTimerPreviousIndex == targetIndex && newIndex != targetIndex) {
78
+ onTriggered?.invoke("mediaItem")
79
+ cancelInternal(restoreVolume = false)
80
+ sleepTimerPreviousIndex = newIndex
81
+ return true
82
+ }
83
+ }
84
+ sleepTimerPreviousIndex = newIndex
85
+ return false
86
+ }
87
+
88
+ /**
89
+ * Simulate one wall-clock second passing.
90
+ * In production, called by a Timer on the main thread.
91
+ * Exposed for testing.
92
+ */
93
+ fun tick() {
94
+ if (sleepTimerType != "time") return
95
+
96
+ sleepTimerRemainingSeconds -= 1.0
97
+ onStateChanged?.invoke()
98
+
99
+ if (sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds) {
100
+ if (sleepTimerPreFadeVolume == null) {
101
+ sleepTimerPreFadeVolume = player.volume
102
+ }
103
+ val progress = maxOf(0.0, sleepTimerRemainingSeconds / sleepTimerFadeOutSeconds).toFloat()
104
+ player.volume = (sleepTimerPreFadeVolume ?: 1f) * progress
105
+ }
106
+
107
+ if (sleepTimerRemainingSeconds <= 0) {
108
+ sleepTimerRemainingSeconds = 0.0
109
+ player.pause()
110
+ onTriggered?.invoke("time")
111
+ cancelInternal(restoreVolume = true)
112
+ return
113
+ }
114
+ }
115
+
116
+ fun cancelInternal(restoreVolume: Boolean) {
117
+ if (restoreVolume) {
118
+ sleepTimerPreFadeVolume?.let { player.volume = it }
119
+ }
120
+ sleepTimerPreFadeVolume = null
121
+ sleepTimerType = null
122
+ sleepTimerRemainingSeconds = 0.0
123
+ sleepTimerFadeOutSeconds = 0.0
124
+ sleepTimerTargetIndex = null
125
+ sleepTimerPreviousIndex = null
126
+ onStateChanged?.invoke()
127
+ }
128
+ }
@@ -562,6 +562,46 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
562
562
 
563
563
  // endregion
564
564
 
565
+ // region Preloading
566
+
567
+ @ReactMethod
568
+ override fun preload(item: ReadableMap, duration: Double) {
569
+ val url = extractUrl(item) ?: return
570
+ controller.run { mc ->
571
+ val args = Bundle().apply { putString("uri", url) }
572
+ mc?.sendCustomCommand(
573
+ SessionCommand(TrackPlayerPlaybackService.COMMAND_PRELOAD, Bundle.EMPTY),
574
+ args
575
+ )
576
+ }
577
+ }
578
+
579
+ @ReactMethod
580
+ override fun cancelPreload(item: ReadableMap) {
581
+ val url = extractUrl(item) ?: return
582
+ controller.run { mc ->
583
+ val args = Bundle().apply { putString("uri", url) }
584
+ mc?.sendCustomCommand(
585
+ SessionCommand(TrackPlayerPlaybackService.COMMAND_CANCEL_PRELOAD, Bundle.EMPTY),
586
+ args
587
+ )
588
+ }
589
+ }
590
+
591
+ private fun extractUrl(item: ReadableMap): String? {
592
+ if (item.hasKey("url")) {
593
+ val urlValue = item.getDynamic("url")
594
+ return when (urlValue.type) {
595
+ com.facebook.react.bridge.ReadableType.String -> urlValue.asString()
596
+ com.facebook.react.bridge.ReadableType.Map -> item.getMap("url")?.getString("uri")
597
+ else -> null
598
+ }
599
+ }
600
+ return null
601
+ }
602
+
603
+ // endregion
604
+
565
605
  // region Destroy
566
606
 
567
607
  @ReactMethod
@@ -22,8 +22,10 @@ import androidx.media3.common.MediaMetadata
22
22
  import androidx.media3.common.Player
23
23
  import androidx.media3.common.util.UnstableApi
24
24
  import androidx.media3.datasource.DataSource
25
+ import androidx.media3.datasource.DataSpec
25
26
  import androidx.media3.datasource.DefaultHttpDataSource
26
27
  import androidx.media3.datasource.cache.CacheDataSource
28
+ import androidx.media3.datasource.cache.CacheWriter
27
29
  import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
28
30
  import androidx.media3.datasource.cache.SimpleCache
29
31
  import androidx.media3.exoplayer.ExoPlayer
@@ -69,13 +71,11 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
69
71
  private var progressSyncTimer: java.util.Timer? = null
70
72
  private var progressSyncExecutor: java.util.concurrent.ExecutorService? = null
71
73
 
72
- private var sleepTimerType: String? = null // "time" or "mediaItem"
73
- private var sleepTimerRemainingSeconds: Double = 0.0
74
- private var sleepTimerFadeOutSeconds: Double = 0.0
75
- private var sleepTimerTargetIndex: Int? = null
74
+ private var sleepTimerController: SleepTimerController? = null
76
75
  private var sleepTimer: java.util.Timer? = null
77
- private var sleepTimerPreFadeVolume: Float? = null
78
- private var sleepTimerPreviousIndex: Int? = null
76
+
77
+ private val activePreloads = mutableMapOf<String, java.util.concurrent.Future<*>>()
78
+ private val preloadExecutor = java.util.concurrent.Executors.newFixedThreadPool(2)
79
79
 
80
80
  @OptIn(UnstableApi::class)
81
81
  override fun onCreate() {
@@ -124,6 +124,12 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
124
124
  .build()
125
125
  exoPlayer = built
126
126
 
127
+ if (config.preloadWindow > 0 && config.cacheMaxSizeBytes != null) {
128
+ built.preloadConfiguration = ExoPlayer.PreloadConfiguration(
129
+ /* targetPreloadDurationUs= */ Long.MAX_VALUE // Full file for auto preloading
130
+ )
131
+ }
132
+
127
133
  // If Cast is enabled, wrap ExoPlayer inside CastPlayer — Media3 automatically
128
134
  // routes playback to the Chromecast when a session is active, and back to
129
135
  // ExoPlayer when it ends. No manual player swapping needed.
@@ -141,6 +147,18 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
141
147
  } ?: built.also { android.util.Log.d("TrackPlayer", "Cast disabled, using ExoPlayer only") }
142
148
 
143
149
  this.activePlayer = activePlayer
150
+
151
+ val controller = SleepTimerController(activePlayer)
152
+ sleepTimerController = controller
153
+ controller.onTriggered = { type ->
154
+ this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent(type))
155
+ if (type == "mediaItem") {
156
+ // Defer pause to next run loop — calling during transition gets overridden
157
+ Handler(Looper.getMainLooper()).post { activePlayer.pause() }
158
+ }
159
+ }
160
+ controller.onStateChanged = { persistSleepTimerState() }
161
+
144
162
  activePlayer.addListener(object : Player.Listener {
145
163
  override fun onIsPlayingChanged(isPlaying: Boolean) {
146
164
  if (isPlaying) startProgressSyncTimer() else stopProgressSyncTimer(fireFinalTick = true)
@@ -148,21 +166,12 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
148
166
 
149
167
  override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
150
168
  val currentIndex = activePlayer?.currentMediaItemIndex ?: 0
151
- if (sleepTimerType == "mediaItem") {
152
- val targetIndex = sleepTimerTargetIndex
153
- if (targetIndex != null && sleepTimerPreviousIndex == targetIndex && currentIndex != targetIndex) {
154
- this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent("mediaItem"))
155
- cancelSleepTimerInternal(restoreVolume = false)
156
- // Defer pause to next run loop — calling during transition gets overridden
157
- Handler(Looper.getMainLooper()).post { activePlayer?.pause() }
158
- }
159
- }
160
- sleepTimerPreviousIndex = currentIndex
169
+ sleepTimerController?.handleItemTransition(currentIndex)
161
170
  }
162
171
 
163
172
  override fun onTimelineChanged(timeline: androidx.media3.common.Timeline, reason: Int) {
164
- if (sleepTimerType == "mediaItem" && (activePlayer?.mediaItemCount ?: 0) == 0) {
165
- cancelSleepTimerInternal(restoreVolume = false)
173
+ if (sleepTimerController?.sleepTimerType == "mediaItem" && (activePlayer?.mediaItemCount ?: 0) == 0) {
174
+ sleepTimerController?.cancelInternal(restoreVolume = false)
166
175
  }
167
176
  }
168
177
  })
@@ -304,7 +313,13 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
304
313
  simpleCache?.release()
305
314
  simpleCache = null
306
315
  sharedCache = null
307
- cancelSleepTimerInternal(restoreVolume = false)
316
+ sleepTimerController?.cancelInternal(restoreVolume = false)
317
+ sleepTimerController = null
318
+ sleepTimer?.cancel()
319
+ sleepTimer = null
320
+ activePreloads.values.forEach { it.cancel(true) }
321
+ activePreloads.clear()
322
+ preloadExecutor.shutdownNow()
308
323
  stopProgressSyncTimer(fireFinalTick = false)
309
324
  progressSyncExecutor?.shutdown()
310
325
  progressSyncExecutor = null
@@ -388,32 +403,29 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
388
403
  COMMAND_SLEEP_AFTER_TIME -> {
389
404
  val seconds = args.getDouble("seconds")
390
405
  val fadeOutSeconds = args.getDouble("fadeOutSeconds")
391
- cancelSleepTimerInternal(restoreVolume = true)
392
- sleepTimerType = "time"
393
- sleepTimerRemainingSeconds = seconds
394
- sleepTimerFadeOutSeconds = fadeOutSeconds.coerceAtMost(seconds)
395
- persistSleepTimerState()
396
-
397
- if (seconds <= 0) {
398
- activePlayer?.pause()
399
- this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent("time"))
400
- cancelSleepTimerInternal(restoreVolume = true)
401
- } else {
402
- startSleepCountdownTimer()
403
- }
406
+ sleepTimerController?.sleepAfterTime(seconds, fadeOutSeconds)
407
+ if (seconds > 0) startSleepCountdownTimer()
404
408
  return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
405
409
  }
406
410
  COMMAND_SLEEP_AFTER_MEDIA_ITEM -> {
407
411
  val index = args.getInt("index")
408
- cancelSleepTimerInternal(restoreVolume = true)
409
- sleepTimerType = "mediaItem"
410
- sleepTimerTargetIndex = index
411
- sleepTimerPreviousIndex = activePlayer?.currentMediaItemIndex
412
- persistSleepTimerState()
412
+ sleepTimerController?.sleepAfterMediaItemAtIndex(index)
413
413
  return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
414
414
  }
415
415
  COMMAND_CANCEL_SLEEP_TIMER -> {
416
- cancelSleepTimerInternal(restoreVolume = true)
416
+ sleepTimerController?.cancel()
417
+ sleepTimer?.cancel()
418
+ sleepTimer = null
419
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
420
+ }
421
+ COMMAND_PRELOAD -> {
422
+ val uri = args.getString("uri") ?: return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_UNKNOWN))
423
+ preloadUri(uri)
424
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
425
+ }
426
+ COMMAND_CANCEL_PRELOAD -> {
427
+ val uri = args.getString("uri") ?: return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_UNKNOWN))
428
+ cancelPreloadUri(uri)
417
429
  return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
418
430
  }
419
431
  }
@@ -465,12 +477,20 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
465
477
  return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.copyOf(categoryItems), params))
466
478
  }
467
479
 
480
+ // Check if it's a category
468
481
  val category = browseTree.findCategory(parentId)
469
482
  if (category != null) {
470
483
  val mediaItems = category.items.map { it.toMediaItem() }
471
484
  return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.copyOf(mediaItems), params))
472
485
  }
473
486
 
487
+ // Check if it's a browsable item with children
488
+ val browseItem = browseTree.findItem(parentId)
489
+ if (browseItem != null && browseItem.children != null) {
490
+ val mediaItems = browseItem.children.map { it.toMediaItem() }
491
+ return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.copyOf(mediaItems), params))
492
+ }
493
+
474
494
  return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
475
495
  }
476
496
 
@@ -539,6 +559,8 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
539
559
  .add(SessionCommand(COMMAND_SLEEP_AFTER_TIME, Bundle.EMPTY))
540
560
  .add(SessionCommand(COMMAND_SLEEP_AFTER_MEDIA_ITEM, Bundle.EMPTY))
541
561
  .add(SessionCommand(COMMAND_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
562
+ .add(SessionCommand(COMMAND_PRELOAD, Bundle.EMPTY))
563
+ .add(SessionCommand(COMMAND_CANCEL_PRELOAD, Bundle.EMPTY))
542
564
  .build()
543
565
 
544
566
  // endregion
@@ -620,75 +642,33 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
620
642
 
621
643
  // region Sleep Timer
622
644
 
623
- private fun cancelSleepTimerInternal(restoreVolume: Boolean) {
624
- sleepTimer?.cancel()
625
- sleepTimer = null
626
- if (restoreVolume) {
627
- sleepTimerPreFadeVolume?.let { activePlayer?.volume = it }
628
- }
629
- sleepTimerPreFadeVolume = null
630
- sleepTimerType = null
631
- sleepTimerRemainingSeconds = 0.0
632
- sleepTimerFadeOutSeconds = 0.0
633
- sleepTimerTargetIndex = null
634
- sleepTimerPreviousIndex = null
635
- persistSleepTimerState()
636
- }
637
-
638
- private fun onSleepTimerTick() {
639
- sleepTimerRemainingSeconds -= 1.0
640
- persistSleepTimerState()
641
-
642
- // Handle fade-out (before zero-check so final tick sets volume to 0)
643
- if (sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds) {
644
- if (sleepTimerPreFadeVolume == null) {
645
- sleepTimerPreFadeVolume = activePlayer?.volume ?: 1f
646
- }
647
- val progress = maxOf(0.0, sleepTimerRemainingSeconds / sleepTimerFadeOutSeconds).toFloat()
648
- activePlayer?.volume = (sleepTimerPreFadeVolume ?: 1f) * progress
649
- }
650
-
651
- if (sleepTimerRemainingSeconds <= 0) {
652
- sleepTimerRemainingSeconds = 0.0
653
- activePlayer?.pause()
654
- this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent("time"))
655
- // Restore volume after pausing so next playback isn't muted
656
- cancelSleepTimerInternal(restoreVolume = true)
657
- return
658
- }
659
- }
660
-
661
645
  private fun startSleepCountdownTimer() {
662
646
  sleepTimer?.cancel()
663
647
  val handler = Handler(Looper.getMainLooper())
664
648
  sleepTimer = java.util.Timer().apply {
665
649
  scheduleAtFixedRate(object : java.util.TimerTask() {
666
650
  override fun run() {
667
- handler.post { onSleepTimerTick() }
651
+ handler.post { sleepTimerController?.tick() }
668
652
  }
669
653
  }, 1000L, 1000L)
670
654
  }
671
655
  }
672
656
 
673
- private fun pauseSleepCountdownTimer() {
674
- sleepTimer?.cancel()
675
- sleepTimer = null
676
- }
677
-
678
657
  private fun persistSleepTimerState() {
679
658
  val prefs = getSharedPreferences(TrackPlayerModule.PLAYER_PREFS_NAME, MODE_PRIVATE)
680
659
  val editor = prefs.edit()
681
- val type = sleepTimerType
682
- if (type == null) {
660
+ val state = sleepTimerController?.getState()
661
+ if (state == null) {
683
662
  editor.remove(SLEEP_TIMER_STATE_KEY)
684
663
  } else {
664
+ val type = state["type"] as String
685
665
  val json = JSONObject().apply {
686
666
  put("type", type)
687
667
  if (type == "time") {
688
- put("remainingSeconds", sleepTimerRemainingSeconds)
689
- put("fadeOutSeconds", sleepTimerFadeOutSeconds)
668
+ put("remainingSeconds", state["remainingSeconds"] as Double)
669
+ put("fadeOutSeconds", state["fadeOutSeconds"] as Double)
690
670
  } else {
691
- put("index", sleepTimerTargetIndex ?: 0)
671
+ put("index", state["index"] as Int)
692
672
  }
693
673
  }
694
674
  editor.putString(SLEEP_TIMER_STATE_KEY, json.toString())
@@ -698,6 +678,44 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
698
678
 
699
679
  // endregion
700
680
 
681
+ // region Preloading
682
+
683
+ @OptIn(UnstableApi::class)
684
+ private fun preloadUri(uri: String) {
685
+ val cache = simpleCache ?: return
686
+ if (activePreloads.containsKey(uri)) return
687
+
688
+ val upstreamFactory = DefaultHttpDataSource.Factory()
689
+ val dataSourceFactory = CacheDataSource.Factory()
690
+ .setCache(cache)
691
+ .setUpstreamDataSourceFactory(upstreamFactory)
692
+
693
+ val dataSpec = DataSpec(Uri.parse(uri))
694
+
695
+ val future = preloadExecutor.submit {
696
+ try {
697
+ val cacheWriter = CacheWriter(
698
+ dataSourceFactory.createDataSource(),
699
+ dataSpec,
700
+ /* temporaryBuffer= */ null,
701
+ /* progressListener= */ null
702
+ )
703
+ cacheWriter.cache()
704
+ } catch (e: Exception) {
705
+ // Cancelled or failed — silently ignore
706
+ } finally {
707
+ activePreloads.remove(uri)
708
+ }
709
+ }
710
+ activePreloads[uri] = future
711
+ }
712
+
713
+ private fun cancelPreloadUri(uri: String) {
714
+ activePreloads.remove(uri)?.cancel(true)
715
+ }
716
+
717
+ // endregion
718
+
701
719
  // region Progress Sync
702
720
 
703
721
  private fun startProgressSyncTimer() {
@@ -799,6 +817,8 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
799
817
  const val COMMAND_SLEEP_AFTER_MEDIA_ITEM = "trackplayer.sleep_after_media_item"
800
818
  const val COMMAND_CANCEL_SLEEP_TIMER = "trackplayer.cancel_sleep_timer"
801
819
  const val SLEEP_TIMER_STATE_KEY = "sleep_timer_state"
820
+ const val COMMAND_PRELOAD = "trackplayer.preload"
821
+ const val COMMAND_CANCEL_PRELOAD = "trackplayer.cancel_preload"
802
822
 
803
823
  @Volatile
804
824
  var sharedCache: SimpleCache? = null
@@ -26,14 +26,18 @@ data class BrowseCategory(
26
26
  @Serializable
27
27
  data class BrowseMediaItem(
28
28
  val mediaId: String,
29
- val url: String,
29
+ val url: String? = null,
30
30
  val title: String? = null,
31
31
  val artist: String? = null,
32
32
  val albumTitle: String? = null,
33
33
  val artworkUrl: String? = null,
34
34
  val duration: Double? = null,
35
35
  val isLive: Boolean? = null,
36
+ val children: List<BrowseMediaItem>? = null,
36
37
  ) {
38
+ val isPlayable: Boolean get() = url != null
39
+ val isBrowsable: Boolean get() = children != null && url == null
40
+
37
41
  fun toMediaItem(): MediaItem {
38
42
  val extras = Bundle()
39
43
  duration?.let { extras.putDouble("duration", it) }
@@ -44,16 +48,20 @@ data class BrowseMediaItem(
44
48
  .setArtist(artist)
45
49
  .setAlbumTitle(albumTitle)
46
50
  .setArtworkUri(artworkUrl?.let { Uri.parse(it) })
47
- .setIsBrowsable(false)
48
- .setIsPlayable(true)
51
+ .setIsBrowsable(isBrowsable)
52
+ .setIsPlayable(isPlayable)
53
+ .setMediaType(if (isBrowsable) MediaMetadata.MEDIA_TYPE_FOLDER_MIXED else MediaMetadata.MEDIA_TYPE_MUSIC)
49
54
  .setExtras(extras)
50
55
  .build()
51
56
 
52
57
  val builder = MediaItem.Builder()
53
58
  .setMediaId(mediaId)
54
- .setUri(url)
55
59
  .setMediaMetadata(metadata)
56
60
 
61
+ if (url != null) {
62
+ builder.setUri(url)
63
+ }
64
+
57
65
  if (isLive == true) {
58
66
  builder.setLiveConfiguration(MediaItem.LiveConfiguration.Builder().build())
59
67
  }
@@ -75,7 +83,7 @@ data class BrowseTree(
75
83
  categories.find { it.mediaId == mediaId }
76
84
 
77
85
  fun findItem(mediaId: String): BrowseMediaItem? =
78
- categories.flatMap { it.items }.find { it.mediaId == mediaId }
86
+ categories.flatMap { findItemRecursive(it.items, mediaId) }.firstOrNull()
79
87
 
80
88
  companion object {
81
89
  private const val BROWSE_TREE_KEY = "browse_tree"
@@ -98,24 +106,47 @@ data class BrowseTree(
98
106
  val title = catMap.getString("title") ?: continue
99
107
  val itemsArray = catMap.getArray("items") ?: continue
100
108
 
101
- val items = mutableListOf<BrowseMediaItem>()
102
- for (j in 0 until itemsArray.size()) {
103
- val itemMap = itemsArray.getMap(j) ?: continue
104
- items.add(BrowseMediaItem(
105
- mediaId = itemMap.getString("mediaId") ?: continue,
106
- url = itemMap.getString("url") as? String ?: continue,
107
- title = if (itemMap.hasKey("title")) itemMap.getString("title") else null,
108
- artist = if (itemMap.hasKey("artist")) itemMap.getString("artist") else null,
109
- albumTitle = if (itemMap.hasKey("albumTitle")) itemMap.getString("albumTitle") else null,
110
- artworkUrl = if (itemMap.hasKey("artworkUrl")) itemMap.getString("artworkUrl") else null,
111
- duration = if (itemMap.hasKey("duration")) itemMap.getDouble("duration") else null,
112
- isLive = if (itemMap.hasKey("isLive")) itemMap.getBoolean("isLive") else null,
113
- ))
114
- }
115
-
109
+ val items = parseItems(itemsArray)
116
110
  categories.add(BrowseCategory(mediaId = mediaId, title = title, items = items))
117
111
  }
118
112
  return BrowseTree(categories)
119
113
  }
114
+
115
+ private fun parseItems(data: ReadableArray): List<BrowseMediaItem> {
116
+ val items = mutableListOf<BrowseMediaItem>()
117
+ for (i in 0 until data.size()) {
118
+ val itemMap = data.getMap(i) ?: continue
119
+ val mediaId = itemMap.getString("mediaId") ?: continue
120
+
121
+ val children = if (itemMap.hasKey("children")) {
122
+ itemMap.getArray("children")?.let { parseItems(it) }
123
+ } else null
124
+
125
+ items.add(BrowseMediaItem(
126
+ mediaId = mediaId,
127
+ url = if (itemMap.hasKey("url")) itemMap.getString("url") else null,
128
+ title = if (itemMap.hasKey("title")) itemMap.getString("title") else null,
129
+ artist = if (itemMap.hasKey("artist")) itemMap.getString("artist") else null,
130
+ albumTitle = if (itemMap.hasKey("albumTitle")) itemMap.getString("albumTitle") else null,
131
+ artworkUrl = if (itemMap.hasKey("artworkUrl")) itemMap.getString("artworkUrl") else null,
132
+ duration = if (itemMap.hasKey("duration")) itemMap.getDouble("duration") else null,
133
+ isLive = if (itemMap.hasKey("isLive")) itemMap.getBoolean("isLive") else null,
134
+ children = children,
135
+ ))
136
+ }
137
+ return items
138
+ }
139
+
140
+ private fun findItemRecursive(items: List<BrowseMediaItem>, mediaId: String): List<BrowseMediaItem> {
141
+ for (item in items) {
142
+ if (item.mediaId == mediaId) return listOf(item)
143
+ val children = item.children
144
+ if (children != null) {
145
+ val found = findItemRecursive(children, mediaId)
146
+ if (found.isNotEmpty()) return found
147
+ }
148
+ }
149
+ return emptyList()
150
+ }
120
151
  }
121
152
  }
@@ -88,6 +88,11 @@ data class PlayerConfig(
88
88
  */
89
89
  val cacheMaxSizeBytes: Long? = null,
90
90
 
91
+ /**
92
+ * Auto-preload window — preload next N tracks. 0 means disabled.
93
+ */
94
+ val preloadWindow: Int = 0,
95
+
91
96
  /**
92
97
  * Cast receiver app ID. Null means Cast is disabled.
93
98
  * Use CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID for the default receiver.
@@ -194,8 +199,14 @@ data class PlayerConfig(
194
199
  notificationChannelName = notification?.getString("channelName"),
195
200
  notificationSmallIcon = notification?.getString("smallIcon"),
196
201
  cacheMaxSizeBytes = cacheMap?.let {
197
- if (it.hasKey("maxSizeBytes")) it.getDouble("maxSizeBytes").toLong() else 100L * 1024 * 1024
202
+ if (it.hasKey("maxSizeBytes")) it.getDouble("maxSizeBytes").toLong() else 500L * 1024 * 1024
198
203
  },
204
+ preloadWindow = cacheMap?.let {
205
+ val preloadMap = if (it.hasKey("preloading")) it.getMap("preloading") else null
206
+ preloadMap?.let { pm ->
207
+ if (pm.hasKey("window")) pm.getInt("window") else 0
208
+ } ?: 0
209
+ } ?: 0,
199
210
  castReceiverAppId = if (android?.hasKey("cast") == true) android.getString("cast") else null,
200
211
  progressSyncIntervalSeconds = map.getMap("progressSync")?.let {
201
212
  if (it.hasKey("intervalSeconds")) it.getDouble("intervalSeconds") else 0.0