react-native-nitro-player 1.5.0-alpha.3 → 1.5.1-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 (33) 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 +71 -56
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +82 -9
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +84 -70
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +4 -2
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +43 -11
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +6 -2
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +67 -53
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +42 -11
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +26 -4
  12. package/ios/HybridTrackPlayer.swift +52 -29
  13. package/ios/core/TrackPlayerCore.swift +9 -1
  14. package/ios/core/TrackPlayerListener.swift +20 -5
  15. package/ios/core/TrackPlayerNotify.swift +9 -7
  16. package/ios/core/TrackPlayerPlayback.swift +75 -39
  17. package/ios/core/TrackPlayerQueue.swift +28 -4
  18. package/ios/core/TrackPlayerQueueBuild.swift +100 -40
  19. package/ios/core/TrackPlayerRecovery.swift +1 -1
  20. package/ios/core/TrackPlayerTempQueue.swift +129 -122
  21. package/ios/core/TrackPlayerUrlLoader.swift +34 -10
  22. package/ios/download/DownloadDatabase.swift +41 -13
  23. package/ios/media/MediaSessionManager.swift +19 -12
  24. package/ios/queue/HybridPlayerQueue.swift +10 -5
  25. package/lib/hooks/callbackManager.d.ts +17 -0
  26. package/lib/hooks/callbackManager.js +67 -1
  27. package/lib/hooks/useActualQueue.js +18 -12
  28. package/lib/hooks/useNowPlaying.js +10 -2
  29. package/package.json +1 -1
  30. package/src/components/CastButton.tsx +14 -2
  31. package/src/hooks/callbackManager.ts +87 -1
  32. package/src/hooks/useActualQueue.ts +18 -13
  33. package/src/hooks/useNowPlaying.ts +12 -2
@@ -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 {
@@ -25,40 +25,63 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
25
25
  super.init()
26
26
  }
27
27
 
28
+ // MARK: - Ordered dispatch
29
+ //
30
+ // `Promise.async` starts an unordered Task, so the hop onto the player queue used
31
+ // to happen at an arbitrary later point: two calls made in order from JS could
32
+ // reach the player in reverse order (play-then-pause landing as pause-then-play).
33
+ // Enqueueing synchronously here — on the JS thread, at call time — makes the serial
34
+ // queue's FIFO order equal the JS call order.
35
+
36
+ private func enqueue<T>(_ block: @escaping () -> T) -> Promise<T> {
37
+ let promise = Promise<T>()
38
+ core.playerQueue.async { promise.resolve(withResult: block()) }
39
+ return promise
40
+ }
41
+
42
+ private func enqueueThrowing<T>(_ block: @escaping () throws -> T) -> Promise<T> {
43
+ let promise = Promise<T>()
44
+ core.playerQueue.async {
45
+ do { promise.resolve(withResult: try block()) }
46
+ catch { promise.reject(withError: error) }
47
+ }
48
+ return promise
49
+ }
50
+
28
51
  // MARK: - Playback Control (async Promise<Void>)
29
52
 
30
53
  func play() throws -> Promise<Void> {
31
- Promise.async { await self.core.play() }
54
+ enqueue { self.core.playOnQueue() }
32
55
  }
33
56
 
34
57
  func pause() throws -> Promise<Void> {
35
- Promise.async { await self.core.pause() }
58
+ enqueue { self.core.pauseOnQueue() }
36
59
  }
37
60
 
38
61
  func seek(position: Double) throws -> Promise<Void> {
39
- Promise.async { await self.core.seek(position: position) }
62
+ enqueue { self.core.seekOnQueue(position: position) }
40
63
  }
41
64
 
42
65
  func skipToNext() throws -> Promise<Void> {
43
- Promise.async { await self.core.skipToNext() }
66
+ enqueue { self.core.skipToNextOnQueue() }
44
67
  }
45
68
 
46
69
  func skipToPrevious() throws -> Promise<Void> {
47
- Promise.async { await self.core.skipToPrevious() }
70
+ enqueue { self.core.skipToPreviousOnQueue() }
48
71
  }
49
72
 
50
73
  func playSong(songId: String, fromPlaylist: String?) throws -> Promise<Void> {
51
- Promise.async { await self.core.playSong(songId: songId, fromPlaylist: fromPlaylist) }
74
+ enqueue { self.core.playSongInternal(songId: songId, fromPlaylist: fromPlaylist) }
52
75
  }
