react-native-nitro-player 1.7.0-alpha.3 → 1.7.0-alpha.4

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 (59) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +46 -14
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +2 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +36 -12
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +9 -3
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +7 -3
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +8 -2
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +26 -1
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +60 -0
  15. package/ios/cast/CastQueueDiff.swift +58 -0
  16. package/ios/cast/CastQueueReconciler.swift +228 -0
  17. package/ios/core/CastSessionManager.swift +215 -31
  18. package/ios/core/TrackPlayerCast.swift +96 -65
  19. package/ios/core/TrackPlayerCore.swift +31 -0
  20. package/ios/core/TrackPlayerListener.swift +19 -3
  21. package/ios/core/TrackPlayerPlayback.swift +12 -10
  22. package/ios/core/TrackPlayerQueue.swift +10 -2
  23. package/ios/core/TrackPlayerQueueBuild.swift +21 -9
  24. package/ios/core/TrackPlayerTempQueue.swift +27 -15
  25. package/ios/core/TrackPlayerUrlLoader.swift +6 -2
  26. package/ios/playlist/PlaylistManager.swift +60 -1
  27. package/ios/queue/HybridPlayerQueue.swift +40 -13
  28. package/lib/hooks/callbackManager.d.ts +2 -0
  29. package/lib/hooks/callbackManager.js +23 -8
  30. package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
  31. package/lib/types/PlayerQueue.d.ts +14 -0
  32. package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
  33. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
  34. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
  35. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
  36. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
  37. package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
  42. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
  43. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
  44. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
  45. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
  46. package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
  47. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
  48. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
  49. package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
  50. package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
  51. package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
  52. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
  53. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
  54. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
  55. package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
  56. package/package.json +1 -1
  57. package/src/hooks/callbackManager.ts +25 -10
  58. package/src/specs/TrackPlayer.nitro.ts +11 -0
  59. package/src/types/PlayerQueue.ts +17 -0
package/README.md CHANGED
@@ -82,6 +82,8 @@ Mutations return **`Promise<void>`** (or **`Promise<string>`** for `createPlayli
82
82
  | `addTracksToPlaylist(pid, tracks)` | Both | **Async**. Adds multiple tracks to a playlist. |
83
83
  | `removeTrackFromPlaylist(pid, tid)` | Both | **Async**. Removes a track from a playlist. |
84
84
  | `reorderTrackInPlaylist(pid, tid, idx)` | Both | **Async**. Moves a track to a new position. |
85
+ | `removeTracksFromPlaylist(pid, tids)` | Both | **Async**. Removes several tracks in one call. |
86
+ | `shufflePlaylist(pid, options?)` | Both | **Async**. Shuffles a playlist without stopping playback.|
85
87
 
86
88
  ### Platform-Specific APIs
87
89
 
@@ -940,6 +942,13 @@ PlayerQueue.removeTrackFromPlaylist(playlistId, trackId)
940
942
  // Reorder tracks
941
943
  PlayerQueue.reorderTrackInPlaylist(playlistId, trackId, newIndex)
942
944
 
945
+ // Remove several tracks in one native call
946
+ PlayerQueue.removeTracksFromPlaylist(playlistId, [trackId1, trackId2])
947
+
948
+ // Shuffle in place — the playing track keeps playing, no shuffle mode to turn off
949
+ PlayerQueue.shufflePlaylist(playlistId)
950
+ PlayerQueue.shufflePlaylist(playlistId, { keepCurrentTrackFirst: false })
951
+
943
952
  // Delete a playlist
944
953
  PlayerQueue.deletePlaylist(playlistId)
945
954
  ```
@@ -94,16 +94,11 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
94
94
  core.updatePlaylist(playlistId)
95
95
  }
96
96
 
