react-native-nitro-player 1.5.0 → 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
@@ -8,7 +8,7 @@ import com.margelo.nitro.NitroModules
8
8
  import com.margelo.nitro.core.NullType
9
9
  import com.margelo.nitro.core.Promise
10
10
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
11
- import com.margelo.nitro.nitroplayer.core.loadPlaylist
11
+ import com.margelo.nitro.nitroplayer.core.loadPlaylistOnQueue
12
12
  import com.margelo.nitro.nitroplayer.core.updatePlaylist
13
13
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
14
14
  import java.util.UUID
@@ -109,13 +109,24 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
109
109
 
110
110
  // ── Playback control ──────────────────────────────────────────────────────
111
111
 
112
- override fun loadPlaylist(playlistId: String, index: Double?): Promise<Unit> =
113
- Promise.async {
114
- val startIndex = index?.toInt()
115
- val loaded = playlistManager.loadPlaylist(playlistId, startIndex)
116
- if (!loaded) return@async
117
- core.loadPlaylist(playlistId, startIndex)
112
+ /**
113
+ * Enqueued synchronously on the player looper so a `loadPlaylist()` followed by
114
+ * `play()` from JS cannot be reordered — see HybridTrackPlayer.enqueue.
115
+ */
116
+ override fun loadPlaylist(
117
+ playlistId: String,
118
+ index: Double?,
119
+ ): Promise<Unit> {
120
+ val startIndex = index?.toInt()
121
+ val promise = Promise<Unit>()
122
+ core.enqueue {
123
+ if (playlistManager.loadPlaylist(playlistId, startIndex)) {
124
+ core.loadPlaylistOnQueue(playlistId, startIndex)
125
+ }
126
+ promise.resolve(Unit)
118
127
  }
128
+ return promise
129
+ }
119
130
 
120
131
  override fun getCurrentPlaylistId(): Variant_NullType_String {
121
132
  val id = core.getCurrentPlaylistId()
@@ -7,35 +7,30 @@ import com.facebook.proguard.annotations.DoNotStrip
7
7
  import com.margelo.nitro.NitroModules
8
8
  import com.margelo.nitro.core.Promise
9
9
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
10
- import com.margelo.nitro.nitroplayer.core.addToUpNext
11
- import com.margelo.nitro.nitroplayer.core.clearPlayNext
12
- import com.margelo.nitro.nitroplayer.core.clearUpNext
13
- import com.margelo.nitro.nitroplayer.core.configure
14
- import com.margelo.nitro.nitroplayer.core.getActualQueue
15
- import com.margelo.nitro.nitroplayer.core.getCurrentTrackIndex
16
- import com.margelo.nitro.nitroplayer.core.getNextTracks
17
- import com.margelo.nitro.nitroplayer.core.getPlayBackSpeed
18
- import com.margelo.nitro.nitroplayer.core.getPlayNextQueue
10
+ import com.margelo.nitro.nitroplayer.core.addToUpNextInternal
11
+ import com.margelo.nitro.nitroplayer.core.clearPlayNextOnQueue
12
+ import com.margelo.nitro.nitroplayer.core.clearUpNextOnQueue
13
+ import com.margelo.nitro.nitroplayer.core.configureOnQueue
14
+ import com.margelo.nitro.nitroplayer.core.getActualQueueInternal
15
+ import com.margelo.nitro.nitroplayer.core.getNextTracksInternal
19
16
  import com.margelo.nitro.nitroplayer.core.getRepeatMode
20
- import com.margelo.nitro.nitroplayer.core.getState
21
- import com.margelo.nitro.nitroplayer.core.getTracksById
22
- import com.margelo.nitro.nitroplayer.core.getTracksNeedingUrls
23
- import com.margelo.nitro.nitroplayer.core.getUpNextQueue
24
- import com.margelo.nitro.nitroplayer.core.pause
25
- import com.margelo.nitro.nitroplayer.core.play
26
- import com.margelo.nitro.nitroplayer.core.playNext
27
- import com.margelo.nitro.nitroplayer.core.playSong
28
- import com.margelo.nitro.nitroplayer.core.removeFromPlayNext
29
- import com.margelo.nitro.nitroplayer.core.removeFromUpNext
30
- import com.margelo.nitro.nitroplayer.core.reorderTemporaryTrack
31
- import com.margelo.nitro.nitroplayer.core.seek
32
- import com.margelo.nitro.nitroplayer.core.setPlayBackSpeed
33
- import com.margelo.nitro.nitroplayer.core.setRepeatMode
34
- import com.margelo.nitro.nitroplayer.core.setVolume
35
- import com.margelo.nitro.nitroplayer.core.skipToIndex
36
- import com.margelo.nitro.nitroplayer.core.skipToNext
37
- import com.margelo.nitro.nitroplayer.core.skipToPrevious
38
- import com.margelo.nitro.nitroplayer.core.updateTracks
17
+ import com.margelo.nitro.nitroplayer.core.getStateInternal
18
+ import com.margelo.nitro.nitroplayer.core.getTracksNeedingUrlsInternal
19
+ import com.margelo.nitro.nitroplayer.core.pauseOnQueue
20
+ import com.margelo.nitro.nitroplayer.core.playNextInternal
21
+ import com.margelo.nitro.nitroplayer.core.playOnQueue
22
+ import com.margelo.nitro.nitroplayer.core.playSongInternal
23
+ import com.margelo.nitro.nitroplayer.core.removeFromPlayNextOnQueue
24
+ import com.margelo.nitro.nitroplayer.core.removeFromUpNextOnQueue
25
+ import com.margelo.nitro.nitroplayer.core.reorderTemporaryTrackOnQueue
26
+ import com.margelo.nitro.nitroplayer.core.seekOnQueue
27
+ import com.margelo.nitro.nitroplayer.core.setPlayBackSpeedOnQueue
28
+ import com.margelo.nitro.nitroplayer.core.setRepeatModeOnQueue
29
+ import com.margelo.nitro.nitroplayer.core.setVolumeOnQueue
30
+ import com.margelo.nitro.nitroplayer.core.skipToIndexInternal
31
+ import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
32
+ import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
33
+ import com.margelo.nitro.nitroplayer.core.updateTracksOnQueue
39
34
 
40
35
  @DoNotStrip
41
36
  @Keep
@@ -52,79 +47,99 @@ class HybridTrackPlayer : HybridTrackPlayerSpec() {
52
47
  core = TrackPlayerCore.getInstance(context)
53
48
  }
54
49
 
50
+ // ── Ordered dispatch ──────────────────────────────────────────────────────
51
+ //
52
+ // `Promise.async { … }` launches an unordered coroutine, so the hop onto the player
53
+ // looper happened at an arbitrary later point: two calls made in order from JS could
54
+ // reach the player in reverse order (play-then-pause landing as pause-then-play).
55
+ // `core.enqueue` appends synchronously, on the JS thread at call time, so execution
56
+ // order equals JS call order.
57
+
58
+ private fun <T> enqueue(block: () -> T): Promise<T> {
59
+ val promise = Promise<T>()
60
+ core.enqueue {
61
+ try {
62
+ promise.resolve(block())
63
+ } catch (e: Throwable) {
64
+ promise.reject(e)
65
+ }
66
+ }
67
+ return promise
68
+ }
69
+
55
70
  // ── Playback ─────────────────────────────────────────────────────────────
56
71
 
57
- override fun play(): Promise<Unit> = Promise.async { core.play() }
72
+ override fun play(): Promise<Unit> = enqueue { core.playOnQueue() }
58
73
 
59
- override fun pause(): Promise<Unit> = Promise.async { core.pause() }
74
+ override fun pause(): Promise<Unit> = enqueue { core.pauseOnQueue() }
60
75
 
61
- override fun seek(position: Double): Promise<Unit> = Promise.async { core.seek(position) }
76
+ override fun seek(position: Double): Promise<Unit> = enqueue { core.seekOnQueue(position) }
62
77
 
63
- override fun skipToNext(): Promise<Unit> = Promise.async { core.skipToNext() }
78
+ override fun skipToNext(): Promise<Unit> = enqueue { core.skipToNextOnQueue() }
64
79
 
65
- override fun skipToPrevious(): Promise<Unit> = Promise.async { core.skipToPrevious() }
80
+ override fun skipToPrevious(): Promise<Unit> = enqueue { core.skipToPreviousOnQueue() }
66
81
 
67
82
  override fun playSong(
68
83
  songId: String,
69
84
  fromPlaylist: String?,
70
- ): Promise<Unit> = Promise.async { core.playSong(songId, fromPlaylist) }
85
+ ): Promise<Unit> = enqueue { core.playSongInternal(songId, fromPlaylist) }
71
86
 
72
- override fun skipToIndex(index: Double): Promise<Boolean> = Promise.async { core.skipToIndex(index.toInt()) }
87
+ override fun skipToIndex(index: Double): Promise<Boolean> = enqueue { core.skipToIndexInternal(index.toInt()) }
73
88
 
74
- override fun setRepeatMode(mode: RepeatMode): Promise<Unit> = Promise.async { core.setRepeatMode(mode) }
89
+ override fun setRepeatMode(mode: RepeatMode): Promise<Unit> = enqueue { core.setRepeatModeOnQueue(mode) }
75
90
 
76
91
  override fun getRepeatMode(): RepeatMode = core.getRepeatMode()
77
92
 
78
- override fun setVolume(volume: Double): Promise<Unit> = Promise.async { core.setVolume(volume) }
93
+ override fun setVolume(volume: Double): Promise<Unit> = enqueue { core.setVolumeOnQueue(volume) }
79
94
 
80
- override fun configure(config: PlayerConfig): Promise<Unit> = Promise.async { core.configure(config) }
95
+ override fun configure(config: PlayerConfig): Promise<Unit> = enqueue { core.configureOnQueue(config) }
81
96
 
82
97
  // ── Queue / state reads ───────────────────────────────────────────────────
83
98
 
84
- override fun getActualQueue(): Promise<Array<TrackItem>> = Promise.async { core.getActualQueue().toTypedArray() }
99
+ override fun getActualQueue(): Promise<Array<TrackItem>> = enqueue { core.getActualQueueInternal().toTypedArray() }
85
100
 
86
- override fun getState(): Promise<PlayerState> = Promise.async { core.getState() }
101
+ override fun getState(): Promise<PlayerState> = enqueue { core.getStateInternal() }
87
102
 
88
- override fun getTracksById(trackIds: Array<String>): Promise<Array<TrackItem>> = Promise.async { core.getTracksById(trackIds.toList()).toTypedArray() }
103
+ override fun getTracksById(trackIds: Array<String>): Promise<Array<TrackItem>> = enqueue { core.getPlaylistManager().getTracksById(trackIds.toList()).toTypedArray() }
89
104
 
90
- override fun getTracksNeedingUrls(): Promise<Array<TrackItem>> = Promise.async { core.getTracksNeedingUrls().toTypedArray() }
105
+ override fun getTracksNeedingUrls(): Promise<Array<TrackItem>> = enqueue { core.getTracksNeedingUrlsInternal().toTypedArray() }
91
106
 
92
- override fun getNextTracks(count: Double): Promise<Array<TrackItem>> = Promise.async { core.getNextTracks(count.toInt()).toTypedArray() }
107
+ override fun getNextTracks(count: Double): Promise<Array<TrackItem>> = enqueue { core.getNextTracksInternal(count.toInt()).toTypedArray() }
93
108
 
94
- override fun getCurrentTrackIndex(): Promise<Double> = Promise.async { core.getCurrentTrackIndex().toDouble() }
109
+ override fun getCurrentTrackIndex(): Promise<Double> = enqueue { core.currentTrackIndex.toDouble() }
95
110
 
96
111
  // ── URL updates ───────────────────────────────────────────────────────────
97
112
 
98
- override fun updateTracks(tracks: Array<TrackItem>): Promise<Unit> = Promise.async { core.updateTracks(tracks.toList()) }
113
+ override fun updateTracks(tracks: Array<TrackItem>): Promise<Unit> = enqueue { core.updateTracksOnQueue(tracks.toList()) }
99
114
 
100
115
  // ── Temporary queue ───────────────────────────────────────────────────────
101
116
 
102
- override fun addToUpNext(trackId: String): Promise<Unit> = Promise.async { core.addToUpNext(trackId) }
117
+ override fun addToUpNext(trackId: String): Promise<Unit> = enqueue { core.addToUpNextInternal(trackId) }
103
118
 
104
- override fun playNext(trackId: String): Promise<Unit> = Promise.async { core.playNext(trackId) }
119
+ override fun playNext(trackId: String): Promise<Unit> = enqueue { core.playNextInternal(trackId) }
105
120
 
106
- override fun removeFromPlayNext(trackId: String): Promise<Boolean> = Promise.async { core.removeFromPlayNext(trackId) }
121
+ override fun removeFromPlayNext(trackId: String): Promise<Boolean> = enqueue { core.removeFromPlayNextOnQueue(trackId) }
107
122
 
108
- override fun removeFromUpNext(trackId: String): Promise<Boolean> = Promise.async { core.removeFromUpNext(trackId) }
123
+ override fun removeFromUpNext(trackId: String): Promise<Boolean> = enqueue { core.removeFromUpNextOnQueue(trackId) }
109
124
 
110
- override fun clearPlayNext(): Promise<Unit> = Promise.async { core.clearPlayNext() }
125
+ override fun clearPlayNext(): Promise<Unit> = enqueue { core.clearPlayNextOnQueue() }
111
126
 
112
- override fun clearUpNext(): Promise<Unit> = Promise.async { core.clearUpNext() }
127
+ override fun clearUpNext(): Promise<Unit> = enqueue { core.clearUpNextOnQueue() }
113
128
 
114
129
  override fun reorderTemporaryTrack(
115
130
  trackId: String,
116
131
  newIndex: Double,
117
- ): Promise<Boolean> = Promise.async { core.reorderTemporaryTrack(trackId, newIndex.toInt()) }
132
+ ): Promise<Boolean> = enqueue { core.reorderTemporaryTrackOnQueue(trackId, newIndex.toInt()) }
118
133
 