53
76
 
54
77
  func skipToIndex(index: Double) throws -> Promise<Bool> {
55
- Promise.async { await self.core.skipToIndex(index: Int(index)) }
78
+ enqueue { self.core.skipToIndexOnQueue(index: Int(index)) }
56
79
  }
57
80
 
58
81
  // MARK: - Repeat / Volume / Config
59
82
 
60
83
  func setRepeatMode(mode: RepeatMode) throws -> Promise<Void> {
61
- Promise.async { await self.core.setRepeatMode(mode: mode) }
84
+ enqueue { self.core.setRepeatModeOnQueue(mode: mode) }
62
85
  }
63
86
 
64
87
  func getRepeatMode() throws -> RepeatMode {
@@ -66,12 +89,12 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
66
89
  }
67
90
 
68
91
  func setVolume(volume: Double) throws -> Promise<Void> {
69
- Promise.async { await self.core.setVolume(volume: volume) }
92
+ enqueue { self.core.setVolumeOnQueue(volume: volume) }
70
93
  }
71
94
 
72
95
  func configure(config: PlayerConfig) throws -> Promise<Void> {
73
- Promise.async {
74
- await self.core.configure(
96
+ enqueue {
97
+ self.core.configureOnQueue(
75
98
  androidAutoEnabled: config.androidAutoEnabled,
76
99
  carPlayEnabled: config.carPlayEnabled,
77
100
  showInNotification: config.showInNotification,
@@ -83,80 +106,80 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
83
106
  // MARK: - Queue / State reads
84
107
 
85
108
  func getActualQueue() throws -> Promise<[TrackItem]> {
86
- Promise.async { await self.core.getActualQueue() }
109
+ enqueue { self.core.getActualQueueInternal() }
87
110
  }
88
111
 
89
112
  func getState() throws -> Promise<PlayerState> {
90
- Promise.async { await self.core.getState() }
113
+ enqueue { self.core.getStateInternal() }
91
114
  }
92
115
 
93
116
  func getCurrentTrackIndex() throws -> Promise<Double> {
94
- Promise.async { Double(await self.core.getCurrentTrackIndex()) }
117
+ enqueue { Double(self.core.currentTrackIndex) }
95
118
  }
96
119
 
97
120
  // MARK: - URL updates / lazy loading
98
121
 
99
122
  func updateTracks(tracks: [TrackItem]) throws -> Promise<Void> {
100
- Promise.async { await self.core.updateTracks(tracks: tracks) }
123
+ enqueue { self.core.updateTracksInternal(tracks: tracks) }
101
124
  }
102
125
 
103
126
  func getTracksById(trackIds: [String]) throws -> Promise<[TrackItem]> {
104
- Promise.async { await self.core.getTracksById(trackIds: trackIds) }
127
+ enqueue { self.core.getPlaylistManager().getTracksById(trackIds: trackIds) }
105
128
  }
106
129
 
107
130
  func getTracksNeedingUrls() throws -> Promise<[TrackItem]> {
108
- Promise.async { await self.core.getTracksNeedingUrls() }
131
+ enqueue { self.core.getTracksNeedingUrlsInternal() }
109
132
  }
110
133
 
111
134
  func getNextTracks(count: Double) throws -> Promise<[TrackItem]> {
112
- Promise.async { await self.core.getNextTracks(count: Int(count)) }
135
+ enqueue { self.core.getNextTracksInternal(count: Int(count)) }
113
136
  }
114
137
 
115
138
  // MARK: - Playback speed
116
139
  func setPlaybackSpeed(speed: Double) throws -> Promise<Void> {
117
- Promise.async { await self.core.setPlaybackSpeed(speed) }
140
+ enqueue { self.core.setPlaybackSpeedOnQueue(speed) }
118
141
  }
119
142
 
120
143
  func getPlaybackSpeed() throws -> Promise<Double> {
121
- Promise.async { await self.core.getPlaybackSpeed() }
144
+ enqueue { self.core.currentPlaybackSpeed }
122
145
  }
123
146
 
124
147
  // MARK: - Temporary queue v2
125
148
 
126
149
  func addToUpNext(trackId: String) throws -> Promise<Void> {
127
- Promise.async { try await self.core.addToUpNext(trackId: trackId) }
150
+ enqueueThrowing { try self.core.addToUpNextOnQueue(trackId: trackId) }
128
151
  }
129
152
 
130
153
  func playNext(trackId: String) throws -> Promise<Void> {
131
- Promise.async { try await self.core.playNext(trackId: trackId) }
154
+ enqueueThrowing { try self.core.playNextOnQueue(trackId: trackId) }
132
155
  }
133
156
 
134
157
  func removeFromPlayNext(trackId: String) throws -> Promise<Bool> {
135
- Promise.async { await self.core.removeFromPlayNext(trackId: trackId) }
158
+ enqueue { self.core.removeFromPlayNextOnQueue(trackId: trackId) }
136
159
  }
137
160
 
138
161
  func removeFromUpNext(trackId: String) throws -> Promise<Bool> {
139
- Promise.async { await self.core.removeFromUpNext(trackId: trackId) }
162
+ enqueue { self.core.removeFromUpNextOnQueue(trackId: trackId) }
140
163
  }
141
164
 
142
165
  func clearPlayNext() throws -> Promise<Void> {
143
- Promise.async { await self.core.clearPlayNext() }
166
+ enqueue { self.core.clearPlayNextOnQueue() }
144
167
  }
145
168
 
146
169
  func clearUpNext() throws -> Promise<Void> {
147
- Promise.async { await self.core.clearUpNext() }
170
+ enqueue { self.core.clearUpNextOnQueue() }
148
171
  }
149
172
 
150
173
  func reorderTemporaryTrack(trackId: String, newIndex: Double) throws -> Promise<Bool> {
151
- Promise.async { await self.core.reorderTemporaryTrack(trackId: trackId, newIndex: Int(newIndex)) }
174
+ enqueue { self.core.reorderTemporaryTrackOnQueue(trackId: trackId, newIndex: Int(newIndex)) }
152
175
  }
153
176
 
154
177
  func getPlayNextQueue() throws -> Promise<[TrackItem]> {
155
- Promise.async { await self.core.getPlayNextQueue() }
178
+ enqueue { self.core.playNextStack }
156
179
  }
157
180
 
158
181
  func getUpNextQueue() throws -> Promise<[TrackItem]> {
159
- Promise.async { await self.core.getUpNextQueue() }
182
+ enqueue { self.core.upNextQueue }
160
183
  }
161
184
 
162
185
  // MARK: - Android Auto (iOS no-op)
@@ -26,6 +26,10 @@ class TrackPlayerCore: NSObject {
26
26
  static let preferredForwardBufferDuration: Double = 30.0
27
27
  static let preloadAssetKeys: [String] = ["playable", "duration", "tracks", "preferredTransform"]
28
28
  static let gaplessPreloadCount: Int = 3
29
+ /// Upcoming AVPlayerItems kept materialized behind the current one. The logical
30
+ /// queue (currentTracks + temp lists) stays complete; only the AVQueuePlayer is
31
+ /// windowed, and it is topped up on every item transition.
32
+ static let queueWindowSize: Int = 4
29
33
  // Stall & failure recovery
30
34
  static let maxFailedItemRetries: Int = 3
31
35
  static let failedItemRetryDelay: TimeInterval = 2.0
@@ -44,7 +48,10 @@ class TrackPlayerCore: NSObject {
44
48
  internal var currentPlaylistId: String?
45
49
  internal var currentTrackIndex: Int = -1
46
50
  internal var currentTracks: [TrackItem] = []
47
- internal var pendingPlaylistUpdateWorkItem: DispatchWorkItem?
51
+ // Bumped on every updatePlaylist request (playerQueue-owned). A queued rebuild
52
+ // whose generation is stale was superseded by a later request and is dropped,
53
+ // so a burst of playlist mutations collapses into a single rebuild.
54
+ internal var playlistUpdateGeneration: UInt64 = 0
48
55
  internal var isManuallySeeked = false
49
56
  internal var currentRepeatMode: RepeatMode = .off
50
57
  internal var currentPlaybackSpeed: Double = 1.0
@@ -239,6 +246,7 @@ class TrackPlayerCore: NSObject {
239
246
  NotificationCenter.default.removeObserver(self)
240
247
  self.pathMonitor?.cancel()
241
248
  self.pathMonitor = nil
249
+ for asset in self.preloadedAssets.values { asset.cancelLoading() }
242
250
  self.preloadedAssets.removeAll()
243
251
  self.failedItemRetryCounts.removeAll()
244
252
  self.redirectResolver.clear()