@rntp/player 5.0.0-beta.4 → 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 (56) 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 +99 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  6. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  14. package/android/src/test/resources/robolectric.properties +1 -0
  15. package/ios/TrackPlayer.swift +46 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +46 -32
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +36 -21
  20. package/ios/player/CacheProxyServer.swift +429 -0
  21. package/ios/player/DownloadCoordinator.swift +242 -0
  22. package/ios/player/Preloader.swift +21 -90
  23. package/ios/player/SleepTimerController.swift +147 -0
  24. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  25. package/ios/tests/AudioPlayerTests.swift +6 -0
  26. package/ios/tests/CacheProxyServerTests.swift +403 -0
  27. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  28. package/ios/tests/LocalAudioServer.swift +171 -0
  29. package/ios/tests/MockPlayerEngine.swift +1 -0
  30. package/ios/tests/QueueManagerTests.swift +6 -0
  31. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  32. package/ios/tests/SleepTimerTests.swift +70 -0
  33. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  34. package/lib/commonjs/audio.js +19 -0
  35. package/lib/commonjs/audio.js.map +1 -1
  36. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  37. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  38. package/lib/module/NativeTrackPlayer.js.map +1 -1
  39. package/lib/module/audio.js +17 -0
  40. package/lib/module/audio.js.map +1 -1
  41. package/lib/module/interfaces/PlayerConfig.js +1 -1
  42. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  43. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  44. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  45. package/lib/typescript/src/audio.d.ts +12 -1
  46. package/lib/typescript/src/audio.d.ts.map +1 -1
  47. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  48. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
  50. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  51. package/package.json +4 -1
  52. package/src/NativeTrackPlayer.ts +4 -0
  53. package/src/audio.ts +18 -0
  54. package/src/interfaces/MediaItem.ts +4 -1
  55. package/src/interfaces/PlayerConfig.ts +22 -3
  56. 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
  }
@@ -547,6 +559,8 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
547
559
  .add(SessionCommand(COMMAND_SLEEP_AFTER_TIME, Bundle.EMPTY))
548
560
  .add(SessionCommand(COMMAND_SLEEP_AFTER_MEDIA_ITEM, Bundle.EMPTY))
549
561
  .add(SessionCommand(COMMAND_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
562
+ .add(SessionCommand(COMMAND_PRELOAD, Bundle.EMPTY))
563
+ .add(SessionCommand(COMMAND_CANCEL_PRELOAD, Bundle.EMPTY))
550
564
  .build()
551
565
 
552
566
  // endregion
@@ -628,75 +642,33 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
628
642
 
629
643
  // region Sleep Timer
630
644
 
631
- private fun cancelSleepTimerInternal(restoreVolume: Boolean) {
632
- sleepTimer?.cancel()
633
- sleepTimer = null
634
- if (restoreVolume) {
635
- sleepTimerPreFadeVolume?.let { activePlayer?.volume = it }
636
- }
637
- sleepTimerPreFadeVolume = null
638
- sleepTimerType = null
639
- sleepTimerRemainingSeconds = 0.0
640
- sleepTimerFadeOutSeconds = 0.0
641
- sleepTimerTargetIndex = null
642
- sleepTimerPreviousIndex = null
643
- persistSleepTimerState()
644
- }
645
-
646
- private fun onSleepTimerTick() {
647
- sleepTimerRemainingSeconds -= 1.0
648
- persistSleepTimerState()
649
-
650
- // Handle fade-out (before zero-check so final tick sets volume to 0)
651
- if (sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds) {
652
- if (sleepTimerPreFadeVolume == null) {
653
- sleepTimerPreFadeVolume = activePlayer?.volume ?: 1f
654
- }
655
- val progress = maxOf(0.0, sleepTimerRemainingSeconds / sleepTimerFadeOutSeconds).toFloat()
656
- activePlayer?.volume = (sleepTimerPreFadeVolume ?: 1f) * progress
657
- }
658
-
659
- if (sleepTimerRemainingSeconds <= 0) {
660
- sleepTimerRemainingSeconds = 0.0
661
- activePlayer?.pause()
662
- this@TrackPlayerPlaybackService.emitEvent(SleepTimerTriggeredEvent("time"))
663
- // Restore volume after pausing so next playback isn't muted
664
- cancelSleepTimerInternal(restoreVolume = true)
665
- return
666
- }
667
- }
668
-
669
645
  private fun startSleepCountdownTimer() {
670
646
  sleepTimer?.cancel()
671
647
  val handler = Handler(Looper.getMainLooper())
672
648
  sleepTimer = java.util.Timer().apply {
673
649
  scheduleAtFixedRate(object : java.util.TimerTask() {
674
650
  override fun run() {
675
- handler.post { onSleepTimerTick() }
651
+ handler.post { sleepTimerController?.tick() }
676
652
  }
677
653
  }, 1000L, 1000L)
678
654
  }
679
655
  }
680
656
 
681
- private fun pauseSleepCountdownTimer() {
682
- sleepTimer?.cancel()
683
- sleepTimer = null
684
- }
685
-
686
657
  private fun persistSleepTimerState() {
687
658
  val prefs = getSharedPreferences(TrackPlayerModule.PLAYER_PREFS_NAME, MODE_PRIVATE)
688
659
  val editor = prefs.edit()
689
- val type = sleepTimerType
690
- if (type == null) {
660
+ val state = sleepTimerController?.getState()
661
+ if (state == null) {
691
662
  editor.remove(SLEEP_TIMER_STATE_KEY)
692
663
  } else {
664
+ val type = state["type"] as String
693
665
  val json = JSONObject().apply {
694
666
  put("type", type)
695
667
  if (type == "time") {
696
- put("remainingSeconds", sleepTimerRemainingSeconds)
697
- put("fadeOutSeconds", sleepTimerFadeOutSeconds)
668
+ put("remainingSeconds", state["remainingSeconds"] as Double)
669
+ put("fadeOutSeconds", state["fadeOutSeconds"] as Double)
698
670
  } else {
699
- put("index", sleepTimerTargetIndex ?: 0)
671
+ put("index", state["index"] as Int)
700
672
  }
701
673
  }
702
674
  editor.putString(SLEEP_TIMER_STATE_KEY, json.toString())
@@ -706,6 +678,44 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
706
678
 
707
679
  // endregion
708
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
+
709
719
  // region Progress Sync
710
720
 
711
721
  private fun startProgressSyncTimer() {
@@ -807,6 +817,8 @@ class TrackPlayerPlaybackService: MediaLibraryService(), MediaLibraryService.Med
807
817
  const val COMMAND_SLEEP_AFTER_MEDIA_ITEM = "trackplayer.sleep_after_media_item"
808
818
  const val COMMAND_CANCEL_SLEEP_TIMER = "trackplayer.cancel_sleep_timer"
809
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"
810
822
 
811
823
  @Volatile
812
824
  var sharedCache: SimpleCache? = null
@@ -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