react-native-nitro-player 0.7.1-alpha.1 → 0.7.1-alpha.3

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 (36) hide show
  1. package/android/src/main/AndroidManifest.xml +15 -1
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +5 -6
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +68 -49
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridDownloadManager.kt +67 -21
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridEqualizer.kt +27 -5
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +88 -49
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +40 -10
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +46 -45
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ListenerRegistry.kt +4 -1
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +38 -32
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +146 -81
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +24 -13
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +16 -4
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +101 -72
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +42 -22
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +55 -24
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +27 -8
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +73 -62
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +51 -48
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +3 -3
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadManagerCore.kt +14 -3
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +94 -23
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +43 -34
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +49 -0
  25. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +167 -0
  26. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +11 -450
  27. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/NitroPlayerNotificationProvider.kt +200 -0
  28. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +101 -0
  29. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +87 -85
  30. package/ios/core/TrackPlayerQueueBuild.swift +16 -2
  31. package/package.json +1 -1
  32. package/src/hooks/useEqualizer.ts +15 -12
  33. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +3 -2
  34. package/src/specs/DownloadManager.nitro.ts +4 -2
  35. package/src/specs/Equalizer.nitro.ts +4 -2
  36. package/src/specs/TrackPlayer.nitro.ts +18 -6
@@ -11,16 +11,17 @@ import com.margelo.nitro.nitroplayer.TrackItem
11
11
 
12
12
  // ── Playlist loading ──────────────────────────────────────────────────────
13
13
 
14
- suspend fun TrackPlayerCore.loadPlaylist(playlistId: String) = withPlayerContext {
15
- playNextStack.clear()
16
- upNextQueue.clear()
17
- currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
18
- val playlist = playlistManager.getPlaylist(playlistId) ?: return@withPlayerContext
19
- currentPlaylistId = playlistId
20
- updatePlayerQueue(playlist.tracks)
21
- checkUpcomingTracksForUrls(lookaheadCount)
22
- notifyTemporaryQueueChange()
23
- }
14
+ suspend fun TrackPlayerCore.loadPlaylist(playlistId: String) =
15
+ withPlayerContext {
16
+ playNextStack.clear()
17
+ upNextQueue.clear()
18
+ currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
19
+ val playlist = playlistManager.getPlaylist(playlistId) ?: return@withPlayerContext
20
+ currentPlaylistId = playlistId
21
+ updatePlayerQueue(playlist.tracks)
22
+ checkUpcomingTracksForUrls(lookaheadCount)
23
+ notifyTemporaryQueueChange()
24
+ }
24
25
 
