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.
- package/README.md +6 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +9 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +23 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +34 -17
- package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +22 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +21 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +33 -19
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +0 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +9 -5
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +96 -56
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +62 -22
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +60 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +9 -9
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt +12 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +4 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +99 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +7 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +85 -70
- package/android/src/main/res/values/strings.xml +7 -0
- package/ios/HybridDownloadManager.swift +13 -3
- package/ios/HybridTrackPlayer.swift +4 -2
- package/ios/core/TrackPlayerCast.swift +32 -1
- package/ios/core/TrackPlayerCore.swift +19 -3
- package/ios/core/TrackPlayerListener.swift +15 -16
- package/ios/core/TrackPlayerNotify.swift +11 -11
- package/ios/core/TrackPlayerPlayback.swift +54 -16
- package/ios/core/TrackPlayerQueueBuild.swift +14 -2
- package/ios/core/TrackPlayerTempQueue.swift +1 -0
- package/ios/core/TrackPlayerUrlLoader.swift +11 -3
- package/ios/download/DownloadDatabase.swift +16 -5
- package/ios/download/DownloadFileManager.swift +12 -15
- package/ios/download/DownloadManagerCore.swift +61 -16
- package/ios/equalizer/EqualizerCore.swift +37 -9
- package/ios/media/MediaSessionManager.swift +89 -4
- package/ios/playlist/PlaylistManager.swift +47 -46
- package/ios/queue/HybridPlayerQueue.swift +28 -7
- package/lib/hooks/callbackManager.d.ts +9 -0
- package/lib/hooks/callbackManager.js +33 -0
- package/lib/hooks/useActualQueue.js +18 -6
- package/lib/hooks/useAndroidAutoConnection.js +2 -4
- package/lib/hooks/useAudioDevices.js +21 -24
- package/lib/hooks/useDownloadActions.js +38 -16
- package/lib/hooks/useDownloadProgress.js +20 -14
- package/lib/hooks/useDownloadStorage.js +1 -1
- package/lib/hooks/useNowPlaying.js +32 -6
- package/lib/hooks/useOnSeek.js +2 -2
- package/lib/hooks/usePlaylist.js +6 -15
- package/lib/specs/TrackPlayer.nitro.d.ts +1 -2
- package/lib/types/PlayerQueue.d.ts +8 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +8 -2
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +37 -1
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -1
- package/package.json +1 -1
- package/src/hooks/callbackManager.ts +44 -0
- package/src/hooks/useActualQueue.ts +18 -6
- package/src/hooks/useAndroidAutoConnection.ts +6 -6
- package/src/hooks/useAudioDevices.ts +21 -24
- package/src/hooks/useDownloadActions.ts +56 -26
- package/src/hooks/useDownloadProgress.ts +22 -19
- package/src/hooks/useDownloadStorage.ts +4 -3
- package/src/hooks/useNowPlaying.ts +34 -6
- package/src/hooks/useOnSeek.ts +2 -2
- package/src/hooks/usePlaylist.ts +6 -14
- package/src/specs/TrackPlayer.nitro.ts +1 -2
- package/src/types/PlayerQueue.ts +8 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +0 -200
- package/android/src/main/java/com/margelo/nitro/nitroplayer/queue/Queue.kt +0 -94
- 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
|
-
|
|
57
|
-
private val
|
|
58
|
-
private val
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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.pollFirst()
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
.
|
|
164
|
-
.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
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
|
|
339
|
-
stateChangeCallbacks.add(callback)
|
|
340
|
-
}
|
|
367
|
+
fun removeProgressCallback(id: Long): Boolean = progressCallbacks.remove(id)
|
|
341
368
|
|
|
342
|
-
fun
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
202
|
-
|
|
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
|
|
483
|
-
onBandChangeListeners.add(callback)
|
|
484
|
-
}
|
|
480
|
+
fun removeOnEnabledChangeListener(id: Long): Boolean = onEnabledChangeListeners.remove(id)
|
|
485
481
|
|
|
486
|
-
fun
|
|
487
|
-
|
|
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) }
|
package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AuthAwareHttpDataSourceFactory.kt
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|