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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +33 -11
  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 +23 -24
  61. package/src/hooks/useDownloadActions.ts +35 -11
  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
package/README.md CHANGED
@@ -52,7 +52,7 @@ Command-style methods return **`Promise<void>`** (or another **`Promise`**) and
52
52
  | `skipToNext()` | Both | **Async**. Skips to the next track in the queue. |
53
53
  | `skipToPrevious()` | Both | **Async**. Skips to the previous track. |
54
54
  | `seek(position)` | Both | **Async**. Seeks to a specific time position in seconds. |
55
- | `setPlaybackSpeed(speed)` | Both | **Async**. Sets playback speed (e.g. 0.5x, 1x, 1.5x, 2x). |
55
+ | `setPlaybackSpeed(speed)` | Both | **Async**. Sets playback speed; rejects non-finite and non-positive values. |
56
56
  | `getPlaybackSpeed()` | Both | **Async**. Gets the current playback speed. |
57
57
  | `setVolume(0-100)` | Both | **Async**. Sets playback volume (0-100). |
58
58
  | `setRepeatMode(mode)` | Both | **Async**. Sets repeat mode (`off`, `track`, `Playlist`). |
@@ -131,9 +131,13 @@ await TrackPlayer.configure({
131
131
  androidAutoEnabled: true,
132
132
  carPlayEnabled: false,
133
133
  showInNotification: true,
134
+ remoteSkipForwardInterval: 15,
135
+ remoteSkipBackwardInterval: 15,
134
136
  })
135
137
  ```
136
138
 
139
+ Remote skip intervals are in seconds. iOS uses them for AirPods/Control Center fixed skip buttons and exposes remote playback rates `0.5, 1.0, 1.25, 1.5, 1.75, 2.0`; Android uses them for the Media3 seek back/forward notification buttons and does not expose a playback-rate media button. Press-and-hold continuous seek is not implemented on either platform. Custom intervals apply to local playback; during a Google Cast session the receiver skips by a fixed 15 seconds.
140
+
137
141
  ### 2. Create Playlists
138
142
 
139
143
  ```typescript
