react-native-nitro-player 1.6.0 → 1.7.0-alpha.1

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 (71) hide show
  1. package/README.md +6 -1
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +9 -1
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +23 -3
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +23 -3
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +34 -17
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +21 -9
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +7 -1
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
  21. package/android/src/main/res/values/strings.xml +7 -0
  22. package/ios/HybridDownloadManager.swift +13 -3
  23. package/ios/HybridTrackPlayer.swift +4 -2
  24. package/ios/core/TrackPlayerCast.swift +32 -1
  25. package/ios/core/TrackPlayerCore.swift +19 -3
  26. package/ios/core/TrackPlayerListener.swift +15 -16
  27. package/ios/core/TrackPlayerNotify.swift +11 -11
  28. package/ios/core/TrackPlayerPlayback.swift +54 -16
  29. package/ios/core/TrackPlayerQueueBuild.swift +14 -2
  30. package/ios/core/TrackPlayerTempQueue.swift +1 -0
  31. package/ios/core/TrackPlayerUrlLoader.swift +11 -3
  32. package/ios/download/DownloadDatabase.swift +16 -5
  33. package/ios/download/DownloadFileManager.swift +12 -15
  34. package/ios/download/DownloadManagerCore.swift +61 -16
  35. package/ios/equalizer/EqualizerCore.swift +37 -9
  36. package/ios/media/MediaSessionManager.swift +89 -4
  37. package/ios/playlist/PlaylistManager.swift +47 -46
  38. package/ios/queue/HybridPlayerQueue.swift +28 -7
  39. package/lib/hooks/callbackManager.d.ts +9 -0
  40. package/lib/hooks/callbackManager.js +33 -0
  41. package/lib/hooks/useActualQueue.js +18 -6
  42. package/lib/hooks/useAndroidAutoConnection.js +2 -4
  43. package/lib/hooks/useAudioDevices.js +21 -24
  44. package/lib/hooks/useDownloadActions.js +38 -16
  45. package/lib/hooks/useDownloadProgress.js +20 -14
  46. package/lib/hooks/useDownloadStorage.js +1 -1
  47. package/lib/hooks/useNowPlaying.js +32 -6
  48. package/lib/hooks/useOnSeek.js +2 -2
  49. package/lib/hooks/usePlaylist.js +6 -15
  50. package/lib/specs/TrackPlayer.nitro.d.ts +1 -2
  51. package/lib/types/PlayerQueue.d.ts +8 -0
  52. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
  54. package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
  55. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
  56. package/package.json +1 -1
  57. package/src/hooks/callbackManager.ts +44 -0
  58. package/src/hooks/useActualQueue.ts +18 -6
  59. package/src/hooks/useAndroidAutoConnection.ts +6 -6
  60. package/src/hooks/useAudioDevices.ts +21 -24
  61. package/src/hooks/useDownloadActions.ts +56 -26
  62. package/src/hooks/useDownloadProgress.ts +22 -19
  63. package/src/hooks/useDownloadStorage.ts +4 -3
  64. package/src/hooks/useNowPlaying.ts +34 -6
  65. package/src/hooks/useOnSeek.ts +2 -2
  66. package/src/hooks/usePlaylist.ts +6 -14
  67. package/src/specs/TrackPlayer.nitro.ts +1 -2
  68. package/src/types/PlayerQueue.ts +8 -0
  69. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
  70. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
  71. package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/QueueManager.kt +0 -143
@@ -1,10 +1,15 @@
1
1
  package com.margelo.nitro.nitroplayer.media
2
2
 
3
3
  import android.content.Context
4
+ import androidx.media3.common.Player
5
+ import androidx.media3.common.util.UnstableApi
6
+ import androidx.media3.session.CommandButton
4
7
  import androidx.media3.session.MediaSession
8
+ import com.margelo.nitro.nitroplayer.R
5
9
  import com.margelo.nitro.nitroplayer.TrackItem
6
10
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
7
11
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
12
+ import java.text.NumberFormat
8
13
 
9
14
  /**
10
15
  * Thin wrapper around a [MediaSession] owned by the playback service.
@@ -12,11 +17,16 @@ import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
12
17
  * No longer creates the session, notification channel, or manages service
13
18
  * start/stop — the service handles all of that automatically per Media3 docs.
14
19
  */