97
+ /** Same semantics as the batch form: unknown ids are skipped, only a missing playlist rejects. */
97
98
  override fun removeTrackFromPlaylist(
98
99
  playlistId: String,
99
100
  trackId: String,
100
- ): Promise<Unit> =
101
- Promise.async {
102
- if (!playlistManager.removeTrackFromPlaylist(playlistId, trackId)) {
103
- throw IllegalArgumentException("Track $trackId not found in playlist $playlistId")
104
- }
105
- core.updatePlaylist(playlistId)
106
- }
101
+ ): Promise<Unit> = removeTracksFromPlaylist(playlistId, arrayOf(trackId))
107
102
 
108
103
  override fun reorderTrackInPlaylist(
109
104
  playlistId: String,
@@ -117,6 +112,49 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
117
112
  core.updatePlaylist(playlistId)
118
113
  }
119
114
 
115
+ /** On the player looper: the live queue must reflect the removal before the promise resolves. */
116
+ override fun removeTracksFromPlaylist(
117
+ playlistId: String,
118
+ trackIds: Array<String>,
119
+ ): Promise<Unit> {
120
+ val promise = Promise<Unit>()
121
+ core.enqueue {
122
+ val removed = playlistManager.removeTracksFromPlaylist(playlistId, trackIds.toList())
123
+ if (removed == null) {
124
+ promise.reject(IllegalArgumentException("Playlist not found: $playlistId"))
125
+ return@enqueue
126
+ }
127
+ if (removed > 0 && core.getCurrentPlaylistId() == playlistId) core.syncCurrentPlaylistOnQueue()
128
+ promise.resolve(Unit)
129
+ }
130
+ return promise
131
+ }
132
+
133
+ /** On the player looper: the anchor id is player-thread state. */
134
+ override fun shufflePlaylist(
135
+ playlistId: String,
136
+ options: ShufflePlaylistOptions?,
137
+ ): Promise<Unit> {
138
+ val promise = Promise<Unit>()
139
+ core.enqueue {
140
+ val isCurrent = core.getCurrentPlaylistId() == playlistId
141
+ val pin =
142
+ if (isCurrent && options?.keepCurrentTrackFirst != false) {
143
+ core.currentTracks.getOrNull(core.currentTrackIndex)?.id
144
+ } else {
145
+ null
146
+ }
147
+ val changed = playlistManager.shufflePlaylist(playlistId, pin)
148
+ if (changed == null) {
149
+ promise.reject(IllegalArgumentException("Playlist not found: $playlistId"))
150
+ return@enqueue
151
+ }
152
+ if (changed && isCurrent) core.syncCurrentPlaylistOnQueue()
153
+ promise.resolve(Unit)
154
+ }
155
+ return promise
156
+ }
157
+
120
158
  // ── Playback control ──────────────────────────────────────────────────────
121
159
 
