react-native-audio-browser 0.2.0-next.30 → 0.2.0-next.32

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.
@@ -84,6 +84,13 @@ final class RetryLoop {
84
84
  /// Retries executed since the budgets last reset.
85
85
  private(set) var attempts = 0
86
86
 
87
+ /// Continuous audible playback that proves the stream recovered, so the budgets refill
88
+ /// (Android's HEALTHY_PLAYBACK_MS); without it a long-lived stream keeps its spent budget until
89
+ /// the track changes.
90
+ var healthyPlaybackDuration: Duration = .seconds(20)
91
+ /// The pending refill. Readable so tests can await the window instead of sleeping past it.
92
+ private(set) var healthyPlaybackTask: Task<Void, Never>?
93
+
87
94
  private var policy: Policy = .disabled
88
95
  private var firstRetryTime: Date?
89
96
  /// First failure observed while online for the current load: the first-connect budget's
@@ -167,6 +174,25 @@ final class RetryLoop {
167
174
  isWaitingForNetwork = false
168
175
  }
169
176
 
177
+ // MARK: - Healthy Playback
178
+
179
+ /// Starts the healthy-playback window; when it elapses between failures, the budgets refill.
180
+ func playbackBecameAudible() {
181
+ healthyPlaybackTask?.cancel()
182
+ healthyPlaybackTask = Task { [weak self, healthyPlaybackDuration] in
183
+ try? await Task.sleep(for: healthyPlaybackDuration)
184
+ guard let self, !Task.isCancelled else { return }
185
+ // reset() cancels a pending retry, which would silently kill an in-flight reconnect.
186
+ guard pendingTask == nil else { return }
187
+ reset()
188
+ }
189
+ }
190
+
191
+ func playbackStoppedBeingAudible() {
192
+ healthyPlaybackTask?.cancel()
193
+ healthyPlaybackTask = nil
194
+ }
195
+
170
196
  // MARK: - Budgets
171
197
 
172
198
  private var isOnline: Bool { networkMonitor?.isOnline ?? true }
@@ -44,15 +44,21 @@ final class SleepTimerManager {
44
44
  // MARK: - Properties
45
45
 
46
46
  private let scheduler: any SleepTimerScheduling
47
+ /// Fades the volume out before a time-based timer's deadline.
48
+ private let fader: VolumeFader
47
49
 
48
50
  // nonisolated(unsafe) for deinit cleanup — deinit is always nonisolated in Swift 6.
49
51
  private nonisolated(unsafe) var sleepTimerJob: (any SleepTimerJob)?
50
52
  private nonisolated(unsafe) var fadeJob: (any SleepTimerJob)?
51
53
 
52
- init(scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
54
+ init(fader: VolumeFader, scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
55
+ self.fader = fader
53
56
  self.scheduler = scheduler
54
57
  }
55
58
 
59
+ /// Whether the fade before the deadline is running.
60
+ var isFading: Bool { fader.isActive }
61
+
56
62
  /// The time when playback should stop (seconds since epoch), or -1 if inactive
57
63
  private(set) var sleepTimerTime: TimeInterval = -1 {
58
64
  didSet {
@@ -73,17 +79,9 @@ final class SleepTimerManager {
73
79
  }
74
80
  }
75
81
 
76
- /// Callback invoked when the sleep timer fires
82
+ /// Halts playback when the timer fires; the pre-fade volume is restored after it returns.
77
83
  var onComplete: (() -> Void)?
78
84
 
79
- /// Callback invoked when the fade-out window of a time-based timer begins
80
- var onFadeStart: ((_ duration: TimeInterval) -> Void)?
81
-
82
- /// Callback invoked when a timer is cancelled/replaced while its fade may be
83
- /// running — the listener restores the pre-fade volume. Not invoked on
84
- /// completion: there the pause lands first, then the volume is restored.
85
- var onFadeCancel: (() -> Void)?
86
-
87
85
  /// Callback invoked when the sleep timer state changes
88
86
  var onChanged: ((SleepTimerState) -> Void)?
89
87
 
@@ -97,7 +95,7 @@ final class SleepTimerManager {
97
95
  if !hasSleepTimer { return false }
98
96
  sleepTimerTime = -1
99
97
  willSleepWhenCurrentItemReachesEnd = false
100
- onFadeCancel?()
98
+ fader.cancel(restoringVolume: true)
101
99
  onChanged?(.first(NullType.null))
102
100
  return true
103
101
  }
@@ -129,7 +127,7 @@ final class SleepTimerManager {
129
127
  /// deadline (clamped to `seconds`); silence lands exactly at the deadline
130
128
  func set(seconds: TimeInterval, fadeDuration: TimeInterval? = nil) {
131
129
  cancelSleepTimerJob()
132
- onFadeCancel?()
130
+ fader.cancel(restoringVolume: true)
133
131
  sleepTimerTime = Date().timeIntervalSince1970 + seconds
134
132
  sleepTimerJob = scheduler.schedule(after: seconds) { [weak self] in
135
133
  self?.complete()
@@ -137,7 +135,7 @@ final class SleepTimerManager {
137
135
  if let fadeDuration, fadeDuration > 0 {
138
136
  let fade = min(fadeDuration, seconds)
139
137
  fadeJob = scheduler.schedule(after: seconds - fade) { [weak self] in
140
- self?.onFadeStart?(fade)
138
+ self?.fader.start(duration: fade)
141
139
  }
142
140
  }
143
141
  if let state = get() { onChanged?(state) }
@@ -148,7 +146,7 @@ final class SleepTimerManager {
148
146
  let changed = !willSleepWhenCurrentItemReachesEnd
149
147
  willSleepWhenCurrentItemReachesEnd = true
150
148
  if changed {
151
- onFadeCancel?()
149
+ fader.cancel(restoringVolume: true)
152
150
  if let state = get() { onChanged?(state) }
153
151
  }
154
152
  }
@@ -182,7 +180,7 @@ final class SleepTimerManager {
182
180
  sleepTimerTime = -1
183
181
  willSleepWhenCurrentItemReachesEnd = false
184
182
  onChanged?(.first(NullType.null))
185
- onComplete?()
183
+ fader.resolve { onComplete?() }
186
184
  }
187
185
 
188
186
  deinit {
@@ -19,6 +19,9 @@ final class VolumeFader {
19
19
 
20
20
  var isActive: Bool { originalVolume != nil }
21
21
 
22
+ /// The length of the running fade.
23
+ private(set) var duration: TimeInterval?
24
+
22
25
  init(getVolume: @escaping () -> Float, setVolume: @escaping (Float) -> Void) {
23
26
  self.getVolume = getVolume
24
27
  self.setVolume = setVolume
@@ -30,6 +33,7 @@ final class VolumeFader {
30
33
  let baseVolume = originalVolume ?? getVolume()
31
34
  cancel(restoringVolume: false)
32
35
  originalVolume = baseVolume
36
+ self.duration = duration
33
37
  guard duration > 0 else { return }
34
38
 
35
39
  let startDate = Date()
@@ -57,6 +61,7 @@ final class VolumeFader {
57
61
  timer = nil
58
62
  guard let originalVolume else { return }
59
63
  self.originalVolume = nil
64
+ duration = nil
60
65
  if restoringVolume { setVolume(originalVolume) }
61
66
  }
62
67
 
@@ -0,0 +1,50 @@
1
+ @testable import AudioBrowserTestable
2
+ import Testing
3
+
4
+ @Suite("ErrorStatus.after(event:from:to:)")
5
+ struct ErrorStatusTests {
6
+ private let retrying = ErrorStatus.retrying(.hostUnreachable)
7
+
8
+ @Test func anErrorEventIsATerminalReport() {
9
+ let status = ErrorStatus.none.after(event: .errorOccurred(.playbackFailed), from: .playing, to: .error)
10
+ #expect(status.error == .playbackFailed)
11
+ #expect(!status.isRetrying)
12
+ }
13
+
14
+ @Test func aRepeatedTerminalReportDiffersFromTheLast() {
15
+ let first = ErrorStatus.none.after(event: .errorOccurred(.playbackFailed), from: .playing, to: .error)
16
+ let repeated = first.after(event: .errorOccurred(.playbackFailed), from: .error, to: .error)
17
+ #expect(repeated.error == .playbackFailed)
18
+ #expect(repeated != first)
19
+ }
20
+
21
+ @Test func anErrorEventReplacesARetryingError() {
22
+ let status = retrying.after(event: .errorOccurred(.playbackFailed), from: .loading, to: .error)
23
+ #expect(status.error == .playbackFailed)
24
+ #expect(!status.isRetrying)
25
+ }
26
+
27
+ @Test func leavingErrorClearsIt() {
28
+ let terminal = ErrorStatus.none.after(event: .errorOccurred(.playbackFailed), from: .playing, to: .error)
29
+ #expect(terminal.after(event: .trackLoading, from: .error, to: .loading) == .none)
30
+ }
31
+
32
+ @Test(arguments: [PlaybackState.loading, .buffering, .paused])
33
+ func aRetryingErrorRidesAlongWhereItShows(_ state: PlaybackState) {
34
+ #expect(retrying.after(event: .trackLoading, from: .loading, to: state) == retrying)
35
+ }
36
+
37
+ @Test(arguments: [PlaybackState.ready, .playing, .stopped, .none, .ended])
38
+ func aRetryingErrorClearsWhereItDoesNotShow(_ state: PlaybackState) {
39
+ #expect(retrying.after(event: .avPlayerPlaying, from: .loading, to: state) == .none)
40
+ }
41
+
42
+ @Test func otherwiseTheStatusHolds() {
43
+ #expect(ErrorStatus.none.after(event: .avPlayerPlaying, from: .ready, to: .playing) == .none)
44
+ }
45
+
46
+ @Test(arguments: [PlaybackState.none, .loading, .ready, .buffering, .playing, .paused, .stopped, .ended, .error])
47
+ func onlyLoadingBufferingAndPausedCarryAnAdvisoryError(_ state: PlaybackState) {
48
+ #expect(state.carriesAdvisoryError == [.loading, .buffering, .paused].contains(state))
49
+ }
50
+ }
@@ -219,3 +219,33 @@ struct JsonStyleTests {
219
219
  #expect(snapshot?.cardImage == "background")
220
220
  }
221
221
  }
222
+
223
+ @Suite("JsonTrack persistence")
224
+ struct JsonTrackPersistenceTests {
225
+ /// The snapshot a resume restores keeps every field a track persists.
226
+ @Test func aTrackSurvivesTheRoundTripThroughItsSnapshot() throws {
227
+ let track = Track(
228
+ id: "t1", path: "/station/1", src: "https://example.com/a.mp3",
229
+ artwork: .first("https://example.com/a.jpg"),
230
+ request: TrackRequest(userAgent: "Agent", headers: ["X": "1"], query: ["q": "2"]),
231
+ title: "Title", subtitle: "Subtitle", artist: "Artist", albumPath: "/album/1", album: "Album",
232
+ description: "Description", genre: "Genre", duration: 123,
233
+ disabled: false, live: true, icyCharset: "windows-1251",
234
+ )
235
+ let data = try JSONEncoder().encode(JsonTrack(from: track))
236
+ let restored = try JSONDecoder().decode(JsonTrack.self, from: data).toNitro()
237
+ #expect(restored == track)
238
+ }
239
+
240
+ /// A favorite is the app's state, never persisted with the track; neither is the resolved artwork.
241
+ @Test func favoritedAndTheResolvedArtworkAreNotPersisted() throws {
242
+ let track = Track(
243
+ id: "t1", src: "https://example.com/a.mp3",
244
+ artworkSource: ImageSource(uri: "https://example.com/r.jpg", method: nil, headers: nil, body: nil),
245
+ title: "Title", favorited: true,
246
+ )
247
+ let restored = JsonTrack(from: track).toNitro()
248
+ #expect(restored.favorited == nil)
249
+ #expect(restored.artworkSource == nil)
250
+ }
251
+ }
@@ -23,9 +23,20 @@ private final class MediaLoaderSpy {
23
23
  }
24
24
  }
25
25
 
26
+ /// Resolves and loads `src`, returning once the resolved URL is handed to the asset load.
26
27
  @MainActor
27
- private func makeLoader() -> (MediaLoader, MediaLoaderSpy) {
28
- let loader = MediaLoader()
28
+ private func load(_ loader: MediaLoader, _ src: String) async {
29
+ loader.resolveAndLoad(src: src)
30
+ await loader.mediaResolverTask?.value
31
+ }
32
+
33
+ @MainActor
34
+ private func makeLoader(
35
+ resolveUrl: @escaping (String, Track?) async -> MediaResolvedUrl = { src, _ in
36
+ MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
37
+ },
38
+ ) -> (MediaLoader, MediaLoaderSpy) {
39
+ let loader = MediaLoader(resolveUrl: resolveUrl)
29
40
  let spy = MediaLoaderSpy()
30
41
  loader.onEvent = { spy.record($0) }
31
42
  return (loader, spy)
@@ -52,9 +63,9 @@ struct MediaLoaderInitialStateTests {
52
63
  @Suite("resolveAndLoad")
53
64
  @MainActor
54
65
  struct ResolveAndLoadTests {
55
- @Test func invalidUrl_callsDelegateWithError() {
66
+ @Test func invalidUrl_callsDelegateWithError() async {
56
67
  let (loader, spy) = makeLoader()
57
- loader.resolveAndLoad(src: "")
68
+ await load(loader, "")
58
69
  #expect(spy.playbackErrors.count == 1)
59
70
  if case let .invalidSourceUrl(url) = spy.playbackErrors.first {
60
71
  #expect(url == "")
@@ -63,25 +74,24 @@ struct ResolveAndLoadTests {
63
74
  }
64
75
  }
65
76
 
66
- @Test func validHttpUrl_createsAsset() {
77
+ @Test func validHttpUrl_createsAsset() async {
67
78
  let (loader, _) = makeLoader()
68
- loader.resolveAndLoad(src: "https://example.com/audio.mp3")
79
+ await load(loader, "https://example.com/audio.mp3")
69
80
  #expect(loader.asset != nil)
70
81
  #expect(loader.asset?.url.absoluteString == "https://example.com/audio.mp3")
71
82
  }
72
83
 
73
- @Test func fileUrl_convertsCorrectly() {
84
+ @Test func fileUrl_convertsCorrectly() async {
74
85
  let (loader, _) = makeLoader()
75
- loader.resolveAndLoad(src: "file:///tmp/audio.mp3")
86
+ await load(loader, "file:///tmp/audio.mp3")
76
87
  #expect(loader.asset != nil)
77
88
  #expect(loader.asset?.url.isFileURL == true)
78
89
  #expect(loader.asset?.url.path == "/tmp/audio.mp3")
79
90
  }
80
91
 
81
92
  @Test func withResolver_callsResolver() async {
82
- let (loader, _) = makeLoader()
83
93
  var resolverCalled = false
84
- loader.mediaUrlResolver = { src, _ in
94
+ let (loader, _) = makeLoader { src, _ in
85
95
  resolverCalled = true
86
96
  return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
87
97
  }
@@ -93,14 +103,11 @@ struct ResolveAndLoadTests {
93
103
  }
94
104
 
95
105
  @Test func withResolver_returningInvalidUrl_callsErrorDelegate() async {
96
- let (loader, spy) = makeLoader()
97
- loader.mediaUrlResolver = { _, _ in
106
+ let (loader, spy) = makeLoader { _, _ in
98
107
  MediaResolvedUrl(url: "", headers: nil, userAgent: nil)
99
108
  }
100
109
  loader.resolveAndLoad(src: "https://example.com/audio.mp3")
101
110
 
102
- // The task awaits the resolver then hops back to the main actor, so the
103
- // delegate call has landed by the time the task itself completes.
104
111
  await loader.mediaResolverTask?.value
105
112
 
106
113
  #expect(spy.playbackErrors.count == 1)
@@ -112,9 +119,8 @@ struct ResolveAndLoadTests {
112
119
  }
113
120
 
114
121
  @Test func cancelsPreviousResolverTask() async {
115
- let (loader, _) = makeLoader()
116
122
  var callCount = 0
117
- loader.mediaUrlResolver = { src, _ in
123
+ let (loader, _) = makeLoader { src, _ in
118
124
  callCount += 1
119
125
  // Simulate slow resolution
120
126
  try? await Task.sleep(for: .milliseconds(100))
@@ -142,8 +148,7 @@ struct ResolveAndLoadTests {
142
148
  @MainActor
143
149
  struct CancelAllTests {
144
150
  @Test func preventsDelegateCallbacksAfterResolveAndLoad() async {
145
- let (loader, spy) = makeLoader()
146
- loader.mediaUrlResolver = { src, _ in
151
+ let (loader, spy) = makeLoader { src, _ in
147
152
  try? await Task.sleep(for: .milliseconds(50))
148
153
  return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
149
154
  }
@@ -164,9 +169,9 @@ struct CancelAllTests {
164
169
  @Suite("clearAsset")
165
170
  @MainActor
166
171
  struct ClearAssetTests {
167
- @Test func nilsOutAsset() {
172
+ @Test func nilsOutAsset() async {
168
173
  let (loader, _) = makeLoader()
169
- loader.resolveAndLoad(src: "https://example.com/audio.mp3")
174
+ await load(loader, "https://example.com/audio.mp3")
170
175
  #expect(loader.asset != nil)
171
176
  loader.clearAsset()
172
177
  #expect(loader.asset == nil)
@@ -187,7 +192,7 @@ struct ClearAssetTests {
187
192
  struct AsyncLoadingTests {
188
193
  @Test func nonExistentLocalFile_eventuallyCallsRetryableError() async {
189
194
  let (loader, spy) = makeLoader()
190
- loader.resolveAndLoad(src: "file:///nonexistent/path.mp3")
195
+ await load(loader, "file:///nonexistent/path.mp3")
191
196
 
192
197
  // Poll for delegate callback — asset loading is async
193
198
  for _ in 0 ..< 50 {
@@ -0,0 +1,43 @@
1
+ @testable import AudioBrowserTestable
2
+ import Testing
3
+
4
+ @Suite("NowPlayingLines.resolve")
5
+ struct NowPlayingLinesTests {
6
+ private let track = Track(title: "Station", artist: "City", album: "Country")
7
+
8
+ @Test func withoutLayersTheTrackShows() {
9
+ #expect(NowPlayingLines.resolve(track: track) == NowPlayingLines(title: "Station", artist: "City", album: "Country"))
10
+ }
11
+
12
+ @Test func theOverrideOutranksTheTrack() {
13
+ let lines = NowPlayingLines.resolve(track: track, override: NowPlayingUpdate(title: "Show", artist: nil, album: nil))
14
+ #expect(lines == NowPlayingLines(title: "Show", artist: "City", album: "Country"))
15
+ }
16
+
17
+ @Test func theFormatterOutranksTheOverride() {
18
+ let lines = NowPlayingLines.resolve(
19
+ track: track,
20
+ formatted: NowPlayingUpdate(title: "Song", artist: nil, album: nil),
21
+ override: NowPlayingUpdate(title: "Show", artist: "Host", album: nil),
22
+ )
23
+ #expect(lines == NowPlayingLines(title: "Song", artist: "Host", album: "Country"))
24
+ }
25
+
26
+ @Test func theFlashOutranksEverything() {
27
+ let lines = NowPlayingLines.resolve(
28
+ track: track,
29
+ flash: NowPlayingUpdate(title: nil, artist: "Can't skip", album: nil),
30
+ formatted: NowPlayingUpdate(title: "Song", artist: "Artist", album: nil),
31
+ override: NowPlayingUpdate(title: "Show", artist: "Host", album: "Album"),
32
+ )
33
+ #expect(lines == NowPlayingLines(title: "Song", artist: "Can't skip", album: "Album"))
34
+ }
35
+
36
+ @Test func eachLineFallsBackSeparately() {
37
+ let lines = NowPlayingLines.resolve(
38
+ track: Track(title: "Station"),
39
+ flash: NowPlayingUpdate(title: nil, artist: nil, album: nil),
40
+ )
41
+ #expect(lines == NowPlayingLines(title: "Station", artist: nil, album: nil))
42
+ }
43
+ }
@@ -119,19 +119,19 @@ struct PlayerEngineEventTests {
119
119
  let (player, engine, _) = makeLoading()
120
120
  engine.emit(.unplayable)
121
121
  #expect(player.state == .error)
122
- #expect(player.errorStatus == .terminal(.trackWasUnplayable))
122
+ #expect(player.errorStatus == .terminal(.trackWasUnplayable, report: 1))
123
123
  }
124
124
 
125
125
  @Test func anInvalidSourceIsTerminal() {
126
126
  let (player, engine, _) = makeLoading()
127
127
  engine.emit(.invalidSource("::"))
128
- #expect(player.errorStatus == .terminal(.invalidSourceUrl("::")))
128
+ #expect(player.errorStatus == .terminal(.invalidSourceUrl("::"), report: 1))
129
129
  }
130
130
 
131
131
  @Test func aPlayerFailureCarriesItsHTTPStatus() {
132
132
  let (player, engine, _) = makePlaying()
133
133
  engine.emit(.failed(nil, httpStatusCode: 404))
134
- #expect(player.errorStatus == .terminal(.httpStatus(404)))
134
+ #expect(player.errorStatus == .terminal(.httpStatus(404), report: 1))
135
135
  }
136
136
 
137
137
  @Test func aGenuineEndAdvances_aLiveEndReconnects() {
@@ -406,6 +406,22 @@ struct PlayerReviewRegressionTests {
406
406
  #expect(makePlayer(retries: false).persistedSession() == nil)
407
407
  }
408
408
 
409
+ /// A track change while the old track loads with a retrying error publishes the new track's
410
+ /// loading state without that error.
411
+ @Test func aTrackChangeClearsTheRetryingErrorOfTheTrackItLeaves() async {
412
+ let (player, engine, callbacks) = makeLoading()
413
+ player.retry.shouldRetry = { true }
414
+ player.setPlayWhenReady(true)
415
+ engine.emit(.loadFailed(URLError(.timedOut)))
416
+ #expect(callbacks.playbackChanges.last?.error != nil)
417
+
418
+ player.setQueue([Track(id: "t2", src: "https://example.com/b.mp3", title: "B")])
419
+
420
+ #expect(callbacks.playbackChanges.last?.state == .loading)
421
+ #expect(callbacks.playbackChanges.last?.error == nil)
422
+ player.retry.cancel()
423
+ }
424
+
409
425
  /// Leaving an errored track publishes the new track's loading state once, never an error
410
426
  /// state without its error.
411
427
  @Test func skippingAwayFromAnErrorNeverPublishesAnErrorlessError() {