react-native-nitro-player 1.7.0-alpha.3 → 1.7.0-alpha.5

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 (98) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/AndroidManifest.xml +8 -0
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrary.kt +51 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +45 -7
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/connection/AndroidAutoConnectionDetector.kt +2 -2
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/ExoPlayerCore.kt +17 -2
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/NitroPlayerLogger.kt +2 -2
  8. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAndroidAuto.kt +41 -31
  9. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerAudioFocus.kt +65 -0
  10. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCast.kt +1 -1
  11. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +92 -15
  12. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +12 -17
  13. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerNotify.kt +5 -0
  14. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +39 -13
  15. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
  16. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +25 -4
  17. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerRecovery.kt +97 -0
  18. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerSetup.kt +0 -5
  19. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerUrlLoader.kt +22 -5
  20. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadDatabase.kt +3 -0
  21. package/android/src/main/java/com/margelo/nitro/nitroplayer/download/TrackItemJson.kt +6 -0
  22. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/AudioFocusController.kt +87 -0
  23. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/CastSessionController.kt +2 -0
  24. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/ExoPlayerBuilder.kt +3 -0
  25. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaBrowserService.kt +96 -45
  26. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibrary.kt +2 -0
  27. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryManager.kt +147 -0
  28. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaLibraryParser.kt +3 -0
  29. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +102 -24
  30. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionManager.kt +51 -3
  31. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/PlaybackService.kt +82 -1
  32. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +75 -0
  33. package/ios/cast/CastQueueDiff.swift +58 -0
  34. package/ios/cast/CastQueueReconciler.swift +228 -0
  35. package/ios/core/CastSessionManager.swift +215 -31
  36. package/ios/core/TrackPlayerCast.swift +96 -65
  37. package/ios/core/TrackPlayerCore.swift +31 -0
  38. package/ios/core/TrackPlayerListener.swift +19 -3
  39. package/ios/core/TrackPlayerPlayback.swift +12 -10
  40. package/ios/core/TrackPlayerQueue.swift +10 -2
  41. package/ios/core/TrackPlayerQueueBuild.swift +21 -9
  42. package/ios/core/TrackPlayerTempQueue.swift +27 -15
  43. package/ios/core/TrackPlayerUrlLoader.swift +6 -2
  44. package/ios/playlist/PlaylistManager.swift +60 -1
  45. package/ios/queue/HybridPlayerQueue.swift +40 -13
  46. package/lib/hooks/callbackManager.d.ts +2 -0
  47. package/lib/hooks/callbackManager.js +23 -8
  48. package/lib/index.js +5 -1
  49. package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +12 -0
  50. package/lib/specs/TrackPlayer.nitro.d.ts +5 -1
  51. package/lib/types/AndroidAutoMediaLibrary.d.ts +11 -0
  52. package/lib/types/PlayerQueue.d.ts +14 -0
  53. package/lib/utils/androidAutoMediaLibrary.d.ts +13 -1
  54. package/lib/utils/androidAutoMediaLibrary.js +23 -0
  55. package/nitrogen/generated/android/NitroPlayerOnLoad.cpp +6 -0
  56. package/nitrogen/generated/android/c++/JAndroidAudioFocusMode.hpp +61 -0
  57. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  58. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  59. package/nitrogen/generated/android/c++/JFunc_void_std__string_std__string.hpp +76 -0
  60. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.cpp +29 -0
  61. package/nitrogen/generated/android/c++/JHybridAndroidAutoMediaLibrarySpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +43 -0
  63. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +2 -0
  64. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
  65. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +9 -3
  66. package/nitrogen/generated/android/c++/JShufflePlaylistOptions.hpp +57 -0
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/AndroidAudioFocusMode.kt +24 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void.kt +80 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string.kt +80 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/Func_void_std__string_std__string.kt +80 -0
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridAndroidAutoMediaLibrarySpec.kt +44 -0
  72. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +8 -0
  73. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/PlayerConfig.kt +6 -3
  74. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/ShufflePlaylistOptions.kt +38 -0
  75. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +36 -0
  76. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +6 -0
  77. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +19 -0
  78. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
  79. package/nitrogen/generated/ios/swift/AndroidAudioFocusMode.swift +44 -0
  80. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +2 -0
  81. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +38 -0
  82. package/nitrogen/generated/ios/swift/PlayerConfig.swift +12 -1
  83. package/nitrogen/generated/ios/swift/ShufflePlaylistOptions.swift +42 -0
  84. package/nitrogen/generated/shared/c++/AndroidAudioFocusMode.hpp +80 -0
  85. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.cpp +6 -0
  86. package/nitrogen/generated/shared/c++/HybridAndroidAutoMediaLibrarySpec.hpp +7 -0
  87. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.cpp +2 -0
  88. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +5 -0
  89. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +9 -3
  90. package/nitrogen/generated/shared/c++/ShufflePlaylistOptions.hpp +83 -0
  91. package/package.json +1 -1
  92. package/src/hooks/callbackManager.ts +25 -10
  93. package/src/index.ts +9 -1
  94. package/src/specs/AndroidAutoMediaLibrary.nitro.ts +18 -0
  95. package/src/specs/TrackPlayer.nitro.ts +11 -0
  96. package/src/types/AndroidAutoMediaLibrary.ts +24 -0
  97. package/src/types/PlayerQueue.ts +17 -0
  98. 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
  }