@@ -1227,6 +1231,7 @@ AndroidAutoMediaLibraryHelper.set({
1227
1231
 
1228
1232
  - ✅ **Playlist Management**: Create, update, and manage multiple playlists
1229
1233
  - ✅ **Playback Controls**: Play, pause, seek, skip tracks
1234
+ - ✅ **Remote Controls**: AirPods, lock screen, and notification skip controls
1230
1235
  - ✅ **Volume Control**: Adjust playback volume (0-100)
1231
1236
  - ✅ **React Hooks**: Built-in hooks for reactive state management
1232
1237
  - ✅ **Event Listeners**: Listen to track changes, state changes, and more
@@ -67,11 +67,19 @@ class HybridAudioDevices : HybridAudioDevicesSpec() {
67
67
  audioManager.setCommunicationDevice(device)
68
68
  } else {
69
69
  when (device.type) {
70
- AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> {
70
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> {
71
71
  audioManager.startBluetoothSco()
72
72
  audioManager.isBluetoothScoOn = true
73
73
  }
74
74
 
75
+ // A2DP is the default media route — routing it through SCO
76
+ // would drop music to narrowband call quality
77
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> {
78
+ audioManager.stopBluetoothSco()
79
+ audioManager.isBluetoothScoOn = false
80
+ audioManager.isSpeakerphoneOn = false
81
+ }
82
+
75
83
  AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> {
76
84
  audioManager.isSpeakerphoneOn = true
77
85
  }
@@ -129,9 +129,29 @@ class HybridDownloadManager : HybridDownloadManagerSpec() {
129
129
  override fun getPlaybackSourcePreference(): PlaybackSource = core.getPlaybackSourcePreference()
130
130
 
131
131
  // ── Events ────────────────────────────────────────────────────────────────
132
- override fun onDownloadProgress(callback: (progress: DownloadProgress) -> Unit) = core.addProgressCallback(callback)
132
+ private val listenerIds = java.util.concurrent.CopyOnWriteArrayList<Pair<String, Long>>()
133
133
 
134
- override fun onDownloadStateChange(callback: (downloadId: String, trackId: String, state: DownloadState, error: DownloadError?) -> Unit) = core.addStateChangeCallback(callback)
134
+ override fun onDownloadProgress(callback: (progress: DownloadProgress) -> Unit) {
135
+ listenerIds.add("progress" to core.addProgressCallback(callback))
136
+ }
137
+
138
+ override fun onDownloadStateChange(callback: (downloadId: String, trackId: String, state: DownloadState, error: DownloadError?) -> Unit) {
139
+ listenerIds.add("state" to core.addStateChangeCallback(callback))
140
+ }
141
+
142
+ override fun onDownloadComplete(callback: (downloadedTrack: DownloadedTrack) -> Unit) {
143
+ listenerIds.add("complete" to core.addCompleteCallback(callback))
144
+ }
135
145
 
136
- override fun onDownloadComplete(callback: (downloadedTrack: DownloadedTrack) -> Unit) = core.addCompleteCallback(callback)
146
+ override fun dispose() {
147
+ super.dispose()
148
+ listenerIds.forEach { (type, id) ->
149
+ when (type) {
150
+ "progress" -> core.removeProgressCallback(id)
151
+ "state" -> core.removeStateChangeCallback(id)
152
+ "complete" -> core.removeCompleteCallback(id)
153
+ }
154
+ }
155
+ listenerIds.clear()
156
+ }
137
157
  }
@@ -63,9 +63,29 @@ class HybridEqualizer : HybridEqualizerSpec() {
63
63
  override fun reset(): Promise<Unit> = Promise.async { core.reset() }
64
64
 
65
65
  // ── Events ────────────────────────────────────────────────────────────────
66
- override fun onEnabledChange(callback: (enabled: Boolean) -> Unit) = core.addOnEnabledChangeListener(callback)
66
+ private val listenerIds = java.util.concurrent.CopyOnWriteArrayList<Pair<String, Long>>()
67
67
 
68
- override fun onBandChange(callback: (bands: Array<EqualizerBand>) -> Unit) = core.addOnBandChangeListener(callback)
68
+ override fun onEnabledChange(callback: (enabled: Boolean) -> Unit) {
69
+ listenerIds.add("enabled" to core.addOnEnabledChangeListener(callback))
70
+ }
71
+
72
+ override fun onBandChange(callback: (bands: Array<EqualizerBand>) -> Unit) {
73
+ listenerIds.add("band" to core.addOnBandChangeListener(callback))
74
+ }
69
75
 
70
- override fun onPresetChange(callback: (presetName: Variant_NullType_String?) -> Unit) = core.addOnPresetChangeListener(callback)
76
+ override fun onPresetChange(callback: (presetName: Variant_NullType_String?) -> Unit) {
77
+ listenerIds.add("preset" to core.addOnPresetChangeListener(callback))
78
+ }
79
+
80
+ override fun dispose() {
81
+ super.dispose()
82
+ listenerIds.forEach { (type, id) ->
83
+ when (type) {
84
+ "enabled" -> core.removeOnEnabledChangeListener(id)
85
+ "band" -> core.removeOnBandChangeListener(id)
86
+ "preset" -> core.removeOnPresetChangeListener(id)
87
+ }
88
+ }
89
+ listenerIds.clear()
90
+ }
71
91
  }
@@ -11,7 +11,6 @@ import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
11
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
- import java.util.UUID
15
14
  import com.margelo.nitro.nitroplayer.playlist.Playlist as InternalPlaylist
16
15
 
17
16
  @DoNotStrip
@@ -29,7 +28,6 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
29
28
  }
30
29
 
31
30
  private val playlistsChangeListeners = java.util.concurrent.CopyOnWriteArrayList<() -> Unit>()
32
- private val playlistChangeListeners = java.util.concurrent.ConcurrentHashMap<String, () -> Unit>()
33
31
 
34
32
  // ── Playlist CRUD ─────────────────────────────────────────────────────────
35
33
 
@@ -41,7 +39,9 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
41
39
 
42
40
  override fun deletePlaylist(playlistId: String): Promise<Unit> =
43
41
  Promise.async {
44
- playlistManager.deletePlaylist(playlistId)
42
+ if (!playlistManager.deletePlaylist(playlistId)) {
43
+ throw IllegalArgumentException("Playlist not found: $playlistId")
44
+ }
45
45
  }
46
46
 
47
47
  override fun updatePlaylist(
@@ -51,7 +51,9 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
51
51
  artwork: String?,
52
52
  ): Promise<Unit> =
53
53
  Promise.async {
54
- playlistManager.updatePlaylist(playlistId, name, description, artwork)
54
+ if (!playlistManager.updatePlaylist(playlistId, name, description, artwork)) {
55
+ throw IllegalArgumentException("Playlist not found: $playlistId")
56
+ }
55
57
  core.updatePlaylist(playlistId)
56
58
  }
57
59
 
@@ -74,7 +76,9 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
74
76
  index: Double?,
75
77
  ): Promise<Unit> =
76
78
  Promise.async {
77
- playlistManager.addTrackToPlaylist(playlistId, track, index?.toInt())
79
+ if (!playlistManager.addTrackToPlaylist(playlistId, track, index?.toInt())) {
80
+ throw IllegalArgumentException("Playlist not found: $playlistId")
81
+ }
78
82
  core.updatePlaylist(playlistId)
79
83
  }
80
84
 
@@ -84,7 +88,9 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
84
88
  index: Double?,
85
89
  ): Promise<Unit> =
86
90
  Promise.async {
87
- playlistManager.addTracksToPlaylist(playlistId, tracks.toList(), index?.toInt())
91
+ if (!playlistManager.addTracksToPlaylist(playlistId, tracks.toList(), index?.toInt())) {
92
+ throw IllegalArgumentException("Playlist not found: $playlistId")
93
+ }
88
94
  core.updatePlaylist(playlistId)
89
95
  }
90
96
 
@@ -93,7 +99,9 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
93
99
  trackId: String,
94
100
  ): Promise<Unit> =
95
101
  Promise.async {
96
- playlistManager.removeTrackFromPlaylist(playlistId, trackId)
102
+ if (!playlistManager.removeTrackFromPlaylist(playlistId, trackId)) {
103
+ throw IllegalArgumentException("Track $trackId not found in playlist $playlistId")
104
+ }
97
105
  core.updatePlaylist(playlistId)
98
106
  }
99
107
 
@@ -103,7 +111,9 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
103
111
  newIndex: Double,
104
112
  ): Promise<Unit> =
