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.
Files changed (63) hide show
  1. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +18 -7
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +77 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +89 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +30 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  13. package/ios/HybridTrackPlayer.swift +58 -29
  14. package/ios/core/TrackPlayerCore.swift +22 -1
  15. package/ios/core/TrackPlayerListener.swift +84 -5
  16. package/ios/core/TrackPlayerNotify.swift +13 -7
  17. package/ios/core/TrackPlayerPlayback.swift +75 -39
  18. package/ios/core/TrackPlayerQueue.swift +28 -4
  19. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  20. package/ios/core/TrackPlayerRecovery.swift +1 -1
  21. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  22. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  23. package/ios/download/DownloadDatabase.swift +41 -13
  24. package/ios/media/MediaSessionManager.swift +19 -12
  25. package/ios/queue/HybridPlayerQueue.swift +10 -5
  26. package/lib/hooks/callbackManager.d.ts +27 -1
  27. package/lib/hooks/callbackManager.js +100 -1
  28. package/lib/hooks/index.d.ts +1 -0
  29. package/lib/hooks/index.js +1 -0
  30. package/lib/hooks/useActualQueue.js +18 -12
  31. package/lib/hooks/useNowPlaying.js +10 -2
  32. package/lib/hooks/useTimedMetadata.d.ts +16 -0
  33. package/lib/hooks/useTimedMetadata.js +21 -0
  34. package/lib/specs/TrackPlayer.nitro.d.ts +6 -1
  35. package/lib/types/PlayerQueue.d.ts +7 -0
  36. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +2 -0
  37. package/nitrogen/generated/android/c++/JFunc_void_TimedMetadata.hpp +79 -0
  38. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +9 -0
  39. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  40. package/nitrogen/generated/android/c++/JTimedMetadata.hpp +70 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_TimedMetadata.kt +80 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +9 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TimedMetadata.kt +47 -0
  44. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.cpp +8 -0
  45. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +25 -0
  46. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  47. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +9 -0
  48. package/nitrogen/generated/ios/swift/Func_void_TimedMetadata.swift +46 -0
  49. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  50. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +16 -0
  51. package/nitrogen/generated/ios/swift/TimedMetadata.swift +96 -0
  52. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  53. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  54. package/nitrogen/generated/shared/c++/TimedMetadata.hpp +96 -0
  55. package/package.json +1 -1
  56. package/src/components/CastButton.tsx +14 -2
  57. package/src/hooks/callbackManager.ts +134 -2
  58. package/src/hooks/index.ts +1 -0
  59. package/src/hooks/useActualQueue.ts +18 -13
  60. package/src/hooks/useNowPlaying.ts +12 -2
  61. package/src/hooks/useTimedMetadata.ts +25 -0
  62. package/src/specs/TrackPlayer.nitro.ts +6 -0
  63. 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 { exo.play() }
18
+ suspend fun TrackPlayerCore.play() = withPlayerContext { playOnQueue() }
19
19
 
20
- suspend fun TrackPlayerCore.pause() = withPlayerContext { exo.pause() }
20
+ internal fun TrackPlayerCore.playOnQueue() = exo.play()
21
21
 
22
- suspend fun TrackPlayerCore.seek(position: Double) =
23
- withPlayerContext {
24
- isManuallySeeked = true
25
- exo.seekTo((position * 1000).toLong())
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.skipToNext() =
29
- withPlayerContext {
30
- if (exo.hasNextMediaItem()) {
31
- exo.seekToNext()
32
- checkUpcomingTracksForUrls(lookaheadCount)
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
- suspend fun TrackPlayerCore.skipToPrevious() =
37
- withPlayerContext {
38
- val currentPosition = exo.currentPosition
39
- when {
40
- currentPosition > 2000 -> {
41
- exo.seekTo(0)
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
- currentTemporaryType != TrackPlayerCore.TemporaryType.NONE -> {
45
- val trackId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
46
- if (trackId != null) {
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
- currentTrackIndex > 0 -> {
66
- playFromIndexInternal(currentTrackIndex - 1)
67
- }
75
+ currentTrackIndex > 0 -> {
76
+ playFromIndexInternal(currentTrackIndex - 1)
77
+ }
68
78
 
69
- else -> {
70
- exo.seekTo(0)
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
- withPlayerContext {
78
- currentRepeatMode = mode
79
- exo.setRepeatMode(
80
- when (mode) {
81
- RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
82
- else -> Player.REPEAT_MODE_OFF
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
- suspend fun TrackPlayerCore.configure(config: PlayerConfig) =
96
- withPlayerContext {
97
- config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it }
98
- config.lookaheadCount?.let { lookaheadCount = it.toInt() }
99
- config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
100
- mediaSessionManager?.configure(config.androidAutoEnabled, config.carPlayEnabled, config.showInNotification)
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
- withPlayerContext {
186
- if (speed <= 0.0) throw IllegalArgumentException("Speed must be greater than 0")
187
- if (isExoInitialized) exo.setPlaybackSpeed(speed.toFloat())
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
- currentIndex = if (exo.currentMediaItemIndex >= 0) exo.currentMediaItemIndex.toDouble() else -1.0,
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
- private fun TrackPlayerCore.skipToIndexInternal(index: Int): Boolean {
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, currentTracks.size).map { track ->
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
- val newMediaItems = newQueueTracks.map { makeMediaItem(it, mediaIdFor(it)) }
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
- val mediaItems = tracks.map { makeMediaItem(it, mediaIdFor(it)) }
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 = headersRaw
243
- .mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }
244
- .toMap()
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, 250)
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
- withPlayerContext {
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@withPlayerContext
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@withPlayerContext
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
- suspend fun TrackPlayerCore.removeFromUpNext(trackId: String): Boolean =
94
- withPlayerContext {
95
- val idx = upNextQueue.indexOfFirst { it.id == trackId }
96
- if (idx < 0) return@withPlayerContext false
97
- upNextQueue.removeAt(idx)
98
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
99
- notifyTemporaryQueueChange()
100
- true
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.clearPlayNext() =
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
- suspend fun TrackPlayerCore.clearUpNext() =
111
- withPlayerContext {
112
- upNextQueue.clear()
113
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
114
- notifyTemporaryQueueChange()
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
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
143
- notifyTemporaryQueueChange()
144
- true
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
- withPlayerContext {
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@withPlayerContext
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@withPlayerContext
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
- val actualQueue = getActualQueueInternal()
89
- if (actualQueue.isEmpty()) return emptyList()
90
- val currentIdx = actualQueue.indexOfFirst { it.id == getCurrentTrack()?.id }
91
- if (currentIdx == -1) return emptyList()
92
- val start = currentIdx + 1
93
- val end = minOf(start + count, actualQueue.size)
94
- return if (start < actualQueue.size) actualQueue.subList(start, end) else emptyList()
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
- // Verify file still exists
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 (!File(record.localPath).exists()) {
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 {