react-native-nitro-player 1.5.0 → 1.6.0-alpha.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/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
- package/ios/HybridTrackPlayer.swift +58 -29
- package/ios/core/TrackPlayerCore.swift +22 -1
- package/ios/core/TrackPlayerListener.swift +84 -5
- package/ios/core/TrackPlayerNotify.swift +13 -7
- package/ios/core/TrackPlayerPlayback.swift +75 -39
- package/ios/core/TrackPlayerQueue.swift +28 -4
- package/ios/core/TrackPlayerQueueBuild.swift +100 -40
- package/ios/core/TrackPlayerRecovery.swift +1 -1
- package/ios/core/TrackPlayerTempQueue.swift +129 -122
- package/ios/core/TrackPlayerUrlLoader.swift +34 -10
- package/ios/download/DownloadDatabase.swift +41 -13
- package/ios/media/MediaSessionManager.swift +19 -12
- package/ios/queue/HybridPlayerQueue.swift +10 -5
- package/lib/hooks/callbackManager.d.ts +27 -1
- package/lib/hooks/callbackManager.js +100 -1
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useActualQueue.js +18 -12
- package/lib/hooks/useNowPlaying.js +10 -2
- package/lib/hooks/useTimedMetadata.d.ts +16 -0
- package/lib/hooks/useTimedMetadata.js +21 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
- package/lib/types/PlayerQueue.d.ts +7 -0
- package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
- package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
- package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
- package/package.json +1 -1
- package/src/components/CastButton.tsx +14 -2
- package/src/hooks/callbackManager.ts +134 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useActualQueue.ts +18 -13
- package/src/hooks/useNowPlaying.ts +12 -2
- package/src/hooks/useTimedMetadata.ts +25 -0
- package/src/specs/TrackPlayer.nitro.ts +6 -0
- package/src/types/PlayerQueue.ts +8 -0
|
@@ -15,90 +15,103 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
|
|
|
15
15
|
* via withPlayerContext.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
suspend fun TrackPlayerCore.play() = withPlayerContext {
|
|
18
|
+
suspend fun TrackPlayerCore.play() = withPlayerContext { playOnQueue() }
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
internal fun TrackPlayerCore.playOnQueue() = exo.play()
|
|
21
21
|
|
|
22
|
-
suspend fun TrackPlayerCore.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
suspend fun TrackPlayerCore.pause() = withPlayerContext { pauseOnQueue() }
|
|
23
|
+
|
|
24
|
+
internal fun TrackPlayerCore.pauseOnQueue() = exo.pause()
|
|
25
|
+
|
|
26
|
+
suspend fun TrackPlayerCore.seek(position: Double) = withPlayerContext { seekOnQueue(position) }
|
|
27
|
+
|
|
28
|
+
internal fun TrackPlayerCore.seekOnQueue(position: Double) {
|
|
29
|
+
isManuallySeeked = true
|
|
30
|
+
exo.seekTo((position * 1000).toLong())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
suspend fun TrackPlayerCore.skipToNext() = withPlayerContext { skipToNextOnQueue() }
|
|
34
|
+
|
|
35
|
+
internal fun TrackPlayerCore.skipToNextOnQueue() {
|
|
36
|
+
// The timeline is windowed, so hasNextMediaItem() is not the logical answer —
|
|
37
|
+
// top up first when more tracks remain in currentTracks / the temp queues.
|
|
38
|
+
if (!exo.hasNextMediaItem()) rebuildQueueFromCurrentPosition()
|
|
39
|
+
if (exo.hasNextMediaItem()) {
|
|
40
|
+
exo.seekToNext()
|
|
41
|
+
checkUpcomingTracksForUrls(lookaheadCount)
|
|
26
42
|
}
|
|
43
|
+
}
|
|
27
44
|
|
|
28
|
-
suspend fun TrackPlayerCore.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
suspend fun TrackPlayerCore.skipToPrevious() = withPlayerContext { skipToPreviousOnQueue() }
|
|
46
|
+
|
|
47
|
+
internal fun TrackPlayerCore.skipToPreviousOnQueue() {
|
|
48
|
+
val currentPosition = exo.currentPosition
|
|
49
|
+
when {
|
|
50
|
+
currentPosition > 2000 -> {
|
|
51
|
+
exo.seekTo(0)
|
|
33
52
|
}
|
|
34
|
-
}
|
|
35
53
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
currentTemporaryType != TrackPlayerCore.TemporaryType.NONE -> {
|
|
55
|
+
val trackId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
|
|
56
|
+
if (trackId != null) {
|
|
57
|
+
when (currentTemporaryType) {
|
|
58
|
+
TrackPlayerCore.TemporaryType.PLAY_NEXT -> {
|
|
59
|
+
val idx = playNextStack.indexOfFirst { it.id == trackId }
|
|
60
|
+
if (idx >= 0) playNextStack.removeAt(idx)
|
|
61
|
+
}
|
|
43
62
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
when (currentTemporaryType) {
|
|
48
|
-
TrackPlayerCore.TemporaryType.PLAY_NEXT -> {
|
|
49
|
-
val idx = playNextStack.indexOfFirst { it.id == trackId }
|
|
50
|
-
if (idx >= 0) playNextStack.removeAt(idx)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
TrackPlayerCore.TemporaryType.UP_NEXT -> {
|
|
54
|
-
val idx = upNextQueue.indexOfFirst { it.id == trackId }
|
|
55
|
-
if (idx >= 0) upNextQueue.removeAt(idx)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
else -> {}
|
|
63
|
+
TrackPlayerCore.TemporaryType.UP_NEXT -> {
|
|
64
|
+
val idx = upNextQueue.indexOfFirst { it.id == trackId }
|
|
65
|
+
if (idx >= 0) upNextQueue.removeAt(idx)
|
|
59
66
|
}
|
|
67
|
+
|
|
68
|
+
else -> {}
|
|
60
69
|
}
|
|
61
|
-
currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
|
|
62
|
-
playFromIndexInternal(currentTrackIndex)
|
|
63
70
|
}
|
|
71
|
+
currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
|
|
72
|
+
playFromIndexInternal(currentTrackIndex)
|
|
73
|
+
}
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
currentTrackIndex > 0 -> {
|
|
76
|
+
playFromIndexInternal(currentTrackIndex - 1)
|
|
77
|
+
}
|
|
68
78
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
79
|
+
else -> {
|
|
80
|
+
exo.seekTo(0)
|
|
72
81
|
}
|
|
73
|
-
checkUpcomingTracksForUrls(lookaheadCount)
|
|
74
82
|
}
|
|
83
|
+
checkUpcomingTracksForUrls(lookaheadCount)
|
|
84
|
+
}
|
|
75
85
|
|
|
76
|
-
suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) = withPlayerContext { setRepeatModeOnQueue(mode) }
|
|
87
|
+
|
|
88
|
+
internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
|
|
89
|
+
currentRepeatMode = mode
|
|
90
|
+
exo.setRepeatMode(
|
|
91
|
+
when (mode) {
|
|
92
|
+
RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
|
|
93
|
+
else -> Player.REPEAT_MODE_OFF
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
}
|
|
86
97
|
|
|
87
98
|
fun TrackPlayerCore.getRepeatMode(): RepeatMode = currentRepeatMode
|
|
88
99
|
|
|
89
|
-
suspend fun TrackPlayerCore.setVolume(volume: Double) =
|
|
90
|
-
withPlayerContext {
|
|
91
|
-
val clamped = volume.coerceIn(0.0, 100.0)
|
|
92
|
-
exo.setVolume((clamped / 100.0).toFloat())
|
|
93
|
-
}
|
|
100
|
+
suspend fun TrackPlayerCore.setVolume(volume: Double) = withPlayerContext { setVolumeOnQueue(volume) }
|
|
94
101
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
internal fun TrackPlayerCore.setVolumeOnQueue(volume: Double) {
|
|
103
|
+
val clamped = volume.coerceIn(0.0, 100.0)
|
|
104
|
+
exo.setVolume((clamped / 100.0).toFloat())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
suspend fun TrackPlayerCore.configure(config: PlayerConfig) = withPlayerContext { configureOnQueue(config) }
|
|
108
|
+
|
|
109
|
+
internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
|
|
110
|
+
config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it }
|
|
111
|
+
config.lookaheadCount?.let { lookaheadCount = it.toInt() }
|
|
112
|
+
config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
|
|
113
|
+
mediaSessionManager?.configure(config.androidAutoEnabled, config.carPlayEnabled, config.showInNotification)
|
|
114
|
+
}
|
|
102
115
|
|
|
103
116
|
suspend fun TrackPlayerCore.playSong(
|
|
104
117
|
songId: String,
|
|
@@ -181,11 +194,12 @@ internal fun TrackPlayerCore.emitStateChange(reason: Reason? = null) {
|
|
|
181
194
|
|
|
182
195
|
// ── Playback speed ────────────────────────────────────────────────────────
|
|
183
196
|
|
|
184
|
-
suspend fun TrackPlayerCore.setPlayBackSpeed(speed: Double) =
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
197
|
+
suspend fun TrackPlayerCore.setPlayBackSpeed(speed: Double) = withPlayerContext { setPlayBackSpeedOnQueue(speed) }
|
|
198
|
+
|
|
199
|
+
internal fun TrackPlayerCore.setPlayBackSpeedOnQueue(speed: Double) {
|
|
200
|
+
if (speed <= 0.0) throw IllegalArgumentException("Speed must be greater than 0")
|
|
201
|
+
if (isExoInitialized) exo.setPlaybackSpeed(speed.toFloat())
|
|
202
|
+
}
|
|
189
203
|
|
|
190
204
|
suspend fun TrackPlayerCore.getPlayBackSpeed(): Double =
|
|
191
205
|
withPlayerContext {
|
|
@@ -59,7 +59,9 @@ internal fun TrackPlayerCore.getStateInternal(): PlayerState {
|
|
|
59
59
|
totalDuration = duration,
|
|
60
60
|
currentState = state,
|
|
61
61
|
currentPlaylistId = currentPlaylistId?.let { Variant_NullType_String.create(it) },
|
|
62
|
-
|
|
62
|
+
// Playlist index, not the index inside ExoPlayer's (windowed, rebuilt-from-N)
|
|
63
|
+
// timeline — that reads 0 after any jump. Matches iOS and the documented contract.
|
|
64
|
+
currentIndex = if (currentTrackIndex >= 0) currentTrackIndex.toDouble() else -1.0,
|
|
63
65
|
currentPlayingType = playingType,
|
|
64
66
|
)
|
|
65
67
|
}
|
|
@@ -128,7 +130,7 @@ internal fun TrackPlayerCore.getActualQueueInternal(): List<TrackItem> {
|
|
|
128
130
|
|
|
129
131
|
suspend fun TrackPlayerCore.skipToIndex(index: Int): Boolean = withPlayerContext { skipToIndexInternal(index) }
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
internal fun TrackPlayerCore.skipToIndexInternal(index: Int): Boolean {
|
|
132
134
|
if (!isExoInitialized) return false
|
|
133
135
|
val actualQueue = getActualQueueInternal()
|
|
134
136
|
if (index < 0 || index >= actualQueue.size) return false
|
|
@@ -37,6 +37,14 @@ internal fun TrackPlayerCore.castableUpcoming(tracks: List<TrackItem>): List<Tra
|
|
|
37
37
|
return result
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Upcoming MediaItems kept materialized behind the current one. The logical queue
|
|
42
|
+
* (currentTracks + temp lists) stays complete; only ExoPlayer's timeline is windowed,
|
|
43
|
+
* and it is topped up on every item transition. Building a MediaItem is not free —
|
|
44
|
+
* each one resolves the effective URL, which used to stat() the download record.
|
|
45
|
+
*/
|
|
46
|
+
internal const val QUEUE_WINDOW_SIZE = 4
|
|
47
|
+
|
|
40
48
|
internal fun TrackPlayerCore.mediaIdFor(track: TrackItem): String {
|
|
41
49
|
val playlistId = currentPlaylistId ?: ""
|
|
42
50
|
return if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
|
|
@@ -70,8 +78,9 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
|
|
|
70
78
|
return
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
val windowEnd = minOf(index + 1 + QUEUE_WINDOW_SIZE, currentTracks.size)
|
|
73
82
|
val mediaItems =
|
|
74
|
-
currentTracks.subList(index,
|
|
83
|
+
currentTracks.subList(index, windowEnd).map { track ->
|
|
75
84
|
makeMediaItem(track, mediaIdFor(track))
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -92,7 +101,7 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
92
101
|
val currentTrackId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
|
|
93
102
|
|
|
94
103
|
if (
|
|
95
|
-
currentTrackId != null &&
|
|
104
|
+
currentTrackId != null &&
|
|
96
105
|
currentTracks.none { it.id == currentTrackId } &&
|
|
97
106
|
currentTemporaryType == TrackPlayerCore.TemporaryType.NONE
|
|
98
107
|
) {
|
|
@@ -131,12 +140,32 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
|
|
143
|
+
// Window the materialized timeline; onMediaItemTransition tops it back up.
|
|
144
|
+
// NOT while casting: the receiver holds the whole queue (rebuildQueueAndPlayFromIndex
|
|
145
|
+
// loads it unwindowed), so truncating here would cut it down to the window size.
|
|
146
|
+
if (!isCastingField && newQueueTracks.size > QUEUE_WINDOW_SIZE) {
|
|
147
|
+
newQueueTracks = newQueueTracks.subList(0, QUEUE_WINDOW_SIZE)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Keep the longest matching prefix of already-buffered items and rebuild only from
|
|
151
|
+
// the first divergence. A pure append (the common window top-up) touches nothing
|
|
152
|
+
// ExoPlayer has already prepared, so gapless transitions survive.
|
|
153
|
+
val existingCount = exo.mediaItemCount - currentIndex - 1
|
|
154
|
+
var commonPrefix = 0
|
|
155
|
+
while (commonPrefix < existingCount && commonPrefix < newQueueTracks.size &&
|
|
156
|
+
extractTrackId(exo.getMediaItemAt(currentIndex + 1 + commonPrefix).mediaId) == newQueueTracks[commonPrefix].id
|
|
157
|
+
) {
|
|
158
|
+
commonPrefix++
|
|
159
|
+
}
|
|
135
160
|
|
|
136
|
-
if (exo.mediaItemCount > currentIndex + 1) {
|
|
137
|
-
exo.removeMediaItems(currentIndex + 1, exo.mediaItemCount)
|
|
161
|
+
if (exo.mediaItemCount > currentIndex + 1 + commonPrefix) {
|
|
162
|
+
exo.removeMediaItems(currentIndex + 1 + commonPrefix, exo.mediaItemCount)
|
|
163
|
+
}
|
|
164
|
+
if (commonPrefix < newQueueTracks.size) {
|
|
165
|
+
exo.addMediaItems(
|
|
166
|
+
newQueueTracks.subList(commonPrefix, newQueueTracks.size).map { makeMediaItem(it, mediaIdFor(it)) },
|
|
167
|
+
)
|
|
138
168
|
}
|
|
139
|
-
exo.addMediaItems(newMediaItems)
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
/** Desired upcoming list after the current track: playNext + upNext + remaining originals, with the playing temp track ([currentId]) skipped. */
|
|
@@ -203,7 +232,8 @@ internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
|
|
|
203
232
|
return
|
|
204
233
|
}
|
|
205
234
|
|
|
206
|
-
|
|
235
|
+
currentTrackIndex = 0
|
|
236
|
+
val mediaItems = tracks.take(1 + QUEUE_WINDOW_SIZE).map { makeMediaItem(it, mediaIdFor(it)) }
|
|
207
237
|
exo.setMediaItems(mediaItems, true)
|
|
208
238
|
if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
|
|
209
239
|
exo.prepare()
|
|
@@ -239,15 +269,17 @@ internal fun TrackPlayerCore.makeMediaItem(
|
|
|
239
269
|
try {
|
|
240
270
|
val headersRaw = payload.toHashMap()["headers"]
|
|
241
271
|
if (headersRaw is Map<*, *>) {
|
|
242
|
-
val headers =
|
|
243
|
-
|
|
244
|
-
|
|
272
|
+
val headers =
|
|
273
|
+
headersRaw
|
|
274
|
+
.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }
|
|
275
|
+
.toMap()
|
|
245
276
|
if (headers.isNotEmpty()) {
|
|
246
277
|
com.margelo.nitro.nitroplayer.media.AuthAwareHttpDataSourceFactory
|
|
247
278
|
.setHeadersForUrl(effectiveUrl, headers)
|
|
248
279
|
}
|
|
249
280
|
}
|
|
250
|
-
} catch (_: Exception) {
|
|
281
|
+
} catch (_: Exception) {
|
|
282
|
+
}
|
|
251
283
|
}
|
|
252
284
|
|
|
253
285
|
return MediaItem
|
|
@@ -43,12 +43,16 @@ internal fun TrackPlayerCore.initFromService(binder: NitroPlayerPlaybackService.
|
|
|
43
43
|
if (sessionId != 0) {
|
|
44
44
|
try {
|
|
45
45
|
EqualizerCore.getInstance(context).initialize(sessionId)
|
|
46
|
-
} catch (_: Exception) {
|
|
46
|
+
} catch (_: Exception) {
|
|
47
|
+
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// Start progress ticks on the main looper
|
|
50
|
-
playerHandler.postDelayed(progressUpdateRunnable,
|
|
51
|
+
playerHandler.postDelayed(progressUpdateRunnable, TrackPlayerCore.PROGRESS_INTERVAL_MS)
|
|
51
52
|
|
|
52
53
|
// Signal that the player is ready — unblocks all withPlayerContext callers
|
|
53
54
|
completeServiceReady()
|
|
55
|
+
|
|
56
|
+
// Flush any commands JS issued before the service bound, in their original order
|
|
57
|
+
startCommandDraining()
|
|
54
58
|
}
|
|
@@ -14,16 +14,21 @@ import com.margelo.nitro.nitroplayer.TrackItem
|
|
|
14
14
|
suspend fun TrackPlayerCore.loadPlaylist(
|
|
15
15
|
playlistId: String,
|
|
16
16
|
startIndex: Int? = null,
|
|
17
|
-
) =
|
|
18
|
-
|
|
17
|
+
) = withPlayerContext { loadPlaylistOnQueue(playlistId, startIndex) }
|
|
18
|
+
|
|
19
|
+
internal fun TrackPlayerCore.loadPlaylistOnQueue(
|
|
20
|
+
playlistId: String,
|
|
21
|
+
startIndex: Int? = null,
|
|
22
|
+
) {
|
|
23
|
+
run {
|
|
19
24
|
playNextStack.clear()
|
|
20
25
|
upNextQueue.clear()
|
|
21
26
|
currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
|
|
22
|
-
val playlist = playlistManager.getPlaylist(playlistId) ?: return@
|
|
27
|
+
val playlist = playlistManager.getPlaylist(playlistId) ?: return@run
|
|
23
28
|
|
|
24
29
|
val targetIndex =
|
|
25
30
|
if (startIndex != null) {
|
|
26
|
-
if (startIndex < 0 || startIndex >= playlist.tracks.size) return@
|
|
31
|
+
if (startIndex < 0 || startIndex >= playlist.tracks.size) return@run
|
|
27
32
|
startIndex
|
|
28
33
|
} else {
|
|
29
34
|
0
|
|
@@ -41,6 +46,7 @@ suspend fun TrackPlayerCore.loadPlaylist(
|
|
|
41
46
|
checkUpcomingTracksForUrls(lookaheadCount)
|
|
42
47
|
notifyTemporaryQueueChange()
|
|
43
48
|
}
|
|
49
|
+
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
46
52
|
* Debounced update — coalesces rapid back-to-back mutations into one player rebuild.
|
|
@@ -80,39 +86,43 @@ internal fun TrackPlayerCore.addToUpNextInternal(trackId: String) {
|
|
|
80
86
|
|
|
81
87
|
// ── Remove / clear ────────────────────────────────────────────────────────
|
|
82
88
|
|
|
83
|
-
suspend fun TrackPlayerCore.removeFromPlayNext(trackId: String): Boolean =
|
|
84
|
-
withPlayerContext {
|
|
85
|
-
val idx = playNextStack.indexOfFirst { it.id == trackId }
|
|
86
|
-
if (idx < 0) return@withPlayerContext false
|
|
87
|
-
playNextStack.removeAt(idx)
|
|
88
|
-
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
89
|
-
notifyTemporaryQueueChange()
|
|
90
|
-
true
|
|
91
|
-
}
|
|
89
|
+
suspend fun TrackPlayerCore.removeFromPlayNext(trackId: String): Boolean = withPlayerContext { removeFromPlayNextOnQueue(trackId) }
|
|
92
90
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
91
|
+
internal fun TrackPlayerCore.removeFromPlayNextOnQueue(trackId: String): Boolean {
|
|
92
|
+
val idx = playNextStack.indexOfFirst { it.id == trackId }
|
|
93
|
+
if (idx < 0) return false
|
|
94
|
+
playNextStack.removeAt(idx)
|
|
95
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
96
|
+
notifyTemporaryQueueChange()
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
102
99
|
|
|
103
|
-
suspend fun TrackPlayerCore.
|
|
104
|
-
withPlayerContext {
|
|
105
|
-
playNextStack.clear()
|
|
106
|
-
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
107
|
-
notifyTemporaryQueueChange()
|
|
108
|
-
}
|
|
100
|
+
suspend fun TrackPlayerCore.removeFromUpNext(trackId: String): Boolean = withPlayerContext { removeFromUpNextOnQueue(trackId) }
|
|
109
101
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
102
|
+
internal fun TrackPlayerCore.removeFromUpNextOnQueue(trackId: String): Boolean {
|
|
103
|
+
val idx = upNextQueue.indexOfFirst { it.id == trackId }
|
|
104
|
+
if (idx < 0) return false
|
|
105
|
+
upNextQueue.removeAt(idx)
|
|
106
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
107
|
+
notifyTemporaryQueueChange()
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
suspend fun TrackPlayerCore.clearPlayNext() = withPlayerContext { clearPlayNextOnQueue() }
|
|
112
|
+
|
|
113
|
+
internal fun TrackPlayerCore.clearPlayNextOnQueue() {
|
|
114
|
+
playNextStack.clear()
|
|
115
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
116
|
+
notifyTemporaryQueueChange()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
suspend fun TrackPlayerCore.clearUpNext() = withPlayerContext { clearUpNextOnQueue() }
|
|
120
|
+
|
|
121
|
+
internal fun TrackPlayerCore.clearUpNextOnQueue() {
|
|
122
|
+
upNextQueue.clear()
|
|
123
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
124
|
+
notifyTemporaryQueueChange()
|
|
125
|
+
}
|
|
116
126
|
|
|
117
127
|
// ── Reorder ───────────────────────────────────────────────────────────────
|
|
118
128
|
|
|
@@ -123,26 +133,30 @@ suspend fun TrackPlayerCore.clearUpNext() =
|
|
|
123
133
|
suspend fun TrackPlayerCore.reorderTemporaryTrack(
|
|
124
134
|
trackId: String,
|
|
125
135
|
newIndex: Int,
|
|
126
|
-
): Boolean =
|
|
127
|
-
withPlayerContext {
|
|
128
|
-
val combined = (playNextStack + upNextQueue).toMutableList()
|
|
129
|
-
val fromIdx = combined.indexOfFirst { it.id == trackId }
|
|
130
|
-
if (fromIdx < 0) return@withPlayerContext false
|
|
131
|
-
val track = combined.removeAt(fromIdx)
|
|
132
|
-
val clampedIndex = newIndex.coerceIn(0, combined.size)
|
|
133
|
-
combined.add(clampedIndex, track)
|
|
134
|
-
|
|
135
|
-
// Split back at original playNextStack.size boundary (reduced if an item was moved out)
|
|
136
|
-
val pnSize = playNextStack.size
|
|
137
|
-
playNextStack.clear()
|
|
138
|
-
upNextQueue.clear()
|
|
139
|
-
playNextStack.addAll(combined.take(pnSize))
|
|
140
|
-
upNextQueue.addAll(combined.drop(pnSize))
|
|
136
|
+
): Boolean = withPlayerContext { reorderTemporaryTrackOnQueue(trackId, newIndex) }
|
|
141
137
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
internal fun TrackPlayerCore.reorderTemporaryTrackOnQueue(
|
|
139
|
+
trackId: String,
|
|
140
|
+
newIndex: Int,
|
|
141
|
+
): Boolean {
|
|
142
|
+
val combined = (playNextStack + upNextQueue).toMutableList()
|
|
143
|
+
val fromIdx = combined.indexOfFirst { it.id == trackId }
|
|
144
|
+
if (fromIdx < 0) return false
|
|
145
|
+
val track = combined.removeAt(fromIdx)
|
|
146
|
+
val clampedIndex = newIndex.coerceIn(0, combined.size)
|
|
147
|
+
combined.add(clampedIndex, track)
|
|
148
|
+
|
|
149
|
+
// Split back at original playNextStack.size boundary (reduced if an item was moved out)
|
|
150
|
+
val pnSize = playNextStack.size
|
|
151
|
+
playNextStack.clear()
|
|
152
|
+
upNextQueue.clear()
|
|
153
|
+
playNextStack.addAll(combined.take(pnSize))
|
|
154
|
+
upNextQueue.addAll(combined.drop(pnSize))
|
|
155
|
+
|
|
156
|
+
if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
|
|
157
|
+
notifyTemporaryQueueChange()
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
146
160
|
|
|
147
161
|
// ── Read-only accessors ────────────────────────────────────────────────────
|
|
148
162
|
|
|
@@ -12,8 +12,10 @@ import com.margelo.nitro.nitroplayer.TrackItem
|
|
|
12
12
|
|
|
13
13
|
// ── Track updates (URL resolution) ────────────────────────────────────────
|
|
14
14
|
|
|
15
|
-
suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
16
|
-
|
|
15
|
+
suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) = withPlayerContext { updateTracksOnQueue(tracks) }
|
|
16
|
+
|
|
17
|
+
internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
|
|
18
|
+
run {
|
|
17
19
|
val currentTrack = getCurrentTrack()
|
|
18
20
|
val currentTrackId = currentTrack?.id
|
|
19
21
|
val currentTrackIsEmpty = currentTrack?.url.isNullOrEmpty()
|
|
@@ -32,7 +34,7 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
|
32
34
|
else -> true
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
|
-
if (safeTracks.isEmpty()) return@
|
|
37
|
+
if (safeTracks.isEmpty()) return@run
|
|
36
38
|
|
|
37
39
|
val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
|
|
38
40
|
|
|
@@ -64,12 +66,13 @@ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
|
|
|
64
66
|
} else {
|
|
65
67
|
rebuildQueueFromCurrentPosition()
|
|
66
68
|
}
|
|
67
|
-
return@
|
|
69
|
+
return@run
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
rebuildQueueFromCurrentPosition()
|
|
71
73
|
}
|
|
72
74
|
}
|
|
75
|
+
}
|
|
73
76
|
|
|
74
77
|
// ── Track queries ─────────────────────────────────────────────────────────
|
|
75
78
|
|
|
@@ -84,14 +87,42 @@ internal fun TrackPlayerCore.getTracksNeedingUrlsInternal(): List<TrackItem> {
|
|
|
84
87
|
|
|
85
88
|
suspend fun TrackPlayerCore.getNextTracks(count: Int): List<TrackItem> = withPlayerContext { getNextTracksInternal(count) }
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Walks the queue structure directly instead of materializing the whole queue — this
|
|
92
|
+
* runs on every skip and every progress tick, so it must not be O(playlist) just to
|
|
93
|
+
* read the next few tracks.
|
|
94
|
+
*/
|
|
87
95
|
internal fun TrackPlayerCore.getNextTracksInternal(count: Int): List<TrackItem> {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
val
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
if (count <= 0) return emptyList()
|
|
97
|
+
val out = ArrayList<TrackItem>(count)
|
|
98
|
+
val currentId = if (isExoInitialized) exo.currentMediaItem?.mediaId?.let { extractTrackId(it) } else null
|
|
99
|
+
|
|
100
|
+
var skippedPlayNext = currentTemporaryType != TrackPlayerCore.TemporaryType.PLAY_NEXT
|
|
101
|
+
for (track in playNextStack) {
|
|
102
|
+
if (!skippedPlayNext && track.id == currentId) {
|
|
103
|
+
skippedPlayNext = true
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
out.add(track)
|
|
107
|
+
if (out.size == count) return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var skippedUpNext = currentTemporaryType != TrackPlayerCore.TemporaryType.UP_NEXT
|
|
111
|
+
for (track in upNextQueue) {
|
|
112
|
+
if (!skippedUpNext && track.id == currentId) {
|
|
113
|
+
skippedUpNext = true
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
out.add(track)
|
|
117
|
+
if (out.size == count) return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
var index = currentTrackIndex + 1
|
|
121
|
+
while (index < currentTracks.size && out.size < count) {
|
|
122
|
+
out.add(currentTracks[index])
|
|
123
|
+
index++
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
suspend fun TrackPlayerCore.getCurrentTrackIndex(): Int = withPlayerContext { currentTrackIndex }
|
|
@@ -39,6 +39,15 @@ class DownloadDatabase private constructor(
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
private val downloadedTracks = mutableMapOf<String, DownloadedTrackRecord>()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Memoized `File.exists()` verdicts, keyed by track ID.
|
|
45
|
+
*
|
|
46
|
+
* Queue building asks "is this downloaded?" once per track, and each answer used to
|
|
47
|
+
* cost a stat() — O(playlist) syscalls per rebuild, on the main looper. Records only
|
|
48
|
+
* change through this class, so the verdict is cached and dropped in [saveToDisk].
|
|
49
|
+
*/
|
|
50
|
+
private val existenceCache = mutableMapOf<String, Boolean>()
|
|
42
51
|
private val playlistTracks = mutableMapOf<String, MutableSet<String>>()
|
|
43
52
|
private val fileManager = DownloadFileManager.getInstance(context)
|
|
44
53
|
private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
@@ -82,11 +91,21 @@ class DownloadDatabase private constructor(
|
|
|
82
91
|
fun isTrackDownloaded(trackId: String): Boolean {
|
|
83
92
|
synchronized(this) {
|
|
84
93
|
val record = downloadedTracks[trackId] ?: return false
|
|
85
|
-
|
|
86
|
-
return File(record.localPath).exists()
|
|
94
|
+
return cachedFileExists(trackId, record.localPath)
|
|
87
95
|
}
|
|
88
96
|
}
|
|
89
97
|
|
|
98
|
+
/** Caller must hold the monitor. */
|
|
99
|
+
private fun cachedFileExists(
|
|
100
|
+
trackId: String,
|
|
101
|
+
path: String,
|
|
102
|
+
): Boolean = existenceCache.getOrPut(trackId) { File(path).exists() }
|
|
103
|
+
|
|
104
|
+
/** Drops every memoized verdict — files may have changed outside the app. */
|
|
105
|
+
fun invalidateExistenceCache() {
|
|
106
|
+
synchronized(this) { existenceCache.clear() }
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
fun isPlaylistDownloaded(playlistId: String): Boolean {
|
|
91
110
|
synchronized(this) {
|
|
92
111
|
val trackIds = playlistTracks[playlistId] ?: return false
|
|
@@ -118,8 +137,8 @@ class DownloadDatabase private constructor(
|
|
|
118
137
|
synchronized(this) {
|
|
119
138
|
val record = downloadedTracks[trackId] ?: return null
|
|
120
139
|
|
|
121
|
-
// Verify file still exists
|
|
122
|
-
if (!
|
|
140
|
+
// Verify file still exists (memoized)
|
|
141
|
+
if (!cachedFileExists(trackId, record.localPath)) {
|
|
123
142
|
// File was deleted externally, clean up record
|
|
124
143
|
downloadedTracks.remove(trackId)
|
|
125
144
|
saveToDisk()
|
|
@@ -302,6 +321,9 @@ class DownloadDatabase private constructor(
|
|
|
302
321
|
|
|
303
322
|
// Persistence — called while holding synchronized(this); snapshots data then writes on IO.
|
|
304
323
|
private fun saveToDisk() {
|
|
324
|
+
// Every record mutation funnels through here, so this is the one place that has
|
|
325
|
+
// to drop memoized File.exists() verdicts.
|
|
326
|
+
existenceCache.clear()
|
|
305
327
|
val trackSnapshot = downloadedTracks.toMap()
|
|
306
328
|
val playlistSnapshot = playlistTracks.mapValues { it.value.toSet() }
|
|
307
329
|
ioScope.launch {
|