react-native-audio-browser 0.2.0-next.31 → 0.2.0-next.33

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.
Files changed (44) hide show
  1. package/ios/AVPlayerEngine.swift +4 -8
  2. package/ios/Browser/BrowserConfig.swift +0 -5
  3. package/ios/Browser/BrowserError.swift +16 -0
  4. package/ios/Browser/BrowserManager.swift +36 -141
  5. package/ios/Browser/BrowserPathHelper.swift +25 -12
  6. package/ios/Browser/FavoriteSet.swift +46 -0
  7. package/ios/Browser/JsonModels.swift +172 -381
  8. package/ios/Browser/PageResolver.swift +16 -96
  9. package/ios/Browser/RequestLayer.swift +97 -2
  10. package/ios/Browser/RequestPipeline.swift +78 -227
  11. package/ios/Browser/SectionScope.swift +25 -0
  12. package/ios/Browser/SimpleRouter.swift +73 -144
  13. package/ios/Extension/SearchParams+Query.swift +28 -0
  14. package/ios/Extension/Track+Copying.swift +3 -1
  15. package/ios/Extension/Track+Identity.swift +7 -0
  16. package/ios/Http/HttpClient.swift +12 -30
  17. package/ios/HybridAudioBrowser+Browser.swift +5 -21
  18. package/ios/HybridAudioBrowser+Player.swift +3 -3
  19. package/ios/Model/NitroTypeStubs.swift +78 -0
  20. package/ios/Player/AudioSessionController.swift +2 -0
  21. package/ios/Player/ErrorStatus.swift +25 -2
  22. package/ios/Player/MediaLoader.swift +23 -71
  23. package/ios/Player/NowPlayingLines.swift +36 -0
  24. package/ios/Player/NowPlayingUpdater.swift +27 -85
  25. package/ios/Player/Player.swift +108 -261
  26. package/ios/Player/RetryLoop.swift +26 -0
  27. package/ios/Player/SleepTimerManager.swift +13 -15
  28. package/ios/Player/VolumeFader.swift +5 -0
  29. package/ios/Tests/BrowserPathHelperTests.swift +33 -0
  30. package/ios/Tests/ErrorStatusTests.swift +50 -0
  31. package/ios/Tests/FavoriteSetTests.swift +60 -0
  32. package/ios/Tests/JsonModelsTests.swift +30 -0
  33. package/ios/Tests/MediaLoaderTests.swift +26 -21
  34. package/ios/Tests/NowPlayingLinesTests.swift +43 -0
  35. package/ios/Tests/PlayerPolicyTests.swift +19 -3
  36. package/ios/Tests/PlayerTests.swift +81 -74
  37. package/ios/Tests/RequestLayerTests.swift +116 -0
  38. package/ios/Tests/RetryLoopTests.swift +35 -0
  39. package/ios/Tests/SearchParamsQueryTests.swift +48 -0
  40. package/ios/Tests/SectionScopeTests.swift +53 -0
  41. package/ios/Tests/SimpleRouterTests.swift +38 -3
  42. package/ios/Tests/SleepTimerManagerTests.swift +65 -41
  43. package/ios/Tests/Support/TestTypes.swift +2 -2
  44. package/package.json +1 -1