122
160
  /**
@@ -0,0 +1,65 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer.core
4
+
5
+ import androidx.annotation.OptIn
6
+ import androidx.media3.common.AudioAttributes
7
+ import androidx.media3.common.C
8
+ import androidx.media3.common.util.UnstableApi
9
+ import androidx.media3.exoplayer.ExoPlayer
10
+ import com.margelo.nitro.nitroplayer.AndroidAudioFocusMode
11
+ import com.margelo.nitro.nitroplayer.media.AudioFocusController
12
+
13
+ /**
14
+ * Audio focus policy. ExoPlayer's built-in handling pauses on every transient loss — even the
15
+ * duckable kind a notification asks for — so 'duck' and 'ignore' take focus into our own hands.
16
+ */
17
+
18
+ internal const val DUCK_VOLUME_FACTOR = 0.2f
19
+
20
+ @OptIn(UnstableApi::class)
21
+ internal fun TrackPlayerCore.applyAudioFocusMode(mode: AndroidAudioFocusMode) {
22
+ if (mode == audioFocusMode && (mode == AndroidAudioFocusMode.PAUSE) == (audioFocusController == null)) return
23
+ audioFocusMode = mode
24
+
25
+ audioFocusController?.abandon()
26
+ audioFocusController = null
27
+ isDucked = false
28
+
29
+ val local = exoOrNull() ?: return
30
+ // media3 only knows how to pause; it owns focus for the default mode and nothing else.
31
+ local.setAudioAttributes(mediaAudioAttributes(), mode == AndroidAudioFocusMode.PAUSE)
32
+ local.volume = userVolume
33
+
34
+ if (mode == AndroidAudioFocusMode.DUCK) {
35
+ audioFocusController =
36
+ AudioFocusController(
37
+ context,
38
+ onDuck = { ducked -> applyDuck(ducked) },
39
+ onPause = { playerHandler.post { pauseOnQueue() } },
40
+ )
41
+ if (isExoInitialized && exo.player.isPlaying) audioFocusController?.acquire()
42
+ }
43
+ }
44
+
45
+ @OptIn(UnstableApi::class)
46
+ internal fun TrackPlayerCore.applyDuck(ducked: Boolean) {
47
+ playerHandler.post {
48
+ isDucked = ducked
49
+ exoOrNull()?.volume = userVolume * if (ducked) DUCK_VOLUME_FACTOR else 1f
50
+ }
51
+ }
52
+
53
+ private fun mediaAudioAttributes(): AudioAttributes =
54
+ AudioAttributes
55
+ .Builder()
56
+ .setUsage(C.USAGE_MEDIA)
57
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
58
+ .build()
59
+
60
+ /** The local ExoPlayer, or null while casting (the CastPlayer has no audio focus of its own). */
61
+ @OptIn(UnstableApi::class)
62
+ internal fun TrackPlayerCore.exoOrNull(): ExoPlayer? {
63
+ if (!isExoInitialized) return null
64
+ return exo.player as? ExoPlayer
65
+ }
@@ -22,6 +22,8 @@ import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
22
22
  import com.margelo.nitro.nitroplayer.download.DownloadManagerCore
23
23
  import com.margelo.nitro.nitroplayer.media.ExoPlayerBuilder
24
24
  import com.margelo.nitro.nitroplayer.media.MediaLibraryManager
25
+ import com.margelo.nitro.nitroplayer.AndroidAudioFocusMode
26
+ import com.margelo.nitro.nitroplayer.media.AudioFocusController
25
27
  import com.margelo.nitro.nitroplayer.media.MediaSessionManager
26
28
  import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
