react-native-nitro-player 0.7.1-alpha.2 → 1.0.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.
@@ -0,0 +1,167 @@
1
+ @file:Suppress("ktlint:standard:max-line-length")
2
+
3
+ package com.margelo.nitro.nitroplayer.media
4
+
5
+ import android.net.Uri
6
+ import androidx.media3.common.MediaItem
7
+ import androidx.media3.common.MediaMetadata
8
+ import androidx.media3.exoplayer.ExoPlayer
9
+ import androidx.media3.session.MediaSession
10
+ import androidx.media3.session.SessionCommand
11
+ import androidx.media3.session.SessionResult
12
+ import com.google.common.util.concurrent.Futures
13
+ import com.google.common.util.concurrent.ListenableFuture
14
+ import com.margelo.nitro.nitroplayer.TrackItem
15
+ import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
16
+ import com.margelo.nitro.nitroplayer.core.loadPlaylist
17
+ import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
18
+ import kotlinx.coroutines.CoroutineScope
19
+ import kotlinx.coroutines.Dispatchers
20
+ import kotlinx.coroutines.SupervisorJob
21
+ import kotlinx.coroutines.launch
22
+
23
+ /**
24
+ * Creates the [MediaSession.Callback] used by both notification controllers and
25
+ * Android Auto. Extracted from the old MediaSessionManager so the service can
26
+ * own session creation while keeping callback logic isolated.
27
+ */
28
+ object MediaSessionCallbackFactory {
29
+
30
+ fun create(
31
+ service: NitroPlayerPlaybackService,
32
+ playlistManager: PlaylistManager,
33
+ ): MediaSession.Callback {
34
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
35
+
36
+ return object : MediaSession.Callback {
37
+ override fun onConnect(
38
+ session: MediaSession,
39
+ controller: MediaSession.ControllerInfo,
40
+ ): MediaSession.ConnectionResult =
41
+ MediaSession.ConnectionResult
42
+ .AcceptedResultBuilder(session)
43
+ .setAvailableSessionCommands(
44
+ MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS,
45
+ ).setAvailablePlayerCommands(
46
+ MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS,
47
+ ).build()
48
+
49
+ override fun onAddMediaItems(
50
+ mediaSession: MediaSession,
51
+ controller: MediaSession.ControllerInfo,
52
+ mediaItems: MutableList<MediaItem>,
53
+ ): ListenableFuture<MutableList<MediaItem>> {
54
+ NitroPlayerLogger.log("MediaSessionCallback") { "onAddMediaItems called with ${mediaItems.size} items" }
55
+ if (mediaItems.isEmpty()) return Futures.immediateFuture(mutableListOf())
56
+
57
+ val updated = mutableListOf<MediaItem>()
58
+ for (requested in mediaItems) {
59
+ val mediaId =
60
+ requested.requestMetadata.mediaUri?.toString()
61
+ ?: requested.mediaId
62
+ try {
63
+ if (mediaId.contains(':')) {
64
+ val colonIdx = mediaId.indexOf(':')
65
+ val playlistId = mediaId.substring(0, colonIdx)
66
+ val trackId = mediaId.substring(colonIdx + 1)
67
+ val playlist = playlistManager.getPlaylist(playlistId)
68
+ val track = playlist?.tracks?.find { it.id == trackId }
69
+ if (track != null) {
70
+ updated.add(createMediaItem(track, mediaId))
71
+ } else {
72
+ updated.add(requested)
73
+ }
74
+ } else {
75
+ updated.add(requested)
76
+ }
77
+ } catch (e: Exception) {
78
+ NitroPlayerLogger.log("MediaSessionCallback") { "Error processing mediaId: ${e.message}" }
79
+ updated.add(requested)
80
+ }
81
+ }
82
+ return Futures.immediateFuture(updated)
83
+ }
84
+
85
+ override fun onSetMediaItems(
86
+ mediaSession: MediaSession,
87
+ controller: MediaSession.ControllerInfo,
88
+ mediaItems: MutableList<MediaItem>,
89
+ startIndex: Int,
90
+ startPositionMs: Long,
91
+ ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
92
+ NitroPlayerLogger.log("MediaSessionCallback") { "onSetMediaItems called with ${mediaItems.size} items, startIndex: $startIndex" }
93
+ if (mediaItems.isEmpty()) {
94
+ return Futures.immediateFuture(
95
+ MediaSession.MediaItemsWithStartPosition(mutableListOf(), 0, 0),
96
+ )
97
+ }
98
+ try {
99
+ val firstMediaId = mediaItems[0].mediaId
100
+ if (firstMediaId.contains(':')) {
101
+ val colonIdx = firstMediaId.indexOf(':')
102
+ val playlistId = firstMediaId.substring(0, colonIdx)
103
+ val trackId = firstMediaId.substring(colonIdx + 1)
104
+ val playlist = playlistManager.getPlaylist(playlistId)
105
+ if (playlist != null) {
106
+ val trackIndex = playlist.tracks.indexOfFirst { it.id == trackId }
107
+ if (trackIndex >= 0) {
108
+ service.trackPlayerCore?.let { core ->
109
+ scope.launch { core.loadPlaylist(playlistId) }
110
+ }
111
+ val playlistMediaItems =
112
+ playlist.tracks
113
+ .map { track -> createMediaItem(track, "$playlistId:${track.id}") }
114
+ .toMutableList()
115
+ return Futures.immediateFuture(
116
+ MediaSession.MediaItemsWithStartPosition(
117
+ playlistMediaItems,
118
+ trackIndex,
119
+ startPositionMs,
120
+ ),
121
+ )
122
+ }
123
+ }
124
+ }
125
+ } catch (e: Exception) {
126
+ NitroPlayerLogger.log("MediaSessionCallback") { "Error in onSetMediaItems: ${e.message}" }
127
+ }
128
+ return Futures.immediateFuture(
129
+ MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs),
130
+ )
131
+ }
132
+
133
+ override fun onCustomCommand(
134
+ session: MediaSession,
135
+ controller: MediaSession.ControllerInfo,
136
+ customCommand: SessionCommand,
137
+ args: android.os.Bundle,
138
+ ): ListenableFuture<SessionResult> =
139
+ Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
140
+ }
141
+ }
142
+
143
+ private fun createMediaItem(
144
+ track: TrackItem,
145
+ mediaId: String,
146
+ ): MediaItem {
147
+ val metadataBuilder =
148
+ MediaMetadata
149
+ .Builder()
150
+ .setTitle(track.title)
151
+ .setArtist(track.artist)
152
+ .setAlbumTitle(track.album)
153
+
154
+ track.artwork?.asSecondOrNull()?.let { artworkUrl ->
155
+ try {
156
+ metadataBuilder.setArtworkUri(Uri.parse(artworkUrl))
157
+ } catch (_: Exception) {}
158
+ }
159
+
160
+ return MediaItem
161
+ .Builder()
162
+ .setMediaId(mediaId)
163
+ .setUri(track.url)
164
+ .setMediaMetadata(metadataBuilder.build())
165
+ .build()
166
+ }
167
+ }
@@ -1,33 +1,20 @@
1
1
  package com.margelo.nitro.nitroplayer.media