25
26
  /**
26
27
  * Debounced update — coalesces rapid back-to-back mutations into one player rebuild.
@@ -37,8 +38,9 @@ fun TrackPlayerCore.updatePlaylist(playlistId: String) {
37
38
  suspend fun TrackPlayerCore.playNext(trackId: String) = withPlayerContext { playNextInternal(trackId) }
38
39
 
39
40
  internal fun TrackPlayerCore.playNextInternal(trackId: String) {
40
- val track = findTrackById(trackId)
41
- ?: throw IllegalArgumentException("Track $trackId not found")
41
+ val track =
42
+ findTrackById(trackId)
43
+ ?: throw IllegalArgumentException("Track $trackId not found")
42
44
  playNextStack.add(0, track)
43
45
  if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
44
46
  notifyTemporaryQueueChange()
@@ -49,8 +51,9 @@ internal fun TrackPlayerCore.playNextInternal(trackId: String) {
49
51
  suspend fun TrackPlayerCore.addToUpNext(trackId: String) = withPlayerContext { addToUpNextInternal(trackId) }
50
52
 
51
53
  internal fun TrackPlayerCore.addToUpNextInternal(trackId: String) {
52
- val track = findTrackById(trackId)
53
- ?: throw IllegalArgumentException("Track $trackId not found")
54
+ val track =
55
+ findTrackById(trackId)
56
+ ?: throw IllegalArgumentException("Track $trackId not found")
54
57
  upNextQueue.add(track)
55
58
  if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
56
59
  notifyTemporaryQueueChange()
@@ -58,35 +61,39 @@ internal fun TrackPlayerCore.addToUpNextInternal(trackId: String) {
58
61
 
59
62
  // ── Remove / clear ────────────────────────────────────────────────────────
60
63
 
61
- suspend fun TrackPlayerCore.removeFromPlayNext(trackId: String): Boolean = withPlayerContext {
62
- val idx = playNextStack.indexOfFirst { it.id == trackId }
63
- if (idx < 0) return@withPlayerContext false
64
- playNextStack.removeAt(idx)
65
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
66
- notifyTemporaryQueueChange()
67
- true
68
- }
69
-
70
- suspend fun TrackPlayerCore.removeFromUpNext(trackId: String): Boolean = withPlayerContext {
71
- val idx = upNextQueue.indexOfFirst { it.id == trackId }
72
- if (idx < 0) return@withPlayerContext false
73
- upNextQueue.removeAt(idx)
74
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
75
- notifyTemporaryQueueChange()
76
- true
77
- }
78
-
79
- suspend fun TrackPlayerCore.clearPlayNext() = withPlayerContext {
80
- playNextStack.clear()
81
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
82
- notifyTemporaryQueueChange()
83
- }
84
-
85
- suspend fun TrackPlayerCore.clearUpNext() = withPlayerContext {
86
- upNextQueue.clear()
87
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
88
- notifyTemporaryQueueChange()
89
- }
64
+ suspend fun TrackPlayerCore.removeFromPlayNext(trackId: String): Boolean =
65
+ withPlayerContext {
66
+ val idx = playNextStack.indexOfFirst { it.id == trackId }
67
+ if (idx < 0) return@withPlayerContext false
68
+ playNextStack.removeAt(idx)
69
+ if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
70
+ notifyTemporaryQueueChange()
71
+ true
72
+ }
73
+
74
+ suspend fun TrackPlayerCore.removeFromUpNext(trackId: String): Boolean =
75
+ withPlayerContext {
76
+ val idx = upNextQueue.indexOfFirst { it.id == trackId }
77
+ if (idx < 0) return@withPlayerContext false
78
+ upNextQueue.removeAt(idx)
79
+ if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
80
+ notifyTemporaryQueueChange()
81
+ true
82
+ }
83
+
84
+ suspend fun TrackPlayerCore.clearPlayNext() =
85
+ withPlayerContext {
86
+ playNextStack.clear()
87
+ if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
88
+ notifyTemporaryQueueChange()
89
+ }
90
+
91
+ suspend fun TrackPlayerCore.clearUpNext() =
92
+ withPlayerContext {
93
+ upNextQueue.clear()
94
+ if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
95
+ notifyTemporaryQueueChange()
96
+ }
90
97
 
91
98
  // ── Reorder ───────────────────────────────────────────────────────────────
92
99
 
@@ -94,25 +101,29 @@ suspend fun TrackPlayerCore.clearUpNext() = withPlayerContext {
94
101
  * Reorder within the combined virtual list [playNextStack + upNextQueue].
95
102
  * newIndex is 0-based within that combined list.
96
103
  */
97
- suspend fun TrackPlayerCore.reorderTemporaryTrack(trackId: String, newIndex: Int): Boolean = withPlayerContext {
98
- val combined = (playNextStack + upNextQueue).toMutableList()
99
- val fromIdx = combined.indexOfFirst { it.id == trackId }
100
- if (fromIdx < 0) return@withPlayerContext false
101
- val track = combined.removeAt(fromIdx)
102
- val clampedIndex = newIndex.coerceIn(0, combined.size)
103
- combined.add(clampedIndex, track)
104
-
105
- // Split back at original playNextStack.size boundary (reduced if an item was moved out)
106
- val pnSize = playNextStack.size
107
- playNextStack.clear()
108
- upNextQueue.clear()
109
- playNextStack.addAll(combined.take(pnSize))
110
- upNextQueue.addAll(combined.drop(pnSize))
111
-
112
- if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
113
- notifyTemporaryQueueChange()
114
- true
115
- }
104
+ suspend fun TrackPlayerCore.reorderTemporaryTrack(
105
+ trackId: String,
106
+ newIndex: Int,
107
+ ): Boolean =
108
+ withPlayerContext {
109
+ val combined = (playNextStack + upNextQueue).toMutableList()
110
+ val fromIdx = combined.indexOfFirst { it.id == trackId }
111
+ if (fromIdx < 0) return@withPlayerContext false
112
+ val track = combined.removeAt(fromIdx)
113
+ val clampedIndex = newIndex.coerceIn(0, combined.size)
114
+ combined.add(clampedIndex, track)
115
+
116
+ // Split back at original playNextStack.size boundary (reduced if an item was moved out)
117
+ val pnSize = playNextStack.size
118
+ playNextStack.clear()
119
+ upNextQueue.clear()
120
+ playNextStack.addAll(combined.take(pnSize))
121
+ upNextQueue.addAll(combined.drop(pnSize))
122
+
123
+ if (isExoInitialized && exo.currentMediaItem != null) rebuildQueueFromCurrentPosition()
124
+ notifyTemporaryQueueChange()
125
+ true
126
+ }
116
127
 
