react-native-nitro-player 0.4.1-alpha.0 → 0.5.0
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 +2 -0
- 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()
|
|
@@ -1785,6 +1785,7 @@ class TrackPlayerCore: NSObject {
|
|
|
1785
1785
|
if self.player?.currentItem != nil {
|
|
1786
1786
|
self.rebuildAVQueueFromCurrentPosition()
|
|
1787
1787
|
}
|
|
1788
|
+
mediaSessionManager?.onQueueChanged()
|
|
1788
1789
|
}
|
|
1789
1790
|
|
|
1790
1791
|
/**
|
|
@@ -1814,6 +1815,7 @@ class TrackPlayerCore: NSObject {
|
|
|
1814
1815
|
if self.player?.currentItem != nil {
|
|
1815
1816
|
self.rebuildAVQueueFromCurrentPosition()
|
|
1816
1817
|
}
|
|
1818
|
+
mediaSessionManager?.onQueueChanged()
|
|
1817
1819
|
}
|
|
1818
1820
|
|
|
1819
1821
|
/**
|
|
@@ -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.0",
|
|
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",
|