react-native-nitro-player 1.0.3 → 1.2.0

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 (29) hide show
  1. package/android/src/main/AndroidManifest.xml +12 -0
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +5 -2
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +1 -1
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
  5. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +16 -1
  6. package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +178 -0
  7. package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +3 -2
  8. package/ios/core/TrackPlayerListener.swift +13 -6
  9. package/ios/core/TrackPlayerQueue.swift +3 -2
  10. package/ios/core/TrackPlayerQueueBuild.swift +4 -4
  11. package/ios/core/TrackPlayerTempQueue.swift +15 -1
  12. package/ios/playlist/PlaylistManager.swift +7 -3
  13. package/ios/queue/HybridPlayerQueue.swift +5 -3
  14. package/lib/specs/TrackPlayer.nitro.d.ts +1 -1
  15. package/lib/types/PlayerQueue.d.ts +1 -1
  16. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +3 -3
  17. package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +1 -1
  18. package/nitrogen/generated/android/c++/JTrackPlayerState.hpp +3 -0
  19. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +1 -1
  20. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TrackPlayerState.kt +2 -1
  21. package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +2 -2
  22. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +1 -1
  23. package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +9 -2
  24. package/nitrogen/generated/ios/swift/TrackPlayerState.swift +4 -0
  25. package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +1 -1
  26. package/nitrogen/generated/shared/c++/TrackPlayerState.hpp +4 -0
  27. package/package.json +1 -1
  28. package/src/specs/TrackPlayer.nitro.ts +1 -1
  29. package/src/types/PlayerQueue.ts +1 -1
@@ -8,6 +8,11 @@
8
8
  <uses-permission android:name="android.permission.WAKE_LOCK" />
9
9
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
10
10
 
11
+ <!-- Required so Android Auto / Assistant treat this as a media app. -->
12
+ <uses-feature
13
+ android:name="android.hardware.audio.output"
14
+ android:required="false" />
15
+
11
16
  <application>
12
17
  <!-- PlaybackService keeps music alive when screen is locked / app backgrounded -->
13
18
  <service
@@ -18,6 +23,13 @@
18
23
  <intent-filter>
19
24
  <action android:name="androidx.media3.session.MediaSessionService" />
20
25
  </intent-filter>
26
+ <!-- Legacy voice-search entry point. Modern Android Auto / Assistant
27
+ routes through MediaSession.Callback.onSetMediaItems with a
28
+ searchQuery, but some surfaces still send this action. -->
29
+ <intent-filter>
30
+ <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
31
+ <category android:name="android.intent.category.DEFAULT" />
32
+ </intent-filter>
21
33
  </service>
22
34
 
23
35
  <!-- WorkManager's SystemForegroundService for download notifications -->
