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 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
- if self.player.playerState == .paused {
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
- configureProgressUpdateEvent(
361
- interval: ((options["progressUpdateEventInterval"] as? NSNumber) ?? 0).doubleValue
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
- resolve(getPlaybackStateBodyKeyValues(state: player.playerState))
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: update every second when we have a current item and are ready/playing/paused.
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
- scheduleNextNowPlayingUpdate()
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
- // Reset playback values without updating, because that will happen in
215
- // the loadNowPlayingMetaValues call straight after:
216
- nowPlayingInfoController.setWithoutUpdate(keyValues: [
217
- MediaItemProperty.duration(nil),
218
- NowPlayingInfoProperty.playbackRate(nil),
219
- NowPlayingInfoProperty.elapsedPlaybackTime(nil)
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
- infoQueue.async(flags: .barrier) { [weak self] in
36
- guard let self = self else { return }
37
- keyValues.forEach {
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
- infoQueue.async(flags: .barrier) { [weak self] in
46
- guard let self = self else { return }
47
- keyValues.forEach {
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
- infoQueue.async(flags: .barrier) { [weak self] in
55
- guard let self = self else { return }
56
- self.info[keyValue.getKey()] = keyValue.getValue()
57
- self.update()
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
- infoCenter.nowPlayingInfo = info
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
- infoQueue.async(flags: .barrier) { [weak self] in
67
- guard let self = self else { return }
68
- self.info = [:]
69
- self.infoCenter.nowPlayingInfo = nil
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
- * Poll for track progress for the given interval (in miliseconds)
4
- * @param updateInterval - ms interval
5
- * @param background - if update state in background. default true. may severely affects performance.
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
- * Poll for track progress for the given interval (in miliseconds)
13
- * @param updateInterval - ms interval
14
- * @param background - if update state in background. default true. may severely affects performance.
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 update = async () => {
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 { position, duration, buffered } = await getProgress();
30
- setState((state) => position === state.position &&
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
- // these method only throw while you haven't yet setup, ignore failure.
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
- await update();
42
- if (!mounted)
43
- return;
44
- await new Promise((resolve) => setTimeout(resolve, updateInterval));
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.4",
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",
@@ -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
- * Poll for track progress for the given interval (in miliseconds)
17
- * @param updateInterval - ms interval
18
- * @param background - if update state in background. default true. may severely affects performance.
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(updateInterval = 1000, background = true) {
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 update = async () => {
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
- const { position, duration, buffered } = await getProgress();
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
- // these method only throw while you haven't yet setup, ignore failure.
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
- await update();
50
- if (!mounted) return;
51
- await new Promise<void>((resolve) => setTimeout(resolve, updateInterval));
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
  }