react-native-audio-browser 0.2.0-next.28 → 0.2.0-next.29
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/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
|
@@ -1,9 +1,151 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import NitroModules
|
|
3
|
+
import os.log
|
|
4
|
+
|
|
5
|
+
/// Builds and runs the browser's HTTP requests: the Request-Config Layers for browse, search,
|
|
6
|
+
/// media and artwork, and the API fetches.
|
|
7
|
+
@MainActor
|
|
8
|
+
final class RequestPipeline {
|
|
9
|
+
let logger = Logger(subsystem: "com.audiobrowser", category: "RequestPipeline")
|
|
10
|
+
private let httpClient = HttpClient()
|
|
11
|
+
/// The request/browse thunks resolve once per content generation.
|
|
12
|
+
private let layers = GenerationMemo<ResolvedLayers>()
|
|
13
|
+
|
|
14
|
+
/// Set by `BrowserManager`; a new config resolves the layers again.
|
|
15
|
+
var config = BrowserConfig() {
|
|
16
|
+
didSet { layers.invalidate() }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
nonisolated init() {}
|
|
20
|
+
|
|
21
|
+
/// Resolves the request and browse layers again on next use (e.g. after a locale change).
|
|
22
|
+
func invalidateLayers() {
|
|
23
|
+
layers.invalidate()
|
|
24
|
+
}
|
|
3
25
|
|
|
4
|
-
|
|
26
|
+
struct ResolvedLayers {
|
|
27
|
+
let request: TransformableRequestConfig?
|
|
28
|
+
let browse: TransformableRequestConfig?
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The request and browse layers of the current config: each resolver's result, or the
|
|
32
|
+
/// static layer when it has none. The resolvers run one after the other, request first, as on
|
|
33
|
+
/// Android and web, and a config change or `clearContentCache` while they run resolves them
|
|
34
|
+
/// again.
|
|
35
|
+
func resolvedLayers() async throws -> ResolvedLayers {
|
|
36
|
+
try await layers.value { [weak self] in
|
|
37
|
+
guard let self else { throw CancellationError() }
|
|
38
|
+
// Read on every run: a retry after a config change must resolve the new config's layers.
|
|
39
|
+
let config = self.config
|
|
40
|
+
// The resolver is Promise-only (the TS layer wraps a sync-or-async thunk in
|
|
41
|
+
// Promise.resolve), so its result is the JS promise behind the bridge promise.
|
|
42
|
+
let request = try await config.requestResolver?().await().await() ?? config.request
|
|
43
|
+
let browse = try await config.browseResolver?().await().await() ?? config.browse
|
|
44
|
+
return ResolvedLayers(request: request, browse: browse)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Applies one Request-Config Layer (request / browse / route) onto `base`: its transforms
|
|
49
|
+
/// (async, then sync) when it has any, else its static fields (`RequestLayer.apply`).
|
|
50
|
+
func applyLayer(
|
|
51
|
+
_ layer: TransformableRequestConfig?,
|
|
52
|
+
to base: RequestConfig,
|
|
53
|
+
params: [String: String],
|
|
54
|
+
) async throws -> RequestConfig {
|
|
55
|
+
guard let layer else { return base }
|
|
56
|
+
var transforms: [nonisolated(nonsending)(RequestConfig) async throws -> RequestConfig] = []
|
|
57
|
+
if let transform = layer.transform {
|
|
58
|
+
transforms.append { try await self.awaitAsyncConfig(transform($0, params)) }
|
|
59
|
+
}
|
|
60
|
+
if let transformSync = layer.transformSync {
|
|
61
|
+
transforms.append { try await self.awaitSyncConfig(transformSync($0, params)) }
|
|
62
|
+
}
|
|
63
|
+
let fields = RequestConfig(
|
|
64
|
+
method: layer.method, path: nil, baseUrl: layer.baseUrl, headers: layer.headers,
|
|
65
|
+
query: layer.query, body: layer.body, contentType: layer.contentType, userAgent: layer.userAgent,
|
|
66
|
+
)
|
|
67
|
+
return try await RequestLayer.apply(fields, transforms: transforms, to: base)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Builds the HTTP request for an API-backed route by layering
|
|
71
|
+
/// request → kind → route configs. `initialQuery` seeds request-level query
|
|
72
|
+
/// params (e.g. search q/mode/…) onto the base before the route layer, so they
|
|
73
|
+
/// survive into `request.query` even when a layer's transform "wins
|
|
74
|
+
/// completely" (it is handed only the base, so the route config's own static
|
|
75
|
+
/// query would otherwise be dropped). `routeConfig` is nil for the implicit
|
|
76
|
+
/// browse default; `kind` names the per-kind layer, nil for kinds with none. The request and
|
|
77
|
+
/// kind layers come from one resolution; `routeConfig` from the caller's config.
|
|
78
|
+
func buildApiRequest(
|
|
79
|
+
kind: KeyPath<ResolvedLayers, TransformableRequestConfig?>?,
|
|
80
|
+
_ routeConfig: TransformableRequestConfig?,
|
|
81
|
+
path: String,
|
|
82
|
+
params: [String: String],
|
|
83
|
+
initialQuery: [String: String]? = nil,
|
|
84
|
+
) async throws -> HttpClient.HttpRequest {
|
|
85
|
+
var merged = RequestConfig(
|
|
86
|
+
method: nil, path: path, baseUrl: nil, headers: nil,
|
|
87
|
+
query: nil, body: nil, contentType: nil, userAgent: nil,
|
|
88
|
+
)
|
|
89
|
+
let layers = try await resolvedLayers()
|
|
90
|
+
merged = try await applyLayer(layers.request, to: merged, params: params)
|
|
91
|
+
if let initialQuery, !initialQuery.isEmpty {
|
|
92
|
+
merged = merged.copying(query: RequestLayer.mergeDicts(merged.query, initialQuery))
|
|
93
|
+
}
|
|
94
|
+
merged = try await applyLayer(kind.flatMap { layers[keyPath: $0] }, to: merged, params: params)
|
|
95
|
+
merged = try await applyLayer(routeConfig, to: merged, params: params)
|
|
96
|
+
|
|
97
|
+
guard merged.baseUrl != nil else {
|
|
98
|
+
throw BrowserError.invalidConfiguration("No URL configured for route")
|
|
99
|
+
}
|
|
100
|
+
return HttpClient.HttpRequest(
|
|
101
|
+
url: RequestLayer.buildUrl(merged, defaultPath: path),
|
|
102
|
+
method: merged.method?.stringValue ?? "GET",
|
|
103
|
+
headers: merged.headers,
|
|
104
|
+
body: merged.body,
|
|
105
|
+
contentType: merged.contentType ?? HttpClient.defaultContentType,
|
|
106
|
+
userAgent: merged.userAgent ?? HttpClient.defaultUserAgent,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Fetches a page object (`{title,path,children:[…]}`) through the request → browse → route
|
|
111
|
+
/// layers.
|
|
112
|
+
func fetchPage(
|
|
113
|
+
_ routeConfig: TransformableRequestConfig?,
|
|
114
|
+
path: String,
|
|
115
|
+
params: [String: String],
|
|
116
|
+
) async throws -> ResolvedTrack {
|
|
117
|
+
let request = try await buildApiRequest(
|
|
118
|
+
kind: \.browse, routeConfig, path: path, params: params,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
logger.debug("Resolving content from API")
|
|
122
|
+
logger.debug(" path: \(path)")
|
|
123
|
+
logger.debug(" url: \(request.url)")
|
|
124
|
+
|
|
125
|
+
let result: JsonResolvedTrack = try await httpClient.requestJson(request, as: JsonResolvedTrack.self)
|
|
126
|
+
let nitroResult = result.toNitro()
|
|
127
|
+
|
|
128
|
+
logger.debug("Resolved: \(nitroResult.title)")
|
|
129
|
+
if let sections = nitroResult.normalizedSections {
|
|
130
|
+
logger.debug(" children: \(sections.reduce(0) { $0 + $1.children.count }) tracks")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return nitroResult
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Fetches a search route's bare Track array through the request → search route layers. The
|
|
137
|
+
/// search params go through `initialQuery`, so they reach the URL even when a transform wins;
|
|
138
|
+
/// the config's static path seeds the base, since a layer's static path never applies.
|
|
139
|
+
func fetchSearch(_ searchConfig: TransformableRequestConfig, params: SearchParams) async throws -> [Track] {
|
|
140
|
+
let request = try await buildApiRequest(
|
|
141
|
+
kind: nil, searchConfig, path: searchConfig.path ?? "", params: ["q": params.query],
|
|
142
|
+
initialQuery: PageResolver.searchQueryParams(params),
|
|
143
|
+
)
|
|
144
|
+
logger.debug("Searching via API: \(request.url)")
|
|
145
|
+
let jsonTracks: [JsonTrack] = try await httpClient.requestJson(request, as: [JsonTrack].self)
|
|
146
|
+
return jsonTracks.map { $0.toNitro() }
|
|
147
|
+
}
|
|
5
148
|
|
|
6
|
-
extension BrowserManager {
|
|
7
149
|
// MARK: - Media URL Resolution
|
|
8
150
|
|
|
9
151
|
/// Resolves a media URL using the configured media transform.
|
|
@@ -11,7 +153,7 @@ extension BrowserManager {
|
|
|
11
153
|
///
|
|
12
154
|
/// Resolution layers, least- to most-specific: shared `request` layer →
|
|
13
155
|
/// `media` transform / static fields → `media.resolve(track)`. The final
|
|
14
|
-
/// `resolve` layer (if configured) is merged last
|
|
156
|
+
/// `resolve` layer (if configured) is merged last,
|
|
15
157
|
/// so its fields win. The `track` is only used to feed `resolve`; we do NOT
|
|
16
158
|
/// auto-merge `track.request` here.
|
|
17
159
|
func resolveMediaUrl(_ originalUrl: String, track: Track? = nil) async -> MediaResolvedUrl {
|
|
@@ -23,9 +165,8 @@ extension BrowserManager {
|
|
|
23
165
|
// `media` config is present, so a relative src still gets baseUrl.
|
|
24
166
|
let baseRequest: RequestConfig
|
|
25
167
|
do {
|
|
26
|
-
try await ensureLayersResolved()
|
|
27
168
|
baseRequest = try await applyLayer(
|
|
28
|
-
|
|
169
|
+
resolvedLayers().request,
|
|
29
170
|
to: RequestConfig(
|
|
30
171
|
method: nil, path: originalUrl, baseUrl: nil, headers: nil,
|
|
31
172
|
query: nil, body: nil, contentType: nil, userAgent: nil,
|
|
@@ -56,8 +197,8 @@ extension BrowserManager {
|
|
|
56
197
|
if let mediaConfig = config.media {
|
|
57
198
|
merged = try await applyMediaLayer(mediaConfig, to: merged)
|
|
58
199
|
}
|
|
59
|
-
merged =
|
|
60
|
-
let finalUrl = buildUrl(
|
|
200
|
+
if let resolveLayer { merged = RequestLayer.merge(merged, resolveLayer) }
|
|
201
|
+
let finalUrl = RequestLayer.buildUrl(merged)
|
|
61
202
|
logger.debug("Media URL resolved: \(originalUrl) -> \(finalUrl)")
|
|
62
203
|
return MediaResolvedUrl(
|
|
63
204
|
url: finalUrl,
|
|
@@ -70,46 +211,21 @@ extension BrowserManager {
|
|
|
70
211
|
}
|
|
71
212
|
}
|
|
72
213
|
|
|
73
|
-
///
|
|
74
|
-
///
|
|
75
|
-
/// bridge immediately (extractConfig, via awaitAsync/SyncConfig) — otherwise the
|
|
76
|
-
/// media config's static fields merge over the base with `path` carried from the
|
|
77
|
-
/// base (only a transform may change it). The same rule as `applyLayer`; matches
|
|
78
|
-
/// the web stub and Android.
|
|
214
|
+
/// The media kind's Request-Config Layer over `base`: its transforms, when it has any, else
|
|
215
|
+
/// its static fields (`RequestLayer.apply`).
|
|
79
216
|
private func applyMediaLayer(_ media: MediaRequestConfig, to base: RequestConfig) async throws -> RequestConfig {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
result = try await awaitAsyncConfig(transform(result, nil))
|
|
84
|
-
}
|
|
85
|
-
if let transformSync = media.transformSync {
|
|
86
|
-
result = try await awaitSyncConfig(transformSync(result, nil))
|
|
87
|
-
}
|
|
88
|
-
return result
|
|
217
|
+
var transforms: [nonisolated(nonsending)(RequestConfig) async throws -> RequestConfig] = []
|
|
218
|
+
if let transform = media.transform {
|
|
219
|
+
transforms.append { try await self.awaitAsyncConfig(transform($0, nil)) }
|
|
89
220
|
}
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
headers: media.headers,
|
|
97
|
-
query: media.query,
|
|
98
|
-
body: media.body,
|
|
99
|
-
contentType: media.contentType,
|
|
100
|
-
userAgent: media.userAgent,
|
|
101
|
-
),
|
|
102
|
-
)
|
|
103
|
-
return RequestConfig(
|
|
104
|
-
method: staticMerged.method,
|
|
105
|
-
path: base.path,
|
|
106
|
-
baseUrl: staticMerged.baseUrl,
|
|
107
|
-
headers: staticMerged.headers,
|
|
108
|
-
query: staticMerged.query,
|
|
109
|
-
body: staticMerged.body,
|
|
110
|
-
contentType: staticMerged.contentType,
|
|
111
|
-
userAgent: staticMerged.userAgent,
|
|
221
|
+
if let transformSync = media.transformSync {
|
|
222
|
+
transforms.append { try await self.awaitSyncConfig(transformSync($0, nil)) }
|
|
223
|
+
}
|
|
224
|
+
let fields = RequestConfig(
|
|
225
|
+
method: media.method, path: nil, baseUrl: media.baseUrl, headers: media.headers,
|
|
226
|
+
query: media.query, body: media.body, contentType: media.contentType, userAgent: media.userAgent,
|
|
112
227
|
)
|
|
228
|
+
return try await RequestLayer.apply(fields, transforms: transforms, to: base)
|
|
113
229
|
}
|
|
114
230
|
|
|
115
231
|
/// Invokes the consumer-supplied `media.resolve`/`resolveSync(track)` and merges
|
|
@@ -122,17 +238,7 @@ extension BrowserManager {
|
|
|
122
238
|
if let resolve = media.resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
|
|
123
239
|
var syncResolved: RequestConfig?
|
|
124
240
|
if let resolveSync = media.resolveSync { syncResolved = try await awaitSyncConfig(resolveSync(track)) }
|
|
125
|
-
return
|
|
126
|
-
async: asyncResolved, sync: syncResolved,
|
|
127
|
-
combine: { self.mergeRequestConfig(base: $0, override: $1) },
|
|
128
|
-
)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/// Merges the resolve layer (most specific, override-wins) over `base`.
|
|
132
|
-
/// A no-op when `resolve` is nil.
|
|
133
|
-
private func applyMediaResolveLayer(base: RequestConfig, resolve: RequestConfig?) -> RequestConfig {
|
|
134
|
-
guard let resolve else { return base }
|
|
135
|
-
return mergeRequestConfig(base: base, override: resolve)
|
|
241
|
+
return RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved)
|
|
136
242
|
}
|
|
137
243
|
|
|
138
244
|
// MARK: - Artwork URL Resolution
|
|
@@ -176,9 +282,8 @@ extension BrowserManager {
|
|
|
176
282
|
// Base config via the shared `request` layer (its transform runs for
|
|
177
283
|
// artwork too), with track.artwork as the default path. Artwork then
|
|
178
284
|
// shapes further via its own resolve / static fields / transform.
|
|
179
|
-
try await ensureLayersResolved()
|
|
180
285
|
var mergedConfig = try await applyLayer(
|
|
181
|
-
|
|
286
|
+
resolvedLayers().request,
|
|
182
287
|
to: RequestConfig(
|
|
183
288
|
method: nil, path: track.artwork?.url, baseUrl: nil, headers: nil,
|
|
184
289
|
query: nil, body: nil, contentType: nil, userAgent: nil,
|
|
@@ -200,18 +305,15 @@ extension BrowserManager {
|
|
|
200
305
|
contentType: artworkConfig.contentType,
|
|
201
306
|
userAgent: artworkConfig.userAgent,
|
|
202
307
|
)
|
|
203
|
-
mergedConfig =
|
|
308
|
+
mergedConfig = RequestLayer.merge(mergedConfig, artworkStaticConfig)
|
|
204
309
|
|
|
205
310
|
if artworkConfig.resolve != nil || artworkConfig.resolveSync != nil {
|
|
206
311
|
var asyncResolved: RequestConfig?
|
|
207
312
|
if let resolve = artworkConfig.resolve { asyncResolved = try await awaitAsyncConfig(resolve(track)) }
|
|
208
313
|
var syncResolved: RequestConfig?
|
|
209
314
|
if let resolveSync = artworkConfig.resolveSync { syncResolved = try await awaitSyncConfig(resolveSync(track)) }
|
|
210
|
-
if let resolved =
|
|
211
|
-
|
|
212
|
-
combine: { self.mergeRequestConfig(base: $0, override: $1) },
|
|
213
|
-
) {
|
|
214
|
-
mergedConfig = mergeRequestConfig(base: mergedConfig, override: resolved)
|
|
315
|
+
if let resolved = RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved) {
|
|
316
|
+
mergedConfig = RequestLayer.merge(mergedConfig, resolved)
|
|
215
317
|
} else if track.artwork == nil {
|
|
216
318
|
// A resolver ran but produced nothing, and there's no artwork URL either
|
|
217
319
|
// → no artwork (matches the web stub).
|
|
@@ -232,20 +334,7 @@ extension BrowserManager {
|
|
|
232
334
|
|
|
233
335
|
if !contextQuery.isEmpty {
|
|
234
336
|
logger.debug("Adding image query params: \(contextQuery)")
|
|
235
|
-
|
|
236
|
-
for (key, value) in contextQuery {
|
|
237
|
-
existingQuery[key] = value
|
|
238
|
-
}
|
|
239
|
-
mergedConfig = RequestConfig(
|
|
240
|
-
method: mergedConfig.method,
|
|
241
|
-
path: mergedConfig.path,
|
|
242
|
-
baseUrl: mergedConfig.baseUrl,
|
|
243
|
-
headers: mergedConfig.headers,
|
|
244
|
-
query: existingQuery,
|
|
245
|
-
body: mergedConfig.body,
|
|
246
|
-
contentType: mergedConfig.contentType,
|
|
247
|
-
userAgent: mergedConfig.userAgent,
|
|
248
|
-
)
|
|
337
|
+
mergedConfig = mergedConfig.copying(query: RequestLayer.mergeDicts(mergedConfig.query, contextQuery))
|
|
249
338
|
}
|
|
250
339
|
}
|
|
251
340
|
|
|
@@ -276,7 +365,7 @@ extension BrowserManager {
|
|
|
276
365
|
return nil
|
|
277
366
|
}
|
|
278
367
|
|
|
279
|
-
let uri = buildUrl(
|
|
368
|
+
let uri = RequestLayer.buildUrl(mergedConfig)
|
|
280
369
|
|
|
281
370
|
// Build headers map, merging explicit headers with userAgent
|
|
282
371
|
var headers = mergedConfig.headers ?? [:]
|
|
@@ -303,22 +392,6 @@ extension BrowserManager {
|
|
|
303
392
|
}
|
|
304
393
|
}
|
|
305
394
|
|
|
306
|
-
// MARK: - URL Building
|
|
307
|
-
|
|
308
|
-
private func buildUrl(from config: RequestConfig) -> String {
|
|
309
|
-
let path = config.path ?? ""
|
|
310
|
-
let baseUrl = config.baseUrl
|
|
311
|
-
|
|
312
|
-
var url = BrowserPathHelper.buildUrl(baseUrl: baseUrl, path: path)
|
|
313
|
-
|
|
314
|
-
// Add query parameters if any
|
|
315
|
-
if let query = config.query, !query.isEmpty {
|
|
316
|
-
url = BrowserPathHelper.appendQuery(query, to: url)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
return url
|
|
320
|
-
}
|
|
321
|
-
|
|
322
395
|
// MARK: - Config Utilities
|
|
323
396
|
|
|
324
397
|
/// Replaces the `{id}` token with the track id in a request config's path, query values, and
|
|
@@ -354,7 +427,6 @@ extension BrowserManager {
|
|
|
354
427
|
|
|
355
428
|
/// Extracts all values from a RequestConfig into a new instance to avoid
|
|
356
429
|
/// memory corruption in Nitro's Swift-C++ bridge when the Promise is deallocated.
|
|
357
|
-
/// Internal so `applyLayer` (in BrowserManager.swift) can copy transform results.
|
|
358
430
|
func extractConfig(_ config: RequestConfig) -> RequestConfig {
|
|
359
431
|
RequestConfig(
|
|
360
432
|
method: config.method,
|
|
@@ -367,18 +439,14 @@ extension BrowserManager {
|
|
|
367
439
|
userAgent: config.userAgent,
|
|
368
440
|
)
|
|
369
441
|
}
|
|
442
|
+
}
|
|
370
443
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
query: mergeDicts(base.query, override.query),
|
|
379
|
-
body: override.body ?? base.body,
|
|
380
|
-
contentType: override.contentType ?? base.contentType,
|
|
381
|
-
userAgent: override.userAgent ?? base.userAgent,
|
|
382
|
-
)
|
|
444
|
+
extension RequestConfig: RequestFields {}
|
|
445
|
+
|
|
446
|
+
extension RequestPipeline: MediaResolving {
|
|
447
|
+
/// Runs the `nowPlayingArtwork` config for every playing track, id or not: an id-less track
|
|
448
|
+
/// leaves an `{id}` token unfilled, so the request visibly fails rather than being skipped.
|
|
449
|
+
func resolveNowPlayingArtwork(track: Track, imageContext: ImageContext?) async -> ImageSource? {
|
|
450
|
+
await resolveArtworkUrl(track: track, perRouteConfig: config.nowPlayingArtwork, imageContext: imageContext)
|
|
383
451
|
}
|
|
384
452
|
}
|
|
@@ -64,6 +64,17 @@ extension CPInterfaceController {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/// Replaces `expected` with `template` while `expected` is still the top template, restoring
|
|
68
|
+
/// it if the push fails; a no-op once the user has moved on.
|
|
69
|
+
func safeReplaceTop(_ expected: CPTemplate, with template: CPTemplate, animated: Bool) {
|
|
70
|
+
whenStackSettled { controller in
|
|
71
|
+
guard controller.topTemplate === expected else { return }
|
|
72
|
+
controller.topReplacer.push(
|
|
73
|
+
template, replacingTopOf: GuardedTemplateStack(controller: controller), animated: animated,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
/// Pushes `template`, skipping the raise cases (already in the hierarchy,
|
|
68
79
|
/// stack at the depth limit).
|
|
69
80
|
fileprivate func guardedPush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)?) {
|
|
@@ -150,30 +161,6 @@ extension CPInterfaceController {
|
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
|
-
|
|
154
|
-
func safePresent(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
155
|
-
presentTemplate(template, animated: animated) { presented, error in
|
|
156
|
-
if let error {
|
|
157
|
-
Self.stackLogger.error("presentTemplate \(Self.name(of: template)) failed: \(error.localizedDescription)")
|
|
158
|
-
}
|
|
159
|
-
completion?(presented, error)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/// Dismisses the presented template, skipping when nothing is presented.
|
|
164
|
-
func safeDismiss(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
165
|
-
guard presentedTemplate != nil else {
|
|
166
|
-
Self.stackLogger.debug("dismiss skipped: no presented template")
|
|
167
|
-
completion?(false, nil)
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
dismissTemplate(animated: animated) { dismissed, error in
|
|
171
|
-
if let error {
|
|
172
|
-
Self.stackLogger.error("dismissTemplate failed: \(error.localizedDescription)")
|
|
173
|
-
}
|
|
174
|
-
completion?(dismissed, error)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
164
|
}
|
|
178
165
|
|
|
179
166
|
/// The guarded pop and push a replacement is made of, so a push that still
|
|
@@ -1,108 +1,60 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
-
///
|
|
3
|
+
/// An artwork URL and the headers to fetch it with.
|
|
4
4
|
struct ArtworkResolvedImage: Equatable, Sendable {
|
|
5
5
|
let uri: String
|
|
6
6
|
let headers: [String: String]?
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
///
|
|
10
|
-
/// Produced by `CarPlayArtworkResolver`, consumed by `CarPlayImageLoader`.
|
|
9
|
+
/// How `CarPlayImageLoader` loads one artwork.
|
|
11
10
|
enum ArtworkLoadAction: Equatable, Sendable {
|
|
12
|
-
/// Render
|
|
11
|
+
/// Render the `sf:` artwork at this canvas size.
|
|
13
12
|
case sfSymbol(artwork: String, width: Double, height: Double)
|
|
14
|
-
/// Fetch an image from a URL, optionally with headers, tinting, and SVG processing
|
|
15
13
|
case fetch(uri: String, headers: [String: String]?, shouldTint: Bool, isSvg: Bool)
|
|
16
|
-
/// No artwork available
|
|
17
14
|
case none
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
///
|
|
21
|
-
///
|
|
22
|
-
/// Encapsulates the decision tree for how to load artwork:
|
|
23
|
-
/// 1. SF Symbol artwork → render directly
|
|
24
|
-
/// 2. URL resolver available → resolve, then fetch from resolved URL
|
|
25
|
-
/// 3. Fallback → fetch directly from artwork/artworkSource URL
|
|
17
|
+
/// Decides how CarPlay loads a track's artwork, given what the Browser layer's
|
|
18
|
+
/// `resolveArtworkUrl` returned for it.
|
|
26
19
|
enum CarPlayArtworkResolver {
|
|
27
|
-
///
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
/// - artwork: The track's `artwork` field (may be an SF Symbol string or URL)
|
|
31
|
-
/// - artworkSourceUri: The track's `artworkSource?.uri` field
|
|
32
|
-
/// - stencil: Whether the artwork renders as a monochrome glyph tinted to
|
|
33
|
-
/// the surface appearance (`style.artworkRendering: 'stencil'`)
|
|
34
|
-
/// - targetWidth: Target width in points
|
|
35
|
-
/// - targetHeight: Target height in points
|
|
36
|
-
/// - displayScale: The CarPlay display scale factor
|
|
37
|
-
/// - urlResolver: Optional closure that resolves artwork URLs (wraps BrowserManager)
|
|
38
|
-
static func resolve(
|
|
39
|
-
artwork: String?,
|
|
40
|
-
artworkSourceUri: String?,
|
|
41
|
-
stencil: Bool,
|
|
42
|
-
targetWidth: Double,
|
|
43
|
-
targetHeight: Double,
|
|
44
|
-
displayScale: Double,
|
|
45
|
-
urlResolver: ((Double, Double) async -> ArtworkResolvedImage?)?,
|
|
46
|
-
) async -> ArtworkLoadAction {
|
|
47
|
-
// 1. Primary SF symbol check
|
|
48
|
-
if let artwork, artwork.hasPrefix("sf:") {
|
|
49
|
-
return .sfSymbol(artwork: artwork, width: targetWidth, height: targetHeight)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// 2. Try URL resolution via resolver
|
|
53
|
-
if let urlResolver {
|
|
54
|
-
let pixelWidth = targetWidth * displayScale
|
|
55
|
-
let pixelHeight = targetHeight * displayScale
|
|
56
|
-
if let resolved = await urlResolver(pixelWidth, pixelHeight) {
|
|
57
|
-
guard let resolvedUrl = URL(string: resolved.uri) else {
|
|
58
|
-
return resolveDirectFallback(
|
|
59
|
-
artwork: artwork,
|
|
60
|
-
artworkSourceUri: artworkSourceUri,
|
|
61
|
-
stencil: stencil,
|
|
62
|
-
targetWidth: targetWidth,
|
|
63
|
-
targetHeight: targetHeight,
|
|
64
|
-
)
|
|
65
|
-
}
|
|
66
|
-
let isSvg = resolvedUrl.pathExtension.lowercased() == "svg"
|
|
67
|
-
return .fetch(
|
|
68
|
-
uri: resolved.uri,
|
|
69
|
-
headers: resolved.headers,
|
|
70
|
-
shouldTint: stencil,
|
|
71
|
-
isSvg: isSvg,
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// 3. Direct fallback
|
|
77
|
-
return resolveDirectFallback(
|
|
78
|
-
artwork: artwork,
|
|
79
|
-
artworkSourceUri: artworkSourceUri,
|
|
80
|
-
stencil: stencil,
|
|
81
|
-
targetWidth: targetWidth,
|
|
82
|
-
targetHeight: targetHeight,
|
|
83
|
-
)
|
|
20
|
+
/// Whether to ask the Browser layer at all: CarPlay renders an SF Symbol itself.
|
|
21
|
+
static func needsResolve(artwork: String?) -> Bool {
|
|
22
|
+
!isSFSymbol(artwork)
|
|
84
23
|
}
|
|
85
24
|
|
|
86
|
-
|
|
25
|
+
/// An SF Symbol renders; otherwise the resolved URL is fetched, falling back to the track's
|
|
26
|
+
/// `artwork`, then its `artworkSource` with that source's headers. `stencil` tints the image
|
|
27
|
+
/// to the surface's appearance.
|
|
28
|
+
static func action(
|
|
87
29
|
artwork: String?,
|
|
88
|
-
|
|
30
|
+
artworkSource: ArtworkResolvedImage?,
|
|
31
|
+
resolved: ArtworkResolvedImage?,
|
|
89
32
|
stencil: Bool,
|
|
90
|
-
|
|
91
|
-
|
|
33
|
+
width: Double,
|
|
34
|
+
height: Double,
|
|
92
35
|
) -> ArtworkLoadAction {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
36
|
+
if isSFSymbol(artwork), let artwork {
|
|
37
|
+
return .sfSymbol(artwork: artwork, width: width, height: height)
|
|
38
|
+
}
|
|
39
|
+
if let resolved, let action = fetch(resolved, stencil: stencil) {
|
|
40
|
+
return action
|
|
41
|
+
}
|
|
42
|
+
let direct = artwork.map { ArtworkResolvedImage(uri: $0, headers: nil) } ?? artworkSource
|
|
43
|
+
guard let direct else { return .none }
|
|
44
|
+
if isSFSymbol(direct.uri) {
|
|
45
|
+
return .sfSymbol(artwork: direct.uri, width: width, height: height)
|
|
97
46
|
}
|
|
47
|
+
return fetch(direct, stencil: stencil) ?? .none
|
|
48
|
+
}
|
|
98
49
|
|
|
99
|
-
|
|
100
|
-
let
|
|
50
|
+
private static func fetch(_ image: ArtworkResolvedImage, stencil: Bool) -> ArtworkLoadAction? {
|
|
51
|
+
guard let url = URL(string: image.uri) else { return nil }
|
|
101
52
|
return .fetch(
|
|
102
|
-
uri:
|
|
103
|
-
headers: nil,
|
|
104
|
-
shouldTint: stencil,
|
|
105
|
-
isSvg: isSvg,
|
|
53
|
+
uri: image.uri, headers: image.headers, shouldTint: stencil, isSvg: url.pathExtension.lowercased() == "svg",
|
|
106
54
|
)
|
|
107
55
|
}
|
|
56
|
+
|
|
57
|
+
private static func isSFSymbol(_ artwork: String?) -> Bool {
|
|
58
|
+
artwork?.hasPrefix("sf:") == true
|
|
59
|
+
}
|
|
108
60
|
}
|