react-native-nitro-player 0.3.0-alpha.5 → 0.3.0-alpha.7
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 +699 -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 +6 -0
- package/lib/hooks/index.js +6 -0
- package/lib/hooks/useAndroidAutoConnection.d.ts +13 -0
- package/lib/hooks/useAndroidAutoConnection.js +26 -0
- package/lib/hooks/useAudioDevices.d.ts +26 -0
- package/lib/hooks/useAudioDevices.js +55 -0
- package/lib/hooks/useOnChangeTrack.d.ts +9 -0
- package/lib/hooks/useOnChangeTrack.js +17 -0
- package/lib/hooks/useOnPlaybackProgressChange.d.ts +9 -0
- package/lib/hooks/useOnPlaybackProgressChange.js +19 -0
- package/lib/hooks/useOnPlaybackStateChange.d.ts +9 -0
- package/lib/hooks/useOnPlaybackStateChange.js +17 -0
- package/lib/hooks/useOnSeek.d.ts +8 -0
- package/lib/hooks/useOnSeek.js +17 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +24 -0
- package/lib/specs/AndroidAutoMediaLibrary.nitro.d.ts +21 -0
- package/lib/specs/AndroidAutoMediaLibrary.nitro.js +1 -0
- package/lib/specs/AudioDevices.nitro.d.ts +24 -0
- package/lib/specs/AudioDevices.nitro.js +1 -0
- package/lib/specs/AudioRoutePicker.nitro.d.ts +10 -0
- package/lib/specs/AudioRoutePicker.nitro.js +1 -0
- package/lib/specs/TrackPlayer.nitro.d.ts +42 -0
- package/lib/specs/TrackPlayer.nitro.js +1 -0
- package/lib/types/AndroidAutoMediaLibrary.d.ts +44 -0
- package/lib/types/AndroidAutoMediaLibrary.js +1 -0
- package/lib/types/PlayerQueue.d.ts +32 -0
- package/lib/types/PlayerQueue.js +1 -0
- package/lib/utils/androidAutoMediaLibrary.d.ts +47 -0
- package/lib/utils/androidAutoMediaLibrary.js +62 -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 +10 -9
- 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)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useOnChangeTrack } from './useOnChangeTrack';
|
|
2
|
+
export { useOnPlaybackStateChange } from './useOnPlaybackStateChange';
|
|
3
|
+
export { useOnSeek } from './useOnSeek';
|
|
4
|
+
export { useOnPlaybackProgressChange } from './useOnPlaybackProgressChange';
|
|
5
|
+
export { useAndroidAutoConnection } from './useAndroidAutoConnection';
|
|
6
|
+
export { useAudioDevices } from './useAudioDevices';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useOnChangeTrack } from './useOnChangeTrack';
|
|
2
|
+
export { useOnPlaybackStateChange } from './useOnPlaybackStateChange';
|
|
3
|
+
export { useOnSeek } from './useOnSeek';
|
|
4
|
+
export { useOnPlaybackProgressChange } from './useOnPlaybackProgressChange';
|
|
5
|
+
export { useAndroidAutoConnection } from './useAndroidAutoConnection';
|
|
6
|
+
export { useAudioDevices } from './useAudioDevices';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to detect Android Auto connection status using the official Android for Cars API
|
|
3
|
+
* Based on: https://developer.android.com/training/cars/apps#car-connection
|
|
4
|
+
*
|
|
5
|
+
* @returns Object with isConnected boolean
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { isConnected } = useAndroidAutoConnection();
|
|
9
|
+
* console.log('Android Auto connected:', isConnected);
|
|
10
|
+
*/
|
|
11
|
+
export declare function useAndroidAutoConnection(): {
|
|
12
|
+
isConnected: boolean;
|
|
13
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { TrackPlayer } from '../index';
|
|
3
|
+
/**
|
|
4
|
+
* Hook to detect Android Auto connection status using the official Android for Cars API
|
|
5
|
+
* Based on: https://developer.android.com/training/cars/apps#car-connection
|
|
6
|
+
*
|
|
7
|
+
* @returns Object with isConnected boolean
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const { isConnected } = useAndroidAutoConnection();
|
|
11
|
+
* console.log('Android Auto connected:', isConnected);
|
|
12
|
+
*/
|
|
13
|
+
export function useAndroidAutoConnection() {
|
|
14
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
// Set initial state
|
|
17
|
+
const initialState = TrackPlayer.isAndroidAutoConnected();
|
|
18
|
+
setIsConnected(initialState);
|
|
19
|
+
// Listen for connection changes
|
|
20
|
+
TrackPlayer.onAndroidAutoConnectionChange((connected) => {
|
|
21
|
+
setIsConnected(connected);
|
|
22
|
+
console.log('🚗 Android Auto connection changed:', connected);
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
return { isConnected };
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TAudioDevice } from '../specs/AudioDevices.nitro';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to get audio devices (Android only)
|
|
4
|
+
*
|
|
5
|
+
* Polls for device changes every 2 seconds
|
|
6
|
+
*
|
|
7
|
+
* @returns Object containing the current list of audio devices
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* function MyComponent() {
|
|
12
|
+
* const { devices } = useAudioDevices()
|
|
13
|
+
*
|
|
14
|
+
* return (
|
|
15
|
+
* <View>
|
|
16
|
+
* {devices.map(device => (
|
|
17
|
+
* <Text key={device.id}>{device.name}</Text>
|
|
18
|
+
* ))}
|
|
19
|
+
* </View>
|
|
20
|
+
* )
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function useAudioDevices(): {
|
|
25
|
+
devices: TAudioDevice[];
|
|
26
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
import { NitroModules } from 'react-native-nitro-modules';
|
|
4
|
+
/**
|
|
5
|
+
* Hook to get audio devices (Android only)
|
|
6
|
+
*
|
|
7
|
+
* Polls for device changes every 2 seconds
|
|
8
|
+
*
|
|
9
|
+
* @returns Object containing the current list of audio devices
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* function MyComponent() {
|
|
14
|
+
* const { devices } = useAudioDevices()
|
|
15
|
+
*
|
|
16
|
+
* return (
|
|
17
|
+
* <View>
|
|
18
|
+
* {devices.map(device => (
|
|
19
|
+
* <Text key={device.id}>{device.name}</Text>
|
|
20
|
+
* ))}
|
|
21
|
+
* </View>
|
|
22
|
+
* )
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function useAudioDevices() {
|
|
27
|
+
const [devices, setDevices] = useState([]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (Platform.OS !== 'android') {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const AudioDevices = NitroModules.createHybridObject('AudioDevices');
|
|
34
|
+
// Get initial devices
|
|
35
|
+
const updateDevices = () => {
|
|
36
|
+
try {
|
|
37
|
+
const currentDevices = AudioDevices.getAudioDevices();
|
|
38
|
+
setDevices(currentDevices);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.error('Error getting audio devices:', error);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
updateDevices();
|
|
45
|
+
// Poll for changes every 2 seconds
|
|
46
|
+
const interval = setInterval(updateDevices, 2000);
|
|
47
|
+
return () => clearInterval(interval);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error('Error setting up audio devices polling:', error);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}, []);
|
|
54
|
+
return { devices };
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TrackItem, Reason } from '../types/PlayerQueue';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to get the current track and track change reason
|
|
4
|
+
* @returns Object with current track and reason, or undefined if no track is playing
|
|
5
|
+
*/
|
|
6
|
+
export declare function useOnChangeTrack(): {
|
|
7
|
+
track: TrackItem | undefined;
|
|
8
|
+
reason: Reason | undefined;
|
|
9
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { TrackPlayer } from '../index';
|
|
3
|
+
/**
|
|
4
|
+
* Hook to get the current track and track change reason
|
|
5
|
+
* @returns Object with current track and reason, or undefined if no track is playing
|
|
6
|
+
*/
|
|
7
|
+
export function useOnChangeTrack() {
|
|
8
|
+
const [track, setTrack] = useState(undefined);
|
|
9
|
+
const [reason, setReason] = useState(undefined);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
TrackPlayer.onChangeTrack((newTrack, newReason) => {
|
|
12
|
+
setTrack(newTrack);
|
|
13
|
+
setReason(newReason);
|
|
14
|
+
});
|
|
15
|
+
}, []);
|
|
16
|
+
return { track, reason };
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to get the current playback progress
|
|
3
|
+
* @returns Object with current position, total duration, and manual seek indicator
|
|
4
|
+
*/
|
|
5
|
+
export declare function useOnPlaybackProgressChange(): {
|
|
6
|
+
position: number;
|
|
7
|
+
totalDuration: number;
|
|
8
|
+
isManuallySeeked: boolean | undefined;
|
|
9
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { TrackPlayer } from '../index';
|
|
3
|
+
/**
|
|
4
|
+
* Hook to get the current playback progress
|
|
5
|
+
* @returns Object with current position, total duration, and manual seek indicator
|
|
6
|
+
*/
|
|
7
|
+
export function useOnPlaybackProgressChange() {
|
|
8
|
+
const [position, setPosition] = useState(0);
|
|
9
|
+
const [totalDuration, setTotalDuration] = useState(0);
|
|
10
|
+
const [isManuallySeeked, setIsManuallySeeked] = useState(undefined);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
TrackPlayer.onPlaybackProgressChange((newPosition, newTotalDuration, newIsManuallySeeked) => {
|
|
13
|
+
setPosition(newPosition);
|
|
14
|
+
setTotalDuration(newTotalDuration);
|
|
15
|
+
setIsManuallySeeked(newIsManuallySeeked);
|
|
16
|
+
});
|
|
17
|
+
}, []);
|
|
18
|
+
return { position, totalDuration, isManuallySeeked };
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TrackPlayerState, Reason } from '../types/PlayerQueue';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to get the current playback state and reason
|
|
4
|
+
* @returns Object with current playback state and reason
|
|
5
|
+
*/
|
|
6
|
+
export declare function useOnPlaybackStateChange(): {
|
|
7
|
+
state: TrackPlayerState | undefined;
|
|
8
|
+
reason: Reason | undefined;
|
|
9
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { TrackPlayer } from '../index';
|
|
3
|
+
/**
|
|
4
|
+
* Hook to get the current playback state and reason
|
|
5
|
+
* @returns Object with current playback state and reason
|
|
6
|
+
*/
|
|
7
|
+
export function useOnPlaybackStateChange() {
|
|
8
|
+
const [state, setState] = useState(undefined);
|
|
9
|
+
const [reason, setReason] = useState(undefined);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
TrackPlayer.onPlaybackStateChange((newState, newReason) => {
|
|
12
|
+
setState(newState);
|
|
13
|
+
setReason(newReason);
|
|
14
|
+
});
|
|
15
|
+
}, []);
|
|
16
|
+
return { state, reason };
|
|
17
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to get the last seek event information
|
|
3
|
+
* @returns Object with last seek position and total duration, or undefined if no seek has occurred
|
|
4
|
+
*/
|
|
5
|
+
export declare function useOnSeek(): {
|
|
6
|
+
position: number | undefined;
|
|
7
|
+
totalDuration: number | undefined;
|
|
8
|
+
};
|