react-native-audio-browser 0.2.0-next.33 → 0.2.0-next.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ios/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- package/ios/CarPlay/CPInterfaceController+Safe.swift +0 -7
- package/ios/CarPlay/CarPlayController.swift +0 -6
- package/ios/CarPlay/CarPlayImageLoader.swift +44 -97
- package/ios/CarPlay/CarPlayListItemFactory.swift +74 -99
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +13 -46
- package/ios/CarPlay/CarPlaySession+Navigator.swift +2 -13
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +12 -13
- package/ios/CarPlay/CarPlaySession+Refresher.swift +13 -13
- package/ios/CarPlay/CarPlaySession+TabBar.swift +22 -23
- package/ios/CarPlay/CarPlaySession.swift +10 -12
- package/ios/CarPlay/IntentHandlers.swift +96 -0
- package/ios/CarPlay/MediaIntentCriteria.swift +38 -80
- package/ios/CarPlay/{RNABMediaIntentHandler.swift → MediaIntentHandler.swift} +3 -6
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- package/ios/CarPlay/NowPlayingButtonRules.swift +35 -0
- package/ios/CarPlay/RNABAudioBrowser.m +7 -15
- package/ios/Extension/Capability+RemoteCommand.swift +22 -69
- package/ios/Extension/Track+AVPlayer.swift +0 -16
- package/ios/HybridAudioBrowser+Browser.swift +16 -17
- package/ios/HybridAudioBrowser+Options.swift +27 -66
- package/ios/HybridAudioBrowser+Player.swift +4 -60
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
- package/ios/HybridAudioBrowser.swift +36 -99
- package/ios/Model/NitroTypeStubs.swift +15 -0
- package/ios/Model/PlayerUpdateOptions.swift +2 -0
- package/ios/Model/RemoteCommand.swift +6 -78
- package/ios/Player/NowPlayingUpdater.swift +13 -7
- package/ios/Player/Player.swift +45 -10
- package/ios/Player/PlayerCallbacks.swift +1 -4
- package/ios/Player/PlayerEngine.swift +17 -0
- package/ios/Player/RetryLoop.swift +5 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
- package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
- package/ios/Tests/ConcurrentMapTests.swift +42 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
- package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +136 -17
- package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
- package/ios/Tests/Support/TestTypes.swift +44 -3
- package/ios/TrackSelector.swift +34 -52
- package/ios/Util/ConcurrentMap.swift +19 -0
- package/ios/Util/Emitter.swift +0 -14
- package/ios/Util/NetworkMonitor.swift +8 -25
- package/ios/Util/OutputPicker.swift +48 -0
- package/package.json +1 -1
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +0 -20
- package/ios/CarPlay/RNABMediaAddHandler.swift +0 -59
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +0 -53
- package/ios/Model/SourceType.swift +0 -6
- package/ios/Tests/RemoteCommandTests.swift +0 -65
package/ios/AVPlayerEngine.swift
CHANGED
|
@@ -13,6 +13,7 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
13
13
|
|
|
14
14
|
var onEvent: (@MainActor (EngineEvent) -> Void)?
|
|
15
15
|
private weak var callbacks: AVPlayerEngineCallbacks?
|
|
16
|
+
private weak var audioSession: AudioSessionController?
|
|
16
17
|
|
|
17
18
|
let nowPlayingInfoController: NowPlayingInfoController
|
|
18
19
|
let nowPlayingUpdater: NowPlayingUpdater
|
|
@@ -25,25 +26,40 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
25
26
|
private var itemObserver: ItemObserver?
|
|
26
27
|
private var rate: Float = 1
|
|
27
28
|
private var skipAvailability = (canNext: false, canPrevious: false)
|
|
28
|
-
|
|
29
|
-
var
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
///
|
|
29
|
+
/// The charset of the track last loaded, for its ICY titles.
|
|
30
|
+
private var icyCharset: String?
|
|
31
|
+
private let bufferDuration: Double
|
|
32
|
+
|
|
33
|
+
/// - Parameters:
|
|
34
|
+
/// - resolver: Resolves media URLs and now-playing artwork.
|
|
35
|
+
/// - bufferDuration: 0 lets AVPlayer choose, minimising stalls.
|
|
36
|
+
/// - nowPlayingEnabled: When false the now-playing surface ignores the override and formatter.
|
|
34
37
|
init(
|
|
35
38
|
callbacks: AVPlayerEngineCallbacks?,
|
|
36
39
|
resolver: MediaResolving?,
|
|
40
|
+
audioSession: AudioSessionController,
|
|
41
|
+
networkStatus: (any NetworkStatusProviding)?,
|
|
42
|
+
bufferDuration: Double,
|
|
43
|
+
nowPlayingEnabled: Bool,
|
|
44
|
+
nowPlayingFormatter: ((FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
|
|
37
45
|
nowPlayingInfoController: NowPlayingInfoController = NowPlayingInfoController(),
|
|
38
46
|
) {
|
|
39
47
|
self.callbacks = callbacks
|
|
48
|
+
self.audioSession = audioSession
|
|
49
|
+
self.bufferDuration = bufferDuration
|
|
40
50
|
self.nowPlayingInfoController = nowPlayingInfoController
|
|
41
|
-
nowPlayingUpdater = NowPlayingUpdater(
|
|
51
|
+
nowPlayingUpdater = NowPlayingUpdater(
|
|
52
|
+
nowPlayingInfoController: nowPlayingInfoController,
|
|
53
|
+
networkStatus: networkStatus,
|
|
54
|
+
enabled: nowPlayingEnabled,
|
|
55
|
+
formatter: nowPlayingFormatter,
|
|
56
|
+
)
|
|
42
57
|
remoteCommandController = RemoteCommandController(callbacks: callbacks)
|
|
43
58
|
mediaLoader = MediaLoader { [weak resolver] src, track in
|
|
44
59
|
await resolver?.resolveMediaUrl(src, track: track) ?? MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
45
60
|
}
|
|
46
61
|
|
|
62
|
+
mediaLoader.bufferDuration = bufferDuration
|
|
47
63
|
mediaLoader.onEvent = { [weak self] in self?.mediaLoaderDidEmit($0) }
|
|
48
64
|
nowPlayingUpdater.artworkUrlResolver = { [weak resolver] track, imageContext in
|
|
49
65
|
await resolver?.resolveNowPlayingArtwork(track: track, imageContext: imageContext)
|
|
@@ -65,21 +81,6 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
65
81
|
stopObservingItem()
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
/// Re-requests the now-playing election, which the system may have declined at link time while
|
|
69
|
-
/// the audio session was still inactive.
|
|
70
|
-
func reactivateNowPlayingSession() {
|
|
71
|
-
nowPlayingInfoController.reactivateSessionIfNeeded()
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// MARK: - Buffering
|
|
75
|
-
|
|
76
|
-
var bufferDuration: Double = 0 {
|
|
77
|
-
didSet {
|
|
78
|
-
avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
|
|
79
|
-
mediaLoader.bufferDuration = bufferDuration
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
84
|
// MARK: - The Current Item
|
|
84
85
|
|
|
85
86
|
var hasItem: Bool {
|
|
@@ -141,6 +142,7 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
141
142
|
// MARK: - Loading
|
|
142
143
|
|
|
143
144
|
func resolveAndLoad(src: String, track: Track) {
|
|
145
|
+
icyCharset = track.icyCharset
|
|
144
146
|
mediaLoader.resolveAndLoad(src: src, track: track)
|
|
145
147
|
}
|
|
146
148
|
|
|
@@ -221,6 +223,18 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
221
223
|
nowPlayingUpdater.clearNowPlaying()
|
|
222
224
|
}
|
|
223
225
|
|
|
226
|
+
func renderNowPlaying() {
|
|
227
|
+
nowPlayingUpdater.render()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func nowPlayingTrackChanged() {
|
|
231
|
+
nowPlayingUpdater.trackChanged()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func applyTimedMetadata(_ metadata: TimedMetadata) {
|
|
235
|
+
nowPlayingUpdater.receivedTimedMetadata(metadata)
|
|
236
|
+
}
|
|
237
|
+
|
|
224
238
|
func updateRemoteRepeatMode(_ mode: RepeatMode) {
|
|
225
239
|
remoteCommandController.updateRepeatMode(mode)
|
|
226
240
|
}
|
|
@@ -234,6 +248,20 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
234
248
|
remoteCommandController.refreshSkipAvailability()
|
|
235
249
|
}
|
|
236
250
|
|
|
251
|
+
// MARK: - Audio Session
|
|
252
|
+
|
|
253
|
+
func activateAudioSession() {
|
|
254
|
+
audioSession?.activate { [weak self] in self?.nowPlayingInfoController.reactivateSessionIfNeeded() }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func cancelAudioSessionRelease() {
|
|
258
|
+
audioSession?.cancelRelease()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func scheduleAudioSessionRelease() {
|
|
262
|
+
audioSession?.scheduleRelease()
|
|
263
|
+
}
|
|
264
|
+
|
|
237
265
|
// MARK: - Observed Events
|
|
238
266
|
|
|
239
267
|
private func playerDidEmit(_ event: PlayerObserver.Event) {
|
|
@@ -260,7 +288,7 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
260
288
|
case let .failed(error), let .failedToPlayToEnd(error): reportFailure(error)
|
|
261
289
|
case .playedToEnd: onEvent?(.playedToEnd)
|
|
262
290
|
case .stalled: onEvent?(.stalled)
|
|
263
|
-
case let .timedMetadata(groups):
|
|
291
|
+
case let .timedMetadata(groups): reportTimedMetadata(groups)
|
|
264
292
|
}
|
|
265
293
|
}
|
|
266
294
|
|
|
@@ -299,7 +327,15 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
299
327
|
case let .chapterMetadata(groups):
|
|
300
328
|
callbacks?.playerDidReceiveChapterMetadata(groups)
|
|
301
329
|
case let .timedMetadata(groups):
|
|
302
|
-
|
|
330
|
+
reportTimedMetadata(groups)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private func reportTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
|
|
335
|
+
for group in groups {
|
|
336
|
+
if let metadata = TimedMetadata.from(items: group.items, icyCharset: icyCharset) {
|
|
337
|
+
onEvent?(.timedMetadata(metadata))
|
|
338
|
+
}
|
|
303
339
|
}
|
|
304
340
|
}
|
|
305
341
|
|
|
@@ -8,7 +8,6 @@ import NitroModules
|
|
|
8
8
|
@MainActor
|
|
9
9
|
protocol AVPlayerEngineCallbacks: RemoteCommandCallbacks {
|
|
10
10
|
func playerDidReceiveCommonMetadata(_ metadata: [AVMetadataItem])
|
|
11
|
-
func playerDidReceiveTimedMetadata(_ metadata: [AVTimedMetadataGroup])
|
|
12
11
|
func playerDidReceiveChapterMetadata(_ metadata: [AVTimedMetadataGroup])
|
|
13
12
|
/// The now-playing lines or track changed; carries the metadata JS sees.
|
|
14
13
|
func playerDidChangeNowPlaying(_ metadata: NowPlayingMetadata)
|
|
@@ -111,13 +111,6 @@ extension CPInterfaceController {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/// Pops the top template, skipping the raise case (nothing above the root).
|
|
115
|
-
func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
116
|
-
whenStackSettled { controller in
|
|
117
|
-
controller.guardedPop(animated: animated, completion: completion)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
114
|
/// Pops back to `template`, skipping the raise case (not in the hierarchy).
|
|
122
115
|
/// A no-op success when it is already the top template.
|
|
123
116
|
func safePop(to template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
|
|
@@ -8,12 +8,6 @@ import os.log
|
|
|
8
8
|
@MainActor
|
|
9
9
|
@objc(RNABCarPlayController)
|
|
10
10
|
public final class RNABCarPlayController: NSObject {
|
|
11
|
-
/// The Siri handlers `RNABAudioBrowser.m` looks up by name, referenced so a static link
|
|
12
|
-
/// keeps them.
|
|
13
|
-
@objc public static let siriIntentHandlerClasses: [AnyClass] = [
|
|
14
|
-
RNABMediaIntentHandler.self, RNABMediaAffinityHandler.self, RNABMediaAddHandler.self,
|
|
15
|
-
]
|
|
16
|
-
|
|
17
11
|
private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayController")
|
|
18
12
|
private let interfaceController: CPInterfaceController
|
|
19
13
|
private var interfaceDelegate: InterfaceControllerDelegate?
|
|
@@ -6,7 +6,7 @@ import os.log
|
|
|
6
6
|
/// Handles all image loading and rendering for CarPlay.
|
|
7
7
|
///
|
|
8
8
|
/// Responsibilities:
|
|
9
|
-
/// - SF Symbol rendering
|
|
9
|
+
/// - SF Symbol rendering, with light/dark variants
|
|
10
10
|
/// - Artwork loading via BrowserManager's artwork URL resolution
|
|
11
11
|
/// - Direct artwork loading as fallback
|
|
12
12
|
/// - Adaptive image tinting for monochrome icons
|
|
@@ -25,15 +25,10 @@ final class CarPlayImageLoader {
|
|
|
25
25
|
|
|
26
26
|
// MARK: - SF Symbols
|
|
27
27
|
|
|
28
|
-
/// Creates an SF Symbol image for tabs - plain systemName, CarPlay handles tinting
|
|
29
|
-
func sfSymbolImage(_ symbolName: String) -> UIImage? {
|
|
30
|
-
UIImage(systemName: symbolName)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
28
|
/// Renders an SF Symbol from an artwork string (e.g. "sf:heart.fill?bg=#000&fg=#fff"). With an
|
|
34
29
|
/// `fg` it is drawn in that color; without one it is black or white by appearance, over a grey
|
|
35
30
|
/// tile.
|
|
36
|
-
func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
|
|
31
|
+
private func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
|
|
37
32
|
let (name, background, tint) = SFSymbolRenderer.parseArtwork(artwork)
|
|
38
33
|
let scale = carTraitCollection.displayScale
|
|
39
34
|
let lightTile = UIColor(white: 0.92, alpha: 1)
|
|
@@ -72,104 +67,56 @@ final class CarPlayImageLoader {
|
|
|
72
67
|
|
|
73
68
|
// MARK: - Artwork Loading
|
|
74
69
|
|
|
75
|
-
///
|
|
76
|
-
///
|
|
77
|
-
///
|
|
78
|
-
///
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
/// - completion: Called with the loaded image, or nil on failure
|
|
83
|
-
func loadArtwork(for track: Track, style: TrackStyle? = nil, size: CGSize, completion: @escaping @Sendable (UIImage?) -> Void) {
|
|
84
|
-
// A track that ships one image per appearance is loaded as two ordinary
|
|
85
|
-
// single-URL loads and combined below. Recursing rather than branching
|
|
86
|
-
// inside the loader means each variant still goes through the whole
|
|
87
|
-
// pipeline — route config, headers, SVG rasterising, the resolve transform
|
|
88
|
-
// — instead of a second, thinner fetch path that would drift from it.
|
|
70
|
+
/// A track's artwork at `size` points, through the artwork transform when one is configured;
|
|
71
|
+
/// nil when it has none or the load fails.
|
|
72
|
+
/// - Parameter style: the track's resolved style block; nil for the track's own declaration,
|
|
73
|
+
/// when there is no section to inherit from (tabs, Up Next).
|
|
74
|
+
func artwork(for track: Track, style: TrackStyle? = nil, size: CGSize) async -> UIImage? {
|
|
75
|
+
// Each variant goes through the whole single-URL path (route config, headers, SVG, the
|
|
76
|
+
// resolve transform), so the pair cannot drift from it.
|
|
89
77
|
if let variants = track.artwork?.variants {
|
|
90
|
-
|
|
91
|
-
return
|
|
78
|
+
return await artwork(for: track, style: style, variants: variants, size: size)
|
|
92
79
|
}
|
|
93
80
|
|
|
94
81
|
let artwork = track.artwork?.url
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
)
|
|
82
|
+
var resolved: ArtworkResolvedImage?
|
|
83
|
+
if CarPlayArtworkResolver.needsResolve(artwork: artwork), let browserManager {
|
|
84
|
+
let scale = Double(carTraitCollection.displayScale)
|
|
85
|
+
let context = ImageContext(width: Double(size.width) * scale, height: Double(size.height) * scale)
|
|
86
|
+
resolved = await browserManager.requests.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: context)
|
|
87
|
+
.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) }
|
|
88
|
+
}
|
|
89
|
+
let action = CarPlayArtworkResolver.action(
|
|
90
|
+
artwork: artwork,
|
|
91
|
+
artworkSource: track.artworkSource.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) },
|
|
92
|
+
resolved: resolved,
|
|
93
|
+
stencil: (style ?? track.style)?.artworkRendering == .stencil,
|
|
94
|
+
width: Double(size.width),
|
|
95
|
+
height: Double(size.height),
|
|
96
|
+
)
|
|
111
97
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
case .none:
|
|
123
|
-
completion(nil)
|
|
124
|
-
}
|
|
98
|
+
switch action {
|
|
99
|
+
case let .sfSymbol(artwork, width, height):
|
|
100
|
+
return sfSymbolImage(forArtwork: artwork, canvasSize: CGSize(width: width, height: height))
|
|
101
|
+
case let .fetch(uri, headers, shouldTint, isSvg):
|
|
102
|
+
let image = await fetchImage(uri: uri, headers: headers, isSvg: isSvg, size: size)
|
|
103
|
+
guard let image, shouldTint else { return image }
|
|
104
|
+
return tinted(image)
|
|
105
|
+
case .none:
|
|
106
|
+
return nil
|
|
125
107
|
}
|
|
126
108
|
}
|
|
127
109
|
|
|
128
110
|
// MARK: - Private
|
|
129
111
|
|
|
130
|
-
///
|
|
131
|
-
///
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
/// If either half fails to load, the other is used alone rather than dropping
|
|
139
|
-
/// the row's image entirely — a glyph that is wrong for one appearance still
|
|
140
|
-
/// beats an empty slot, and this is usually a transient fetch failure.
|
|
141
|
-
private func loadVariants(
|
|
142
|
-
for track: Track,
|
|
143
|
-
style: TrackStyle?,
|
|
144
|
-
variants: ArtworkVariants,
|
|
145
|
-
size: CGSize,
|
|
146
|
-
completion: @escaping @Sendable (UIImage?) -> Void,
|
|
147
|
-
) {
|
|
148
|
-
Task {
|
|
149
|
-
async let light = self.loadArtworkAsync(
|
|
150
|
-
for: track.copying(artwork: .some(.first(variants.light))), style: style, size: size,
|
|
151
|
-
)
|
|
152
|
-
async let dark = self.loadArtworkAsync(
|
|
153
|
-
for: track.copying(artwork: .some(.first(variants.dark))), style: style, size: size,
|
|
154
|
-
)
|
|
155
|
-
let (lightImage, darkImage) = await (light, dark)
|
|
156
|
-
|
|
157
|
-
guard let lightImage, let darkImage else {
|
|
158
|
-
completion(lightImage ?? darkImage)
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
completion(.adaptive(light: lightImage, dark: darkImage, for: self.carTraitCollection))
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/// `loadArtwork` as an awaitable, so the two variants can load concurrently.
|
|
167
|
-
private func loadArtworkAsync(for track: Track, style: TrackStyle?, size: CGSize) async -> UIImage? {
|
|
168
|
-
await withCheckedContinuation { continuation in
|
|
169
|
-
self.loadArtwork(for: track, style: style, size: size) { image in
|
|
170
|
-
continuation.resume(returning: image)
|
|
171
|
-
}
|
|
172
|
-
}
|
|
112
|
+
/// Both images of an `ArtworkVariants` pair as one adaptive image, which CarPlay re-resolves
|
|
113
|
+
/// in place when the appearance changes. When one half fails, the other is used alone.
|
|
114
|
+
private func artwork(for track: Track, style: TrackStyle?, variants: ArtworkVariants, size: CGSize) async -> UIImage? {
|
|
115
|
+
async let light = artwork(for: track.copying(artwork: .some(.first(variants.light))), style: style, size: size)
|
|
116
|
+
async let dark = artwork(for: track.copying(artwork: .some(.first(variants.dark))), style: style, size: size)
|
|
117
|
+
let (lightImage, darkImage) = await (light, dark)
|
|
118
|
+
guard let lightImage, let darkImage else { return lightImage ?? darkImage }
|
|
119
|
+
return .adaptive(light: lightImage, dark: darkImage, for: carTraitCollection)
|
|
173
120
|
}
|
|
174
121
|
|
|
175
122
|
/// Fetches an image from a URL with optional headers and SVG processing.
|
|
@@ -199,8 +146,8 @@ final class CarPlayImageLoader {
|
|
|
199
146
|
return rendered.withRenderingMode(.alwaysOriginal)
|
|
200
147
|
}
|
|
201
148
|
|
|
202
|
-
///
|
|
203
|
-
private
|
|
149
|
+
/// A monochrome image drawn black for light appearance and white for dark.
|
|
150
|
+
private func tinted(_ image: UIImage) -> UIImage {
|
|
204
151
|
.adaptive(
|
|
205
152
|
light: renderImageToBitmap(image, tintColor: .black),
|
|
206
153
|
dark: renderImageToBitmap(image, tintColor: .white),
|