117
128
  // ── Read-only accessors ────────────────────────────────────────────────────
118
129
 
@@ -12,62 +12,65 @@ import com.margelo.nitro.nitroplayer.TrackItem
12
12
 
13
13
  // ── Track updates (URL resolution) ────────────────────────────────────────
14
14
 
15
- suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) = withPlayerContext {
16
- val currentTrack = getCurrentTrack()
17
- val currentTrackId = currentTrack?.id
18
- val currentTrackIsEmpty = currentTrack?.url.isNullOrEmpty()
19
- val currentTrackUpdate = if (currentTrackId != null) tracks.find { it.id == currentTrackId } else null
20
-
21
- val safeTracks = tracks.filter { track ->
22
- when {
23
- track.id == currentTrackId && !currentTrackIsEmpty -> false // preserve gapless
24
- track.id == currentTrackId && currentTrackIsEmpty -> track.url.isNotEmpty()
25
- track.url.isEmpty() -> false
26
- else -> true
27
- }
28
- }
29
- if (safeTracks.isEmpty()) return@withPlayerContext
30
-
31
- val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
32
-
33
- // Replace current track's MediaItem if it was empty-URL and now has a URL
34
- if (currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()) {
35
- val exoIndex = exo.currentMediaItemIndex
36
- if (exoIndex >= 0) {
37
- val playlistId = currentPlaylistId ?: ""
38
- val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${currentTrackUpdate.id}" else currentTrackUpdate.id
39
- exo.replaceMediaItem(exoIndex, makeMediaItem(currentTrackUpdate, mediaId))
40
- if (exo.playbackState == Player.STATE_IDLE) exo.prepare()
15
+ suspend fun TrackPlayerCore.updateTracks(tracks: List<TrackItem>) =
16
+ withPlayerContext {
17
+ val currentTrack = getCurrentTrack()
18
+ val currentTrackId = currentTrack?.id
19
+ val currentTrackIsEmpty = currentTrack?.url.isNullOrEmpty()
20
+ val currentTrackUpdate = if (currentTrackId != null) tracks.find { it.id == currentTrackId } else null
21
+
22
+ val safeTracks =
23
+ tracks.filter { track ->
24
+ when {
25
+ track.id == currentTrackId && !currentTrackIsEmpty -> false
26
+
27
+ // preserve gapless
28
+ track.id == currentTrackId && currentTrackIsEmpty -> track.url.isNotEmpty()
29
+
30
+ track.url.isEmpty() -> false
31
+
32
+ else -> true
33
+ }
34
+ }
35
+ if (safeTracks.isEmpty()) return@withPlayerContext
36
+
37
+ val affectedPlaylists: Map<String, Int> = playlistManager.updateTracks(safeTracks)
38
+
39
+ // Replace current track's MediaItem if it was empty-URL and now has a URL
40
+ if (currentTrackUpdate != null && currentTrackIsEmpty && currentTrackUpdate.url.isNotEmpty()) {
41
+ val exoIndex = exo.currentMediaItemIndex
42
+ if (exoIndex >= 0) {
43
+ val playlistId = currentPlaylistId ?: ""
44
+ val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${currentTrackUpdate.id}" else currentTrackUpdate.id
45
+ exo.replaceMediaItem(exoIndex, makeMediaItem(currentTrackUpdate, mediaId))
46
+ if (exo.playbackState == Player.STATE_IDLE) exo.prepare()
47
+ }
41
48
  }
42
- }
43
49
 
44
- if (currentPlaylistId != null && affectedPlaylists.containsKey(currentPlaylistId)) {
45
- val refreshedPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
46
- if (refreshedPlaylist != null) {
47
- currentTracks = refreshedPlaylist.tracks
48
- val updatedById = currentTracks.associateBy { it.id }
49
- playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
50
- upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
50
+ if (currentPlaylistId != null && affectedPlaylists.containsKey(currentPlaylistId)) {
51
+ val refreshedPlaylist = playlistManager.getPlaylist(currentPlaylistId!!)
52
+ if (refreshedPlaylist != null) {
53
+ currentTracks = refreshedPlaylist.tracks
54
+ val updatedById = currentTracks.associateBy { it.id }
55
+ playNextStack.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) playNextStack[i] = it } }
56
+ upNextQueue.forEachIndexed { i, t -> updatedById[t.id]?.let { if (it !== t) upNextQueue[i] = it } }
57
+ }
58
+ rebuildQueueFromCurrentPosition()
51
59
  }
52
- rebuildQueueFromCurrentPosition()
53
60
  }
54
- }
55
61
 