105
113
  Promise.async {
106
- playlistManager.reorderTrackInPlaylist(playlistId, trackId, newIndex.toInt())
114
+ if (!playlistManager.reorderTrackInPlaylist(playlistId, trackId, newIndex.toInt())) {
115
+ throw IllegalArgumentException("Invalid reorder for track $trackId in playlist $playlistId")
116
+ }
107
117
  core.updatePlaylist(playlistId)
108
118
  }
109
119
 
@@ -122,8 +132,10 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
122
132
  core.enqueue {
123
133
  if (playlistManager.loadPlaylist(playlistId, startIndex)) {
124
134
  core.loadPlaylistOnQueue(playlistId, startIndex)
135
+ promise.resolve(Unit)
136
+ } else {
137
+ promise.reject(IllegalArgumentException("Invalid playlist or index: $playlistId"))
125
138
  }
126
- promise.resolve(Unit)
127
139
  }
128
140
  return promise
129
141
  }
@@ -148,14 +160,19 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
148
160
  }
149
161
 
150
162
  override fun onPlaylistChanged(callback: (playlistId: String, playlist: Playlist, operation: QueueOperation?) -> Unit) {
151
- val listenerId = UUID.randomUUID().toString()
152
- playlistManager.getAllPlaylists().forEach { internalPlaylist ->
153
- val removeListener =
154
- playlistManager.addPlaylistChangeListener(internalPlaylist.id) { playlist, operation ->
155
- callback(playlist.id, playlist.toPlaylist(), operation)
156
- }
157
- playlistChangeListeners[listenerId] = removeListener
158
- }
163
+ // Manager-level listener: covers playlists created after registration,
164
+ // and one remover per callback instead of one per playlist
165
+ val removeListener =
166
+ playlistManager.addAnyPlaylistChangeListener { playlist, operation ->
167
+ callback(playlist.id, playlist.toPlaylist(), operation)
168
+ }
169
+ playlistsChangeListeners.add(removeListener)
170
+ }
171
+
172
+ override fun dispose() {
173
+ super.dispose()
174
+ playlistsChangeListeners.forEach { it() }
175
+ playlistsChangeListeners.clear()
159
176
  }
