react-native-nitro-player 1.7.0-alpha.1 → 1.7.0-alpha.3
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/java/com/margelo/nitro/nitroplayer/core/TrackPlayerListener.kt +6 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerPlayback.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerQueueBuild.kt +14 -3
- package/ios/core/TrackPlayerCore.swift +1 -0
- package/ios/core/TrackPlayerListener.swift +18 -8
- package/ios/core/TrackPlayerPlayback.swift +13 -1
- package/ios/core/TrackPlayerQueueBuild.swift +40 -9
- package/package.json +1 -1
|
@@ -58,12 +58,16 @@ internal class TrackPlayerEventListener(
|
|
|
58
58
|
currentTemporaryType = determineCurrentTemporaryType()
|
|
59
59
|
|
|
60
60
|
// Update currentTrackIndex when landing on an original playlist track
|
|
61
|
+
val previousTrackIndex = currentTrackIndex
|
|
61
62
|
if (currentTemporaryType == TrackPlayerCore.TemporaryType.NONE && mediaItem != null) {
|
|
62
63
|
val trackId = extractTrackId(mediaItem.mediaId)
|
|
63
64
|
val newIdx = currentTracks.indexOfFirst { it.id == trackId }
|
|
64
65
|
if (newIdx >= 0 && newIdx != currentTrackIndex) currentTrackIndex = newIdx
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
val wrappedToStart = currentRepeatMode == RepeatMode.PLAYLIST &&
|
|
69
|
+
currentTrackIndex == 0 && previousTrackIndex == currentTracks.size - 1
|
|
70
|
+
|
|
67
71
|
// Top up the windowed timeline — the item we just left freed a slot.
|
|
68
72
|
// Only when it is actually short: rebuildQueueFromCurrentPosition can fall
|
|
69
73
|
// into the substitute-jump path (setMediaItems), which re-enters this very
|
|
@@ -77,7 +81,8 @@ internal class TrackPlayerEventListener(
|
|
|
77
81
|
val track = getCurrentTrack() ?: return
|
|
78
82
|
val r =
|
|
79
83
|
when (reason) {
|
|
80
|
-
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ->
|
|
84
|
+
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO ->
|
|
85
|
+
if (wrappedToStart) Reason.REPEAT else Reason.END
|
|
81
86
|
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> Reason.USER_ACTION
|
|
82
87
|
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> Reason.USER_ACTION
|
|
83
88
|
else -> null
|
|
@@ -74,6 +74,7 @@ internal fun TrackPlayerCore.skipToPreviousOnQueue() {
|
|
|
74
74
|
suspend fun TrackPlayerCore.setRepeatMode(mode: RepeatMode) = withPlayerContext { setRepeatModeOnQueue(mode) }
|
|
75
75
|
|
|
76
76
|
internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
|
|
77
|
+
val previousMode = currentRepeatMode
|
|
77
78
|
currentRepeatMode = mode
|
|
78
79
|
exo.setRepeatMode(
|
|
79
80
|
when (mode) {
|
|
@@ -81,6 +82,11 @@ internal fun TrackPlayerCore.setRepeatModeOnQueue(mode: RepeatMode) {
|
|
|
81
82
|
else -> Player.REPEAT_MODE_OFF
|
|
82
83
|
},
|
|
83
84
|
)
|
|
85
|
+
if (!isCastingField && previousMode != mode &&
|
|
86
|
+
(previousMode == RepeatMode.PLAYLIST || mode == RepeatMode.PLAYLIST)
|
|
87
|
+
) {
|
|
88
|
+
rebuildQueueFromCurrentPosition()
|
|
89
|
+
}
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
fun TrackPlayerCore.getRepeatMode(): RepeatMode = currentRepeatMode
|
|
@@ -7,6 +7,7 @@ import androidx.media3.common.MediaItem
|
|
|
7
7
|
import androidx.media3.common.MediaMetadata
|
|
8
8
|
import androidx.media3.common.Player
|
|
9
9
|
import com.margelo.nitro.nitroplayer.Reason
|
|
10
|
+
import com.margelo.nitro.nitroplayer.RepeatMode
|
|
10
11
|
import com.margelo.nitro.nitroplayer.TrackItem
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -121,6 +122,12 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
121
122
|
val currentId = exo.currentMediaItem?.mediaId?.let { extractTrackId(it) }
|
|
122
123
|
var newQueueTracks: List<TrackItem> = buildUpcomingQueueTracks(currentId)
|
|
123
124
|
|
|
125
|
+
if (!isCastingField && currentRepeatMode == RepeatMode.PLAYLIST &&
|
|
126
|
+
currentTrackIndex > 0 && currentTrackIndex <= currentTracks.size
|
|
127
|
+
) {
|
|
128
|
+
newQueueTracks = newQueueTracks + currentTracks.subList(0, currentTrackIndex)
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
if (isCastingField) {
|
|
125
132
|
newQueueTracks = castableUpcoming(newQueueTracks)
|
|
126
133
|
|
|
@@ -151,9 +158,13 @@ internal fun TrackPlayerCore.rebuildQueueFromCurrentPosition() {
|
|
|
151
158
|
// ExoPlayer has already prepared, so gapless transitions survive.
|
|
152
159
|
val existingCount = exo.mediaItemCount - currentIndex - 1
|
|
153
160
|
var commonPrefix = 0
|
|
154
|
-
while (commonPrefix < existingCount && commonPrefix < newQueueTracks.size
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
while (commonPrefix < existingCount && commonPrefix < newQueueTracks.size) {
|
|
162
|
+
val existing = exo.getMediaItemAt(currentIndex + 1 + commonPrefix)
|
|
163
|
+
val track = newQueueTracks[commonPrefix]
|
|
164
|
+
if (extractTrackId(existing.mediaId) != track.id) break
|
|
165
|
+
// URI must match too — items materialized before lazy URL resolution are unplayable
|
|
166
|
+
val desiredUrl = if (isCastingField) track.url else downloadManager.getEffectiveUrl(track)
|
|
167
|
+
if (existing.localConfiguration?.uri?.toString().orEmpty() != desiredUrl) break
|
|
157
168
|
commonPrefix++
|
|
158
169
|
}
|
|
159
170
|
|
|
@@ -91,6 +91,7 @@ class TrackPlayerCore: NSObject {
|
|
|
91
91
|
// track, not a real track change — so it must not emit onChangeTrack or reset
|
|
92
92
|
// per-track state. Consumed (cleared) by the next `currentItemDidChange`.
|
|
93
93
|
internal var suppressTrackChangeEmit = false
|
|
94
|
+
internal var lastCurrentItemTrackId: String?
|
|
94
95
|
// Last observed playback position, used to resume after recreating a failed item.
|
|
95
96
|
internal var lastKnownPosition: Double = 0
|
|
96
97
|
// Per-track retry budget for recreating AVPlayerItems that hit status == .failed.
|
|
@@ -173,10 +173,12 @@ extension TrackPlayerCore {
|
|
|
173
173
|
guard finishedItem.trackId != nil else { return }
|
|
174
174
|
|
|
175
175
|
// 1. TRACK repeat — handle FIRST, before any temp-track removal
|
|
176
|
-
if currentRepeatMode == .track
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
if currentRepeatMode == .track {
|
|
177
|
+
if player?.currentItem == nil || player?.currentItem === finishedItem {
|
|
178
|
+
NitroPlayerLogger.log("TrackPlayerCore", "🔁 TRACK repeat — no queued copy, seeking to zero")
|
|
179
|
+
player?.seek(to: .zero)
|
|
180
|
+
player?.rate = Float(currentPlaybackSpeed)
|
|
181
|
+
}
|
|
180
182
|
return // do not remove temp tracks, do not notify track change (same track looping)
|
|
181
183
|
}
|
|
182
184
|
|
|
@@ -293,6 +295,7 @@ extension TrackPlayerCore {
|
|
|
293
295
|
guard let player, let currentItem = player.currentItem else {
|
|
294
296
|
NitroPlayerLogger.log("TrackPlayerCore", "⚠️ Current item changed to nil")
|
|
295
297
|
detachMetadataOutput()
|
|
298
|
+
lastCurrentItemTrackId = nil
|
|
296
299
|
// Queue exhausted — handle PLAYLIST repeat
|
|
297
300
|
if currentRepeatMode == .playlist && !currentTracks.isEmpty, let player = self.player {
|
|
298
301
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 PLAYLIST repeat — rebuilding original queue and restarting")
|
|
@@ -318,6 +321,11 @@ extension TrackPlayerCore {
|
|
|
318
321
|
return
|
|
319
322
|
}
|
|
320
323
|
|
|
324
|
+
let isRepeatLoop =
|
|
325
|
+
currentRepeatMode == .track && currentItem.trackId != nil
|
|
326
|
+
&& currentItem.trackId == lastCurrentItemTrackId
|
|
327
|
+
lastCurrentItemTrackId = currentItem.trackId
|
|
328
|
+
|
|
321
329
|
#if DEBUG
|
|
322
330
|
NitroPlayerLogger.log("TrackPlayerCore", "\n" + String(repeating: "▶", count: Constants.separatorLineLength))
|
|
323
331
|
NitroPlayerLogger.log("TrackPlayerCore", "🔄 CURRENT ITEM CHANGED")
|
|
@@ -367,9 +375,9 @@ extension TrackPlayerCore {
|
|
|
367
375
|
if currentTemporaryType == .playNext { tempTrack = playNextStack.first(where: { $0.id == trackId }) }
|
|
368
376
|
else if currentTemporaryType == .upNext { tempTrack = upNextQueue.first(where: { $0.id == trackId }) }
|
|
369
377
|
if let track = tempTrack {
|
|
370
|
-
if isRecoveryReplace {
|
|
378
|
+
if isRecoveryReplace || isRepeatLoop {
|
|
371
379
|
NitroPlayerLogger.log("TrackPlayerCore",
|
|
372
|
-
" ♻️
|
|
380
|
+
" ♻️ Same-track transition for '\(track.title)' — suppressing onChangeTrack")
|
|
373
381
|
} else {
|
|
374
382
|
NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Temporary track: \(track.title) - \(track.artist)")
|
|
375
383
|
NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack for temporary track")
|
|
@@ -387,7 +395,9 @@ extension TrackPlayerCore {
|
|
|
387
395
|
NitroPlayerLogger.log("TrackPlayerCore", " 🎵 Track: \(track.title) - \(track.artist)")
|
|
388
396
|
if oldIndex != index {
|
|
389
397
|
NitroPlayerLogger.log("TrackPlayerCore", " 📢 Emitting onChangeTrack (index changed from \(oldIndex) to \(index))")
|
|
390
|
-
|
|
398
|
+
let wrappedToStart =
|
|
399
|
+
currentRepeatMode == .playlist && index == 0 && oldIndex == currentTracks.count - 1
|
|
400
|
+
notifyTrackChange(track, wrappedToStart ? .repeat : .skip)
|
|
391
401
|
} else {
|
|
392
402
|
NitroPlayerLogger.log("TrackPlayerCore", " ⏭️ Skipping onChangeTrack emission (index unchanged)")
|
|
393
403
|
}
|
|
@@ -413,7 +423,7 @@ extension TrackPlayerCore {
|
|
|
413
423
|
// rebuildQueueFromPlaylistIndex (current track no longer in the playlist), which
|
|
414
424
|
// replaces the current item and re-enters this callback. The length check makes any
|
|
415
425
|
// such re-entry a no-op and terminate.
|
|
416
|
-
if player.items().count - 1 < Constants.queueWindowSize {
|
|
426
|
+
if currentRepeatMode == .track || player.items().count - 1 < Constants.queueWindowSize {
|
|
417
427
|
rebuildAVQueueFromCurrentPosition()
|
|
418
428
|
}
|
|
419
429
|
|
|
@@ -72,9 +72,13 @@ extension TrackPlayerCore {
|
|
|
72
72
|
func skipToPrevious() async { await withPlayerQueueNoThrow { self.skipToPreviousOnQueue() } }
|
|
73
73
|
|
|
74
74
|
func setRepeatModeOnQueue(mode: RepeatMode) {
|
|
75
|
+
let previousMode = currentRepeatMode
|
|
75
76
|
currentRepeatMode = mode
|
|
76
|
-
player?.actionAtItemEnd =
|
|
77
|
+
player?.actionAtItemEnd = .advance
|
|
77
78
|
if isCasting { castManager?.setQueueRepeatMode(mode) }
|
|
79
|
+
if !isCasting, previousMode != mode, player?.currentItem != nil {
|
|
80
|
+
rebuildAVQueueFromCurrentPosition()
|
|
81
|
+
}
|
|
78
82
|
NitroPlayerLogger.log("TrackPlayerCore", "🔁 setRepeatMode: \(mode)")
|
|
79
83
|
}
|
|
80
84
|
func setRepeatMode(mode: RepeatMode) async {
|
|
@@ -254,6 +258,14 @@ extension TrackPlayerCore {
|
|
|
254
258
|
return
|
|
255
259
|
}
|
|
256
260
|
|
|
261
|
+
if currentRepeatMode == .track {
|
|
262
|
+
let items = queuePlayer.items()
|
|
263
|
+
if items.count > 1, let copyId = items[1].trackId, copyId == items[0].trackId {
|
|
264
|
+
queuePlayer.remove(items[1])
|
|
265
|
+
}
|
|
266
|
+
rebuildAVQueueFromCurrentPosition(skipRepeatCopy: true)
|
|
267
|
+
}
|
|
268
|
+
|
|
257
269
|
// Remove current temp track from its list before advancing
|
|
258
270
|
if let trackId = queuePlayer.currentItem?.trackId {
|
|
259
271
|
if currentTemporaryType == .playNext {
|
|
@@ -272,7 +272,9 @@ extension TrackPlayerCore {
|
|
|
272
272
|
///
|
|
273
273
|
/// - Parameter changedTrackIds: When non-nil, performs a surgical update:
|
|
274
274
|
/// only AVPlayerItems whose track ID is in this set are removed and re-created.
|
|
275
|
-
func rebuildAVQueueFromCurrentPosition(
|
|
275
|
+
func rebuildAVQueueFromCurrentPosition(
|
|
276
|
+
changedTrackIds: Set<String>? = nil, skipRepeatCopy: Bool = false
|
|
277
|
+
) {
|
|
276
278
|
// While casting, queue changes are applied to the receiver — the local AVQueuePlayer is dormant.
|
|
277
279
|
if isCasting {
|
|
278
280
|
syncCastQueueAfterCurrent()
|
|
@@ -341,6 +343,18 @@ extension TrackPlayerCore {
|
|
|
341
343
|
newQueueTracks.append(contentsOf: currentTracks[(currentTrackIndex + 1)...])
|
|
342
344
|
}
|
|
343
345
|
|
|
346
|
+
if currentRepeatMode == .playlist, currentTrackIndex > 0, currentTrackIndex <= currentTracks.count {
|
|
347
|
+
newQueueTracks.append(contentsOf: currentTracks[0..<currentTrackIndex])
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if currentRepeatMode == .track, !skipRepeatCopy {
|
|
351
|
+
let repeated =
|
|
352
|
+
playNextStack.first(where: { $0.id == playingTrackId })
|
|
353
|
+
?? upNextQueue.first(where: { $0.id == playingTrackId })
|
|
354
|
+
?? currentTracks.first(where: { $0.id == playingTrackId })
|
|
355
|
+
if let repeated = repeated { newQueueTracks = [repeated] }
|
|
356
|
+
}
|
|
357
|
+
|
|
344
358
|
// Window the materialized queue — currentItemDidChange tops it up on every transition.
|
|
345
359
|
if newQueueTracks.count > Constants.queueWindowSize {
|
|
346
360
|
newQueueTracks.removeSubrange(Constants.queueWindowSize...)
|
|
@@ -530,7 +544,10 @@ extension TrackPlayerCore {
|
|
|
530
544
|
}
|
|
531
545
|
|
|
532
546
|
let asset: AVURLAsset
|
|
533
|
-
|
|
547
|
+
let preloaded = preloadedAssets[track.id]
|
|
548
|
+
let preloadedInUse =
|
|
549
|
+
preloaded != nil && (player?.items().contains { ($0.asset as? AVURLAsset) === preloaded } ?? false)
|
|
550
|
+
if let preloadedAsset = preloaded, !preloadedInUse {
|
|
534
551
|
asset = preloadedAsset
|
|
535
552
|
NitroPlayerLogger.log("TrackPlayerCore", "🚀 Using preloaded asset for \(track.title)")
|
|
536
553
|
} else {
|
|
@@ -571,9 +588,13 @@ extension TrackPlayerCore {
|
|
|
571
588
|
let queuedTrackIds = Set(player?.items().compactMap { $0.trackId } ?? [])
|
|
572
589
|
let alreadyPreloaded = Set(preloadedAssets.keys)
|
|
573
590
|
let generation = preloadGeneration
|
|
574
|
-
let
|
|
575
|
-
|
|
576
|
-
|
|
591
|
+
let wraps = currentRepeatMode == .playlist && !currentTracks.isEmpty
|
|
592
|
+
let available =
|
|
593
|
+
wraps
|
|
594
|
+
? min(Constants.gaplessPreloadCount, currentTracks.count)
|
|
595
|
+
: max(0, min(startIndex + Constants.gaplessPreloadCount, currentTracks.count) - startIndex)
|
|
596
|
+
guard startIndex >= 0, available > 0 else { return }
|
|
597
|
+
let candidates = (0..<available).map { currentTracks[(startIndex + $0) % currentTracks.count] }
|
|
577
598
|
|
|
578
599
|
preloadQueue.async { [weak self] in
|
|
579
600
|
guard let self else { return }
|
|
@@ -631,10 +652,20 @@ extension TrackPlayerCore {
|
|
|
631
652
|
/// Clears preloaded assets that are no longer needed
|
|
632
653
|
func cleanupPreloadedAssets(keepingFrom currentIndex: Int) {
|
|
633
654
|
// Already on playerQueue — access preloadedAssets directly
|
|
634
|
-
let
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
655
|
+
let keepIds: Set<String>
|
|
656
|
+
if currentRepeatMode == .playlist, !self.currentTracks.isEmpty {
|
|
657
|
+
let start = max(0, currentIndex)
|
|
658
|
+
let keepCount = min(Constants.gaplessPreloadCount + 1, self.currentTracks.count)
|
|
659
|
+
keepIds = Set(
|
|
660
|
+
(0..<keepCount).compactMap {
|
|
661
|
+
self.currentTracks[safe: (start + $0) % self.currentTracks.count]?.id
|
|
662
|
+
})
|
|
663
|
+
} else {
|
|
664
|
+
let keepRange =
|
|
665
|
+
currentIndex..<min(
|
|
666
|
+
currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
|
|
667
|
+
keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
|
|
668
|
+
}
|
|
638
669
|
|
|
639
670
|
let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
|
|
640
671
|
for id in assetsToRemove {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-player",
|
|
3
|
-
"version": "1.7.0-alpha.
|
|
3
|
+
"version": "1.7.0-alpha.3",
|
|
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",
|