react-native-audio-browser 0.2.0-next.32 → 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.
@@ -8,11 +8,6 @@ struct RouteMatch {
8
8
 
9
9
  /// Specificity score (higher = more specific match)
10
10
  let specificity: Int
11
-
12
- init(params: [String: String] = [:], specificity: Int = 0) {
13
- self.params = params
14
- self.specificity = specificity
15
- }
16
11
  }
17
12
 
18
13
  /// Protocol for route entries that can be matched.
@@ -20,165 +15,99 @@ protocol RouteEntry {
20
15
  var path: String { get }
21
16
  }
22
17
 
23
- /// Simple route matcher for path patterns with parameter extraction.
18
+ /// Route pattern matching with parameter extraction.
24
19
  ///
25
20
  /// Supports patterns like:
26
21
  /// - /artists - exact match
27
22
  /// - /artists/{id} - parameter extraction
28
- /// - /artists/{id}/albums - multiple segments with parameters
29
23
  /// - /artists/* - wildcard match (matches any single segment)
30
- /// - /files/** - tail wildcard (matches remaining path segments)
24
+ /// - /files/** - tail wildcard (matches remaining path segments, captured as `tail`)
31
25
  ///
32
- /// Route specificity rules:
33
- /// - More path segments = higher specificity
34
- /// - Constant segments > parameter segments > wildcards > tail wildcards
35
- /// - Most specific route wins
36
- final class SimpleRouter {
37
- /// Finds the best matching route for the given path.
38
- ///
39
- /// - Parameters:
40
- /// - path: The incoming path to match (e.g., "/artists/123")
41
- /// - routes: Array of route entries to match against
42
- /// - Returns: Tuple of (route, match result) or nil if no match
43
- func findBestMatch<T: RouteEntry>(path: String, routes: [T]) -> (route: T, match: RouteMatch)? {
44
- var bestMatch: (route: T, match: RouteMatch)?
26
+ /// The most specific route wins, the first on a tie: constant segments over parameters over
27
+ /// wildcards over the tail wildcard, then more segments.
28
+ enum SimpleRouter {
29
+ /// The route the top-level `browse:` source is flattened into.
30
+ static let defaultRoutePath = "__default__"
31
+ static let tabsRoutePath = "__tabs__"
32
+ static let searchRoutePath = "__search__"
33
+
34
+ /// The route that browses `path`: the best match among the routes that are not tabs, search or
35
+ /// the default, else the default route with `path` as its `path` param.
36
+ static func browseRoute<T: RouteEntry>(for path: String, in routes: [T]) -> (route: T, match: RouteMatch)? {
37
+ let browseRoutes = routes.filter {
38
+ $0.path != tabsRoutePath && $0.path != searchRoutePath && $0.path != defaultRoutePath
39
+ }
40
+ if let match = findBestMatch(path: path, routes: browseRoutes) {
41
+ return match
42
+ }
43
+ guard let defaultRoute = routes.first(where: { $0.path == defaultRoutePath }) else { return nil }
44
+ return (defaultRoute, RouteMatch(params: ["path": path], specificity: 0))
45
+ }
45
46
 
47
+ /// The most specific of `routes` matching `path`, or nil when none does.
48
+ static func findBestMatch<T: RouteEntry>(path: String, routes: [T]) -> (route: T, match: RouteMatch)? {
49
+ var best: (route: T, match: RouteMatch)?
46
50
  for route in routes {
47
- if let match = matchPattern(pattern: route.path, path: path) {
48
- if bestMatch == nil || match.specificity > bestMatch!.match.specificity {
49
- bestMatch = (route, match)
50
- }
51
+ guard let match = match(pattern: route.path, path: path) else { continue }
52
+ if best.map({ match.specificity > $0.match.specificity }) ?? true {
53
+ best = (route, match)
51
54
  }
52
55
  }
53
-
54
- return bestMatch
56
+ return best
55
57
  }
56
58
 
57
- /// Attempts to match a single pattern against a path.
58
- ///
59
- /// - Parameters:
60
- /// - pattern: Route pattern (e.g., "/artists/{id}", "/files/*", "/api/**")
61
- /// - path: Incoming path (e.g., "/artists/123")
62
- /// - Returns: RouteMatch if successful, nil otherwise
63
- private func matchPattern(pattern: String, path: String) -> RouteMatch? {
64
- let patternSegments = pattern.split(separator: "/").map(String.init)
65
- let pathSegments = path.split(separator: "/").map(String.init)
66
-
67
- var params: [String: String] = [:]
68
- var constantSegments = 0
69
- var parameterSegments = 0
70
- var wildcardSegments = 0
71
- var hasTailWildcard = false
72
-
73
- // Check for tail wildcard at end
74
- if let lastSegment = patternSegments.last, lastSegment == "**" {
75
- hasTailWildcard = true
76
-
77
- // For tail wildcard, pattern must have <= path segments
78
- if patternSegments.count - 1 > pathSegments.count {
79
- return nil
80
- }
81
-
82
- // Match all segments except the tail wildcard
83
- for i in 0 ..< (patternSegments.count - 1) {
84
- guard
85
- matchSingleSegment(
86
- patternSegment: patternSegments[i],
87
- pathSegment: pathSegments[i],
88
- params: &params,
89
- )
90
- else {
91
- return nil
92
- }
93
-
94
- updateSegmentCounts(
95
- patternSegment: patternSegments[i],
96
- constantSegments: &constantSegments,
97
- parameterSegments: &parameterSegments,
98
- wildcardSegments: &wildcardSegments,
99
- )
100
- }
101
-
102
- // Capture remaining path segments as "tail" parameter (matches Kotlin behavior)
103
- if pathSegments.count > patternSegments.count - 1 {
104
- let remainingSegments = pathSegments.dropFirst(patternSegments.count - 1)
105
- params["tail"] = remainingSegments.joined(separator: "/")
106
- }
107
- } else {
108
- // Normal matching - must have same number of segments
109
- if patternSegments.count != pathSegments.count {
110
- return nil
59
+ private enum Segment {
60
+ case constant(Substring)
61
+ case param(String)
62
+ case wildcard
63
+
64
+ init(_ segment: Substring) {
65
+ if segment.hasPrefix("{"), segment.hasSuffix("}") {
66
+ self = .param(String(segment.dropFirst().dropLast()))
67
+ } else if segment == "*" {
68
+ self = .wildcard
69
+ } else {
70
+ self = .constant(segment)
111
71
  }
72
+ }
112
73
 
113
- for i in patternSegments.indices {
114
- guard
115
- matchSingleSegment(
116
- patternSegment: patternSegments[i],
117
- pathSegment: pathSegments[i],
118
- params: &params,
119
- )
120
- else {
121
- return nil
122
- }
123
-
124
- updateSegmentCounts(
125
- patternSegment: patternSegments[i],
126
- constantSegments: &constantSegments,
127
- parameterSegments: &parameterSegments,
128
- wildcardSegments: &wildcardSegments,
129
- )
74
+ var specificity: Int {
75
+ switch self {
76
+ case .constant: 1000
77
+ case .param: 100
78
+ case .wildcard: 10
130
79
  }
131
80
  }
132
-
133
- // Specificity calculation (higher = more specific):
134
- // - Constants: 1000 points each
135
- // - Parameters: 100 points each
136
- // - Single wildcards: 10 points each
137
- // - Tail wildcard: 1 point
138
- // - Base path length: segments count
139
- let specificity =
140
- (constantSegments * 1000)
141
- + (parameterSegments * 100)
142
- + (wildcardSegments * 10)
143
- + (hasTailWildcard ? 1 : 0)
144
- + patternSegments.count
145
-
146
- return RouteMatch(params: params, specificity: specificity)
147
81
  }
148
82
 
149
- private func matchSingleSegment(
150
- patternSegment: String,
151
- pathSegment: String,
152
- params: inout [String: String],
153
- ) -> Bool {
154
- // Parameter segment: {paramName}
155
- if patternSegment.hasPrefix("{"), patternSegment.hasSuffix("}") {
156
- let paramName = String(patternSegment.dropFirst().dropLast())
157
- params[paramName] = pathSegment
158
- return true
159
- }
83
+ private static func match(pattern: String, path: String) -> RouteMatch? {
84
+ var patternSegments = pattern.split(separator: "/")
85
+ let hasTailWildcard = patternSegments.last == "**"
86
+ if hasTailWildcard { patternSegments.removeLast() }
87
+ let pathSegments = path.split(separator: "/")
88
+ guard hasTailWildcard
89
+ ? pathSegments.count >= patternSegments.count
90
+ : pathSegments.count == patternSegments.count
91
+ else { return nil }
160
92
 
161
- // Single wildcard: *
162
- if patternSegment == "*" {
163
- return true
93
+ var params: [String: String] = [:]
94
+ // Each segment counts once more, the tail wildcard twice (its segment and its own point).
95
+ var specificity = patternSegments.count + (hasTailWildcard ? 2 : 0)
96
+ for (patternSegment, pathSegment) in zip(patternSegments, pathSegments) {
97
+ let segment = Segment(patternSegment)
98
+ switch segment {
99
+ case let .constant(constant):
100
+ guard constant == pathSegment else { return nil }
101
+ case let .param(name):
102
+ params[name] = String(pathSegment)
103
+ case .wildcard:
104
+ break
105
+ }
106
+ specificity += segment.specificity
164
107
  }
165
-
166
- // Constant segment: must match exactly
167
- return patternSegment == pathSegment
168
- }
169
-
170
- private func updateSegmentCounts(
171
- patternSegment: String,
172
- constantSegments: inout Int,
173
- parameterSegments: inout Int,
174
- wildcardSegments: inout Int,
175
- ) {
176
- if patternSegment.hasPrefix("{"), patternSegment.hasSuffix("}") {
177
- parameterSegments += 1
178
- } else if patternSegment == "*" {
179
- wildcardSegments += 1
180
- } else {
181
- constantSegments += 1
108
+ if hasTailWildcard, pathSegments.count > patternSegments.count {
109
+ params["tail"] = pathSegments.dropFirst(patternSegments.count).joined(separator: "/")
182
110
  }
111
+ return RouteMatch(params: params, specificity: specificity)
183
112
  }
184
113
  }
@@ -0,0 +1,28 @@
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
4
+
5
+ extension SearchParams {
6
+ /// The query params this search sends: `q` plus whichever filters are set, as on Android and
7
+ /// web. Also the search's cache identity, so a field added here distinguishes both.
8
+ var queryParams: [String: String] {
9
+ var query: [String: String] = ["q": query]
10
+ if let mode { query["mode"] = mode.stringValue }
11
+ if reference == .my { query["reference"] = "my" }
12
+ if let genre { query["genre"] = genre }
13
+ if let artist { query["artist"] = artist }
14
+ if let album { query["album"] = album }
15
+ if let title { query["title"] = title }
16
+ if let playlist { query["playlist"] = playlist }
17
+ return query
18
+ }
19
+
20
+ /// The cache identity: `queryParams`, sorted so key order is stable and `\u{1}`-delimited so
21
+ /// two param sets cannot serialize alike.
22
+ var cacheKey: String {
23
+ queryParams
24
+ .sorted { $0.key < $1.key }
25
+ .map { "\($0.key)=\($0.value)" }
26
+ .joined(separator: "\u{1}")
27
+ }
28
+ }
@@ -1,4 +1,6 @@
1
- import NitroModules
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
2
4
 
3
5
  extension Track {
4
6
  /// Returns a copy of this Track with only the specified fields changed.
@@ -16,3 +16,10 @@ extension Track {
16
16
  return src
17
17
  }
18
18
  }
19
+
20
+ extension Track {
21
+ /// Playable and not Disabled: a track the library may queue and play.
22
+ var isPlayable: Bool {
23
+ src != nil && disabled != true
24
+ }
25
+ }
@@ -13,27 +13,11 @@ final class HttpClient: @unchecked Sendable {
13
13
  /// HTTP request configuration (matches Kotlin HttpClient.HttpRequest).
14
14
  struct HttpRequest {
15
15
  let url: String
16
- let method: String
16
+ let method: HttpMethod
17
17
  let headers: [String: String]?
18
18
  let body: String?
19
19
  let contentType: String
20
20
  let userAgent: String
21
-
22
- init(
23
- url: String,
24
- method: String = "GET",
25
- headers: [String: String]? = nil,
26
- body: String? = nil,
27
- contentType: String = HttpClient.defaultContentType,
28
- userAgent: String = HttpClient.defaultUserAgent,
29
- ) {
30
- self.url = url
31
- self.method = method
32
- self.headers = headers
33
- self.body = body
34
- self.contentType = contentType
35
- self.userAgent = userAgent
36
- }
37
21
  }
38
22
 
39
23
  /// A non-2xx response (matches Kotlin HttpClient.HttpException).
@@ -55,14 +39,14 @@ final class HttpClient: @unchecked Sendable {
55
39
 
56
40
  enum HttpClientError: LocalizedError {
57
41
  case invalidUrl(String)
58
- case unsupportedMethod(String)
42
+ case unsupportedMethod(HttpMethod)
59
43
  case notHttp
60
44
  case undecodable(String)
61
45
 
62
46
  var errorDescription: String? {
63
47
  switch self {
64
48
  case let .invalidUrl(url): "Invalid URL: \(url)"
65
- case let .unsupportedMethod(method): "Unsupported HTTP method: \(method)"
49
+ case let .unsupportedMethod(method): "Unsupported HTTP method: \(method.stringValue)"
66
50
  case .notHttp: "Invalid response type"
67
51
  case let .undecodable(reason): reason
68
52
  }
@@ -74,7 +58,7 @@ final class HttpClient: @unchecked Sendable {
74
58
  }
75
59
 
76
60
  /// Sends `httpRequest` and returns the status code and body, whatever the status.
77
- func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
61
+ private func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
78
62
  guard let url = URL(string: httpRequest.url) else { throw HttpClientError.invalidUrl(httpRequest.url) }
79
63
 
80
64
  var urlRequest = URLRequest(url: url)
@@ -87,21 +71,19 @@ final class HttpClient: @unchecked Sendable {
87
71
  urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
88
72
  }
89
73
 
90
- let method = httpRequest.method.uppercased()
91
- switch method {
92
- case "GET":
93
- urlRequest.httpMethod = method
94
- case "POST", "PUT", "PATCH":
95
- urlRequest.httpMethod = method
74
+ urlRequest.httpMethod = httpRequest.method.stringValue
75
+ switch httpRequest.method {
76
+ case .get:
77
+ break
78
+ case .post, .put, .patch:
96
79
  urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
97
80
  urlRequest.httpBody = httpRequest.body?.data(using: .utf8)
98
- case "DELETE":
99
- urlRequest.httpMethod = method
81
+ case .delete:
100
82
  if let body = httpRequest.body {
101
83
  urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
102
84
  urlRequest.httpBody = body.data(using: .utf8)
103
85
  }
104
- default:
86
+ case .head, .options:
105
87
  throw HttpClientError.unsupportedMethod(httpRequest.method)
106
88
  }
107
89
 
@@ -143,7 +125,7 @@ final class HttpClient: @unchecked Sendable {
143
125
 
144
126
  extension HttpClient {
145
127
  private func logRequest(_ request: HttpRequest) {
146
- logger.debug("→ \(request.method) \(request.url)")
128
+ logger.debug("→ \(request.method.stringValue) \(request.url)")
147
129
  for (key, value) in request.headers ?? [:] {
148
130
  logger.debug(" \(key): \(value)")
149
131
  }
@@ -93,29 +93,13 @@ public extension HybridAudioBrowser {
93
93
  }
94
94
 
95
95
  func invalidateAllContent() throws {
96
- onMainActor { browserManager.clearContentCache() }
97
-
98
- // Tell external controllers (CarPlay) to refresh every displayed template.
99
- externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
100
-
101
- // Re-query the tabs source too: a callback source can resolve to new
102
- // titles after an invalidation (e.g. a locale switch). BrowserManager
103
- // emits tabsChanged only when the result actually differs, and a failed
104
- // re-query keeps the current tabs. (Android needs no equivalent — its
105
- // root re-query already runs queryTabs(); the web stub re-queries tabs on
106
- // the re-navigation.)
107
- Task {
108
- _ = try? await browserManager.queryTabs()
109
- }
110
-
111
- // Re-resolve the JS-facing current path with the cache cleared.
112
- Task { @MainActor in
113
- do {
114
- try await browserManager.load(browserManager.path, bypassCache: true)
115
- } catch {
116
- await reportNavigationError(error, path: browserManager.path)
96
+ onMainActor {
97
+ browserManager.invalidateAll { error, path in
98
+ await self.reportNavigationError(error, path: path)
117
99
  }
118
100
  }
101
+ // Tell external controllers (CarPlay) to refresh every displayed template.
102
+ externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
119
103
  }
120
104
 
121
105
  func setFavorites(favorites: [String]) throws {
@@ -110,6 +110,71 @@
110
110
  case options
111
111
  }
112
112
 
113
+ enum SearchMode {
114
+ case any
115
+ case song
116
+ case playlist
117
+ case station
118
+ case podcast
119
+ case audiobook
120
+ case news
121
+ case music
122
+ case musicVideo
123
+ case movie
124
+ case tvShow
125
+ case tvShowEpisode
126
+
127
+ var stringValue: String {
128
+ switch self {
129
+ case .any: "any"
130
+ case .song: "song"
131
+ case .playlist: "playlist"
132
+ case .station: "station"
133
+ case .podcast: "podcast"
134
+ case .audiobook: "audiobook"
135
+ case .news: "news"
136
+ case .music: "music"
137
+ case .musicVideo: "music-video"
138
+ case .movie: "movie"
139
+ case .tvShow: "tv-show"
140
+ case .tvShowEpisode: "tv-show-episode"
141
+ }
142
+ }
143
+ }
144
+
145
+ enum MediaReference {
146
+ case my
147
+ case unknown
148
+
149
+ var stringValue: String {
150
+ switch self {
151
+ case .my: "my"
152
+ case .unknown: "unknown"
153
+ }
154
+ }
155
+ }
156
+
157
+ struct SearchParams {
158
+ var mode: SearchMode?
159
+ var query: String
160
+ var genre: String?
161
+ var artist: String?
162
+ var album: String?
163
+ var title: String?
164
+ var playlist: String?
165
+ var reference: MediaReference
166
+ }
167
+
168
+ struct ImageContext {
169
+ var width: Double?
170
+ var height: Double?
171
+ }
172
+
173
+ struct ImageQueryParams {
174
+ var width: String?
175
+ var height: String?
176
+ }
177
+
113
178
  struct ImageSource: Equatable {
114
179
  var uri: String
115
180
  var method: HttpMethod?
@@ -177,3 +177,36 @@ import Testing
177
177
  #expect(BrowserPathHelper.isSearchPath("/search?q=jazz") == false)
178
178
  #expect(BrowserPathHelper.isSearchPath("/__searchable") == false)
179
179
  }
180
+
181
+ // MARK: - stampingContextualPaths
182
+
183
+ @Test func stampsPlayableOnlyTracksWithTheirFlatPagePosition() throws {
184
+ let sections = [
185
+ Section(children: [Track(path: "/a", title: "A"), Track(src: "one.mp3", title: "One")]),
186
+ Section(children: [Track(id: "two", src: "two.mp3", title: "Two")]),
187
+ ]
188
+ let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
189
+ #expect(stamped.map { $0.children.map(\.path) } == [
190
+ ["/a", "/page?__trackId=one.mp3&__index=1"],
191
+ ["/page?__trackId=two&__index=2"],
192
+ ])
193
+ }
194
+
195
+ @Test func keepsTheSectionsAndATrackThatHasAPath() throws {
196
+ let track = Track(path: "/a", src: "a.mp3", title: "A")
197
+ let sections = [Section(title: "Top", path: "/top", children: [track])]
198
+ #expect(try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page") == sections)
199
+ }
200
+
201
+ @Test func stampsBesideAnExistingQuery() throws {
202
+ let sections = [Section(children: [Track(src: "s.mp3", title: "S")])]
203
+ let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/search?q=jazz")
204
+ #expect(stamped[0].children[0].path == "/search?q=jazz&__trackId=s.mp3&__index=0")
205
+ }
206
+
207
+ @Test func rejectsATrackWithNeitherPathNorSrc() {
208
+ let sections = [Section(children: [Track(id: "only-id", title: "Nowhere")])]
209
+ #expect(throws: BrowserError.self) {
210
+ try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
211
+ }
212
+ }
@@ -0,0 +1,60 @@
1
+ import Testing
2
+
3
+ @testable import AudioBrowserTestable
4
+
5
+ struct FavoriteSetTests {
6
+ private func favorites(_ identities: [String], enabled: Bool = true) -> FavoriteSet {
7
+ var set = FavoriteSet()
8
+ set.enabled = enabled
9
+ _ = set.replace(with: identities)
10
+ return set
11
+ }
12
+
13
+ @Test func replaceReportsWhetherTheIdentitiesChanged() {
14
+ var set = FavoriteSet()
15
+ let changes = [["a", "b"], ["b", "a"], ["a", "b", "a"], []].map { set.replace(with: $0) }
16
+ #expect(changes == [true, false, false, true])
17
+ }
18
+
19
+ @Test func hydratesTrueOrFalseByIdentity() {
20
+ let set = favorites(["a", "stable"])
21
+ #expect(set.hydrate(Track(src: "a")).favorited == true)
22
+ #expect(set.hydrate(Track(src: "b")).favorited == false)
23
+ #expect(set.hydrate(Track(id: "stable", src: "x")).favorited == true)
24
+ #expect(set.hydrate(Track(id: "other", src: "a")).favorited == false)
25
+ }
26
+
27
+ @Test func leavesATrackAloneWhenDisabled() {
28
+ #expect(favorites(["a"], enabled: false).hydrate(Track(src: "a")).favorited == nil)
29
+ }
30
+
31
+ @Test func leavesATrackWithoutIdentityAlone() {
32
+ #expect(favorites(["a"]).hydrate(Track(path: "/browse")).favorited == nil)
33
+ }
34
+
35
+ @Test func aFavoritedTheTrackCarriesWins() {
36
+ let set = favorites(["a"])
37
+ #expect(set.hydrate(Track(src: "a", favorited: false)).favorited == false)
38
+ #expect(set.hydrate(Track(src: "b", favorited: true)).favorited == true)
39
+ }
40
+
41
+ @Test func aLocalToggleHoldsUntilTheNextReplace() {
42
+ var set = favorites(["a"])
43
+ set.set("b", favorited: true)
44
+ set.set("a", favorited: false)
45
+ #expect(set.hydrate(Track(src: "a")).favorited == false)
46
+ #expect(set.hydrate(Track(src: "b")).favorited == true)
47
+ _ = set.replace(with: ["a"])
48
+ #expect(set.hydrate(Track(src: "b")).favorited == false)
49
+ }
50
+
51
+ @Test func hydratesEverySectionOfAPage() {
52
+ let page = ResolvedTrack(
53
+ path: "/p",
54
+ sections: [Section(children: [Track(src: "a")]), Section(children: [Track(src: "b")])],
55
+ title: "P",
56
+ )
57
+ let hydrated = favorites(["b"]).hydrate(page)
58
+ #expect(hydrated.sections?.map { $0.children.map(\.favorited) } == [[false], [true]])
59
+ }
60
+ }