@@ -15,7 +15,11 @@ final class MediaLoader {
15
15
  /// Resolves a playback src into a concrete URL + headers/user-agent.
16
16
  /// The `Track` is threaded through so the resolver can invoke the
17
17
  /// consumer-supplied `media.resolve(track)` as the final media layer.
18
- var mediaUrlResolver: ((String, Track?) async -> MediaResolvedUrl)?
18
+ private let resolveUrl: (String, Track?) async -> MediaResolvedUrl
19
+
20
+ init(resolveUrl: @escaping (String, Track?) async -> MediaResolvedUrl) {
21
+ self.resolveUrl = resolveUrl
22
+ }
19
23
 
20
24
  /// Internal access so AVPlayerEngine can read it.
21
25
  private(set) var asset: AVURLAsset?
@@ -47,51 +51,14 @@ final class MediaLoader {
47
51
  // MARK: - Public API
48
52
 
49
53
  func resolveAndLoad(src: String, track: Track? = nil) {
50
- if let resolver = mediaUrlResolver {
51
- mediaResolverTask?.cancel()
52
- // `[weak self]` like the sibling load tasks below: the resolver is
53
- // consumer-supplied and `cancelAll()` cannot force an in-flight `await`
54
- // to return, so a strong capture let a resolver that never settles pin
55
- // this loader (which also retains the task) indefinitely.
56
- // `Logger` is a value type, so capturing it directly keeps the logging
57
- // off `self` — and `self` weak, so a consumer resolver that never settles
58
- // can't pin the loader (`cancelAll()` can't force an in-flight `await` to
59
- // return).
60
- let logger = logger
61
- mediaResolverTask = Task { [weak self] in
62
- logger.debug("resolveAndLoad: starting resolution for \(src)")
63
-
64
- guard !Task.isCancelled else {
65
- logger.debug("resolveAndLoad: cancelled before start")
66
- return
67
- }
68
-
69
- logger.debug("resolveAndLoad: calling resolver...")
70
- let resolved = await resolver(src, track)
71
-
72
- guard !Task.isCancelled, let self else {
73
- logger.debug("resolveAndLoad: cancelled after resolver returned")
74
- return
75
- }
76
-
77
- self.logger.debug("resolveAndLoad: resolver returned, resolved URL: \(resolved.url)")
78
- if let headers = resolved.headers {
79
- self.logger.debug(" headers: \(headers)")
80
- }
81
- if let userAgent = resolved.userAgent {
82
- self.logger.debug(" userAgent: \(userAgent)")
83
- }
84
-
85
- await MainActor.run {
86
- guard !Task.isCancelled else {
87
- self.logger.debug("resolveAndLoad: cancelled before loadWithResolvedUrl")
88
- return
89
- }
90
- self.loadWithResolvedUrl(resolved)
91
- }
92
- }
93
- } else {
94
- let resolved = MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
54
+ mediaResolverTask?.cancel()
55
+ // `self` stays weak across the await: a consumer resolver that never settles must not pin
56
+ // the loader, and cancellation cannot force its `await` to return.
57
+ let resolveUrl = resolveUrl
58
+ mediaResolverTask = Task { [weak self] in
59
+ guard !Task.isCancelled else { return }
60
+ let resolved = await resolveUrl(src, track)
61
+ guard !Task.isCancelled, let self else { return }
95
62
  loadWithResolvedUrl(resolved)
96
63
  }
97
64
  }
@@ -149,28 +116,17 @@ final class MediaLoader {
149
116
 
150
117
  do {
151
118
  let isPlayable = try await pendingAsset.load(.isPlayable)
152
-
153
- guard !Task.isCancelled else { return }
154
-
155
- await MainActor.run {
156
- guard pendingAsset == self.asset else { return }
157
-
158
- if !isPlayable {
159
- self.onEvent?(.unplayable)
160
- return
161
- }
162
-
163
- let avItem = AVPlayerItem(asset: pendingAsset)
164
- avItem.preferredForwardBufferDuration = self.bufferDuration / 1000.0
165
- self.onEvent?(.prepared(avItem))
119
+ guard isCurrent(pendingAsset) else { return }
120
+ guard isPlayable else {
121
+ onEvent?(.unplayable)
122
+ return
166
123
  }
124
+ let avItem = AVPlayerItem(asset: pendingAsset)
125
+ avItem.preferredForwardBufferDuration = bufferDuration / 1000.0
126
+ onEvent?(.prepared(avItem))
167
127
  } catch {
168
- guard !Task.isCancelled else { return }
169
-
170
- await MainActor.run {
171
- guard pendingAsset == self.asset else { return }
172
- self.onEvent?(.failed(error))
173
- }
128
+ guard isCurrent(pendingAsset) else { return }
129
+ onEvent?(.failed(error))
174
130
  }
175
131
  }
176
132
  }
@@ -232,12 +188,8 @@ final class MediaLoader {
232
188
  return
233
189
  }
234
190
 
235
- let isLocalFile = mediaUrl.isFileURL
236
- logger.debug(" final playbackUrl: \(mediaUrl.absoluteString)")
237
- logger.debug(" isLocalFile: \(isLocalFile)")
238
-
239
191
  loadAsset(
240
- url: isLocalFile ? URL(fileURLWithPath: mediaUrl.path) : mediaUrl,
192
+ url: mediaUrl.isFileURL ? URL(fileURLWithPath: mediaUrl.path) : mediaUrl,
241
193
  options: Self.buildAssetOptions(headers: resolved.headers, userAgent: resolved.userAgent),
242
194
  )
243
195
  }
@@ -0,0 +1,36 @@
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
4
+
5
+ /// The title, artist and album lines the now-playing surface shows.
6
+ struct NowPlayingLines: Equatable {
7
+ let title: String
8
+ let artist: String?
9
+ let album: String?
10
+
11
+ /// Each line from the first of `flash`, `formatted` and `override` that sets it, else the
12
+ /// track's own.
13
+ static func resolve(
14
+ track: Track,
15
+ flash: NowPlayingUpdate? = nil,
16
+ formatted: NowPlayingUpdate? = nil,
17
+ override: NowPlayingUpdate? = nil,
18
+ ) -> NowPlayingLines {
19
+ let layers = [flash, formatted, override].compactMap(\.self)
20
+ return NowPlayingLines(
21
+ title: layers.lazy.compactMap(\.title).first ?? track.title,
22
+ artist: layers.lazy.compactMap(\.artist).first ?? track.artist,
23
+ album: layers.lazy.compactMap(\.album).first ?? track.album,
24
+ )
25
+ }
26
+ }
27
+
28
+ /// What the now-playing surface renders from: the player's track and state.
29
+ @MainActor
30
+ protocol NowPlayingSource: AnyObject {
31
+ var currentTrack: Track? { get }
32
+ var currentTime: Double { get }
33
+ var playWhenReady: Bool { get }
34
+ var isStalled: Bool { get }
35
+ var nitroPlaybackError: PlaybackError? { get }
36
+ }
@@ -3,16 +3,6 @@ import MediaPlayer
3
3
  import NitroModules
4
4
  import os.log
5
5
 
6
- /// What the now-playing surface renders from: the player's track and state.
7
- @MainActor
8
- protocol NowPlayingSource: AnyObject {
9
- var currentTrack: Track? { get }
10
- var currentTime: Double { get }
11
- var playWhenReady: Bool { get }
12
- var isStalled: Bool { get }
13
- var nitroPlaybackError: PlaybackError? { get }
14
- }
15
-
16
6
  /// Owns the now-playing surface: publishes a track's static fields + artwork, and renders the
17
7
  /// title/artist line — a flash, else the formatter's result, else the override or the track's own
18
8
  /// fields. Holds that state (override, flash, latest timed metadata, formatter) and reports each
@@ -86,26 +76,10 @@ final class NowPlayingUpdater {
86
76
  }
87
77
 
88
78
  /// The metadata JS sees for the current track, before any formatter result.
79
+ /// Applies the override whether or not `enabled` is set.
89
80
  var nowPlaying: NowPlayingMetadata? {
90
81
  guard let track = source?.currentTrack else { return nil }
91
- let lines = defaultLines(for: track)
92
- return metadata(track: track, title: lines.title, artist: lines.artist, album: lines.album)
93
- }
94
-
95
- /// Re-renders the text lines for the current track and state. Called on every playback
96
- /// transition; unchanged lines are not re-published.
97
- func render() {
98
- guard let source, let track = source.currentTrack else { return }
99
- render(
100
- track: track,
101
- timedMetadata: timedMetadata,
102
- playWhenReady: source.playWhenReady,
103
- stalled: source.isStalled ? (isOnline ? .buffering : .offline) : nil,
104
- error: source.nitroPlaybackError,
105
- flash: flash,
106
- override: enabled ? override : nil,
107
- formatter: enabled ? formatter : nil,
108
- )
82
+ return metadata(track: track, lines: .resolve(track: track, flash: flash, override: override))
109
83
  }
110
84
 
111
85
  /// Empties the now-playing surface and drops any artwork fetch or formatter result in flight.
@@ -148,11 +122,11 @@ final class NowPlayingUpdater {
148
122
  private static let maxArtworkPixelWidth: CGFloat = 1200
149
123
 
150
124
  /// Publishes the static, per-track fields + artwork on a track change. The title/artist/album
151
- /// lines are owned by `render` (routed through `applyFields` here so it dedupes against later
125
+ /// lines are owned by `render` (routed through `apply` here so it dedupes against later
152
126
  /// renders).
153
127
  func loadMetaValues(for track: Track) {
154
128
  nowPlayingInfoController.set([MPNowPlayingInfoPropertyIsLiveStream: track.live])
155
- applyFields(track: track, title: track.title, artist: track.artist, album: track.album)
129
+ apply(.resolve(track: track), for: track)
156
130
  loadArtwork(for: track)
157
131
  }
158
132
 
@@ -160,34 +134,19 @@ final class NowPlayingUpdater {
160
134
  /// immediately, then — when a formatter is configured — its async result overlaid (each field
161
135
  /// falling back to the default). A `flash` (transient line, e.g. feedback for a refused remote
162
136
  /// command) outranks both: while one is active the formatter pass is skipped entirely, so its
163
- /// async result can't land on top. Safe to call on every playback transition; redundant writes
164
- /// are deduped.
165
- private func render(
166
- track: Track,
167
- timedMetadata: TimedMetadata?,
168
- playWhenReady: Bool,
169
- stalled: StallReason?,
170
- error: PlaybackError?,
171
- flash: NowPlayingUpdate?,
172
- override: NowPlayingUpdate?,
173
- formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
174
- ) {
137
+ /// async result can't land on top. Called on every playback transition; unchanged lines are not
138
+ /// re-published.
139
+ func render() {
140
+ guard let source, let track = source.currentTrack else { return }
141
+ let override = enabled ? self.override : nil
142
+ let formatter = enabled ? self.formatter : nil
175
143
  renderGeneration &+= 1
176
144
  let generation = renderGeneration
177
145
 
178
- let defaultTitle = override?.title ?? track.title
179
- let defaultSecondary = override?.artist ?? track.artist
180
- let defaultAlbum = override?.album ?? track.album
181
-
182
146
  let trackId = track.identity ?? track.path
183
147
 
184
148
  if let flash {
185
- applyFields(
186
- track: track,
187
- title: flash.title ?? defaultTitle,
188
- artist: flash.artist ?? defaultSecondary,
189
- album: flash.album ?? defaultAlbum,
190
- )
149
+ apply(.resolve(track: track, flash: flash, override: override), for: track)
191
150
  // The flash replaced the formatter's line, so the surface no longer shows
192
151
  // one: the render that follows the flash reverting must publish the default
193
152
  // immediately rather than sit on stale flash text awaiting the formatter.
@@ -202,16 +161,16 @@ final class NowPlayingUpdater {
202
161
  // until the async result lands, since the two differ by construction on a live
203
162
  // stream (station name vs. song line).
204
163
  if formatter == nil || formattedTrackId != trackId {
205
- applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
164
+ apply(.resolve(track: track, override: override), for: track)
206
165
  }
207
166
 
208
167
  guard let formatter else { return }
209
168
  let params = FormatNowPlayingParams(
210
169
  track: track,
211
170
  timedMetadata: timedMetadata,
212
- playWhenReady: playWhenReady,
213
- stalled: stalled,
214
- error: error,
171
+ playWhenReady: source.playWhenReady,
172
+ stalled: source.isStalled ? (isOnline ? .buffering : .offline) : nil,
173
+ error: source.nitroPlaybackError,
215
174
  )
216
175
  // `@Sendable` breaks the @MainActor isolation inheritance: Nitro resolves the promise on the JS
217
176
  // thread, so the resolver must NOT be main-isolated (a main-isolated closure run off-main traps
@@ -223,12 +182,7 @@ final class NowPlayingUpdater {
223
182
  Task { @MainActor in
224
183
  // Apply only if no newer render has superseded this one (fast skip / state moved on).
225
184
  guard self.renderGeneration == generation else { return }
226
- self.applyFields(
227
- track: track,
228
- title: formatted.title ?? defaultTitle,
229
- artist: formatted.artist ?? defaultSecondary,
230
- album: formatted.album ?? defaultAlbum,
231
- )
185
+ self.apply(.resolve(track: track, formatted: formatted, override: override), for: track)
232
186
  // A formatter result now owns the surface for this track, so later
233
187
  // renders skip the interim default write.
234
188
  self.formattedTrackId = trackId
@@ -244,21 +198,18 @@ final class NowPlayingUpdater {
244
198
 
245
199
  // MARK: - Private
246
200
 
247
- /// Stamps the title/artist/album (each falling back to the track's own) onto the now-playing
248
- /// surface, and notifies `onChanged` when the text or the track changed.
249
- private func applyFields(track: Track, title: String?, artist: String?, album: String?) {
250
- let resolvedTitle = title ?? track.title
251
- let resolvedArtist = artist ?? track.artist
252
- let resolvedAlbum = album ?? track.album
201
+ /// Stamps the lines onto the now-playing surface, and notifies `onChanged` when the text or the
202
+ /// track changed.
203
+ private func apply(_ lines: NowPlayingLines, for track: Track) {
253
204
  let textChanged = nowPlayingInfoController.set([
254
- MPMediaItemPropertyTitle: resolvedTitle,
255
- MPMediaItemPropertyArtist: resolvedArtist,
256
- MPMediaItemPropertyAlbumTitle: resolvedAlbum,
205
+ MPMediaItemPropertyTitle: lines.title,
206
+ MPMediaItemPropertyArtist: lines.artist,
207
+ MPMediaItemPropertyAlbumTitle: lines.album,
257
208
  ])
258
209
  let trackId = track.identity ?? track.path
259
210
  guard textChanged || trackId != publishedTrackId else { return }
260
211
  publishedTrackId = trackId
261
- onChanged?(metadata(track: track, title: resolvedTitle, artist: resolvedArtist, album: resolvedAlbum))
212
+ onChanged?(metadata(track: track, lines: lines))
262
213
  }
263
214
 
264
215
  private func cancelFlash() {
@@ -267,21 +218,12 @@ final class NowPlayingUpdater {
267
218
  flash = nil
268
219
  }
269
220
 
270
- /// Flash over override over the track's own fields.
271
- private func defaultLines(for track: Track) -> (title: String, artist: String?, album: String?) {
272
- (
273
- flash?.title ?? override?.title ?? track.title,
274
- flash?.artist ?? override?.artist ?? track.artist,
275
- flash?.album ?? override?.album ?? track.album,
276
- )
277
- }
278
-
279
- private func metadata(track: Track, title: String, artist: String?, album: String?) -> NowPlayingMetadata {
221
+ private func metadata(track: Track, lines: NowPlayingLines) -> NowPlayingMetadata {
280
222
  NowPlayingMetadata(
281
223
  elapsedTime: source?.currentTime,
282
- title: title,
283
- album: album,
284
- artist: artist,
224
+ title: lines.title,
225
+ album: lines.album,
226
+ artist: lines.artist,
285
227
  duration: track.duration,
286
228
  artwork: Self.jsArtwork(for: track),
287
229
  description: track.description,