20
+ @UnstableApi
15
21
  class MediaSessionManager(
16
22
  private val context: Context,
17
23
  session: MediaSession,
18
24
  private val playlistManager: PlaylistManager,
19
25
  ) {
26
+ private companion object {
27
+ const val DEFAULT_REMOTE_SKIP_INTERVAL_MS = 15_000L
28
+ }
29
+
20
30
  private var trackPlayerCore: TrackPlayerCore? = null
21
31
 
22
32
  fun setTrackPlayerCore(core: TrackPlayerCore) {
@@ -32,15 +42,26 @@ class MediaSessionManager(
32
42
  private var androidAutoEnabled: Boolean = false
33
43
  private var carPlayEnabled: Boolean = false
34
44
  private var showInNotification: Boolean = true
45
+ private var remoteSkipForwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
46
+ private var remoteSkipBackwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
47
+
48
+ init {
49
+ updateMediaButtonPreferences()
50
+ }
35
51
 
36
52
  fun configure(
37
53
  androidAutoEnabled: Boolean?,
38
54
  carPlayEnabled: Boolean?,
39
55
  showInNotification: Boolean?,
56
+ remoteSkipForwardIntervalMs: Long,
57
+ remoteSkipBackwardIntervalMs: Long,
40
58
  ) {
41
59
  androidAutoEnabled?.let { this.androidAutoEnabled = it }
42
60
  carPlayEnabled?.let { this.carPlayEnabled = it }
43
61
  showInNotification?.let { this.showInNotification = it }
62
+ this.remoteSkipForwardIntervalMs = remoteSkipForwardIntervalMs
63
+ this.remoteSkipBackwardIntervalMs = remoteSkipBackwardIntervalMs
64
+ updateMediaButtonPreferences()
44
65
  }
45
66
 
46
67
  fun onTrackChanged(track: TrackItem?) {
@@ -55,4 +76,82 @@ class MediaSessionManager(
55
76
  // Service owns the session — just null out our reference
56
77
  mediaSession = null
57
78
  }
79
+
80
+ private fun updateMediaButtonPreferences() {
81
+ val session = mediaSession ?: return
82
+ // Preferences replace Media3's default layout — prev/next must be declared or they vanish
83
+ val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() }
84
+ session.setMediaButtonPreferences(
85
+ listOf(previousTrackButton(), skipBackButton(), skipForwardButton(), nextTrackButton()) + preserved,
86
+ )
87
+ }
88
+
89
+ private fun previousTrackButton(): CommandButton =
90
+ CommandButton
91
+ .Builder(CommandButton.ICON_PREVIOUS)
92
+ .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
93
+ .setDisplayName(context.getString(R.string.nitro_player_previous_track))
94
+ .build()
95
+
96
+ private fun nextTrackButton(): CommandButton =
97
+ CommandButton
98
+ .Builder(CommandButton.ICON_NEXT)
99
+ .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
100
+ .setDisplayName(context.getString(R.string.nitro_player_next_track))
101
+ .build()
102
+
103
+ private fun skipBackButton(): CommandButton =
104
+ CommandButton
105
+ .Builder(skipBackIcon(remoteSkipBackwardIntervalMs))
106
+ .setPlayerCommand(Player.COMMAND_SEEK_BACK)
107
+ .setDisplayName(
108
+ context.getString(
109
+ R.string.nitro_player_skip_back_seconds,
110
+ displaySeconds(remoteSkipBackwardIntervalMs),
111
+ ),
112
+ ).build()
113
+
114
+ private fun skipForwardButton(): CommandButton =
115
+ CommandButton
116
+ .Builder(skipForwardIcon(remoteSkipForwardIntervalMs))
117
+ .setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
118
+ .setDisplayName(
119
+ context.getString(
120
+ R.string.nitro_player_skip_forward_seconds,
121
+ displaySeconds(remoteSkipForwardIntervalMs),
122
+ ),
123
+ ).build()
124
+
125
+ private fun CommandButton.isManagedButton(): Boolean =
126
+ playerCommand == Player.COMMAND_SEEK_BACK ||
127
+ playerCommand == Player.COMMAND_SEEK_FORWARD ||
128
+ playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS ||
129
+ playerCommand == Player.COMMAND_SEEK_TO_NEXT
130
+
131
+ private fun skipBackIcon(intervalMs: Long): Int =
132
+ when (intervalMs) {
133
+ 5_000L -> CommandButton.ICON_SKIP_BACK_5
134
+ 10_000L -> CommandButton.ICON_SKIP_BACK_10
135
+ 15_000L -> CommandButton.ICON_SKIP_BACK_15
136
+ 30_000L -> CommandButton.ICON_SKIP_BACK_30
137
+ else -> CommandButton.ICON_SKIP_BACK
138
+ }
139
+
140
+ private fun skipForwardIcon(intervalMs: Long): Int =
141
+ when (intervalMs) {
142
+ 5_000L -> CommandButton.ICON_SKIP_FORWARD_5
143
+ 10_000L -> CommandButton.ICON_SKIP_FORWARD_10
144
+ 15_000L -> CommandButton.ICON_SKIP_FORWARD_15
145
+ 30_000L -> CommandButton.ICON_SKIP_FORWARD_30
146
+ else -> CommandButton.ICON_SKIP_FORWARD
147
+ }
148
+
149
+ private fun displaySeconds(intervalMs: Long): String =
150
+ NumberFormat
151
+ .getNumberInstance()
152
+ .apply {
153
+ maximumFractionDigits = 3
154
+ minimumFractionDigits = 0
155
+ isGroupingUsed = false
156
+ }.format(intervalMs / 1000.0)
58
157
  }
@@ -107,7 +107,13 @@ class NitroPlayerPlaybackService : MediaSessionService() {
107
107
  private fun initCast() {
108
108
  try {
109
109
  val castContext = CastContext.getSharedInstance(this)
110
- val castPlayer = CastPlayer(castContext, NitroMediaItemConverter())
110
+ val castPlayer =
111
+ CastPlayer(
112
+ castContext,
113
+ NitroMediaItemConverter(),
114
+ ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS,
115
+ ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS,
116
+ )
111
117
  castSessionController =
112
118
  CastSessionController(
113
119
  castContext = castContext,
@@ -28,7 +28,7 @@ class PlaylistManager private constructor(
28
28
  ) {
29
29
  private val playlists = ConcurrentHashMap<String, Playlist>()
30
30
  private val listeners = CopyOnWriteArrayList<(List<Playlist>, QueueOperation?) -> Unit>()
31
- private val playlistListeners = mutableMapOf<String, CopyOnWriteArrayList<(Playlist, QueueOperation?) -> Unit>>()
31
+ private val playlistListeners = ConcurrentHashMap<String, CopyOnWriteArrayList<(Playlist, QueueOperation?) -> Unit>>()
32
32
  private var currentPlaylistId: String? = null
33
33
 
34
34
  private val saveScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -59,7 +59,8 @@ class PlaylistManager private constructor(
59
59
  }
60
60
 
61
61
  init {
62
- // Don't load from file on init - only load when Android Auto needs it
62
+ // Synchronous so no mutation can race a late async load and be clobbered
63
+ loadPlaylistsFromFile()
63
64
  }
64
65
 
65
66
  /**
@@ -75,10 +76,7 @@ class PlaylistManager private constructor(
75
76
 
76
77
  playlists[id] = playlist
77
78
 
78
- // Only cache for Android Auto if connected
79
- if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
80
- scheduleSave()
81
- }
79
+ scheduleSave()
82
80
  notifyPlaylistsChanged(QueueOperation.ADD)
83
81
  NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
84
82
 
@@ -96,10 +94,7 @@ class PlaylistManager private constructor(
96
94
  currentPlaylistId = null
97
95
  }
98
96
  playlistListeners.remove(playlistId)
99
- // Only cache for Android Auto if connected
100
- if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
101
- scheduleSave()
102
- }
97
+ scheduleSave()
103
98
  notifyPlaylistsChanged(QueueOperation.REMOVE)
104
99
  NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
105
100
  return true
@@ -117,18 +112,18 @@ class PlaylistManager private constructor(
117
112
  description: String? = null,
118
113
  artwork: String? = null,
119
114
  ): Boolean {
120
- val playlist = playlists[playlistId] ?: return false
121
- playlists[playlistId] =
122
- playlist.copy(
123
- name = name ?: playlist.name,
124
- description = description ?: playlist.description,
125
- artwork = artwork ?: playlist.artwork,
126
- )
127
-
128
- // Only cache for Android Auto if connected
129
- if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
130
- scheduleSave()
131
- }
115
+ // computeIfPresent: read-copy-put from two threads loses one edit
116
+ val updated =
117
+ playlists.computeIfPresent(playlistId) { _, playlist ->
118
+ playlist.copy(
119
+ name = name ?: playlist.name,
120
+ description = description ?: playlist.description,
121
+ artwork = artwork ?: playlist.artwork,
122
+ )
123
+ } != null
124
+ if (!updated) return false
125
+
126
+ scheduleSave()
132
127
  notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
133
128
  notifyPlaylistsChanged(QueueOperation.UPDATE)
134
129
  NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
@@ -154,19 +149,19 @@ class PlaylistManager private constructor(
154
149
  track: TrackItem,
155
150
  index: Int? = null,
156
151
  ): Boolean {
157
- val playlist = playlists[playlistId] ?: return false
158
- val tracks = playlist.tracks.toMutableList()
159
- if (index != null && index >= 0 && index <= tracks.size) {
160
- tracks.add(index, track)
161
- } else {
162
- tracks.add(track)
163
- }
164
- playlists[playlistId] = playlist.copy(tracks = tracks)
152
+ val added =
153
+ playlists.computeIfPresent(playlistId) { _, playlist ->
154
+ val tracks = playlist.tracks.toMutableList()
155
+ if (index != null && index >= 0 && index <= tracks.size) {
156
+ tracks.add(index, track)
157
+ } else {
158
+ tracks.add(track)
159
+ }
160
+ playlist.copy(tracks = tracks)
161
+ } != null
162
+ if (!added) return false
165
163
 
166
- // Only cache for Android Auto if connected
167
- if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
168
- scheduleSave()
169
- }
164
+ scheduleSave()
170
165
  notifyPlaylistChanged(playlistId, QueueOperation.ADD)
171
166
  NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
172
167
  return true
@@ -180,19 +175,19 @@ class PlaylistManager private constructor(
180
175
  tracks: List<TrackItem>,
181
176
  index: Int? = null,
182
177
  ): Boolean {
183
- val playlist = playlists[playlistId] ?: return false
184
- val currentTracks = playlist.tracks.toMutableList()
185
- if (index != null && index >= 0 && index <= currentTracks.size) {
186
- currentTracks.addAll(index, tracks)
187
- } else {
188
- currentTracks.addAll(tracks)
189
- }
190
- playlists[playlistId] = playlist.copy(tracks = currentTracks)
178
+ val added =
179
+ playlists.computeIfPresent(playlistId) { _, playlist ->
180
+ val currentTracks = playlist.tracks.toMutableList()
181
+ if (index != null && index >= 0 && index <= currentTracks.size) {
182
+ currentTracks.addAll(index, tracks)
183
+ } else {
184
+ currentTracks.addAll(tracks)
185
+ }
186
+ playlist.copy(tracks = currentTracks)
187
+ } != null
188
+ if (!added) return false
191
189
 
192
- // Only cache for Android Auto if connected
193
- if (NitroPlayerMediaBrowserService.isAndroidAutoConnected) {
194
- scheduleSave()
195
- }
190
+ scheduleSave()
196
191
  notifyPlaylistChanged(playlistId, QueueOperation.ADD)
197
192
  NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
198
193
  return true
@@ -205,11 +200,15 @@ class PlaylistManager private constructor(
205
200
  playlistId: String,
206
201
  trackId: String,
207
202
  ): Boolean {
208
- val playlist = playlists[playlistId] ?: return false
209
- val tracks = playlist.tracks.toMutableList()
210
- val removed = tracks.removeAll { it.id == trackId }
211
- if (removed) {
212
- playlists[playlistId] = playlist.copy(tracks = tracks)
203
+ var removed = false
204
+ playlists.computeIfPresent(playlistId) { _, playlist ->
205
+ val tracks = playlist.tracks.toMutableList()
206
+ if (tracks.removeAll { it.id == trackId }) {
207
+ removed = true
208
+ playlist.copy(tracks = tracks)
209
+ } else {
210
+ playlist
211
+ }
213
212
  }
214
213
 
215
214
  if (removed) {
@@ -229,15 +228,20 @@ class PlaylistManager private constructor(
229
228
  trackId: String,
230
229
  newIndex: Int,
231
230
  ): Boolean {
232
- val playlist = playlists[playlistId] ?: return false
233
- val tracks = playlist.tracks.toMutableList()
234
- val oldIndex = tracks.indexOfFirst { it.id == trackId }
235
- if (oldIndex < 0 || newIndex < 0 || newIndex >= tracks.size) {
236
- return false
231
+ var reordered = false
232
+ playlists.computeIfPresent(playlistId) { _, playlist ->
233
+ val tracks = playlist.tracks.toMutableList()
234
+ val oldIndex = tracks.indexOfFirst { it.id == trackId }
235
+ if (oldIndex < 0 || newIndex < 0 || newIndex >= tracks.size) {
236
+ playlist
237
+ } else {
238
+ val track = tracks.removeAt(oldIndex)
239
+ tracks.add(newIndex, track)
240
+ reordered = true
241
+ playlist.copy(tracks = tracks)
242
+ }
237
243
  }
238
- val track = tracks.removeAt(oldIndex)
239
- tracks.add(newIndex, track)
240
- playlists[playlistId] = playlist.copy(tracks = tracks)
244
+ if (!reordered) return false
241
245
 
242
246
  scheduleSave()
243
247
  notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
@@ -255,17 +259,19 @@ class PlaylistManager private constructor(
255
259
  val tracksMap = tracks.associateBy { it.id }
256
260
  val affectedPlaylists = mutableMapOf<String, Int>()
257
261
 
258
- playlists.forEach { (playlistId, playlist) ->
259
- var updateCount = 0
260
- val newTracks =
261
- playlist.tracks
262
- .map { track ->
262
+ for (playlistId in playlists.keys) {
263
+ playlists.computeIfPresent(playlistId) { _, playlist ->
264
+ var updateCount = 0
265
+ val newTracks =
266
+ playlist.tracks.map { track ->
263
267
  tracksMap[track.id]?.also { updateCount++ } ?: track
264
- }.toMutableList()
265
-
266
- if (updateCount > 0) {
267
- affectedPlaylists[playlistId] = updateCount
268
- playlists[playlistId] = playlist.copy(tracks = newTracks)
268
+ }
269
+ if (updateCount > 0) {
270
+ affectedPlaylists[playlistId] = updateCount
271
+ playlist.copy(tracks = newTracks.toMutableList())
272
+ } else {
273
+ playlist
274
+ }
269
275
  }
270
276
  }
271
277
 
@@ -336,11 +342,19 @@ class PlaylistManager private constructor(
336
342
  playlistId: String,
337
343
  listener: (Playlist, QueueOperation?) -> Unit,
338
344
  ): () -> Unit {
339
- val playlistListeners = playlistListeners.getOrPut(playlistId) { CopyOnWriteArrayList() }
345
+ val playlistListeners = playlistListeners.computeIfAbsent(playlistId) { CopyOnWriteArrayList() }
340
346
  playlistListeners.add(listener)
341
347
  return { playlistListeners.remove(listener) }
342
348
  }
343
349
 
350
+ private val anyPlaylistListeners = CopyOnWriteArrayList<(Playlist, QueueOperation?) -> Unit>()
351
+
352
+ /** Fires for changes to ANY playlist, including ones created after registration. */
353
+ fun addAnyPlaylistChangeListener(listener: (Playlist, QueueOperation?) -> Unit): () -> Unit {
354
+ anyPlaylistListeners.add(listener)
355
+ return { anyPlaylistListeners.remove(listener) }
356
+ }
357
+
344
358
  private fun notifyPlaylistsChanged(operation: QueueOperation?) {
345
359
  listeners.forEach { it(playlists.values.toList(), operation) }
346
360
  }
@@ -351,6 +365,7 @@ class PlaylistManager private constructor(
351
365
  ) {
352
366
  val playlist = playlists[playlistId] ?: return
353
367
  playlistListeners[playlistId]?.forEach { it(playlist, operation) }
368
+ anyPlaylistListeners.forEach { it(playlist, operation) }
354
369
  }
355
370
 
356
371
  // MARK: - Persistence
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <string name="nitro_player_skip_back_seconds">Skip back %1$s seconds</string>
4
+ <string name="nitro_player_skip_forward_seconds">Skip forward %1$s seconds</string>
5
+ <string name="nitro_player_previous_track">Previous track</string>
6
+ <string name="nitro_player_next_track">Next track</string>
7
+ </resources>
@@ -213,20 +213,30 @@ final class HybridDownloadManager: HybridDownloadManagerSpec {
213
213
 
214
214
  // MARK: - Event Callbacks
215
215
 
216
+ private var progressCallbackIds: [UUID] = []
217
+ private var stateChangeCallbackIds: [UUID] = []
218
+ private var completeCallbackIds: [UUID] = []
219
+
216
220
  func onDownloadProgress(callback: @escaping (DownloadProgress) -> Void) throws {
217
221
  NitroPlayerLogger.log("HybridDownloadManager", "onDownloadProgress callback registered")
218
- core.addProgressCallback(callback)
222
+ progressCallbackIds.append(core.addProgressCallback(callback))
219
223
  }
220
224
 
221
225
  func onDownloadStateChange(
222
226
  callback: @escaping (String, String, DownloadState, DownloadError?) -> Void
223
227
  ) throws {
224
228
  NitroPlayerLogger.log("HybridDownloadManager", "onDownloadStateChange callback registered")
225
- core.addStateChangeCallback(callback)
229
+ stateChangeCallbackIds.append(core.addStateChangeCallback(callback))
226
230
  }
227
231
 
228
232
  func onDownloadComplete(callback: @escaping (DownloadedTrack) -> Void) throws {
229
233
  NitroPlayerLogger.log("HybridDownloadManager", "onDownloadComplete callback registered")
230
- core.addCompleteCallback(callback)
234
+ completeCallbackIds.append(core.addCompleteCallback(callback))
235
+ }
236
+
237
+ deinit {
238
+ progressCallbackIds.forEach { core.removeProgressCallback(id: $0) }
239
+ stateChangeCallbackIds.forEach { core.removeStateChangeCallback(id: $0) }
240
+ completeCallbackIds.forEach { core.removeCompleteCallback(id: $0) }
231
241
  }
232
242
  }
@@ -98,7 +98,9 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
98
98
  androidAutoEnabled: config.androidAutoEnabled,
99
99
  carPlayEnabled: config.carPlayEnabled,
100
100
  showInNotification: config.showInNotification,
101
- lookaheadCount: config.lookaheadCount.map { Int($0) }
101
+ lookaheadCount: config.lookaheadCount.map { Int($0) },
102
+ remoteSkipForwardInterval: config.remoteSkipForwardInterval,
103
+ remoteSkipBackwardInterval: config.remoteSkipBackwardInterval
102
104
  )
103
105
  }
104
106
  }
@@ -137,7 +139,7 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
137
139
 
138
140
  // MARK: - Playback speed
139
141
  func setPlaybackSpeed(speed: Double) throws -> Promise<Void> {
140
- enqueue { self.core.setPlaybackSpeedOnQueue(speed) }
142
+ enqueueThrowing { try self.core.setPlaybackSpeedOnQueue(speed) }
141
143
  }
142
144
 
143
145
  func getPlaybackSpeed() throws -> Promise<Double> {
@@ -67,13 +67,44 @@ extension TrackPlayerCore {
67
67
  guard let self else { return }
68
68
  guard self.isCasting else { return }
69
69
  let resumePosition = self.castManager?.lastKnownRemotePosition ?? 0
70
+ let remoteTrackId = self.castManager?.currentRemoteTrackId
70
71
  self.isCasting = false
71
72
 
72
73
  let index = self.currentTrackIndex
73
74
  guard index >= 0 && index < self.currentTracks.count else { return }
74
75
 
76
+ // rebuildQueueFromPlaylistIndex wipes the temp lists — preserve them
77
+ let savedPlayNext = self.playNextStack
78
+ let savedUpNext = self.upNextQueue
79
+
75
80
  _ = self.rebuildQueueFromPlaylistIndex(index: index)
76
- if resumePosition.isFinite && resumePosition > 0 {
81
+
82
+ if !savedPlayNext.isEmpty || !savedUpNext.isEmpty {
83
+ self.playNextStack = savedPlayNext
84
+ self.upNextQueue = savedUpNext
85
+ self.rebuildAVQueueFromCurrentPosition()
86
+ self.notifyTemporaryQueueChange()
87
+ }
88
+
89
+ // If the receiver was playing the head temp track, make it current again
90
+ // (mirrors skipToNext: remove from its list before it becomes current)
91
+ if let target = remoteTrackId, target != self.player?.currentItem?.trackId {
92
+ if self.playNextStack.first?.id == target {
93
+ self.playNextStack.removeFirst()
94
+ self.player?.advanceToNextItem()
95
+ self.currentTemporaryType = .playNext
96
+ self.notifyTemporaryQueueChange()
97
+ } else if self.playNextStack.isEmpty, self.upNextQueue.first?.id == target {
98
+ self.upNextQueue.removeFirst()
99
+ self.player?.advanceToNextItem()
100
+ self.currentTemporaryType = .upNext
101
+ self.notifyTemporaryQueueChange()
102
+ }
103
+ }
104
+
105
+ // Only seek when the local current track is the one that was playing remotely
106
+ if resumePosition.isFinite && resumePosition > 0,
107
+ remoteTrackId != nil, self.player?.currentItem?.trackId == remoteTrackId {
77
108
  let time = CMTime(seconds: resumePosition, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
78
109
  self.player?.seek(to: time)
79
110
  }
@@ -65,6 +65,9 @@ class TrackPlayerCore: NSObject {
65
65
 
66
66
  // Gapless playback
67
67
  internal var preloadedAssets: [String: AVURLAsset] = [:]
68
+ // playerQueue-owned; bumped on every teardown so in-flight async preloads
69
+ // can't republish a stale asset into the cache after a queue switch
70
+ internal var preloadGeneration: Int = 0
68
71
  internal let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
69
72
  internal var didRequestUrlsForCurrentItem = false
70
73
 
@@ -149,14 +152,26 @@ class TrackPlayerCore: NSObject {
149
152
 
150
153
  internal func setupAudioSession() {
151
154
  do {
152
- let audioSession = AVAudioSession.sharedInstance()
153
- try audioSession.setCategory(.playback, mode: .default, options: [])
154
- try audioSession.setActive(true)
155
+ // Category only — setActive(true) is deferred to first play so merely
156
+ // loading the module doesn't silence other apps' audio.
157
+ try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
155
158
  } catch {
156
159
  NitroPlayerLogger.log("TrackPlayerCore", "❌ Failed to setup audio session - \(error)")
157
160
  }
158
161
  }
159
162
 
163
+ private var audioSessionActivated = false
164
+
165
+ internal func activateAudioSessionIfNeeded() {
166
+ guard !audioSessionActivated else { return }
167
+ do {
168
+ try AVAudioSession.sharedInstance().setActive(true)
169
+ audioSessionActivated = true
170
+ } catch {
171
+ NitroPlayerLogger.log("TrackPlayerCore", "❌ Failed to activate audio session - \(error)")
172
+ }
173
+ }
174
+
160
175
  // MARK: - withPlayerQueue (async bridge to player thread)
161
176
 
162
177
  internal func withPlayerQueue<T>(_ block: @escaping () throws -> T) async throws -> T {
@@ -261,6 +276,7 @@ class TrackPlayerCore: NSObject {
261
276
  self.pathMonitor = nil
262
277
  for asset in self.preloadedAssets.values { asset.cancelLoading() }
263
278
  self.preloadedAssets.removeAll()
279
+ self.preloadGeneration += 1
264
280
  self.failedItemRetryCounts.removeAll()
265
281
  self.redirectResolver.clear()
266
282
  }
@@ -169,12 +169,14 @@ extension TrackPlayerCore {
169
169
  func playerItemDidPlayToEndTimeInternal(_ notification: Notification) {
170
170
  NitroPlayerLogger.log("TrackPlayerCore", "\n🏁 Track finished playing")
171
171
  guard let finishedItem = notification.object as? AVPlayerItem else { return }
172
+ // Items without our trackId belong to another AVPlayer in the host app
173
+ guard finishedItem.trackId != nil else { return }
172
174
 
173
175
  // 1. TRACK repeat — handle FIRST, before any temp-track removal
174
- if currentRepeatMode == .track {
176
+ if currentRepeatMode == .track, finishedItem === player?.currentItem {
175
177
  NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — seeking to zero and replaying")
176
178
  player?.seek(to: .zero)
177
- player?.play()
179
+ player?.rate = Float(currentPlaybackSpeed)
178
180
  return // do not remove temp tracks, do not notify track change (same track looping)
179
181
  }
180
182
 
@@ -201,14 +203,18 @@ extension TrackPlayerCore {
201
203
  }
202
204
 
203
205
  @objc func playerItemFailedToPlayToEndTime(_ notification: Notification) {
204
- if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
206
+ guard (notification.object as? AVPlayerItem)?.trackId != nil else { return }
207
+ guard let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
208
+ else { return }
209
+ playerQueue.async { [weak self] in
205
210
  NitroPlayerLogger.log("TrackPlayerCore", "❌ Playback failed - \(error)")
206
- notifyPlaybackStateChange(.stopped, .error)
211
+ self?.notifyPlaybackStateChange(.stopped, .error)
207
212
  }
208
213
  }
209
214
 
210
215
  @objc func playerItemNewErrorLogEntry(_ notification: Notification) {
211
- guard let item = notification.object as? AVPlayerItem, let errorLog = item.errorLog() else { return }
216
+ guard let item = notification.object as? AVPlayerItem, item.trackId != nil,
217
+ let errorLog = item.errorLog() else { return }
212
218
  for event in errorLog.events ?? [] {
213
219
  NitroPlayerLogger.log("TrackPlayerCore", "❌ Error log - \(event.errorComment ?? "Unknown error") - Code: \(event.errorStatusCode)")
214
220
  }
@@ -220,6 +226,7 @@ extension TrackPlayerCore {
220
226
  @objc func playerItemTimeJumped(_ notification: Notification) {
221
227
  playerQueue.async { [weak self] in
222
228
  guard let self, let player = self.player, let currentItem = player.currentItem else { return }
229
+ guard (notification.object as? AVPlayerItem) === currentItem else { return }
223
230
  let position = currentItem.currentTime().seconds
224
231
  let duration = currentItem.duration.seconds
225
232
  NitroPlayerLogger.log("TrackPlayerCore", "🎯 Time jumped (seek detected) - position: \(Int(position))s")
@@ -301,7 +308,7 @@ extension TrackPlayerCore {
301
308
  lastItem = item
302
309
  }
303
310
  currentTrackIndex = 0
304
- player.play()
311
+ player.rate = Float(currentPlaybackSpeed)
305
312
 
306
313
  if let firstTrack = currentTracks.first {
307
314
  notifyTrackChange(firstTrack, .repeat)
@@ -454,16 +461,8 @@ extension TrackPlayerCore {
454
461
  self?.setupBoundaryTimeObserver()
455
462
  // First item is buffered and ready — disable stall waiting for gapless inter-track transitions
456
463
  self?.player?.automaticallyWaitsToMinimizeStalling = false
457
- // Update now playing info now that duration is available (capture on playerQueue first)
458
- let state = self?.getStateInternal()
459
- let metrics = self?.actualQueueMetrics() ?? (count: 0, position: -1)
460
- let track = self?.getCurrentTrack()
461
- DispatchQueue.main.async {
462
- if let track = track, let state = state {
463
- self?.mediaSessionManager?.updateFromPlayerQueue(
464
- track: track, state: state, queueCount: metrics.count, positionInQueue: metrics.position)
465
- }
466
- }
464
+ // Update now playing info now that duration is available (captured on playerQueue)
465
+ self?.refreshMediaSession()
467
466
  } else if item.status == .failed {
468
467
  NitroPlayerLogger.log("TrackPlayerCore",
469
468
  "❌ Item failed — \(item.error?.localizedDescription ?? "unknown error")")