react-native-audio-browser 0.2.0-next.1 → 0.2.0-next.2

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.
@@ -113,7 +113,8 @@ class TransformingDataSource(private val upstream: DataSource, private val facto
113
113
  * installs the `IcyDataSource` that emits in-stream ICY/Shoutcast song metadata via
114
114
  * `Player.Listener.onMetadata`. The `DataSource` default returns an empty map, so without this
115
115
  * override this transparent wrapper would swallow the ICY headers — playback keeps working but
116
- * `onMetadata` never fires (live "now playing" song goes missing).
116
+ * `onMetadata` never fires (live "now playing" song goes missing). Swallowing them also leaves
117
+ * the interleaved metadata bytes in the audio stream, where they decode as audible chirping.
117
118
  */
118
119
  override fun getResponseHeaders(): Map<String, List<String>> {
119
120
  return upstream.responseHeaders
@@ -25,7 +25,9 @@ final class MediaLoader {
25
25
 
26
26
  weak var delegate: MediaLoaderDelegate?
27
27
 
28
- private var mediaResolverTask: Task<Void, Never>?
28
+ /// The in-flight resolve. Readable so tests can await the resolve-then-load
29
+ /// chain instead of sleeping past it; only this type starts and cancels it.
30
+ private(set) var mediaResolverTask: Task<Void, Never>?
29
31
  private var metadataLoadTask: Task<Void, Never>?
30
32
  private var playableLoadTask: Task<Void, Never>?
31
33
  private var url: URL?
@@ -76,7 +76,10 @@ class PlaybackCoordinator {
76
76
  /// the window/attempt counters otherwise reset only on track change.
77
77
  /// Overridable in tests.
78
78
  var healthyPlaybackDuration: TimeInterval = 20
79
- private var healthyPlaybackTask: Task<Void, Never>?
79
+
80
+ /// The pending refill. Readable so tests can await the window instead of
81
+ /// sleeping past it; only this type arms and cancels it.
82
+ private(set) var healthyPlaybackTask: Task<Void, Never>?
80
83
 
81
84
  /// Playback rate (1.0 = normal speed).
82
85
  var rate: Float = 1.0
@@ -7,15 +7,51 @@ import Foundation
7
7
  /// Type alias for the Nitro SleepTimer variant type
8
8
  typealias SleepTimerState = SleepTimer
9
9
 
10
+ /// A scheduled job that has not run yet. `cancel()` is nonisolated so `deinit`
11
+ /// — always nonisolated in Swift 6 — can tear pending work down.
12
+ protocol SleepTimerJob: AnyObject {
13
+ func cancel()
14
+ }
15
+
16
+ extension DispatchWorkItem: SleepTimerJob {}
17
+
18
+ /// Runs work after a delay. Injected into `SleepTimerManager` so tests can
19
+ /// drive the clock instead of racing it: production schedules on the main
20
+ /// queue, tests hand in a scheduler they advance by hand.
21
+ @MainActor
22
+ protocol SleepTimerScheduling {
23
+ func schedule(
24
+ after delay: TimeInterval,
25
+ _ work: @escaping @Sendable @MainActor () -> Void,
26
+ ) -> any SleepTimerJob
27
+ }
28
+
29
+ struct MainQueueSleepTimerScheduler: SleepTimerScheduling {
30
+ func schedule(
31
+ after delay: TimeInterval,
32
+ _ work: @escaping @Sendable @MainActor () -> Void,
33
+ ) -> any SleepTimerJob {
34
+ let job = DispatchWorkItem { MainActor.assumeIsolated { work() } }
35
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: job)
36
+ return job
37
+ }
38
+ }
39
+
10
40
  /// Manages sleep timer functionality for the audio player.
11
41
  /// Supports both time-based timers and end-of-track timers.
12
42
  @MainActor
13
43
  class SleepTimerManager: SleepTimerHandling {
14
44
  // MARK: - Properties
15
45
 
46
+ private let scheduler: any SleepTimerScheduling
47
+
16
48
  // nonisolated(unsafe) for deinit cleanup — deinit is always nonisolated in Swift 6.
17
- private nonisolated(unsafe) var sleepTimerJob: DispatchWorkItem?
18
- private nonisolated(unsafe) var fadeJob: DispatchWorkItem?
49
+ private nonisolated(unsafe) var sleepTimerJob: (any SleepTimerJob)?
50
+ private nonisolated(unsafe) var fadeJob: (any SleepTimerJob)?
51
+
52
+ init(scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
53
+ self.scheduler = scheduler
54
+ }
19
55
 
20
56
  /// The time when playback should stop (seconds since epoch), or -1 if inactive
21
57
  private(set) var sleepTimerTime: TimeInterval = -1 {
@@ -94,23 +130,15 @@ class SleepTimerManager: SleepTimerHandling {
94
130
  func set(seconds: TimeInterval, fadeDuration: TimeInterval? = nil) {
95
131
  cancelSleepTimerJob()
96
132
  onFadeCancel?()
97
- let job = DispatchWorkItem { [weak self] in
98
- MainActor.assumeIsolated {
99
- self?.complete()
100
- }
101
- }
102
- sleepTimerJob = job
103
133
  sleepTimerTime = Date().timeIntervalSince1970 + seconds
104
- DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: job)
134
+ sleepTimerJob = scheduler.schedule(after: seconds) { [weak self] in
135
+ self?.complete()
136
+ }
105
137
  if let fadeDuration, fadeDuration > 0 {
106
138
  let fade = min(fadeDuration, seconds)
107
- let fadeJob = DispatchWorkItem { [weak self] in
108
- MainActor.assumeIsolated {
109
- self?.onFadeStart?(fade)
110
- }
139
+ fadeJob = scheduler.schedule(after: seconds - fade) { [weak self] in
140
+ self?.onFadeStart?(fade)
111
141
  }
112
- self.fadeJob = fadeJob
113
- DispatchQueue.main.asyncAfter(deadline: .now() + (seconds - fade), execute: fadeJob)
114
142
  }
115
143
  if let state = get() { onChanged?(state) }
116
144
  }
@@ -123,8 +123,7 @@ struct ResolveAndLoadTests {
123
123
  }
124
124
  loader.resolveAndLoad(src: "https://example.com/audio.mp3")
125
125
 
126
- // Allow the resolver task to run
127
- await Task.yield()
126
+ await loader.mediaResolverTask?.value
128
127
 
129
128
  #expect(resolverCalled == true)
130
129
  }
@@ -136,12 +135,9 @@ struct ResolveAndLoadTests {
136
135
  }
137
136
  loader.resolveAndLoad(src: "https://example.com/audio.mp3")
138
137
 
139
- // The resolver runs in a Task that awaits the resolver then does MainActor.run,
140
- // so we need multiple yields for the full chain to settle.
141
- for _ in 0 ..< 10 {
142
- await Task.yield()
143
- if !spy.playbackErrors.isEmpty { break }
144
- }
138
+ // The task awaits the resolver then hops back to the main actor, so the
139
+ // delegate call has landed by the time the task itself completes.
140
+ await loader.mediaResolverTask?.value
145
141
 
146
142
  #expect(spy.playbackErrors.count == 1)
147
143
  if case let .invalidSourceUrl(url) = spy.playbackErrors.first {
@@ -166,8 +162,8 @@ struct ResolveAndLoadTests {
166
162
  // Immediately start second — should cancel first
167
163
  loader.resolveAndLoad(src: "https://example.com/second.mp3")
168
164
 
169
- // Wait for both to settle
170
- try? await Task.sleep(for: .milliseconds(200))
165
+ // Await the surviving task; the cancelled one bailed before its resolver.
166
+ await loader.mediaResolverTask?.value
171
167
 
172
168
  // Only the second resolver should have run (first was cancelled before calling resolver)
173
169
  #expect(callCount == 1)
@@ -188,10 +184,11 @@ struct CancelAllTests {
188
184
  return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
189
185
  }
190
186
  loader.resolveAndLoad(src: "https://example.com/audio.mp3")
187
+ // cancelAll() drops the handle, so grab it first to await the point where
188
+ // the callback would have fired.
189
+ let resolve = loader.mediaResolverTask
191
190
  loader.cancelAll()
192
-
193
- // Wait for what would have been the callback
194
- try? await Task.sleep(for: .milliseconds(100))
191
+ await resolve?.value
195
192
 
196
193
  #expect(spy.preparedItems.isEmpty)
197
194
  #expect(spy.playbackErrors.isEmpty)
@@ -743,7 +743,7 @@ struct NaturalEndPlayIntentTests {
743
743
  @Suite("PlaybackCoordinator - healthy playback resets retry budget")
744
744
  struct HealthyPlaybackRetryResetTests {
745
745
  @Test @MainActor
746
- func sustainedPlayback_resetsRetryBudget() async throws {
746
+ func sustainedPlayback_resetsRetryBudget() async {
747
747
  let retryHandler = MockRetryHandling()
748
748
  let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
749
749
  let coordinator = PlaybackCoordinator(
@@ -754,7 +754,7 @@ struct HealthyPlaybackRetryResetTests {
754
754
  coordinator.healthyPlaybackDuration = 0.01
755
755
 
756
756
  coordinator.transition(.avPlayerPlaying)
757
- try await Task.sleep(nanoseconds: 100_000_000)
757
+ await coordinator.healthyPlaybackTask?.value
758
758
 
759
759
  #expect(retryHandler.resetCallCount == 1)
760
760
  }
@@ -762,7 +762,7 @@ struct HealthyPlaybackRetryResetTests {
762
762
  /// resetRetry() cancels a pending retry — firing the refill mid-episode
763
763
  /// would kill an in-flight reconnect silently (stuck .playing forever).
764
764
  @Test @MainActor
765
- func refill_skipsWhileRetryIsPending() async throws {
765
+ func refill_skipsWhileRetryIsPending() async {
766
766
  let retryHandler = MockRetryHandling()
767
767
  retryHandler.isRetryableResult = true
768
768
  retryHandler.attemptRetryDelayNs = 1_000_000_000
@@ -776,14 +776,14 @@ struct HealthyPlaybackRetryResetTests {
776
776
 
777
777
  coordinator.transition(.avPlayerPlaying)
778
778
  errorHandler.handleError(URLError(.timedOut), context: .playback)
779
- try await Task.sleep(nanoseconds: 100_000_000)
779
+ await coordinator.healthyPlaybackTask?.value
780
780
 
781
781
  #expect(retryHandler.resetCallCount == 0)
782
782
  #expect(errorHandler.pendingRetryTask != nil)
783
783
  }
784
784
 
785
785
  @Test @MainActor
786
- func playbackInterruptedBeforeThreshold_doesNotReset() async throws {
786
+ func playbackInterruptedBeforeThreshold_doesNotReset() async {
787
787
  let retryHandler = MockRetryHandling()
788
788
  let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
789
789
  let coordinator = PlaybackCoordinator(
@@ -795,9 +795,12 @@ struct HealthyPlaybackRetryResetTests {
795
795
  coordinator.healthyPlaybackDuration = 0.5
796
796
 
797
797
  coordinator.transition(.avPlayerPlaying)
798
+ let refill = coordinator.healthyPlaybackTask
798
799
  coordinator.transition(.avPlayerWaiting) // stalls before the threshold
799
800
 
800
- try await Task.sleep(nanoseconds: 100_000_000)
801
+ // Leaving .playing cancels the refill; awaiting the cancelled task is the
802
+ // deterministic way to reach the point where it would have fired.
803
+ await refill?.value
801
804
 
802
805
  #expect(retryHandler.resetCallCount == 0)
803
806
  }
@@ -2,15 +2,15 @@
2
2
  import Foundation
3
3
  import Testing
4
4
 
5
- // Timing tests use generous margins (50ms+) around DispatchQueue scheduling
6
- // slop. While an @MainActor test awaits Task.sleep, the main queue is free to
7
- // run the manager's scheduled jobs.
5
+ // Timing tests drive a ManualSleepTimerScheduler rather than the main queue:
6
+ // every deadline below is virtual, so nothing races the real clock.
8
7
 
9
8
  @Suite("SleepTimerManager - fade scheduling")
10
9
  struct SleepTimerManagerTests {
11
10
  @Test @MainActor
12
- func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel() async throws {
13
- let manager = SleepTimerManager()
11
+ func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel() {
12
+ let clock = ManualSleepTimerScheduler()
13
+ let manager = SleepTimerManager(scheduler: clock)
14
14
  var fadeStarts: [TimeInterval] = []
15
15
  var completes = 0
16
16
  var cancels = 0
@@ -20,35 +20,37 @@ struct SleepTimerManagerTests {
20
20
  // Attach after set() — replacing a timer emits a defensive onFadeCancel.
21
21
  manager.onFadeCancel = { cancels += 1 }
22
22
 
23
- try await Task.sleep(nanoseconds: 50_000_000) // 0.05s — before the fade window
23
+ clock.advance(to: 0.05) // before the fade window
24
24
  #expect(fadeStarts.isEmpty)
25
25
  #expect(manager.get() != nil)
26
26
 
27
- try await Task.sleep(nanoseconds: 170_000_000) // ~0.22s — inside the fade window
27
+ clock.advance(to: 0.22) // inside the fade window
28
28
  #expect(fadeStarts == [0.15])
29
29
  #expect(completes == 0)
30
30
 
31
- try await Task.sleep(nanoseconds: 180_000_000) // ~0.40s — past the deadline
31
+ clock.advance(to: 0.4) // past the deadline
32
32
  #expect(completes == 1)
33
33
  #expect(cancels == 0)
34
34
  #expect(manager.get() == nil)
35
35
  }
36
36
 
37
37
  @Test @MainActor
38
- func fadeLongerThanTimer_clampsAndStartsImmediately() async throws {
39
- let manager = SleepTimerManager()
38
+ func fadeLongerThanTimer_clampsAndStartsImmediately() {
39
+ let clock = ManualSleepTimerScheduler()
40
+ let manager = SleepTimerManager(scheduler: clock)
40
41
  var fadeStarts: [TimeInterval] = []
41
42
  manager.onFadeStart = { fadeStarts.append($0) }
42
43
 
43
44
  manager.set(seconds: 0.2, fadeDuration: 5)
44
45
 
45
- try await Task.sleep(nanoseconds: 100_000_000) // 0.1s — fade should have begun
46
+ clock.advance(to: 0.1) // fade should have begun
46
47
  #expect(fadeStarts == [0.2])
47
48
  }
48
49
 
49
50
  @Test @MainActor
50
- func clear_emitsFadeCancel_andCancelsScheduledFade() async throws {
51
- let manager = SleepTimerManager()
51
+ func clear_emitsFadeCancel_andCancelsScheduledFade() {
52
+ let clock = ManualSleepTimerScheduler()
53
+ let manager = SleepTimerManager(scheduler: clock)
52
54
  var fadeStarts = 0
53
55
  manager.set(seconds: 0.2, fadeDuration: 0.15)
54
56
  var cancels = 0
@@ -58,13 +60,13 @@ struct SleepTimerManagerTests {
58
60
  #expect(manager.clear())
59
61
  #expect(cancels == 1)
60
62
 
61
- try await Task.sleep(nanoseconds: 300_000_000) // well past the would-be deadline
63
+ clock.advance(to: 0.3) // well past the would-be deadline
62
64
  #expect(fadeStarts == 0)
63
65
  }
64
66
 
65
67
  @Test @MainActor
66
68
  func replacingTimer_emitsFadeCancel() {
67
- let manager = SleepTimerManager()
69
+ let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
68
70
  manager.set(seconds: 60, fadeDuration: 10)
69
71
  var cancels = 0
70
72
  manager.onFadeCancel = { cancels += 1 }
@@ -76,7 +78,7 @@ struct SleepTimerManagerTests {
76
78
 
77
79
  @Test @MainActor
78
80
  func switchingToEndOfTrack_emitsFadeCancel() {
79
- let manager = SleepTimerManager()
81
+ let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
80
82
  manager.set(seconds: 60, fadeDuration: 10)
81
83
  var cancels = 0
82
84
  manager.onFadeCancel = { cancels += 1 }
@@ -88,8 +90,9 @@ struct SleepTimerManagerTests {
88
90
  }
89
91
 
90
92
  @Test @MainActor
91
- func noFadeDuration_schedulesNoFade() async throws {
92
- let manager = SleepTimerManager()
93
+ func noFadeDuration_schedulesNoFade() {
94
+ let clock = ManualSleepTimerScheduler()
95
+ let manager = SleepTimerManager(scheduler: clock)
93
96
  var fadeStarts = 0
94
97
  var completes = 0
95
98
  manager.onFadeStart = { _ in fadeStarts += 1 }
@@ -97,7 +100,7 @@ struct SleepTimerManagerTests {
97
100
 
98
101
  manager.set(seconds: 0.1)
99
102
 
100
- try await Task.sleep(nanoseconds: 200_000_000)
103
+ clock.advance(to: 0.2)
101
104
  #expect(fadeStarts == 0)
102
105
  #expect(completes == 1)
103
106
  }
@@ -0,0 +1,47 @@
1
+ @testable import AudioBrowserTestable
2
+ import Foundation
3
+
4
+ /// A job handed back by `ManualSleepTimerScheduler`.
5
+ private final class ManualJob: SleepTimerJob {
6
+ nonisolated(unsafe) var isCancelled = false
7
+ func cancel() { isCancelled = true }
8
+ }
9
+
10
+ /// Deterministic stand-in for the main-queue scheduler: jobs fire only when the
11
+ /// test advances the clock, so an assertion can never lose a race against a
12
+ /// real deadline on a loaded CI runner.
13
+ @MainActor
14
+ final class ManualSleepTimerScheduler: SleepTimerScheduling {
15
+ private struct Entry {
16
+ let deadline: TimeInterval
17
+ let job: ManualJob
18
+ let work: @MainActor () -> Void
19
+ }
20
+
21
+ private var entries: [Entry] = []
22
+ private(set) var now: TimeInterval = 0
23
+
24
+ func schedule(
25
+ after delay: TimeInterval,
26
+ _ work: @escaping @Sendable @MainActor () -> Void,
27
+ ) -> any SleepTimerJob {
28
+ let job = ManualJob()
29
+ entries.append(Entry(deadline: now + delay, job: job, work: work))
30
+ return job
31
+ }
32
+
33
+ /// Moves the virtual clock to `time`, firing every due job in deadline order.
34
+ /// Jobs run one at a time so a job that schedules or cancels another (the
35
+ /// fade landing before completion) sees the same ordering it would in
36
+ /// production.
37
+ func advance(to time: TimeInterval) {
38
+ now = time
39
+ while let next = entries
40
+ .filter({ !$0.job.isCancelled && $0.deadline <= now })
41
+ .min(by: { $0.deadline < $1.deadline })
42
+ {
43
+ entries.removeAll { $0.job === next.job }
44
+ next.work()
45
+ }
46
+ }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-browser",
3
- "version": "0.2.0-next.1",
3
+ "version": "0.2.0-next.2",
4
4
  "description": "react-native-audio-browser is a react native package built with Nitro",
5
5
  "keywords": [
6
6
  "react-native",