@@ -79,10 +112,21 @@ class MediaSessionManager(
79
112
 
80
113
  private fun updateMediaButtonPreferences() {
81
114
  val session = mediaSession ?: return
82
- // Preferences replace Media3's default layout — prev/next must be declared or they vanish
83
- val preserved = session.mediaButtonPreferences.filterNot { it.isManagedButton() }
115
+ // Preferences replace Media3's default layout — prev/next must be declared or they vanish.
116
+ // Compact only shows BACK / play-pause / FORWARD, so the ±N skips go to OVERFLOW; left in
117
+ // the forward slot they push Next out of the compact notification entirely.
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
+ }
84
128
  session.setMediaButtonPreferences(
85
- listOf(previousTrackButton(), skipBackButton(), skipForwardButton(), nextTrackButton()) + preserved,
129
+ listOf(previousTrackButton(), nextTrackButton()) + custom + listOf(skipBackButton(), skipForwardButton()) + preserved,
86
130
  )
87
131
  }
88
132
 
@@ -90,6 +134,7 @@ class MediaSessionManager(
90
134
  CommandButton
91
135
  .Builder(CommandButton.ICON_PREVIOUS)
92
136
  .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS)
137
+ .setSlots(CommandButton.SLOT_BACK)
93
138
  .setDisplayName(context.getString(R.string.nitro_player_previous_track))
94
139
  .build()
95
140
 
