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
@@ -7,6 +7,7 @@ import android.util.Log
7
7
  import androidx.work.*
8
8
  import com.margelo.nitro.core.NullType
9
9
  import com.margelo.nitro.nitroplayer.*
10
+ import com.margelo.nitro.nitroplayer.core.ListenerRegistry
10
11
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
11
12
  import java.util.*
12
13
  import java.util.concurrent.ConcurrentHashMap
@@ -52,10 +53,11 @@ class DownloadManagerCore private constructor(
52
53
  private val trackMetadata = ConcurrentHashMap<String, TrackItem>()
53
54
  private val playlistAssociations = ConcurrentHashMap<String, String>()
54
55
 
55
- // Callbacks
56
- private val progressCallbacks = CopyOnWriteArrayList<(DownloadProgress) -> Unit>()
57
- private val stateChangeCallbacks = CopyOnWriteArrayList<(String, String, DownloadState, DownloadError?) -> Unit>()
58
- private val completeCallbacks = CopyOnWriteArrayList<(DownloadedTrack) -> Unit>()
56
+ // Callbacks — ListenerRegistry so HybridDownloadManager.dispose can remove
57
+ // them instead of leaking closures into dead JS runtimes across reloads
58
+ private val progressCallbacks = ListenerRegistry<(DownloadProgress) -> Unit>()
59
+ private val stateChangeCallbacks = ListenerRegistry<(String, String, DownloadState, DownloadError?) -> Unit>()
60
+ private val completeCallbacks = ListenerRegistry<(DownloadedTrack) -> Unit>()
59
61
 
60
62
  private val mainHandler = Handler(Looper.getMainLooper())
61
63
  private val database = DownloadDatabase.getInstance(context)
@@ -98,7 +100,34 @@ class DownloadManagerCore private constructor(
98
100
  )
99
101
  activeTasks[downloadId] = metadata
100
102
 
101
- // Create WorkManager request
103
+ // Gate on maxConcurrentDownloads — excess requests wait in FIFO order
104
+ synchronized(launchGate) {
105
+ if (runningDownloads.size >= maxConcurrent()) {
106
+ pendingLaunchQueue.add(downloadId)
107
+ } else {
108
+ runningDownloads.add(downloadId)
109
+ enqueueDownloadWork(downloadId, track, playlistId)
110
+ }
111
+ }
112
+
113
+ // Update state
114
+ activeTasks[downloadId]?.state = DownloadState.PENDING
115
+ notifyStateChange(downloadId, track.id, DownloadState.PENDING, null)
116
+
117
+ return downloadId
118
+ }
119
+
120
+ private val launchGate = Any()
121
+ private val pendingLaunchQueue = ArrayDeque<String>()
122
+ private val runningDownloads = mutableSetOf<String>()
123
+
124
+ private fun maxConcurrent(): Int = (config.maxConcurrentDownloads?.toInt() ?: 3).coerceAtLeast(1)
125
+
126
+ private fun enqueueDownloadWork(
127
+ downloadId: String,
128
+ track: TrackItem,
129
+ playlistId: String?,
130
+ ) {
102
131
  val constraints =
103
132
  Constraints
104
133
  .Builder()
@@ -115,6 +144,7 @@ class DownloadManagerCore private constructor(
115
144
  DownloadWorker.KEY_URL to track.url,
116
145
  DownloadWorker.KEY_PLAYLIST_ID to (playlistId ?: ""),
117
146
  DownloadWorker.KEY_STORAGE_LOCATION to (config.storageLocation?.name ?: StorageLocation.PRIVATE.name),
147
+ DownloadWorker.KEY_TRACK_JSON to TrackItemJson.toJson(track),
118
148
  )
119
149
 
120
150
  val downloadRequest =
@@ -125,13 +155,25 @@ class DownloadManagerCore private constructor(
125
155
  .addTag("track_${track.id}")
126
156
  .build()
127
157
 
128
- workManager.enqueue(downloadRequest)
129
-
130
- // Update state
131
- activeTasks[downloadId]?.state = DownloadState.PENDING
132
- notifyStateChange(downloadId, track.id, DownloadState.PENDING, null)
158
+ workManager.enqueueUniqueWork("download_$downloadId", ExistingWorkPolicy.KEEP, downloadRequest)
159
+ }
133
160
 
134
- return downloadId
161
+ // Frees this download's concurrency slot and launches the next queued one
162
+ private fun releaseDownloadSlot(downloadId: String) {
163
+ var next: String? = null
164
+ synchronized(launchGate) {
165
+ runningDownloads.remove(downloadId)
166
+ pendingLaunchQueue.remove(downloadId)
167
+ if (runningDownloads.size < maxConcurrent()) {
168
+ next = pendingLaunchQueue.removeFirstOrNull()
169
+ next?.let { runningDownloads.add(it) }
170
+ }
171
+ }
172
+ next?.let { nextId ->
173
+ val meta = activeTasks[nextId] ?: return
174
+ val track = trackMetadata[meta.trackId] ?: return
175
+ enqueueDownloadWork(nextId, track, playlistAssociations[nextId])
176
+ }
135
177
  }
136
178
 
137
179
  fun downloadPlaylist(
@@ -150,6 +192,7 @@ class DownloadManagerCore private constructor(
150
192
  metadata.state = DownloadState.PAUSED
151
193
  notifyStateChange(downloadId, metadata.trackId, DownloadState.PAUSED, null)
152
194
  }
195
+ releaseDownloadSlot(downloadId)
153
196
  }
154
197
 
155
198
  fun resumeDownload(downloadId: String) {
@@ -157,34 +200,16 @@ class DownloadManagerCore private constructor(
157
200
  val track = trackMetadata[metadata.trackId] ?: return
158
201
  val playlistId = playlistAssociations[downloadId]
159
202
 
160
- // Re-create work request
161
- val constraints =
162
- Constraints
163
- .Builder()
164
- .setRequiredNetworkType(
165
- if (config.wifiOnlyDownloads == true) NetworkType.UNMETERED else NetworkType.CONNECTED,
166
- ).setRequiresStorageNotLow(true)
167
- .build()
168
-
169
- val inputData =
170
- workDataOf(
171
- DownloadWorker.KEY_DOWNLOAD_ID to downloadId,
172
- DownloadWorker.KEY_TRACK_ID to track.id,
173
- DownloadWorker.KEY_TRACK_TITLE to track.title,
174
- DownloadWorker.KEY_URL to track.url,
175
- DownloadWorker.KEY_PLAYLIST_ID to (playlistId ?: ""),
176
- DownloadWorker.KEY_STORAGE_LOCATION to (config.storageLocation?.name ?: StorageLocation.PRIVATE.name),
177
- )
178
-
179
- val downloadRequest =
180
- OneTimeWorkRequestBuilder<DownloadWorker>()
181
- .setConstraints(constraints)
182
- .setInputData(inputData)
183
- .addTag("download_$downloadId")
184
- .addTag("track_${track.id}")
185
- .build()
186
-
187
- workManager.enqueue(downloadRequest)
203
+ synchronized(launchGate) {
204
+ if (runningDownloads.size >= maxConcurrent() && downloadId !in runningDownloads) {
205
+ pendingLaunchQueue.add(downloadId)
206
+ metadata.state = DownloadState.PENDING
207
+ notifyStateChange(downloadId, metadata.trackId, DownloadState.PENDING, null)
208
+ return
209
+ }
210
+ runningDownloads.add(downloadId)
211
+ }
212
+ enqueueDownloadWork(downloadId, track, playlistId)
188
213
 
189
214
  metadata.state = DownloadState.DOWNLOADING
190
215
  notifyStateChange(downloadId, metadata.trackId, DownloadState.DOWNLOADING, null)
@@ -197,7 +222,13 @@ class DownloadManagerCore private constructor(
197
222
  metadata.state = DownloadState.CANCELLED
198
223
  notifyStateChange(downloadId, metadata.trackId, DownloadState.CANCELLED, null)
199
224
  activeTasks.remove(downloadId)
225
+ // Drop the partial file (pause keeps it for Range resume), but never
226
+ // a previously completed download's file
227
+ if (!database.isTrackDownloaded(metadata.trackId)) {
228
+ fileManager.getLocalPath(metadata.trackId)?.let { fileManager.deleteFile(it) }
229
+ }
200
230
  }
231
+ releaseDownloadSlot(downloadId)
201
232
  }
202
233
 
203
234
  fun retryDownload(downloadId: String) {
@@ -256,7 +287,7 @@ class DownloadManagerCore private constructor(
256
287
  downloadedBytes += m.bytesDownloaded
257
288
  }
258
289
 
259
- val completedCount = database.getAllDownloadedTracks().size
290
+ val completedCount = database.countDownloadedTracks()
260
291
 
261
292
  return DownloadQueueStatus(
262
293
  pendingCount = pendingCount.toDouble(),
@@ -331,17 +362,17 @@ class DownloadManagerCore private constructor(
331
362
  }
332
363
 
333
364
  // Callbacks
334
- fun addProgressCallback(callback: (DownloadProgress) -> Unit) {
335
- progressCallbacks.add(callback)
336
- }
365
+ fun addProgressCallback(callback: (DownloadProgress) -> Unit): Long = progressCallbacks.add(callback)
337
366
 
338
- fun addStateChangeCallback(callback: (String, String, DownloadState, DownloadError?) -> Unit) {
339
- stateChangeCallbacks.add(callback)
340
- }
367
+ fun removeProgressCallback(id: Long): Boolean = progressCallbacks.remove(id)
341
368
 
342
- fun addCompleteCallback(callback: (DownloadedTrack) -> Unit) {
343
- completeCallbacks.add(callback)
344
- }
369
+ fun addStateChangeCallback(callback: (String, String, DownloadState, DownloadError?) -> Unit): Long = stateChangeCallbacks.add(callback)
370
+
371
+ fun removeStateChangeCallback(id: Long): Boolean = stateChangeCallbacks.remove(id)
372
+
373
+ fun addCompleteCallback(callback: (DownloadedTrack) -> Unit): Long = completeCallbacks.add(callback)
374
+
375
+ fun removeCompleteCallback(id: Long): Boolean = completeCallbacks.remove(id)
345
376
 
346
377
  // Internal callbacks from DownloadWorker
347
378
  internal fun onProgress(
@@ -375,8 +406,11 @@ class DownloadManagerCore private constructor(
375
406
  downloadId: String,
376
407
  trackId: String,
377
408
  localPath: String,
409
+ fallbackTrack: TrackItem? = null,
378
410
  ) {
379
- val track = trackMetadata[trackId] ?: return
411
+ // fallbackTrack comes from the worker's persisted inputData — the
412
+ // in-memory maps are empty when the worker completes in a fresh process.
413
+ val track = trackMetadata[trackId] ?: fallbackTrack ?: return
380
414
  val playlistId = playlistAssociations[downloadId]
381
415
  val storageLocation = config.storageLocation ?: StorageLocation.PRIVATE
382
416
  val fileSize = fileManager.getFileSize(localPath)
@@ -399,6 +433,7 @@ class DownloadManagerCore private constructor(
399
433
  metadata.completedAt = System.currentTimeMillis().toDouble()
400
434
  }
401
435
  activeTasks.remove(downloadId)
436
+ releaseDownloadSlot(downloadId)
402
437
 
403
438
  notifyStateChange(downloadId, trackId, DownloadState.COMPLETED, null)
404
439
 
@@ -407,26 +442,31 @@ class DownloadManagerCore private constructor(
407
442
  }
408
443
  }
409
444
 
445
+ // Retries are owned by WorkManager (Result.retry in DownloadWorker); this
446
+ // only records state so a second, competing worker is never scheduled here.
410
447
  internal fun onError(
411
448
  downloadId: String,
412
449
  trackId: String,
413
450
  error: DownloadError,
451
+ willRetry: Boolean = false,
414
452
  ) {
415
453
  activeTasks[downloadId]?.let { metadata ->
416
- metadata.state = DownloadState.FAILED
417
454
  metadata.error = error
418
455
 
419
- // Auto-retry if enabled
420
- if (config.autoRetry == true && error.isRetryable && metadata.retryCount < (config.maxRetryAttempts?.toInt() ?: 3)) {
421
- mainHandler.postDelayed({
422
- retryDownload(downloadId)
423
- }, 2000)
456
+ if (willRetry) {
457
+ metadata.retryCount++
458
+ metadata.state = DownloadState.PENDING
424
459
  } else {
460
+ metadata.state = DownloadState.FAILED
425
461
  notifyStateChange(downloadId, trackId, DownloadState.FAILED, error)
462
+ releaseDownloadSlot(downloadId)
426
463
  }
427
464
  }
428
465
  }
429
466
 
467
+ internal fun maxWorkerRetryAttempts(): Int =
468
+ if (config.autoRetry == true) (config.maxRetryAttempts?.toInt() ?: 3) else 0
469
+
430
470
  private fun notifyStateChange(
431
471
  downloadId: String,
432
472
  trackId: String,
@@ -10,11 +10,14 @@ import androidx.work.CoroutineWorker
10
10
  import androidx.work.ForegroundInfo
11
11
  import androidx.work.WorkerParameters
12
12
  import com.margelo.nitro.nitroplayer.*
13
+ import kotlinx.coroutines.CancellationException
13
14
  import kotlinx.coroutines.Dispatchers
14
15
  import kotlinx.coroutines.TimeoutCancellationException
16
+ import kotlinx.coroutines.ensureActive
15
17
  import kotlinx.coroutines.withContext
16
18
  import kotlinx.coroutines.withTimeout
17
19
  import java.io.BufferedInputStream
20
+ import java.io.File
18
21
  import java.io.FileOutputStream
19
22
  import java.net.HttpURLConnection
20
23
  import java.net.URL
@@ -33,6 +36,7 @@ class DownloadWorker(
33
36
  const val KEY_URL = "url"
34
37
  const val KEY_PLAYLIST_ID = "playlist_id"
35
38
  const val KEY_STORAGE_LOCATION = "storage_location"
39
+ const val KEY_TRACK_JSON = "track_json"
36
40
 
37
41
  private const val NOTIFICATION_CHANNEL_ID = "nitro_player_downloads"
38
42
  private const val BASE_NOTIFICATION_ID = 2001
@@ -63,7 +67,7 @@ class DownloadWorker(
63
67
  val urlString = inputData.getString(KEY_URL) ?: return@withContext Result.failure()
64
68
  val storageLocationStr = inputData.getString(KEY_STORAGE_LOCATION) ?: StorageLocation.PRIVATE.name
65
69
 
66
- notificationId = BASE_NOTIFICATION_ID + trackId.hashCode().and(0xFFFF)
70
+ notificationId = BASE_NOTIFICATION_ID + trackId.hashCode().and(0x7FFFFF)
67
71
 
68
72
  val storageLocation =
69
73
  try {
@@ -90,7 +94,8 @@ class DownloadWorker(
90
94
  }
91
95
 
92
96
  if (localPath != null) {
93
- downloadManager.onComplete(downloadId, trackId, localPath)
97
+ val fallbackTrack = inputData.getString(KEY_TRACK_JSON)?.let { TrackItemJson.fromJson(it) }
98
+ downloadManager.onComplete(downloadId, trackId, localPath, fallbackTrack)
94
99
  showCompletionNotification(trackTitle)
95
100
  Result.success()
96
101
  } else {
@@ -101,9 +106,7 @@ class DownloadWorker(
101
106
  reason = DownloadErrorReason.UNKNOWN,
102
107
  isRetryable = true,
103
108
  )
104
- downloadManager.onError(downloadId, trackId, error)
105
- showErrorNotification(trackTitle)
106
- Result.retry()
109
+ handleError(downloadId, trackId, trackTitle, error)
107
110
  }
108
111
  } catch (e: TimeoutCancellationException) {
109
112
  val error =
@@ -113,9 +116,11 @@ class DownloadWorker(
113
116
  reason = DownloadErrorReason.TIMEOUT,
114
117
  isRetryable = true,
115
118
  )
116
- downloadManager.onError(downloadId, trackId, error)
117
- showErrorNotification(trackTitle)
118
- Result.retry()
119
+ handleError(downloadId, trackId, trackTitle, error)
120
+ } catch (e: CancellationException) {
121
+ // Worker stopped (pause/cancel) — state was already set by the
122
+ // manager; don't overwrite it with FAILED.
123
+ throw e
119
124
  } catch (e: Exception) {
120
125
  val errorReason =
121
126
  when {
@@ -132,17 +137,24 @@ class DownloadWorker(
132
137
  reason = errorReason,
133
138
  isRetryable = errorReason in listOf(DownloadErrorReason.NETWORK_ERROR, DownloadErrorReason.TIMEOUT),
134
139
  )
135
- downloadManager.onError(downloadId, trackId, error)
136
- showErrorNotification(trackTitle)
137
-
138
- if (error.isRetryable) {
139
- Result.retry()
140
- } else {
141
- Result.failure()
142
- }
140
+ handleError(downloadId, trackId, trackTitle, error)
143
141
  }
144
142
  }
145
143
 
144
+ // Single retry owner: WorkManager reschedules via Result.retry(); the manager
145
+ // only records state, never enqueues a competing request.
146
+ private fun handleError(
147
+ downloadId: String,
148
+ trackId: String,
149
+ trackTitle: String,
150
+ error: DownloadError,
151
+ ): Result {
152
+ val willRetry = error.isRetryable && runAttemptCount < downloadManager.maxWorkerRetryAttempts()
153
+ downloadManager.onError(downloadId, trackId, error, willRetry)
154
+ showErrorNotification(trackTitle)
155
+ return if (willRetry) Result.retry() else Result.failure()
156
+ }
157
+
146
158
  private suspend fun downloadFile(
147
159
  downloadId: String,
148
160
  trackId: String,
@@ -154,18 +166,31 @@ class DownloadWorker(
154
166
  var connection: HttpURLConnection? = null
155
167
  var inputStream: BufferedInputStream? = null
156
168
  var outputStream: FileOutputStream? = null
169
+ var destinationFile: File? = null
157
170
 
158
171
  try {
172
+ val partialPath = fileManager.getLocalPath(trackId)
173
+ val existingBytes = partialPath?.let { File(it).length() } ?: 0L
174
+
159
175
  val url = URL(urlString)
160
176
  connection = url.openConnection() as HttpURLConnection
161
177
  connection.connectTimeout = 30000
162
178
  connection.readTimeout = 30000
179
+ if (existingBytes > 0) {
180
+ connection.setRequestProperty("Range", "bytes=$existingBytes-")
181
+ }
163
182
  connection.connect()
164
183
 
165
184
  val responseCode = connection.responseCode
166
- if (responseCode != HttpURLConnection.HTTP_OK) {
185
+ if (responseCode == 416) {
186
+ // Partial file is at or past the server's length — start clean next attempt
187
+ partialPath?.let { File(it).delete() }
188
+ throw Exception("Server returned HTTP 416 for resume, restarting download")
189
+ }
190
+ if (responseCode != HttpURLConnection.HTTP_OK && responseCode != HttpURLConnection.HTTP_PARTIAL) {
167
191
  throw Exception("Server returned HTTP $responseCode")
168
192
  }
193
+ val resuming = responseCode == HttpURLConnection.HTTP_PARTIAL && existingBytes > 0 && partialPath != null
169
194
  // Determine extension
170
195
  var extension = MimeTypeMap.getFileExtensionFromUrl(urlString)
171
196
 
@@ -192,20 +217,31 @@ class DownloadWorker(
192
217
 
193
218
  val finalExtension = if (extension.isNullOrEmpty()) "mp3" else extension
194
219
 
195
- // Create destination file
196
- val destinationFile = fileManager.createDownloadFile(trackId, storageLocation, finalExtension)
220
+ // Create destination file — appending to the partial file when the
221
+ // server honored the Range request, truncating otherwise
222
+ destinationFile =
223
+ if (resuming) {
224
+ File(partialPath!!)
225
+ } else {
226
+ fileManager.createDownloadFile(trackId, storageLocation, finalExtension)
227
+ }
197
228
 
198
229
  inputStream = BufferedInputStream(connection.inputStream)
199
- outputStream = FileOutputStream(destinationFile)
230
+ outputStream = FileOutputStream(destinationFile, resuming)
200
231
 
201
- val totalBytes = connection.contentLengthLong
202
- var bytesDownloaded: Long = 0
232
+ val startOffset = if (resuming) existingBytes else 0L
233
+ val totalBytes =
234
+ if (connection.contentLengthLong > 0) connection.contentLengthLong + startOffset else -1L
235
+ var bytesDownloaded: Long = startOffset
203
236
 
204
237
  val buffer = ByteArray(BUFFER_SIZE)
205
238
  var bytesRead: Int
206
239
  var lastProgressUpdate = System.currentTimeMillis()
207
240
 
208
241
  while (inputStream.read(buffer).also { bytesRead = it } != -1) {
242
+ // Blocking reads never suspend, so cancellation (pause/cancel,
243
+ // withTimeout, worker stop) must be checked explicitly here.
244
+ coroutineContext.ensureActive()
209
245
  outputStream.write(buffer, 0, bytesRead)
210
246
  bytesDownloaded += bytesRead
211
247
 
@@ -225,6 +261,10 @@ class DownloadWorker(
225
261
  downloadManager.onProgress(downloadId, trackId, bytesDownloaded, totalBytes)
226
262
 
227
263
  destinationFile.absolutePath
264
+ } catch (e: CancellationException) {
265
+ // Keep the partial file — pause resumes from this offset via Range;
266
+ // an explicit cancel deletes it in DownloadManagerCore.cancelDownload
267
+ throw e
228
268
  } catch (e: Exception) {
229
269
  throw e
230
270
  } finally {
@@ -0,0 +1,60 @@
1
+ package com.margelo.nitro.nitroplayer.download
2
+
3
+ import com.margelo.nitro.core.AnyMap
4
+ import com.margelo.nitro.nitroplayer.TrackItem
5
+ import com.margelo.nitro.nitroplayer.Variant_NullType_String
6
+ import org.json.JSONObject
7
+
8
+ // Survives process death inside the worker's inputData so a completed
9
+ // download can be recorded even when the in-memory metadata maps are gone.
10
+ internal object TrackItemJson {
11
+ fun toJson(track: TrackItem): String =
12
+ JSONObject()
13
+ .apply {
14
+ put("id", track.id)
15
+ put("title", track.title)
16
+ put("artist", track.artist)
17
+ put("album", track.album)
18
+ put("duration", track.duration)
19
+ put("url", track.url)
20
+ track.artwork?.asSecondOrNull()?.let { put("artwork", it) }
21
+ track.extraPayload?.let { payload ->
22
+ put("extraPayload", JSONObject(payload.toHashMap()))
23
+ }
24
+ }.toString()
25
+
26
+ fun fromJson(json: String): TrackItem? =
27
+ try {
28
+ val obj = JSONObject(json)
29
+ val artworkStr = obj.optString("artwork")
30
+ val extraPayload: AnyMap? =
31
+ if (obj.has("extraPayload")) {
32
+ val extraObj = obj.getJSONObject("extraPayload")
33
+ val map = AnyMap()
34
+ val keys = extraObj.keys()
35
+ while (keys.hasNext()) {
36
+ val key = keys.next()
37
+ when (val value = extraObj.get(key)) {
38
+ is String -> map.setString(key, value)
39
+ is Number -> map.setDouble(key, value.toDouble())
40
+ is Boolean -> map.setBoolean(key, value)
41
+ }
42
+ }
43
+ map
44
+ } else {
45
+ null
46
+ }
47
+ TrackItem(
48
+ id = obj.getString("id"),
49
+ title = obj.getString("title"),
50
+ artist = obj.getString("artist"),
51
+ album = obj.getString("album"),
52
+ duration = obj.getDouble("duration"),
53
+ url = obj.getString("url"),
54
+ artwork = if (artworkStr.isNullOrEmpty()) null else Variant_NullType_String.create(artworkStr),
55
+ extraPayload = extraPayload,
56
+ )
57
+ } catch (e: Exception) {
58
+ null
59
+ }
60
+ }
@@ -475,17 +475,17 @@ class EqualizerCore private constructor(
475
475
 
476
476
  // === Callback management ===
477
477
 
478
- fun addOnEnabledChangeListener(callback: (Boolean) -> Unit) {
479
- onEnabledChangeListeners.add(callback)
480
- }
478
+ fun addOnEnabledChangeListener(callback: (Boolean) -> Unit): Long = onEnabledChangeListeners.add(callback)
481
479
 
482
- fun addOnBandChangeListener(callback: (Array<EqualizerBand>) -> Unit) {
483
- onBandChangeListeners.add(callback)
484
- }
480
+ fun removeOnEnabledChangeListener(id: Long): Boolean = onEnabledChangeListeners.remove(id)
485
481
 
486
- fun addOnPresetChangeListener(callback: (Variant_NullType_String?) -> Unit) {
487
- onPresetChangeListeners.add(callback)
488
- }
482
+ fun addOnBandChangeListener(callback: (Array<EqualizerBand>) -> Unit): Long = onBandChangeListeners.add(callback)
483
+
484
+ fun removeOnBandChangeListener(id: Long): Boolean = onBandChangeListeners.remove(id)
485
+
486
+ fun addOnPresetChangeListener(callback: (Variant_NullType_String?) -> Unit): Long = onPresetChangeListeners.add(callback)
487
+
488
+ fun removeOnPresetChangeListener(id: Long): Boolean = onPresetChangeListeners.remove(id)
489
489
 
490
490
  private fun notifyEnabledChange(enabled: Boolean) {
491
491
  onEnabledChangeListeners.forEach { it(enabled) }
@@ -4,7 +4,6 @@ import androidx.media3.common.util.UnstableApi
4
4
  import androidx.media3.datasource.DataSource
5
5
  import androidx.media3.datasource.DefaultHttpDataSource
6
6
  import androidx.media3.datasource.ResolvingDataSource
7
- import java.util.concurrent.ConcurrentHashMap
8
7
 
9
8
  /**
10
9
  * HTTP [DataSource.Factory] that injects per-URL request headers (e.g. `Authorization`)
@@ -13,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap
13
12
  * Headers are registered per effective URL via [setHeadersForUrl] during MediaItem
14
13
  * construction and applied through a [ResolvingDataSource] just before each request opens.
15
14
  *
16
- * The registry is a [ConcurrentHashMap] because it is written on the player thread but
15
+ * The registry is a synchronized LRU map because it is written on the player thread but
17
16
  * read on ExoPlayer's loader threads.
18
17
  */
19
18
  @UnstableApi
@@ -34,7 +33,17 @@ class AuthAwareHttpDataSourceFactory : DataSource.Factory {
34
33
  override fun createDataSource(): DataSource = factory.createDataSource()
35
34
 
36
35
  companion object {
37
- private val headersMap = ConcurrentHashMap<String, Map<String, String>>()
36
+ // LRU-bounded: rotating signed URLs would otherwise add an entry per
37
+ // playback for the process lifetime (destroy() has no callers today)
38
+ private const val MAX_HEADER_ENTRIES = 500
39
+
40
+ private val headersMap: MutableMap<String, Map<String, String>> =
41
+ java.util.Collections.synchronizedMap(
42
+ object : LinkedHashMap<String, Map<String, String>>(16, 0.75f, true) {
43
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Map<String, String>>) =
44
+ size > MAX_HEADER_ENTRIES
45
+ },
46
+ )
38
47
 
39
48
  fun setHeadersForUrl(url: String, headers: Map<String, String>) {
40
49
  headersMap[url] = headers
@@ -16,6 +16,8 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
16
16
  */
17
17
  @UnstableApi
18
18
  object ExoPlayerBuilder {
19
+ const val DEFAULT_REMOTE_SKIP_INTERVAL_MS = 15_000L
20
+
19
21
  fun build(context: Context): ExoPlayer {
20
22
  val loadControl =
21
23
  DefaultLoadControl
@@ -50,6 +52,8 @@ object ExoPlayerBuilder {
50
52
  .Builder(context)
51
53
  .setLoadControl(loadControl)
52
54
  .setMediaSourceFactory(mediaSourceFactory)
55
+ .setSeekBackIncrementMs(DEFAULT_REMOTE_SKIP_INTERVAL_MS)
56
+ .setSeekForwardIncrementMs(DEFAULT_REMOTE_SKIP_INTERVAL_MS)
53
57
  .setAudioAttributes(audioAttrs, /* handleAudioFocus */ true)
54
58
  .setHandleAudioBecomingNoisy(true)
55
59
  .setWakeMode(C.WAKE_MODE_NETWORK)