react-native-nitro-player 0.4.1-alpha.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadFileManager.kt +10 -7
- package/android/src/main/java/com/margelo/nitro/nitroplayer/download/DownloadWorker.kt +30 -3
- package/ios/core/TrackPlayerCore.swift +34 -25
- package/ios/download/DownloadFileManager.swift +12 -4
- package/ios/download/DownloadManagerCore.swift +6 -1
- package/ios/media/MediaSessionManager.swift +181 -108
- package/package.json +2 -2
|
@@ -50,6 +50,7 @@ class DownloadFileManager private constructor(
|
|
|
50
50
|
fun createDownloadFile(
|
|
51
51
|
trackId: String,
|
|
52
52
|
storageLocation: StorageLocation,
|
|
53
|
+
extension: String = "mp3",
|
|
53
54
|
): File {
|
|
54
55
|
val destinationDir =
|
|
55
56
|
when (storageLocation) {
|
|
@@ -58,7 +59,7 @@ class DownloadFileManager private constructor(
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// Create unique filename based on trackId
|
|
61
|
-
val fileName = "$trackId
|
|
62
|
+
val fileName = "$trackId.$extension"
|
|
62
63
|
return File(destinationDir, fileName)
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -119,15 +120,17 @@ class DownloadFileManager private constructor(
|
|
|
119
120
|
|
|
120
121
|
fun getLocalPath(trackId: String): String? {
|
|
121
122
|
// Check private directory first
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
privateDownloadsDir.listFiles()?.forEach { file ->
|
|
124
|
+
if (file.nameWithoutExtension == trackId) {
|
|
125
|
+
return file.absolutePath
|
|
126
|
+
}
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
// Check public directory
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
publicDownloadsDir.listFiles()?.forEach { file ->
|
|
131
|
+
if (file.nameWithoutExtension == trackId) {
|
|
132
|
+
return file.absolutePath
|
|
133
|
+
}
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
return null
|
|
@@ -8,6 +8,7 @@ import androidx.core.app.NotificationCompat
|
|
|
8
8
|
import androidx.work.CoroutineWorker
|
|
9
9
|
import androidx.work.ForegroundInfo
|
|
10
10
|
import androidx.work.WorkerParameters
|
|
11
|
+
import android.webkit.MimeTypeMap
|
|
11
12
|
import com.margelo.nitro.nitroplayer.*
|
|
12
13
|
import kotlinx.coroutines.Dispatchers
|
|
13
14
|
import kotlinx.coroutines.withContext
|
|
@@ -122,16 +123,42 @@ class DownloadWorker(
|
|
|
122
123
|
if (responseCode != HttpURLConnection.HTTP_OK) {
|
|
123
124
|
throw Exception("Server returned HTTP $responseCode")
|
|
124
125
|
}
|
|
126
|
+
// Determine extension
|
|
127
|
+
var extension = MimeTypeMap.getFileExtensionFromUrl(urlString)
|
|
128
|
+
|
|
129
|
+
// 1. Try Content-Disposition
|
|
130
|
+
if (extension.isNullOrEmpty()) {
|
|
131
|
+
val contentDisposition = connection.getHeaderField("Content-Disposition")
|
|
132
|
+
if (contentDisposition != null) {
|
|
133
|
+
val match = Regex("filename=\"?([^\";]+)\"?").find(contentDisposition)
|
|
134
|
+
if (match != null) {
|
|
135
|
+
val filename = match.groupValues[1]
|
|
136
|
+
extension = MimeTypeMap.getFileExtensionFromUrl(filename)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. Try Content-Type
|
|
142
|
+
if (extension.isNullOrEmpty()) {
|
|
143
|
+
val contentType = connection.contentType
|
|
144
|
+
if (contentType != null) {
|
|
145
|
+
val mimeType = contentType.split(";")[0].trim()
|
|
146
|
+
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
val finalExtension = if (extension.isNullOrEmpty()) "mp3" else extension
|
|
125
151
|
|
|
126
|
-
val totalBytes = connection.contentLengthLong
|
|
127
|
-
var bytesDownloaded: Long = 0
|
|
128
152
|
|
|
129
153
|
// Create destination file
|
|
130
|
-
val destinationFile = fileManager.createDownloadFile(trackId, storageLocation)
|
|
154
|
+
val destinationFile = fileManager.createDownloadFile(trackId, storageLocation, finalExtension)
|
|
131
155
|
|
|
132
156
|
inputStream = BufferedInputStream(connection.inputStream)
|
|
133
157
|
outputStream = FileOutputStream(destinationFile)
|
|
134
158
|
|
|
159
|
+
val totalBytes = connection.contentLengthLong
|
|
160
|
+
var bytesDownloaded: Long = 0
|
|
161
|
+
|
|
135
162
|
val buffer = ByteArray(BUFFER_SIZE)
|
|
136
163
|
var bytesRead: Int
|
|
137
164
|
var lastProgressUpdate = System.currentTimeMillis()
|
|
@@ -125,9 +125,10 @@ class TrackPlayerCore: NSObject {
|
|
|
125
125
|
|
|
126
126
|
// MARK: - Gapless Playback Configuration
|
|
127
127
|
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
128
|
+
// Start with stall-waiting enabled so the first track buffers before playing.
|
|
129
|
+
// Once the first item is ready (readyToPlay), this is flipped to false for
|
|
130
|
+
// gapless inter-track transitions (see setupCurrentItemObservers).
|
|
131
|
+
player?.automaticallyWaitsToMinimizeStalling = true
|
|
131
132
|
|
|
132
133
|
// Set playback rate to 1.0 immediately when ready (reduces gap between tracks)
|
|
133
134
|
player?.actionAtItemEnd = .advance
|
|
@@ -138,7 +139,7 @@ class TrackPlayerCore: NSObject {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
print(
|
|
141
|
-
"🎵 TrackPlayerCore: Gapless playback configured - automaticallyWaitsToMinimizeStalling=false")
|
|
142
|
+
"🎵 TrackPlayerCore: Gapless playback configured - automaticallyWaitsToMinimizeStalling=true (flipped to false on first readyToPlay)")
|
|
142
143
|
|
|
143
144
|
setupPlayerObservers()
|
|
144
145
|
}
|
|
@@ -569,6 +570,8 @@ class TrackPlayerCore: NSObject {
|
|
|
569
570
|
if item.status == .readyToPlay {
|
|
570
571
|
print("✅ TrackPlayerCore: Item ready, setting up boundaries")
|
|
571
572
|
self?.setupBoundaryTimeObserver()
|
|
573
|
+
// First item is buffered and ready — disable stall waiting for gapless inter-track transitions
|
|
574
|
+
self?.player?.automaticallyWaitsToMinimizeStalling = false
|
|
572
575
|
// Update now playing info now that duration is available
|
|
573
576
|
self?.mediaSessionManager?.updateNowPlayingInfo()
|
|
574
577
|
} else if item.status == .failed {
|
|
@@ -736,12 +739,11 @@ class TrackPlayerCore: NSObject {
|
|
|
736
739
|
asset = preloadedAsset
|
|
737
740
|
print("🚀 TrackPlayerCore: Using preloaded asset for \(track.title)")
|
|
738
741
|
} else {
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
])
|
|
742
|
+
// No AVURLAssetPreferPreciseDurationAndTimingKey — gapless playback is achieved via
|
|
743
|
+
// AVQueuePlayer's internal audio buffer pre-roll, not timing metadata.
|
|
744
|
+
// Precise timing only helps with accurate VBR duration display, at the cost of
|
|
745
|
+
// deep file scanning that delays readyToPlay.
|
|
746
|
+
asset = AVURLAsset(url: url)
|
|
745
747
|
}
|
|
746
748
|
|
|
747
749
|
let item = AVPlayerItem(asset: asset)
|
|
@@ -807,11 +809,7 @@ class TrackPlayerCore: NSObject {
|
|
|
807
809
|
|
|
808
810
|
guard let url = URL(string: track.url) else { continue }
|
|
809
811
|
|
|
810
|
-
let asset = AVURLAsset(
|
|
811
|
-
url: url,
|
|
812
|
-
options: [
|
|
813
|
-
AVURLAssetPreferPreciseDurationAndTimingKey: true
|
|
814
|
-
])
|
|
812
|
+
let asset = AVURLAsset(url: url)
|
|
815
813
|
|
|
816
814
|
// Preload essential keys for gapless playback
|
|
817
815
|
asset.loadValuesAsynchronously(forKeys: Constants.preloadAssetKeys) { [weak self] in
|
|
@@ -1000,7 +998,7 @@ class TrackPlayerCore: NSObject {
|
|
|
1000
998
|
print("📋 TrackPlayerCore: UPDATE PLAYER QUEUE - Received \(tracks.count) tracks")
|
|
1001
999
|
print(String(repeating: "=", count: Constants.separatorLineLength))
|
|
1002
1000
|
|
|
1003
|
-
|
|
1001
|
+
#if DEBUG
|
|
1004
1002
|
for (index, track) in tracks.enumerated() {
|
|
1005
1003
|
let isDownloaded = DownloadManagerCore.shared.isTrackDownloaded(trackId: track.id)
|
|
1006
1004
|
let downloadStatus = isDownloaded ? "📥 DOWNLOADED" : "🌐 REMOTE"
|
|
@@ -1013,6 +1011,7 @@ class TrackPlayerCore: NSObject {
|
|
|
1013
1011
|
}
|
|
1014
1012
|
}
|
|
1015
1013
|
print(String(repeating: "=", count: Constants.separatorLineLength) + "\n")
|
|
1014
|
+
#endif
|
|
1016
1015
|
|
|
1017
1016
|
// Store tracks for index tracking
|
|
1018
1017
|
currentTracks = tracks
|
|
@@ -1025,12 +1024,15 @@ class TrackPlayerCore: NSObject {
|
|
|
1025
1024
|
boundaryTimeObserver = nil
|
|
1026
1025
|
}
|
|
1027
1026
|
|
|
1027
|
+
// Re-enable stall waiting for the new first track so it buffers before playing.
|
|
1028
|
+
// Will be flipped back to false once the first item reaches readyToPlay.
|
|
1029
|
+
player?.automaticallyWaitsToMinimizeStalling = true
|
|
1030
|
+
|
|
1028
1031
|
// Clear old preloaded assets when loading new queue
|
|
1029
1032
|
preloadedAssets.removeAll()
|
|
1030
1033
|
|
|
1031
1034
|
// Create gapless-optimized AVPlayerItems from tracks
|
|
1032
1035
|
let items = tracks.enumerated().compactMap { (index, track) -> AVPlayerItem? in
|
|
1033
|
-
// First few items get preload treatment for faster initial playback
|
|
1034
1036
|
let isPreload = index < Constants.gaplessPreloadCount
|
|
1035
1037
|
return createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
1036
1038
|
}
|
|
@@ -1068,23 +1070,25 @@ class TrackPlayerCore: NSObject {
|
|
|
1068
1070
|
}
|
|
1069
1071
|
}
|
|
1070
1072
|
|
|
1071
|
-
|
|
1073
|
+
#if DEBUG
|
|
1074
|
+
let trackById = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
|
|
1072
1075
|
print(
|
|
1073
1076
|
"\n🔍 TrackPlayerCore: VERIFICATION - Player now has \(existingPlayer.items().count) items:")
|
|
1074
1077
|
for (index, item) in existingPlayer.items().enumerated() {
|
|
1075
|
-
if let trackId = item.trackId, let track =
|
|
1078
|
+
if let trackId = item.trackId, let track = trackById[trackId] {
|
|
1076
1079
|
print(" [\(index + 1)] ✓ \(track.title) - \(track.artist) (ID: \(track.id))")
|
|
1077
1080
|
} else {
|
|
1078
1081
|
print(" [\(index + 1)] ⚠️ Unknown item (no trackId)")
|
|
1079
1082
|
}
|
|
1080
1083
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1084
|
+
if let currentItem = existingPlayer.currentItem,
|
|
1085
|
+
let trackId = currentItem.trackId,
|
|
1086
|
+
let track = trackById[trackId]
|
|
1087
|
+
{
|
|
1088
|
+
print("▶️ Current item: \(track.title)")
|
|
1086
1089
|
}
|
|
1087
1090
|
print(String(repeating: "=", count: Constants.separatorLineLength) + "\n")
|
|
1091
|
+
#endif
|
|
1088
1092
|
|
|
1089
1093
|
// Note: Boundary time observers will be set up automatically when item becomes ready
|
|
1090
1094
|
// This happens in setupCurrentItemObservers() -> status observer -> setupBoundaryTimeObserver()
|
|
@@ -1715,7 +1719,6 @@ class TrackPlayerCore: NSObject {
|
|
|
1715
1719
|
|
|
1716
1720
|
// Create gapless-optimized player items
|
|
1717
1721
|
let items = tracksToPlay.enumerated().compactMap { (offset, track) -> AVPlayerItem? in
|
|
1718
|
-
// First few items get preload treatment for faster playback
|
|
1719
1722
|
let isPreload = offset < Constants.gaplessPreloadCount
|
|
1720
1723
|
return self.createGaplessPlayerItem(for: track, isPreload: isPreload)
|
|
1721
1724
|
}
|
|
@@ -1731,6 +1734,10 @@ class TrackPlayerCore: NSObject {
|
|
|
1731
1734
|
self.boundaryTimeObserver = nil
|
|
1732
1735
|
}
|
|
1733
1736
|
|
|
1737
|
+
// Re-enable stall waiting for the new first track so it buffers before playing.
|
|
1738
|
+
// Will be flipped back to false once the first item reaches readyToPlay.
|
|
1739
|
+
player.automaticallyWaitsToMinimizeStalling = true
|
|
1740
|
+
|
|
1734
1741
|
// Clear and rebuild queue
|
|
1735
1742
|
player.removeAllItems()
|
|
1736
1743
|
var lastItem: AVPlayerItem? = nil
|
|
@@ -1785,6 +1792,7 @@ class TrackPlayerCore: NSObject {
|
|
|
1785
1792
|
if self.player?.currentItem != nil {
|
|
1786
1793
|
self.rebuildAVQueueFromCurrentPosition()
|
|
1787
1794
|
}
|
|
1795
|
+
mediaSessionManager?.onQueueChanged()
|
|
1788
1796
|
}
|
|
1789
1797
|
|
|
1790
1798
|
/**
|
|
@@ -1814,6 +1822,7 @@ class TrackPlayerCore: NSObject {
|
|
|
1814
1822
|
if self.player?.currentItem != nil {
|
|
1815
1823
|
self.rebuildAVQueueFromCurrentPosition()
|
|
1816
1824
|
}
|
|
1825
|
+
mediaSessionManager?.onQueueChanged()
|
|
1817
1826
|
}
|
|
1818
1827
|
|
|
1819
1828
|
/**
|
|
@@ -56,20 +56,28 @@ final class DownloadFileManager {
|
|
|
56
56
|
|
|
57
57
|
func saveDownloadedFile(
|
|
58
58
|
from temporaryLocation: URL, trackId: String, storageLocation: StorageLocation,
|
|
59
|
-
originalURL: String? = nil
|
|
59
|
+
originalURL: String? = nil,
|
|
60
|
+
suggestedFilename: String? = nil
|
|
60
61
|
) -> String? {
|
|
61
62
|
print("🎯 DownloadFileManager: saveDownloadedFile called for trackId=\(trackId)")
|
|
62
63
|
print(" From: \(temporaryLocation.path)")
|
|
63
64
|
print(" Original URL: \(originalURL ?? "nil")")
|
|
65
|
+
print(" Suggested Filename: \(suggestedFilename ?? "nil")")
|
|
64
66
|
|
|
65
67
|
let destinationDirectory =
|
|
66
68
|
storageLocation == .private ? privateDownloadsDirectory : publicDownloadsDirectory
|
|
67
69
|
print(" Destination directory: \(destinationDirectory.path)")
|
|
68
70
|
|
|
69
|
-
// Determine file extension
|
|
70
|
-
// The temp file has .tmp extension which AVPlayer cannot play
|
|
71
|
+
// Determine file extension
|
|
71
72
|
var fileExtension = "mp3" // Default fallback
|
|
72
|
-
|
|
73
|
+
|
|
74
|
+
if let suggestedFilename = suggestedFilename, !suggestedFilename.isEmpty {
|
|
75
|
+
let url = URL(fileURLWithPath: suggestedFilename)
|
|
76
|
+
let pathExtension = url.pathExtension.lowercased()
|
|
77
|
+
if !pathExtension.isEmpty {
|
|
78
|
+
fileExtension = pathExtension
|
|
79
|
+
}
|
|
80
|
+
} else if let originalURL = originalURL, let url = URL(string: originalURL) {
|
|
73
81
|
let pathExtension = url.pathExtension.lowercased()
|
|
74
82
|
if !pathExtension.isEmpty {
|
|
75
83
|
fileExtension = pathExtension
|
|
@@ -681,11 +681,16 @@ extension DownloadManagerCore: URLSessionDownloadDelegate {
|
|
|
681
681
|
let (storageLocation, originalURL) = queue.sync {
|
|
682
682
|
(self.config.storageLocation ?? .private, self.trackMetadata[trackId]?.url)
|
|
683
683
|
}
|
|
684
|
+
|
|
685
|
+
// Get suggested filename from response
|
|
686
|
+
let suggestedFilename = downloadTask.response?.suggestedFilename
|
|
687
|
+
|
|
684
688
|
let destinationPath = DownloadFileManager.shared.saveDownloadedFile(
|
|
685
689
|
from: location,
|
|
686
690
|
trackId: trackId,
|
|
687
691
|
storageLocation: storageLocation,
|
|
688
|
-
originalURL: originalURL
|
|
692
|
+
originalURL: originalURL,
|
|
693
|
+
suggestedFilename: suggestedFilename
|
|
689
694
|
)
|
|
690
695
|
|
|
691
696
|
// Now handle the rest asynchronously
|
|
@@ -15,10 +15,6 @@ class MediaSessionManager {
|
|
|
15
15
|
// MARK: - Constants
|
|
16
16
|
|
|
17
17
|
private enum Constants {
|
|
18
|
-
// Seek intervals (in seconds)
|
|
19
|
-
static let seekInterval: Double = 10.0
|
|
20
|
-
|
|
21
|
-
// Artwork size
|
|
22
18
|
static let artworkSize: CGFloat = 500.0
|
|
23
19
|
}
|
|
24
20
|
|
|
@@ -27,10 +23,11 @@ class MediaSessionManager {
|
|
|
27
23
|
private var trackPlayerCore: TrackPlayerCore?
|
|
28
24
|
private var artworkCache: [String: UIImage] = [:]
|
|
29
25
|
|
|
30
|
-
private var androidAutoEnabled: Bool = false
|
|
31
|
-
private var carPlayEnabled: Bool = false
|
|
32
26
|
private var showInNotification: Bool = true
|
|
33
27
|
|
|
28
|
+
// Tracks the artwork URL currently shown so we can discard stale async loads
|
|
29
|
+
private var lastArtworkUrl: String?
|
|
30
|
+
|
|
34
31
|
init() {
|
|
35
32
|
setupRemoteCommandCenter()
|
|
36
33
|
}
|
|
@@ -44,47 +41,165 @@ class MediaSessionManager {
|
|
|
44
41
|
carPlayEnabled: Bool?,
|
|
45
42
|
showInNotification: Bool?
|
|
46
43
|
) {
|
|
47
|
-
if let androidAutoEnabled = androidAutoEnabled {
|
|
48
|
-
self.androidAutoEnabled = androidAutoEnabled
|
|
49
|
-
}
|
|
50
|
-
if let carPlayEnabled = carPlayEnabled {
|
|
51
|
-
self.carPlayEnabled = carPlayEnabled
|
|
52
|
-
// CarPlay is handled by the app's CarPlaySceneDelegate
|
|
53
|
-
// We just maintain the flag here for reference
|
|
54
|
-
}
|
|
55
44
|
if let showInNotification = showInNotification {
|
|
56
45
|
self.showInNotification = showInNotification
|
|
57
|
-
|
|
58
|
-
|
|
46
|
+
}
|
|
47
|
+
refresh()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - Single refresh entry point
|
|
51
|
+
//
|
|
52
|
+
// All public callbacks route here. Always dispatches to main thread so
|
|
53
|
+
// MPNowPlayingInfoCenter and MPRemoteCommandCenter are only touched from main.
|
|
54
|
+
|
|
55
|
+
func refresh() {
|
|
56
|
+
if Thread.isMainThread {
|
|
57
|
+
refreshInternal()
|
|
58
|
+
} else {
|
|
59
|
+
DispatchQueue.main.async { [weak self] in
|
|
60
|
+
self?.refreshInternal()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Convenience aliases used by TrackPlayerCore call sites
|
|
66
|
+
func updateNowPlayingInfo() { refresh() }
|
|
67
|
+
func onTrackChanged() { refresh() }
|
|
68
|
+
func onPlaybackStateChanged() { refresh() }
|
|
69
|
+
func onQueueChanged() { refresh() }
|
|
70
|
+
|
|
71
|
+
// MARK: - Core internal update (main thread only)
|
|
72
|
+
|
|
73
|
+
private func refreshInternal() {
|
|
74
|
+
guard showInNotification else {
|
|
75
|
+
clearNowPlayingInfo()
|
|
76
|
+
disableAllCommands()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
guard let core = trackPlayerCore,
|
|
81
|
+
let track = core.getCurrentTrack()
|
|
82
|
+
else {
|
|
83
|
+
clearNowPlayingInfo()
|
|
84
|
+
disableAllCommands()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fetch snapshot once — both calls are cheap on main thread (no sync overhead)
|
|
89
|
+
let state = core.getState()
|
|
90
|
+
let queue = core.getActualQueue()
|
|
91
|
+
|
|
92
|
+
// Find the actual position of the current track inside the actual queue.
|
|
93
|
+
// state.currentIndex is the original-playlist index which is wrong when a
|
|
94
|
+
// temp (playNext / upNext) track is playing.
|
|
95
|
+
let positionInQueue = queue.firstIndex(where: { $0.id == track.id }) ?? -1
|
|
96
|
+
|
|
97
|
+
updateNowPlayingInfoInternal(track: track, state: state, queue: queue, positionInQueue: positionInQueue)
|
|
98
|
+
updateCommandCenterState(state: state, queue: queue, positionInQueue: positionInQueue)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// MARK: - Now Playing Info
|
|
102
|
+
|
|
103
|
+
private func updateNowPlayingInfoInternal(
|
|
104
|
+
track: TrackItem,
|
|
105
|
+
state: PlayerState,
|
|
106
|
+
queue: [TrackItem],
|
|
107
|
+
positionInQueue: Int
|
|
108
|
+
) {
|
|
109
|
+
let playerDuration = state.totalDuration
|
|
110
|
+
let effectiveDuration: Double
|
|
111
|
+
if playerDuration > 0 && !playerDuration.isNaN && !playerDuration.isInfinite {
|
|
112
|
+
effectiveDuration = playerDuration
|
|
113
|
+
} else if track.duration > 0 {
|
|
114
|
+
effectiveDuration = track.duration
|
|
115
|
+
} else {
|
|
116
|
+
effectiveDuration = 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let currentPosition = state.currentPosition
|
|
120
|
+
let safePosition = currentPosition.isNaN || currentPosition.isInfinite ? 0 : currentPosition
|
|
121
|
+
let isPlaying = state.currentState == .playing
|
|
122
|
+
|
|
123
|
+
var nowPlayingInfo: [String: Any] = [
|
|
124
|
+
MPMediaItemPropertyTitle: track.title,
|
|
125
|
+
MPMediaItemPropertyArtist: track.artist,
|
|
126
|
+
MPMediaItemPropertyAlbumTitle: track.album,
|
|
127
|
+
MPNowPlayingInfoPropertyElapsedPlaybackTime: safePosition,
|
|
128
|
+
MPMediaItemPropertyPlaybackDuration: effectiveDuration,
|
|
129
|
+
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0,
|
|
130
|
+
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
|
|
131
|
+
MPNowPlayingInfoPropertyPlaybackQueueCount: max(1, queue.count),
|
|
132
|
+
MPNowPlayingInfoPropertyPlaybackQueueIndex: max(0, positionInQueue),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
// Artwork: use cache synchronously when available, otherwise kick off async load
|
|
136
|
+
if let artwork = track.artwork, case .second(let artworkUrl) = artwork {
|
|
137
|
+
lastArtworkUrl = artworkUrl
|
|
138
|
+
if let cachedImage = artworkCache[artworkUrl] {
|
|
139
|
+
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
|
|
140
|
+
boundsSize: CGSize(width: Constants.artworkSize, height: Constants.artworkSize),
|
|
141
|
+
requestHandler: { _ in cachedImage }
|
|
142
|
+
)
|
|
59
143
|
} else {
|
|
60
|
-
|
|
144
|
+
// Write info first without artwork, then patch it in when loaded
|
|
145
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
146
|
+
loadArtwork(url: artworkUrl) { [weak self] image in
|
|
147
|
+
guard let self = self, let image = image else { return }
|
|
148
|
+
// Discard if track changed while loading
|
|
149
|
+
guard self.lastArtworkUrl == artworkUrl else { return }
|
|
150
|
+
var updated = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
|
|
151
|
+
updated[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
|
|
152
|
+
boundsSize: CGSize(width: Constants.artworkSize, height: Constants.artworkSize),
|
|
153
|
+
requestHandler: { _ in image }
|
|
154
|
+
)
|
|
155
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = updated
|
|
156
|
+
}
|
|
157
|
+
return
|
|
61
158
|
}
|
|
159
|
+
} else {
|
|
160
|
+
lastArtworkUrl = nil
|
|
62
161
|
}
|
|
162
|
+
|
|
163
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
63
164
|
}
|
|
64
165
|
|
|
166
|
+
// MARK: - Command Center State
|
|
167
|
+
|
|
65
168
|
private func setupRemoteCommandCenter() {
|
|
66
169
|
let commandCenter = MPRemoteCommandCenter.shared()
|
|
67
170
|
|
|
68
|
-
//
|
|
171
|
+
// Clear any previously registered targets before adding fresh ones.
|
|
172
|
+
// Prevents duplicate handlers if this were ever called more than once.
|
|
173
|
+
commandCenter.playCommand.removeTarget(nil)
|
|
174
|
+
commandCenter.pauseCommand.removeTarget(nil)
|
|
175
|
+
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
|
176
|
+
commandCenter.nextTrackCommand.removeTarget(nil)
|
|
177
|
+
commandCenter.previousTrackCommand.removeTarget(nil)
|
|
178
|
+
commandCenter.seekForwardCommand.removeTarget(nil)
|
|
179
|
+
commandCenter.seekBackwardCommand.removeTarget(nil)
|
|
180
|
+
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
|
181
|
+
|
|
182
|
+
// Play
|
|
69
183
|
commandCenter.playCommand.isEnabled = true
|
|
70
184
|
commandCenter.playCommand.addTarget { [weak self] _ in
|
|
71
|
-
self?.trackPlayerCore
|
|
185
|
+
guard let core = self?.trackPlayerCore else { return .commandFailed }
|
|
186
|
+
core.play()
|
|
72
187
|
return .success
|
|
73
188
|
}
|
|
74
189
|
|
|
75
|
-
// Pause
|
|
190
|
+
// Pause
|
|
76
191
|
commandCenter.pauseCommand.isEnabled = true
|
|
77
192
|
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
|
78
|
-
self?.trackPlayerCore
|
|
193
|
+
guard let core = self?.trackPlayerCore else { return .commandFailed }
|
|
194
|
+
core.pause()
|
|
79
195
|
return .success
|
|
80
196
|
}
|
|
81
197
|
|
|
82
198
|
// Toggle play/pause
|
|
83
199
|
commandCenter.togglePlayPauseCommand.isEnabled = true
|
|
84
200
|
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
|
85
|
-
guard let
|
|
86
|
-
|
|
87
|
-
if state.currentState == .playing {
|
|
201
|
+
guard let core = self?.trackPlayerCore else { return .commandFailed }
|
|
202
|
+
if core.getState().currentState == .playing {
|
|
88
203
|
core.pause()
|
|
89
204
|
} else {
|
|
90
205
|
core.play()
|
|
@@ -92,27 +207,28 @@ class MediaSessionManager {
|
|
|
92
207
|
return .success
|
|
93
208
|
}
|
|
94
209
|
|
|
95
|
-
// Next track
|
|
96
|
-
commandCenter.nextTrackCommand.isEnabled =
|
|
210
|
+
// Next track — isEnabled managed dynamically in updateCommandCenterState
|
|
211
|
+
commandCenter.nextTrackCommand.isEnabled = false
|
|
97
212
|
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
|
|
98
|
-
self?.trackPlayerCore
|
|
213
|
+
guard let core = self?.trackPlayerCore else { return .commandFailed }
|
|
214
|
+
core.skipToNext()
|
|
99
215
|
return .success
|
|
100
216
|
}
|
|
101
217
|
|
|
102
|
-
// Previous track
|
|
103
|
-
commandCenter.previousTrackCommand.isEnabled =
|
|
218
|
+
// Previous track — isEnabled managed dynamically in updateCommandCenterState
|
|
219
|
+
commandCenter.previousTrackCommand.isEnabled = false
|
|
104
220
|
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
|
|
105
|
-
self?.trackPlayerCore
|
|
221
|
+
guard let core = self?.trackPlayerCore else { return .commandFailed }
|
|
222
|
+
core.skipToPrevious()
|
|
106
223
|
return .success
|
|
107
224
|
}
|
|
108
225
|
|
|
109
|
-
// Disable
|
|
110
|
-
// with non-interactive forward/backward buttons on the lock screen
|
|
226
|
+
// Disable skip-forward/backward — these replace the scrubber with non-interactive buttons
|
|
111
227
|
commandCenter.seekForwardCommand.isEnabled = false
|
|
112
228
|
commandCenter.seekBackwardCommand.isEnabled = false
|
|
113
229
|
|
|
114
|
-
//
|
|
115
|
-
commandCenter.changePlaybackPositionCommand.isEnabled =
|
|
230
|
+
// Scrubber — isEnabled managed dynamically based on known duration
|
|
231
|
+
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
|
116
232
|
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
|
117
233
|
guard let self = self,
|
|
118
234
|
let core = self.trackPlayerCore,
|
|
@@ -120,11 +236,10 @@ class MediaSessionManager {
|
|
|
120
236
|
else {
|
|
121
237
|
return .commandFailed
|
|
122
238
|
}
|
|
123
|
-
//
|
|
124
|
-
//
|
|
239
|
+
// Optimistically freeze the scrubber at the tapped position while the async
|
|
240
|
+
// seek is in flight — updateNowPlayingInfo in the seek completion restores it.
|
|
125
241
|
if var info = MPNowPlayingInfoCenter.default().nowPlayingInfo {
|
|
126
242
|
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = positionEvent.positionTime
|
|
127
|
-
// Set rate to 0 to pause scrubber animation during seek
|
|
128
243
|
info[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
|
|
129
244
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
130
245
|
}
|
|
@@ -133,73 +248,43 @@ class MediaSessionManager {
|
|
|
133
248
|
}
|
|
134
249
|
}
|
|
135
250
|
|
|
136
|
-
private func
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let core = trackPlayerCore
|
|
145
|
-
else {
|
|
146
|
-
clearNowPlayingInfo()
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
let state = core.getState()
|
|
251
|
+
private func updateCommandCenterState(
|
|
252
|
+
state: PlayerState,
|
|
253
|
+
queue: [TrackItem],
|
|
254
|
+
positionInQueue: Int
|
|
255
|
+
) {
|
|
256
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
257
|
+
let hasCurrentTrack = positionInQueue >= 0
|
|
258
|
+
let isNotLast = positionInQueue < queue.count - 1
|
|
151
259
|
|
|
152
|
-
// Use player duration if valid, otherwise fall back to track metadata duration.
|
|
153
|
-
// Duration must always be present for the lock screen scrubber to be interactive.
|
|
154
260
|
let playerDuration = state.totalDuration
|
|
155
|
-
let
|
|
156
|
-
if playerDuration > 0 && !playerDuration.isNaN && !playerDuration.isInfinite {
|
|
157
|
-
effectiveDuration = playerDuration
|
|
158
|
-
} else {
|
|
159
|
-
effectiveDuration = track.duration
|
|
160
|
-
}
|
|
261
|
+
let hasDuration = playerDuration > 0 && !playerDuration.isNaN && !playerDuration.isInfinite
|
|
161
262
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
MPMediaItemPropertyArtist: track.artist,
|
|
165
|
-
MPMediaItemPropertyAlbumTitle: track.album,
|
|
166
|
-
MPNowPlayingInfoPropertyElapsedPlaybackTime: state.currentPosition,
|
|
167
|
-
MPMediaItemPropertyPlaybackDuration: effectiveDuration,
|
|
168
|
-
MPNowPlayingInfoPropertyPlaybackRate: state.currentState == .playing ? 1.0 : 0.0,
|
|
169
|
-
]
|
|
263
|
+
// Next: only enabled when there is a track after the current one
|
|
264
|
+
commandCenter.nextTrackCommand.isEnabled = hasCurrentTrack && isNotLast
|
|
170
265
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
if let cachedImage = artworkCache[artworkUrl] {
|
|
174
|
-
// Artwork is cached - include it directly to avoid overwrite race condition
|
|
175
|
-
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
|
|
176
|
-
boundsSize: CGSize(width: Constants.artworkSize, height: Constants.artworkSize),
|
|
177
|
-
requestHandler: { _ in cachedImage }
|
|
178
|
-
)
|
|
179
|
-
} else {
|
|
180
|
-
// Artwork not cached - load asynchronously and update later
|
|
181
|
-
loadArtwork(url: artworkUrl) { [weak self] image in
|
|
182
|
-
guard let self = self, let image = image else { return }
|
|
183
|
-
// Re-read current nowPlayingInfo to avoid overwriting other updates
|
|
184
|
-
var updatedInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
|
|
185
|
-
updatedInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(
|
|
186
|
-
boundsSize: CGSize(width: Constants.artworkSize, height: Constants.artworkSize),
|
|
187
|
-
requestHandler: { _ in image }
|
|
188
|
-
)
|
|
189
|
-
MPNowPlayingInfoCenter.default().nowPlayingInfo = updatedInfo
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
266
|
+
// Previous: always enabled when something is playing — either restarts current or goes back
|
|
267
|
+
commandCenter.previousTrackCommand.isEnabled = hasCurrentTrack
|
|
193
268
|
|
|
194
|
-
|
|
269
|
+
// Scrubber: only enabled when we have a known, finite duration
|
|
270
|
+
commandCenter.changePlaybackPositionCommand.isEnabled = hasCurrentTrack && hasDuration
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func disableAllCommands() {
|
|
274
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
275
|
+
commandCenter.nextTrackCommand.isEnabled = false
|
|
276
|
+
commandCenter.previousTrackCommand.isEnabled = false
|
|
277
|
+
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
|
195
278
|
}
|
|
196
279
|
|
|
280
|
+
// MARK: - Helpers
|
|
281
|
+
|
|
197
282
|
private func clearNowPlayingInfo() {
|
|
198
283
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
284
|
+
lastArtworkUrl = nil
|
|
199
285
|
}
|
|
200
286
|
|
|
201
287
|
private func loadArtwork(url: String, completion: @escaping (UIImage?) -> Void) {
|
|
202
|
-
// Check cache first
|
|
203
288
|
if let cached = artworkCache[url] {
|
|
204
289
|
completion(cached)
|
|
205
290
|
return
|
|
@@ -210,33 +295,21 @@ class MediaSessionManager {
|
|
|
210
295
|
return
|
|
211
296
|
}
|
|
212
297
|
|
|
213
|
-
// Load image asynchronously
|
|
214
298
|
URLSession.shared.dataTask(with: imageUrl) { [weak self] data, _, _ in
|
|
215
|
-
guard let data = data,
|
|
216
|
-
|
|
217
|
-
else {
|
|
218
|
-
completion(nil)
|
|
299
|
+
guard let data = data, let image = UIImage(data: data) else {
|
|
300
|
+
DispatchQueue.main.async { completion(nil) }
|
|
219
301
|
return
|
|
220
302
|
}
|
|
221
|
-
|
|
222
|
-
// Cache the image
|
|
223
|
-
self?.artworkCache[url] = image
|
|
224
303
|
DispatchQueue.main.async {
|
|
304
|
+
self?.artworkCache[url] = image
|
|
225
305
|
completion(image)
|
|
226
306
|
}
|
|
227
307
|
}.resume()
|
|
228
308
|
}
|
|
229
309
|
|
|
230
|
-
func onTrackChanged() {
|
|
231
|
-
updateNowPlayingInfo()
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
func onPlaybackStateChanged() {
|
|
235
|
-
updateNowPlayingInfo()
|
|
236
|
-
}
|
|
237
|
-
|
|
238
310
|
func release() {
|
|
239
311
|
clearNowPlayingInfo()
|
|
312
|
+
disableAllCommands()
|
|
240
313
|
artworkCache.removeAll()
|
|
241
314
|
}
|
|
242
315
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-nitro-player",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "A powerful audio player library for React Native with playlist management, playback controls, and support for Android Auto and CarPlay",
|
|
5
5
|
"main": "lib/index",
|
|
6
6
|
"module": "lib/index",
|
|
7
7
|
"types": "lib/index.d.ts",
|