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.
- package/ios/AVPlayerEngine.swift +4 -8
- package/ios/Browser/BrowserConfig.swift +0 -5
- package/ios/Browser/BrowserError.swift +16 -0
- package/ios/Browser/BrowserManager.swift +36 -141
- package/ios/Browser/BrowserPathHelper.swift +25 -12
- package/ios/Browser/FavoriteSet.swift +46 -0
- package/ios/Browser/JsonModels.swift +172 -381
- package/ios/Browser/PageResolver.swift +16 -96
- package/ios/Browser/RequestLayer.swift +97 -2
- package/ios/Browser/RequestPipeline.swift +78 -227
- package/ios/Browser/SectionScope.swift +25 -0
- package/ios/Browser/SimpleRouter.swift +73 -144
- package/ios/Extension/SearchParams+Query.swift +28 -0
- package/ios/Extension/Track+Copying.swift +3 -1
- package/ios/Extension/Track+Identity.swift +7 -0
- package/ios/Http/HttpClient.swift +12 -30
- package/ios/HybridAudioBrowser+Browser.swift +5 -21
- package/ios/HybridAudioBrowser+Player.swift +3 -3
- package/ios/Model/NitroTypeStubs.swift +78 -0
- package/ios/Player/AudioSessionController.swift +2 -0
- package/ios/Player/ErrorStatus.swift +25 -2
- package/ios/Player/MediaLoader.swift +23 -71
- package/ios/Player/NowPlayingLines.swift +36 -0
- package/ios/Player/NowPlayingUpdater.swift +27 -85
- package/ios/Player/Player.swift +108 -261
- package/ios/Player/RetryLoop.swift +26 -0
- package/ios/Player/SleepTimerManager.swift +13 -15
- package/ios/Player/VolumeFader.swift +5 -0
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/ErrorStatusTests.swift +50 -0
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/JsonModelsTests.swift +30 -0
- package/ios/Tests/MediaLoaderTests.swift +26 -21
- package/ios/Tests/NowPlayingLinesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +19 -3
- package/ios/Tests/PlayerTests.swift +81 -74
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/RetryLoopTests.swift +35 -0
- package/ios/Tests/SearchParamsQueryTests.swift +48 -0
- package/ios/Tests/SectionScopeTests.swift +53 -0
- package/ios/Tests/SimpleRouterTests.swift +38 -3
- package/ios/Tests/SleepTimerManagerTests.swift +65 -41
- package/ios/Tests/Support/TestTypes.swift +2 -2
- 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 =
|
|
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 ==
|
|
66
|
-
let resolved = try await resolveRouteEntry(tabsEntry, path:
|
|
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 ==
|
|
74
|
-
throw BrowserError.contentNotFound(path:
|
|
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:
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
137
|
+
for (section, loads) in zip(stamped, artworkLoads) {
|
|
217
138
|
var children: [Track] = []
|
|
218
|
-
for
|
|
219
|
-
let
|
|
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(
|
|
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
|
|
4
|
-
protocol
|
|
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
|
}
|