27
29
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
@@ -71,6 +73,13 @@ class TrackPlayerCore private constructor(
71
73
  @Volatile internal var currentRepeatMode: RepeatMode = RepeatMode.OFF
72
74
  internal var lookaheadCount: Int = 5
73
75
 
76
+ internal var audioFocusMode: AndroidAudioFocusMode = AndroidAudioFocusMode.PAUSE
77
+ internal var audioFocusController: AudioFocusController? = null
78
+
79
+ /** Volume the app asked for; ducking scales this instead of overwriting it. */
80
+ internal var userVolume: Float = 1f
81
+ internal var isDucked: Boolean = false
82
+
74
83
  internal var remoteSkipForwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
75
84
  internal var remoteSkipBackwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
76
85
  internal var playerListener: androidx.media3.common.Player.Listener? = null
@@ -144,21 +153,44 @@ class TrackPlayerCore private constructor(
144
153
  playerHandler.postDelayed(progressUpdateRunnable, PROGRESS_INTERVAL_MS)
145
154
  }
146
155
 
147
- internal val updateCurrentPlaylistRunnable =
148
- Runnable {
149
- val id = currentPlaylistId ?: return@Runnable
150
- val playlist = playlistManager.getPlaylist(id) ?: return@Runnable
151
- currentTracks = playlist.tracks
152
- if (::exo.isInitialized &&
153
- exo.currentMediaItem != null &&
154
- exo.currentMediaItemIndex >= 0
155
- ) {
156
- rebuildQueueFromCurrentPosition()
157
- } else {
158
- updatePlayerQueue(playlist.tracks)
159
- }
160
- checkUpcomingTracksForUrls(lookaheadCount)
156
+ internal enum class AnchorOutcome { NONE, KEPT, REMOVED }
157
+
158
+ /** Re-resolves the anchor by id, or moves the cursor to the resume slot when it was removed. */
159
+ internal fun assignCurrentTracks(next: List<TrackItem>): AnchorOutcome {
160
+ val old = currentTracks
161
+ val anchor = old.getOrNull(currentTrackIndex)
162
+ currentTracks = next
163
+ if (anchor == null) return AnchorOutcome.NONE
164
+ val newIndex = next.indexOfFirst { it.id == anchor.id }
165
+ if (newIndex >= 0) {
166
+ currentTrackIndex = newIndex
167
+ return AnchorOutcome.KEPT
161
168
  }
169
+ val alive = next.mapTo(HashSet()) { it.id }
170
+ val followerId = old.drop(currentTrackIndex + 1).firstOrNull { it.id in alive }?.id
171
+ val resume = followerId?.let { id -> next.indexOfFirst { it.id == id } } ?: next.size
172
+ currentTrackIndex = resume - 1
173
+ return AnchorOutcome.REMOVED
174
+ }
175
+
176
+ /** Applies the stored playlist to the live queue without re-preparing the playing item. */
177
+ internal fun syncCurrentPlaylistOnQueue() {
178
+ playerHandler.removeCallbacks(updateCurrentPlaylistRunnable)
179
+ val id = currentPlaylistId ?: return
180
+ val playlist = playlistManager.getPlaylist(id) ?: return
181
+ assignCurrentTracks(playlist.tracks)
182
+ if (::exo.isInitialized &&
183
+ exo.currentMediaItem != null &&
184
+ exo.currentMediaItemIndex >= 0
185
+ ) {
186
+ rebuildQueueFromCurrentPosition()
187
+ } else {
188
+ updatePlayerQueue(playlist.tracks)
189
+ }
190
+ checkUpcomingTracksForUrls(lookaheadCount)
191
+ }
192
+
193
+ internal val updateCurrentPlaylistRunnable = Runnable { syncCurrentPlaylistOnQueue() }
162
194
 
163
195
  // ── Service binding ────────────────────────────────────────────────────
164
196
  private var serviceBound = false
@@ -64,6 +64,8 @@ internal class TrackPlayerEventListener(
64
64
  val newIdx = currentTracks.indexOfFirst { it.id == trackId }
65
65
  if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
66
66
  }
67
+ // A transient play-out ends here: restore the user's repeat mode.
68
+ core.applyBackendRepeatMode()
67
69
 
68
70
  val wrappedToStart = currentRepeatMode == RepeatMode.PLAYLIST &&
69
71
  currentTrackIndex == 0 && previousTrackIndex == currentTracks.size - 1
@@ -20,11 +20,17 @@ import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
20
20
 
21
21
  suspend fun TrackPlayerCore.play() = withPlayerContext { playOnQueue() }
22
22
 
23
- internal fun TrackPlayerCore.playOnQueue() = exo.play()
23
+ internal fun TrackPlayerCore.playOnQueue() {
24
+ audioFocusController?.acquire()
25
+ exo.play()
26
+ }
24
27
 
25
28
  suspend fun TrackPlayerCore.pause() = withPlayerContext { pauseOnQueue() }
26
29
 
27
- internal fun TrackPlayerCore.pauseOnQueue() = exo.pause()
30
+ internal fun TrackPlayerCore.pauseOnQueue() {
31
+ exo.pause()
32
+ audioFocusController?.abandon()
33
+ }
28
34
 
29
35
  suspend fun TrackPlayerCore.seek(position: Double) = withPlayerContext { seekOnQueue(position) }
30
36
 
@@ -55,9 +61,8 @@ internal fun TrackPlayerCore.skipToPreviousOnQueue() {
55
61
  }
56
62
 
57
63
  currentTemporaryType != TrackPlayerCore.TemporaryType.NONE -> {
58
- // playFromIndexInternal clears both temp lists (matching iOS behavior),
59
- // so no per-track removal is needed here
60
- playFromIndexInternal(currentTrackIndex)
64
+ // playFromIndexInternal clears both temp lists; cursor may be -1 (anchor removed).
65
+ if (currentTracks.isEmpty()) exo.seekTo(0) else playFromIndexInternal(maxOf(0, currentTrackIndex))
61
66
  }
62
67
 
63
68
  currentTrackIndex > 0 -> {
@@ -76,12 +81,7 @@ suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) = withPlayerContext
76
81
  internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
77
82
  val previousMode = currentRepeatMode
78
83
  currentRepeatMode = mode
79
- exo.setRepeatMode(
80
- when (mode) {
81
- RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
82
- else -> Player.REPEAT_MODE_OFF
83
- },
84
- )
84
+ applyBackendRepeatMode()
85
85
  if (!isCastingField && previousMode != mode &&
86
86
  (previousMode == RepeatMode.PLAYLIST || mode == RepeatMode.PLAYLIST)
87
87
  ) {
@@ -89,18 +89,41 @@ internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
89
89
  }
90
90
  }
91
91
 
92
+ /** True while the playing item was removed from the playlist and is finishing. */
93
+ internal fun TrackPlayerCore.isTransientPlayOut(): Boolean {
94
+ if (!isExoInitialized || currentTemporaryType != TrackPlayerCore.TemporaryType.NONE) return false
95
+ val item = exo.currentMediaItem ?: return false
96
+ return findTrack(item) == null
97
+ }
98
+
99
+ /** Follows the user's mode except during a transient play-out, which must finish once. */
100
+ internal fun TrackPlayerCore.backendRepeatMode(): Int =
101
+ when {
102
+ isTransientPlayOut() -> Player.REPEAT_MODE_OFF
103
+ currentRepeatMode == RepeatMode.TRACK -> Player.REPEAT_MODE_ONE
104
+ else -> Player.REPEAT_MODE_OFF
105
+ }
106
+
107
+ internal fun TrackPlayerCore.applyBackendRepeatMode() {
108
+ if (!isExoInitialized) return
109
+ val desired = backendRepeatMode()
110
+ if (exo.player.repeatMode != desired) exo.setRepeatMode(desired)
111
+ }
112
+
92
113
  fun TrackPlayerCore.getRepeatMode(): RepeatMode = currentRepeatMode
93
114
 
94
115
  suspend fun TrackPlayerCore.setVolume(volume: Double) = withPlayerContext { setVolumeOnQueue(volume) }
95
116
 
96
117
  internal fun TrackPlayerCore.setVolumeOnQueue(volume: Double) {
97
118
  val clamped = volume.coerceIn(0.0, 100.0)
98
- exo.setVolume((clamped / 100.0).toFloat())
119
+ userVolume = (clamped / 100.0).toFloat()
120
+ exo.setVolume(userVolume * if (isDucked) DUCK_VOLUME_FACTOR else 1f)
99
121
  }
100
122
 
101
123
  suspend fun TrackPlayerCore.configure(config: PlayerConfig) = withPlayerContext { configureOnQueue(config) }
102
124
 
103
125
  internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
126
+ config.androidAudioFocus?.let { applyAudioFocusMode(it) }
104
127
  config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it }
