@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.
- package/android/build.gradle +7 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +107 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
- package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
- package/ios/TrackPlayer.swift +46 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +46 -32
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +36 -21
- package/ios/player/CacheProxyServer.swift +429 -0
- package/ios/player/DownloadCoordinator.swift +242 -0
- package/ios/player/Preloader.swift +21 -90
- package/ios/player/SleepTimerController.swift +147 -0
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
- package/ios/tests/AudioPlayerTests.swift +6 -0
- package/ios/tests/CacheProxyServerTests.swift +403 -0
- package/ios/tests/DownloadCoordinatorTests.swift +197 -0
- package/ios/tests/LocalAudioServer.swift +171 -0
- package/ios/tests/MockPlayerEngine.swift +1 -0
- package/ios/tests/QueueManagerTests.swift +6 -0
- package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
- package/ios/tests/SleepTimerTests.swift +70 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +39 -4
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +37 -4
- package/lib/module/audio.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -1
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +16 -4
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
- package/lib/typescript/src/interfaces/BrowseTree.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/NativeTrackPlayer.ts +4 -0
- package/src/audio.ts +37 -4
- package/src/interfaces/BrowseTree.ts +40 -5
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +22 -3
- package/ios/player/CachingResourceLoader.swift +0 -273
package/android/build.gradle
CHANGED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
private
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
682
|
-
if (
|
|
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",
|
|
689
|
-
put("fadeOutSeconds",
|
|
668
|
+
put("remainingSeconds", state["remainingSeconds"] as Double)
|
|
669
|
+
put("fadeOutSeconds", state["fadeOutSeconds"] as Double)
|
|
690
670
|
} else {
|
|
691
|
-
put("index",
|
|
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(
|
|
48
|
-
.setIsPlayable(
|
|
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
|
|
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 =
|
|
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
|
|
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
|