56
62
  // ── Track queries ─────────────────────────────────────────────────────────
57
63
 
58
- suspend fun TrackPlayerCore.getTracksById(trackIds: List<String>): List<TrackItem> =
59
- withPlayerContext { playlistManager.getTracksById(trackIds) as List<TrackItem> }
64
+ suspend fun TrackPlayerCore.getTracksById(trackIds: List<String>): List<TrackItem> = withPlayerContext { playlistManager.getTracksById(trackIds) as List<TrackItem> }
60
65
 
61
- suspend fun TrackPlayerCore.getTracksNeedingUrls(): List<TrackItem> =
62
- withPlayerContext { getTracksNeedingUrlsInternal() }
66
+ suspend fun TrackPlayerCore.getTracksNeedingUrls(): List<TrackItem> = withPlayerContext { getTracksNeedingUrlsInternal() }
63
67
 
64
68
  internal fun TrackPlayerCore.getTracksNeedingUrlsInternal(): List<TrackItem> {
65
69
  val pid = currentPlaylistId ?: return emptyList()
66
70
  return playlistManager.getPlaylist(pid)?.tracks?.filter { it.url.isEmpty() } ?: emptyList()
67
71
  }
68
72
 
69
- suspend fun TrackPlayerCore.getNextTracks(count: Int): List<TrackItem> =
70
- withPlayerContext { getNextTracksInternal(count) }
73
+ suspend fun TrackPlayerCore.getNextTracks(count: Int): List<TrackItem> = withPlayerContext { getNextTracksInternal(count) }
71
74
 