105
128
  config.lookaheadCount?.let { lookaheadCount = it.toInt() }
106
129
  config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
@@ -185,6 +208,7 @@ internal fun TrackPlayerCore.playSongInternal(
185
208
 
186
209
  if (targetPlaylistId == null || songIndex < 0) return
187
210
 
211
+ playlistManager.setCurrentPlaylistId(targetPlaylistId)
188
212
  if (currentPlaylistId != targetPlaylistId) {
189
213
  val playlist = playlistManager.getPlaylist(targetPlaylistId) ?: return
190
214
  currentPlaylistId = targetPlaylistId
@@ -72,8 +72,8 @@ suspend fun TrackPlayerCore.getActualQueue(): List<TrackItem> = withPlayerContex
72
72
 
73
73
  internal fun TrackPlayerCore.getActualQueueInternal(): List<TrackItem> {
74
74
  if (!isExoInitialized) return emptyList()
75
+ // May be -1 (no anchor while a temp plays); the arithmetic below handles it.
75
76
  val currentIndex = currentTrackIndex
76
- if (currentIndex < 0) return emptyList()
77
77
 
78
78
  val queue = ArrayList<TrackItem>(currentTracks.size + playNextStack.size + upNextQueue.size)
79
79
 
@@ -88,6 +88,7 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
88
88
  currentTrackIndex = index
89
89
  exo.setMediaItems(mediaItems, true)
90
90
  exo.prepare()
91
+ applyBackendRepeatMode()
91
92
  }
92
93
 
93
94
  // ── Surgical rebuild (preserve current item) ──────────────────────────────
@@ -105,9 +106,13 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
105
106
  currentTracks.none { it.id == currentTrackId } &&
106
107
  currentTemporaryType == TrackPlayerCore.TemporaryType.NONE
107
108
  ) {
108
- if (currentTracks.isEmpty()) return
109
- playFromIndexInternal(minOf(currentTrackIndex, currentTracks.size - 1))
110
- return
109
+ if (currentTracks.isNotEmpty()) {
110
+ // currentTrackIndex + 1 is the resume slot set by assignCurrentTracks.
111
+ playFromIndexInternal(minOf(currentTrackIndex + 1, currentTracks.size - 1))
112
+ return
113
+ }
114
+ // Transient play-out: the removed item must finish once, not loop.
115
+ applyBackendRepeatMode()
111
116
  }
112
117
 
113
118
  // Keep the logical playlist pointer in sync after playlist mutations.
@@ -248,6 +253,7 @@ internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
248
253
  if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
249
254
  exo.prepare()
250
255
  }
