react-native-mp3-player 1.0.5 → 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 +1 -0
- package/ios/RNTrackPlayer/RNTrackPlayer.swift +41 -31
- package/ios/SwiftAudioEx/Sources/SwiftAudioEx/AudioPlayer.swift +8 -0
- 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
|
@@ -100,6 +100,7 @@ See [example/README.md](./example/README.md) for running the example app.
|
|
|
100
100
|
|
|
101
101
|
## Documentation
|
|
102
102
|
|
|
103
|
+
- [PERFORMANCE.md](./PERFORMANCE.md) – Optimizing speed and reducing bridge calls (progress, events, buffers).
|
|
103
104
|
- [NOTICE](./NOTICE) – Attribution and license.
|
|
104
105
|
- [LICENSE](./LICENSE) – Apache-2.0.
|
|
105
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,10 +205,9 @@ 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()
|
|
205
|
-
|
|
206
|
-
self.player.updateNowPlayingPlaybackValues()
|
|
207
|
-
}
|
|
210
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
208
211
|
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .paused))
|
|
209
212
|
self.emit(event: EventType.RemotePause)
|
|
210
213
|
return MPRemoteCommandHandlerStatus.success
|
|
@@ -212,10 +215,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
212
215
|
|
|
213
216
|
player.remoteCommandController.handlePlayCommand = { [weak self] _ in
|
|
214
217
|
guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
|
|
218
|
+
self.effectivePlaybackState = .playing
|
|
215
219
|
self.player.play()
|
|
216
|
-
|
|
217
|
-
self.player.updateNowPlayingPlaybackValues()
|
|
218
|
-
}
|
|
220
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
219
221
|
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .playing))
|
|
220
222
|
self.emit(event: EventType.RemotePlay)
|
|
221
223
|
return MPRemoteCommandHandlerStatus.success
|
|
@@ -254,10 +256,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
254
256
|
|
|
255
257
|
player.remoteCommandController.handleStopCommand = { [weak self] _ in
|
|
256
258
|
guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
|
|
259
|
+
self.effectivePlaybackState = .stopped
|
|
257
260
|
self.player.stop()
|
|
258
|
-
|
|
259
|
-
self.player.updateNowPlayingPlaybackValues()
|
|
260
|
-
}
|
|
261
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
261
262
|
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .stopped))
|
|
262
263
|
self.emit(event: EventType.RemoteStop)
|
|
263
264
|
return MPRemoteCommandHandlerStatus.success
|
|
@@ -265,18 +266,17 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
265
266
|
|
|
266
267
|
player.remoteCommandController.handleTogglePlayPauseCommand = { [weak self] _ in
|
|
267
268
|
guard let self = self else { return MPRemoteCommandHandlerStatus.commandFailed }
|
|
268
|
-
|
|
269
|
+
let currentState = self.effectivePlaybackState ?? self.player.playerState
|
|
270
|
+
if currentState == .paused || currentState == .stopped || currentState == .ended {
|
|
271
|
+
self.effectivePlaybackState = .playing
|
|
269
272
|
self.player.play()
|
|
270
|
-
|
|
271
|
-
self.player.updateNowPlayingPlaybackValues()
|
|
272
|
-
}
|
|
273
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
273
274
|
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .playing))
|
|
274
275
|
self.emit(event: EventType.RemotePlay)
|
|
275
276
|
} else {
|
|
277
|
+
self.effectivePlaybackState = .paused
|
|
276
278
|
self.player.pause()
|
|
277
|
-
|
|
278
|
-
self.player.updateNowPlayingPlaybackValues()
|
|
279
|
-
}
|
|
279
|
+
self.updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
280
280
|
self.emit(event: EventType.PlaybackState, body: self.getPlaybackStateBodyKeyValues(state: .paused))
|
|
281
281
|
self.emit(event: EventType.RemotePause)
|
|
282
282
|
}
|
|
@@ -377,9 +377,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
377
377
|
)
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
)
|
|
380
|
+
let interval = ((options["progressUpdateEventInterval"] as? NSNumber) ?? 0).doubleValue
|
|
381
|
+
progressUpdateEventIntervalSeconds = interval
|
|
382
|
+
configureProgressUpdateEvent(interval: interval)
|
|
383
383
|
|
|
384
384
|
resolve(NSNull())
|
|
385
385
|
}
|
|
@@ -554,6 +554,7 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
554
554
|
public func reset(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
555
555
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
556
556
|
|
|
557
|
+
effectivePlaybackState = nil
|
|
557
558
|
stopNowPlayingUpdateTimer()
|
|
558
559
|
player.stop()
|
|
559
560
|
player.clear()
|
|
@@ -563,11 +564,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
563
564
|
@objc(play:rejecter:)
|
|
564
565
|
public func play(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
565
566
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
567
|
+
effectivePlaybackState = .playing
|
|
566
568
|
player.play()
|
|
567
|
-
|
|
568
|
-
if player.currentItem != nil && player.automaticallyUpdateNowPlayingInfo {
|
|
569
|
-
player.updateNowPlayingPlaybackValues()
|
|
570
|
-
}
|
|
569
|
+
updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
571
570
|
resolve(NSNull())
|
|
572
571
|
emit(event: EventType.PlaybackState, body: getPlaybackStateBodyKeyValues(state: .playing))
|
|
573
572
|
}
|
|
@@ -575,12 +574,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
575
574
|
@objc(pause:rejecter:)
|
|
576
575
|
public func pause(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
577
576
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
578
|
-
|
|
577
|
+
effectivePlaybackState = .paused
|
|
579
578
|
player.pause()
|
|
580
|
-
|
|
581
|
-
if player.currentItem != nil && player.automaticallyUpdateNowPlayingInfo {
|
|
582
|
-
player.updateNowPlayingPlaybackValues()
|
|
583
|
-
}
|
|
579
|
+
updateNowPlayingPlaybackValuesOnMainIfNeeded()
|
|
584
580
|
resolve(NSNull())
|
|
585
581
|
emit(event: EventType.PlaybackState, body: getPlaybackStateBodyKeyValues(state: .paused))
|
|
586
582
|
}
|
|
@@ -775,7 +771,8 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
775
771
|
@objc(getPlaybackState:rejecter:)
|
|
776
772
|
public func getPlaybackState(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) {
|
|
777
773
|
if (rejectWhenNotInitialized(reject: reject)) { return }
|
|
778
|
-
|
|
774
|
+
let state = effectivePlaybackState ?? player.playerState
|
|
775
|
+
resolve(getPlaybackStateBodyKeyValues(state: state))
|
|
779
776
|
}
|
|
780
777
|
|
|
781
778
|
@objc(updateMetadataForTrack:metadata:resolver:rejecter:)
|
|
@@ -850,6 +847,7 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
850
847
|
// MARK: - QueuedAudioPlayer Event Handlers
|
|
851
848
|
|
|
852
849
|
func handleAudioPlayerStateChange(state: AVPlayerWrapperState) {
|
|
850
|
+
effectivePlaybackState = state
|
|
853
851
|
emit(event: EventType.PlaybackState, body: getPlaybackStateBodyKeyValues(state: state))
|
|
854
852
|
if (state == .ended) {
|
|
855
853
|
emit(event: EventType.PlaybackQueueEnded, body: [
|
|
@@ -857,11 +855,14 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
857
855
|
"position": player.currentTime,
|
|
858
856
|
] as [String : Any])
|
|
859
857
|
}
|
|
860
|
-
// 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.
|
|
861
859
|
switch state {
|
|
862
860
|
case .ready, .playing, .paused:
|
|
863
861
|
if player.currentItem != nil && player.automaticallyUpdateNowPlayingInfo {
|
|
864
|
-
|
|
862
|
+
let useProgressTickForNowPlaying = shouldEmitProgressEvent && progressUpdateEventIntervalSeconds > 0 && progressUpdateEventIntervalSeconds <= 1.0
|
|
863
|
+
if !useProgressTickForNowPlaying {
|
|
864
|
+
scheduleNextNowPlayingUpdate()
|
|
865
|
+
}
|
|
865
866
|
} else {
|
|
866
867
|
stopNowPlayingUpdateTimer()
|
|
867
868
|
}
|
|
@@ -885,6 +886,12 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
885
886
|
nowPlayingUpdateWorkItem?.cancel()
|
|
886
887
|
nowPlayingUpdateWorkItem = nil
|
|
887
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
|
+
}
|
|
888
895
|
|
|
889
896
|
func handleAudioPlayerCommonMetadataReceived(metadata: [AVMetadataItem]) {
|
|
890
897
|
let commonMetadata = MetadataAdapter.convertToCommonMetadata(metadata: metadata, skipRaw: true)
|
|
@@ -974,6 +981,9 @@ public class RNTrackPlayer: NSObject, AudioSessionControllerDelegate {
|
|
|
974
981
|
// _after_ a manipulation to the queu causing no currentItem to exist (see reset)
|
|
975
982
|
// in which case we shouldn't emit anything or we'll get an exception.
|
|
976
983
|
if !shouldEmitProgressEvent || player.currentItem == nil { return }
|
|
984
|
+
if player.automaticallyUpdateNowPlayingInfo {
|
|
985
|
+
player.updateNowPlayingPlaybackValues()
|
|
986
|
+
}
|
|
977
987
|
emit(
|
|
978
988
|
event: EventType.PlaybackProgressUpdated,
|
|
979
989
|
body: [
|
|
@@ -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
|
}
|