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

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 (31) hide show
  1. package/android/src/main/AndroidManifest.xml +14 -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 +70 -29
  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 +70 -65
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +23 -12
  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 +40 -23
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +4 -3
  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 +12 -3
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/equalizer/EqualizerCore.kt +43 -34
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +30 -178
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +40 -0
  25. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +87 -85
  26. package/package.json +1 -1
  27. package/src/hooks/useEqualizer.ts +15 -12
  28. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +3 -2
  29. package/src/specs/DownloadManager.nitro.ts +4 -2
  30. package/src/specs/Equalizer.nitro.ts +4 -2
  31. package/src/specs/TrackPlayer.nitro.ts +18 -6
@@ -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
@@ -236,9 +236,18 @@ class DownloadManagerCore private constructor(
236
236
 
237
237
  for (m in activeTasks.values) {
238
238
  when (m.state) {
239
- DownloadState.PENDING -> pendingCount++
240
- DownloadState.DOWNLOADING -> activeCount++
241
- DownloadState.FAILED -> failedCount++
239
+ DownloadState.PENDING -> {
240
+ pendingCount++
241
+ }
242
+
243
+ DownloadState.DOWNLOADING -> {
244
+ activeCount++
245
+ }
246
+
247
+ DownloadState.FAILED -> {
248
+ failedCount++
249
+ }
250
+
242
251
  else -> {}
243
252
  }
244
253
  totalBytes += m.totalBytes ?: 0.0
@@ -13,10 +13,10 @@ import com.margelo.nitro.nitroplayer.EqualizerState
13
13
  import com.margelo.nitro.nitroplayer.GainRange
14
14
  import com.margelo.nitro.nitroplayer.PresetType
15
15
  import com.margelo.nitro.nitroplayer.Variant_NullType_String
16
+ import com.margelo.nitro.nitroplayer.core.ListenerRegistry
16
17
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
17
18
  import org.json.JSONArray
18
19
  import org.json.JSONObject
19
- import com.margelo.nitro.nitroplayer.core.ListenerRegistry
20
20
 
21
21
  class EqualizerCore private constructor(
22
22
  private val context: Context,
@@ -95,9 +95,10 @@ class EqualizerCore private constructor(
95
95
  initDynamicsProcessing(audioSessionId)
96
96
  usingDynamicsProcessing = true
97
97
  } else {
98
- equalizer = Equalizer(0, audioSessionId).apply {
99
- enabled = false
100
- }
98
+ equalizer =
99
+ Equalizer(0, audioSessionId).apply {
100
+ enabled = false
101
+ }
101
102
  usingDynamicsProcessing = false
102
103
  setupBandMapping()
103
104
  }
@@ -109,14 +110,19 @@ class EqualizerCore private constructor(
109
110
 
110
111
  @RequiresApi(Build.VERSION_CODES.P)
111
112
  private fun initDynamicsProcessing(sessionId: Int) {
112
- val config = DynamicsProcessing.Config.Builder(
113
- DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
114
- 1, // channelCount (stereo handled internally)
115
- true, 10, // Pre-EQ enabled, 10 bands
116
- false, 0, // MBC disabled
117
- false, 0, // Post-EQ disabled
118
- false // Limiter disabled
119
- ).build()
113
+ val config =
114
+ DynamicsProcessing.Config
115
+ .Builder(
116
+ DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
117
+ 1, // channelCount (stereo handled internally)
118
+ true,
119
+ 10, // Pre-EQ enabled, 10 bands
120
+ false,
121
+ 0, // MBC disabled
122
+ false,
123
+ 0, // Post-EQ disabled
124
+ false, // Limiter disabled
125
+ ).build()
120
126
  dynamicsProcessing = DynamicsProcessing(0, sessionId, config).apply { enabled = false }
121
127
  for (i in 0 until 10) {
122
128
  val band = DynamicsProcessing.EqBand(true, frequencies[i].toFloat(), 0f)
@@ -155,8 +161,8 @@ class EqualizerCore private constructor(
155
161
  }
156
162
  }
157
163
 
158
- fun setEnabled(enabled: Boolean): Boolean {
159
- return try {
164
+ fun setEnabled(enabled: Boolean): Boolean =
165
+ try {
160
166
  if (usingDynamicsProcessing && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
161
167
  dynamicsProcessing?.enabled = enabled
162
168
  } else {
@@ -170,12 +176,11 @@ class EqualizerCore private constructor(
170
176
  NitroPlayerLogger.log("EqualizerCore", "Failed to set enabled: ${e.message}")
171
177
  false
172
178
  }
173
- }
174
179
 
175
180
  fun isEnabled(): Boolean = isEqualizerEnabled
176
181
 
177
- fun getBands(): Array<EqualizerBand> {
178
- return (0 until 10)
182
+ fun getBands(): Array<EqualizerBand> =
183
+ (0 until 10)
179
184
  .map { i ->
180
185
  val gainDb = getCurrentBandGain(i)
181
186
  EqualizerBand(
@@ -185,7 +190,6 @@ class EqualizerCore private constructor(
185
190
  frequencyLabel = frequencyLabels[i],
186
191
  )
187
192
  }.toTypedArray()
188
- }
189
193
 
190
194
  private fun getCurrentBandGain(bandIndex: Int): Double = currentGainsArray[bandIndex]
191
195
 
@@ -217,7 +221,10 @@ class EqualizerCore private constructor(
217
221
  }
218
222
 
219
223
  @RequiresApi(Build.VERSION_CODES.P)
220
- private fun setDPBandGain(bandIndex: Int, gainDb: Float) {
224
+ private fun setDPBandGain(
225
+ bandIndex: Int,
226
+ gainDb: Float,
227
+ ) {
221
228
  val band = DynamicsProcessing.EqBand(true, frequencies[bandIndex].toFloat(), gainDb)
222
229
  dynamicsProcessing?.setPreEqBandAllChannelsTo(bandIndex, band)
223
230
  }
@@ -249,12 +256,10 @@ class EqualizerCore private constructor(
249
256
  }
250
257
  }
251
258
 
252
- private fun getAllGains(): List<Double> {
253
- return (0 until 10).map { i -> getCurrentBandGain(i) }
254
- }
259
+ private fun getAllGains(): List<Double> = (0 until 10).map { i -> getCurrentBandGain(i) }
255
260
 
256
- fun getBandRange(): GainRange {
257
- return if (usingDynamicsProcessing) {
261
+ fun getBandRange(): GainRange =
262
+ if (usingDynamicsProcessing) {
258
263
  GainRange(min = -12.0, max = 12.0)
259
264
  } else {
260
265
  val eq = equalizer
@@ -268,7 +273,6 @@ class EqualizerCore private constructor(
268
273
  GainRange(min = -12.0, max = 12.0)
269
274
  }
270
275
  }
271
- }
272
276
 
273
277
  fun getPresets(): Array<EqualizerPreset> {
274
278
  val builtIn = getBuiltInPresets()
@@ -418,17 +422,22 @@ class EqualizerCore private constructor(
418
422
  }
419
423
  }
420
424
 
421
- private fun saveBandGainsAndPreset(gains: List<Double>, presetName: String?) {
425
+ private fun saveBandGainsAndPreset(
426
+ gains: List<Double>,
427
+ presetName: String?,
428
+ ) {
422
429
  val json = JSONArray()
423
430
  gains.forEach { json.put(it) }
424
- prefs.edit().apply {
425
- putString("eq_band_gains", json.toString())
426
- if (presetName != null) {
427
- putString("eq_current_preset", presetName)
428
- } else {
429
- remove("eq_current_preset")
430
- }
431
- }.apply()
431
+ prefs
432
+ .edit()
433
+ .apply {
434
+ putString("eq_band_gains", json.toString())
435
+ if (presetName != null) {
436
+ putString("eq_current_preset", presetName)
437
+ } else {
438
+ remove("eq_current_preset")
439
+ }
440
+ }.apply()
432
441
  }
433
442
 
434
443
  private fun restoreSettings() {
@@ -3,22 +3,14 @@ package com.margelo.nitro.nitroplayer.media
3
3
  import android.app.Notification
4
4
  import android.app.NotificationChannel
5
5
  import android.app.NotificationManager
6
- import android.app.PendingIntent
7
6
  import android.content.Context
8
7
  import android.content.Intent
9
- import android.graphics.Bitmap
10
- import android.graphics.BitmapFactory
11
8
  import android.net.Uri
12
9
  import android.os.Build
13
- import android.util.Log
14
- import android.util.LruCache
15
- import androidx.core.app.NotificationCompat
16
10
  import androidx.media3.common.MediaItem
17
11
  import androidx.media3.common.MediaMetadata
18
- import androidx.media3.common.Player
19
12
  import androidx.media3.exoplayer.ExoPlayer
20
13
  import androidx.media3.session.MediaSession
21
- import androidx.media3.session.MediaSessionService
22
14
  import androidx.media3.session.SessionCommand
23
15
  import androidx.media3.session.SessionResult
24
16
  import com.google.common.util.concurrent.Futures
@@ -27,14 +19,11 @@ import com.margelo.nitro.nitroplayer.TrackItem
27
19
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
28
20
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
29
21
  import com.margelo.nitro.nitroplayer.core.loadPlaylist
30
- import com.margelo.nitro.nitroplayer.media.NitroPlayerMediaBrowserService
31
22
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
32
23
  import kotlinx.coroutines.CoroutineScope
33
24
  import kotlinx.coroutines.Dispatchers
34
25
  import kotlinx.coroutines.SupervisorJob
35
26
  import kotlinx.coroutines.launch
36
- import kotlinx.coroutines.withContext
37
- import java.net.URL
38
27
 
39
28
  class MediaSessionManager(
40
29
  private val context: Context,
@@ -49,26 +38,19 @@ class MediaSessionManager(
49
38
 
50
39
  var mediaSession: MediaSession? = null // Make public so MediaBrowserService can access it
51
40
  private set
52
- private var notificationManager: NotificationManager? = null
53
41
  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
42
+
54
43
  @Volatile private var currentTrack: TrackItem? = null
44
+
55
45
  @Volatile private var isPlaying: Boolean = false
56
- private val artworkCache = object : LruCache<String, Bitmap>(20) {
57
- override fun sizeOf(key: String, value: Bitmap): Int = 1
58
- }
59
46
 
60
47
  private var androidAutoEnabled: Boolean = false
61
48
  private var carPlayEnabled: Boolean = false
62
49
  private var showInNotification: Boolean = true
63
50
 
64
51
  companion object {
65
- private const val NOTIFICATION_ID = 1001
66
52
  private const val CHANNEL_ID = "nitro_player_channel"
67
53
  private const val CHANNEL_NAME = "Music Player"
68
- const val ACTION_PLAY = "com.margelo.nitro.nitroplayer.PLAY"
69
- const val ACTION_PAUSE = "com.margelo.nitro.nitroplayer.PAUSE"
70
- const val ACTION_NEXT = "com.margelo.nitro.nitroplayer.NEXT"
71
- const val ACTION_PREVIOUS = "com.margelo.nitro.nitroplayer.PREVIOUS"
72
54
  }
73
55
 
74
56
  init {
@@ -85,10 +67,8 @@ class MediaSessionManager(
85
67
  carPlayEnabled?.let { this.carPlayEnabled = it }
86
68
  showInNotification?.let {
87
69
  this.showInNotification = it
88
- if (it) {
89
- updateNotification()
90
- } else {
91
- hideNotification()
70
+ if (!it) {
71
+ stopPlaybackService()
92
72
  }
93
73
  }
94
74
  }
@@ -273,45 +253,17 @@ class MediaSessionManager(
273
253
  }
274
254
  },
275
255
  ).build()
276
- // MediaSession is active by default in Media3
277
- updateMediaSessionMetadata()
278
- } catch (e: Exception) {
279
- e.printStackTrace()
280
- }
281
- }
282
-
283
- private fun updateMediaSessionMetadata() {
284
- // MediaSession will automatically use the metadata from player's current MediaItem
285
- // No need to manually update here as TrackPlayerCore already sets metadata
286
- }
287
-
288
- private suspend fun loadArtworkBitmap(artworkUrl: String?): Bitmap? {
289
- if (artworkUrl.isNullOrEmpty()) return null
290
256
 
291
- // Check cache first
292
- artworkCache.get(artworkUrl)?.let { return it }
293
-
294
- return try {
295
- val bitmap =
296
- withContext(Dispatchers.IO) {
297
- val url = URL(artworkUrl)
298
- BitmapFactory.decodeStream(url.openConnection().getInputStream())
299
- }
300
- // Cache the bitmap
301
- if (bitmap != null) {
302
- artworkCache.put(artworkUrl, bitmap)
303
- }
304
- bitmap
257
+ // Wire the session into the PlaybackService so it can be promoted to foreground
258
+ NitroPlayerPlaybackService.mediaSession = mediaSession
305
259
  } catch (e: Exception) {
306
260
  e.printStackTrace()
307
- null
308
261
  }
309
262
  }
310
263
 
311
264
  private fun createNotificationChannel() {
312
- notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
313
-
314
265
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
266
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
315
267
  val channel =
316
268
  NotificationChannel(
317
269
  CHANNEL_ID,
@@ -322,150 +274,50 @@ class MediaSessionManager(
322
274
  setShowBadge(false)
323
275
  lockscreenVisibility = Notification.VISIBILITY_PUBLIC
324
276
  }
325
-
326
- notificationManager?.createNotificationChannel(channel)
277
+ manager.createNotificationChannel(channel)
327
278
  }
328
279
  }
329
280
 
330
- private fun getCurrentTrack(): TrackItem? = currentTrack
331
-
332
- private fun updateNotification() {
333
- if (!showInNotification) return
334
-
335
- val currentTrack = getCurrentTrack()
336
- val notification = buildNotification(currentTrack)
337
- notificationManager?.notify(NOTIFICATION_ID, notification)
338
- }
339
-
340
- private fun buildNotification(track: TrackItem?): Notification {
341
- val mediaSession = this.mediaSession ?: return createEmptyNotification()
342
-
343
- // Launch intent
344
- val contentIntent =
345
- PendingIntent.getActivity(
346
- context,
347
- 0,
348
- context.packageManager.getLaunchIntentForPackage(context.packageName),
349
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
350
- )
351
-
352
- val builder =
353
- NotificationCompat
354
- .Builder(context, CHANNEL_ID)
355
- .setContentTitle(track?.title ?: "Unknown Title")
356
- .setContentText(track?.artist ?: "Unknown Artist")
357
- .setSubText(track?.album ?: "")
358
- .setSmallIcon(android.R.drawable.ic_media_play)
359
- .setContentIntent(contentIntent)
360
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
361
- .setOngoing(isPlaying)
362
- .setShowWhen(false)
363
- .setPriority(NotificationCompat.PRIORITY_LOW)
364
- .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
281
+ private fun startPlaybackService() {
365
282
  try {
366
- val compatToken =
367
- android.support.v4.media.session.MediaSessionCompat.Token
368
- .fromToken(mediaSession.platformToken)
369
- builder.setStyle(
370
- androidx.media.app.NotificationCompat
371
- .MediaStyle()
372
- .setMediaSession(compatToken)
373
- .setShowActionsInCompactView(0, 1, 2),
374
- )
375
- } catch (e: Exception) {
376
- NitroPlayerLogger.log("MediaSessionManager") { "Failed to set media session token: ${e.message}" }
377
- }
378
-
379
- // Add action buttons
380
- builder.addAction(
381
- android.R.drawable.ic_media_previous,
382
- "Previous",
383
- createMediaAction(ACTION_PREVIOUS),
384
- )
385
-
386
- if (isPlaying) {
387
- builder.addAction(
388
- android.R.drawable.ic_media_pause,
389
- "Pause",
390
- createMediaAction(ACTION_PAUSE),
391
- )
392
- } else {
393
- builder.addAction(
394
- android.R.drawable.ic_media_play,
395
- "Play",
396
- createMediaAction(ACTION_PLAY),
397
- )
398
- }
399
-
400
- builder.addAction(
401
- android.R.drawable.ic_media_next,
402
- "Next",
403
- createMediaAction(ACTION_NEXT),
404
- )
405
-
406
- // Load artwork asynchronously and update notification
407
- track?.artwork?.asSecondOrNull()?.let { artworkUrl ->
408
- scope.launch {
409
- val bitmap = loadArtworkBitmap(artworkUrl)
410
- if (bitmap != null) {
411
- builder.setLargeIcon(bitmap)
412
- notificationManager?.notify(NOTIFICATION_ID, builder.build())
413
- }
283
+ val intent = Intent(context, NitroPlayerPlaybackService::class.java)
284
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
285
+ context.startForegroundService(intent)
286
+ } else {
287
+ context.startService(intent)
414
288
  }
289
+ } catch (e: Exception) {
290
+ NitroPlayerLogger.log("MediaSessionManager") { "Failed to start PlaybackService: ${e.message}" }
415
291
  }
416
-
417
- return builder.build()
418
- }
419
-
420
- private fun createEmptyNotification(): Notification =
421
- NotificationCompat
422
- .Builder(context, CHANNEL_ID)
423
- .setContentTitle("Music Player")
424
- .setSmallIcon(android.R.drawable.ic_media_play)
425
- .build()
426
-
427
- private fun createMediaAction(action: String): PendingIntent {
428
- val intent =
429
- Intent(action).apply {
430
- setPackage(context.packageName)
431
- }
432
- return PendingIntent.getBroadcast(
433
- context,
434
- 0,
435
- intent,
436
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
437
- )
438
292
  }
439
293
 
440
- private fun hideNotification() {
441
- notificationManager?.cancel(NOTIFICATION_ID)
294
+ private fun stopPlaybackService() {
295
+ try {
296
+ val intent = Intent(context, NitroPlayerPlaybackService::class.java)
297
+ context.stopService(intent)
298
+ } catch (e: Exception) {
299
+ NitroPlayerLogger.log("MediaSessionManager") { "Failed to stop PlaybackService: ${e.message}" }
300
+ }
442
301
  }
443
302
 
444
303
  fun onTrackChanged(track: TrackItem?) {
445
304
  currentTrack = track
446
- // Preload artwork for better notification display
447
- if (track != null) {
448
- scope.launch {
449
- track.artwork?.asSecondOrNull()?.let { artworkUrl ->
450
- loadArtworkBitmap(artworkUrl)
451
- }
452
- updateNotification()
453
- }
454
- } else {
455
- updateNotification()
456
- }
457
305
  }
458
306
 
459
307
  fun onPlaybackStateChanged(playing: Boolean) {
460
308
  isPlaying = playing
461
- updateNotification()
309
+ if (playing && showInNotification) {
310
+ startPlaybackService()
311
+ } else if (!playing) {
312
+ stopPlaybackService()
313
+ }
462
314
  }
463
315
 
464
316
  fun release() {
465
- hideNotification()
317
+ stopPlaybackService()
318
+ NitroPlayerPlaybackService.mediaSession = null
466
319
  mediaSession?.release()
467
320
  mediaSession = null
468
- artworkCache.evictAll()
469
321
  }
470
322
 
471
323
  private fun createMediaItem(
@@ -0,0 +1,40 @@
1
+ package com.margelo.nitro.nitroplayer.media
2
+
3
+ import android.content.Intent
4
+ import androidx.media3.session.MediaSession
5
+ import androidx.media3.session.MediaSessionService
6
+ import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
7
+
8
+ /**
9
+ * Foreground service that keeps playback alive when the app is backgrounded or the screen is locked.
10
+ *
11
+ * Media3's [MediaSessionService] automatically:
12
+ * - Promotes to a foreground service (with notification) when playback starts
13
+ * - Demotes / stops when playback stops
14
+ * - Posts a media-style notification with transport controls
15
+ * - Handles media-button intents
16
+ */
17
+ class NitroPlayerPlaybackService : MediaSessionService() {
18
+
19
+ companion object {
20
+ @Volatile
21
+ var mediaSession: MediaSession? = null
22
+ }
23
+
24
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
25
+ return mediaSession
26
+ }
27
+
28
+ override fun onTaskRemoved(rootIntent: Intent?) {
29
+ val session = mediaSession
30
+ if (session == null || !session.player.playWhenReady) {
31
+ stopSelf()
32
+ }
33
+ super.onTaskRemoved(rootIntent)
34
+ }
35
+
36
+ override fun onDestroy() {
37
+ NitroPlayerLogger.log("PlaybackService") { "Service destroyed" }
38
+ super.onDestroy()
39
+ }
40
+ }