2
2
 
3
- import android.app.Notification
4
- import android.app.NotificationChannel
5
- import android.app.NotificationManager
6
3
  import android.content.Context
7
- import android.content.Intent
8
- import android.net.Uri
9
- import android.os.Build
10
- import androidx.media3.common.MediaItem
11
- import androidx.media3.common.MediaMetadata
12
- import androidx.media3.exoplayer.ExoPlayer
13
4
  import androidx.media3.session.MediaSession
14
- import androidx.media3.session.SessionCommand
15
- import androidx.media3.session.SessionResult
16
- import com.google.common.util.concurrent.Futures
17
- import com.google.common.util.concurrent.ListenableFuture
18
5
  import com.margelo.nitro.nitroplayer.TrackItem
19
- import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
20
6
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
21
- import com.margelo.nitro.nitroplayer.core.loadPlaylist
22
7
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
23
- import kotlinx.coroutines.CoroutineScope
24
- import kotlinx.coroutines.Dispatchers
25
- import kotlinx.coroutines.SupervisorJob
26
- import kotlinx.coroutines.launch
27
8
 
9
+ /**
10
+ * Thin wrapper around a [MediaSession] owned by the playback service.
11
+ *
12
+ * No longer creates the session, notification channel, or manages service
13
+ * start/stop — the service handles all of that automatically per Media3 docs.
14
+ */
28
15
  class MediaSessionManager(
29
16
  private val context: Context,
30
- private val player: ExoPlayer,
17
+ session: MediaSession,
31
18
  private val playlistManager: PlaylistManager,
32
19
  ) {
33
20
  private var trackPlayerCore: TrackPlayerCore? = null
@@ -36,28 +23,16 @@ class MediaSessionManager(
36
23
  trackPlayerCore = core
37
24
  }
38
25
 
39
- var mediaSession: MediaSession? = null // Make public so MediaBrowserService can access it
26
+ var mediaSession: MediaSession? = session
40
27
  private set
41
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
42
28
 
43
29
  @Volatile private var currentTrack: TrackItem? = null
44
-
45
30
  @Volatile private var isPlaying: Boolean = false
46
31
 
47
32
  private var androidAutoEnabled: Boolean = false
48
33
  private var carPlayEnabled: Boolean = false
49
34
  private var showInNotification: Boolean = true
50
35
 
51
- companion object {
52
- private const val CHANNEL_ID = "nitro_player_channel"
53
- private const val CHANNEL_NAME = "Music Player"
54
- }
55
-
56
- init {
57
- setupMediaSession()
58
- createNotificationChannel()
59
- }
60
-
61
36
  fun configure(
62
37
  androidAutoEnabled: Boolean?,
63
38
  carPlayEnabled: Boolean?,
@@ -65,239 +40,7 @@ class MediaSessionManager(
65
40
  ) {
66
41
  androidAutoEnabled?.let { this.androidAutoEnabled = it }
67
42
  carPlayEnabled?.let { this.carPlayEnabled = it }
68
- showInNotification?.let {
69
- this.showInNotification = it
70
- if (!it) {
71
- stopPlaybackService()
72
- }
73
- }
74
- }
75
-
76
- private fun setupMediaSession() {
77
- try {
78
- mediaSession =
79
- MediaSession
80
- .Builder(context, player)
81
- .setCallback(
82
- object : MediaSession.Callback {
83
- override fun onConnect(
84
- session: MediaSession,
85
- controller: MediaSession.ControllerInfo,
86
- ): MediaSession.ConnectionResult {
87
- // Accept all connections with default commands
88
- // Media3 automatically handles play, pause, skip, etc. through the player
89
- return MediaSession.ConnectionResult
90
- .AcceptedResultBuilder(session)
91
- .setAvailableSessionCommands(
92
- MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS,
93
- ).setAvailablePlayerCommands(
94
- MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS,
95
- ).build()
96
- }
97
-
98
- override fun onAddMediaItems(
99
- mediaSession: MediaSession,
100
- controller: MediaSession.ControllerInfo,
101
- mediaItems: MutableList<MediaItem>,
102
- ): ListenableFuture<MutableList<MediaItem>> {
103
- // This is called when Android Auto requests to play a track
104
- NitroPlayerLogger.log("MediaSessionManager") { "🎵 MediaSessionManager: onAddMediaItems called with ${mediaItems.size} items" }
105
-
106
- if (mediaItems.isEmpty()) {
107
- return Futures.immediateFuture(mutableListOf())
108
- }
109
-
110
- val updatedMediaItems = mutableListOf<MediaItem>()
111
-
112
- for (requestedMediaItem in mediaItems) {
113
- // Get the mediaId from requestMetadata or mediaId
114
- val mediaId =
115
- requestedMediaItem.requestMetadata.mediaUri?.toString()
116
- ?: requestedMediaItem.mediaId
117
-
118
- NitroPlayerLogger.log("MediaSessionManager") { "🎵 MediaSessionManager: Processing mediaId: $mediaId" }
119
-
120
- try {
121
- // Parse mediaId format: "playlistId:trackId"
122
- if (mediaId.contains(':')) {
123
- val colonIndex = mediaId.indexOf(':')
124
- val playlistId = mediaId.substring(0, colonIndex)
125
- val trackId = mediaId.substring(colonIndex + 1)
126
-
127
- NitroPlayerLogger.log("MediaSessionManager") { "🎵 MediaSessionManager: Parsed playlistId: $playlistId, trackId: $trackId" }
128
-
129
- // Get the playlist and track
130
- val playlist = playlistManager.getPlaylist(playlistId)
131
- if (playlist != null) {
132
- val track = playlist.tracks.find { it.id == trackId }
133
- if (track != null) {
134
- // Create a proper MediaItem with all metadata
135
- val resolvedMediaItem = createMediaItem(track, mediaId)
136
- updatedMediaItems.add(resolvedMediaItem)
137
- NitroPlayerLogger.log("MediaSessionManager") { "✅ MediaSessionManager: Resolved track: ${track.title}" }
138
- } else {
139
- NitroPlayerLogger.log("MediaSessionManager") { "⚠️ MediaSessionManager: Track $trackId not found in playlist" }
140
- updatedMediaItems.add(requestedMediaItem)
141
- }
142
- } else {
143
- NitroPlayerLogger.log("MediaSessionManager") { "⚠️ MediaSessionManager: Playlist $playlistId not found" }
144
- updatedMediaItems.add(requestedMediaItem)
145
- }
146
- } else {
147
- NitroPlayerLogger.log("MediaSessionManager") { "⚠️ MediaSessionManager: Invalid mediaId format: $mediaId" }
148
- updatedMediaItems.add(requestedMediaItem)
149
- }
150
- } catch (e: Exception) {
151
- NitroPlayerLogger.log("MediaSessionManager") { "❌ MediaSessionManager: Error processing mediaId - ${e.message}" }
152
- e.printStackTrace()
153
- updatedMediaItems.add(requestedMediaItem)
154
- }
155
- }
156
-
157
- NitroPlayerLogger.log("MediaSessionManager") { "🎵 MediaSessionManager: Returning ${updatedMediaItems.size} resolved media items" }
158
- return Futures.immediateFuture(updatedMediaItems)
159
- }
160
-
161
- override fun onSetMediaItems(
162
- mediaSession: MediaSession,
163
- controller: MediaSession.ControllerInfo,
164
- mediaItems: MutableList<MediaItem>,
165
- startIndex: Int,
166
- startPositionMs: Long,
167
- ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
168
- // This is called when Android Auto wants to set and play media items
169
- NitroPlayerLogger.log("MediaSessionManager") { "🎵 MediaSessionManager: onSetMediaItems called with ${mediaItems.size} items, startIndex: $startIndex" }
170
-
171
- if (mediaItems.isEmpty()) {
172
- return Futures.immediateFuture(
173
- MediaSession.MediaItemsWithStartPosition(
174
- mutableListOf(),
175
- 0,
176
- 0,
177
- ),
178
- )
179
- }
180
-
181
- try {
182
- // Get the first item's mediaId to determine the playlist
183
- val firstMediaId = mediaItems[0].mediaId
184
- NitroPlayerLogger.log("MediaSessionManager") { "🎵 MediaSessionManager: First mediaId: $firstMediaId" }
185
-
186
- // Parse mediaId format: "playlistId:trackId"
187
- if (firstMediaId.contains(':')) {
188
- val colonIndex = firstMediaId.indexOf(':')
189
- val playlistId = firstMediaId.substring(0, colonIndex)
190
- val trackId = firstMediaId.substring(colonIndex + 1)
191
-
192
- NitroPlayerLogger.log("MediaSessionManager") { "🎵 MediaSessionManager: Loading full playlist: $playlistId, starting at track: $trackId" }
193
-
194
- // Get the full playlist
195
- val playlist = playlistManager.getPlaylist(playlistId)
196
- if (playlist != null) {
197
- // Find the track index in the full playlist
198
- val trackIndex = playlist.tracks.indexOfFirst { it.id == trackId }
199
-
200
- if (trackIndex >= 0) {
201
- // Load the entire playlist into TrackPlayerCore
202
- trackPlayerCore?.let { core -> scope.launch { core.loadPlaylist(playlistId) } }
203
-
204
- // Create MediaItems for the entire playlist
205
- val playlistMediaItems =
206
- playlist.tracks
207
- .map { track ->
208
- val trackMediaId = "$playlistId:${track.id}"
209
- createMediaItem(track, trackMediaId)
210
- }.toMutableList()
211
-
212
- NitroPlayerLogger.log("MediaSessionManager") { "✅ MediaSessionManager: Loaded ${playlistMediaItems.size} tracks, starting at index $trackIndex" }
213
-
214
- // Return the full playlist with the correct start index
215
- return Futures.immediateFuture(
216
- MediaSession.MediaItemsWithStartPosition(
217
- playlistMediaItems,
218
- trackIndex,
219
- startPositionMs,
220
- ),
221
- )
222
- } else {
223
- NitroPlayerLogger.log("MediaSessionManager", "⚠️ MediaSessionManager: Track not found in playlist")
224
- }
225
- } else {
226
- NitroPlayerLogger.log("MediaSessionManager", "⚠️ MediaSessionManager: Playlist not found")
227
- }
228
- }
229
- } catch (e: Exception) {
230
- NitroPlayerLogger.log("MediaSessionManager") { "❌ MediaSessionManager: Error in onSetMediaItems - ${e.message}" }
231
- e.printStackTrace()
232
- }
233
-
234
- // Fallback: use the provided media items
235
- NitroPlayerLogger.log("MediaSessionManager", "🎵 MediaSessionManager: Using fallback - provided media items")
236
- return Futures.immediateFuture(
237
- MediaSession.MediaItemsWithStartPosition(
238
- mediaItems,
239
- startIndex,
240
- startPositionMs,
241
- ),
242
- )
243
- }
244
-
245
- override fun onCustomCommand(
246
- session: MediaSession,
247
- controller: MediaSession.ControllerInfo,
248
- customCommand: SessionCommand,
249
- args: android.os.Bundle,
250
- ): ListenableFuture<SessionResult> {
251
- // Handle custom commands if needed
252
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
253
- }
254
- },
255
- ).build()
256
-
257
- // Wire the session into the PlaybackService so it can be promoted to foreground
258
- NitroPlayerPlaybackService.mediaSession = mediaSession
259
- } catch (e: Exception) {
260
- e.printStackTrace()
261
- }
262
- }
263
-
264
- private fun createNotificationChannel() {
265
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
266
- val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
267
- val channel =
268
- NotificationChannel(
269
- CHANNEL_ID,
270
- CHANNEL_NAME,
271
- NotificationManager.IMPORTANCE_LOW,
272
- ).apply {
273
- description = "Media playback controls"
274
- setShowBadge(false)
275
- lockscreenVisibility = Notification.VISIBILITY_PUBLIC
276
- }
277
- manager.createNotificationChannel(channel)
278
- }
279
- }
280
-
281
- private fun startPlaybackService() {
282
- try {
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)
288
- }
289
- } catch (e: Exception) {
290
- NitroPlayerLogger.log("MediaSessionManager") { "Failed to start PlaybackService: ${e.message}" }
291
- }
292
- }
293
-
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
- }
43
+ showInNotification?.let { this.showInNotification = it }
301
44
  }