119
- override fun getPlayNextQueue(): Promise<Array<TrackItem>> = Promise.async { core.getPlayNextQueue().toTypedArray() }
134
+ override fun getPlayNextQueue(): Promise<Array<TrackItem>> = enqueue { core.playNextStack.toTypedArray() }
120
135
 
121
- override fun getUpNextQueue(): Promise<Array<TrackItem>> = Promise.async { core.getUpNextQueue().toTypedArray() }
136
+ override fun getUpNextQueue(): Promise<Array<TrackItem>> = enqueue { core.upNextQueue.toTypedArray() }
122
137
 
123
138
  // ── Playback speed ────────────────────────────────────────────────────────
124
139
 
125
- override fun setPlaybackSpeed(speed: Double): Promise<Unit> = Promise.async { core.setPlayBackSpeed(speed) }
140
+ override fun setPlaybackSpeed(speed: Double): Promise<Unit> = enqueue { core.setPlayBackSpeedOnQueue(speed) }
126
141
 
127
- override fun getPlaybackSpeed(): Promise<Double> = Promise.async { core.getPlayBackSpeed() }
142
+ override fun getPlaybackSpeed(): Promise<Double> = enqueue { if (core.isExoInitialized) core.exo.getPlaybackSpeed().toDouble() else 1.0 }
128
143
 
