react-native-nitro-player 0.3.0-alpha.6 → 0.3.0-alpha.8
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 +719 -1
- package/android/src/main/java/com/margelo/nitro/nitroplayer/HybridTrackPlayer.kt +4 -0
- package/android/src/main/java/com/margelo/nitro/nitroplayer/core/TrackPlayerCore.kt +44 -7
- package/ios/HybridTrackPlayer.swift +6 -0
- package/ios/core/TrackPlayerCore.swift +214 -41
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useNowPlaying.d.ts +36 -0
- package/lib/hooks/useNowPlaying.js +79 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +1 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt +4 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +8 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +12 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
- package/package.json +8 -4
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useNowPlaying.ts +84 -0
- package/src/specs/TrackPlayer.nitro.ts +1 -0
|
@@ -4,6 +4,8 @@ package com.margelo.nitro.nitroplayer.core
|
|
|
4
4
|
|
|
5
5
|
import android.content.Context
|
|
6
6
|
import android.net.Uri
|
|
7
|
+
import androidx.media3.common.AudioAttributes
|
|
8
|
+
import androidx.media3.common.C
|
|
7
9
|
import androidx.media3.common.MediaItem
|
|
8
10
|
import androidx.media3.common.MediaMetadata
|
|
9
11
|
import androidx.media3.common.Player
|
|
@@ -72,25 +74,43 @@ class TrackPlayerCore private constructor(
|
|
|
72
74
|
|
|
73
75
|
init {
|
|
74
76
|
handler.post {
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
+
// ============================================================
|
|
78
|
+
// GAPLESS PLAYBACK CONFIGURATION
|
|
79
|
+
// ============================================================
|
|
80
|
+
// Configure LoadControl for maximum gapless playback
|
|
81
|
+
// Large buffers ensure next track is fully ready before current ends
|
|
77
82
|
val loadControl =
|
|
78
83
|
DefaultLoadControl
|
|
79
84
|
.Builder()
|
|
80
85
|
.setBufferDurationsMs(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
).setBackBuffer(
|
|
86
|
+
30_000, // MIN_BUFFER_MS: 30 seconds minimum buffer
|
|
87
|
+
120_000, // MAX_BUFFER_MS: 2 minutes maximum buffer (enables preloading next tracks)
|
|
88
|
+
2_500, // BUFFER_FOR_PLAYBACK_MS: 2.5s before playback starts
|
|
89
|
+
5_000, // BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS: 5s after rebuffer
|
|
90
|
+
).setBackBuffer(30_000, true) // Keep 30s back buffer for seamless seek-back
|
|
91
|
+
.setTargetBufferBytes(C.LENGTH_UNSET) // No size limit - prioritize time
|
|
86
92
|
.setPrioritizeTimeOverSizeThresholds(true) // Prioritize time-based buffering
|
|
87
93
|
.build()
|
|
88
94
|
|
|
95
|
+
// Configure audio attributes for optimal music playback
|
|
96
|
+
// This enables gapless audio processing in the audio pipeline
|
|
97
|
+
val audioAttributes =
|
|
98
|
+
AudioAttributes
|
|
99
|
+
.Builder()
|
|
100
|
+
.setUsage(C.USAGE_MEDIA)
|
|
101
|
+
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
|
102
|
+
.build()
|
|
103
|
+
|
|
89
104
|
player =
|
|
90
105
|
ExoPlayer
|
|
91
106
|
.Builder(context)
|
|
92
107
|
.setLoadControl(loadControl)
|
|
108
|
+
.setAudioAttributes(audioAttributes, true) // handleAudioFocus = true for gapless
|
|
109
|
+
.setHandleAudioBecomingNoisy(true) // Pause when headphones disconnected
|
|
110
|
+
.setPauseAtEndOfMediaItems(false) // Don't pause between items - key for gapless!
|
|
93
111
|
.build()
|
|
112
|
+
|
|
113
|
+
println("🎵 TrackPlayerCore: Gapless playback configured - 120s buffer, audio focus handling enabled")
|
|
94
114
|
mediaSessionManager =
|
|
95
115
|
MediaSessionManager(context, player, playlistManager).apply {
|
|
96
116
|
setTrackPlayerCore(this@TrackPlayerCore)
|
|
@@ -652,4 +672,21 @@ class TrackPlayerCore private constructor(
|
|
|
652
672
|
NitroPlayerMediaBrowserService.getInstance()?.onPlaylistsUpdated()
|
|
653
673
|
}
|
|
654
674
|
}
|
|
675
|
+
|
|
676
|
+
// Set volume (0-100 range, converted to 0.0-1.0 for ExoPlayer)
|
|
677
|
+
fun setVolume(volume: Double): Boolean =
|
|
678
|
+
if (::player.isInitialized) {
|
|
679
|
+
handler.post {
|
|
680
|
+
// Clamp volume to 0-100 range
|
|
681
|
+
val clampedVolume = volume.coerceIn(0.0, 100.0)
|
|
682
|
+
// Convert to 0.0-1.0 range for ExoPlayer
|
|
683
|
+
val normalizedVolume = (clampedVolume / 100.0).toFloat()
|
|
684
|
+
player.volume = normalizedVolume
|
|
685
|
+
println("🔊 TrackPlayerCore: Volume set to $clampedVolume% (normalized: $normalizedVolume)")
|
|
686
|
+
}
|
|
687
|
+
true
|
|
688
|
+
} else {
|
|
689
|
+
println("⚠️ TrackPlayerCore: Cannot set volume - player not initialized")
|
|
690
|
+
false
|
|
691
|
+
}
|
|
655
692
|
}
|
|
@@ -101,4 +101,10 @@ final class HybridTrackPlayer: HybridTrackPlayerSpec {
|
|
|
101
101
|
func isAndroidAutoConnected() throws -> Bool {
|
|
102
102
|
return false
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
// MARK: - Volume Control
|
|
106
|
+
|
|
107
|
+
func setVolume(volume: Double) throws -> Bool {
|
|
108
|
+
return core.setVolume(volume: volume)
|
|
109
|
+
}
|
|
104
110
|
}
|
|
@@ -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
|
|
@@ -46,6 +53,10 @@ class TrackPlayerCore: NSObject {
|
|
|
46
53
|
private var boundaryTimeObserver: Any?
|
|
47
54
|
private var currentItemObservers: [NSKeyValueObservation] = []
|
|
48
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
|
+
|
|
49
60
|
var onChangeTrack: ((TrackItem, Reason?) -> Void)?
|
|
50
61
|
var onPlaybackStateChange: ((TrackPlayerState, Reason?) -> Void)?
|
|
51
62
|
var onSeek: ((Double, Double) -> Void)?
|
|
@@ -77,6 +88,24 @@ class TrackPlayerCore: NSObject {
|
|
|
77
88
|
|
|
78
89
|
private func setupPlayer() {
|
|
79
90
|
player = AVQueuePlayer()
|
|
91
|
+
|
|
92
|
+
// MARK: - Gapless Playback Configuration
|
|
93
|
+
|
|
94
|
+
// Disable automatic waiting to minimize stalling - this allows smoother transitions
|
|
95
|
+
// between tracks as AVPlayer won't pause to buffer excessively
|
|
96
|
+
player?.automaticallyWaitsToMinimizeStalling = false
|
|
97
|
+
|
|
98
|
+
// Set playback rate to 1.0 immediately when ready (reduces gap between tracks)
|
|
99
|
+
player?.actionAtItemEnd = .advance
|
|
100
|
+
|
|
101
|
+
// Configure for high-quality audio playback with minimal latency
|
|
102
|
+
if #available(iOS 15.0, *) {
|
|
103
|
+
player?.audiovisualBackgroundPlaybackPolicy = .continuesIfPossible
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
print(
|
|
107
|
+
"🎵 TrackPlayerCore: Gapless playback configured - automaticallyWaitsToMinimizeStalling=false")
|
|
108
|
+
|
|
80
109
|
setupPlayerObservers()
|
|
81
110
|
}
|
|
82
111
|
|
|
@@ -446,6 +475,11 @@ class TrackPlayerCore: NSObject {
|
|
|
446
475
|
if currentItem.status == .readyToPlay {
|
|
447
476
|
setupBoundaryTimeObserver()
|
|
448
477
|
}
|
|
478
|
+
|
|
479
|
+
// MARK: - Gapless Playback: Preload upcoming tracks when track changes
|
|
480
|
+
// This ensures the next tracks are ready for seamless transitions
|
|
481
|
+
preloadUpcomingTracks(from: currentTrackIndex + 1)
|
|
482
|
+
cleanupPreloadedAssets(keepingFrom: currentTrackIndex)
|
|
449
483
|
}
|
|
450
484
|
|
|
451
485
|
private func setupCurrentItemObservers(item: AVPlayerItem) {
|
|
@@ -554,6 +588,135 @@ class TrackPlayerCore: NSObject {
|
|
|
554
588
|
mediaSessionManager?.onPlaybackStateChanged()
|
|
555
589
|
}
|
|
556
590
|
|
|
591
|
+
// MARK: - Gapless Playback Helpers
|
|
592
|
+
|
|
593
|
+
/// Creates a gapless-optimized AVPlayerItem with proper buffering configuration
|
|
594
|
+
private func createGaplessPlayerItem(for track: TrackItem, isPreload: Bool = false)
|
|
595
|
+
-> AVPlayerItem?
|
|
596
|
+
{
|
|
597
|
+
guard let url = URL(string: track.url) else {
|
|
598
|
+
print("❌ TrackPlayerCore: Invalid URL for track: \(track.title) - \(track.url)")
|
|
599
|
+
return nil
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Check if we have a preloaded asset for this track
|
|
603
|
+
let asset: AVURLAsset
|
|
604
|
+
if let preloadedAsset = preloadedAssets[track.id] {
|
|
605
|
+
asset = preloadedAsset
|
|
606
|
+
print("🚀 TrackPlayerCore: Using preloaded asset for \(track.title)")
|
|
607
|
+
} else {
|
|
608
|
+
// Create asset with options optimized for gapless playback
|
|
609
|
+
asset = AVURLAsset(
|
|
610
|
+
url: url,
|
|
611
|
+
options: [
|
|
612
|
+
AVURLAssetPreferPreciseDurationAndTimingKey: true // Ensures accurate duration for gapless transitions
|
|
613
|
+
])
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
let item = AVPlayerItem(asset: asset)
|
|
617
|
+
|
|
618
|
+
// Configure buffer duration for gapless playback
|
|
619
|
+
// This tells AVPlayer how much content to buffer ahead
|
|
620
|
+
item.preferredForwardBufferDuration = Constants.preferredForwardBufferDuration
|
|
621
|
+
|
|
622
|
+
// Enable automatic loading of item properties for faster starts
|
|
623
|
+
item.canUseNetworkResourcesForLiveStreamingWhilePaused = true
|
|
624
|
+
|
|
625
|
+
// Store track ID for later reference
|
|
626
|
+
item.trackId = track.id
|
|
627
|
+
|
|
628
|
+
// If this is a preload request, start loading asset keys asynchronously
|
|
629
|
+
if isPreload {
|
|
630
|
+
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) {
|
|
631
|
+
// Asset keys are now loaded, which speeds up playback start
|
|
632
|
+
var allKeysLoaded = true
|
|
633
|
+
for key in Constants.preloadAssetKeys {
|
|
634
|
+
var error: NSError?
|
|
635
|
+
let status = asset.statusOfValue(forKey: key, error: &error)
|
|
636
|
+
if status == .failed {
|
|
637
|
+
print(
|
|
638
|
+
"⚠️ TrackPlayerCore: Failed to load key '\(key)' for \(track.title): \(error?.localizedDescription ?? "unknown")"
|
|
639
|
+
)
|
|
640
|
+
allKeysLoaded = false
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if allKeysLoaded {
|
|
644
|
+
print("✅ TrackPlayerCore: All asset keys preloaded for \(track.title)")
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return item
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/// Preloads assets for upcoming tracks to enable gapless playback
|
|
653
|
+
private func preloadUpcomingTracks(from startIndex: Int) {
|
|
654
|
+
preloadQueue.async { [weak self] in
|
|
655
|
+
guard let self = self else { return }
|
|
656
|
+
|
|
657
|
+
let endIndex = min(startIndex + Constants.gaplessPreloadCount, self.currentTracks.count)
|
|
658
|
+
|
|
659
|
+
for i in startIndex..<endIndex {
|
|
660
|
+
let track = self.currentTracks[i]
|
|
661
|
+
|
|
662
|
+
// Skip if already preloaded
|
|
663
|
+
if self.preloadedAssets[track.id] != nil {
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
guard let url = URL(string: track.url) else { continue }
|
|
668
|
+
|
|
669
|
+
let asset = AVURLAsset(
|
|
670
|
+
url: url,
|
|
671
|
+
options: [
|
|
672
|
+
AVURLAssetPreferPreciseDurationAndTimingKey: true
|
|
673
|
+
])
|
|
674
|
+
|
|
675
|
+
// Preload essential keys for gapless playback
|
|
676
|
+
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
|
|
677
|
+
var allKeysLoaded = true
|
|
678
|
+
for key in Constants.preloadAssetKeys {
|
|
679
|
+
var error: NSError?
|
|
680
|
+
let status = asset.statusOfValue(forKey: key, error: &error)
|
|
681
|
+
if status != .loaded {
|
|
682
|
+
allKeysLoaded = false
|
|
683
|
+
break
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if allKeysLoaded {
|
|
688
|
+
DispatchQueue.main.async {
|
|
689
|
+
self?.preloadedAssets[track.id] = asset
|
|
690
|
+
print("🎯 TrackPlayerCore: Preloaded asset for upcoming track: \(track.title)")
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/// Clears preloaded assets that are no longer needed
|
|
699
|
+
private func cleanupPreloadedAssets(keepingFrom currentIndex: Int) {
|
|
700
|
+
preloadQueue.async { [weak self] in
|
|
701
|
+
guard let self = self else { return }
|
|
702
|
+
|
|
703
|
+
// Keep assets for current track and upcoming tracks within preload range
|
|
704
|
+
let keepRange =
|
|
705
|
+
currentIndex..<min(
|
|
706
|
+
currentIndex + Constants.gaplessPreloadCount + 1, self.currentTracks.count)
|
|
707
|
+
let keepIds = Set(keepRange.compactMap { self.currentTracks[safe: $0]?.id })
|
|
708
|
+
|
|
709
|
+
let assetsToRemove = self.preloadedAssets.keys.filter { !keepIds.contains($0) }
|
|
710
|
+
for id in assetsToRemove {
|
|
711
|
+
self.preloadedAssets.removeValue(forKey: id)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if !assetsToRemove.isEmpty {
|
|
715
|
+
print("🧹 TrackPlayerCore: Cleaned up \(assetsToRemove.count) preloaded assets")
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
557
720
|
// MARK: - Queue Management
|
|
558
721
|
|
|
559
722
|
private func updatePlayerQueue(tracks: [TrackItem]) {
|
|
@@ -578,40 +741,18 @@ class TrackPlayerCore: NSObject {
|
|
|
578
741
|
boundaryTimeObserver = nil
|
|
579
742
|
}
|
|
580
743
|
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
guard let url = URL(string: track.url) else {
|
|
584
|
-
print("❌ TrackPlayerCore: Invalid URL for track: \(track.title) - \(track.url)")
|
|
585
|
-
return nil
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
let item = AVPlayerItem(url: url)
|
|
589
|
-
|
|
590
|
-
// Set metadata using AVMutableMetadataItem
|
|
591
|
-
let metadata = AVMutableMetadataItem()
|
|
592
|
-
metadata.identifier = .commonIdentifierTitle
|
|
593
|
-
metadata.value = track.title as NSString
|
|
594
|
-
metadata.locale = Locale.current
|
|
595
|
-
|
|
596
|
-
let artistMetadata = AVMutableMetadataItem()
|
|
597
|
-
artistMetadata.identifier = .commonIdentifierArtist
|
|
598
|
-
artistMetadata.value = track.artist as NSString
|
|
599
|
-
artistMetadata.locale = Locale.current
|
|
600
|
-
|
|
601
|
-
let albumMetadata = AVMutableMetadataItem()
|
|
602
|
-
albumMetadata.identifier = .commonIdentifierAlbumName
|
|
603
|
-
albumMetadata.value = track.album as NSString
|
|
604
|
-
albumMetadata.locale = Locale.current
|
|
605
|
-
|
|
606
|
-
// Note: AVPlayerItem doesn't have externalMetadata property
|
|
607
|
-
// Metadata will be set via MPNowPlayingInfoCenter in MediaSessionManager
|
|
744
|
+
// Clear old preloaded assets when loading new queue
|
|
745
|
+
preloadedAssets.removeAll()
|
|
608
746
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
747
|
+
// Create gapless-optimized AVPlayerItems from tracks
|
|
748
|
+
let items = tracks.enumerated().compactMap { (index, track) -> AVPlayerItem? in
|
|
749
|
+
// First few items get preload treatment for faster initial playback
|
|
750
|
+
let isPreload = index < Constants.gaplessPreloadCount
|
|
751
|
+
return createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
613
752
|
}
|
|
614
753
|
|
|
754
|
+
print("🎵 TrackPlayerCore: Created \(items.count) gapless-optimized player items")
|
|
755
|
+
|
|
615
756
|
guard !items.isEmpty else {
|
|
616
757
|
print("❌ TrackPlayerCore: No valid items to play")
|
|
617
758
|
return
|
|
@@ -672,7 +813,10 @@ class TrackPlayerCore: NSObject {
|
|
|
672
813
|
mediaSessionManager?.onTrackChanged()
|
|
673
814
|
}
|
|
674
815
|
|
|
675
|
-
|
|
816
|
+
// Start preloading upcoming tracks for gapless playback
|
|
817
|
+
preloadUpcomingTracks(from: 1)
|
|
818
|
+
|
|
819
|
+
print("✅ TrackPlayerCore: Queue updated with \(items.count) gapless-optimized tracks")
|
|
676
820
|
}
|
|
677
821
|
|
|
678
822
|
func getCurrentTrack() -> TrackItem? {
|
|
@@ -966,6 +1110,28 @@ class TrackPlayerCore: NSObject {
|
|
|
966
1110
|
return playlistManager.getAllPlaylists().map { $0.toGeneratedPlaylist() }
|
|
967
1111
|
}
|
|
968
1112
|
|
|
1113
|
+
// MARK: - Volume Control
|
|
1114
|
+
|
|
1115
|
+
func setVolume(volume: Double) -> Bool {
|
|
1116
|
+
guard let player = player else {
|
|
1117
|
+
print("⚠️ TrackPlayerCore: Cannot set volume - no player available")
|
|
1118
|
+
return false
|
|
1119
|
+
}
|
|
1120
|
+
DispatchQueue.main.async { [weak self] in
|
|
1121
|
+
guard let self = self, let currentPlayer = self.player else {
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
// Clamp volume to 0-100 range
|
|
1125
|
+
let clampedVolume = max(0.0, min(100.0, volume))
|
|
1126
|
+
// Convert to 0.0-1.0 range for AVQueuePlayer
|
|
1127
|
+
let normalizedVolume = Float(clampedVolume / 100.0)
|
|
1128
|
+
currentPlayer.volume = normalizedVolume
|
|
1129
|
+
print(
|
|
1130
|
+
"🔊 TrackPlayerCore: Volume set to \(Int(clampedVolume))% (normalized: \(normalizedVolume))")
|
|
1131
|
+
}
|
|
1132
|
+
return true
|
|
1133
|
+
}
|
|
1134
|
+
|
|
969
1135
|
func playFromIndex(index: Int) {
|
|
970
1136
|
DispatchQueue.main.async { [weak self] in
|
|
971
1137
|
guard let self = self,
|
|
@@ -988,14 +1154,15 @@ class TrackPlayerCore: NSObject {
|
|
|
988
1154
|
// Recreate the queue starting from the target index
|
|
989
1155
|
// This ensures all remaining tracks are in the queue
|
|
990
1156
|
let tracksToPlay = Array(fullPlaylist[index...])
|
|
991
|
-
print(
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1157
|
+
print(
|
|
1158
|
+
" 🔄 Creating gapless queue with \(tracksToPlay.count) tracks starting from index \(index)"
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
// Create gapless-optimized player items
|
|
1162
|
+
let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
|
|
1163
|
+
// First few items get preload treatment for faster playback
|
|
1164
|
+
let isPreload = offset < Constants.gaplessPreloadCount
|
|
1165
|
+
return self.createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
999
1166
|
}
|
|
1000
1167
|
|
|
1001
1168
|
guard let player = self.player, !items.isEmpty else {
|
|
@@ -1020,13 +1187,16 @@ class TrackPlayerCore: NSObject {
|
|
|
1020
1187
|
// Restore the full playlist reference (don't slice it!)
|
|
1021
1188
|
self.currentTracks = fullPlaylist
|
|
1022
1189
|
|
|
1023
|
-
print(" ✅
|
|
1190
|
+
print(" ✅ Gapless queue recreated. Now at index: \(self.currentTrackIndex)")
|
|
1024
1191
|
if let track = self.getCurrentTrack() {
|
|
1025
1192
|
print(" 🎵 Playing: \(track.title)")
|
|
1026
1193
|
self.onChangeTrack?(track, .skip)
|
|
1027
1194
|
self.mediaSessionManager?.onTrackChanged()
|
|
1028
1195
|
}
|
|
1029
1196
|
|
|
1197
|
+
// Start preloading upcoming tracks for gapless playback
|
|
1198
|
+
self.preloadUpcomingTracks(from: index + 1)
|
|
1199
|
+
|
|
1030
1200
|
player.play()
|
|
1031
1201
|
}
|
|
1032
1202
|
}
|
|
@@ -1036,6 +1206,9 @@ class TrackPlayerCore: NSObject {
|
|
|
1036
1206
|
deinit {
|
|
1037
1207
|
print("🧹 TrackPlayerCore: Cleaning up...")
|
|
1038
1208
|
|
|
1209
|
+
// Clear preloaded assets for gapless playback
|
|
1210
|
+
preloadedAssets.removeAll()
|
|
1211
|
+
|
|
1039
1212
|
// Remove boundary time observer
|
|
1040
1213
|
if let boundaryObserver = boundaryTimeObserver, let currentPlayer = player {
|
|
1041
1214
|
currentPlayer.removeTimeObserver(boundaryObserver)
|
package/lib/hooks/index.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { useOnSeek } from './useOnSeek';
|
|
|
4
4
|
export { useOnPlaybackProgressChange } from './useOnPlaybackProgressChange';
|
|
5
5
|
export { useAndroidAutoConnection } from './useAndroidAutoConnection';
|
|
6
6
|
export { useAudioDevices } from './useAudioDevices';
|
|
7
|
+
export { useNowPlaying } from './useNowPlaying';
|
package/lib/hooks/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { useOnSeek } from './useOnSeek';
|
|
|
4
4
|
export { useOnPlaybackProgressChange } from './useOnPlaybackProgressChange';
|
|
5
5
|
export { useAndroidAutoConnection } from './useAndroidAutoConnection';
|
|
6
6
|
export { useAudioDevices } from './useAudioDevices';
|
|
7
|
+
export { useNowPlaying } from './useNowPlaying';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PlayerState } from '../types/PlayerQueue';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to get the current player state (same as TrackPlayer.getState())
|
|
4
|
+
*
|
|
5
|
+
* This hook provides all player state information including:
|
|
6
|
+
* - Current track
|
|
7
|
+
* - Current position and duration
|
|
8
|
+
* - Playback state (playing, paused, stopped)
|
|
9
|
+
* - Current playlist ID
|
|
10
|
+
* - Current track index
|
|
11
|
+
*
|
|
12
|
+
* The hook polls getState() periodically and also listens to events
|
|
13
|
+
* for immediate updates when state changes.
|
|
14
|
+
*
|
|
15
|
+
* @returns PlayerState object with all current player information
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* function PlayerComponent() {
|
|
20
|
+
* const state = useNowPlaying()
|
|
21
|
+
*
|
|
22
|
+
* return (
|
|
23
|
+
* <View>
|
|
24
|
+
* {state.currentTrack && (
|
|
25
|
+
* <Text>Now Playing: {state.currentTrack.title}</Text>
|
|
26
|
+
* )}
|
|
27
|
+
* <Text>Position: {state.currentPosition} / {state.totalDuration}</Text>
|
|
28
|
+
* <Text>State: {state.currentState}</Text>
|
|
29
|
+
* <Text>Playlist: {state.currentPlaylistId || 'None'}</Text>
|
|
30
|
+
* <Text>Index: {state.currentIndex}</Text>
|
|
31
|
+
* </View>
|
|
32
|
+
* )
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function useNowPlaying(): PlayerState;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { TrackPlayer } from '../index';
|
|
3
|
+
/**
|
|
4
|
+
* Hook to get the current player state (same as TrackPlayer.getState())
|
|
5
|
+
*
|
|
6
|
+
* This hook provides all player state information including:
|
|
7
|
+
* - Current track
|
|
8
|
+
* - Current position and duration
|
|
9
|
+
* - Playback state (playing, paused, stopped)
|
|
10
|
+
* - Current playlist ID
|
|
11
|
+
* - Current track index
|
|
12
|
+
*
|
|
13
|
+
* The hook polls getState() periodically and also listens to events
|
|
14
|
+
* for immediate updates when state changes.
|
|
15
|
+
*
|
|
16
|
+
* @returns PlayerState object with all current player information
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* function PlayerComponent() {
|
|
21
|
+
* const state = useNowPlaying()
|
|
22
|
+
*
|
|
23
|
+
* return (
|
|
24
|
+
* <View>
|
|
25
|
+
* {state.currentTrack && (
|
|
26
|
+
* <Text>Now Playing: {state.currentTrack.title}</Text>
|
|
27
|
+
* )}
|
|
28
|
+
* <Text>Position: {state.currentPosition} / {state.totalDuration}</Text>
|
|
29
|
+
* <Text>State: {state.currentState}</Text>
|
|
30
|
+
* <Text>Playlist: {state.currentPlaylistId || 'None'}</Text>
|
|
31
|
+
* <Text>Index: {state.currentIndex}</Text>
|
|
32
|
+
* </View>
|
|
33
|
+
* )
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function useNowPlaying() {
|
|
38
|
+
const [state, setState] = useState(() => {
|
|
39
|
+
// Get initial state
|
|
40
|
+
try {
|
|
41
|
+
return TrackPlayer.getState();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error('Error getting initial player state:', error);
|
|
45
|
+
// Return default state
|
|
46
|
+
return {
|
|
47
|
+
currentTrack: null,
|
|
48
|
+
currentPosition: 0,
|
|
49
|
+
totalDuration: 0,
|
|
50
|
+
currentState: 'stopped',
|
|
51
|
+
currentPlaylistId: null,
|
|
52
|
+
currentIndex: -1,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
// Update state function
|
|
58
|
+
const updateState = () => {
|
|
59
|
+
try {
|
|
60
|
+
const newState = TrackPlayer.getState();
|
|
61
|
+
setState(newState);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.error('Error updating player state:', error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
// Get initial state
|
|
68
|
+
updateState();
|
|
69
|
+
// Listen to track changes
|
|
70
|
+
TrackPlayer.onChangeTrack(() => {
|
|
71
|
+
updateState();
|
|
72
|
+
});
|
|
73
|
+
// Listen to playback state changes
|
|
74
|
+
TrackPlayer.onPlaybackStateChange(() => {
|
|
75
|
+
updateState();
|
|
76
|
+
});
|
|
77
|
+
}, []);
|
|
78
|
+
return state;
|
|
79
|
+
}
|
|
@@ -38,4 +38,5 @@ export interface TrackPlayer extends HybridObject<{
|
|
|
38
38
|
onPlaybackProgressChange(callback: (position: number, totalDuration: number, isManuallySeeked?: boolean) => void): void;
|
|
39
39
|
onAndroidAutoConnectionChange(callback: (connected: boolean) => void): void;
|
|
40
40
|
isAndroidAutoConnected(): boolean;
|
|
41
|
+
setVolume(volume: number): boolean;
|
|
41
42
|
}
|
|
@@ -142,5 +142,10 @@ namespace margelo::nitro::nitroplayer {
|
|
|
142
142
|
auto __result = method(_javaPart);
|
|
143
143
|
return static_cast<bool>(__result);
|
|
144
144
|
}
|
|
145
|
+
bool JHybridTrackPlayerSpec::setVolume(double volume) {
|
|
146
|
+
static const auto method = javaClassStatic()->getMethod<jboolean(double /* volume */)>("setVolume");
|
|
147
|
+
auto __result = method(_javaPart, volume);
|
|
148
|
+
return static_cast<bool>(__result);
|
|
149
|
+
}
|
|
145
150
|
|
|
146
151
|
} // namespace margelo::nitro::nitroplayer
|
|
@@ -69,6 +69,7 @@ namespace margelo::nitro::nitroplayer {
|
|
|
69
69
|
void onPlaybackProgressChange(const std::function<void(double /* position */, double /* totalDuration */, std::optional<bool> /* isManuallySeeked */)>& callback) override;
|
|
70
70
|
void onAndroidAutoConnectionChange(const std::function<void(bool /* connected */)>& callback) override;
|
|
71
71
|
bool isAndroidAutoConnected() override;
|
|
72
|
+
bool setVolume(double volume) override;
|
|
72
73
|
|
|
73
74
|
private:
|
|
74
75
|
friend HybridBase;
|
package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroplayer/HybridTrackPlayerSpec.kt
CHANGED
|
@@ -129,6 +129,10 @@ abstract class HybridTrackPlayerSpec: HybridObject() {
|
|
|
129
129
|
@DoNotStrip
|
|
130
130
|
@Keep
|
|
131
131
|
abstract fun isAndroidAutoConnected(): Boolean
|
|
132
|
+
|
|
133
|
+
@DoNotStrip
|
|
134
|
+
@Keep
|
|
135
|
+
abstract fun setVolume(volume: Double): Boolean
|
|
132
136
|
|
|
133
137
|
private external fun initHybrid(): HybridData
|
|
134
138
|
|
|
@@ -177,6 +177,14 @@ namespace margelo::nitro::nitroplayer {
|
|
|
177
177
|
auto __value = std::move(__result.value());
|
|
178
178
|
return __value;
|
|
179
179
|
}
|
|
180
|
+
inline bool setVolume(double volume) override {
|
|
181
|
+
auto __result = _swiftPart.setVolume(std::forward<decltype(volume)>(volume));
|
|
182
|
+
if (__result.hasError()) [[unlikely]] {
|
|
183
|
+
std::rethrow_exception(__result.error());
|
|
184
|
+
}
|
|
185
|
+
auto __value = std::move(__result.value());
|
|
186
|
+
return __value;
|
|
187
|
+
}
|
|
180
188
|
|
|
181
189
|
private:
|
|
182
190
|
NitroPlayer::HybridTrackPlayerSpec_cxx _swiftPart;
|
|
@@ -29,6 +29,7 @@ public protocol HybridTrackPlayerSpec_protocol: HybridObject {
|
|
|
29
29
|
func onPlaybackProgressChange(callback: @escaping (_ position: Double, _ totalDuration: Double, _ isManuallySeeked: Bool?) -> Void) throws -> Void
|
|
30
30
|
func onAndroidAutoConnectionChange(callback: @escaping (_ connected: Bool) -> Void) throws -> Void
|
|
31
31
|
func isAndroidAutoConnected() throws -> Bool
|
|
32
|
+
func setVolume(volume: Double) throws -> Bool
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
public extension HybridTrackPlayerSpec_protocol {
|
|
@@ -334,4 +334,16 @@ open class HybridTrackPlayerSpec_cxx {
|
|
|
334
334
|
return bridge.create_Result_bool_(__exceptionPtr)
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
|
+
|
|
338
|
+
@inline(__always)
|
|
339
|
+
public final func setVolume(volume: Double) -> bridge.Result_bool_ {
|
|
340
|
+
do {
|
|
341
|
+
let __result = try self.__implementation.setVolume(volume: volume)
|
|
342
|
+
let __resultCpp = __result
|
|
343
|
+
return bridge.create_Result_bool_(__resultCpp)
|
|
344
|
+
} catch (let __error) {
|
|
345
|
+
let __exceptionPtr = __error.toCpp()
|
|
346
|
+
return bridge.create_Result_bool_(__exceptionPtr)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
337
349
|
}
|
|
@@ -29,6 +29,7 @@ namespace margelo::nitro::nitroplayer {
|
|
|
29
29
|
prototype.registerHybridMethod("onPlaybackProgressChange", &HybridTrackPlayerSpec::onPlaybackProgressChange);
|
|
30
30
|
prototype.registerHybridMethod("onAndroidAutoConnectionChange", &HybridTrackPlayerSpec::onAndroidAutoConnectionChange);
|
|
31
31
|
prototype.registerHybridMethod("isAndroidAutoConnected", &HybridTrackPlayerSpec::isAndroidAutoConnected);
|
|
32
|
+
prototype.registerHybridMethod("setVolume", &HybridTrackPlayerSpec::setVolume);
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -82,6 +82,7 @@ namespace margelo::nitro::nitroplayer {
|
|
|
82
82
|
virtual void onPlaybackProgressChange(const std::function<void(double /* position */, double /* totalDuration */, std::optional<bool> /* isManuallySeeked */)>& callback) = 0;
|
|
83
83
|
virtual void onAndroidAutoConnectionChange(const std::function<void(bool /* connected */)>& callback) = 0;
|
|
84
84
|
virtual bool isAndroidAutoConnected() = 0;
|
|
85
|
+
virtual bool setVolume(double volume) = 0;
|
|
85
86
|
|
|
86
87
|
protected:
|
|
87
88
|
// Hybrid Setup
|