302
45
 
303
46
  fun onTrackChanged(track: TrackItem?) {
@@ -306,44 +49,10 @@ class MediaSessionManager(
306
49
 
307
50
  fun onPlaybackStateChanged(playing: Boolean) {
308
51
  isPlaying = playing
309
- if (playing && showInNotification) {
310
- startPlaybackService()
311
- } else if (!playing) {
312
- stopPlaybackService()
313
- }
314
52
  }
315
53
 
316
54
  fun release() {
317
- stopPlaybackService()
318
- NitroPlayerPlaybackService.mediaSession = null
319
- mediaSession?.release()
55
+ // Service owns the session — just null out our reference
320
56
  mediaSession = null
321
57
  }
322
-
323
- private fun createMediaItem(
324
- track: TrackItem,
325
- mediaId: String,
326
- ): MediaItem {
327
- val metadataBuilder =
328
- MediaMetadata
329
- .Builder()
330
- .setTitle(track.title)
331
- .setArtist(track.artist)
332
- .setAlbumTitle(track.album)
333
-
334
- track.artwork?.asSecondOrNull()?.let { artworkUrl ->
335
- try {
336
- metadataBuilder.setArtworkUri(Uri.parse(artworkUrl))
337
- } catch (e: Exception) {
338
- NitroPlayerLogger.log("MediaSessionManager") { "⚠️ MediaSessionManager: Invalid artwork URI: $artworkUrl" }
339
- }
340
- }
341
-
342
- return MediaItem
343
- .Builder()
344
- .setMediaId(mediaId)
345
- .setUri(track.url)
346
- .setMediaMetadata(metadataBuilder.build())
347
- .build()
348
- }
349
58
  }