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
@@ -7,17 +7,7 @@ import os.log
7
7
  /// artwork). Also resolves the tabs and search routes.
8
8
  @MainActor
9
9
  final class PageResolver {
10
- /// Internal path used for the default/root browse source
11
- static let defaultRoutePath = "__default__"
12
-
13
- /// Internal path used for navigation tabs
14
- static let tabsRoutePath = "__tabs__"
15
-
16
- /// Internal path used for search
17
- static let searchRoutePath = "__search__"
18
-
19
10
  let logger = Logger(subsystem: "com.audiobrowser", category: "PageResolver")
20
- private let router = SimpleRouter()
21
11
  private let requests: RequestPipeline
22
12
 
23
13
  private var config: BrowserConfig {
@@ -32,7 +22,7 @@ final class PageResolver {
32
22
  func resolve(_ path: String) async throws -> ResolvedTrack {
33
23
  // Match an explicit route (or the '*' default). With no match, fall back to
34
24
  // the implicit default: fetch the path via the request + browse config.
35
- let routeEntry = (config.routes).flatMap { findBestRouteMatch(path: path, routes: $0) }
25
+ let routeEntry = config.routes.flatMap { SimpleRouter.browseRoute(for: path, in: $0) }
36
26
 
37
27
  let resolvedTrack: ResolvedTrack = if let (entry, routeMatch) = routeEntry {
38
28
  try await resolveRouteEntry(entry, path: path, params: routeMatch.params)
@@ -62,16 +52,16 @@ final class PageResolver {
62
52
 
63
53
  /// The tabs: the `__tabs__` route's page, flattened. Nil when there is no such route.
64
54
  func tabs() async throws -> [Track]? {
65
- guard let tabsEntry = config.routes?.first(where: { $0.path == Self.tabsRoutePath }) else { return nil }
66
- let resolved = try await resolveRouteEntry(tabsEntry, path: Self.tabsRoutePath, params: [:])
55
+ guard let tabsEntry = config.routes?.first(where: { $0.path == SimpleRouter.tabsRoutePath }) else { return nil }
56
+ let resolved = try await resolveRouteEntry(tabsEntry, path: SimpleRouter.tabsRoutePath, params: [:])
67
57
  // Tabs are a flat list, not a page: a sectioned tabs source flattens.
68
58
  return resolved.flattenedChildren ?? []
69
59
  }
70
60
 
71
61
  /// The `__search__` route's results for `params`, unhydrated.
72
62
  func search(_ params: SearchParams) async throws -> [Track] {
73
- guard let searchEntry = config.routes?.first(where: { $0.path == Self.searchRoutePath }) else {
74
- throw BrowserError.contentNotFound(path: Self.searchRoutePath)
63
+ guard let searchEntry = config.routes?.first(where: { $0.path == SimpleRouter.searchRoutePath }) else {
64
+ throw BrowserError.contentNotFound(path: SimpleRouter.searchRoutePath)
75
65
  }
76
66
  if let callback = searchEntry.searchCallback {
77
67
  return try await callback(params).await().await()
@@ -79,54 +69,11 @@ final class PageResolver {
79
69
  if let searchConfig = searchEntry.searchConfig {
80
70
  return try await requests.fetchSearch(searchConfig, params: params)
81
71
  }
82
- throw BrowserError.contentNotFound(path: Self.searchRoutePath)
83
- }
84
-
85
- /// The query params a structured search sends: `q` plus whichever filters are
86
- /// set. Mirrors Android's `executeSearchApiRequest` and the web stub.
87
- ///
88
- /// Single source of truth for "what distinguishes one search from another" —
89
- /// the request sends exactly this, and the cache keys on exactly this, so a
90
- /// field added here can't be forgotten in one place and silently serve one
91
- /// search's results for another.
92
- static func searchQueryParams(_ params: SearchParams) -> [String: String] {
93
- var query: [String: String] = ["q": params.query]
94
- if let mode = params.mode { query["mode"] = mode.stringValue }
95
- if params.reference == .my { query["reference"] = "my" }
96
- if let genre = params.genre { query["genre"] = genre }
97
- if let artist = params.artist { query["artist"] = artist }
98
- if let album = params.album { query["album"] = album }
99
- if let title = params.title { query["title"] = title }
100
- if let playlist = params.playlist { query["playlist"] = playlist }
101
- return query
72
+ throw BrowserError.contentNotFound(path: SimpleRouter.searchRoutePath)
102
73
  }
103
74
 
104
75
  // MARK: - Route Resolution
105
76
 
106
- private func findBestRouteMatch(
107
- path: String,
108
- routes: [NativeRouteEntry],
109
- ) -> (NativeRouteEntry, RouteMatch)? {
110
- // Filter out special routes
111
- let browseRoutes = routes.filter {
112
- $0.path != Self.tabsRoutePath &&
113
- $0.path != Self.searchRoutePath &&
114
- $0.path != Self.defaultRoutePath
115
- }
116
-
117
- // Try to find a matching route
118
- if let match = router.findBestMatch(path: path, routes: browseRoutes) {
119
- return match
120
- }
121
-
122
- // Fall back to __default__ if present
123
- if let defaultRoute = routes.first(where: { $0.path == Self.defaultRoutePath }) {
124
- return (defaultRoute, RouteMatch(params: ["path": path]))
125
- }
126
-
127
- return nil
128
- }
129
-
130
77
  private func resolveRouteEntry(
131
78
  _ entry: NativeRouteEntry,
132
79
  path: String,
@@ -169,43 +116,17 @@ final class PageResolver {
169
116
 
170
117
  // MARK: - Stamping
171
118
 
119
+ /// `sections` stamped with contextual paths, every track's browse-time artwork (no size
120
+ /// context) resolved at once.
172
121
  private func transformSections(
173
122
  _ sections: [Section],
174
123
  parentPath: String,
175
124
  routeEntry: NativeRouteEntry?,
176
125
  ) async throws -> [Section] {
177
- // The contextual index is the flat page position — children concatenated
178
- // in section order (ADR 0009/0010).
179
- var flatIndex = 0
180
- var pageTracks: [[Track]] = []
181
- for section in sections {
182
- var tracks: [Track] = []
183
- for track in section.children {
184
- let index = flatIndex
185
- flatIndex += 1
186
- if track.path == nil, track.src == nil {
187
- throw BrowserError.invalidConfiguration(
188
- "Track must have either 'path' or 'src' for stable identification: \(track.title)",
189
- )
190
- }
191
- if track.src != nil, track.path == nil {
192
- // src != nil guarantees a non-nil identity (id when non-blank, else src).
193
- // The flat page position rides along as a duplicate-identity tie-breaker.
194
- let contextualPath = BrowserPathHelper.build(
195
- parentPath: parentPath, trackId: track.identity!, index: index,
196
- )
197
- tracks.append(track.copying(path: contextualPath))
198
- } else {
199
- tracks.append(track)
200
- }
201
- }
202
- pageTracks.append(tracks)
203
- }
204
-
205
- // Every track's browse-time artwork (no size context) resolves at once.
206
- let artworkConfig = routeEntry?.artwork ?? config.artwork
207
- let artworkLoads = pageTracks.map { tracks in
208
- tracks.map { track in
126
+ let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: parentPath)
127
+ let artworkConfig = routeEntry?.artwork
128
+ let artworkLoads = stamped.map { section in
129
+ section.children.map { track in
209
130
  Task<ImageSource?, Never> { @MainActor in
210
131
  await self.requests.resolveArtworkUrl(track: track, perRouteConfig: artworkConfig)
211
132
  }
@@ -213,14 +134,13 @@ final class PageResolver {
213
134
  }
214
135
 
215
136
  var transformed: [Section] = []
216
- for sectionIndex in sections.indices {
137
+ for (section, loads) in zip(stamped, artworkLoads) {
217
138
  var children: [Track] = []
218
- for trackIndex in pageTracks[sectionIndex].indices {
219
- let track = pageTracks[sectionIndex][trackIndex]
220
- let artwork = await artworkLoads[sectionIndex][trackIndex].value
139
+ for (track, load) in zip(section.children, loads) {
140
+ let artwork = await load.value
221
141
  children.append(artwork.map { track.copying(artworkSource: $0) } ?? track)
222
142
  }
223
- transformed.append(sections[sectionIndex].copying(children: children))
143
+ transformed.append(section.copying(children: children))
224
144
  }
225
145
  return transformed
226
146
  }
@@ -1,7 +1,10 @@
1
1
  import Foundation
2
+ #if canImport(NitroModules)
3
+ import NitroModules
4
+ #endif
2
5
 
3
- /// A request config's fields: the Nitro `RequestConfig` in the app, a plain struct in tests.
4
- protocol RequestFields {
6
+ /// A request config's fields, as every config kind (request, browse, media, artwork) has them.
7
+ protocol RequestFieldValues<Method> {
5
8
  associatedtype Method
6
9
  var method: Method? { get }
7
10
  var path: String? { get }
@@ -11,7 +14,10 @@ protocol RequestFields {
11
14
  var body: String? { get }
12
15
  var contentType: String? { get }
13
16
  var userAgent: String? { get }
17
+ }
14
18
 
19
+ /// A request config the layers build: the Nitro `RequestConfig` in the app, a plain struct in tests.
20
+ protocol RequestFields: RequestFieldValues {
15
21
  init(
16
22
  method: Method?,
17
23
  path: String?,
@@ -24,6 +30,21 @@ protocol RequestFields {
24
30
  )
25
31
  }
26
32
 
33
+ extension RequestFields {
34
+ /// The config a layer stack starts from: only `path` set.
35
+ static func base(path: String?) -> Self {
36
+ Self(method: nil, path: path, baseUrl: nil, headers: nil, query: nil, body: nil, contentType: nil, userAgent: nil)
37
+ }
38
+
39
+ /// `fields`' values, copied.
40
+ init(fieldsOf fields: some RequestFieldValues<Method>) {
41
+ self.init(
42
+ method: fields.method, path: fields.path, baseUrl: fields.baseUrl, headers: fields.headers,
43
+ query: fields.query, body: fields.body, contentType: fields.contentType, userAgent: fields.userAgent,
44
+ )
45
+ }
46
+ }
47
+
27
48
  /// How Request-Config Layers compose, least specific first, each over the one before. The one
28
49
  /// implementation of these rules; Android and the web stub follow the same ones.
29
50
  enum RequestLayer {
@@ -87,4 +108,78 @@ enum RequestLayer {
87
108
  guard let override else { return base }
88
109
  return base.merging(override) { _, new in new }
89
110
  }
111
+
112
+ // MARK: - Artwork
113
+
114
+ /// An artwork request, in order: `artwork`'s static fields over `base`, path included; the
115
+ /// per-track config `resolve` produces; `sizeQuery`; the transforms, each handed the result of
116
+ /// the one before; then the track's `id` for `{id}`. Nil when `resolve` produces nothing for a
117
+ /// track without artwork, or when no path remains.
118
+ nonisolated(nonsending) static func artwork<C: RequestFields>(
119
+ _ artwork: C,
120
+ over base: C,
121
+ resolve: (nonisolated(nonsending)() async throws -> C?)?,
122
+ trackHasArtwork: Bool,
123
+ sizeQuery: [String: String],
124
+ transforms: [nonisolated(nonsending)(C) async throws -> C],
125
+ id: String?,
126
+ ) async throws -> C? {
127
+ var request = merge(base, artwork)
128
+ if let resolve {
129
+ if let resolved = try await resolve() {
130
+ request = merge(request, resolved)
131
+ } else if !trackHasArtwork {
132
+ return nil
133
+ }
134
+ }
135
+ if !sizeQuery.isEmpty {
136
+ request = merge(request, C(
137
+ method: nil, path: nil, baseUrl: nil, headers: nil,
138
+ query: sizeQuery, body: nil, contentType: nil, userAgent: nil,
139
+ ))
140
+ }
141
+ for transform in transforms {
142
+ request = try await transform(request)
143
+ }
144
+ request = C(
145
+ method: request.method,
146
+ path: TrackIdTemplate.substitute(request.path, id: id),
147
+ baseUrl: request.baseUrl,
148
+ headers: TrackIdTemplate.substitute(request.headers, id: id),
149
+ query: TrackIdTemplate.substitute(request.query, id: id),
150
+ body: request.body,
151
+ contentType: request.contentType,
152
+ userAgent: request.userAgent,
153
+ )
154
+ return request.path == nil ? nil : request
155
+ }
156
+
157
+ /// The image-size query `params` names for `context`'s width and height, in whole pixels.
158
+ static func sizeQuery(_ params: ImageQueryParams?, _ context: ImageContext?) -> [String: String] {
159
+ guard let params, let context else { return [:] }
160
+ var query: [String: String] = [:]
161
+ if let key = params.width, let width = context.width { query[key] = String(Int(width)) }
162
+ if let key = params.height, let height = context.height { query[key] = String(Int(height)) }
163
+ return query
164
+ }
165
+
166
+ /// Whether `request` still carries an `{id}` token: the track had no id to fill it.
167
+ static func hasUnfilledIdToken(_ request: some RequestFieldValues) -> Bool {
168
+ TrackIdTemplate.containsToken(request.path)
169
+ || TrackIdTemplate.containsToken(request.query)
170
+ || TrackIdTemplate.containsToken(request.headers)
171
+ }
172
+
173
+ /// The headers an image load sends: `request.headers`, plus `userAgent` as `User-Agent` and
174
+ /// `contentType` as `Content-Type` unless a header sets them. Nil when there are none.
175
+ static func imageHeaders(_ request: some RequestFieldValues) -> [String: String]? {
176
+ var headers = request.headers ?? [:]
177
+ if let userAgent = request.userAgent, headers["User-Agent"] == nil {
178
+ headers["User-Agent"] = userAgent
179
+ }
180
+ if let contentType = request.contentType, headers["Content-Type"] == nil {
181
+ headers["Content-Type"] = contentType
182
+ }
183
+ return headers.isEmpty ? nil : headers
184
+ }
90
185
  }