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.
@@ -36,9 +36,6 @@ struct BrowserConfig {
36
36
  /// Behavior: single track playback vs queue expansion
37
37
  var singleTrack: Bool = false
38
38
 
39
- /// Behavior: show offline error in Android Auto controller
40
- var androidControllerOfflineError: Bool = true
41
-
42
39
  // MARK: - CarPlay Options
43
40
 
44
41
  /// App-localized title for CarPlay loading screens (shown as the centered
@@ -70,7 +67,6 @@ struct BrowserConfig {
70
67
  routes = config.routes
71
68
  singleTrack = config.singleTrack ?? false
72
69
  handleTrackLoad = config.handleTrackLoad
73
- androidControllerOfflineError = config.androidControllerOfflineError ?? true
74
70
  carPlayLoadingTitle = config.carPlayLoadingTitle
75
71
  resolveAlbumPath = config.resolveAlbumPath
76
72
  formatNavigationError = config.formatNavigationError
@@ -93,7 +89,6 @@ struct BrowserConfig {
93
89
  stripped.nowPlayingArtwork = nowPlayingArtwork?.strippingJSCallbacks()
94
90
  stripped.routes = routes?.map { $0.strippingJSCallbacks() }
95
91
  stripped.singleTrack = singleTrack
96
- stripped.androidControllerOfflineError = androidControllerOfflineError
97
92
  stripped.carPlayLoadingTitle = carPlayLoadingTitle
98
93
  return stripped
99
94
  }
@@ -0,0 +1,16 @@
1
+ import Foundation
2
+
3
+ /// Custom browser errors.
4
+ enum BrowserError: LocalizedError {
5
+ case contentNotFound(path: String)
6
+ case invalidConfiguration(String)
7
+ case callbackError(String)
8
+
9
+ var errorDescription: String? {
10
+ switch self {
11
+ case .contentNotFound: "Content not found"
12
+ case let .invalidConfiguration(message): "Invalid configuration: \(message)"
13
+ case let .callbackError(message): message
14
+ }
15
+ }
16
+ }
@@ -2,21 +2,6 @@ import Foundation
2
2
  import NitroModules
3
3
  import os.log
4
4
 
5
- /// Custom browser errors.
6
- enum BrowserError: LocalizedError {
7
- case contentNotFound(path: String)
8
- case invalidConfiguration(String)
9
- case callbackError(String)
10
-
11
- var errorDescription: String? {
12
- switch self {
13
- case .contentNotFound: "Content not found"
14
- case let .invalidConfiguration(message): "Invalid configuration: \(message)"
15
- case let .callbackError(message): message
16
- }
17
- }
18
- }
19
-
20
5
  /// The browser's state (path, content, tabs, config), its content and search caches, favorites
21
6
  /// and events. Pages come from `PageResolver`; requests go through `RequestPipeline`.
22
7
  @MainActor
@@ -36,16 +21,11 @@ final class BrowserManager {
36
21
  // LRU cache for resolved content - keyed by path
37
22
  private let contentCache = LRUCache<String, ResolvedTrack>(maxSize: 20)
38
23
 
39
- // Most recent search, keyed by `searchCacheKey`.
24
+ // Most recent search, keyed by `SearchParams.cacheKey`.
40
25
  private var lastSearchKey: String?
41
26
  private var lastSearchResults: [Track]?
42
27
 
43
- // Set of favorited track identities (id when non-blank, else src).
44
- private var favoriteIds: Set<String> = []
45
-
46
- // Whether favoriting is enabled, propagated from the player's `favorite`
47
- // capability. false = no row hearts. Set via setFavoriteEnabled.
48
- private var favoriteEnabled = false
28
+ private var favorites = FavoriteSet()
49
29
 
50
30
  // Navigation tracking to prevent race conditions
51
31
  private var currentNavigationId: Int = 0
@@ -120,54 +100,27 @@ final class BrowserManager {
120
100
 
121
101
  // MARK: - Favorites
122
102
 
123
- /// Sets the favorited track identifiers.
103
+ /// Sets the favorited track identities, emitting `favoritesChanged` when they change.
124
104
  func setFavorites(_ favorites: [String]) {
125
- let newIds = Set(favorites)
126
- guard newIds != favoriteIds else { return }
127
- favoriteIds = newIds
128
- favoritesChanged.emit(())
105
+ if self.favorites.replace(with: favorites) {
106
+ favoritesChanged.emit(())
107
+ }
129
108
  }
130
109
 
131
110
  /// Enables/disables favoriting (propagated from the `favorite` capability).
132
- /// false disables row-heart hydration.
133
111
  func setFavoriteEnabled(_ enabled: Bool) {
134
- favoriteEnabled = enabled
112
+ favorites.enabled = enabled
135
113
  }
136
114
 
137
- /// Optimistically reflects a local favorite toggle in the favorite set, keyed
138
- /// by the track's identity (id when non-blank, else src). The consumer
139
- /// reconciles `favoriteIds` to its canonical set on the next `setFavorites`;
140
- /// `hydrateFavorite` gives the same answer before and after, so the
141
- /// now-playing heart stays responsive.
115
+ /// Optimistically reflects a local favorite toggle (`FavoriteSet.set`), so the now-playing
116
+ /// heart stays responsive.
142
117
  func setFavorited(identity: String, favorited: Bool) {
143
- if favorited {
144
- favoriteIds.insert(identity)
145
- } else {
146
- favoriteIds.remove(identity)
147
- }
118
+ favorites.set(identity, favorited: favorited)
148
119
  }
149
120
 
150
- /// Hydrates the favorited field on a track based on the favoriteIds set.
151
- /// No-op unless favoriting is enabled (the `favorite` capability). Only
152
- /// identity-bearing tracks (id or src) are favoritable; the flag is set to
153
- /// true OR false so non-favorited tracks still show an (empty) heart. A
154
- /// caller-set `favorited` wins — API/consumer-provided values are never
155
- /// overwritten (matches Android and web).
121
+ /// `track` with its `favorited` hydrated (`FavoriteSet.hydrate`).
156
122
  func hydrateFavorite(_ track: Track) -> Track {
157
- // `identity` bridges a std::string out of the C++ track, so it is bound
158
- // once here rather than computed again to answer "is it favorited".
159
- guard favoriteEnabled, track.favorited == nil, let identity = track.identity else { return track }
160
- return track.copying(favorited: favoriteIds.contains(identity))
161
- }
162
-
163
- /// Hydrates favorites on all tracks of a resolved page. Pages reach
164
- /// hydration normalized (ADR 0010), so `sections` is the only structure
165
- /// to walk.
166
- private func hydrateChildren(_ resolvedTrack: ResolvedTrack) -> ResolvedTrack {
167
- guard let sections = resolvedTrack.sections else { return resolvedTrack }
168
- return resolvedTrack.copying(sections: sections.map { section in
169
- section.copying(children: section.children.map { hydrateFavorite($0) })
170
- })
123
+ favorites.hydrate(track)
171
124
  }
172
125
 
173
126
  // MARK: - Navigation
@@ -203,7 +156,7 @@ final class BrowserManager {
203
156
 
204
157
  // Check content cache first
205
158
  if useCache, let cached = contentCache.get(normalizedPath) {
206
- return hydrateChildren(cached)
159
+ return favorites.hydrate(cached)
207
160
  }
208
161
 
209
162
  let resolvedTrack = try await pages.resolve(normalizedPath)
@@ -211,7 +164,7 @@ final class BrowserManager {
211
164
  // Cache the resolved content for future navigation
212
165
  contentCache.set(normalizedPath, value: resolvedTrack)
213
166
 
214
- return hydrateChildren(resolvedTrack)
167
+ return favorites.hydrate(resolvedTrack)
215
168
  }
216
169
 
217
170
  /// Invalidates the content cache for a specific path.
@@ -230,16 +183,23 @@ final class BrowserManager {
230
183
  contentCache.remove(path)
231
184
  }
232
185
 
233
- /// Clears the entire content cache (e.g. on a locale change).
234
- /// Runs from `invalidateAllContent`, so re-resolve the layer thunks too.
235
- func clearContentCache() {
186
+ /// Clears the content cache, the search cache and the resolved request layers (e.g. on a
187
+ /// locale change), then re-queries the tabs and reloads the current path from the network.
188
+ /// The two are independent: a failed reload goes to `reloadFailed` with its path and neither
189
+ /// waits for nor cancels the tabs, and a failed tabs re-query keeps the current tabs.
190
+ func invalidateAll(reloadFailed: @escaping @MainActor (Error, String) async -> Void) {
236
191
  contentCache.clear()
237
192
  requests.invalidateLayers()
238
- // The search slot is content too: a locale switch or a re-auth changes
239
- // titles, src, artwork and availability, and nothing else ever evicts it,
240
- // so a repeated query returned pre-invalidation results forever.
241
193
  lastSearchKey = nil
242
194
  lastSearchResults = nil
195
+ Task { _ = try? await queryTabs() }
196
+ Task {
197
+ do {
198
+ try await load(path, bypassCache: true)
199
+ } catch {
200
+ await reloadFailed(error, path)
201
+ }
202
+ }
243
203
  }
244
204
 
245
205
  /// `error` as the app's `formatNavigationError` displays it, or its default display when
@@ -263,24 +223,15 @@ final class BrowserManager {
263
223
  )
264
224
  }
265
225
 
266
- /// The cache identity of a search. Sorted so key order is stable, and
267
- /// `\u{1}`-delimited so two different param sets can't serialize alike.
268
- private static func searchCacheKey(_ params: SearchParams) -> String {
269
- PageResolver.searchQueryParams(params)
270
- .sorted { $0.key < $1.key }
271
- .map { "\($0.key)=\($0.value)" }
272
- .joined(separator: "\u{1}")
273
- }
274
-
275
226
  /// Structured search, cached by the full parameter set.
276
227
  ///
277
228
  /// Voice intents reuse `query` across different structures —
278
229
  /// `MediaIntentCriteria` falls back to the structured value when there is no
279
230
  /// spoken name, so "play jazz" (genre) and a text search for "jazz" arrive
280
- /// with an identical `query` — hence `searchCacheKey` rather than `query`.
231
+ /// with an identical `query` — hence `cacheKey` rather than `query`.
281
232
  func search(_ params: SearchParams) async throws -> ResolvedTrack {
282
233
  let query = params.query
283
- let cacheKey = Self.searchCacheKey(params)
234
+ let cacheKey = params.cacheKey
284
235
 
285
236
  // Check cache - hydrate favorites on the way out, since they may have
286
237
  // changed since the search ran (the slot holds the raw results for exactly
@@ -292,7 +243,7 @@ final class BrowserManager {
292
243
  let results = try await pages.search(params)
293
244
 
294
245
  // Cache the RAW results, never the hydrated ones: hydration is one-shot
295
- // (`hydrateFavorite` skips a track whose `favorited` is already set), so a
246
+ // (`FavoriteSet.hydrate` skips a track whose `favorited` is already set), so a
296
247
  // hydrated slot would serve the favorites as they were at first search
297
248
  // forever. The content cache keys on the same rule — it stores the
298
249
  // unhydrated resolve and hydrates in `resolve()`.
@@ -319,14 +270,14 @@ final class BrowserManager {
319
270
 
320
271
  /// Wraps raw search results as a resolved page, hydrating favorites on the
321
272
  /// way out — the single exit for `search`, so an unhydrated search page can't
322
- /// be emitted (the same funnel `resolve()` gets from `hydrateChildren`).
273
+ /// be emitted (the same funnel `resolve()` has).
323
274
  private func makeSearchResult(query: String, results: [Track]) -> ResolvedTrack {
324
275
  // Search results are a flat list; as a resolved page they are one
325
276
  // untitled section (ADR 0010).
326
277
  ResolvedTrack(
327
278
  path: BrowserPathHelper.createSearchPath(query),
328
279
  style: nil,
329
- sections: [.untitled(results.map { hydrateFavorite($0) })],
280
+ sections: [.untitled(results.map { favorites.hydrate($0) })],
330
281
  children: nil,
331
282
  carPlaySiriListButton: nil,
332
283
  id: nil,
@@ -359,68 +310,12 @@ final class BrowserManager {
359
310
 
360
311
  // MARK: - Queue Expansion (for CarPlay/external controllers)
361
312
 
362
- /// Expands a contextual path to a full queue of playable tracks.
313
+ /// The queue a tap on the contextual `path` plays (`SectionScope.queue`), from its page.
363
314
  func expandQueueFromContextualPath(_ path: String) async throws -> (tracks: [Track], selectedIndex: Int)? {
364
- guard BrowserPathHelper.isContextual(path) else { return nil }
365
-
366
- let parentPath = BrowserPathHelper.stripTrackId(path)
367
- guard let trackId = BrowserPathHelper.extractTrackId(path) else { return nil }
368
-
369
- logger.debug("Expanding queue from contextual path")
370
- logger.debug(" path: \(path)")
371
- logger.debug(" parentPath: \(parentPath)")
372
- logger.debug(" trackId: \(trackId)")
373
-
374
- // Resolve the parent container
375
- let resolved = try await resolve(parentPath, useCache: true)
315
+ guard BrowserPathHelper.extractTrackId(path) != nil else { return nil }
316
+ let resolved = try await resolve(BrowserPathHelper.stripTrackId(path), useCache: true)
376
317
  guard let sections = resolved.normalizedSections else { return nil }
377
-
378
- // Queue scope is the tapped section, not the whole page (ADR 0006). The
379
- // stamped flat index pins which section (and which copy) was tapped when
380
- // the same identity appears more than once; it is only a tie-breaker —
381
- // a stale index falls back to the first identity match. An id that no
382
- // longer appears on the page at all aborts the expansion — the caller
383
- // falls back to the stored single track (matching Android); silently
384
- // queueing the changed list would resume the wrong station.
385
- let tappedIndex = BrowserPathHelper.extractIndex(path)
386
- guard let scoped = SectionScope.scoped(in: sections, containing: trackId, tappedIndex: tappedIndex) else {
387
- return nil
388
- }
389
- let sectionTracks = scoped.section.children
390
- let tappedOffset = scoped.tappedOffset
391
-
392
- // Filter to playable tracks (have src). A disabled track is unavailable —
393
- // queue expansion excludes it, so auto-advance never meets one.
394
- let playableTracks = sectionTracks.filter { $0.src != nil && $0.disabled != true }
395
- guard !playableTracks.isEmpty else { return nil }
396
-
397
- logger.debug("Found \(playableTracks.count) playable tracks")
398
- for (index, track) in playableTracks.enumerated() {
399
- logger.debug(" [\(index)] \(track.title) - src: \(track.src ?? "nil")")
400
- }
401
-
402
- // Find selected track index: the pinned copy when the stamp survived,
403
- // else the first identity match. A miss must abort rather than silently
404
- // start the queue at the wrong track (unreachable in practice — the
405
- // section was found BY this id).
406
- let selectedIndex: Int
407
- if let tappedOffset, sectionTracks[tappedOffset].src != nil, sectionTracks[tappedOffset].disabled != true {
408
- selectedIndex =
409
- sectionTracks.prefix(through: tappedOffset)
410
- .count(where: { $0.src != nil && $0.disabled != true }) - 1
411
- } else if let index = playableTracks.firstIndex(where: { $0.identity == trackId }) {
412
- selectedIndex = index
413
- } else {
414
- return nil
415
- }
416
- logger.debug("Selected track index: \(selectedIndex)")
417
-
418
- // If singleTrack mode, return just the selected track
419
- if config.singleTrack {
420
- return (tracks: [playableTracks[selectedIndex]], selectedIndex: 0)
421
- }
422
-
423
- return (tracks: playableTracks, selectedIndex: selectedIndex)
318
+ return SectionScope.queue(in: sections, path: path, singleTrack: config.singleTrack)
424
319
  }
425
320
  }
426
321
 
@@ -1,9 +1,12 @@
1
1
  import Foundation
2
+ #if canImport(NitroModules)
3
+ import NitroModules
4
+ #endif
2
5
 
3
6
  /// Utility for handling browser paths and contextual URLs in the media browser system.
4
7
  ///
5
8
  /// Handles two types of special paths:
6
- /// 1. System paths (prefixed with `/__`): root, search, and offline paths
9
+ /// 1. Search paths (`/__search?q=…`)
7
10
  /// 2. Contextual URLs: Embed parent context in track identifiers for playback integration
8
11
  ///
9
12
  /// Contextual URL format: `{parentPath}?__trackId={trackIdentity}&__index={childIndex}`
@@ -18,15 +21,9 @@ import Foundation
18
21
  /// - Cache lookup to work consistently
19
22
  /// - Parent context to be preserved for queue restoration
20
23
  enum BrowserPathHelper {
21
- /// Root path for media browsing
22
- static let rootPath = "/__root"
23
-
24
24
  /// Search path prefix (full path is /__search?q=query)
25
25
  static let searchPathPrefix = "/__search"
26
26
 
27
- /// Offline error placeholder media ID
28
- static let offlinePath = "/__offline"
29
-
30
27
  /// Character set for percent-encoding query parameter keys and values.
31
28
  /// Starts from `.urlQueryAllowed` but removes characters that have special
32
29
  /// meaning in query strings (`&`, `=`, `+`) to prevent value corruption.
@@ -42,11 +39,6 @@ enum BrowserPathHelper {
42
39
  /// Query parameter name for the tapped child's page position (tie-breaker)
43
40
  private static let contextualIndexParam = "__index"
44
41
 
45
- /// Check if a path is a special system path (not a regular navigation path)
46
- static func isSpecialPath(_ path: String) -> Bool {
47
- path == rootPath || isSearchPath(path)
48
- }
49
-
50
42
  /// Check if a path is a search path (`/__search?q=…`)
51
43
  static func isSearchPath(_ path: String) -> Bool {
52
44
  path.hasPrefix("\(searchPathPrefix)?")
@@ -221,3 +213,24 @@ enum BrowserPathHelper {
221
213
  return "\(normalizedBase)/\(normalizedPath)"
222
214
  }
223
215
  }
216
+
217
+ extension BrowserPathHelper {
218
+ /// `sections` with a contextual path on every playable-only track (a `src` and no `path`), so
219
+ /// it can be addressed: `parentPath`, the track's identity and its flat page position (children
220
+ /// concatenated in section order). Throws when a track has neither `path` nor `src`.
221
+ static func stampingContextualPaths(_ sections: [Section], parentPath: String) throws -> [Section] {
222
+ var flatIndex = 0
223
+ return try sections.map { section in
224
+ try section.copying(children: section.children.map { track in
225
+ defer { flatIndex += 1 }
226
+ if track.path != nil { return track }
227
+ guard track.src != nil, let identity = track.identity else {
228
+ throw BrowserError.invalidConfiguration(
229
+ "Track must have either 'path' or 'src' for stable identification: \(track.title)",
230
+ )
231
+ }
232
+ return track.copying(path: build(parentPath: parentPath, trackId: identity, index: flatIndex))
233
+ })
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,46 @@
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
4
+
5
+ /// The favorited track identities (id when non-blank, else src), and whether favoriting is on:
6
+ /// the player's `favorite` capability. Hydrates a track's `favorited` from them.
7
+ struct FavoriteSet {
8
+ private var identities: Set<String> = []
9
+ /// Off, tracks are left as they are: no row hearts.
10
+ var enabled = false
11
+
12
+ /// Replaces the identities with `favorites`; false when that changes nothing.
13
+ mutating func replace(with favorites: [String]) -> Bool {
14
+ let replaced = Set(favorites)
15
+ guard replaced != identities else { return false }
16
+ identities = replaced
17
+ return true
18
+ }
19
+
20
+ /// Reflects a local favorite toggle until the consumer's next `replace(with:)`, which
21
+ /// reconciles the set to its canonical one.
22
+ mutating func set(_ identity: String, favorited: Bool) {
23
+ if favorited {
24
+ identities.insert(identity)
25
+ } else {
26
+ identities.remove(identity)
27
+ }
28
+ }
29
+
30
+ /// `track` with `favorited` set to true or false, so a track that is not favorited still shows
31
+ /// an empty heart. Unchanged when favoriting is off, when the track has no identity, or when it
32
+ /// already carries `favorited`: a value from the API or the consumer wins, as on Android and web.
33
+ func hydrate(_ track: Track) -> Track {
34
+ // `identity` bridges a std::string out of the C++ track, so it is read once.
35
+ guard enabled, track.favorited == nil, let identity = track.identity else { return track }
36
+ return track.copying(favorited: identities.contains(identity))
37
+ }
38
+
39
+ /// Every track of a normalized page (ADR 0010) hydrated.
40
+ func hydrate(_ page: ResolvedTrack) -> ResolvedTrack {
41
+ guard let sections = page.sections else { return page }
42
+ return page.copying(sections: sections.map { section in
43
+ section.copying(children: section.children.map(hydrate))
44
+ })
45
+ }
46
+ }