160
177
 
161
178
  // ── Helper ────────────────────────────────────────────────────────────────
@@ -144,29 +144,29 @@ class AndroidAutoConnectionDetector(
144
144
  return
145
145
  }
146
146
 
147
- val carConnectionTypeColumn = response.getColumnIndex(CAR_CONNECTION_STATE)
148
- if (carConnectionTypeColumn < 0) {
149
- NitroPlayerLogger.log("AndroidAutoConnection", "⚠️ Connection type column missing, treating as disconnected")
150
- notifyCarDisconnected()
151
- return
152
- }
153
-
154
- if (!response.moveToNext()) {
155
- NitroPlayerLogger.log("AndroidAutoConnection", "⚠️ Empty response, treating as disconnected")
156
- notifyCarDisconnected()
157
- return
147
+ response.use { cursor ->
148
+ val carConnectionTypeColumn = cursor.getColumnIndex(CAR_CONNECTION_STATE)
149
+ if (carConnectionTypeColumn < 0) {
150
+ NitroPlayerLogger.log("AndroidAutoConnection", "⚠️ Connection type column missing, treating as disconnected")
151
+ notifyCarDisconnected()
152
+ return
153
+ }
154
+
155
+ if (!cursor.moveToNext()) {
156
+ NitroPlayerLogger.log("AndroidAutoConnection", "⚠️ Empty response, treating as disconnected")
157
+ notifyCarDisconnected()
158
+ return
159
+ }
160
+
161
+ val connectionState = cursor.getInt(carConnectionTypeColumn)
162
+ NitroPlayerLogger.log("AndroidAutoConnection", "📊 Connection state queried: $connectionState")
163
+
164
+ if (connectionState == CONNECTION_TYPE_NOT_CONNECTED) {
165
+ notifyCarDisconnected()
166
+ } else {
167
+ notifyCarConnected(connectionState)
168
+ }
158
169
  }
159
-
160
- val connectionState = response.getInt(carConnectionTypeColumn)
161
- NitroPlayerLogger.log("AndroidAutoConnection", "📊 Connection state queried: $connectionState")
162
-
163
- if (connectionState == CONNECTION_TYPE_NOT_CONNECTED) {
164
- notifyCarDisconnected()
165
- } else {
166
- notifyCarConnected(connectionState)
167
- }
168
-
169
- response.close()
170
170
  }
171
171
  }
172
172
  }
@@ -12,7 +12,7 @@ import android.os.Bundle
12
12
  import android.os.Handler
13
13
  import android.os.IBinder
14
14
  import android.os.Looper
15
- import com.facebook.react.bridge.ReactApplicationContext
15
+ import com.margelo.nitro.NitroModules
16
16
  import com.margelo.nitro.nitroplayer.Reason
17
17
  import com.margelo.nitro.nitroplayer.RepeatMode
18
18
  import com.margelo.nitro.nitroplayer.TimedMetadata
@@ -20,6 +20,7 @@ import com.margelo.nitro.nitroplayer.TrackItem
20
20
  import com.margelo.nitro.nitroplayer.TrackPlayerState
21
21
  import com.margelo.nitro.nitroplayer.connection.AndroidAutoConnectionDetector
22
22
  import com.margelo.nitro.nitroplayer.download.DownloadManagerCore
23
+ import com.margelo.nitro.nitroplayer.media.ExoPlayerBuilder
23
24
  import com.margelo.nitro.nitroplayer.media.MediaLibraryManager
