react-native-mp3-player 1.0.4 → 1.0.6
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/PERFORMANCE.md +76 -0
- package/README.md +2 -0
- package/ios/RNTrackPlayer/RNTrackPlayer.swift +46 -10
- package/ios/SwiftAudioEx/Sources/SwiftAudioEx/AudioPlayer.swift +14 -6
- package/ios/SwiftAudioEx/Sources/SwiftAudioEx/NowPlayingInfoController/NowPlayingInfoController.swift +55 -21
- package/ios/SwiftAudioEx/Sources/SwiftAudioEx/NowPlayingInfoController/NowPlayingInfoControllerProtocol.swift +12 -0
- package/lib/src/hooks/useProgress.d.ts +9 -4
- package/lib/src/hooks/useProgress.js +43 -18
- package/package.json +2 -1
- package/src/hooks/useProgress.ts +60 -21
package/PERFORMANCE.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Performance and optimization
|
|
2
|
+
|
|
3
|
+
This document describes how to get the best speed and lowest overhead from **react-native-mp3-player**, and what the package does internally to reduce work.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Progress updates (fewer bridge calls)
|
|
8
|
+
|
|
9
|
+
### Prefer native-driven progress when possible
|
|
10
|
+
|
|
11
|
+
- **Set `progressUpdateEventInterval`** in `updateOptions()` (in **seconds**, e.g. `1` for every second). The native layer will then emit `Event.PlaybackProgressUpdated` at that interval instead of you polling.
|
|
12
|
+
- **Use `useProgress(intervalMs, background, { useNativeEvents: true })`** so the hook subscribes to `PlaybackProgressUpdated` and updates state from events. You still get a slow fallback poll (e.g. every 3 s) for drift. This reduces bridge round-trips compared to polling every second.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// In your setup / updateOptions:
|
|
18
|
+
await TrackPlayer.updateOptions({
|
|
19
|
+
capabilities: [...],
|
|
20
|
+
progressUpdateEventInterval: 1, // seconds; native emits every 1 s
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// In your component:
|
|
24
|
+
const progress = useProgress(1000, true, { useNativeEvents: true });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **Avoid** using a very small `updateInterval` in `useProgress` (e.g. 250 ms) without `useNativeEvents: true`; that causes 4+ `getProgress()` bridge calls per second. If you need smooth progress, set `progressUpdateEventInterval` to 0.25 and use `useNativeEvents: true` with a fallback poll of ~2–3 s.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. iOS: Now Playing and progress tick
|
|
32
|
+
|
|
33
|
+
- When **`progressUpdateEventInterval`** is set and ≤ 1 second, the native progress tick also updates **MPNowPlayingInfoCenter** (elapsed/duration). A separate 1-second timer is not used in that case, so there is only one periodic task instead of two.
|
|
34
|
+
- When `progressUpdateEventInterval` is 0 or > 1 s, a dedicated 1-second timer runs to keep the lock screen / Control Center widget in sync.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 3. Batch and debounce in your app
|
|
39
|
+
|
|
40
|
+
- **Seeking:** If the user drags a slider, debounce or throttle `seekTo()` (e.g. at most one call every 200–300 ms) so you don’t flood the bridge.
|
|
41
|
+
- **Metadata:** Prefer a single `updateNowPlayingMetadata()` or `updateMetadataForTrack()` with all fields instead of multiple small updates.
|
|
42
|
+
- **Queue changes:** Prefer `setQueue()` or a single `add()` with multiple tracks over many small `add()` calls when building a queue.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 4. Buffer and streaming (setup options)
|
|
47
|
+
|
|
48
|
+
- Use **`minBuffer`**, **`maxBuffer`**, **`playBuffer`** (and Android equivalents where supported) to tune buffering. Larger buffers can reduce stalling on slow networks but use more memory; smaller values can reduce memory and startup time for short clips.
|
|
49
|
+
- For **local files**, you typically need minimal buffering; the default or small values are usually enough.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 5. Android
|
|
54
|
+
|
|
55
|
+
- **Progress:** When `progressUpdateEventInterval` is set, the service uses a single coroutine/flow to emit progress at that interval. No change needed on your side; avoid polling with `getProgress()` at a higher rate than the native interval if you can use `Event.PlaybackProgressUpdated` and `useProgress(..., { useNativeEvents: true })` instead.
|
|
56
|
+
- **Service:** Playback runs in a Media3 `MediaLibraryService`; the system manages the process. Avoid doing heavy work on the JS thread while playback is active so the bridge can process events promptly.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 6. General
|
|
61
|
+
|
|
62
|
+
- **Events vs polling:** Prefer `addEventListener(Event.PlaybackState, ...)` and similar for state changes instead of repeatedly calling `getPlaybackState()` in a loop.
|
|
63
|
+
- **Cleanup:** Remove event listeners and cancel any app-side timers when components unmount or when the player is reset, so the bridge and native side aren’t doing work for inactive UI.
|
|
64
|
+
- **Single source of truth:** Use one place (e.g. one context or hook) to drive progress and playback state so you don’t have multiple subscribers or timers doing the same work.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Summary
|
|
69
|
+
|
|
70
|
+
| Goal | Recommendation |
|
|
71
|
+
|--------------------------|-------------------------------------------------------------------------------|
|
|
72
|
+
| Fewer bridge calls | Set `progressUpdateEventInterval` and use `useProgress(..., { useNativeEvents: true })`. |
|
|
73
|
+
| Smoother progress UI | Use native events (e.g. interval 0.25–1 s) + `useNativeEvents: true`; avoid very fast polling. |
|
|
74
|
+
| Less duplicate work (iOS)| Use `progressUpdateEventInterval` ≤ 1 s so one tick drives both progress and Now Playing. |
|
|
75
|
+
| Responsive seek UI | Throttle/debounce `seekTo()` while the user drags the slider. |
|
|
76
|
+
| Lower memory / faster start | Tune buffer options for your use case (streaming vs local). |
|
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ For audio to continue when the app is backgrounded or the screen is locked (and
|
|
|
34
34
|
1. **Enable Background Modes → Audio** (or “Audio, AirPlay, and Picture in Picture”) in your app’s Xcode project: select your target → **Signing & Capabilities** → **+ Capability** → **Background Modes** → check **Audio**.
|
|
35
35
|
2. The package configures **AVAudioSession** (category `.playback` with options for Bluetooth, AirPlay, ducking) and handles **interruptions** and **background transitions** so that playback can continue when the app is backgrounded.
|
|
36
36
|
3. **Lock screen and Control Center** controls (play, pause, seek, 15-second skip) are handled **natively**, so they work even when the JavaScript thread is suspended (e.g. screen locked). When the app returns to the foreground, events are emitted so your UI stays in sync.
|
|
37
|
+
4. **Now Playing widget:** The package sets and updates **MPNowPlayingInfoCenter** as soon as a track is loaded (title, artist, duration, elapsed, rate, artwork) and keeps it updated every second during playback. When you pause, the widget shows the track as paused (rate 0), not "Not Playing". Now playing info is only cleared when there is no current track (e.g. after `reset()`).
|
|
37
38
|
|
|
38
39
|
### Android background playback
|
|
39
40
|
|
|
@@ -99,6 +100,7 @@ See [example/README.md](./example/README.md) for running the example app.
|
|
|
99
100
|
|
|
100
101
|
## Documentation
|
|
101
102
|
|
|
103
|
+
- [PERFORMANCE.md](./PERFORMANCE.md) – Optimizing speed and reducing bridge calls (progress, events, buffers).
|
|
102
104
|
- [NOTICE](./NOTICE) – Attribution and license.
|
|
103
105
|
- [LICENSE](./LICENSE) – Apache-2.0.
|
|
104
106
|
|
|
@@ -22,7 +22,11 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
22
22
|
private var hasInitialized = false
|
|
23
23
|
private let player = QueuedAudioPlayer()
|
|
24
24
|
private let audioSessionController = AudioSessionController.shared
|
|
25
|
+
/// Synchronous playback state for getPlaybackState() and events. Set on play()/pause() (and remote) so the next getPlaybackState() returns the new state before the wrapper's async state update. Updated from handleAudioPlayerStateChange so ended/failed/loading etc. are correct.
|
|
26
|
+
private var effectivePlaybackState: AVPlayerWrapperState? = nil
|
|
25
27
|
private var shouldEmitProgressEvent: Bool = false
|
|
28
|
+
/// When > 0 and <= 1, the progress tick already fires every second; we use it to update Now Playing instead of a separate timer.
|
|
29
|
+
private var progressUpdateEventIntervalSeconds: Double = 0
|
|
26
30
|
private var shouldResumePlaybackAfterInterruptionEnds: Bool = false
|
|
27
31
|
private var forwardJumpInterval: NSNumber? = nil;
|
|
28
32
|
private var backwardJumpInterval: NSNumber? = nil;
|
|
@@ -201,14 +205,20 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
201
205
|
|
|
202
206
|
player.remoteCommandController.handlePauseCommand = { [weak self] _ in
|
|
203
207
|
guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
|
|
208
|
+
self.effectivePlaybackState = .paused
|
|
204
209
|
self.player.pause()
|
|
210
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
211
|
+
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .paused))
|
|
205
212
|
self.emit(event: EventType.RemotePause)
|
|
206
213
|
return MPRemoteCommandHandlerStatus.success
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
player.remoteCommandController.handlePlayCommand = { [weak self] _ in
|
|
210
217
|
guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
|
|
218
|
+
self.effectivePlaybackState = .playing
|
|
211
219
|
self.player.play()
|
|
220
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
221
|
+
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .playing))
|
|
212
222
|
self.emit(event: EventType.RemotePlay)
|
|
213
223
|
return MPRemoteCommandHandlerStatus.success
|
|
214
224
|
}
|
|
@@ -246,18 +256,28 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
246
256
|
|
|
247
257
|
player.remoteCommandController.handleStopCommand = { [weak self] _ in
|
|
248
258
|
guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
|
|
259
|
+
self.effectivePlaybackState = .stopped
|
|
249
260
|
self.player.stop()
|
|
261
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
262
|
+
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .stopped))
|
|
250
263
|
self.emit(event: EventType.RemoteStop)
|
|
251
264
|
return MPRemoteCommandHandlerStatus.success
|
|
252
265
|
}
|
|
253
266
|
|
|
254
267
|
player.remoteCommandController.handleTogglePlayPauseCommand = { [weak self] _ in
|
|
255
268
|
guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
|
|
256
|
-
|
|
269
|
+
let currentState = self.effectivePlaybackState ?? self.player.playerState
|
|
270
|
+
if currentState == .paused || currentState == .stopped || currentState == .ended {
|
|
271
|
+
self.effectivePlaybackState = .playing
|
|
257
272
|
self.player.play()
|
|
273
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
274
|
+
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .playing))
|
|
258
275
|
self.emit(event: EventType.RemotePlay)
|
|
259
276
|
} else {
|
|
277
|
+
self.effectivePlaybackState = .paused
|
|
260
278
|
self.player.pause()
|
|
279
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
280
|
+
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .paused))
|
|
261
281
|
self.emit(event: EventType.RemotePause)
|
|
262
282
|
}
|
|
263
283
|
return MPRemoteCommandHandlerStatus.success
|
|
@@ -357,9 +377,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
357
377
|
)
|
|
358
378
|
}
|
|
359
379
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
)
|
|
380
|
+
let interval = ((options["progressUpdateEventInterval"] as? NSNumber) ?? 0).doubleValue
|
|
381
|
+
progressUpdateEventIntervalSeconds = interval
|
|
382
|
+
configureProgressUpdateEvent(interval: interval)
|
|
363
383
|
|
|
364
384
|
resolve(NSNull())
|
|
365
385
|
}
|
|
@@ -534,6 +554,7 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
534
554
|
public func reset(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
535
555
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
536
556
|
|
|
557
|
+
effectivePlaybackState = nil
|
|
537
558
|
stopNowPlayingUpdateTimer()
|
|
538
559
|
player.stop()
|
|
539
560
|
player.clear()
|
|
@@ -543,19 +564,20 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
543
564
|
@objc(play:rejecter:)
|
|
544
565
|
public func play(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
545
566
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
567
|
+
effectivePlaybackState = .playing
|
|
546
568
|
player.play()
|
|
569
|
+
updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
547
570
|
resolve(NSNull())
|
|
548
|
-
// Emit PlaybackState immediately so in-app UI (play/pause button) updates without waiting for native state transition.
|
|
549
571
|
emit(event: EventType.PlaybackState, body: getPlaybackStateBodyKeyValues(state: .playing))
|
|
550
572
|
}
|
|
551
573
|
|
|
552
574
|
@objc(pause:rejecter:)
|
|
553
575
|
public func pause(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
554
576
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
555
|
-
|
|
577
|
+
effectivePlaybackState = .paused
|
|
556
578
|
player.pause()
|
|
579
|
+
updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
557
580
|
resolve(NSNull())
|
|
558
|
-
// Emit PlaybackState immediately so in-app UI (play/pause button) updates without waiting for native state transition.
|
|
559
581
|
emit(event: EventType.PlaybackState, body: getPlaybackStateBodyKeyValues(state: .paused))
|
|
560
582
|
}
|
|
561
583
|
|
|
@@ -749,7 +771,8 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
749
771
|
@objc(getPlaybackState:rejecter:)
|
|
750
772
|
public func getPlaybackState(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
751
773
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
752
|
-
|
|
774
|
+
let state = effectivePlaybackState ?? player.playerState
|
|
775
|
+
resolve(getPlaybackStateBodyKeyValues(state: state))
|
|
753
776
|
}
|
|
754
777
|
|
|
755
778
|
@objc(updateMetadataForTrack:metadata:resolver:rejecter:)
|
|
@@ -824,6 +847,7 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
824
847
|
// MARK: - QueuedAudioPlayer Event Handlers
|
|
825
848
|
|
|
826
849
|
func handleAudioPlayerStateChange(state: AVPlayerWrapperState) {
|
|
850
|
+
effectivePlaybackState = state
|
|
827
851
|
emit(event: EventType.PlaybackState, body: getPlaybackStateBodyKeyValues(state: state))
|
|
828
852
|
if (state == .ended) {
|
|
829
853
|
emit(event: EventType.PlaybackQueueEnded, body: [
|
|
@@ -831,11 +855,14 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
831
855
|
"position": player.currentTime,
|
|
832
856
|
] as [String : Any])
|
|
833
857
|
}
|
|
834
|
-
// Keep Now Playing widget elapsed/duration in sync
|
|
858
|
+
// Keep Now Playing widget elapsed/duration in sync. When progress events fire every second (interval > 0 and <= 1), use that tick instead of a separate timer.
|
|
835
859
|
switch state {
|
|
836
860
|
case .ready, .playing, .paused:
|
|
837
861
|
if player.currentItem != nil && player.automaticallyUpdateNowPlayingInfo {
|
|
838
|
-
|
|
862
|
+
let useProgressTickForNowPlaying = shouldEmitProgressEvent && progressUpdateEventIntervalSeconds > 0 && progressUpdateEventIntervalSeconds <= 1.0
|
|
863
|
+
if !useProgressTickForNowPlaying {
|
|
864
|
+
scheduleNextNowPlayingUpdate()
|
|
865
|
+
}
|
|
839
866
|
} else {
|
|
840
867
|
stopNowPlayingUpdateTimer()
|
|
841
868
|
}
|
|
@@ -859,6 +886,12 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
859
886
|
nowPlayingUpdateWorkItem?.cancel()
|
|
860
887
|
nowPlayingUpdateWorkItem = nil
|
|
861
888
|
}
|
|
889
|
+
|
|
890
|
+
/// Updates MPNowPlayingInfoCenter (rate, elapsed, duration) synchronously so the widget reflects play/pause before we return to JS. Call after play()/pause() from JS or remote.
|
|
891
|
+
private func updateNowPlayingPlaybackValuesOnMainIfNeeded() {
|
|
892
|
+
guard player.currentItem != nil, player.automaticallyUpdateNowPlayingInfo else { return }
|
|
893
|
+
player.updateNowPlayingPlaybackValuesSync()
|
|
894
|
+
}
|
|
862
895
|
|
|
863
896
|
func handleAudioPlayerCommonMetadataReceived(metadata: [AVMetadataItem]) {
|
|
864
897
|
let commonMetadata = MetadataAdapter.convertToCommonMetadata(metadata: metadata, skipRaw: true)
|
|
@@ -948,6 +981,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
948
981
|
// _after_ a manipulation to the queu causing no currentItem to exist (see reset)
|
|
949
982
|
// in which case we shouldn't emit anything or we'll get an exception.
|
|
950
983
|
if !shouldEmitProgressEvent || player.currentItem == nil { return }
|
|
984
|
+
if player.automaticallyUpdateNowPlayingInfo {
|
|
985
|
+
player.updateNowPlayingPlaybackValues()
|
|
986
|
+
}
|
|
951
987
|
emit(
|
|
952
988
|
event: EventType.PlaybackProgressUpdated,
|
|
953
989
|
body: [
|
|
@@ -211,12 +211,12 @@ public class AudioPlayer: AVPlayerWrapperDelegate {
|
|
|
211
211
|
currentItem = item
|
|
212
212
|
|
|
213
213
|
if (automaticallyUpdateNowPlayingInfo) {
|
|
214
|
-
//
|
|
215
|
-
// the
|
|
216
|
-
nowPlayingInfoController.
|
|
217
|
-
MediaItemProperty.duration(
|
|
218
|
-
NowPlayingInfoProperty.playbackRate(
|
|
219
|
-
NowPlayingInfoProperty.elapsedPlaybackTime(
|
|
214
|
+
// Set initial playback values so the Now Playing widget never shows "Not Playing"
|
|
215
|
+
// (duration/elapsed/rate must be set; use 0 until the player reports real values).
|
|
216
|
+
nowPlayingInfoController.set(keyValues: [
|
|
217
|
+
MediaItemProperty.duration(0),
|
|
218
|
+
NowPlayingInfoProperty.playbackRate(playWhenReady ? 1.0 : 0.0),
|
|
219
|
+
NowPlayingInfoProperty.elapsedPlaybackTime(0)
|
|
220
220
|
])
|
|
221
221
|
loadNowPlayingMetaValues()
|
|
222
222
|
}
|
|
@@ -348,6 +348,14 @@ public class AudioPlayer: AVPlayerWrapperDelegate {
|
|
|
348
348
|
])
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
/// Pushes current duration/elapsed/rate to MPNowPlayingInfoCenter synchronously on main so the widget updates before returning (e.g. after play/pause).
|
|
352
|
+
func updateNowPlayingPlaybackValuesSync() {
|
|
353
|
+
let duration = wrapper.duration
|
|
354
|
+
let elapsed = wrapper.currentTime
|
|
355
|
+
let rate = wrapper.playWhenReady ? Double(wrapper.rate) : 0
|
|
356
|
+
nowPlayingInfoController.setPlaybackValuesSync(duration: duration, elapsed: elapsed, rate: rate)
|
|
357
|
+
}
|
|
358
|
+
|
|
351
359
|
public func clear() {
|
|
352
360
|
let playbackWasActive = wrapper.playbackActive
|
|
353
361
|
currentItem = nil
|
|
@@ -9,13 +9,14 @@ import Foundation
|
|
|
9
9
|
import MediaPlayer
|
|
10
10
|
|
|
11
11
|
public class NowPlayingInfoController: NowPlayingInfoControllerProtocol {
|
|
12
|
+
private let lock = NSLock()
|
|
12
13
|
private var infoQueue: DispatchQueueType = DispatchQueue(
|
|
13
14
|
label: "NowPlayingInfoController.infoQueue",
|
|
14
15
|
attributes: .concurrent
|
|
15
16
|
)
|
|
16
17
|
|
|
17
|
-
private(set) var infoCenter: NowPlayingInfoCenter
|
|
18
18
|
private(set) var info: [String: Any] = [:]
|
|
19
|
+
private(set) var infoCenter: NowPlayingInfoCenter
|
|
19
20
|
|
|
20
21
|
public required init() {
|
|
21
22
|
infoCenter = MPNowPlayingInfoCenter.default()
|
|
@@ -32,41 +33,74 @@ public class NowPlayingInfoController: NowPlayingInfoControllerProtocol {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
public func set(keyValues: [NowPlayingInfoKeyValue]) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
(keyValue) in self.info[keyValue.getKey()] = keyValue.getValue()
|
|
39
|
-
}
|
|
40
|
-
self.update()
|
|
36
|
+
lock.lock()
|
|
37
|
+
keyValues.forEach { keyValue in
|
|
38
|
+
self.info[keyValue.getKey()] = keyValue.getValue()
|
|
41
39
|
}
|
|
40
|
+
let snapshot = self.info
|
|
41
|
+
lock.unlock()
|
|
42
|
+
pushToCenter(snapshot)
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
public func setWithoutUpdate(keyValues: [NowPlayingInfoKeyValue]) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
(keyValue) in self.info[keyValue.getKey()] = keyValue.getValue()
|
|
49
|
-
}
|
|
46
|
+
lock.lock()
|
|
47
|
+
keyValues.forEach { keyValue in
|
|
48
|
+
self.info[keyValue.getKey()] = keyValue.getValue()
|
|
50
49
|
}
|
|
50
|
+
lock.unlock()
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
public func set(keyValue: NowPlayingInfoKeyValue) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
lock.lock()
|
|
55
|
+
self.info[keyValue.getKey()] = keyValue.getValue()
|
|
56
|
+
let snapshot = self.info
|
|
57
|
+
lock.unlock()
|
|
58
|
+
pushToCenter(snapshot)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public func setPlaybackValuesSync(duration: TimeInterval, elapsed: TimeInterval, rate: Double) {
|
|
62
|
+
lock.lock()
|
|
63
|
+
self.info[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
|
|
64
|
+
self.info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: elapsed)
|
|
65
|
+
self.info[MPNowPlayingInfoPropertyPlaybackRate] = NSNumber(value: rate)
|
|
66
|
+
let snapshot = self.info
|
|
67
|
+
lock.unlock()
|
|
68
|
+
if Thread.isMainThread {
|
|
69
|
+
infoCenter.nowPlayingInfo = snapshot
|
|
70
|
+
} else {
|
|
71
|
+
DispatchQueue.main.sync { [weak self] in
|
|
72
|
+
self?.infoCenter.nowPlayingInfo = snapshot
|
|
73
|
+
}
|
|
58
74
|
}
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
private func update() {
|
|
62
|
-
|
|
78
|
+
lock.lock()
|
|
79
|
+
let snapshot = self.info
|
|
80
|
+
lock.unlock()
|
|
81
|
+
pushToCenter(snapshot)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func pushToCenter(_ snapshot: [String: Any]) {
|
|
85
|
+
if Thread.isMainThread {
|
|
86
|
+
infoCenter.nowPlayingInfo = snapshot
|
|
87
|
+
} else {
|
|
88
|
+
DispatchQueue.main.async { [weak self] in
|
|
89
|
+
self?.infoCenter.nowPlayingInfo = snapshot
|
|
90
|
+
}
|
|
91
|
+
}
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
public func clear() {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
lock.lock()
|
|
96
|
+
self.info = [:]
|
|
97
|
+
lock.unlock()
|
|
98
|
+
if Thread.isMainThread {
|
|
99
|
+
infoCenter.nowPlayingInfo = nil
|
|
100
|
+
} else {
|
|
101
|
+
DispatchQueue.main.async { [weak self] in
|
|
102
|
+
self?.infoCenter.nowPlayingInfo = nil
|
|
103
|
+
}
|
|
70
104
|
}
|
|
71
105
|
}
|
|
72
106
|
|
|
@@ -23,4 +23,16 @@ public protocol NowPlayingInfoControllerProtocol {
|
|
|
23
23
|
|
|
24
24
|
func clear()
|
|
25
25
|
|
|
26
|
+
/// Optional: push playback values to the system synchronously on main (e.g. so play/pause widget updates before returning). Default merges and calls set() (async).
|
|
27
|
+
func setPlaybackValuesSync(duration: TimeInterval, elapsed: TimeInterval, rate: Double)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
extension NowPlayingInfoControllerProtocol {
|
|
31
|
+
public func setPlaybackValuesSync(duration: TimeInterval, elapsed: TimeInterval, rate: Double) {
|
|
32
|
+
set(keyValues: [
|
|
33
|
+
MediaItemProperty.duration(duration),
|
|
34
|
+
NowPlayingInfoProperty.elapsedPlaybackTime(elapsed),
|
|
35
|
+
NowPlayingInfoProperty.playbackRate(rate)
|
|
36
|
+
])
|
|
37
|
+
}
|
|
26
38
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { Progress } from '../interfaces';
|
|
2
|
+
export interface UseProgressOptions {
|
|
3
|
+
/** If true, subscribe to Event.PlaybackProgressUpdated (native-driven) when you set progressUpdateEventInterval in updateOptions. Reduces bridge round-trips vs polling. Default false. */
|
|
4
|
+
useNativeEvents?: boolean;
|
|
5
|
+
}
|
|
2
6
|
/**
|
|
3
|
-
*
|
|
4
|
-
* @param updateInterval - ms
|
|
5
|
-
* @param background -
|
|
7
|
+
* Track progress (position, duration, buffered) for the current track.
|
|
8
|
+
* @param updateInterval - Polling interval in ms when not using native events. Default 1000.
|
|
9
|
+
* @param background - Update state when app is in background. Default true. Can affect performance.
|
|
10
|
+
* @param options - useNativeEvents: true to prefer Event.PlaybackProgressUpdated (set progressUpdateEventInterval in updateOptions). Fewer bridge calls.
|
|
6
11
|
*/
|
|
7
|
-
export declare function useProgress(updateInterval?: number, background?: boolean): Progress;
|
|
12
|
+
export declare function useProgress(updateInterval?: number, background?: boolean, options?: UseProgressOptions): Progress;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { AppState } from 'react-native';
|
|
3
|
-
import { getProgress } from '../trackPlayer';
|
|
3
|
+
import { getProgress, addEventListener } from '../trackPlayer';
|
|
4
4
|
import { Event } from '../constants';
|
|
5
5
|
import { useTrackPlayerEvents } from './useTrackPlayerEvents';
|
|
6
6
|
const INITIAL_STATE = {
|
|
@@ -9,39 +9,63 @@ const INITIAL_STATE = {
|
|
|
9
9
|
buffered: 0,
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
* @param updateInterval - ms
|
|
14
|
-
* @param background -
|
|
12
|
+
* Track progress (position, duration, buffered) for the current track.
|
|
13
|
+
* @param updateInterval - Polling interval in ms when not using native events. Default 1000.
|
|
14
|
+
* @param background - Update state when app is in background. Default true. Can affect performance.
|
|
15
|
+
* @param options - useNativeEvents: true to prefer Event.PlaybackProgressUpdated (set progressUpdateEventInterval in updateOptions). Fewer bridge calls.
|
|
15
16
|
*/
|
|
16
|
-
export function useProgress(updateInterval = 1000, background = true) {
|
|
17
|
+
export function useProgress(updateInterval = 1000, background = true, options = {}) {
|
|
17
18
|
const [state, setState] = useState(INITIAL_STATE);
|
|
18
19
|
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], () => {
|
|
19
20
|
setState(INITIAL_STATE);
|
|
20
21
|
});
|
|
21
22
|
useEffect(() => {
|
|
22
23
|
let mounted = true;
|
|
23
|
-
const
|
|
24
|
+
const updateFromProgress = (next) => {
|
|
25
|
+
if (!mounted)
|
|
26
|
+
return;
|
|
27
|
+
setState((prev) => next.position === prev.position &&
|
|
28
|
+
next.duration === prev.duration &&
|
|
29
|
+
next.buffered === prev.buffered
|
|
30
|
+
? prev
|
|
31
|
+
: next);
|
|
32
|
+
};
|
|
33
|
+
const updateFromBridge = async () => {
|
|
24
34
|
try {
|
|
25
35
|
if (!mounted)
|
|
26
36
|
return;
|
|
27
37
|
if (!background && AppState.currentState !== 'active')
|
|
28
38
|
return;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
duration === state.duration &&
|
|
32
|
-
buffered === state.buffered
|
|
33
|
-
? state
|
|
34
|
-
: { position, duration, buffered });
|
|
39
|
+
const next = await getProgress();
|
|
40
|
+
updateFromProgress(next);
|
|
35
41
|
}
|
|
36
42
|
catch {
|
|
37
|
-
//
|
|
43
|
+
// only throws when player not set up
|
|
38
44
|
}
|
|
39
45
|
};
|
|
46
|
+
let progressSub = null;
|
|
47
|
+
if (options.useNativeEvents) {
|
|
48
|
+
progressSub = addEventListener(Event.PlaybackProgressUpdated, (payload) => {
|
|
49
|
+
updateFromProgress({
|
|
50
|
+
position: payload.position,
|
|
51
|
+
duration: payload.duration,
|
|
52
|
+
buffered: payload.buffered,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
40
56
|
const poll = async () => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
if (options.useNativeEvents) {
|
|
58
|
+
await updateFromBridge();
|
|
59
|
+
if (!mounted)
|
|
60
|
+
return;
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(updateInterval * 3, 3000)));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
await updateFromBridge();
|
|
65
|
+
if (!mounted)
|
|
66
|
+
return;
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, updateInterval));
|
|
68
|
+
}
|
|
45
69
|
if (!mounted)
|
|
46
70
|
return;
|
|
47
71
|
poll();
|
|
@@ -49,7 +73,8 @@ export function useProgress(updateInterval = 1000, background = true) {
|
|
|
49
73
|
poll();
|
|
50
74
|
return () => {
|
|
51
75
|
mounted = false;
|
|
76
|
+
progressSub?.remove();
|
|
52
77
|
};
|
|
53
|
-
}, [updateInterval]);
|
|
78
|
+
}, [updateInterval, options?.useNativeEvents ?? false]);
|
|
54
79
|
return state;
|
|
55
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-mp3-player",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "React Native audio player with reliable iOS background playback. Media controls, queue, hooks. Built for stability and long-running playback.",
|
|
5
5
|
"main": "lib/src/index.js",
|
|
6
6
|
"types": "lib/src/index.d.ts",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"src",
|
|
22
22
|
"lib/**/*",
|
|
23
|
+
"PERFORMANCE.md",
|
|
23
24
|
"ios/**/*",
|
|
24
25
|
"android/src/**/*",
|
|
25
26
|
"android/build.gradle",
|
package/src/hooks/useProgress.ts
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { AppState } from 'react-native';
|
|
3
3
|
|
|
4
|
-
import { getProgress } from '../trackPlayer';
|
|
4
|
+
import { getProgress, addEventListener } from '../trackPlayer';
|
|
5
5
|
import { Event } from '../constants';
|
|
6
6
|
import type { Progress } from '../interfaces';
|
|
7
7
|
import { useTrackPlayerEvents } from './useTrackPlayerEvents';
|
|
8
8
|
|
|
9
|
-
const INITIAL_STATE = {
|
|
9
|
+
const INITIAL_STATE: Progress = {
|
|
10
10
|
position: 0,
|
|
11
11
|
duration: 0,
|
|
12
12
|
buffered: 0,
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
export interface UseProgressOptions {
|
|
16
|
+
/** If true, subscribe to Event.PlaybackProgressUpdated (native-driven) when you set progressUpdateEventInterval in updateOptions. Reduces bridge round-trips vs polling. Default false. */
|
|
17
|
+
useNativeEvents?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
|
-
*
|
|
17
|
-
* @param updateInterval - ms
|
|
18
|
-
* @param background -
|
|
21
|
+
* Track progress (position, duration, buffered) for the current track.
|
|
22
|
+
* @param updateInterval - Polling interval in ms when not using native events. Default 1000.
|
|
23
|
+
* @param background - Update state when app is in background. Default true. Can affect performance.
|
|
24
|
+
* @param options - useNativeEvents: true to prefer Event.PlaybackProgressUpdated (set progressUpdateEventInterval in updateOptions). Fewer bridge calls.
|
|
19
25
|
*/
|
|
20
|
-
export function useProgress(
|
|
26
|
+
export function useProgress(
|
|
27
|
+
updateInterval = 1000,
|
|
28
|
+
background = true,
|
|
29
|
+
options: UseProgressOptions = {},
|
|
30
|
+
) {
|
|
21
31
|
const [state, setState] = useState<Progress>(INITIAL_STATE);
|
|
22
32
|
|
|
23
33
|
useTrackPlayerEvents([Event.PlaybackActiveTrackChanged], () => {
|
|
@@ -27,28 +37,56 @@ export function useProgress(updateInterval = 1000, background = true) {
|
|
|
27
37
|
useEffect(() => {
|
|
28
38
|
let mounted = true;
|
|
29
39
|
|
|
30
|
-
const
|
|
40
|
+
const updateFromProgress = (next: Progress) => {
|
|
41
|
+
if (!mounted) return;
|
|
42
|
+
setState((prev) =>
|
|
43
|
+
next.position === prev.position &&
|
|
44
|
+
next.duration === prev.duration &&
|
|
45
|
+
next.buffered === prev.buffered
|
|
46
|
+
? prev
|
|
47
|
+
: next
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const updateFromBridge = async () => {
|
|
31
52
|
try {
|
|
32
53
|
if (!mounted) return;
|
|
33
54
|
if (!background && AppState.currentState !== 'active') return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
setState((state) =>
|
|
37
|
-
position === state.position &&
|
|
38
|
-
duration === state.duration &&
|
|
39
|
-
buffered === state.buffered
|
|
40
|
-
? state
|
|
41
|
-
: { position, duration, buffered }
|
|
42
|
-
);
|
|
55
|
+
const next = await getProgress();
|
|
56
|
+
updateFromProgress(next);
|
|
43
57
|
} catch {
|
|
44
|
-
//
|
|
58
|
+
// only throws when player not set up
|
|
45
59
|
}
|
|
46
60
|
};
|
|
47
61
|
|
|
62
|
+
let progressSub: { remove: () => void } | null = null;
|
|
63
|
+
if (options.useNativeEvents) {
|
|
64
|
+
progressSub = addEventListener(
|
|
65
|
+
Event.PlaybackProgressUpdated,
|
|
66
|
+
(payload: { position: number; duration: number; buffered: number }) => {
|
|
67
|
+
updateFromProgress({
|
|
68
|
+
position: payload.position,
|
|
69
|
+
duration: payload.duration,
|
|
70
|
+
buffered: payload.buffered,
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
const poll = async () => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
if (options.useNativeEvents) {
|
|
78
|
+
await updateFromBridge();
|
|
79
|
+
if (!mounted) return;
|
|
80
|
+
await new Promise<void>((resolve) =>
|
|
81
|
+
setTimeout(resolve, Math.max(updateInterval * 3, 3000)),
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
await updateFromBridge();
|
|
85
|
+
if (!mounted) return;
|
|
86
|
+
await new Promise<void>((resolve) =>
|
|
87
|
+
setTimeout(resolve, updateInterval),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
52
90
|
if (!mounted) return;
|
|
53
91
|
poll();
|
|
54
92
|
};
|
|
@@ -57,8 +95,9 @@ export function useProgress(updateInterval = 1000, background = true) {
|
|
|
57
95
|
|
|
58
96
|
return () => {
|
|
59
97
|
mounted = false;
|
|
98
|
+
progressSub?.remove();
|
|
60
99
|
};
|
|
61
|
-
}, [updateInterval]);
|
|
100
|
+
}, [updateInterval, options?.useNativeEvents ?? false]);
|
|
62
101
|
|
|
63
102
|
return state;
|
|
64
103
|
}
|