256
+ applyBackendRepeatMode()
251
257
  }
252
258
 
253
259
  // ── MediaItem construction (member extension to access downloadManager) ────
@@ -24,7 +24,9 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
24
24
  val safeTracks =
25
25
  tracks.filter { track ->
26
26
  when {
27
- track.id == currentTrackId && !currentTrackIsEmpty -> false
27
+ // Same URL is a metadata-only edit (a title or artwork refresh): it reaches
28
+ // the session without touching the media, so gapless is unaffected.
29
+ track.id == currentTrackId && !currentTrackIsEmpty -> track.url == currentTrack?.url
28
30
 
29
31
  // preserve gapless
30
32
  track.id == currentTrackId && currentTrackIsEmpty -> track.url.isNotEmpty()
@@ -40,7 +42,9 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
40
42
 
41
43
  // Replace the current MediaItem when its URL just resolved (local only — the cast branch below reloads instead).
42
44
  val currentTrackResolvedNow = currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()
43
- if (!isCastingField && currentTrackResolvedNow) {
45
+ val currentMetadataChanged =
46
+ currentTrackUpdate != null && !currentTrackIsEmpty && currentTrackUpdate.url == currentTrack?.url
47
+ if (!isCastingField && (currentTrackResolvedNow || currentMetadataChanged)) {
44
48
  val exoIndex = exo.currentMediaItemIndex
45
49
  if (exoIndex >= 0) {
46
50
  val playlistId = currentPlaylistId ?: ""
@@ -53,7 +57,7 @@ internal fun TrackPlayerCore.updateTracksOnQueue(tracks: List<TrackItem>) {
53
57
  if (currentPlaylistId != null && affectedPlaylists.containsKey(currentPlaylistId)) {
54
58
  val refreshedPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
55
59
  if (refreshedPlaylist != null) {
56
- currentTracks = refreshedPlaylist.tracks
60
+ assignCurrentTracks(refreshedPlaylist.tracks)
57
61
  val updatedById = currentTracks.associateBy { it.id }
58
62
  playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
59
63
  upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
@@ -0,0 +1,87 @@
1
+ package com.margelo.nitro.nitroplayer.media
2
+
3
+ import android.content.Context
4
+ import android.media.AudioAttributes
5
+ import android.media.AudioFocusRequest
6
+ import android.media.AudioManager
7
+ import android.os.Build
8
+
9
+ /**
10
+ * Owns audio focus for the 'duck' mode. ExoPlayer's own handling pauses on every transient
11
+ * loss, duckable or not, which is why a notification can stop playback outright.
12
+ */
13
+ class AudioFocusController(
14
+ context: Context,
15
+ private val onDuck: (Boolean) -> Unit,
16
+ private val onPause: () -> Unit,
17
+ ) {
18
+ private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
19
+ private var request: AudioFocusRequest? = null
20
+ private var ducked = false
21
+ private var held = false
22
+
23
+ private val listener =
24
+ AudioManager.OnAudioFocusChangeListener { change ->
25
+ when (change) {
26
+ // Another app took focus for good — stop and let it go.
27
+ AudioManager.AUDIOFOCUS_LOSS -> {
28
+ setDucked(false)
29
+ onPause()
30
+ abandon()
31
+ }
32
+
33
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
34
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
35
+ -> setDucked(true)
36
+
37
+ AudioManager.AUDIOFOCUS_GAIN -> setDucked(false)
38
+ }
39
+ }
40
+
41
+ private fun setDucked(next: Boolean) {
42
+ if (ducked == next) return
43
+ ducked = next
44
+ onDuck(next)
45
+ }
46
+
47
+ /** Returns false when the system refused focus, so the caller can leave playback alone. */
48
+ fun acquire(): Boolean {
49
+ if (held) return true
50
+ val attributes =
51
+ AudioAttributes
52
+ .Builder()
53
+ .setUsage(AudioAttributes.USAGE_MEDIA)
54
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
55
+ .build()
56
+ val result =
57
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
58
+ val built =
59
+ AudioFocusRequest
60
+ .Builder(AudioManager.AUDIOFOCUS_GAIN)
61
+ .setAudioAttributes(attributes)
62
+ .setWillPauseWhenDucked(false)
63
+ .setOnAudioFocusChangeListener(listener)
64
+ .build()
65
+ request = built
66
+ audioManager.requestAudioFocus(built)
67
+ } else {
68
+ @Suppress("DEPRECATION")
69
+ audioManager.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
70
+ }
71
+ held = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
72
+ return held
73
+ }
74
+
75
+ fun abandon() {
76
+ if (!held) return
77
+ held = false
78
+ setDucked(false)
79
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
80
+ request?.let { audioManager.abandonAudioFocusRequest(it) }
81
+ request = null
82
+ } else {
83
+ @Suppress("DEPRECATION")
84
+ audioManager.abandonAudioFocus(listener)
85
+ }
86
+ }
87
+ }
@@ -8,6 +8,7 @@ import androidx.media3.datasource.DefaultDataSource
8
8
  import androidx.media3.exoplayer.DefaultLoadControl
9
9
  import androidx.media3.exoplayer.ExoPlayer
10
10
  import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
11
+ import androidx.media3.exoplayer.source.ShuffleOrder
11
12
 
12
13
  /**
13
14
  * Builds an [ExoPlayer] with the standard NitroPlayer configuration.
@@ -59,5 +60,7 @@ object ExoPlayerBuilder {
59
60
  .setWakeMode(C.WAKE_MODE_NETWORK)
60
61
  .setPauseAtEndOfMediaItems(false)
61
62
  .build()
63
+ // Queue order is ours; a controller's shuffle toggle must not randomize the window.
64
+ .apply { setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) }
62
65
  }
63
66
  }
@@ -79,10 +79,12 @@ class MediaSessionManager(
79
79
 
80
80
  private fun updateMediaButtonPreferences() {
81
81
  val session = mediaSession ?: return
82
- // Preferences replace Media3's default layout — prev/next must be declared or they vanish
82
+ // Preferences replace Media3's default layout — prev/next must be declared or they vanish.
83
+ // Compact only shows BACK / play-pause / FORWARD, so the ±N skips go to OVERFLOW; left in
84
+ // the forward slot they push Next out of the compact notification entirely.
83
85
  val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() }
84
86
  session.setMediaButtonPreferences(
85
- listOf(previousTrackButton(), skipBackButton(), skipForwardButton(), nextTrackButton()) + preserved,
87
+ listOf(previousTrackButton(), nextTrackButton(), skipBackButton(), skipForwardButton()) + preserved,
86
88
  )
87
89
  }
88
90
 
@@ -90,6 +92,7 @@ class MediaSessionManager(
90
92
  CommandButton
91
93
  .Builder(CommandButton.ICON_PREVIOUS)
92
94
  .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
95
+ .setSlots(CommandButton.SLOT_BACK)
93
96
  .setDisplayName(context.getString(R.string.nitro_player_previous_track))
94
97
  .build()
95
98
 
@@ -97,6 +100,7 @@ class MediaSessionManager(
97
100
  CommandButton
98
101
  .Builder(CommandButton.ICON_NEXT)
99
102
  .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
103
+ .setSlots(CommandButton.SLOT_FORWARD)
100
104
  .setDisplayName(context.getString(R.string.nitro_player_next_track))
101
105
  .build()
102
106
 
@@ -104,6 +108,7 @@ class MediaSessionManager(
104
108
  CommandButton
105
109
  .Builder(skipBackIcon(remoteSkipBackwardIntervalMs))
106
110
  .setPlayerCommand(Player.COMMAND_SEEK_BACK)
111
+ .setSlots(CommandButton.SLOT_OVERFLOW)
107
112
  .setDisplayName(
108
113
  context.getString(
109
114
  R.string.nitro_player_skip_back_seconds,
@@ -115,6 +120,7 @@ class MediaSessionManager(
115
120
  CommandButton
116
121
  .Builder(skipForwardIcon(remoteSkipForwardIntervalMs))
117
122
  .setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
123
+ .setSlots(CommandButton.SLOT_OVERFLOW)
118
124
  .setDisplayName(
119
125
  context.getString(
120
126
  R.string.nitro_player_skip_forward_seconds,
@@ -7,6 +7,7 @@ import android.os.Handler
7
7
  import android.os.IBinder
8
8
  import android.os.Looper
9
9
  import androidx.media3.cast.CastPlayer
10
+ import androidx.media3.common.ForwardingPlayer
10
11
  import androidx.media3.common.util.UnstableApi
11
12
  import androidx.media3.exoplayer.ExoPlayer
12
13
  import androidx.media3.session.DefaultMediaNotificationProvider
@@ -15,6 +16,8 @@ import androidx.media3.session.MediaSessionService
15
16
  import com.google.android.gms.cast.framework.CastContext
16
17
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
17
18
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
19
+ import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
20
+ import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
18
21
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
19
22
 
20
23
  /**
@@ -81,10 +84,32 @@ class NitroPlayerPlaybackService : MediaSessionService() {
81
84
  // Build ExoPlayer on main looper (default)
82
85
  player = ExoPlayerBuilder.build(this)
83
86
 
87
+ // The timeline is a window over the logical queue, so ExoPlayer's own prev/next would
88
+ // only ever move inside it — remote Previous seeks to 0 instead of the previous track.
89
+ // Route the remote controls through the same logical skips JS uses.
90
+ val sessionPlayer =
91
+ object : ForwardingPlayer(player) {
92
+ override fun seekToPrevious() {
93
+ trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPrevious()
94
+ }
95
+
96
+ override fun seekToPreviousMediaItem() {
97
+ trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPreviousMediaItem()
98
+ }
99
+
100
+ override fun seekToNext() {
101
+ trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNext()
102
+ }
103
+
104
+ override fun seekToNextMediaItem() {
105
+ trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNextMediaItem()
106
+ }
107
+ }
108
+
84
109
  // Build MediaSession
85
110
  val playlistManager = PlaylistManager.getInstance(this)
86
111
  mediaSession = MediaSession
87
- .Builder(this, player)
112
+ .Builder(this, sessionPlayer)
88
113
  .setCallback(MediaSessionCallbackFactory.create(this, playlistManager))
89
114
  .apply { buildSessionActivity()?.let { setSessionActivity(it) } }
90
115
  .build()