24
25
  import com.margelo.nitro.nitroplayer.media.MediaSessionManager
25
26
  import com.margelo.nitro.nitroplayer.media.NitroPlayerPlaybackService
@@ -69,6 +70,9 @@ class TrackPlayerCore private constructor(
69
70
 
70
71
  @Volatile internal var currentRepeatMode: RepeatMode = RepeatMode.OFF
71
72
  internal var lookaheadCount: Int = 5
73
+
74
+ internal var remoteSkipForwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
75
+ internal var remoteSkipBackwardIntervalMs: Long = ExoPlayerBuilder.DEFAULT_REMOTE_SKIP_INTERVAL_MS
72
76
  internal var playerListener: androidx.media3.common.Player.Listener? = null
73
77
 
74
78
  // ── Temporary queue ────────────────────────────────────────────────────
@@ -124,16 +128,22 @@ class TrackPlayerCore private constructor(
124
128
  internal val progressUpdateRunnable =
125
129
  object : Runnable {
126
130
  override fun run() {
127
- if (::exo.isInitialized && exo.isPlaying) {
128
- val pos = exo.currentPosition / 1000.0
129
- val dur = if (exo.duration > 0) exo.duration / 1000.0 else 0.0
130
- notifyPlaybackProgress(pos, dur, if (isManuallySeeked) true else null)
131
- isManuallySeeked = false
132
- }
131
+ // Stop ticking while paused/idle — onIsPlayingChanged restarts it,
132
+ // so the main looper isn't woken every second for the process lifetime
133
+ if (!::exo.isInitialized || !exo.isPlaying) return
134
+ val pos = exo.currentPosition / 1000.0
135
+ val dur = if (exo.duration > 0) exo.duration / 1000.0 else 0.0
136
+ notifyPlaybackProgress(pos, dur, if (isManuallySeeked) true else null)
137
+ isManuallySeeked = false
133
138
  playerHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
134
139
  }
135
140
  }
136
141
 
142
+ internal fun startProgressTicks() {
143
+ playerHandler.removeCallbacks(progressUpdateRunnable)
144
+ playerHandler.postDelayed(progressUpdateRunnable, PROGRESS_INTERVAL_MS)
145
+ }
146
+
137
147
  internal val updateCurrentPlaylistRunnable =
138
148
  Runnable {
139
149
  val id = currentPlaylistId ?: return@Runnable
@@ -214,9 +224,11 @@ class TrackPlayerCore private constructor(
214
224
  @Suppress("ktlint:standard:property-naming")
215
225
  private var INSTANCE: TrackPlayerCore? = null
216
226
 
227
+ // applicationContext: the process-lifetime singleton must not pin the
228
+ // ReactApplicationContext (and its whole JS runtime) across reloads
217
229
  fun getInstance(context: Context): TrackPlayerCore =
218
230
  INSTANCE ?: synchronized(this) {
219
- INSTANCE ?: TrackPlayerCore(context).also { INSTANCE = it }
231
+ INSTANCE ?: TrackPlayerCore(context.applicationContext).also { INSTANCE = it }
220
232
  }
221
233
  }
222
234
 
@@ -405,7 +417,7 @@ class TrackPlayerCore private constructor(
405
417
  val id = onNotificationLaunchListeners.add(cb)
406
418
  handler.post {
407
419
  registerNotificationLaunchDetection()
408
- checkNotificationLaunch((context as? ReactApplicationContext)?.currentActivity)
420
+ checkNotificationLaunch(NitroModules.applicationContext?.currentActivity)
409
421
  }
410
422
  return id
411
423
  }
@@ -5,12 +5,14 @@ package com.margelo.nitro.nitroplayer.core
5
5
  import androidx.media3.common.MediaItem
6
6
  import androidx.media3.common.MediaMetadata
7
7
  import androidx.media3.common.Metadata
8
+ import androidx.media3.common.PlaybackException
8
9
  import androidx.media3.common.Player
9
10
  import androidx.media3.common.util.UnstableApi
10
11
  import androidx.media3.extractor.metadata.icy.IcyInfo
11
12
  import com.margelo.nitro.nitroplayer.Reason
12
13
  import com.margelo.nitro.nitroplayer.RepeatMode
13
14
  import com.margelo.nitro.nitroplayer.TimedMetadata
15
+ import com.margelo.nitro.nitroplayer.TrackPlayerState
14
16
  import com.margelo.nitro.nitroplayer.equalizer.EqualizerCore
15
17
  import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
16
18
 
@@ -147,6 +149,7 @@ internal class TrackPlayerEventListener(
147
149
  }
148
150
 
149
151
  override fun onIsPlayingChanged(isPlaying: Boolean) {
152
+ if (isPlaying) core.startProgressTicks()
150
153
  core.emitStateChange()
151
154
  }
152
155
 
@@ -163,6 +166,22 @@ internal class TrackPlayerEventListener(
163
166
  }
164
167
  }
165
168
 
169
+ override fun onPlayerError(error: PlaybackException) {
170
+ with(core) {
171
+ NitroPlayerLogger.log(
172
+ "TrackPlayer",
173
+ "Player error: ${error.errorCodeName} - ${error.message}",
174
+ )
175
+ notifyPlaybackStateChange(TrackPlayerState.STOPPED, Reason.ERROR)
176
+ // A source error leaves ExoPlayer in IDLE; skip the dead item and
177
+ // re-prepare so the rest of the queue keeps playing.
178
+ if (exo.hasNextMediaItem()) {
179
+ exo.seekToNextMediaItem()
180
+ exo.prepare()
181
+ }
182
+ }
183
+ }
184
+
166
185
  override fun onAudioSessionIdChanged(audioSessionId: Int) {
167
186
  if (audioSessionId != 0) {
168
187
  try {
@@ -2,7 +2,10 @@
2
2
 
3
3
  package com.margelo.nitro.nitroplayer.core
4
4
 
5
+ import androidx.annotation.OptIn
5
6
  import androidx.media3.common.Player
7
+ import androidx.media3.common.util.UnstableApi
8
+ import androidx.media3.exoplayer.ExoPlayer
6
9
  import com.margelo.nitro.nitroplayer.PlayerConfig
7
10
  import com.margelo.nitro.nitroplayer.Reason
8
11
  import com.margelo.nitro.nitroplayer.RepeatMode
@@ -52,23 +55,8 @@ internal fun TrackPlayerCore.skipToPreviousOnQueue() {
52
55
  }
53
56
 
54
57
  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
- }
62
-
63
- TrackPlayerCore.TemporaryType.UP_NEXT -> {
64
- val idx = upNextQueue.indexOfFirst { it.id == trackId }
65
- if (idx >= 0) upNextQueue.removeAt(idx)
66
- }
67
-
68
- else -> {}
69
- }
70
- }
71
- currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
58
+ // playFromIndexInternal clears both temp lists (matching iOS behavior),
59
+ // so no per-track removal is needed here
72
60
  playFromIndexInternal(currentTrackIndex)
73
61
  }
74
62
 
@@ -110,7 +98,31 @@ internal fun TrackPlayerCore.configureOnQueue(config: PlayerConfig) {
110
98
  config.androidAutoEnabled?.let { NitroPlayerMediaBrowserService.isAndroidAutoEnabled = it }
111
99
  config.lookaheadCount?.let { lookaheadCount = it.toInt() }
112
100
  config.androidNotificationIcon?.let { NitroPlayerPlaybackService.notificationSmallIconResName = it }
113
- mediaSessionManager?.configure(config.androidAutoEnabled, config.carPlayEnabled, config.showInNotification)
101
+ remoteSkipIntervalMs(config.remoteSkipForwardInterval)?.let { remoteSkipForwardIntervalMs = it }
102
+ remoteSkipIntervalMs(config.remoteSkipBackwardInterval)?.let { remoteSkipBackwardIntervalMs = it }
103
+ applyRemoteSkipIntervals()
104
+ mediaSessionManager?.configure(
105
+ config.androidAutoEnabled,
106
+ config.carPlayEnabled,
107
+ config.showInNotification,
108
+ remoteSkipForwardIntervalMs,
109
+ remoteSkipBackwardIntervalMs,
110
+ )
111
+ }
112
+
113
+ // Resolved from the cast controller: mid-cast `exo` is the CastPlayer, whose increments are fixed
114
+ @OptIn(UnstableApi::class)
115
+ internal fun TrackPlayerCore.applyRemoteSkipIntervals() {
116
+ val local = castSessionController?.localPlayer ?: if (isExoInitialized) exo.player else null
117
+ (local as? ExoPlayer)?.let {
118
+ it.setSeekBackIncrementMs(remoteSkipBackwardIntervalMs)
119
+ it.setSeekForwardIncrementMs(remoteSkipForwardIntervalMs)
120
+ }
121
+ }
122
+
123
+ private fun remoteSkipIntervalMs(intervalSeconds: Double?): Long? {
124
+ if (intervalSeconds == null || !intervalSeconds.isFinite() || intervalSeconds <= 0.0) return null
125
+ return (intervalSeconds * 1000.0).toLong().coerceAtLeast(1L)
114
126
  }
115
127
 
116
128
  suspend fun TrackPlayerCore.playSong(
@@ -197,7 +209,9 @@ internal fun TrackPlayerCore.emitStateChange(reason: Reason? = null) {
197
209
  suspend fun TrackPlayerCore.setPlayBackSpeed(speed: Double) = withPlayerContext { setPlayBackSpeedOnQueue(speed) }
198
210
 
199
211
  internal fun TrackPlayerCore.setPlayBackSpeedOnQueue(speed: Double) {
200
- if (speed <= 0.0) throw IllegalArgumentException("Speed must be greater than 0")
212
+ if (!speed.isFinite() || speed <= 0.0) {
213
+ throw IllegalArgumentException("Speed must be finite and greater than 0")
214
+ }
201
215
  if (isExoInitialized) exo.setPlaybackSpeed(speed.toFloat())
202
216
  }
203
217
 
@@ -85,7 +85,6 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
85
85
  }
86
86
 
87
87
  currentTrackIndex = index
88
- exo.clearMediaItems()
89
88
  exo.setMediaItems(mediaItems, true)
90
89
  exo.prepare()
91
90
  }
@@ -8,6 +8,7 @@ import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
8
8
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
9
9
  import com.margelo.nitro.nitroplayer.storage.NitroPlayerStorage
10
10
  import kotlinx.coroutines.CoroutineScope
11
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
11
12
  import kotlinx.coroutines.Dispatchers
12
13
  import kotlinx.coroutines.SupervisorJob
13
14
  import kotlinx.coroutines.launch
@@ -50,7 +51,9 @@ class DownloadDatabase private constructor(
50
51
  private val existenceCache = mutableMapOf<String, Boolean>()
51
52
  private val playlistTracks = mutableMapOf<String, MutableSet<String>>()
52
53
  private val fileManager = DownloadFileManager.getInstance(context)
53
- private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
54
+ // Single-threaded so rapid saves cannot land out of order and persist a stale snapshot
55
+ @OptIn(ExperimentalCoroutinesApi::class)
56
+ private val ioScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1) + SupervisorJob())
54
57
 
55
58
  init {
56
59
  loadFromDisk()
@@ -101,10 +104,11 @@ class DownloadDatabase private constructor(
101
104
  path: String,
102
105
  ): Boolean = existenceCache.getOrPut(trackId) { File(path).exists() }
103
106
 
104
- /** Drops every memoized verdict — files may have changed outside the app. */
105
- fun invalidateExistenceCache() {
106
- synchronized(this) { existenceCache.clear() }
107
- }
107
+ /** Count via the memoized existence cache — no per-record stat() on the caller's thread. */
108
+ fun countDownloadedTracks(): Int =
109
+ synchronized(this) {
110
+ downloadedTracks.count { (trackId, record) -> cachedFileExists(trackId, record.localPath) }
111
+ }
108
112
 
109
113
  fun isPlaylistDownloaded(playlistId: String): Boolean {
110
114
  synchronized(this) {