72
75
  internal fun TrackPlayerCore.getNextTracksInternal(count: Int): List<TrackItem> {
73
76
  val actualQueue = getActualQueueInternal()
@@ -84,15 +87,15 @@ suspend fun TrackPlayerCore.getCurrentTrackIndex(): Int = withPlayerContext { cu
84
87
  // ── URL lookahead ─────────────────────────────────────────────────────────
85
88
 
86
89
  internal fun TrackPlayerCore.checkUpcomingTracksForUrls(lookahead: Int = 5) {
87
- val upcomingTracks = if (currentTrackIndex < 0) {
88
- currentTracks.take(lookahead)
89
- } else {
90
- getNextTracksInternal(lookahead)
91
- }
90
+ val upcomingTracks =
91
+ if (currentTrackIndex < 0) {
92
+ currentTracks.take(lookahead)
93
+ } else {
94
+ getNextTracksInternal(lookahead)
95
+ }
92
96
  val currentTrack = getCurrentTrack()
93
97
  val currentNeedsUrl = currentTrack != null && currentTrack.url.isEmpty()
94
98
  val candidates = if (currentNeedsUrl) listOf(currentTrack!!) + upcomingTracks else upcomingTracks
95
99
  val needUrls = candidates.filter { it.url.isEmpty() }
96
100
  if (needUrls.isNotEmpty()) notifyTracksNeedUpdate(needUrls, lookahead)
97
101
  }
98
-
@@ -7,13 +7,13 @@ import com.margelo.nitro.nitroplayer.*
7
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
- import org.json.JSONArray
11
- import org.json.JSONObject
12
- import java.io.File
13
10
  import kotlinx.coroutines.CoroutineScope
14
11
  import kotlinx.coroutines.Dispatchers
15
12
  import kotlinx.coroutines.SupervisorJob
16
13
  import kotlinx.coroutines.launch
14
+ import org.json.JSONArray
15
+ import org.json.JSONObject
16
+ import java.io.File
17
17
 
18
18
  /**
19
19
  * Manages persistence of downloaded track metadata using file storage
@@ -111,6 +111,7 @@ class DownloadManagerCore private constructor(
111
111
  workDataOf(
112
112
  DownloadWorker.KEY_DOWNLOAD_ID to downloadId,
113
113
  DownloadWorker.KEY_TRACK_ID to track.id,
114
+ DownloadWorker.KEY_TRACK_TITLE to track.title,
114
115
  DownloadWorker.KEY_URL to track.url,
115
116
  DownloadWorker.KEY_PLAYLIST_ID to (playlistId ?: ""),
116
117
  DownloadWorker.KEY_STORAGE_LOCATION to (config.storageLocation?.name ?: StorageLocation.PRIVATE.name),
@@ -169,6 +170,7 @@ class DownloadManagerCore private constructor(
169
170
  workDataOf(
170
171
  DownloadWorker.KEY_DOWNLOAD_ID to downloadId,
171
172
  DownloadWorker.KEY_TRACK_ID to track.id,
173
+ DownloadWorker.KEY_TRACK_TITLE to track.title,
172
174
  DownloadWorker.KEY_URL to track.url,
173
175
  DownloadWorker.KEY_PLAYLIST_ID to (playlistId ?: ""),
174
176
  DownloadWorker.KEY_STORAGE_LOCATION to (config.storageLocation?.name ?: StorageLocation.PRIVATE.name),
@@ -236,9 +238,18 @@ class DownloadManagerCore private constructor(
236
238
 
237
239
  for (m in activeTasks.values) {
238
240
  when (m.state) {
239
- DownloadState.PENDING -> pendingCount++
240
- DownloadState.DOWNLOADING -> activeCount++
241
- DownloadState.FAILED -> failedCount++
241
+ DownloadState.PENDING -> {
242
+ pendingCount++
243
+ }
244
+
245
+ DownloadState.DOWNLOADING -> {
246
+ activeCount++
247
+ }
248
+
249
+ DownloadState.FAILED -> {
250
+ failedCount++
251
+ }
252
+
242
253
  else -> {}
243
254
  }
244
255
  totalBytes += m.totalBytes ?: 0.0
@@ -13,7 +13,6 @@ import com.margelo.nitro.nitroplayer.*
13
13
  import kotlinx.coroutines.Dispatchers
14
14
  import kotlinx.coroutines.withContext
15
15
  import java.io.BufferedInputStream
16
- import java.io.File
17
16
  import java.io.FileOutputStream
18
17
  import java.net.HttpURLConnection
19
18
  import java.net.URL
@@ -28,26 +27,34 @@ class DownloadWorker(
28
27
  companion object {
29
28
  const val KEY_DOWNLOAD_ID = "download_id"
30
29
  const val KEY_TRACK_ID = "track_id"
30
+ const val KEY_TRACK_TITLE = "track_title"
31
31
  const val KEY_URL = "url"
32
32
  const val KEY_PLAYLIST_ID = "playlist_id"
33
33
  const val KEY_STORAGE_LOCATION = "storage_location"
34
34
 
35
35
  private const val NOTIFICATION_CHANNEL_ID = "nitro_player_downloads"
36
- private const val NOTIFICATION_ID = 2001
36
+ private const val BASE_NOTIFICATION_ID = 2001
37
37
  private const val BUFFER_SIZE = 8192
38
38
  private val CONTENT_DISPOSITION_REGEX = Regex("filename=\"?([^\";]+)\"?")
39
39
  }
40
40
 
41
41
  private val downloadManager = DownloadManagerCore.getInstance(context)
42
42
  private val fileManager = DownloadFileManager.getInstance(context)
43
+ private val notificationManager =
44
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
45
+ /** Stable notification ID per download — derived from trackId hash. */
46
+ private var notificationId = BASE_NOTIFICATION_ID
43
47
 
44
48
  override suspend fun doWork(): Result =
45
49
  withContext(Dispatchers.IO) {
46
50
  val downloadId = inputData.getString(KEY_DOWNLOAD_ID) ?: return@withContext Result.failure()
47
51
  val trackId = inputData.getString(KEY_TRACK_ID) ?: return@withContext Result.failure()
52
+ val trackTitle = inputData.getString(KEY_TRACK_TITLE) ?: "Unknown track"
48
53
  val urlString = inputData.getString(KEY_URL) ?: return@withContext Result.failure()
49
54
  val storageLocationStr = inputData.getString(KEY_STORAGE_LOCATION) ?: StorageLocation.PRIVATE.name
50
55
 
56
+ notificationId = BASE_NOTIFICATION_ID + trackId.hashCode().and(0xFFFF)
57
+
51
58
  val storageLocation =
52
59
  try {
53
60
  StorageLocation.valueOf(storageLocationStr)
@@ -56,14 +63,21 @@ class DownloadWorker(
56
63
  }
57
64
 
58
65
  try {
59
- // Set foreground notification
60
- setForeground(createForegroundInfo(trackId))
66
+ // Set foreground notification — if POST_NOTIFICATIONS is denied,
67
+ // WorkManager still runs the task; the notification just won't show.
68
+ try {
69
+ setForeground(createForegroundInfo(trackTitle, 0, true))
70
+ } catch (_: Exception) {
71
+ // Foreground promotion failed (e.g. missing permission on some OEMs).
72
+ // Download continues in background.
73
+ }
61
74
 
62
75
  // Perform download
63
- val localPath = downloadFile(downloadId, trackId, urlString, storageLocation)
76
+ val localPath = downloadFile(downloadId, trackId, trackTitle, urlString, storageLocation)
64
77
 
65
78
  if (localPath != null) {
66
79
  downloadManager.onComplete(downloadId, trackId, localPath)
80
+ showCompletionNotification(trackTitle)
67
81
  Result.success()
68
82
  } else {
69
83
  val error =
@@ -74,6 +88,7 @@ class DownloadWorker(
74
88
  isRetryable = true,
75
89
  )
76
90
  downloadManager.onError(downloadId, trackId, error)
91
+ showErrorNotification(trackTitle)
77
92
  Result.retry()
78
93
  }
79
94
  } catch (e: Exception) {
@@ -93,6 +108,7 @@ class DownloadWorker(
93
108
  isRetryable = errorReason in listOf(DownloadErrorReason.NETWORK_ERROR, DownloadErrorReason.TIMEOUT),
94
109
  )
95
110
  downloadManager.onError(downloadId, trackId, error)
111
+ showErrorNotification(trackTitle)
96
112
 
97
113
  if (error.isRetryable) {
98
114
  Result.retry()
@@ -105,6 +121,7 @@ class DownloadWorker(
105
121
  private suspend fun downloadFile(
106
122
  downloadId: String,
107
123
  trackId: String,
124
+ trackTitle: String,
108
125
  urlString: String,
109
126
  storageLocation: StorageLocation,
110
127
  ): String? =
@@ -167,10 +184,12 @@ class DownloadWorker(
167
184
  outputStream.write(buffer, 0, bytesRead)
168
185
  bytesDownloaded += bytesRead
169
186
 
170
- // Update progress every 250ms
187
+ // Update progress every 250ms — both the callback and the notification
171
188
  val now = System.currentTimeMillis()
172
189
  if (now - lastProgressUpdate >= 250) {
173
190
  downloadManager.onProgress(downloadId, trackId, bytesDownloaded, totalBytes)
191
+ val percent = if (totalBytes > 0) ((bytesDownloaded * 100) / totalBytes).toInt() else 0
192
+ updateProgressNotification(trackTitle, percent)
174
193
  lastProgressUpdate = now
175
194
  }
176
195
  }
@@ -194,31 +213,85 @@ class DownloadWorker(
194
213
  }
195
214
  }
196
215
 
197
- private fun createForegroundInfo(trackId: String): ForegroundInfo {
198
- createNotificationChannel()
216
+ // ── Notification helpers ──────────────────────────────────────────────
217
+
218
+ private fun createForegroundInfo(
219
+ trackTitle: String,
220
+ percent: Int,
221
+ indeterminate: Boolean,
222
+ ): ForegroundInfo {
223
+ ensureNotificationChannel()
199
224
 
200
- val notification =
201
- NotificationCompat
202
- .Builder(context, NOTIFICATION_CHANNEL_ID)
203
- .setContentTitle("Downloading")
204
- .setContentText("Downloading track...")
205
- .setSmallIcon(android.R.drawable.stat_sys_download)
206
- .setOngoing(true)
207
- .setProgress(100, 0, true)
208
- .build()
225
+ val notification = buildProgressNotification(trackTitle, percent, indeterminate)
209
226
 
210
227
  return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
211
228
  ForegroundInfo(
212
- NOTIFICATION_ID,
229
+ notificationId,
213
230
  notification,
214
231
  android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
215
232
  )
216
233
  } else {
217
- ForegroundInfo(NOTIFICATION_ID, notification)
234
+ ForegroundInfo(notificationId, notification)
235
+ }
236
+ }
237
+
238
+ private fun buildProgressNotification(
239
+ trackTitle: String,
240
+ percent: Int,
241
+ indeterminate: Boolean,
242
+ ) = NotificationCompat
243
+ .Builder(context, NOTIFICATION_CHANNEL_ID)
244
+ .setContentTitle("Downloading")
245
+ .setContentText(trackTitle)
246
+ .setSubText(if (!indeterminate) "$percent%" else null)
247
+ .setSmallIcon(android.R.drawable.stat_sys_download)
248
+ .setOngoing(true)
249
+ .setOnlyAlertOnce(true)
250
+ .setProgress(100, percent, indeterminate)
251
+ .build()
252
+
253
+ private fun updateProgressNotification(trackTitle: String, percent: Int) {
254
+ try {
255
+ notificationManager.notify(
256
+ notificationId,
257
+ buildProgressNotification(trackTitle, percent, false),
258
+ )
259
+ } catch (_: SecurityException) {
260
+ // POST_NOTIFICATIONS not granted — download continues silently
218
261
  }
219
262
  }
220
263
 
221
- private fun createNotificationChannel() {
264
+ private fun showCompletionNotification(trackTitle: String) {
265
+ try {
266
+ notificationManager.notify(
267
+ notificationId,
268
+ NotificationCompat
269
+ .Builder(context, NOTIFICATION_CHANNEL_ID)
270
+ .setContentTitle("Download complete")
271
+ .setContentText(trackTitle)
272
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
273
+ .setAutoCancel(true)
274
+ .build(),
275
+ )
276
+ } catch (_: SecurityException) { }
277
+ }
278
+
279
+ private fun showErrorNotification(trackTitle: String) {
280
+ try {
281
+ notificationManager.notify(
282
+ notificationId,
283
+ NotificationCompat
284
+ .Builder(context, NOTIFICATION_CHANNEL_ID)
285
+ .setContentTitle("Download failed")
286
+ .setContentText(trackTitle)
287
+ .setSmallIcon(android.R.drawable.stat_notify_error)
288
+ .setAutoCancel(true)
289
+ .build(),
290
+ )
291
+ } catch (_: SecurityException) { }
292
+ }
293
+
294
+ private fun ensureNotificationChannel() {
222
295
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
223
296
  val channel =
224
297
  NotificationChannel(
@@ -228,9 +301,7 @@ class DownloadWorker(
228
301
  ).apply {
229
302
  description = "Download progress notifications"
230
303
  }
231
-
232
- val notificationManager = context.getSystemService(NotificationManager::class.java)
233
- notificationManager?.createNotificationChannel(channel)
304
+ notificationManager.createNotificationChannel(channel)
234
305
  }
235
306
  }
236
307
  }