react-native-nitro-player 0.7.1-alpha.2 → 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.
@@ -0,0 +1,200 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer.media
4
+
5
+ import android.app.Notification
6
+ import android.app.NotificationChannel
7
+ import android.app.NotificationManager
8
+ import android.app.PendingIntent
9
+ import android.content.Context
10
+ import android.content.Intent
11
+ import android.graphics.Bitmap
12
+ import android.graphics.BitmapFactory
13
+ import android.os.Build
14
+ import android.os.Bundle
15
+ import android.util.LruCache
16
+ import android.view.KeyEvent
17
+ import androidx.core.app.NotificationCompat
18
+ import androidx.media3.common.util.UnstableApi
19
+ import androidx.media3.session.CommandButton
20
+ import androidx.media3.session.MediaNotification
21
+ import androidx.media3.session.MediaSession
22
+ import com.google.common.collect.ImmutableList
23
+ import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
24
+ import kotlinx.coroutines.CoroutineScope
25
+ import kotlinx.coroutines.Dispatchers
26
+ import kotlinx.coroutines.SupervisorJob
27
+ import kotlinx.coroutines.launch
28
+ import kotlinx.coroutines.withContext
29
+ import java.net.URL
30
+
31
+ /**
32
+ * Custom notification provider that manually builds a [MediaStyle][androidx.media.app.NotificationCompat.MediaStyle]
33
+ * notification with artwork, title, artist, and transport controls — matching
34
+ * the proven approach from the main branch.
35
+ */
36
+ @UnstableApi
37
+ class NitroPlayerNotificationProvider(
38
+ private val context: Context,
39
+ ) : MediaNotification.Provider {
40
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
41
+ private val notificationManager =
42
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
43
+ private val artworkCache =
44
+ object : LruCache<String, Bitmap>(20) {
45
+ override fun sizeOf(key: String, value: Bitmap): Int = 1
46
+ }
47
+
48
+ companion object {
49
+ const val NOTIFICATION_ID = 1001
50
+ private const val CHANNEL_ID = "nitro_player_channel"
51
+ private const val CHANNEL_NAME = "Music Player"
52
+ }
53
+
54
+ init {
55
+ createNotificationChannel()
56
+ }
57
+
58
+ override fun createNotification(
59
+ mediaSession: MediaSession,
60
+ customLayout: ImmutableList<CommandButton>,
61
+ actionFactory: MediaNotification.ActionFactory,
62
+ onNotificationChangedCallback: MediaNotification.Provider.Callback,
63
+ ): MediaNotification {
64
+ val player = mediaSession.player
65
+ val metadata = player.mediaMetadata
66
+ val isPlaying = player.isPlaying
67
+
68
+ val contentIntent =
69
+ PendingIntent.getActivity(
70
+ context,
71
+ 0,
72
+ context.packageManager.getLaunchIntentForPackage(context.packageName),
73
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
74
+ )
75
+
76
+ val builder =
77
+ NotificationCompat
78
+ .Builder(context, CHANNEL_ID)
79
+ .setContentTitle(metadata.title ?: "Unknown Title")
80
+ .setContentText(metadata.artist ?: "Unknown Artist")
81
+ .setSubText(metadata.albumTitle ?: "")
82
+ .setSmallIcon(android.R.drawable.ic_media_play)
83
+ .setContentIntent(contentIntent)
84
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
85
+ .setOngoing(isPlaying)
86
+ .setShowWhen(false)
87
+ .setPriority(NotificationCompat.PRIORITY_LOW)
88
+ .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
89
+
90
+ // MediaStyle with session token
91
+ try {
92
+ val compatToken =
93
+ android.support.v4.media.session.MediaSessionCompat.Token
94
+ .fromToken(mediaSession.platformToken)
95
+ builder.setStyle(
96
+ androidx.media.app.NotificationCompat
97
+ .MediaStyle()
98
+ .setMediaSession(compatToken)
99
+ .setShowActionsInCompactView(0, 1, 2),
100
+ )
101
+ } catch (e: Exception) {
102
+ NitroPlayerLogger.log("NotificationProvider") { "Failed to set media session token: ${e.message}" }
103
+ }
104
+
105
+ // Transport actions using media button PendingIntents
106
+ builder.addAction(
107
+ android.R.drawable.ic_media_previous,
108
+ "Previous",
109
+ buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PREVIOUS, 0),
110
+ )
111
+
112
+ if (isPlaying) {
113
+ builder.addAction(
114
+ android.R.drawable.ic_media_pause,
115
+ "Pause",
116
+ buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PAUSE, 1),
117
+ )
118
+ } else {
119
+ builder.addAction(
120
+ android.R.drawable.ic_media_play,
121
+ "Play",
122
+ buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY, 1),
123
+ )
124
+ }
125
+
126
+ builder.addAction(
127
+ android.R.drawable.ic_media_next,
128
+ "Next",
129
+ buildMediaButtonIntent(KeyEvent.KEYCODE_MEDIA_NEXT, 2),
130
+ )
131
+
132
+ // Load artwork async and update notification
133
+ metadata.artworkUri?.toString()?.let { artworkUrl ->
134
+ val cached = artworkCache.get(artworkUrl)
135
+ if (cached != null) {
136
+ builder.setLargeIcon(cached)
137
+ } else {
138
+ scope.launch {
139
+ val bitmap = loadArtworkBitmap(artworkUrl)
140
+ if (bitmap != null) {
141
+ builder.setLargeIcon(bitmap)
142
+ onNotificationChangedCallback.onNotificationChanged(
143
+ MediaNotification(NOTIFICATION_ID, builder.build()),
144
+ )
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return MediaNotification(NOTIFICATION_ID, builder.build())
151
+ }
152
+
153
+ override fun handleCustomCommand(
154
+ session: MediaSession,
155
+ action: String,
156
+ extras: Bundle,
157
+ ): Boolean = false
158
+
159
+ private fun buildMediaButtonIntent(keyCode: Int, requestCode: Int): PendingIntent {
160
+ val intent =
161
+ Intent(Intent.ACTION_MEDIA_BUTTON).apply {
162
+ putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
163
+ setPackage(context.packageName)
164
+ }
165
+ return PendingIntent.getBroadcast(
166
+ context,
167
+ requestCode,
168
+ intent,
169
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
170
+ )
171
+ }
172
+
173
+ private fun createNotificationChannel() {
174
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
175
+ val channel =
176
+ NotificationChannel(
177
+ CHANNEL_ID,
178
+ CHANNEL_NAME,
179
+ NotificationManager.IMPORTANCE_LOW,
180
+ ).apply {
181
+ description = "Media playback controls"
182
+ setShowBadge(false)
183
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
184
+ }
185
+ notificationManager.createNotificationChannel(channel)
186
+ }
187
+ }
188
+
189
+ private suspend fun loadArtworkBitmap(artworkUrl: String): Bitmap? {
190
+ artworkCache.get(artworkUrl)?.let { return it }
191
+ return try {
192
+ withContext(Dispatchers.IO) {
193
+ val url = URL(artworkUrl)
194
+ BitmapFactory.decodeStream(url.openConnection().getInputStream())
195
+ }?.also { artworkCache.put(artworkUrl, it) }
196
+ } catch (_: Exception) {
197
+ null
198
+ }
199
+ }
200
+ }
@@ -1,28 +1,86 @@
1
1
  package com.margelo.nitro.nitroplayer.media
2
2
 
3
3
  import android.content.Intent
4
+ import android.os.Binder
5
+ import android.os.Handler
6
+ import android.os.IBinder
7
+ import android.os.Looper
8
+ import androidx.media3.common.util.UnstableApi
9
+ import androidx.media3.exoplayer.ExoPlayer
4
10
  import androidx.media3.session.MediaSession
5
11
  import androidx.media3.session.MediaSessionService
6
12
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
13
+ import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
14
+ import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
7
15
 
8
16
  /**
9
- * Foreground service that keeps playback alive when the app is backgrounded or the screen is locked.
17
+ * Foreground service that **owns** the ExoPlayer and MediaSession.
10
18
  *
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
19
+ * The player runs on the **main looper** so that Media3's
20
+ * [MediaSessionService] can access it directly for automatic notification
21
+ * management (foreground promotion, media-style notification, demotion).
22
+ *
23
+ * ExoPlayer does all heavy work (decoding, buffering, network I/O) on its
24
+ * own internal threads — the application looper only handles lightweight
25
+ * callbacks and control calls.
16
26
  */
27
+ @UnstableApi
17
28
  class NitroPlayerPlaybackService : MediaSessionService() {
18
29
 
19
30
  companion object {
20
- @Volatile
21
- var mediaSession: MediaSession? = null
31
+ const val ACTION_LOCAL_BIND = "com.margelo.nitro.nitroplayer.LOCAL_BIND"
32
+ }
33
+
34
+ // ── Created in onCreate ────────────────────────────────────────────────
35
+ private lateinit var player: ExoPlayer
36
+ private var mediaSession: MediaSession? = null
37
+ private val mainHandler = Handler(Looper.getMainLooper())
38
+
39
+ @Volatile
40
+ var trackPlayerCore: TrackPlayerCore? = null
41
+
42
+ // ── Binder exposed to TrackPlayerCore ──────────────────────────────────
43
+ inner class LocalBinder : Binder() {
44
+ val service: NitroPlayerPlaybackService get() = this@NitroPlayerPlaybackService
45
+ val exoPlayer: ExoPlayer get() = player
46
+ val session: MediaSession get() = mediaSession!!
47
+ val handler: Handler get() = mainHandler
22
48
  }
23
49
 
24
- override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
25
- return mediaSession
50
+ private val localBinder = lazy { LocalBinder() }
51
+
52
+ // ── Lifecycle ───────────────────────────────────────────────────────────
53
+
54
+ override fun onCreate() {
55
+ super.onCreate()
56
+ NitroPlayerLogger.log("PlaybackService") { "onCreate" }
57
+
58
+ // Build ExoPlayer on main looper (default)
59
+ player = ExoPlayerBuilder.build(this)
60
+
61
+ // Build MediaSession
62
+ val playlistManager = PlaylistManager.getInstance(this)
63
+ mediaSession = MediaSession
64
+ .Builder(this, player)
65
+ .setCallback(MediaSessionCallbackFactory.create(this, playlistManager))
66
+ .build()
67
+
68
+ // Explicitly register the session with the service so that
69
+ // MediaNotificationManager (created in super.onCreate()) can
70
+ // connect its internal MediaController and post notifications.
71
+ addSession(mediaSession!!)
72
+
73
+ // Media3 automatically handles the notification via DefaultMediaNotificationProvider.
74
+ }
75
+
76
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
77
+ mediaSession
78
+
79
+ override fun onBind(intent: Intent?): IBinder? {
80
+ if (intent?.action == ACTION_LOCAL_BIND) {
81
+ return localBinder.value
82
+ }
83
+ return super.onBind(intent)
26
84
  }
27
85
 
28
86
  override fun onTaskRemoved(rootIntent: Intent?) {
@@ -34,7 +92,10 @@ class NitroPlayerPlaybackService : MediaSessionService() {
34
92
  }
35
93
 
36
94
  override fun onDestroy() {
37
- NitroPlayerLogger.log("PlaybackService") { "Service destroyed" }
95
+ NitroPlayerLogger.log("PlaybackService") { "onDestroy" }
96
+ mediaSession?.release()
97
+ mediaSession = null
98
+ player.release()
38
99
  super.onDestroy()
39
100
  }
40
101
  }
@@ -216,13 +216,19 @@ extension TrackPlayerCore {
216
216
  guard let player = self.player else { return }
217
217
 
218
218
  let currentItem = player.currentItem
219
+
220
+ guard let playingTrackId = currentItem?.trackId else {
221
+ NitroPlayerLogger.log("TrackPlayerCore", "❌ No current item or track ID found during queue rebuild")
222
+ return
223
+ }
224
+
219
225
  let playingItems = player.items()
220
226
 
221
227
  // If the currently playing AVPlayerItem is no longer in currentTracks,
222
228
  // delegate to rebuildQueueFromPlaylistIndex so the player immediately
223
229
  // starts what is now at currentTrackIndex in the updated list.
224
- if let playingTrackId = currentItem?.trackId,
225
- !currentTracks.contains(where: { $0.id == playingTrackId }) {
230
+ if !currentTracks.contains(where: { $0.id == playingTrackId }) &&
231
+ currentTemporaryType == .none {
226
232
  let targetIndex = currentTrackIndex < currentTracks.count
227
233
  ? currentTrackIndex : currentTracks.count - 1
228
234
  if targetIndex >= 0 {
@@ -231,6 +237,14 @@ extension TrackPlayerCore {
231
237
  return
232
238
  }
233
239
 
240
+ // Sync currentTrackIndex to the track's actual position after a playlist mutation
241
+ // (e.g. reorder). Without this, the remaining-tracks slice uses the stale index,
242
+ // causing wrong tracks to play after skip/next.
243
+ if currentTemporaryType == .none,
244
+ let newIndex = currentTracks.firstIndex(where: { $0.id == playingTrackId }) {
245
+ currentTrackIndex = newIndex
246
+ }
247
+
234
248
  // Build the desired upcoming track list
235
249
  var newQueueTracks: [TrackItem] = []
236
250
  let currentId = currentItem?.trackId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-player",
3
- "version": "0.7.1-alpha.2",
3
+ "version": "0.7.1-alpha.3",
4
4
  "description": "A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",