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.
- 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/TrackPlayerPlayback.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueue.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerTempQueue.kt +16 -1
- 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/TrackPlayerListener.swift +13 -6
- package/ios/core/TrackPlayerQueue.swift +3 -2
- package/ios/core/TrackPlayerQueueBuild.swift +4 -4
- package/ios/core/TrackPlayerTempQueue.swift +15 -1
- package/ios/playlist/PlaylistManager.swift +7 -3
- package/ios/queue/HybridPlayerQueue.swift +5 -3
- package/lib/specs/TrackPlayer.nitro.d.ts +1 -1
- package/lib/types/PlayerQueue.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/c++/JTrackPlayerState.hpp +3 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridPlayerQueueSpec.kt +1 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/TrackPlayerState.kt +2 -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/ios/swift/TrackPlayerState.swift +4 -0
- package/nitrogen/generated/shared/c++/HybridPlayerQueueSpec.hpp +1 -1
- package/nitrogen/generated/shared/c++/TrackPlayerState.hpp +4 -0
- package/package.json +1 -1
- package/src/specs/TrackPlayer.nitro.ts +1 -1
- 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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
}
|
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
|
}
|
|
@@ -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.
|
|
394
|
-
|
|
395
|
-
else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
|
|
396
|
-
|
|
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.
|
|
51
|
-
else if player.timeControlStatus == .
|
|
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
|
|
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
|
|
@@ -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
|
|
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;
|
|
@@ -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 + "!");
|
|
@@ -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
|
+
"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
|
package/src/types/PlayerQueue.ts
CHANGED
|
@@ -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
|
|