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.
- 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 +79 -199
- 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/Model/NitroTypeStubs.swift +65 -0
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/SearchParamsQueryTests.swift +48 -0
- package/ios/Tests/SectionScopeTests.swift +53 -0
- package/ios/Tests/SimpleRouterTests.swift +38 -3
- package/package.json +1 -1
|
@@ -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 `
|
|
24
|
+
// Most recent search, keyed by `SearchParams.cacheKey`.
|
|
40
25
|
private var lastSearchKey: String?
|
|
41
26
|
private var lastSearchResults: [Track]?
|
|
42
27
|
|
|
43
|
-
|
|
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
|
|
103
|
+
/// Sets the favorited track identities, emitting `favoritesChanged` when they change.
|
|
124
104
|
func setFavorites(_ favorites: [String]) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
112
|
+
favorites.enabled = enabled
|
|
135
113
|
}
|
|
136
114
|
|
|
137
|
-
/// Optimistically reflects a local favorite toggle
|
|
138
|
-
///
|
|
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
|
-
|
|
144
|
-
favoriteIds.insert(identity)
|
|
145
|
-
} else {
|
|
146
|
-
favoriteIds.remove(identity)
|
|
147
|
-
}
|
|
118
|
+
favorites.set(identity, favorited: favorited)
|
|
148
119
|
}
|
|
149
120
|
|
|
150
|
-
///
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
234
|
-
///
|
|
235
|
-
|
|
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 `
|
|
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 =
|
|
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
|
-
// (`
|
|
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()`
|
|
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 {
|
|
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
|
-
///
|
|
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.
|
|
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.
|
|
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
|
+
}
|