react-native-nitro-player 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridPlayerQueue.kt +5 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +2 -3
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +21 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt +178 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/playlist/PlaylistManager.kt +3 -2
- package/ios/core/TrackPlayerQueueBuild.swift +10 -4
- package/ios/core/TrackPlayerTempQueue.swift +21 -2
- package/ios/playlist/PlaylistManager.swift +7 -3
- package/ios/queue/HybridPlayerQueue.swift +5 -3
- package/lib/specs/TrackPlayer.nitro.d.ts +1 -1
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.cpp +3 -3
- package/nitrogen/generated/android/c++/JHybridPlayerQueueSpec.hpp +1 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +1 -1
- package/nitrogen/generated/ios/c++/HybridPlayerQueueSpecSwift.hpp +2 -2
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec.swift +1 -1
- package/nitrogen/generated/ios/swift/HybridPlayerQueueSpec_cxx.swift +9 -2
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +1 -1
- package/package.json +1 -1
- package/src/specs/TrackPlayer.nitro.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
|
-
|
|
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 {
|
|
@@ -30,8 +30,7 @@ internal fun TrackPlayerCore.rebuildQueueAndPlayFromIndex(index: Int) {
|
|
|
30
30
|
|
|
31
31
|
currentTrackIndex = index
|
|
32
32
|
exo.clearMediaItems()
|
|
33
|
-
exo.setMediaItems(mediaItems)
|
|
34
|
-
exo.seekToDefaultPosition(0)
|
|
33
|
+
exo.setMediaItems(mediaItems, true)
|
|
35
34
|
exo.prepare()
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -123,7 +122,7 @@ internal fun TrackPlayerCore.updatePlayerQueue(tracks: List<TrackItem>) {
|
|
|
123
122
|
val mediaId = if (playlistId.isNotEmpty()) "$playlistId:${track.id}" else track.id
|
|
124
123
|
makeMediaItem(track, mediaId)
|
|
125
124
|
}
|
|
126
|
-
exo.setMediaItems(mediaItems,
|
|
125
|
+
exo.setMediaItems(mediaItems, true)
|
|
127
126
|
if (exo.playbackState == Player.STATE_IDLE && mediaItems.isNotEmpty()) {
|
|
128
127
|
exo.prepare()
|
|
129
128
|
}
|
|
@@ -11,14 +11,33 @@ import com.margelo.nitro.nitroplayer.TrackItem
|
|
|
11
11
|
|
|
12
12
|
// ── Playlist loading ──────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
suspend fun TrackPlayerCore.loadPlaylist(
|
|
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
|
+
if (targetIndex == 0) {
|
|
34
|
+
updatePlayerQueue(playlist.tracks)
|
|
35
|
+
} else {
|
|
36
|
+
// Bypass updatePlayerQueue to avoid emitting a spurious onTrackChange for index 0.
|
|
37
|
+
// Set currentTracks directly so rebuildQueueAndPlayFromIndex can use them.
|
|
38
|
+
currentTracks = playlist.tracks
|
|
39
|
+
rebuildQueueAndPlayFromIndex(targetIndex)
|
|
40
|
+
}
|
|
22
41
|
checkUpcomingTracksForUrls(lookaheadCount)
|
|
23
42
|
notifyTemporaryQueueChange()
|
|
24
43
|
}
|
package/android/src/main/java/com/margelo/nitro/nitroplayer/media/MediaSessionCallbackFactory.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
59
|
-
//
|
|
60
|
-
let isLazyLoad = tracks.
|
|
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
|
|
@@ -156,6 +156,12 @@ extension TrackPlayerCore {
|
|
|
156
156
|
&& !DownloadManagerCore.shared.isTrackDownloaded(trackId: targetTrack.id)
|
|
157
157
|
if isLazyLoad {
|
|
158
158
|
NitroPlayerLogger.log("TrackPlayerCore", " ⏳ Lazy-load — deferring AVQueuePlayer setup; emitting track change for index \(index)")
|
|
159
|
+
// Clear old items immediately so the previous track stops while waiting for the URL to resolve
|
|
160
|
+
if let boundaryObserver = self.boundaryTimeObserver {
|
|
161
|
+
player?.removeTimeObserver(boundaryObserver)
|
|
162
|
+
self.boundaryTimeObserver = nil
|
|
163
|
+
}
|
|
164
|
+
player?.removeAllItems()
|
|
159
165
|
self.currentTracks = fullPlaylist
|
|
160
166
|
if let track = self.currentTracks[safe: index] {
|
|
161
167
|
notifyTrackChange(track, .skip)
|
|
@@ -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,27 @@ 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
|
+
if targetIndex == 0 {
|
|
48
|
+
self.updatePlayerQueue(tracks: playlist.tracks)
|
|
49
|
+
} else {
|
|
50
|
+
// Bypass updatePlayerQueue to avoid emitting a spurious onTrackChange for index 0.
|
|
51
|
+
// Set currentTracks directly so rebuildQueueFromPlaylistIndex can use them.
|
|
52
|
+
self.currentTracks = playlist.tracks
|
|
53
|
+
self.preloadedAssets.removeAll()
|
|
54
|
+
_ = self.rebuildQueueFromPlaylistIndex(index: targetIndex)
|
|
55
|
+
}
|
|
37
56
|
self.emitStateChange()
|
|
38
57
|
self.checkUpcomingTracksForUrls(lookahead: self.lookaheadCount)
|
|
39
58
|
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
|
|
266
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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;
|
|
@@ -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;
|
|
@@ -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)
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-player",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
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
|