react-native-nitro-player 1.7.0-alpha.4 → 1.7.0-alpha.6

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 (57) hide show
  1. package/android/src/main/AndroidManifest.xml +8 -0
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +51 -0
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +2 -2
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +17 -2
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/NitroPlayerLogger.kt +2 -2
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +41 -31
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +1 -1
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +47 -2
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +10 -17
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +3 -1
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +16 -1
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerRecovery.kt +97 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +0 -5
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +15 -2
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +3 -0
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +6 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +2 -0
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaBrowserService.kt +96 -45
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibrary.kt +2 -0
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryManager.kt +147 -0
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryParser.kt +3 -0
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +102 -24
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +44 -2
  25. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +57 -1
  26. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +15 -0
  27. package/ios/HybridTrackPlayer.swift +2 -1
  28. package/ios/core/TrackPlayerPlayback.swift +7 -3
  29. package/ios/media/MediaSessionManager.swift +8 -2
  30. package/lib/index.js +5 -1
  31. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
  32. package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
  33. package/lib/types/PlayerQueue.d.ts +4 -0
  34. package/lib/utils/androidAutoMediaLibrary.d.ts +13 -1
  35. package/lib/utils/androidAutoMediaLibrary.js +23 -0
  36. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +6 -0
  37. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  38. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  39. package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string.hpp +76 -0
  40. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +29 -0
  41. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +6 -0
  42. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void.kt +80 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string.kt +80 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_std__string.kt +80 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +44 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +5 -2
  48. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  49. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
  50. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
  51. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  52. package/package.json +1 -1
  53. package/src/index.ts +9 -1
  54. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
  55. package/src/types/AndroidAutoMediaLibrary.ts +24 -0
  56. package/src/types/PlayerQueue.ts +4 -0
  57. package/src/utils/androidAutoMediaLibrary.ts +48 -1
@@ -3,18 +3,22 @@
3
3
  package com.margelo.nitro.nitroplayer.media
4
4
 
5
5
  import android.net.Uri
6
+ import android.os.Handler
7
+ import android.os.Looper
6
8
  import android.provider.MediaStore
7
9
  import androidx.media3.common.MediaItem
8
10
  import androidx.media3.common.MediaMetadata
11
+ import androidx.media3.common.Player
12
+ import androidx.media3.common.Timeline
9
13
  import androidx.media3.exoplayer.ExoPlayer
10
14
  import androidx.media3.session.MediaSession
11
15
  import androidx.media3.session.SessionCommand
12
16
  import androidx.media3.session.SessionResult
13
17
  import com.google.common.util.concurrent.Futures
14
18
  import com.google.common.util.concurrent.ListenableFuture
19
+ import com.google.common.util.concurrent.SettableFuture
15
20
  import com.margelo.nitro.nitroplayer.TrackItem
16
21
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
17
- import com.margelo.nitro.nitroplayer.core.loadPlaylist
18
22
  import com.margelo.nitro.nitroplayer.playlist.Playlist
19
23
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
20
24
  import kotlinx.coroutines.CoroutineScope
