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.
Files changed (47) hide show
  1. package/README.md +282 -2
  2. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridAudioDevices.kt +37 -29
  3. package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +24 -0
  4. package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +408 -16
  5. package/ios/HybridAudioRoutePicker.swift +47 -46
  6. package/ios/HybridTrackPlayer.swift +22 -0
  7. package/ios/core/TrackPlayerCore.swift +538 -48
  8. package/lib/hooks/callbackManager.d.ts +28 -0
  9. package/lib/hooks/callbackManager.js +76 -0
  10. package/lib/hooks/index.d.ts +7 -0
  11. package/lib/hooks/index.js +3 -0
  12. package/lib/hooks/useActualQueue.d.ts +48 -0
  13. package/lib/hooks/useActualQueue.js +98 -0
  14. package/lib/hooks/useNowPlaying.d.ts +36 -0
  15. package/lib/hooks/useNowPlaying.js +87 -0
  16. package/lib/hooks/useOnChangeTrack.d.ts +33 -6
  17. package/lib/hooks/useOnChangeTrack.js +65 -9
  18. package/lib/hooks/useOnPlaybackStateChange.d.ts +32 -6
  19. package/lib/hooks/useOnPlaybackStateChange.js +65 -9
  20. package/lib/hooks/usePlaylist.d.ts +48 -0
  21. package/lib/hooks/usePlaylist.js +136 -0
  22. package/lib/index.d.ts +1 -0
  23. package/lib/specs/TrackPlayer.nitro.d.ts +6 -0
  24. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +46 -9
  25. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +5 -0
  26. package/nitrogen/generated/android/c++/JRepeatMode.hpp +62 -0
  27. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +20 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/RepeatMode.kt +22 -0
  29. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Bridge.hpp +9 -0
  30. package/nitrogen/generated/ios/NitroPlayer-Swift-Cxx-Umbrella.hpp +3 -0
  31. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +44 -4
  32. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +5 -0
  33. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +64 -0
  34. package/nitrogen/generated/ios/swift/RepeatMode.swift +44 -0
  35. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +5 -0
  36. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +12 -3
  37. package/nitrogen/generated/shared/c++/RepeatMode.hpp +80 -0
  38. package/package.json +13 -12
  39. package/src/hooks/callbackManager.ts +96 -0
  40. package/src/hooks/index.ts +7 -0
  41. package/src/hooks/useActualQueue.ts +116 -0
  42. package/src/hooks/useNowPlaying.ts +97 -0
  43. package/src/hooks/useOnChangeTrack.ts +77 -13
  44. package/src/hooks/useOnPlaybackStateChange.ts +83 -13
  45. package/src/hooks/usePlaylist.ts +161 -0
  46. package/src/index.ts +1 -1
  47. 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
- if let trackId = finishedItem.trackId,
225
- let track = currentTracks.first(where: { $0.id == trackId })
226
- {
227
- print("๐Ÿ Finished: \(track.title)")
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
- if let index = currentTracks.firstIndex(where: { $0.id == trackId }) {
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
- // Create AVPlayerItems from tracks
549
- let items = tracks.compactMap { track -> AVPlayerItem? in
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
- // Set metadata using AVMutableMetadataItem
558
- let metadata = AVMutableMetadataItem()
559
- metadata.identifier = .commonIdentifierTitle
560
- metadata.value = track.title as NSString
561
- metadata.locale = Locale.current
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
- print("โœ… TrackPlayerCore: Queue updated with \(items.count) tracks")
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(" ๐Ÿ”„ Creating queue with \(tracksToPlay.count) tracks starting from index \(index)")
951
-
952
- // Update the queue (but keep the full currentTracks for reference)
953
- let items = tracksToPlay.compactMap { track -> AVPlayerItem? in
954
- guard let url = URL(string: track.url) else { return nil }
955
- let item = AVPlayerItem(url: url)
956
- item.trackId = track.id
957
- return item
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(" โœ… Queue recreated. Now at index: \(self.currentTrackIndex)")
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)