react-native-nitro-player 0.4.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/core/TrackPlayerCore.kt +89 -115
- 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 +101 -113
- package/ios/download/DownloadFileManager.swift +12 -4
- package/ios/download/DownloadManagerCore.swift +6 -1
- package/ios/media/MediaSessionManager.swift +181 -108
- package/lib/hooks/callbackManager.d.ts +18 -0
- package/lib/hooks/callbackManager.js +66 -0
- package/lib/hooks/useNowPlaying.js +30 -18
- package/lib/hooks/useOnPlaybackProgressChange.js +2 -2
- package/package.json +2 -2
- package/src/hooks/callbackManager.ts +87 -0
- package/src/hooks/useNowPlaying.ts +31 -19
- package/src/hooks/useOnPlaybackProgressChange.ts +2 -2
|
@@ -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
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { TrackItem, TrackPlayerState, Reason } from '../types/PlayerQueue';
|
|
2
2
|
type PlaybackStateCallback = (state: TrackPlayerState, reason?: Reason) => void;
|
|
3
3
|
type TrackChangeCallback = (track: TrackItem, reason?: Reason) => void;
|
|
4
|
+
type PlaybackProgressCallback = (position: number, totalDuration: number, isManuallySeeked?: boolean) => void;
|
|
5
|
+
type SeekCallback = (position: number, totalDuration: number) => void;
|
|
4
6
|
/**
|
|
5
7
|
* Internal subscription manager that allows multiple hooks to subscribe
|
|
6
8
|
* to a single native callback. This solves the problem where registering
|
|
@@ -9,8 +11,12 @@ type TrackChangeCallback = (track: TrackItem, reason?: Reason) => void;
|
|
|
9
11
|
declare class CallbackSubscriptionManager {
|
|
10
12
|
private playbackStateSubscribers;
|
|
11
13
|
private trackChangeSubscribers;
|
|
14
|
+
private playbackProgressSubscribers;
|
|
15
|
+
private seekSubscribers;
|
|
12
16
|
private isPlaybackStateRegistered;
|
|
13
17
|
private isTrackChangeRegistered;
|
|
18
|
+
private isPlaybackProgressRegistered;
|
|
19
|
+
private isSeekRegistered;
|
|
14
20
|
/**
|
|
15
21
|
* Subscribe to playback state changes
|
|
16
22
|
* @returns Unsubscribe function
|
|
@@ -23,6 +29,18 @@ declare class CallbackSubscriptionManager {
|
|
|
23
29
|
subscribeToTrackChange(callback: TrackChangeCallback): () => void;
|
|
24
30
|
private ensurePlaybackStateRegistered;
|
|
25
31
|
private ensureTrackChangeRegistered;
|
|
32
|
+
/**
|
|
33
|
+
* Subscribe to playback progress changes
|
|
34
|
+
* @returns Unsubscribe function
|
|
35
|
+
*/
|
|
36
|
+
subscribeToPlaybackProgressChange(callback: PlaybackProgressCallback): () => void;
|
|
37
|
+
/**
|
|
38
|
+
* Subscribe to seek events
|
|
39
|
+
* @returns Unsubscribe function
|
|
40
|
+
*/
|
|
41
|
+
subscribeToSeek(callback: SeekCallback): () => void;
|
|
42
|
+
private ensurePlaybackProgressRegistered;
|
|
43
|
+
private ensureSeekRegistered;
|
|
26
44
|
}
|
|
27
45
|
export declare const callbackManager: CallbackSubscriptionManager;
|
|
28
46
|
export {};
|
|
@@ -7,8 +7,12 @@ import { TrackPlayer } from '../index';
|
|
|
7
7
|
class CallbackSubscriptionManager {
|
|
8
8
|
playbackStateSubscribers = new Set();
|
|
9
9
|
trackChangeSubscribers = new Set();
|
|
10
|
+
playbackProgressSubscribers = new Set();
|
|
11
|
+
seekSubscribers = new Set();
|
|
10
12
|
isPlaybackStateRegistered = false;
|
|
11
13
|
isTrackChangeRegistered = false;
|
|
14
|
+
isPlaybackProgressRegistered = false;
|
|
15
|
+
isSeekRegistered = false;
|
|
12
16
|
/**
|
|
13
17
|
* Subscribe to playback state changes
|
|
14
18
|
* @returns Unsubscribe function
|
|
@@ -71,6 +75,68 @@ class CallbackSubscriptionManager {
|
|
|
71
75
|
console.error('[CallbackManager] Failed to register track change callback:', error);
|
|
72
76
|
}
|
|
73
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Subscribe to playback progress changes
|
|
80
|
+
* @returns Unsubscribe function
|
|
81
|
+
*/
|
|
82
|
+
subscribeToPlaybackProgressChange(callback) {
|
|
83
|
+
this.playbackProgressSubscribers.add(callback);
|
|
84
|
+
this.ensurePlaybackProgressRegistered();
|
|
85
|
+
return () => {
|
|
86
|
+
this.playbackProgressSubscribers.delete(callback);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Subscribe to seek events
|
|
91
|
+
* @returns Unsubscribe function
|
|
92
|
+
*/
|
|
93
|
+
subscribeToSeek(callback) {
|
|
94
|
+
this.seekSubscribers.add(callback);
|
|
95
|
+
this.ensureSeekRegistered();
|
|
96
|
+
return () => {
|
|
97
|
+
this.seekSubscribers.delete(callback);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
ensurePlaybackProgressRegistered() {
|
|
101
|
+
if (this.isPlaybackProgressRegistered)
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
TrackPlayer.onPlaybackProgressChange((position, totalDuration, isManuallySeeked) => {
|
|
105
|
+
this.playbackProgressSubscribers.forEach((subscriber) => {
|
|
106
|
+
try {
|
|
107
|
+
subscriber(position, totalDuration, isManuallySeeked);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error('[CallbackManager] Error in playback progress subscriber:', error);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
this.isPlaybackProgressRegistered = true;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
console.error('[CallbackManager] Failed to register playback progress callback:', error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
ensureSeekRegistered() {
|
|
121
|
+
if (this.isSeekRegistered)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
TrackPlayer.onSeek((position, totalDuration) => {
|
|
125
|
+
this.seekSubscribers.forEach((subscriber) => {
|
|
126
|
+
try {
|
|
127
|
+
subscriber(position, totalDuration);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
console.error('[CallbackManager] Error in seek subscriber:', error);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
this.isSeekRegistered = true;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.error('[CallbackManager] Failed to register seek callback:', error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
74
140
|
}
|
|
75
141
|
// Export singleton instance
|
|
76
142
|
export const callbackManager = new CallbackSubscriptionManager();
|
|
@@ -47,12 +47,14 @@ const DEFAULT_STATE = {
|
|
|
47
47
|
export function useNowPlaying() {
|
|
48
48
|
const [state, setState] = useState(DEFAULT_STATE);
|
|
49
49
|
const isMounted = useRef(true);
|
|
50
|
-
const
|
|
50
|
+
const fetchFullState = useCallback(async () => {
|
|
51
51
|
if (!isMounted.current)
|
|
52
52
|
return;
|
|
53
53
|
try {
|
|
54
54
|
const newState = await TrackPlayer.getState();
|
|
55
|
-
|
|
55
|
+
if (isMounted.current) {
|
|
56
|
+
setState(newState);
|
|
57
|
+
}
|
|
56
58
|
}
|
|
57
59
|
catch (error) {
|
|
58
60
|
console.error('[useNowPlaying] Error updating player state:', error);
|
|
@@ -61,28 +63,38 @@ export function useNowPlaying() {
|
|
|
61
63
|
// Initialize with current state
|
|
62
64
|
useEffect(() => {
|
|
63
65
|
isMounted.current = true;
|
|
64
|
-
|
|
66
|
+
fetchFullState();
|
|
65
67
|
return () => {
|
|
66
68
|
isMounted.current = false;
|
|
67
69
|
};
|
|
68
|
-
}, [
|
|
69
|
-
// Subscribe to track changes
|
|
70
|
+
}, [fetchFullState]);
|
|
71
|
+
// Subscribe to track changes — full refresh
|
|
70
72
|
useEffect(() => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
return callbackManager.subscribeToTrackChange(() => {
|
|
74
|
+
fetchFullState();
|
|
73
75
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
77
|
-
}, [updateState]);
|
|
78
|
-
// Subscribe to playback state changes
|
|
76
|
+
}, [fetchFullState]);
|
|
77
|
+
// Subscribe to playback state changes — full refresh
|
|
79
78
|
useEffect(() => {
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
return callbackManager.subscribeToPlaybackState(() => {
|
|
80
|
+
fetchFullState();
|
|
82
81
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
}, [fetchFullState]);
|
|
83
|
+
// Subscribe to progress changes — lightweight position/duration update
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
return callbackManager.subscribeToPlaybackProgressChange((currentPosition, totalDuration) => {
|
|
86
|
+
if (!isMounted.current)
|
|
87
|
+
return;
|
|
88
|
+
setState((prev) => ({ ...prev, currentPosition, totalDuration }));
|
|
89
|
+
});
|
|
90
|
+
}, []);
|
|
91
|
+
// Subscribe to seek events — lightweight position/duration update
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
return callbackManager.subscribeToSeek((currentPosition, totalDuration) => {
|
|
94
|
+
if (!isMounted.current)
|
|
95
|
+
return;
|
|
96
|
+
setState((prev) => ({ ...prev, currentPosition, totalDuration }));
|
|
97
|
+
});
|
|
98
|
+
}, []);
|
|
87
99
|
return state;
|
|
88
100
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { callbackManager } from './callbackManager';
|
|
3
3
|
/**
|
|
4
4
|
* Hook to get the current playback progress
|
|
5
5
|
* @returns Object with current position, total duration, and manual seek indicator
|
|
@@ -9,7 +9,7 @@ export function useOnPlaybackProgressChange() {
|
|
|
9
9
|
const [totalDuration, setTotalDuration] = useState(0);
|
|
10
10
|
const [isManuallySeeked, setIsManuallySeeked] = useState(undefined);
|
|
11
11
|
useEffect(() => {
|
|
12
|
-
|
|
12
|
+
return callbackManager.subscribeToPlaybackProgressChange((newPosition, newTotalDuration, newIsManuallySeeked) => {
|
|
13
13
|
setPosition(newPosition);
|
|
14
14
|
setTotalDuration(newTotalDuration);
|
|
15
15
|
setIsManuallySeeked(newIsManuallySeeked);
|
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",
|