@@ -43,7 +47,8 @@ object MediaSessionCallbackFactory {
43
47
  MediaSession.ConnectionResult
44
48
  .AcceptedResultBuilder(session)
45
49
  .setAvailableSessionCommands(
46
- MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS,
50
+ service.trackPlayerCore?.mediaSessionManager?.sessionCommands()
51
+ ?: MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS,
47
52
  ).setAvailablePlayerCommands(
48
53
  MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS,
49
54
  ).build()
@@ -74,7 +79,7 @@ object MediaSessionCallbackFactory {
74
79
  val playlistId = mediaId.substring(0, colonIdx)
75
80
  val trackId = mediaId.substring(colonIdx + 1)
76
81
  val playlist = playlistManager.getPlaylist(playlistId)
77
- val track = playlist?.tracks?.find { it.id == trackId }
82
+ val track = playlist?.tracks?.getOrNull(playlistManager.indexOfTrackRef(playlistId, trackId))
78
83
  if (track != null) {
79
84
  updated.add(createMediaItem(track, mediaId))
80
85
  } else {
@@ -105,10 +110,7 @@ object MediaSessionCallbackFactory {
105
110
  // it against loaded playlists before falling through.
106
111
  val voiceMatch = resolveVoiceSearch(mediaItems, playlistManager)
107
112
  if (voiceMatch != null) {
108
- val (playlistId, items, trackIndex) = voiceMatch
109
- service.trackPlayerCore?.let { core ->
110
- scope.launch { core.loadPlaylist(playlistId, trackIndex) }
111
- }
113
+ val (_, items, trackIndex) = voiceMatch
112
114
  return Futures.immediateFuture(
113
115
  MediaSession.MediaItemsWithStartPosition(items, trackIndex, startPositionMs),
114
116
  )
@@ -127,15 +129,15 @@ object MediaSessionCallbackFactory {
127
129
  val trackId = firstMediaId.substring(colonIdx + 1)
128
130
  val playlist = playlistManager.getPlaylist(playlistId)
129
131
  if (playlist != null) {
130
- val trackIndex = playlist.tracks.indexOfFirst { it.id == trackId }
132
+ val trackIndex = playlistManager.indexOfTrackRef(playlistId, trackId)
131
133
  if (trackIndex >= 0) {
132
- service.trackPlayerCore?.let { core ->
133
- scope.launch { core.loadPlaylist(playlistId, trackIndex) }
134
- }
134
+ val seen = mutableSetOf<String>()
135
135
  val playlistMediaItems =
136
136
  playlist.tracks
137
- .map { track -> createMediaItem(track, "$playlistId:${track.id}") }
138
- .toMutableList()
137
+ .mapIndexed { i, track ->
138
+ val ref = if (seen.add(track.id)) track.id else "${track.id}#$i"
139
+ createMediaItem(track, "$playlistId:$ref")
140
+ }.toMutableList()
139
141
  return Futures.immediateFuture(
140
142
  MediaSession.MediaItemsWithStartPosition(
141
143
  playlistMediaItems,
@@ -150,31 +152,107 @@ object MediaSessionCallbackFactory {
150
152
  NitroPlayerLogger.log("MediaSessionCallback") { "Error in onSetMediaItems: ${e.message}" }
151
153
  }
152
154
 
153
- // If a voice query was issued but found nothing, return an
154
- // explicit failure so the controller (Assistant) surfaces a
155
- // spoken error instead of silently no-oping. Without this the
156
- // Auto QA bot reports "no music played or error message shown".
157
- if (mediaItems.isNotEmpty() && mediaItems[0].requestMetadata.searchQuery != null) {
158
- return Futures.immediateFailedFuture(
159
- UnsupportedOperationException("No matching tracks for voice search"),
160
- )
161
- }
155
+ // No match in loaded playlists: try the app's search, else fail so Assistant speaks an error
156
+ val voiceQuery = mediaItems.firstOrNull()?.requestMetadata?.searchQuery
157
+ if (voiceQuery != null) return searchAndPlay(service, playlistManager, scope, voiceQuery, startPositionMs)
162
158
 
163
159
  return Futures.immediateFuture(
164
160
  MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs),
165
161
  )
166
162
  }
167
163
 
164
+ // Car "start music automatically" on a cold start: play what the app restores once its JS is up
165
+ override fun onPlaybackResumption(
166
+ mediaSession: MediaSession,
167
+ controller: MediaSession.ControllerInfo,
168
+ isForPlayback: Boolean,
169
+ ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
170
+ service.trackPlayerCore?.startJsRuntimeIfNeeded()
171
+ MediaLibraryManager.getInstance(service).requestResumption()
172
+ val future = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
173
+ val player = mediaSession.player
174
+ val handler = Handler(Looper.getMainLooper())
175
+ lateinit var listener: Player.Listener
176
+ lateinit var timeout: Runnable
177
+ val finish = {
178
+ player.removeListener(listener)
179
+ handler.removeCallbacks(timeout)
180
+ val item = player.currentMediaItem
181
+ if (item != null) {
182
+ future.set(MediaSession.MediaItemsWithStartPosition(listOf(item), 0, player.currentPosition))
183
+ } else {
184
+ future.setException(UnsupportedOperationException("Nothing to resume"))
185
+ }
186
+ }
187
+ listener =
188
+ object : Player.Listener {
189
+ override fun onTimelineChanged(
190
+ timeline: Timeline,
191
+ reason: Int,
192
+ ) {
193
+ if (player.currentMediaItem != null) finish()
194
+ }
195
+ }
196
+ timeout = Runnable { finish() }
197
+ player.addListener(listener)
198
+ handler.postDelayed(timeout, RESUMPTION_TIMEOUT_MS)
199
+ return future
200
+ }
201
+
168
202
  override fun onCustomCommand(
169
203
  session: MediaSession,
170
204
  controller: MediaSession.ControllerInfo,
171
205
  customCommand: SessionCommand,
172
206
  args: android.os.Bundle,
173
- ): ListenableFuture<SessionResult> =
174
- Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
207
+ ): ListenableFuture<SessionResult> {
208
+ val manager = service.trackPlayerCore?.mediaSessionManager
209
+ if (manager?.handlesSessionButton(customCommand.customAction) == true) {
210
+ manager.onSessionButtonPress?.invoke(customCommand.customAction)
211
+ }
212
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
213
+ }
175
214
  }
176
215
  }
177
216
 
217
+ // Asks the app's search, which can look past the loaded playlists; plays the first result naming a playlist track
218
+ private fun searchAndPlay(
219
+ service: NitroPlayerPlaybackService,
220
+ playlistManager: PlaylistManager,
221
+ scope: CoroutineScope,
222
+ query: String,
223
+ startPositionMs: Long,
224
+ ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
225
+ val future = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
226
+ scope.launch {
227
+ val match =
228
+ MediaLibraryManager
229
+ .getInstance(service)
230
+ .search(query)
231
+ ?.firstNotNullOfOrNull { resolveTrack(it.id, playlistManager) }
232
+ if (match == null) {
233
+ future.setException(UnsupportedOperationException("No matching tracks for voice search"))
234
+ return@launch
235
+ }
236
+ val (playlist, index) = match
237
+ val items = playlist.tracks.map { createMediaItem(it, "${playlist.id}:${it.id}") }
238
+ future.set(MediaSession.MediaItemsWithStartPosition(items, index, startPositionMs))
239
+ }
240
+ return future
241
+ }
242
+
243
+ private fun resolveTrack(
244
+ mediaId: String,
245
+ playlistManager: PlaylistManager,
246
+ ): Pair<Playlist, Int>? {
247
+ val colonIndex = mediaId.indexOf(':')
248
+ if (colonIndex <= 0) return null
249
+ val playlist = playlistManager.getPlaylist(mediaId.substring(0, colonIndex)) ?: return null
250
+ val index = playlistManager.indexOfTrackRef(playlist.id, mediaId.substring(colonIndex + 1))
251
+ return if (index >= 0) playlist to index else null
252
+ }
253
+
254
+ private const val RESUMPTION_TIMEOUT_MS = 10_000L
255
+
178
256
  private data class VoiceMatch(
179
257
  val playlistId: String,
180
258
  val items: List<MediaItem>,
@@ -1,10 +1,13 @@
1
1
  package com.margelo.nitro.nitroplayer.media
2
2
 
3
3
  import android.content.Context
4
+ import android.os.Bundle
4
5
  import androidx.media3.common.Player
5
6
  import androidx.media3.common.util.UnstableApi
6
7
  import androidx.media3.session.CommandButton
7
8
  import androidx.media3.session.MediaSession
9
+ import androidx.media3.session.SessionCommand
10
+ import androidx.media3.session.SessionCommands
8
11
  import com.margelo.nitro.nitroplayer.R
9
12
  import com.margelo.nitro.nitroplayer.TrackItem
10
13
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
@@ -45,6 +48,36 @@ class MediaSessionManager(
45
48
  private var remoteSkipForwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
46
49
  private var remoteSkipBackwardIntervalMs: Long = DEFAULT_REMOTE_SKIP_INTERVAL_MS
47
50
 
51
+ /** App-defined button; [icon] is a CommandButton.ICON_* constant. */
52
+ data class SessionButton(
53
+ val action: String,
54
+ val title: String,
55
+ val icon: Int,
56
+ )
57
+
58
+ private var sessionButtons: List<SessionButton> = emptyList()
59
+
60
+ @Volatile var onSessionButtonPress: ((action: String) -> Unit)? = null
61
+
62
+ fun sessionCommands(): SessionCommands =
63
+ MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
64
+ .buildUpon()
65
+ .apply { sessionButtons.forEach { add(SessionCommand(it.action, Bundle.EMPTY)) } }
66
+ .build()
67
+
68
+ fun handlesSessionButton(action: String): Boolean = sessionButtons.any { it.action == action }
69
+
70
+ fun setSessionButtons(buttons: List<SessionButton>) {
71
+ sessionButtons = buttons
72
+ val session = mediaSession ?: return
73
+ // A controller only shows or presses a button whose command it was granted; the notification one feeds Android Auto
74
+ val commands = sessionCommands()
75
+ (session.connectedControllers + listOfNotNull(session.mediaNotificationControllerInfo)).distinct().forEach {
76
+ session.setAvailableCommands(it, commands, MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
77
+ }
78
+ updateMediaButtonPreferences()
79
+ }
80
+
48
81
  init {
49
82
  updateMediaButtonPreferences()
50
83
  }
@@ -82,9 +115,18 @@ class MediaSessionManager(
82
115
  // Preferences replace Media3's default layout — prev/next must be declared or they vanish.
83
116
  // Compact only shows BACK / play-pause / FORWARD, so the ±N skips go to OVERFLOW; left in
84
117
  // the forward slot they push Next out of the compact notification entirely.
85
- val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() }
118
+ val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() || it.sessionCommand != null }
119
+ val custom =
120
+ sessionButtons.map {
121
+ CommandButton
122
+ .Builder(it.icon)
123
+ .setDisplayName(it.title)
124
+ .setSessionCommand(SessionCommand(it.action, Bundle.EMPTY))
125
+ .setSlots(CommandButton.SLOT_OVERFLOW)
126
+ .build()
127
+ }
86
128
  session.setMediaButtonPreferences(
87
- listOf(previousTrackButton(), nextTrackButton(), skipBackButton(), skipForwardButton()) + preserved,
129
+ listOf(previousTrackButton(), nextTrackButton()) + custom + listOf(skipBackButton(), skipForwardButton()) + preserved,
88
130
  )
89
131
  }
90
132
 
@@ -2,12 +2,16 @@ package com.margelo.nitro.nitroplayer.media
2
2
 
3
3
  import android.app.PendingIntent
4
4
  import android.content.Intent
5
+ import android.net.Uri
5
6
  import android.os.Binder
6
7
  import android.os.Handler
7
8
  import android.os.IBinder
8
9
  import android.os.Looper
9
10
  import androidx.media3.cast.CastPlayer
11
+ import androidx.media3.common.C
10
12
  import androidx.media3.common.ForwardingPlayer
13
+ import androidx.media3.common.MediaItem
14
+ import androidx.media3.common.Player
11
15
  import androidx.media3.common.util.UnstableApi
12
16
  import androidx.media3.exoplayer.ExoPlayer
13
17
  import androidx.media3.session.DefaultMediaNotificationProvider
@@ -15,7 +19,10 @@ import androidx.media3.session.MediaSession
15
19
  import androidx.media3.session.MediaSessionService
16
20
  import com.google.android.gms.cast.framework.CastContext
17
21
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
22
+ import com.margelo.nitro.nitroplayer.core.checkUpcomingTracksForUrls
18
23
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
24
+ import com.margelo.nitro.nitroplayer.core.playFromMediaId
25
+ import com.margelo.nitro.nitroplayer.core.playOnQueue
19
26
  import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
20
27
  import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
21
28
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
@@ -54,6 +61,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
54
61
  // ── Created in onCreate ────────────────────────────────────────────────
55
62
  private lateinit var player: ExoPlayer
56
63
  private var mediaSession: MediaSession? = null
64
+ private lateinit var sessionPlayer: Player
57
65
  private var notificationProvider: DefaultMediaNotificationProvider? = null
58
66
  private val mainHandler = Handler(Looper.getMainLooper())
59
67
 
@@ -87,7 +95,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
87
95
  // The timeline is a window over the logical queue, so ExoPlayer's own prev/next would
88
96
  // only ever move inside it — remote Previous seeks to 0 instead of the previous track.
89
97
  // Route the remote controls through the same logical skips JS uses.
90
- val sessionPlayer =
98
+ sessionPlayer =
91
99
  object : ForwardingPlayer(player) {
92
100
  override fun seekToPrevious() {
93
101
  trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPrevious()
@@ -104,6 +112,53 @@ class NitroPlayerPlaybackService : MediaSessionService() {
104
112
  override fun seekToNextMediaItem() {
105
113
  trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNextMediaItem()
106
114
  }
115
+
116
+ // A controller's pick would otherwise load every track at once, bypassing the window and lazy URLs
117
+ override fun setMediaItems(
118
+ mediaItems: MutableList<MediaItem>,
119
+ resetPosition: Boolean,
120
+ ) {
121
+ if (!playFromController(mediaItems, 0)) super.setMediaItems(mediaItems, resetPosition)
122
+ }
123
+
124
+ override fun setMediaItems(
125
+ mediaItems: MutableList<MediaItem>,
126
+ startIndex: Int,
127
+ startPositionMs: Long,
128
+ ) {
129
+ if (!playFromController(mediaItems, startIndex)) super.setMediaItems(mediaItems, startIndex, startPositionMs)
130
+ }
131
+
132
+ // Queued behind the load above, in the order the controller sent them
133
+ override fun prepare() {
134
+ trackPlayerCore?.let { core -> core.enqueue { core.exo.prepare() } } ?: super.prepare()
135
+ }
136
+
137
+ override fun play() {
138
+ trackPlayerCore?.let { core -> core.enqueue { core.playOnQueue() } } ?: super.play()
139
+ }
140
+
141
+ // Transcoded streams lack a length header, so ExoPlayer reads them as live: no bar or position in the car
142
+ override fun isCurrentMediaItemLive(): Boolean = super.isCurrentMediaItemLive() && knownDurationMs() == null
143
+
144
+ override fun getDuration(): Long = super.getDuration().takeIf { it != C.TIME_UNSET } ?: knownDurationMs() ?: C.TIME_UNSET
145
+
146
+ private fun knownDurationMs(): Long? = currentMediaItem?.mediaMetadata?.durationMs?.takeIf { it > 0 }
147
+
148
+ private fun playFromController(
149
+ mediaItems: List<MediaItem>,
150
+ startIndex: Int,
151
+ ): Boolean {
152
+ val mediaId = mediaItems.getOrNull(maxOf(startIndex, 0))?.mediaId ?: return false
153
+ if (mediaId == currentMediaItem?.mediaId && playerError == null) {
154
+ // Re-picking a song still waiting for its URL asks the app for it again
155
+ if (currentMediaItem?.localConfiguration?.uri == Uri.EMPTY) {
156
+ trackPlayerCore?.let { core -> core.enqueue { core.checkUpcomingTracksForUrls(core.lookaheadCount, force = true) } }
157
+ }
158
+ return true
159
+ }
160
+ return trackPlayerCore?.playFromMediaId(mediaId) == true
161
+ }
107
162
  }
108
163
 
109
164
  // Build MediaSession
@@ -144,6 +199,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
144
199
  castContext = castContext,
145
200
  castPlayer = castPlayer,
146
201
  localPlayer = player,
202
+ localSessionPlayer = sessionPlayer,
147
203
  mediaSession = mediaSession!!,
148
204
  mainHandler = mainHandler,
149
205
  )
@@ -1,6 +1,7 @@
1
1
  package com.margelo.nitro.nitroplayer.playlist
2
2
 
3
3
  import android.content.Context
4
+ import com.margelo.nitro.JNIOnLoad
4
5
  import com.margelo.nitro.core.AnyMap
5
6
  import com.margelo.nitro.nitroplayer.QueueOperation
6
7
  import com.margelo.nitro.nitroplayer.TrackItem
@@ -59,6 +60,8 @@ class PlaylistManager private constructor(
59
60
  }
60
61
 
61
62
  init {
63
+ // Persisted extraPayloads are AnyMaps; a process started without JS has not loaded Nitro's JNI yet
64
+ JNIOnLoad.initializeNativeNitro()
62
65
  // Synchronous so no mutation can race a late async load and be clobbered
63
66
  loadPlaylistsFromFile()
64
67
  }
@@ -136,6 +139,18 @@ class PlaylistManager private constructor(
136
139
  */
137
140
  fun getPlaylist(playlistId: String): Playlist? = playlists[playlistId]
138
141
 
142
+ /** "trackId" or, for a repeated track, "trackId#index" as the car browser lists it; -1 when it is not in the playlist. */
143
+ fun indexOfTrackRef(
144
+ playlistId: String,
145
+ trackRef: String,
146
+ ): Int {
147
+ val tracks = playlists[playlistId]?.tracks ?: return -1
148
+ val exact = tracks.indexOfFirst { it.id == trackRef }
149
+ if (exact >= 0) return exact
150
+ val index = trackRef.substringAfterLast('#', "").toIntOrNull() ?: return -1
151
+ return index.takeIf { tracks.getOrNull(it)?.id == trackRef.substringBeforeLast('#') } ?: -1
152
+ }
153
+
139
154
  /**
140
155
  * Get all playlists
141
156
  */
@@ -100,7 +100,8 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
100
100
  showInNotification: config.showInNotification,
101
101
  lookaheadCount: config.lookaheadCount.map { Int($0) },
102
102
  remoteSkipForwardInterval: config.remoteSkipForwardInterval,
103
- remoteSkipBackwardInterval: config.remoteSkipBackwardInterval
103
+ remoteSkipBackwardInterval: config.remoteSkipBackwardInterval,
104
+ preferRemoteSkipIntervals: config.preferRemoteSkipIntervals
104
105
  )
105
106
  }
106
107
  }
@@ -104,7 +104,8 @@ extension TrackPlayerCore {
104
104
  func configureOnQueue(
105
105
  androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
106
106
  showInNotification: Bool?, lookaheadCount: Int?,
107
- remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?
107
+ remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?,
108
+ preferRemoteSkipIntervals: Bool?
108
109
  ) {
109
110
  if let la = lookaheadCount {
110
111
  self.lookaheadCount = la
@@ -118,6 +119,7 @@ extension TrackPlayerCore {
118
119
  showInNotification: showInNotification,
119
120
  remoteSkipForwardInterval: remoteSkipForwardInterval,
120
121
  remoteSkipBackwardInterval: remoteSkipBackwardInterval,
122
+ preferRemoteSkipIntervals: preferRemoteSkipIntervals,
121
123
  playbackSpeed: playbackSpeed
122
124
  )
123
125
  }
@@ -125,14 +127,16 @@ extension TrackPlayerCore {
125
127
  func configure(
126
128
  androidAutoEnabled: Bool?, carPlayEnabled: Bool?,
127
129
  showInNotification: Bool?, lookaheadCount: Int?,
128
- remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?
130
+ remoteSkipForwardInterval: Double?, remoteSkipBackwardInterval: Double?,
131
+ preferRemoteSkipIntervals: Bool?
129
132
  ) async {
130
133
  await withPlayerQueueNoThrow {
131
134
  self.configureOnQueue(
132
135
  androidAutoEnabled: androidAutoEnabled, carPlayEnabled: carPlayEnabled,
133
136
  showInNotification: showInNotification, lookaheadCount: lookaheadCount,
134
137
  remoteSkipForwardInterval: remoteSkipForwardInterval,
135
- remoteSkipBackwardInterval: remoteSkipBackwardInterval)
138
+ remoteSkipBackwardInterval: remoteSkipBackwardInterval,
139
+ preferRemoteSkipIntervals: preferRemoteSkipIntervals)
136
140
  }
137
141
  }
138
142
 
@@ -28,6 +28,7 @@ class MediaSessionManager {
28
28
  private var showInNotification: Bool = true
29
29
  private var remoteSkipForwardInterval: Double = Constants.defaultRemoteSkipInterval
30
30
  private var remoteSkipBackwardInterval: Double = Constants.defaultRemoteSkipInterval
31
+ private var preferRemoteSkipIntervals = false
31
32
 
32
33
  // Tracks the artwork URL currently shown so we can discard stale async loads
33
34
  private var lastArtworkUrl: String?
@@ -56,11 +57,15 @@ class MediaSessionManager {
56
57
  showInNotification: Bool?,
57
58
  remoteSkipForwardInterval: Double?,
58
59
  remoteSkipBackwardInterval: Double?,
60
+ preferRemoteSkipIntervals: Bool?,
59
61
  playbackSpeed: Double?
60
62
  ) {
61
63
  if let showInNotification = showInNotification {
62
64
  self.showInNotification = showInNotification
63
65
  }
66
+ if let preferRemoteSkipIntervals = preferRemoteSkipIntervals {
67
+ self.preferRemoteSkipIntervals = preferRemoteSkipIntervals
68
+ }
64
69
  if let playbackSpeed = sanitizedPositive(playbackSpeed) {
65
70
  cachedPlaybackSpeed = playbackSpeed
66
71
  }
@@ -321,8 +326,9 @@ class MediaSessionManager {
321
326
 
322
327
  commandCenter.nextTrackCommand.isEnabled = hasCurrentTrack && isNotLast
323
328
  commandCenter.previousTrackCommand.isEnabled = hasCurrentTrack
324
- commandCenter.skipForwardCommand.isEnabled = hasCurrentTrack
325
- commandCenter.skipBackwardCommand.isEnabled = hasCurrentTrack
329
+ // iOS shows enabled skip-interval commands in place of next/previous, so they stay off unless the app prefers them
330
+ commandCenter.skipForwardCommand.isEnabled = hasCurrentTrack && preferRemoteSkipIntervals
331
+ commandCenter.skipBackwardCommand.isEnabled = hasCurrentTrack && preferRemoteSkipIntervals
326
332
  commandCenter.changePlaybackRateCommand.isEnabled = hasCurrentTrack
327
333
  commandCenter.changePlaybackPositionCommand.isEnabled = hasCurrentTrack && hasDuration
328
334
  }
package/lib/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // TODO: Export all HybridObjects here for the user
2
2
  import { NitroModules } from 'react-native-nitro-modules';
3
- import { Platform } from 'react-native';
3
+ import { AppRegistry, Platform } from 'react-native';
4
+ // Native runs this while it needs JS without a resumed activity (car, screen off): it keeps JS timers ticking
5
+ if (Platform.OS === 'android') {
6
+ AppRegistry.registerHeadlessTask('NitroPlayerKeepAlive', () => () => new Promise(() => { }));
7
+ }
4
8
  export const PlayerQueue = NitroModules.createHybridObject('PlayerQueue');
5
9
  export const TrackPlayer = NitroModules.createHybridObject('TrackPlayer');
6
10
  // Android-only: Android Auto Media Library
@@ -18,4 +18,16 @@ export interface AndroidAutoMediaLibrary extends HybridObject<{
18
18
  * Falls back to showing all playlists
19
19
  */
20
20
  clearMediaLibrary(): Promise<void>;
21
+ /** Android Auto opened a folder published with `children: []`; answer with {@link resolveRequest}. */
22
+ onLoadChildren(callback: (requestId: string, parentId: string) => void): void;
23
+ /** A car search, or a voice request the loaded playlists cannot match; answer with {@link resolveRequest}. */
24
+ onSearch(callback: (requestId: string, query: string) => void): void;
25
+ /** Answers {@link onLoadChildren} / {@link onSearch} with a JSON array of MediaItem. */
26
+ resolveRequest(requestId: string, itemsJson: string): void;
27
+ /** Replaces the session buttons with a JSON array of SessionButton. */
28
+ setSessionButtons(buttonsJson: string): void;
29
+ /** Called with the button's `action` when a session button is pressed. */
30
+ onSessionButtonPress(callback: (action: string) => void): void;
31
+ /** A controller asked to play with nothing loaded (car auto-play, play on connect); load the last queue now. */
32
+ onPlaybackResumption(callback: () => void): void;
21
33
  }
@@ -28,6 +28,8 @@ export interface MediaItem {
28
28
  children?: MediaItem[];
29
29
  /** Layout type for folder items (overrides library default) */
30
30
  layoutType?: LayoutType;
31
+ /** Group header; contiguous items sharing one are listed under it */
32
+ groupTitle?: string;
31
33
  }
32
34
  /**
33
35
  * Media library structure for Android Auto
@@ -42,3 +44,12 @@ export interface MediaLibrary {
42
44
  /** Optional app icon URL */
43
45
  appIconUrl?: string;
44
46
  }
47
+ export type SessionButtonIcon = 'heart' | 'heart_filled' | 'star' | 'star_filled' | 'thumb_up' | 'thumb_up_filled' | 'plus' | 'check';
48
+ /** App-defined button beside the transport controls in Android Auto and the notification */
49
+ export interface SessionButton {
50
+ /** Passed to the press callback */
51
+ action: string;
52
+ /** Accessibility label */
53
+ title: string;
54
+ icon: SessionButtonIcon;
55
+ }
@@ -68,6 +68,10 @@ export interface PlayerConfig {
68
68
  * Fixed interval, in seconds, for remote skip-backward controls (default: 15)
69
69
  */
70
70
  remoteSkipBackwardInterval?: number;
71
+ /**
72
+ * iOS only, ignored on Android: the lock screen, Control Center and CarPlay side buttons skip by the remote skip intervals instead of changing track (default: false)
73
+ */
74
+ preferRemoteSkipIntervals?: boolean;
71
75
  /**
72
76
  * Number of upcoming tracks to preload URLs for (default: 5)
73
77
  * Higher values = more proactive loading, but more network requests
@@ -1,4 +1,5 @@
1
- import type { MediaLibrary } from '../types/AndroidAutoMediaLibrary';
1
+ import type { MediaItem, MediaLibrary, SessionButton } from '../types/AndroidAutoMediaLibrary';
2
+ type ItemsProvider = (argument: string) => Promise<MediaItem[] | null | undefined>;
2
3
  /**
3
4
  * Helper utilities for Android Auto Media Library
4
5
  * Android-only functionality
@@ -40,8 +41,19 @@ export declare const AndroidAutoMediaLibraryHelper: {
40
41
  * Falls back to showing all playlists
41
42
  */
42
43
  clear: () => void;
44
+ /** Children of folders published with `children: []`; playable ids are `${playlistId}:${trackId}`, empty after 10s. Register at module scope: the car can start the app without UI. */
45
+ setChildrenLoader: (loader: ItemsProvider) => void;
46
+ /** Answers car searches and unmatched voice requests (first playable result plays); search shows from the next connection. */
47
+ setSearchHandler: (handler: ItemsProvider) => void;
48
+ /** Buttons beside the transport controls in Android Auto and the notification; call again to change their state. */
49
+ setSessionButtons: (buttons: SessionButton[]) => void;
50
+ /** Called with the pressed button's `action`. */
51
+ onSessionButtonPress: (callback: (action: string) => void) => void;
52
+ /** Car auto-play or play-on-connect found nothing loaded: restore your last queue (playback starts once it is loaded). */
53
+ onPlaybackResumption: (callback: () => void) => void;
43
54
  /**
44
55
  * Check if Android Auto Media Library is available (Android only)
45
56
  */
46
57
  isAvailable: () => boolean;
47
58
  };
59
+ export {};
@@ -1,4 +1,7 @@
1
1
  import { AndroidAutoMediaLibrary as AndroidAutoMediaLibraryModule } from '../index';
2
+ const answer = (requestId, provide) => provide()
3
+ .catch(() => null)
4
+ .then((items) => AndroidAutoMediaLibraryModule?.resolveRequest(requestId, JSON.stringify(items ?? [])));
2
5
  /**
3
6
  * Helper utilities for Android Auto Media Library
4
7
  * Android-only functionality
@@ -53,6 +56,26 @@ export const AndroidAutoMediaLibraryHelper = {
53
56
  }
54
57
  AndroidAutoMediaLibraryModule.clearMediaLibrary();
55
58
  },
59
+ /** Children of folders published with `children: []`; playable ids are `${playlistId}:${trackId}`, empty after 10s. Register at module scope: the car can start the app without UI. */
60
+ setChildrenLoader: (loader) => {
61
+ AndroidAutoMediaLibraryModule?.onLoadChildren((requestId, parentId) => answer(requestId, () => loader(parentId)));
62
+ },
63
+ /** Answers car searches and unmatched voice requests (first playable result plays); search shows from the next connection. */
64
+ setSearchHandler: (handler) => {
65
+ AndroidAutoMediaLibraryModule?.onSearch((requestId, query) => answer(requestId, () => handler(query)));
66
+ },
67
+ /** Buttons beside the transport controls in Android Auto and the notification; call again to change their state. */
68
+ setSessionButtons: (buttons) => {
69
+ AndroidAutoMediaLibraryModule?.setSessionButtons(JSON.stringify(buttons));
70
+ },
71
+ /** Called with the pressed button's `action`. */
72
+ onSessionButtonPress: (callback) => {
73
+ AndroidAutoMediaLibraryModule?.onSessionButtonPress(callback);
74
+ },
75
+ /** Car auto-play or play-on-connect found nothing loaded: restore your last queue (playback starts once it is loaded). */
76
+ onPlaybackResumption: (callback) => {
77
+ AndroidAutoMediaLibraryModule?.onPlaybackResumption(callback);
78
+ },
56
79
  /**
57
80
  * Check if Android Auto Media Library is available (Android only)
58
81
  */
@@ -16,6 +16,9 @@
16
16
  #include <NitroModules/HybridObjectRegistry.hpp>
17
17
 
18
18
  #include "JHybridAndroidAutoMediaLibrarySpec.hpp"
19
+ #include "JFunc_void_std__string_std__string.hpp"
20
+ #include "JFunc_void_std__string.hpp"
21
+ #include "JFunc_void.hpp"
19
22
  #include "JHybridAudioDevicesSpec.hpp"
20
23
  #include "JHybridCastSpec.hpp"
21
24
  #include "JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string__.hpp"
@@ -112,6 +115,9 @@ void registerAllNatives() {
112
115
 
113
116
  // Register native JNI methods
114
117
  margelo::nitro::nitroplayer::JHybridAndroidAutoMediaLibrarySpec::CxxPart::registerNatives();
118
+ margelo::nitro::nitroplayer::JFunc_void_std__string_std__string_cxx::registerNatives();
119
+ margelo::nitro::nitroplayer::JFunc_void_std__string_cxx::registerNatives();
120
+ margelo::nitro::nitroplayer::JFunc_void_cxx::registerNatives();
115
121
  margelo::nitro::nitroplayer::JHybridAudioDevicesSpec::CxxPart::registerNatives();
116
122
  margelo::nitro::nitroplayer::JHybridCastSpec::CxxPart::registerNatives();
117
123
  margelo::nitro::nitroplayer::JFunc_void_CastState_std__optional_std__variant_nitro__NullType__std__string___cxx::registerNatives();