react-native-nitro-player 0.0.1 โ 0.3.0-alpha.10
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/README.md +282 -2
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +37 -29
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +24 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +408 -16
- package/ios/HybridAudioRoutePicker.swift +47 -46
- package/ios/HybridTrackPlayer.swift +22 -0
- package/ios/core/TrackPlayerCore.swift +538 -48
- package/lib/hooks/callbackManager.d.ts +28 -0
- package/lib/hooks/callbackManager.js +76 -0
- package/lib/hooks/index.d.ts +7 -0
- package/lib/hooks/index.js +3 -0
- package/lib/hooks/useActualQueue.d.ts +48 -0
- package/lib/hooks/useActualQueue.js +98 -0
- package/lib/hooks/useNowPlaying.d.ts +36 -0
- package/lib/hooks/useNowPlaying.js +87 -0
- package/lib/hooks/useOnChangeTrack.d.ts +33 -6
- package/lib/hooks/useOnChangeTrack.js +65 -9
- package/lib/hooks/useOnPlaybackStateChange.d.ts +32 -6
- package/lib/hooks/useOnPlaybackStateChange.js +65 -9
- package/lib/hooks/usePlaylist.d.ts +48 -0
- package/lib/hooks/usePlaylist.js +136 -0
- package/lib/index.d.ts +1 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +6 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +46 -9
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +5 -0
- package/nitrogen/generated/android/c++/JRepeatMode.hpp +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +20 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/RepeatMode.kt +22 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +9 -0
- package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +44 -4
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +5 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +64 -0
- package/nitrogen/generated/ios/swift/RepeatMode.swift +44 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +5 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +12 -3
- package/nitrogen/generated/shared/c++/RepeatMode.hpp +80 -0
- package/package.json +13 -12
- package/src/hooks/callbackManager.ts +96 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useActualQueue.ts +116 -0
- package/src/hooks/useNowPlaying.ts +97 -0
- package/src/hooks/useOnChangeTrack.ts +77 -13
- package/src/hooks/useOnPlaybackStateChange.ts +83 -13
- package/src/hooks/usePlaylist.ts +161 -0
- package/src/index.ts +1 -1
- package/src/specs/TrackPlayer.nitro.ts +7 -0
|
@@ -31,6 +31,13 @@ class TrackPlayerCore: NSObject {
|
|
|
31
31
|
// UI/Display constants
|
|
32
32
|
static let separatorLineLength: Int = 80
|
|
33
33
|
static let playlistSeparatorLength: Int = 40
|
|
34
|
+
|
|
35
|
+
// Gapless playback configuration
|
|
36
|
+
static let preferredForwardBufferDuration: Double = 30.0 // Buffer 30 seconds ahead
|
|
37
|
+
static let preloadAssetKeys: [String] = [
|
|
38
|
+
"playable", "duration", "tracks", "preferredTransform",
|
|
39
|
+
]
|
|
40
|
+
static let gaplessPreloadCount: Int = 3 // Number of tracks to preload ahead
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
// MARK: - Properties
|
|
@@ -42,9 +49,26 @@ class TrackPlayerCore: NSObject {
|
|
|
42
49
|
private var currentTrackIndex: Int = -1
|
|
43
50
|
private var currentTracks: [TrackItem] = []
|
|
44
51
|
private var isManuallySeeked = false
|
|
52
|
+
private var repeatMode: RepeatMode = .off
|
|
45
53
|
private var boundaryTimeObserver: Any?
|
|
46
54
|
private var currentItemObservers: [NSKeyValueObservation] = []
|
|
47
55
|
|
|
56
|
+
// Gapless playback: Cache for preloaded assets
|
|
57
|
+
private var preloadedAssets: [String: AVURLAsset] = [:]
|
|
58
|
+
private let preloadQueue = DispatchQueue(label: "com.nitroplayer.preload", qos: .utility)
|
|
59
|
+
|
|
60
|
+
// Temporary tracks for addToUpNext and playNext
|
|
61
|
+
private var playNextStack: [TrackItem] = [] // LIFO - last added plays first
|
|
62
|
+
private var upNextQueue: [TrackItem] = [] // FIFO - first added plays first
|
|
63
|
+
private var currentTemporaryType: TemporaryType = .none
|
|
64
|
+
|
|
65
|
+
// Enum to track what type of track is currently playing
|
|
66
|
+
private enum TemporaryType {
|
|
67
|
+
case none // Playing from original playlist
|
|
68
|
+
case playNext // Currently in playNextStack
|
|
69
|
+
case upNext // Currently in upNextQueue
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
var onChangeTrack: ((TrackItem, Reason?) -> Void)?
|
|
49
73
|
var onPlaybackStateChange: ((TrackPlayerState, Reason?) -> Void)?
|
|
50
74
|
var onSeek: ((Double, Double) -> Void)?
|
|
@@ -76,6 +100,24 @@ class TrackPlayerCore: NSObject {
|
|
|
76
100
|
|
|
77
101
|
private func setupPlayer() {
|
|
78
102
|
player = AVQueuePlayer()
|
|
103
|
+
|
|
104
|
+
// MARK: - Gapless Playback Configuration
|
|
105
|
+
|
|
106
|
+
// Disable automatic waiting to minimize stalling - this allows smoother transitions
|
|
107
|
+
// between tracks as AVPlayer won't pause to buffer excessively
|
|
108
|
+
player?.automaticallyWaitsToMinimizeStalling = false
|
|
109
|
+
|
|
110
|
+
// Set playback rate to 1.0 immediately when ready (reduces gap between tracks)
|
|
111
|
+
player?.actionAtItemEnd = .advance
|
|
112
|
+
|
|
113
|
+
// Configure for high-quality audio playback with minimal latency
|
|
114
|
+
if #available(iOS 15.0, *) {
|
|
115
|
+
player?.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
print(
|
|
119
|
+
"๐ต TrackPlayerCore: Gapless playback configured - automaticallyWaitsToMinimizeStalling=false")
|
|
120
|
+
|
|
79
121
|
setupPlayerObservers()
|
|
80
122
|
}
|
|
81
123
|
|
|
@@ -221,10 +263,22 @@ class TrackPlayerCore: NSObject {
|
|
|
221
263
|
return
|
|
222
264
|
}
|
|
223
265
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
266
|
+
// Determine what type of track just finished and remove it from temporary lists
|
|
267
|
+
if let trackId = finishedItem.trackId {
|
|
268
|
+
// Check if it was a playNext track
|
|
269
|
+
if let index = playNextStack.firstIndex(where: { $0.id == trackId }) {
|
|
270
|
+
let track = playNextStack.remove(at: index)
|
|
271
|
+
print("๐ Finished playNext track: \(track.title) - removed from stack")
|
|
272
|
+
}
|
|
273
|
+
// Check if it was an upNext track
|
|
274
|
+
else if let index = upNextQueue.firstIndex(where: { $0.id == trackId }) {
|
|
275
|
+
let track = upNextQueue.remove(at: index)
|
|
276
|
+
print("๐ Finished upNext track: \(track.title) - removed from queue")
|
|
277
|
+
}
|
|
278
|
+
// Otherwise it was from original playlist
|
|
279
|
+
else if let track = currentTracks.first(where: { $0.id == trackId }) {
|
|
280
|
+
print("๐ Finished original track: \(track.title)")
|
|
281
|
+
}
|
|
228
282
|
}
|
|
229
283
|
|
|
230
284
|
// Check remaining queue
|
|
@@ -232,6 +286,50 @@ class TrackPlayerCore: NSObject {
|
|
|
232
286
|
print("๐ Remaining items in queue: \(player.items().count)")
|
|
233
287
|
}
|
|
234
288
|
|
|
289
|
+
// Handle repeat modes
|
|
290
|
+
switch repeatMode {
|
|
291
|
+
case .track:
|
|
292
|
+
// Repeat current track - seek to beginning and play
|
|
293
|
+
print("๐ TrackPlayerCore: Repeat mode is TRACK - replaying current track")
|
|
294
|
+
DispatchQueue.main.async { [weak self] in
|
|
295
|
+
guard let self = self, let player = self.player else { return }
|
|
296
|
+
// For temporary tracks, just seek to beginning
|
|
297
|
+
if self.currentTemporaryType != .none {
|
|
298
|
+
player.seek(to: .zero)
|
|
299
|
+
player.play()
|
|
300
|
+
} else {
|
|
301
|
+
// For original tracks, recreate via playFromIndex
|
|
302
|
+
self.playFromIndex(index: self.currentTrackIndex)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
case .playlist:
|
|
308
|
+
// Check if we're at the end of the ORIGINAL playlist (ignore temps)
|
|
309
|
+
if currentTemporaryType == .none && currentTrackIndex >= currentTracks.count - 1 {
|
|
310
|
+
// Check if there are still temporary tracks
|
|
311
|
+
if !playNextStack.isEmpty || !upNextQueue.isEmpty {
|
|
312
|
+
print("๐ TrackPlayerCore: Temporary tracks remaining, continuing...")
|
|
313
|
+
} else {
|
|
314
|
+
print("๐ TrackPlayerCore: Repeat mode is PLAYLIST - restarting from beginning")
|
|
315
|
+
// Clear temps and restart
|
|
316
|
+
playNextStack.removeAll()
|
|
317
|
+
upNextQueue.removeAll()
|
|
318
|
+
DispatchQueue.main.async { [weak self] in
|
|
319
|
+
guard let self = self else { return }
|
|
320
|
+
self.playFromIndex(index: 0)
|
|
321
|
+
}
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
print("๐ TrackPlayerCore: Repeat mode is PLAYLIST - continuing to next track")
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case .off:
|
|
329
|
+
// Default behavior - stop at end of playlist
|
|
330
|
+
print("๐ TrackPlayerCore: Repeat mode is OFF")
|
|
331
|
+
}
|
|
332
|
+
|
|
235
333
|
// Track ended naturally
|
|
236
334
|
onChangeTrack?(
|
|
237
335
|
getCurrentTrack()
|
|
@@ -375,12 +473,34 @@ class TrackPlayerCore: NSObject {
|
|
|
375
473
|
// Setup KVO observers for current item
|
|
376
474
|
setupCurrentItemObservers(item: currentItem)
|
|
377
475
|
|
|
378
|
-
// Update track index
|
|
476
|
+
// Update track index and determine temporary type
|
|
379
477
|
if let trackId = currentItem.trackId {
|
|
380
478
|
print("๐ TrackPlayerCore: Looking up trackId '\(trackId)' in currentTracks...")
|
|
381
479
|
print(" Current index BEFORE lookup: \(currentTrackIndex)")
|
|
382
480
|
|
|
383
|
-
|
|
481
|
+
// Update temporary type
|
|
482
|
+
currentTemporaryType = determineCurrentTemporaryType()
|
|
483
|
+
print(" ๐ฏ Track type: \(currentTemporaryType)")
|
|
484
|
+
|
|
485
|
+
// If it's a temporary track, don't update currentTrackIndex
|
|
486
|
+
if currentTemporaryType != .none {
|
|
487
|
+
// Find and emit the temporary track
|
|
488
|
+
var tempTrack: TrackItem? = nil
|
|
489
|
+
if currentTemporaryType == .playNext {
|
|
490
|
+
tempTrack = playNextStack.first(where: { $0.id == trackId })
|
|
491
|
+
} else if currentTemporaryType == .upNext {
|
|
492
|
+
tempTrack = upNextQueue.first(where: { $0.id == trackId })
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if let track = tempTrack {
|
|
496
|
+
print(" ๐ต Temporary track: \(track.title) - \(track.artist)")
|
|
497
|
+
print(" ๐ข Emitting onChangeTrack for temporary track")
|
|
498
|
+
onChangeTrack?(track, .skip)
|
|
499
|
+
mediaSessionManager?.onTrackChanged()
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// It's an original playlist track
|
|
503
|
+
else if let index = currentTracks.firstIndex(where: { $0.id == trackId }) {
|
|
384
504
|
print(" โ
Found track at index: \(index)")
|
|
385
505
|
print(" Setting currentTrackIndex from \(currentTrackIndex) to \(index)")
|
|
386
506
|
|
|
@@ -413,6 +533,11 @@ class TrackPlayerCore: NSObject {
|
|
|
413
533
|
if currentItem.status == .readyToPlay {
|
|
414
534
|
setupBoundaryTimeObserver()
|
|
415
535
|
}
|
|
536
|
+
|
|
537
|
+
// MARK: - Gapless Playback: Preload upcoming tracks when track changes
|
|
538
|
+
// This ensures the next tracks are ready for seamless transitions
|
|
539
|
+
preloadUpcomingTracks(from: currentTrackIndex + 1)
|
|
540
|
+
cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
|
|
416
541
|
}
|
|
417
542
|
|
|
418
543
|
private func setupCurrentItemObservers(item: AVPlayerItem) {
|
|
@@ -457,6 +582,12 @@ class TrackPlayerCore: NSObject {
|
|
|
457
582
|
print("๐ TrackPlayerCore: LOAD PLAYLIST REQUEST")
|
|
458
583
|
print(" Playlist ID: \(playlistId)")
|
|
459
584
|
|
|
585
|
+
// Clear temporary tracks when loading new playlist
|
|
586
|
+
self.playNextStack.removeAll()
|
|
587
|
+
self.upNextQueue.removeAll()
|
|
588
|
+
self.currentTemporaryType = .none
|
|
589
|
+
print(" ๐งน Cleared temporary tracks")
|
|
590
|
+
|
|
460
591
|
let playlist = self.playlistManager.getPlaylist(playlistId: playlistId)
|
|
461
592
|
if let playlist = playlist {
|
|
462
593
|
print(" โ
Found playlist: \(playlist.name)")
|
|
@@ -521,6 +652,135 @@ class TrackPlayerCore: NSObject {
|
|
|
521
652
|
mediaSessionManager?.onPlaybackStateChanged()
|
|
522
653
|
}
|
|
523
654
|
|
|
655
|
+
// MARK: - Gapless Playback Helpers
|
|
656
|
+
|
|
657
|
+
/// Creates a gapless-optimized AVPlayerItem with proper buffering configuration
|
|
658
|
+
private func createGaplessPlayerItem(for track: TrackItem, isPreload: Bool = false)
|
|
659
|
+
-> AVPlayerItem?
|
|
660
|
+
{
|
|
661
|
+
guard let url = URL(string: track.url) else {
|
|
662
|
+
print("โ TrackPlayerCore: Invalid URL for track: \(track.title) - \(track.url)")
|
|
663
|
+
return nil
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Check if we have a preloaded asset for this track
|
|
667
|
+
let asset: AVURLAsset
|
|
668
|
+
if let preloadedAsset = preloadedAssets[track.id] {
|
|
669
|
+
asset = preloadedAsset
|
|
670
|
+
print("๐ TrackPlayerCore: Using preloaded asset for \(track.title)")
|
|
671
|
+
} else {
|
|
672
|
+
// Create asset with options optimized for gapless playback
|
|
673
|
+
asset = AVURLAsset(
|
|
674
|
+
url: url,
|
|
675
|
+
options: [
|
|
676
|
+
AVURLAssetPreferPreciseDurationAndTimingKey: true // Ensures accurate duration for gapless transitions
|
|
677
|
+
])
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
let item = AVPlayerItem(asset: asset)
|
|
681
|
+
|
|
682
|
+
// Configure buffer duration for gapless playback
|
|
683
|
+
// This tells AVPlayer how much content to buffer ahead
|
|
684
|
+
item.preferredForwardBufferDuration = Constants.preferredForwardBufferDuration
|
|
685
|
+
|
|
686
|
+
// Enable automatic loading of item properties for faster starts
|
|
687
|
+
item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
|
|
688
|
+
|
|
689
|
+
// Store track ID for later reference
|
|
690
|
+
item.trackId = track.id
|
|
691
|
+
|
|
692
|
+
// If this is a preload request, start loading asset keys asynchronously
|
|
693
|
+
if isPreload {
|
|
694
|
+
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
|
|
695
|
+
// Asset keys are now loaded, which speeds up playback start
|
|
696
|
+
var allKeysLoaded = true
|
|
697
|
+
for key in Constants.preloadAssetKeys {
|
|
698
|
+
var error: NSError?
|
|
699
|
+
let status = asset.statusOfValue(forKey: key, error: &error)
|
|
700
|
+
if status == .failed {
|
|
701
|
+
print(
|
|
702
|
+
"โ ๏ธ TrackPlayerCore: Failed to load key '\(key)' for \(track.title): \(error?.localizedDescription ?? "unknown")"
|
|
703
|
+
)
|
|
704
|
+
allKeysLoaded = false
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if allKeysLoaded {
|
|
708
|
+
print("โ
TrackPlayerCore: All asset keys preloaded for \(track.title)")
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return item
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/// Preloads assets for upcoming tracks to enable gapless playback
|
|
717
|
+
private func preloadUpcomingTracks(from startIndex: Int) {
|
|
718
|
+
preloadQueue.async { [weak self] in
|
|
719
|
+
guard let self = self else { return }
|
|
720
|
+
|
|
721
|
+
let endIndex = min(startIndex + Constants.gaplessPreloadCount, self.currentTracks.count)
|
|
722
|
+
|
|
723
|
+
for i in startIndex..<endIndex {
|
|
724
|
+
let track = self.currentTracks[i]
|
|
725
|
+
|
|
726
|
+
// Skip if already preloaded
|
|
727
|
+
if self.preloadedAssets[track.id] != nil {
|
|
728
|
+
continue
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
guard let url = URL(string: track.url) else { continue }
|
|
732
|
+
|
|
733
|
+
let asset = AVURLAsset(
|
|
734
|
+
url: url,
|
|
735
|
+
options: [
|
|
736
|
+
AVURLAssetPreferPreciseDurationAndTimingKey: true
|
|
737
|
+
])
|
|
738
|
+
|
|
739
|
+
// Preload essential keys for gapless playback
|
|
740
|
+
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
|
|
741
|
+
var allKeysLoaded = true
|
|
742
|
+
for key in Constants.preloadAssetKeys {
|
|
743
|
+
var error: NSError?
|
|
744
|
+
let status = asset.statusOfValue(forKey: key, error: &error)
|
|
745
|
+
if status != .loaded {
|
|
746
|
+
allKeysLoaded = false
|
|
747
|
+
break
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if allKeysLoaded {
|
|
752
|
+
DispatchQueue.main.async {
|
|
753
|
+
self?.preloadedAssets[track.id] = asset
|
|
754
|
+
print("๐ฏ TrackPlayerCore: Preloaded asset for upcoming track: \(track.title)")
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/// Clears preloaded assets that are no longer needed
|
|
763
|
+
private func cleanupPreloadedAssets(keepingFrom currentIndex: Int) {
|
|
764
|
+
preloadQueue.async { [weak self] in
|
|
765
|
+
guard let self = self else { return }
|
|
766
|
+
|
|
767
|
+
// Keep assets for current track and upcoming tracks within preload range
|
|
768
|
+
let keepRange =
|
|
769
|
+
currentIndex..<min(
|
|
770
|
+
currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
|
|
771
|
+
let keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
|
|
772
|
+
|
|
773
|
+
let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
|
|
774
|
+
for id in assetsToRemove {
|
|
775
|
+
self.preloadedAssets.removeValue(forKey: id)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if !assetsToRemove.isEmpty {
|
|
779
|
+
print("๐งน TrackPlayerCore: Cleaned up \(assetsToRemove.count) preloaded assets")
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
524
784
|
// MARK: - Queue Management
|
|
525
785
|
|
|
526
786
|
private func updatePlayerQueue(tracks: [TrackItem]) {
|
|
@@ -545,40 +805,18 @@ class TrackPlayerCore: NSObject {
|
|
|
545
805
|
boundaryTimeObserver = nil
|
|
546
806
|
}
|
|
547
807
|
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
guard let url = URL(string: track.url) else {
|
|
551
|
-
print("โ TrackPlayerCore: Invalid URL for track: \(track.title) - \(track.url)")
|
|
552
|
-
return nil
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
let item = AVPlayerItem(url: url)
|
|
808
|
+
// Clear old preloaded assets when loading new queue
|
|
809
|
+
preloadedAssets.removeAll()
|
|
556
810
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
let artistMetadata = AVMutableMetadataItem()
|
|
564
|
-
artistMetadata.identifier = .commonIdentifierArtist
|
|
565
|
-
artistMetadata.value = track.artist as NSString
|
|
566
|
-
artistMetadata.locale = Locale.current
|
|
567
|
-
|
|
568
|
-
let albumMetadata = AVMutableMetadataItem()
|
|
569
|
-
albumMetadata.identifier = .commonIdentifierAlbumName
|
|
570
|
-
albumMetadata.value = track.album as NSString
|
|
571
|
-
albumMetadata.locale = Locale.current
|
|
572
|
-
|
|
573
|
-
// Note: AVPlayerItem doesn't have externalMetadata property
|
|
574
|
-
// Metadata will be set via MPNowPlayingInfoCenter in MediaSessionManager
|
|
575
|
-
|
|
576
|
-
// Store track ID in item for later reference
|
|
577
|
-
item.trackId = track.id
|
|
578
|
-
|
|
579
|
-
return item
|
|
811
|
+
// Create gapless-optimized AVPlayerItems from tracks
|
|
812
|
+
let items = tracks.enumerated().compactMap { (index, track) -> AVPlayerItem? in
|
|
813
|
+
// First few items get preload treatment for faster initial playback
|
|
814
|
+
let isPreload = index < Constants.gaplessPreloadCount
|
|
815
|
+
return createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
580
816
|
}
|
|
581
817
|
|
|
818
|
+
print("๐ต TrackPlayerCore: Created \(items.count) gapless-optimized player items")
|
|
819
|
+
|
|
582
820
|
guard !items.isEmpty else {
|
|
583
821
|
print("โ TrackPlayerCore: No valid items to play")
|
|
584
822
|
return
|
|
@@ -639,16 +877,64 @@ class TrackPlayerCore: NSObject {
|
|
|
639
877
|
mediaSessionManager?.onTrackChanged()
|
|
640
878
|
}
|
|
641
879
|
|
|
642
|
-
|
|
880
|
+
// Start preloading upcoming tracks for gapless playback
|
|
881
|
+
preloadUpcomingTracks(from: 1)
|
|
882
|
+
|
|
883
|
+
print("โ
TrackPlayerCore: Queue updated with \(items.count) gapless-optimized tracks")
|
|
643
884
|
}
|
|
644
885
|
|
|
645
886
|
func getCurrentTrack() -> TrackItem? {
|
|
887
|
+
// If playing a temporary track, return that
|
|
888
|
+
if currentTemporaryType != .none,
|
|
889
|
+
let currentItem = player?.currentItem,
|
|
890
|
+
let trackId = currentItem.trackId
|
|
891
|
+
{
|
|
892
|
+
if currentTemporaryType == .playNext {
|
|
893
|
+
return playNextStack.first(where: { $0.id == trackId })
|
|
894
|
+
} else if currentTemporaryType == .upNext {
|
|
895
|
+
return upNextQueue.first(where: { $0.id == trackId })
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Otherwise return from original playlist
|
|
646
900
|
guard currentTrackIndex >= 0 && currentTrackIndex < currentTracks.count else {
|
|
647
901
|
return nil
|
|
648
902
|
}
|
|
649
903
|
return currentTracks[currentTrackIndex]
|
|
650
904
|
}
|
|
651
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Get the actual queue with temporary tracks
|
|
908
|
+
* Returns: [original_before_current] + [current] + [playNext_stack] + [upNext_queue] + [original_after_current]
|
|
909
|
+
*/
|
|
910
|
+
func getActualQueue() -> [TrackItem] {
|
|
911
|
+
var queue: [TrackItem] = []
|
|
912
|
+
|
|
913
|
+
// Add tracks before current (original playlist)
|
|
914
|
+
if currentTrackIndex > 0 {
|
|
915
|
+
queue.append(contentsOf: Array(currentTracks[0..<currentTrackIndex]))
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Add current track
|
|
919
|
+
if let current = getCurrentTrack() {
|
|
920
|
+
queue.append(current)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Add playNext stack (LIFO - most recently added plays first)
|
|
924
|
+
// Stack is already in correct order since we insert at position 0
|
|
925
|
+
queue.append(contentsOf: playNextStack)
|
|
926
|
+
|
|
927
|
+
// Add upNext queue (in order, FIFO)
|
|
928
|
+
queue.append(contentsOf: upNextQueue)
|
|
929
|
+
|
|
930
|
+
// Add remaining original tracks
|
|
931
|
+
if currentTrackIndex + 1 < currentTracks.count {
|
|
932
|
+
queue.append(contentsOf: Array(currentTracks[(currentTrackIndex + 1)...]))
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return queue
|
|
936
|
+
}
|
|
937
|
+
|
|
652
938
|
func play() {
|
|
653
939
|
print("โถ๏ธ TrackPlayerCore: play() called")
|
|
654
940
|
DispatchQueue.main.async { [weak self] in
|
|
@@ -695,6 +981,12 @@ class TrackPlayerCore: NSObject {
|
|
|
695
981
|
DispatchQueue.main.async { [weak self] in
|
|
696
982
|
guard let self = self else { return }
|
|
697
983
|
|
|
984
|
+
// Clear temporary tracks when directly playing a song
|
|
985
|
+
self.playNextStack.removeAll()
|
|
986
|
+
self.upNextQueue.removeAll()
|
|
987
|
+
self.currentTemporaryType = .none
|
|
988
|
+
print(" ๐งน Cleared temporary tracks")
|
|
989
|
+
|
|
698
990
|
var targetPlaylistId: String?
|
|
699
991
|
var songIndex: Int = -1
|
|
700
992
|
|
|
@@ -831,6 +1123,7 @@ class TrackPlayerCore: NSObject {
|
|
|
831
1123
|
|
|
832
1124
|
print("\nโฎ๏ธ TrackPlayerCore: SKIP TO PREVIOUS")
|
|
833
1125
|
print(" Current index: \(self.currentTrackIndex)")
|
|
1126
|
+
print(" Temporary type: \(self.currentTemporaryType)")
|
|
834
1127
|
print(" Current time: \(queuePlayer.currentTime().seconds)s")
|
|
835
1128
|
|
|
836
1129
|
let currentTime = queuePlayer.currentTime()
|
|
@@ -839,8 +1132,12 @@ class TrackPlayerCore: NSObject {
|
|
|
839
1132
|
print(
|
|
840
1133
|
" ๐ More than \(Int(Constants.skipToPreviousThreshold))s in, restarting current track")
|
|
841
1134
|
queuePlayer.seek(to: .zero)
|
|
1135
|
+
} else if self.currentTemporaryType != .none {
|
|
1136
|
+
// Playing temporary track - just restart it (temps are not navigable backwards)
|
|
1137
|
+
print(" ๐ Playing temporary track - restarting it (temps not navigable backwards)")
|
|
1138
|
+
queuePlayer.seek(to: .zero)
|
|
842
1139
|
} else if self.currentTrackIndex > 0 {
|
|
843
|
-
// Go to previous track
|
|
1140
|
+
// Go to previous track in original playlist
|
|
844
1141
|
let previousIndex = self.currentTrackIndex - 1
|
|
845
1142
|
print(" โฎ๏ธ Going to previous track at index \(previousIndex)")
|
|
846
1143
|
self.playFromIndex(index: previousIndex)
|
|
@@ -867,6 +1164,14 @@ class TrackPlayerCore: NSObject {
|
|
|
867
1164
|
}
|
|
868
1165
|
}
|
|
869
1166
|
|
|
1167
|
+
// MARK: - Repeat Mode
|
|
1168
|
+
|
|
1169
|
+
func setRepeatMode(mode: RepeatMode) -> Bool {
|
|
1170
|
+
print("๐ TrackPlayerCore: setRepeatMode called with mode: \(mode)")
|
|
1171
|
+
self.repeatMode = mode
|
|
1172
|
+
return true
|
|
1173
|
+
}
|
|
1174
|
+
|
|
870
1175
|
// MARK: - State Management
|
|
871
1176
|
|
|
872
1177
|
func getState() -> PlayerState {
|
|
@@ -925,6 +1230,28 @@ class TrackPlayerCore: NSObject {
|
|
|
925
1230
|
return playlistManager.getAllPlaylists().map { $0.toGeneratedPlaylist() }
|
|
926
1231
|
}
|
|
927
1232
|
|
|
1233
|
+
// MARK: - Volume Control
|
|
1234
|
+
|
|
1235
|
+
func setVolume(volume: Double) -> Bool {
|
|
1236
|
+
guard let player = player else {
|
|
1237
|
+
print("โ ๏ธ TrackPlayerCore: Cannot set volume - no player available")
|
|
1238
|
+
return false
|
|
1239
|
+
}
|
|
1240
|
+
DispatchQueue.main.async { [weak self] in
|
|
1241
|
+
guard let self = self, let currentPlayer = self.player else {
|
|
1242
|
+
return
|
|
1243
|
+
}
|
|
1244
|
+
// Clamp volume to 0-100 range
|
|
1245
|
+
let clampedVolume = max(0.0, min(100.0, volume))
|
|
1246
|
+
// Convert to 0.0-1.0 range for AVQueuePlayer
|
|
1247
|
+
let normalizedVolume = Float(clampedVolume / 100.0)
|
|
1248
|
+
currentPlayer.volume = normalizedVolume
|
|
1249
|
+
print(
|
|
1250
|
+
"๐ TrackPlayerCore: Volume set to \(Int(clampedVolume))% (normalized: \(normalizedVolume))")
|
|
1251
|
+
}
|
|
1252
|
+
return true
|
|
1253
|
+
}
|
|
1254
|
+
|
|
928
1255
|
func playFromIndex(index: Int) {
|
|
929
1256
|
DispatchQueue.main.async { [weak self] in
|
|
930
1257
|
guard let self = self,
|
|
@@ -938,6 +1265,12 @@ class TrackPlayerCore: NSObject {
|
|
|
938
1265
|
print(" Total tracks in playlist: \(self.currentTracks.count)")
|
|
939
1266
|
print(" Current index: \(self.currentTrackIndex), target index: \(index)")
|
|
940
1267
|
|
|
1268
|
+
// Clear temporary tracks when jumping to specific index
|
|
1269
|
+
self.playNextStack.removeAll()
|
|
1270
|
+
self.upNextQueue.removeAll()
|
|
1271
|
+
self.currentTemporaryType = .none
|
|
1272
|
+
print(" ๐งน Cleared temporary tracks")
|
|
1273
|
+
|
|
941
1274
|
// Store the full playlist
|
|
942
1275
|
let fullPlaylist = self.currentTracks
|
|
943
1276
|
|
|
@@ -947,14 +1280,15 @@ class TrackPlayerCore: NSObject {
|
|
|
947
1280
|
// Recreate the queue starting from the target index
|
|
948
1281
|
// This ensures all remaining tracks are in the queue
|
|
949
1282
|
let tracksToPlay = Array(fullPlaylist[index...])
|
|
950
|
-
print(
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1283
|
+
print(
|
|
1284
|
+
" ๐ Creating gapless queue with \(tracksToPlay.count) tracks starting from index \(index)"
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
// Create gapless-optimized player items
|
|
1288
|
+
let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
|
|
1289
|
+
// First few items get preload treatment for faster playback
|
|
1290
|
+
let isPreload = offset < Constants.gaplessPreloadCount
|
|
1291
|
+
return self.createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
958
1292
|
}
|
|
959
1293
|
|
|
960
1294
|
guard let player = self.player, !items.isEmpty else {
|
|
@@ -979,22 +1313,178 @@ class TrackPlayerCore: NSObject {
|
|
|
979
1313
|
// Restore the full playlist reference (don't slice it!)
|
|
980
1314
|
self.currentTracks = fullPlaylist
|
|
981
1315
|
|
|
982
|
-
print(" โ
|
|
1316
|
+
print(" โ
Gapless queue recreated. Now at index: \(self.currentTrackIndex)")
|
|
983
1317
|
if let track = self.getCurrentTrack() {
|
|
984
1318
|
print(" ๐ต Playing: \(track.title)")
|
|
985
1319
|
self.onChangeTrack?(track, .skip)
|
|
986
1320
|
self.mediaSessionManager?.onTrackChanged()
|
|
987
1321
|
}
|
|
988
1322
|
|
|
1323
|
+
// Start preloading upcoming tracks for gapless playback
|
|
1324
|
+
self.preloadUpcomingTracks(from: index + 1)
|
|
1325
|
+
|
|
989
1326
|
player.play()
|
|
990
1327
|
}
|
|
991
1328
|
}
|
|
992
1329
|
|
|
1330
|
+
// MARK: - Temporary Track Management
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Add a track to the up-next queue (FIFO - first added plays first)
|
|
1334
|
+
* Track will be inserted after currently playing track and any playNext tracks
|
|
1335
|
+
*/
|
|
1336
|
+
func addToUpNext(trackId: String) {
|
|
1337
|
+
DispatchQueue.main.async { [weak self] in
|
|
1338
|
+
guard let self = self else { return }
|
|
1339
|
+
|
|
1340
|
+
print("๐ TrackPlayerCore: addToUpNext(\(trackId))")
|
|
1341
|
+
|
|
1342
|
+
// Find the track from current playlist or all playlists
|
|
1343
|
+
guard let track = self.findTrackById(trackId) else {
|
|
1344
|
+
print("โ TrackPlayerCore: Track \(trackId) not found")
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Add to end of upNext queue (FIFO)
|
|
1349
|
+
self.upNextQueue.append(track)
|
|
1350
|
+
print(" โ
Added '\(track.title)' to upNext queue (position: \(self.upNextQueue.count))")
|
|
1351
|
+
|
|
1352
|
+
// Rebuild the player queue if actively playing
|
|
1353
|
+
if self.player?.currentItem != nil {
|
|
1354
|
+
self.rebuildAVQueueFromCurrentPosition()
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Add a track to play next (LIFO - last added plays first)
|
|
1361
|
+
* Track will be inserted immediately after currently playing track
|
|
1362
|
+
*/
|
|
1363
|
+
func playNext(trackId: String) {
|
|
1364
|
+
DispatchQueue.main.async { [weak self] in
|
|
1365
|
+
guard let self = self else { return }
|
|
1366
|
+
|
|
1367
|
+
print("โญ๏ธ TrackPlayerCore: playNext(\(trackId))")
|
|
1368
|
+
|
|
1369
|
+
// Find the track from current playlist or all playlists
|
|
1370
|
+
guard let track = self.findTrackById(trackId) else {
|
|
1371
|
+
print("โ TrackPlayerCore: Track \(trackId) not found")
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Insert at beginning of playNext stack (LIFO)
|
|
1376
|
+
self.playNextStack.insert(track, at: 0)
|
|
1377
|
+
print(" โ
Added '\(track.title)' to playNext stack (position: 1)")
|
|
1378
|
+
|
|
1379
|
+
// Rebuild the player queue if actively playing
|
|
1380
|
+
if self.player?.currentItem != nil {
|
|
1381
|
+
self.rebuildAVQueueFromCurrentPosition()
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Rebuild the AVQueuePlayer from current position with temporary tracks
|
|
1388
|
+
* Order: [current] + [playNext stack reversed] + [upNext queue] + [remaining original]
|
|
1389
|
+
*/
|
|
1390
|
+
private func rebuildAVQueueFromCurrentPosition() {
|
|
1391
|
+
guard let player = self.player else { return }
|
|
1392
|
+
|
|
1393
|
+
print("\n๐ TrackPlayerCore: REBUILDING QUEUE FROM CURRENT POSITION")
|
|
1394
|
+
print(" playNext stack: \(playNextStack.count) tracks")
|
|
1395
|
+
print(" upNext queue: \(upNextQueue.count) tracks")
|
|
1396
|
+
|
|
1397
|
+
// Don't interrupt currently playing item
|
|
1398
|
+
let currentItem = player.currentItem
|
|
1399
|
+
let playingItems = player.items()
|
|
1400
|
+
|
|
1401
|
+
// Build new queue order:
|
|
1402
|
+
// [playNext stack] + [upNext queue] + [remaining original tracks]
|
|
1403
|
+
var newQueueTracks: [TrackItem] = []
|
|
1404
|
+
|
|
1405
|
+
// Add playNext stack (LIFO - most recently added plays first)
|
|
1406
|
+
// Stack is already in correct order since we insert at position 0
|
|
1407
|
+
newQueueTracks.append(contentsOf: playNextStack)
|
|
1408
|
+
|
|
1409
|
+
// Add upNext queue (in order, FIFO)
|
|
1410
|
+
newQueueTracks.append(contentsOf: upNextQueue)
|
|
1411
|
+
|
|
1412
|
+
// Add remaining original tracks
|
|
1413
|
+
if currentTrackIndex + 1 < currentTracks.count {
|
|
1414
|
+
let remainingOriginal = Array(currentTracks[(currentTrackIndex + 1)...])
|
|
1415
|
+
newQueueTracks.append(contentsOf: remainingOriginal)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
print(" New queue: \(newQueueTracks.count) tracks total")
|
|
1419
|
+
|
|
1420
|
+
// Remove all items from player EXCEPT the currently playing one
|
|
1421
|
+
for item in playingItems where item != currentItem {
|
|
1422
|
+
player.remove(item)
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Insert new items in order
|
|
1426
|
+
var lastItem = currentItem
|
|
1427
|
+
for track in newQueueTracks {
|
|
1428
|
+
if let item = createGaplessPlayerItem(for: track, isPreload: false) {
|
|
1429
|
+
player.insert(item, after: lastItem)
|
|
1430
|
+
lastItem = item
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
print(" โ
Queue rebuilt successfully")
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Find a track by ID from current playlist or all playlists
|
|
1439
|
+
*/
|
|
1440
|
+
private func findTrackById(_ trackId: String) -> TrackItem? {
|
|
1441
|
+
// First check current playlist
|
|
1442
|
+
if let track = currentTracks.first(where: { $0.id == trackId }) {
|
|
1443
|
+
return track
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Then check all playlists
|
|
1447
|
+
let allPlaylists = playlistManager.getAllPlaylists()
|
|
1448
|
+
for playlist in allPlaylists {
|
|
1449
|
+
if let track = playlist.tracks.first(where: { $0.id == trackId }) {
|
|
1450
|
+
return track
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return nil
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Determine what type of track is currently playing
|
|
1459
|
+
*/
|
|
1460
|
+
private func determineCurrentTemporaryType() -> TemporaryType {
|
|
1461
|
+
guard let currentItem = player?.currentItem,
|
|
1462
|
+
let trackId = currentItem.trackId
|
|
1463
|
+
else {
|
|
1464
|
+
return .none
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// Check if in playNext stack
|
|
1468
|
+
if playNextStack.contains(where: { $0.id == trackId }) {
|
|
1469
|
+
return .playNext
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Check if in upNext queue
|
|
1473
|
+
if upNextQueue.contains(where: { $0.id == trackId }) {
|
|
1474
|
+
return .upNext
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return .none
|
|
1478
|
+
}
|
|
1479
|
+
|
|
993
1480
|
// MARK: - Cleanup
|
|
994
1481
|
|
|
995
1482
|
deinit {
|
|
996
1483
|
print("๐งน TrackPlayerCore: Cleaning up...")
|
|
997
1484
|
|
|
1485
|
+
// Clear preloaded assets for gapless playback
|
|
1486
|
+
preloadedAssets.removeAll()
|
|
1487
|
+
|
|
998
1488
|
// Remove boundary time observer
|
|
999
1489
|
if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
|
|
1000
1490
|
currentPlayer.removeTimeObserver(boundaryObserver)
|