@@ -97,6 +142,7 @@ class MediaSessionManager(
97
142
  CommandButton
98
143
  .Builder(CommandButton.ICON_NEXT)
99
144
  .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
145
+ .setSlots(CommandButton.SLOT_FORWARD)
100
146
  .setDisplayName(context.getString(R.string.nitro_player_next_track))
101
147
  .build()
102
148
 
@@ -104,6 +150,7 @@ class MediaSessionManager(
104
150
  CommandButton
105
151
  .Builder(skipBackIcon(remoteSkipBackwardIntervalMs))
106
152
  .setPlayerCommand(Player.COMMAND_SEEK_BACK)
153
+ .setSlots(CommandButton.SLOT_OVERFLOW)
107
154
  .setDisplayName(
108
155
  context.getString(
109
156
  R.string.nitro_player_skip_back_seconds,
@@ -115,6 +162,7 @@ class MediaSessionManager(
115
162
  CommandButton
116
163
  .Builder(skipForwardIcon(remoteSkipForwardIntervalMs))
117
164
  .setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
165
+ .setSlots(CommandButton.SLOT_OVERFLOW)
118
166
  .setDisplayName(
119
167
  context.getString(
120
168
  R.string.nitro_player_skip_forward_seconds,
@@ -2,11 +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
12
+ import androidx.media3.common.ForwardingPlayer
13
+ import androidx.media3.common.MediaItem
14
+ import androidx.media3.common.Player
10
15
  import androidx.media3.common.util.UnstableApi
11
16
  import androidx.media3.exoplayer.ExoPlayer
12
17
  import androidx.media3.session.DefaultMediaNotificationProvider
@@ -14,7 +19,12 @@ import androidx.media3.session.MediaSession
14
19
  import androidx.media3.session.MediaSessionService
15
20
  import com.google.android.gms.cast.framework.CastContext
16
21
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
22
+ import com.margelo.nitro.nitroplayer.core.checkUpcomingTracksForUrls
17
23
  import com.margelo.nitro.nitroplayer.core.TrackPlayerCore
24
+ import com.margelo.nitro.nitroplayer.core.playFromMediaId
25
+ import com.margelo.nitro.nitroplayer.core.playOnQueue
26
+ import com.margelo.nitro.nitroplayer.core.skipToNextOnQueue
27
+ import com.margelo.nitro.nitroplayer.core.skipToPreviousOnQueue
18
28
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
19
29
 
20
30
  /**
@@ -51,6 +61,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
51
61
  // ── Created in onCreate ────────────────────────────────────────────────
52
62
  private lateinit var player: ExoPlayer
53
63
  private var mediaSession: MediaSession? = null
64
+ private lateinit var sessionPlayer: Player
54
65
  private var notificationProvider: DefaultMediaNotificationProvider? = null
55
66
  private val mainHandler = Handler(Looper.getMainLooper())
56
67
 
@@ -81,10 +92,79 @@ class NitroPlayerPlaybackService : MediaSessionService() {
81
92
  // Build ExoPlayer on main looper (default)
82
93
  player = ExoPlayerBuilder.build(this)
83
94
 
95
+ // The timeline is a window over the logical queue, so ExoPlayer's own prev/next would
96
+ // only ever move inside it — remote Previous seeks to 0 instead of the previous track.
97
+ // Route the remote controls through the same logical skips JS uses.
98
+ sessionPlayer =
99
+ object : ForwardingPlayer(player) {
100
+ override fun seekToPrevious() {
101
+ trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPrevious()
102
+ }
103
+
104
+ override fun seekToPreviousMediaItem() {
105
+ trackPlayerCore?.skipToPreviousOnQueue() ?: super.seekToPreviousMediaItem()
106
+ }
107
+
108
+ override fun seekToNext() {
109
+ trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNext()
110
+ }
111
+
112
+ override fun seekToNextMediaItem() {
113
+ trackPlayerCore?.skipToNextOnQueue() ?: super.seekToNextMediaItem()
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
+ }
162
+ }
163
+
84
164
  // Build MediaSession
85
165
  val playlistManager = PlaylistManager.getInstance(this)
86
166
  mediaSession = MediaSession
87
- .Builder(this, player)
167
+ .Builder(this, sessionPlayer)
88
168
  .setCallback(MediaSessionCallbackFactory.create(this, playlistManager))
89
169
  .apply { buildSessionActivity()?.let { setSessionActivity(it) } }
90
170
  .build()
@@ -119,6 +199,7 @@ class NitroPlayerPlaybackService : MediaSessionService() {
119
199
  castContext = castContext,
120
200
  castPlayer = castPlayer,
121
201
  localPlayer = player,
202
+ localSessionPlayer = sessionPlayer,
122
203
  mediaSession = mediaSession!!,
123
204
  mainHandler = mainHandler,
124
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
  */
@@ -249,6 +264,61 @@ class PlaylistManager private constructor(
249
264
  return true
250
265
  }
251
266
 
267
+ /** Removed count; null when the playlist does not exist. Unknown ids are ignored. */
268
+ fun removeTracksFromPlaylist(
269
+ playlistId: String,
270
+ trackIds: List<String>,
271
+ ): Int? {
272
+ val ids = trackIds.toHashSet()
273
+ var removed = 0
274
+ val found =
275
+ playlists.computeIfPresent(playlistId) { _, playlist ->
276
+ val tracks = playlist.tracks.toMutableList()
277
+ val before = tracks.size
278
+ if (tracks.removeAll { it.id in ids }) {
279
+ removed = before - tracks.size
280
+ playlist.copy(tracks = tracks)
281
+ } else {
282
+ playlist
283
+ }
284
+ } != null
285
+ if (!found) return null
286
+ if (removed > 0) {
287
+ scheduleSave()
288
+ notifyPlaylistChanged(playlistId, QueueOperation.REMOVE)
289
+ NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
290
+ }
291
+ return removed
292
+ }
293
+
294
+ /** Fisher-Yates; [firstTrackId] is pinned at index 0. Null when the playlist does not exist. */
295
+ fun shufflePlaylist(
296
+ playlistId: String,
297
+ firstTrackId: String?,
298
+ ): Boolean? {
299
+ var changed = false
300
+ val found =
301
+ playlists.computeIfPresent(playlistId) { _, playlist ->
302
+ val tracks = playlist.tracks.toMutableList()
303
+ val pinned = tracks.indexOfFirst { it.id == firstTrackId }.takeIf { it >= 0 }?.let { tracks.removeAt(it) }
304
+ tracks.shuffle()
305
+ pinned?.let { tracks.add(0, it) }
306
+ if (tracks.map { it.id } == playlist.tracks.map { it.id }) {
307
+ playlist
308
+ } else {
309
+ changed = true
310
+ playlist.copy(tracks = tracks)
311
+ }
312
+ } != null
313
+ if (!found) return null
314
+ if (changed) {
315
+ scheduleSave()
316
+ notifyPlaylistChanged(playlistId, QueueOperation.UPDATE)
317
+ NitroPlayerMediaBrowserService.getInstance()?.onPlaylistUpdated(playlistId)
318
+ }
319
+ return changed
320
+ }
321
+
252
322
  /**
253
323
  * Update entire track objects across all playlists
254
324
  * Matches by track.id and replaces the entire track object
@@ -317,6 +387,11 @@ class PlaylistManager private constructor(
317
387
  return true
318
388
  }
319
389
 
390
+ /** Mirrors the core's current playlist (playSong bypasses loadPlaylist); Android Auto and persistence read this. */
391
+ fun setCurrentPlaylistId(playlistId: String?) {
392
+ currentPlaylistId = playlistId
393
+ }
394
+
320
395
  /**
321
396
  * Get the current playlist ID
322
397
  */
@@ -0,0 +1,58 @@
1
+ // Pure receiver-queue diff: no Cast SDK types, no operation ever contains the current item.
2
+
3
+ import Foundation
4
+
5
+ enum CastQueueOp: Equatable {
6
+ case remove([String])
7
+ case insert([String])
8
+ case moveToEnd([String])
9
+ case moveBefore([String], anchor: String)
10
+ }
11
+
12
+ enum CastQueueDiff {
13
+ /// Converges in at most 4 ops. Caller guarantees `currentId` occurs exactly once in both lists.
14
+ static func nextOp(existing: [String], desired: [String], currentId: String) -> CastQueueOp? {
15
+ let desiredSet = Set(desired)
16
+ let existingSet = Set(existing)
17
+
18
+ let toRemove = existing.filter { !desiredSet.contains($0) }
19
+ if !toRemove.isEmpty { return .remove(toRemove) }
20
+
21
+ let toInsert = desired.filter { !existingSet.contains($0) }
22
+ if !toInsert.isEmpty { return .insert(toInsert) }
23
+
24
+ if existing == desired { return nil }
25
+ guard let cur = desired.firstIndex(of: currentId) else { return nil }
26
+
27
+ let after = Array(desired[(cur + 1)...])
28
+ if !after.isEmpty, Array(existing.suffix(after.count)) != after { return .moveToEnd(after) }
29
+
30
+ let before = Array(desired[..<cur])
31
+ if !before.isEmpty, Array(existing.prefix(before.count)) != before {
32
+ return .moveBefore(before, anchor: currentId)
33
+ }
34
+ return nil
35
+ }
36
+
37
+ static func apply(_ op: CastQueueOp, to existing: [String]) -> [String] {
38
+ switch op {
39
+ case .remove(let ids):
40
+ let dropped = Set(ids)
41
+ return existing.filter { !dropped.contains($0) }
42
+ case .insert(let ids):
43
+ return existing + ids
44
+ case .moveToEnd(let ids):
45
+ let moved = Set(ids)
46
+ return existing.filter { !moved.contains($0) } + ids
47
+ case .moveBefore(let ids, let anchor):
48
+ let moved = Set(ids)
49
+ var out: [String] = []
50
+ out.reserveCapacity(existing.count)
51
+ for id in existing where !moved.contains(id) {
52
+ if id == anchor { out.append(contentsOf: ids) }
53
+ out.append(id)
54
+ }
55
+ return out
56
+ }
57
+ }
58
+ }