129
144
  // ── Android Auto ──────────────────────────────────────────────────────────
130
145
 
@@ -94,6 +94,7 @@ class TrackPlayerCore private constructor(
94
94
  ListenerRegistry<(com.margelo.nitro.nitroplayer.CastState, String?) -> Unit>()
95
95
 
96
96
  // ── Google Cast ──────────────────────────────────────────────────────────
97
+
97
98
  /** Set once the playback service has a usable CastContext; null when Cast is unavailable. */
98
99
  internal var castSessionController: com.margelo.nitro.nitroplayer.media.CastSessionController? = null
99
100
 
@@ -104,18 +105,23 @@ class TrackPlayerCore private constructor(
104
105
  internal var lastCastWaitTrackId: String? = null
105
106
 
106
107
  // ── Progress & playlist-update runnables ───────────────────────────────
108
+
109
+ /**
110
+ * Emits progress once per second while actually playing (matching iOS), instead of
111
+ * four times a second regardless of state. A paused player has nothing to report,
112
+ * and every tick is a JS callback that re-renders every `useNowPlaying` consumer.
113
+ * A seek still reports immediately via `onPositionDiscontinuity`.
114
+ */
107
115
  internal val progressUpdateRunnable =
108
116
  object : Runnable {
109
117
  override fun run() {
110
- if (::exo.isInitialized &&
111
- exo.playbackState != androidx.media3.common.Player.STATE_IDLE
112
- ) {
118
+ if (::exo.isInitialized && exo.isPlaying) {
113
119
  val pos = exo.currentPosition / 1000.0
114
120
  val dur = if (exo.duration > 0) exo.duration / 1000.0 else 0.0
115
121
  notifyPlaybackProgress(pos, dur, if (isManuallySeeked) true else null)
116
122
  isManuallySeeked = false
117
123
  }
118
- playerHandler.postDelayed(this, 250)
124
+ playerHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
119
125
  }
120
126
  }
121
127
 
@@ -155,7 +161,8 @@ class TrackPlayerCore private constructor(
155
161
  }
156
162
  try {
157
163
  context.unbindService(this)
158
- } catch (_: Exception) {}
164
+ } catch (_: Exception) {
165
+ }
159
166
  serviceBound = false
160
167
  if (rebindAttempts < 3) {
161
168
  rebindAttempts++
@@ -192,6 +199,8 @@ class TrackPlayerCore private constructor(
192
199
 
193
200
  // ── Singleton ──────────────────────────────────────────────────────────
194
201
  companion object {
202
+ internal const val PROGRESS_INTERVAL_MS = 1000L
203
+
195
204
  @Volatile
196
205
  @Suppress("ktlint:standard:property-naming")
197
206
  private var INSTANCE: TrackPlayerCore? = null
@@ -206,8 +215,18 @@ class TrackPlayerCore private constructor(
206
215
  // Defer service start/bind to the main thread so it doesn't run
207
216
  // synchronously on the JNI thread during HybridObject creation.
208
217
  handler.post {
209
- val intent = Intent(context, NitroPlayerPlaybackService::class.java)
210
- context.startService(intent)
218
+ // startService throws BackgroundServiceStartNotAllowedException on Android 12+
219
+ // when the process is in the background — which is exactly what happens when JS
220
+ // loads the module from a headless/backgrounded start. It is only here to keep
221
+ // the service alive across unbind; BIND_AUTO_CREATE below already creates it,
222
+ // and the service promotes itself to foreground once playback starts.
223
+ try {
224
+ context.startService(Intent(context, NitroPlayerPlaybackService::class.java))
225
+ } catch (e: Exception) {
226
+ NitroPlayerLogger.log("TrackPlayerCore") {
227
+ "startService skipped (app in background): ${e.message}"
228
+ }
229
+ }
211
230
 
212
231
  val bindIntent =
213
232
  Intent(context, NitroPlayerPlaybackService::class.java).apply {
@@ -219,6 +238,58 @@ class TrackPlayerCore private constructor(
219
238
 
220
239
  // ── Coroutine bridge to player looper (main thread) ────────────────────
221
240
 
241
+ // ── Ordered command dispatch ───────────────────────────────────────────
242
+ //
243
+ // `Promise.async { … }` launches an unordered coroutine, so the hop onto the
244
+ // player looper used to happen at an arbitrary later point: two calls made in
245
+ // order from JS could reach the player in reverse order (play-then-pause landing
246
+ // as pause-then-play). `enqueue` appends to a queue synchronously, on the JS
247
+ // thread at call time, so execution order equals JS call order — including for
248
+ // commands issued before the playback service has bound.
249
+
250
+ private val pendingCommands = ArrayDeque<() -> Unit>()
251
+ private var commandsDraining = false
252
+
253
+ /**
254
+ * Runs [block] on the player looper, preserving the order in which callers
255
+ * enqueued. Safe to call before the service binds — commands buffer until then.
256
+ */
257
+ internal fun enqueue(block: () -> Unit) {
258
+ synchronized(pendingCommands) {
259
+ pendingCommands.addLast(block)
260
+ if (commandsDraining) return
261
+ if (!::playerHandler.isInitialized) return
262
+ commandsDraining = true
263
+ }
264
+ playerHandler.post { drainCommands() }
265
+ }
266
+
267
+ private fun drainCommands() {
268
+ while (true) {
269
+ val next =
270
+ synchronized(pendingCommands) {
271
+ val head = pendingCommands.removeFirstOrNull()
272
+ if (head == null) commandsDraining = false
273
+ head
274
+ } ?: return
275
+ next()
276
+ }
277
+ }
278
+
279
+ /** Called once the player looper exists — flushes anything queued before binding. */
280
+ internal fun startCommandDraining() {
281
+ val shouldPost =
282
+ synchronized(pendingCommands) {
283
+ if (commandsDraining || pendingCommands.isEmpty()) {
284
+ false
285
+ } else {
286
+ commandsDraining = true
287
+ true
288
+ }
289
+ }
290
+ if (shouldPost) playerHandler.post { drainCommands() }
291
+ }
292
+
222
293
  internal suspend fun <T> withPlayerContext(block: () -> T): T {
223
294
  // Wait until the service is bound and player is initialized
224
295
  serviceReady.await()
@@ -256,13 +327,15 @@ class TrackPlayerCore private constructor(
256
327
  }
257
328
  }
258
329
  scope.cancel()
259
- com.margelo.nitro.nitroplayer.media.AuthAwareHttpDataSourceFactory.clear()
330
+ com.margelo.nitro.nitroplayer.media.AuthAwareHttpDataSourceFactory
331
+ .clear()
260
332
  // Do NOT stop the service — it owns the player.
261
333
  // Unbind so Android can clean up if needed.
262
334
  if (serviceBound) {
263
335
  try {
264
336
  context.unbindService(serviceConnection)
265
- } catch (_: Exception) {}
337
+ } catch (_: Exception) {
338
+ }
266
339
  serviceBound = false
267
340
  }
268
341
  }
@@ -57,6 +57,16 @@ internal class TrackPlayerEventListener(
57
57
  if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
58
58
  }
59
59
 
60
+ // Top up the windowed timeline — the item we just left freed a slot.
61
+ // Only when it is actually short: rebuildQueueFromCurrentPosition can fall
62
+ // into the substitute-jump path (setMediaItems), which re-enters this very
63
+ // callback. The length check makes any such re-entry a no-op and terminate.
64
+ if (mediaItem != null &&
65
+ exo.mediaItemCount - exo.currentMediaItemIndex - 1 < QUEUE_WINDOW_SIZE
66
+ ) {
67
+ rebuildQueueFromCurrentPosition()
68
+ }
69
+
60
70
  val track = getCurrentTrack() ?: return
61
71
  val r =
62
72
  when (reason) {
@@ -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