@@ -109,9 +109,12 @@ class HybridPlayerQueue : HybridPlayerQueueSpec() {
109
109
 
110
110
  // ── Playback control ──────────────────────────────────────────────────────
111
111
 
112
- override fun loadPlaylist(playlistId: String): Promise<Unit> =
112
+ override fun loadPlaylist(playlistId: String, index: Double?): Promise<Unit> =
113
113
  Promise.async {
114
- core.loadPlaylist(playlistId)
114
+ val startIndex = index?.toInt()
115
+ val loaded = playlistManager.loadPlaylist(playlistId, startIndex)
116
+ if (!loaded) return@async
117
+ core.loadPlaylist(playlistId, startIndex)
115
118
  }
116
119
 
117
120
  override fun getCurrentPlaylistId(): Variant_NullType_String {
@@ -167,7 +167,7 @@ internal fun TrackPlayerCore.emitStateChange(reason: Reason? = null) {
167
167
  val state =
168
168
  when (exo.playbackState) {
169
169
  Player.STATE_IDLE -> TrackPlayerState.STOPPED
170
- Player.STATE_BUFFERING -> if (exo.playWhenReady) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
170
+ Player.STATE_BUFFERING -> if (exo.playWhenReady) TrackPlayerState.BUFFERING else TrackPlayerState.PAUSED
171
171
  Player.STATE_READY -> if (exo.isPlaying) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
172
172
  Player.STATE_ENDED -> TrackPlayerState.STOPPED
173
173
  else -> TrackPlayerState.STOPPED
@@ -38,7 +38,7 @@ internal fun TrackPlayerCore.getStateInternal(): PlayerState {
38
38
  val state =
39
39
  when (exo.playbackState) {
40
40
  Player.STATE_IDLE -> TrackPlayerState.STOPPED
41
- Player.STATE_BUFFERING -> if (exo.playWhenReady) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
41
+ Player.STATE_BUFFERING -> if (exo.playWhenReady) TrackPlayerState.BUFFERING else TrackPlayerState.PAUSED
42
42
  Player.STATE_READY -> if (exo.isPlaying) TrackPlayerState.PLAYING else TrackPlayerState.PAUSED
43
43
  Player.STATE_ENDED -> TrackPlayerState.STOPPED
44
44
  else -> TrackPlayerState.STOPPED
@@ -11,14 +11,29 @@ import com.margelo.nitro.nitroplayer.TrackItem
11
11
 
12
12
  // ── Playlist loading ──────────────────────────────────────────────────────
13
13
 
14
- suspend fun TrackPlayerCore.loadPlaylist(playlistId: String) =
14
+ suspend fun TrackPlayerCore.loadPlaylist(
15
+ playlistId: String,
16
+ startIndex: Int? = null,
17
+ ) =
15
18
  withPlayerContext {
16
19
  playNextStack.clear()
17
20
  upNextQueue.clear()
18
21
  currentTemporaryType = TrackPlayerCore.TemporaryType.NONE
19
22
  val playlist = playlistManager.getPlaylist(playlistId) ?: return@withPlayerContext
23
+
24
+ val targetIndex =
25
+ if (startIndex != null) {
26
+ if (startIndex < 0 || startIndex >= playlist.tracks.size) return@withPlayerContext
27
+ startIndex
28
+ } else {
29
+ 0
30
+ }
31
+
20
32
  currentPlaylistId = playlistId
21
33
  updatePlayerQueue(playlist.tracks)
34
+ if (targetIndex > 0) {
35
+ playFromIndexInternal(targetIndex)
36
+ }
22
37
  checkUpcomingTracksForUrls(lookaheadCount)
23
38
  notifyTemporaryQueueChange()
24
39
  }
@@ -3,6 +3,7 @@
3
3
  package com.margelo.nitro.nitroplayer.media
4
4
 
5
5
  import android.net.Uri
6
+ import android.provider.MediaStore
6
7
  import androidx.media3.common.MediaItem
7
8
  import androidx.media3.common.MediaMetadata
8
9
  import androidx.media3.exoplayer.ExoPlayer
@@ -14,6 +15,7 @@ import com.google.common.util.concurrent.ListenableFuture
14
15
  import com.margelo.nitro.nitroplayer.TrackItem
15
16
  import com.margelo.nitro.nitroplayer.core.NitroPlayerLogger
16
17
  import com.margelo.nitro.nitroplayer.core.loadPlaylist
18
+ import com.margelo.nitro.nitroplayer.playlist.Playlist
17
19
  import com.margelo.nitro.nitroplayer.playlist.PlaylistManager
18
20
  import kotlinx.coroutines.CoroutineScope
19
21
  import kotlinx.coroutines.Dispatchers
@@ -52,6 +54,13 @@ object MediaSessionCallbackFactory {
52
54
  mediaItems: MutableList<MediaItem>,
53
55
  ): ListenableFuture<MutableList<MediaItem>> {
54
56
  NitroPlayerLogger.log("MediaSessionCallback") { "onAddMediaItems called with ${mediaItems.size} items" }
57
+
58
+ // Voice search ("Add X to the queue") lands here too.
59
+ val voiceMatch = resolveVoiceSearch(mediaItems, playlistManager)
60
+ if (voiceMatch != null) {
61
+ return Futures.immediateFuture(voiceMatch.items.toMutableList())
62
+ }
63
+
55
64
  if (mediaItems.isEmpty()) return Futures.immediateFuture(mutableListOf())
56
65
 
57
66
  val updated = mutableListOf<MediaItem>()
@@ -90,6 +99,21 @@ object MediaSessionCallbackFactory {
90
99
  startPositionMs: Long,
91
100
  ): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
92
101
  NitroPlayerLogger.log("MediaSessionCallback") { "onSetMediaItems called with ${mediaItems.size} items, startIndex: $startIndex" }
102
+
103
+ // Voice search ("Hey Google, play X on …") routes here with a
104
+ // MediaItem whose requestMetadata.searchQuery is set. Resolve
105
+ // it against loaded playlists before falling through.
106
+ val voiceMatch = resolveVoiceSearch(mediaItems, playlistManager)
107
+ if (voiceMatch != null) {
108
+ val (playlistId, items, trackIndex) = voiceMatch
109
+ service.trackPlayerCore?.let { core ->
110
+ scope.launch { core.loadPlaylist(playlistId) }
111
+ }
112
+ return Futures.immediateFuture(
113
+ MediaSession.MediaItemsWithStartPosition(items, trackIndex, startPositionMs),
114
+ )
115
+ }
116
+
93
117
  if (mediaItems.isEmpty()) {
94
118
  return Futures.immediateFuture(
95
119
  MediaSession.MediaItemsWithStartPosition(mutableListOf(), 0, 0),
@@ -125,6 +149,17 @@ object MediaSessionCallbackFactory {
125
149
  } catch (e: Exception) {
126
150
  NitroPlayerLogger.log("MediaSessionCallback") { "Error in onSetMediaItems: ${e.message}" }
127
151
  }
152
+
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
+ }
162
+
128
163
  return Futures.immediateFuture(
129
164
  MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs),
130
165
  )
@@ -140,6 +175,149 @@ object MediaSessionCallbackFactory {
140
175
  }
141
176
  }
142
177
 
178
+ private data class VoiceMatch(
179
+ val playlistId: String,
180
+ val items: List<MediaItem>,
181
+ val startIndex: Int,
182
+ )
183
+
184
+ /**
185
+ * Resolves a voice-search MediaItem into a concrete playlist + start index.
186
+ *
187
+ * Triggered when Google Assistant ("Hey Google, play X on …") sends a
188
+ * MediaItem with `requestMetadata.searchQuery`. Honors the
189
+ * `MediaStore.EXTRA_MEDIA_FOCUS` bundle so artist/album/genre/playlist/title
190
+ * queries narrow the search appropriately. Empty queries fall back to the
191
+ * current or first available playlist (Assistant: "Play music").
192
+ */
193
+ @Suppress("DEPRECATION") // MediaStore.Audio.Playlists constants — Assistant still sends them.
194
+ private fun resolveVoiceSearch(
195
+ mediaItems: List<MediaItem>,
196
+ playlistManager: PlaylistManager,
197
+ ): VoiceMatch? {
198
+ val first = mediaItems.firstOrNull() ?: return null
199
+ val request = first.requestMetadata
200
+ val rawQuery = request.searchQuery ?: return null
201
+ val query = rawQuery.trim()
202
+ val extras = request.extras
203
+
204
+ // Empty query → "play music"-style resume. Prefer current playlist,
205
+ // otherwise the first non-empty playlist.
206
+ if (query.isEmpty()) {
207
+ val fallback =
208
+ playlistManager.getCurrentPlaylist()
209
+ ?: playlistManager.getAllPlaylists().firstOrNull { it.tracks.isNotEmpty() }
210
+ ?: return null
211
+ val items =
212
+ fallback.tracks.map { createMediaItem(it, "${fallback.id}:${it.id}") }
213
+ return VoiceMatch(fallback.id, items, 0)
214
+ }
215
+
216
+ val focus = extras?.getString(MediaStore.EXTRA_MEDIA_FOCUS)
217
+ val artist = extras?.getString(MediaStore.EXTRA_MEDIA_ARTIST)
218
+ val album = extras?.getString(MediaStore.EXTRA_MEDIA_ALBUM)
219
+ val title = extras?.getString(MediaStore.EXTRA_MEDIA_TITLE)
220
+ val playlistName = extras?.getString(MediaStore.EXTRA_MEDIA_PLAYLIST)
221
+ val genre = extras?.getString(MediaStore.EXTRA_MEDIA_GENRE)
222
+
223
+ val all = playlistManager.getAllPlaylists()
224
+ if (all.isEmpty()) return null
225
+
226
+ // Playlist-focused query: best match by playlist name.
227
+ if (focus == MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE && !playlistName.isNullOrBlank()) {
228
+ val matched = bestPlaylistByName(all, playlistName) ?: bestPlaylistByName(all, query)
229
+ if (matched != null && matched.tracks.isNotEmpty()) {
230
+ val items = matched.tracks.map { createMediaItem(it, "${matched.id}:${it.id}") }
231
+ return VoiceMatch(matched.id, items, 0)
232
+ }
233
+ }
234
+
235
+ // Score every track across every playlist; play the best track in the
236
+ // context of its playlist so "next/previous" still work naturally.
237
+ var bestPlaylist: Playlist? = null
238
+ var bestIndex = -1
239
+ var bestScore = 0
240
+
241
+ for (playlist in all) {
242
+ playlist.tracks.forEachIndexed { idx, track ->
243
+ val score = scoreTrack(track, query, focus, title, artist, album, genre)
244
+ if (score > bestScore) {
245
+ bestScore = score
246
+ bestPlaylist = playlist
247
+ bestIndex = idx
248
+ }
249
+ }
250
+ }
251
+
252
+ val match = bestPlaylist
253
+ if (match == null || bestIndex < 0 || bestScore <= 0) return null
254
+
255
+ val items = match.tracks.map { createMediaItem(it, "${match.id}:${it.id}") }
256
+ return VoiceMatch(match.id, items, bestIndex)
257
+ }
258
+
259
+ private fun bestPlaylistByName(
260
+ playlists: List<Playlist>,
261
+ name: String,
262
+ ): Playlist? {
263
+ val q = name.lowercase().trim()
264
+ if (q.isEmpty()) return null
265
+ return playlists
266
+ .map { it to it.name.lowercase() }
267
+ .filter { (_, n) -> n.contains(q) || q.contains(n) }
268
+ .minByOrNull { (_, n) -> kotlin.math.abs(n.length - q.length) }
269
+ ?.first
270
+ }
271
+
272
+ private fun scoreTrack(
273
+ track: TrackItem,
274
+ query: String,
275
+ focus: String?,
276
+ title: String?,
277
+ artist: String?,
278
+ album: String?,
279
+ genre: String?,
280
+ ): Int {
281
+ val q = query.lowercase()
282
+ val titleL = track.title.lowercase()
283
+ val artistL = track.artist.lowercase()
284
+ val albumL = track.album.lowercase()
285
+
286
+ var score = 0
287
+
288
+ when (focus) {
289
+ MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> {
290
+ if (!artist.isNullOrBlank() && artistL.contains(artist.lowercase())) score += 80
291
+ if (artistL.contains(q)) score += 40
292
+ }
293
+ MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> {
294
+ if (!album.isNullOrBlank() && albumL.contains(album.lowercase())) score += 80
295
+ if (albumL.contains(q)) score += 40
296
+ }
297
+ MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> {
298
+ if (!genre.isNullOrBlank()) {
299
+ val g = genre.lowercase()
300
+ if (titleL.contains(g) || albumL.contains(g) || artistL.contains(g)) score += 40
301
+ }
302
+ }
303
+ else -> {
304
+ if (!title.isNullOrBlank() && titleL.contains(title.lowercase())) score += 80
305
+ if (!artist.isNullOrBlank() && artistL.contains(artist.lowercase())) score += 50
306
+ if (!album.isNullOrBlank() && albumL.contains(album.lowercase())) score += 30
307
+ }
308
+ }
309
+
310
+ // Token-overlap fallback against the whole free-text query.
311
+ val tokens = q.split(' ', ',', '-').filter { it.length >= 2 }
312
+ for (t in tokens) {
313
+ if (titleL.contains(t)) score += 10
314
+ if (artistL.contains(t)) score += 6
315
+ if (albumL.contains(t)) score += 3
316
+ }
317
+ if (titleL == q || artistL == q || albumL == q) score += 100
318
+ return score
319
+ }
320
+
143
321
  private fun createMediaItem(
144
322
  track: TrackItem,
145
323
  mediaId: String,
@@ -304,8 +304,9 @@ class PlaylistManager private constructor(
304
304
  /**
305
305
  * Load a playlist for playback (sets it as current)
306
306
  */
307
- fun loadPlaylist(playlistId: String): Boolean {
308
- if (playlists[playlistId] == null) return false
307
+ fun loadPlaylist(playlistId: String, index: Int? = null): Boolean {
308
+ val playlist = playlists[playlistId] ?: return false
309
+ if (index != null && (index < 0 || index >= playlist.tracks.size)) return false
309
310
  currentPlaylistId = playlistId
310
311
  return true
311
312
  }
@@ -372,16 +372,18 @@ extension TrackPlayerCore {
372
372
  }
373
373
  currentItemObservers.append(statusObserver)
374
374
 
375
- let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { item, _ in
375
+ let bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] item, _ in
376
376
  if item.isPlaybackBufferEmpty {
377
377
  NitroPlayerLogger.log("TrackPlayerCore", "⏸️ Buffer empty (buffering)")
378
+ self?.emitStateChange()
378
379
  }
379
380
  }
380
381
  currentItemObservers.append(bufferEmptyObserver)
381
382
 
382
- let bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { item, _ in
383
+ let bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in
383
384
  if item.isPlaybackLikelyToKeepUp {
384
385
  NitroPlayerLogger.log("TrackPlayerCore", "▶️ Buffer likely to keep up")
386
+ self?.emitStateChange()
385
387
  }
386
388
  }
387
389
  currentItemObservers.append(bufferKeepUpObserver)
@@ -390,10 +392,15 @@ extension TrackPlayerCore {
390
392
  func emitStateChange(reason: Reason? = nil) {
391
393
  guard let player else { return }
392
394
  let state: TrackPlayerState
393
- if player.rate == 0 { state = .paused }
394
- else if player.timeControlStatus == .playing { state = .playing }
395
- else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate { state = .paused }
396
- else { state = .stopped }
395
+ if player.timeControlStatus == .playing {
396
+ state = .playing
397
+ } else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
398
+ state = .buffering
399
+ } else if player.rate == 0 {
400
+ state = .paused
401
+ } else {
402
+ state = .stopped
403
+ }
397
404
  NitroPlayerLogger.log("TrackPlayerCore", "🔔 Emitting state change: \(state)")
398
405
  notifyPlaybackStateChange(state, reason)
399
406
  }
@@ -47,8 +47,9 @@ extension TrackPlayerCore {
47
47
  let totalDuration = (rawDuration > 0 && !rawDuration.isNaN && !rawDuration.isInfinite) ? rawDuration : 0.0
48
48
 
49
49
  let state: TrackPlayerState
50
- if player.rate == 0 { state = .paused }
51
- else if player.timeControlStatus == .playing { state = .playing }
50
+ if player.timeControlStatus == .playing { state = .playing }
51
+ else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate { state = .buffering }
52
+ else if player.rate == 0 { state = .paused }
52
53
  else { state = .stopped }
53
54
 
54
55
  let currentIndex: Double = currentTrackIndex >= 0 ? Double(currentTrackIndex) : -1.0
@@ -55,11 +55,11 @@ extension TrackPlayerCore {
55
55
  NitroPlayerLogger.log("TrackPlayerCore", "🔄 Removing \(existingPlayer.items().count) old items from player")
56
56
  existingPlayer.removeAllItems()
57
57
 
58
- // Lazy-load mode: if any track has no URL AND is not downloaded locally,
59
- // we can't create an AVPlayerItem for it and the queue order would be wrong.
60
- let isLazyLoad = tracks.contains {
58
+ // Lazy-load mode if the first track needs a URL. Tracks further in the queue with
59
+ // empty URLs are dropped safely by compactMap below and resolved via onTracksNeedUpdate.
60
+ let isLazyLoad = tracks.first.map {
61
61
  $0.url.isEmpty && !DownloadManagerCore.shared.isTrackDownloaded(trackId: $0.id)
62
- }
62
+ } ?? false
63
63
  if isLazyLoad {
64
64
  NitroPlayerLogger.log("TrackPlayerCore", "⏳ Lazy-load mode — player cleared, awaiting URL resolution")
65
65
  return
@@ -8,7 +8,7 @@ import Foundation
8
8
 
9
9
  extension TrackPlayerCore {
10
10
 
11
- func loadPlaylist(playlistId: String) async {
11
+ func loadPlaylist(playlistId: String, startIndex: Int? = nil) async {
12
12
  await withPlayerQueueNoThrow {
13
13
  self.playNextStack.removeAll()
14
14
  self.upNextQueue.removeAll()
@@ -32,8 +32,22 @@ extension TrackPlayerCore {
32
32
  }
33
33
  NitroPlayerLogger.log("TrackPlayerCore", String(repeating: "🎼", count: Constants.playlistSeparatorLength) + "\n")
34
34
 
35
+ let targetIndex: Int
36
+ if let startIndex {
37
+ guard startIndex >= 0 && startIndex < playlist.tracks.count else {
38
+ NitroPlayerLogger.log("TrackPlayerCore", " ❌ Invalid start index: \(startIndex) (track count: \(playlist.tracks.count))")
39
+ return
40
+ }
41
+ targetIndex = startIndex
42
+ } else {
43
+ targetIndex = 0
44
+ }
45
+
35
46
  self.currentPlaylistId = playlistId
36
47
  self.updatePlayerQueue(tracks: playlist.tracks)
48
+ if targetIndex > 0 {
49
+ _ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
50
+ }
37
51
  self.emitStateChange()
38
52
  self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
39
53
  self.notifyTemporaryQueueChange()
@@ -261,9 +261,13 @@ class PlaylistManager {
261
261
  /**
262
262
  * Load a playlist for playback (sets it as current)
263
263
  */
264
- func loadPlaylist(playlistId: String) -> Bool {
265
- let exists = queue.sync { playlists[playlistId] != nil }
266
- guard exists else {
264
+ func loadPlaylist(playlistId: String, index: Int? = nil) -> Bool {
265
+ let isValid = queue.sync {
266
+ guard let playlist = playlists[playlistId] else { return false }
267
+ guard let index else { return true }
268
+ return index >= 0 && index < playlist.tracks.count
269
+ }
270
+ guard isValid else {
267
271
  return false
268
272
  }
269
273
 
@@ -77,11 +77,13 @@ final class HybridPlayerQueue: HybridPlayerQueueSpec {
77
77
 
78
78
  // MARK: - Playback control
79
79
 
80
- func loadPlaylist(playlistId: String) throws -> Promise<Void> {
80
+ func loadPlaylist(playlistId: String, index: Double?) throws -> Promise<Void> {
81
81
  Promise.async {
82
+ let startIndex = index.map { Int($0) }
82
83
  // Update PlaylistManager.currentPlaylistId so getCurrentPlaylistId() returns correctly
83
- _ = self.playlistManager.loadPlaylist(playlistId: playlistId)
84
- await self.core.loadPlaylist(playlistId: playlistId)
84
+ let loaded = self.playlistManager.loadPlaylist(playlistId: playlistId, index: startIndex)
85
+ guard loaded else { return }
86
+ await self.core.loadPlaylist(playlistId: playlistId, startIndex: startIndex)
85
87
  }
86
88
  }
87
89
 
@@ -13,7 +13,7 @@ export interface PlayerQueue extends HybridObject<{
13
13
  addTracksToPlaylist(playlistId: string, tracks: TrackItem[], index?: number): Promise<void>;
14
14
  removeTrackFromPlaylist(playlistId: string, trackId: string): Promise<void>;
15
15
  reorderTrackInPlaylist(playlistId: string, trackId: string, newIndex: number): Promise<void>;
16
- loadPlaylist(playlistId: string): Promise<void>;
16
+ loadPlaylist(playlistId: string, index?: number): Promise<void>;
17
17
  getCurrentPlaylistId(): string | null;
18
18
  onPlaylistsChanged(callback: (playlists: Playlist[], operation?: QueueOperation) => void): void;
19
19
  onPlaylistChanged(callback: (playlistId: string, playlist: Playlist, operation?: QueueOperation) => void): void;
@@ -18,7 +18,7 @@ export interface Playlist {
18
18
  tracks: TrackItem[];
19
19
  }
20
20
  export type QueueOperation = 'add' | 'remove' | 'clear' | 'update';
21
- export type TrackPlayerState = 'playing' | 'paused' | 'stopped';
21
+ export type TrackPlayerState = 'playing' | 'paused' | 'stopped' | 'buffering';
22
22
  export type Reason = 'user_action' | 'skip' | 'end' | 'error' | 'repeat';
23
23
  export interface PlayerState {
24
24
  currentTrack: TrackItem | null;
@@ -205,9 +205,9 @@ namespace margelo::nitro::nitroplayer {
205
205
  return __promise;
206
206
  }();
207
207
  }
208
- std::shared_ptr<Promise<void>> JHybridPlayerQueueSpec::loadPlaylist(const std::string& playlistId) {
209
- static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JPromise::javaobject>(jni::alias_ref<jni::JString> /* playlistId */)>("loadPlaylist");
210
- auto __result = method(_javaPart, jni::make_jstring(playlistId));
208
+ std::shared_ptr<Promise<void>> JHybridPlayerQueueSpec::loadPlaylist(const std::string& playlistId, std::optional<double> index) {
209
+ static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JPromise::javaobject>(jni::alias_ref<jni::JString> /* playlistId */, jni::alias_ref<jni::JDouble> /* index */)>("loadPlaylist");
210
+ auto __result = method(_javaPart, jni::make_jstring(playlistId), index.has_value() ? jni::JDouble::valueOf(index.value()) : nullptr);
211
211
  return [&]() {
212
212
  auto __promise = Promise<void>::create();
213
213
  __result->cthis()->addOnResolvedListener([=](const jni::alias_ref<jni::JObject>& /* unit */) {
@@ -63,7 +63,7 @@ namespace margelo::nitro::nitroplayer {
63
63
  std::shared_ptr<Promise<void>> addTracksToPlaylist(const std::string& playlistId, const std::vector<TrackItem>& tracks, std::optional<double> index) override;
64
64
  std::shared_ptr<Promise<void>> removeTrackFromPlaylist(const std::string& playlistId, const std::string& trackId) override;
65
65
  std::shared_ptr<Promise<void>> reorderTrackInPlaylist(const std::string& playlistId, const std::string& trackId, double newIndex) override;
66
- std::shared_ptr<Promise<void>> loadPlaylist(const std::string& playlistId) override;
66
+ std::shared_ptr<Promise<void>> loadPlaylist(const std::string& playlistId, std::optional<double> index) override;
67
67
  std::variant<nitro::NullType, std::string> getCurrentPlaylistId() override;
68
68
  void onPlaylistsChanged(const std::function<void(const std::vector<Playlist>& /* playlists */, std::optional<QueueOperation> /* operation */)>& callback) override;
69
69
  void onPlaylistChanged(const std::function<void(const std::string& /* playlistId */, const Playlist& /* playlist */, std::optional<QueueOperation> /* operation */)>& callback) override;
@@ -51,6 +51,9 @@ namespace margelo::nitro::nitroplayer {
51
51
  case TrackPlayerState::STOPPED:
52
52
  static const auto fieldSTOPPED = clazz->getStaticField<JTrackPlayerState>("STOPPED");
53
53
  return clazz->getStaticFieldValue(fieldSTOPPED);
54
+ case TrackPlayerState::BUFFERING:
55
+ static const auto fieldBUFFERING = clazz->getStaticField<JTrackPlayerState>("BUFFERING");
56
+ return clazz->getStaticFieldValue(fieldBUFFERING);
54
57
  default:
55
58
  std::string stringValue = std::to_string(static_cast<int>(value));
56
59
  throw std::invalid_argument("Invalid enum value (" + stringValue + "!");
@@ -68,7 +68,7 @@ abstract class HybridPlayerQueueSpec: HybridObject() {
68
68
 
69
69
  @DoNotStrip
70
70
  @Keep
71
- abstract fun loadPlaylist(playlistId: String): Promise<Unit>
71
+ abstract fun loadPlaylist(playlistId: String, index: Double?): Promise<Unit>
72
72
 
73
73
  @DoNotStrip
74
74
  @Keep
@@ -18,7 +18,8 @@ import com.facebook.proguard.annotations.DoNotStrip
18
18
  enum class TrackPlayerState(@DoNotStrip @Keep val value: Int) {
19
19
  PAUSED(0),
20
20
  PLAYING(1),
21
- STOPPED(2);
21
+ STOPPED(2),
22
+ BUFFERING(3);
22
23
 
23
24
  companion object
24
25
  }
@@ -153,8 +153,8 @@ namespace margelo::nitro::nitroplayer {
153
153
  auto __value = std::move(__result.value());
154
154
  return __value;
155
155
  }
156
- inline std::shared_ptr<Promise<void>> loadPlaylist(const std::string& playlistId) override {
157
- auto __result = _swiftPart.loadPlaylist(playlistId);
156
+ inline std::shared_ptr<Promise<void>> loadPlaylist(const std::string& playlistId, std::optional<double> index) override {
157
+ auto __result = _swiftPart.loadPlaylist(playlistId, index);
158
158
  if (__result.hasError()) [[unlikely]] {
159
159
  std::rethrow_exception(__result.error());
160
160
  }
@@ -22,7 +22,7 @@ public protocol HybridPlayerQueueSpec_protocol: HybridObject {
22
22
  func addTracksToPlaylist(playlistId: String, tracks: [TrackItem], index: Double?) throws -> Promise<Void>
23
23
  func removeTrackFromPlaylist(playlistId: String, trackId: String) throws -> Promise<Void>
24
24
  func reorderTrackInPlaylist(playlistId: String, trackId: String, newIndex: Double) throws -> Promise<Void>
25
- func loadPlaylist(playlistId: String) throws -> Promise<Void>
25
+ func loadPlaylist(playlistId: String, index: Double?) throws -> Promise<Void>
26
26
  func getCurrentPlaylistId() throws -> Variant_NullType_String
27
27
  func onPlaylistsChanged(callback: @escaping (_ playlists: [Playlist], _ operation: QueueOperation?) -> Void) throws -> Void
28
28
  func onPlaylistChanged(callback: @escaping (_ playlistId: String, _ playlist: Playlist, _ operation: QueueOperation?) -> Void) throws -> Void
@@ -344,9 +344,16 @@ open class HybridPlayerQueueSpec_cxx {
344
344
  }
345
345
 
346
346
  @inline(__always)
347
- public final func loadPlaylist(playlistId: std.string) -> bridge.Result_std__shared_ptr_Promise_void___ {
347
+ public final func loadPlaylist(playlistId: std.string, index: bridge.std__optional_double_) -> bridge.Result_std__shared_ptr_Promise_void___ {
348
348
  do {
349
- let __result = try self.__implementation.loadPlaylist(playlistId: String(playlistId))
349
+ let __result = try self.__implementation.loadPlaylist(playlistId: String(playlistId), index: { () -> Double? in
350
+ if bridge.has_value_std__optional_double_(index) {
351
+ let __unwrapped = bridge.get_std__optional_double_(index)
352
+ return __unwrapped
353
+ } else {
354
+ return nil
355
+ }
356
+ }())
350
357
  let __resultCpp = { () -> bridge.std__shared_ptr_Promise_void__ in
351
358
  let __promise = bridge.create_std__shared_ptr_Promise_void__()
352
359
  let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_void__(__promise)
@@ -23,6 +23,8 @@ public extension TrackPlayerState {
23
23
  self = .playing
24
24
  case "stopped":
25
25
  self = .stopped
26
+ case "buffering":
27
+ self = .buffering
26
28
  default:
27
29
  return nil
28
30
  }
@@ -39,6 +41,8 @@ public extension TrackPlayerState {
39
41
  return "playing"
40
42
  case .stopped:
41
43
  return "stopped"
44
+ case .buffering:
45
+ return "buffering"
42
46
  }
43
47
  }
44
48
  }
@@ -71,7 +71,7 @@ namespace margelo::nitro::nitroplayer {
71
71
  virtual std::shared_ptr<Promise<void>> addTracksToPlaylist(const std::string& playlistId, const std::vector<TrackItem>& tracks, std::optional<double> index) = 0;
72
72
  virtual std::shared_ptr<Promise<void>> removeTrackFromPlaylist(const std::string& playlistId, const std::string& trackId) = 0;
73
73
  virtual std::shared_ptr<Promise<void>> reorderTrackInPlaylist(const std::string& playlistId, const std::string& trackId, double newIndex) = 0;
74
- virtual std::shared_ptr<Promise<void>> loadPlaylist(const std::string& playlistId) = 0;
74
+ virtual std::shared_ptr<Promise<void>> loadPlaylist(const std::string& playlistId, std::optional<double> index) = 0;
75
75
  virtual std::variant<nitro::NullType, std::string> getCurrentPlaylistId() = 0;
76
76
  virtual void onPlaylistsChanged(const std::function<void(const std::vector<Playlist>& /* playlists */, std::optional<QueueOperation> /* operation */)>& callback) = 0;
77
77
  virtual void onPlaylistChanged(const std::function<void(const std::string& /* playlistId */, const Playlist& /* playlist */, std::optional<QueueOperation> /* operation */)>& callback) = 0;
@@ -32,6 +32,7 @@ namespace margelo::nitro::nitroplayer {
32
32
  PAUSED SWIFT_NAME(paused) = 0,
33
33
  PLAYING SWIFT_NAME(playing) = 1,
34
34
  STOPPED SWIFT_NAME(stopped) = 2,
35
+ BUFFERING SWIFT_NAME(buffering) = 3,
35
36
  } CLOSED_ENUM;
36
37
 
37
38
  } // namespace margelo::nitro::nitroplayer
@@ -47,6 +48,7 @@ namespace margelo::nitro {
47
48
  case hashString("paused"): return margelo::nitro::nitroplayer::TrackPlayerState::PAUSED;
48
49
  case hashString("playing"): return margelo::nitro::nitroplayer::TrackPlayerState::PLAYING;
49
50
  case hashString("stopped"): return margelo::nitro::nitroplayer::TrackPlayerState::STOPPED;
51
+ case hashString("buffering"): return margelo::nitro::nitroplayer::TrackPlayerState::BUFFERING;
50
52
  default: [[unlikely]]
51
53
  throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum TrackPlayerState - invalid value!");
52
54
  }
@@ -56,6 +58,7 @@ namespace margelo::nitro {
56
58
  case margelo::nitro::nitroplayer::TrackPlayerState::PAUSED: return JSIConverter<std::string>::toJSI(runtime, "paused");
57
59
  case margelo::nitro::nitroplayer::TrackPlayerState::PLAYING: return JSIConverter<std::string>::toJSI(runtime, "playing");
58
60
  case margelo::nitro::nitroplayer::TrackPlayerState::STOPPED: return JSIConverter<std::string>::toJSI(runtime, "stopped");
61
+ case margelo::nitro::nitroplayer::TrackPlayerState::BUFFERING: return JSIConverter<std::string>::toJSI(runtime, "buffering");
59
62
  default: [[unlikely]]
60
63
  throw std::invalid_argument("Cannot convert TrackPlayerState to JS - invalid value: "
61
64
  + std::to_string(static_cast<int>(arg)) + "!");
@@ -70,6 +73,7 @@ namespace margelo::nitro {
70
73
  case hashString("paused"):
71
74
  case hashString("playing"):
72
75
  case hashString("stopped"):
76
+ case hashString("buffering"):
73
77
  return true;
74
78
  default:
75
79
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-player",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
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",
@@ -48,7 +48,7 @@ export interface PlayerQueue extends HybridObject<{
48
48
  ): Promise<void>
49
49
 
50
50
  // Playback control
51
- loadPlaylist(playlistId: string): Promise<void>
51
+ loadPlaylist(playlistId: string, index?: number): Promise<void>
52
52
  getCurrentPlaylistId(): string | null
53
53
 
54
54
  // Events
@@ -26,7 +26,7 @@ export interface Playlist {
26
26
 
27
27
  export type QueueOperation = 'add' | 'remove' | 'clear' | 'update'
28
28
 
29
- export type TrackPlayerState = 'playing' | 'paused' | 'stopped'
29
+ export type TrackPlayerState = 'playing' | 'paused' | 'stopped' | 'buffering'
30
30
 
31
31
  export type Reason = 'user_action' | 'skip' | 'end' | 'error' | 'repeat'
32
32