react-native-audio-browser 0.2.0-next.34 → 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/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 +11 -44
- 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 +8 -10
- 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/NowPlayingButtonRules.swift +35 -0
- package/ios/CarPlay/RNABAudioBrowser.m +7 -15
- package/ios/Player/Player.swift +19 -0
- package/ios/Tests/ConcurrentMapTests.swift +42 -0
- package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
- package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
- package/ios/Tests/PlayerTests.swift +41 -0
- package/ios/Util/ConcurrentMap.swift +19 -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
|
@@ -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),
|
|
@@ -29,18 +29,42 @@ extension CPListItem {
|
|
|
29
29
|
final class CarPlayListItemFactory {
|
|
30
30
|
private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayListItemFactory")
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
///
|
|
34
|
-
|
|
35
|
-
var playingIndicatorLocation: CPListItemPlayingIndicatorLocation = .leading
|
|
32
|
+
private let imageLoader: CarPlayImageLoader
|
|
33
|
+
/// The last-applied location; the factory never reads options itself.
|
|
34
|
+
var playingIndicatorLocation: CPListItemPlayingIndicatorLocation
|
|
36
35
|
private let isActiveTrack: (_ identity: String?) -> Bool
|
|
36
|
+
/// Runs an artwork load as a task the session owns, so ending the session cancels it.
|
|
37
|
+
private let spawn: (@escaping @MainActor () async -> Void) -> Void
|
|
37
38
|
/// A row or tile was tapped; call `completion` once CarPlay may take the next tap.
|
|
38
|
-
|
|
39
|
+
private let onItemSelected: (Track, @escaping () -> Void) -> Void
|
|
39
40
|
/// A section header's "view all" was tapped.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
init(
|
|
41
|
+
private let onNavigate: (_ path: String, _ title: String) -> Void
|
|
42
|
+
|
|
43
|
+
init(
|
|
44
|
+
imageLoader: CarPlayImageLoader,
|
|
45
|
+
playingIndicatorLocation: CPListItemPlayingIndicatorLocation,
|
|
46
|
+
isActiveTrack: @escaping (_ identity: String?) -> Bool,
|
|
47
|
+
spawn: @escaping (@escaping @MainActor () async -> Void) -> Void,
|
|
48
|
+
onItemSelected: @escaping (Track, @escaping () -> Void) -> Void,
|
|
49
|
+
onNavigate: @escaping (_ path: String, _ title: String) -> Void,
|
|
50
|
+
) {
|
|
51
|
+
self.imageLoader = imageLoader
|
|
52
|
+
self.playingIndicatorLocation = playingIndicatorLocation
|
|
43
53
|
self.isActiveTrack = isActiveTrack
|
|
54
|
+
self.spawn = spawn
|
|
55
|
+
self.onItemSelected = onItemSelected
|
|
56
|
+
self.onNavigate = onNavigate
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Loads `track`'s artwork and hands it to `apply` unless the session ended meanwhile.
|
|
60
|
+
private func loadArtwork(
|
|
61
|
+
for track: Track, style: TrackStyle?, size: CGSize, apply: @escaping @MainActor (UIImage?) -> Void,
|
|
62
|
+
) {
|
|
63
|
+
spawn { [imageLoader] in
|
|
64
|
+
let image = await imageLoader.artwork(for: track, style: style, size: size)
|
|
65
|
+
guard !Task.isCancelled else { return }
|
|
66
|
+
apply(image)
|
|
67
|
+
}
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
// MARK: - Sections
|
|
@@ -117,14 +141,11 @@ final class CarPlayListItemFactory {
|
|
|
117
141
|
|
|
118
142
|
// MARK: - List Item
|
|
119
143
|
|
|
120
|
-
///
|
|
144
|
+
/// A list row for `track`: its identity, accessory, playing flag, artwork and tap handler.
|
|
121
145
|
/// - Parameters:
|
|
122
|
-
/// -
|
|
123
|
-
///
|
|
124
|
-
///
|
|
125
|
-
/// genuinely section-less caller (the Up Next queue list) has no
|
|
126
|
-
/// inheritance to resolve.
|
|
127
|
-
/// - handler: Optional custom handler. If nil, uses default browse/play handling.
|
|
146
|
+
/// - style: The track's resolved style block (`track ?? section ?? page`); nil for the
|
|
147
|
+
/// track's own declaration, when there is no section (Up Next).
|
|
148
|
+
/// - handler: Replaces the default browse or play on tap.
|
|
128
149
|
func createListItem(
|
|
129
150
|
for track: Track,
|
|
130
151
|
style: TrackStyle? = nil,
|
|
@@ -135,26 +156,18 @@ final class CarPlayListItemFactory {
|
|
|
135
156
|
detailText: track.subtitle,
|
|
136
157
|
)
|
|
137
158
|
|
|
138
|
-
// Store typed info for updatePlayingIndicators()
|
|
139
159
|
item.setCarPlayItemInfo(CarPlayItemInfo(identity: track.identity))
|
|
140
160
|
|
|
141
|
-
// Set accessory type based on whether track is browsable or playable
|
|
142
161
|
if let src = track.src {
|
|
143
|
-
// Playable track - check if it's currently playing
|
|
144
162
|
item.accessoryType = .none
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// indicator's rendering is owned by the phone's CarPlay service, and on
|
|
148
|
-
// some iOS/CarPlay-Simulator combinations it isn't drawn at all — for
|
|
149
|
-
// ANY third-party app. Verify on a real head unit before assuming a
|
|
150
|
-
// logic bug here.
|
|
163
|
+
// The phone's CarPlay service draws the indicator, and some iOS and CarPlay Simulator
|
|
164
|
+
// combinations draw none for any app: check a real head unit before suspecting this code.
|
|
151
165
|
item.playingIndicatorLocation = playingIndicatorLocation
|
|
152
166
|
item.isPlaying = isActiveTrack(track.identity)
|
|
153
167
|
if item.isPlaying {
|
|
154
168
|
logger.debug("Setting isPlaying=true for: \(track.title) (src: \(src))")
|
|
155
169
|
}
|
|
156
170
|
} else if track.path != nil {
|
|
157
|
-
// Browsable only - show disclosure indicator
|
|
158
171
|
item.accessoryType = .disclosureIndicator
|
|
159
172
|
}
|
|
160
173
|
|
|
@@ -176,21 +189,15 @@ final class CarPlayListItemFactory {
|
|
|
176
189
|
item.isEnabled = false
|
|
177
190
|
}
|
|
178
191
|
|
|
179
|
-
// Load artwork with size context for proper CDN optimization
|
|
180
192
|
if track.artwork != nil || track.artworkSource != nil {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
Task { @MainActor in
|
|
185
|
-
item?.setImage(image)
|
|
186
|
-
}
|
|
193
|
+
item.setImage(imageLoader.placeholderImage(size: CPListItem.maximumImageSize))
|
|
194
|
+
loadArtwork(for: track, style: style, size: CPListItem.maximumImageSize) { [weak item] image in
|
|
195
|
+
item?.setImage(image)
|
|
187
196
|
}
|
|
188
197
|
}
|
|
189
198
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// over the isEnabled braces, so an unavailable track stays inert even if
|
|
193
|
-
// a surface delivers the tap.
|
|
199
|
+
// The disabled guard wraps custom handlers too (Up Next rows), so an unavailable track
|
|
200
|
+
// stays inert even if a surface delivers the tap.
|
|
194
201
|
let selection = handler ?? { [onItemSelected] _, completion in
|
|
195
202
|
onItemSelected(track, completion)
|
|
196
203
|
}
|
|
@@ -268,50 +275,40 @@ final class CarPlayListItemFactory {
|
|
|
268
275
|
? Array(visibleChildren.prefix(CPMaximumNumberOfGridImages))
|
|
269
276
|
: visibleChildren
|
|
270
277
|
let placeholder = { [imageLoader] in
|
|
271
|
-
imageLoader
|
|
278
|
+
imageLoader.placeholderImage(size: Self.rowImageSize) ?? UIImage()
|
|
272
279
|
}
|
|
273
280
|
|
|
274
281
|
let item: CPListImageRowItem
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
// @MainActor makes the closure Sendable for the artwork completion hop.
|
|
279
|
-
var applyImage: (@MainActor @Sendable (Int, UIImage) -> Void)?
|
|
280
|
-
// Resolved once per track, shared by element construction and the
|
|
281
|
-
// artwork loads below.
|
|
282
|
+
// Sets a loaded image on its tile's element; nil on the legacy gridImages path, which has
|
|
283
|
+
// no elements (their types do not exist before iOS 26).
|
|
284
|
+
var applyImage: (@MainActor (Int, UIImage) -> Void)?
|
|
282
285
|
let resolvedStyles = tracks.map { StyleResolver.trackStyle(track: $0.style, section: style) }
|
|
283
286
|
if #available(iOS 26.0, *) {
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
) ->
|
|
290
|
-
zip(tracks, resolvedStyles).map { track, resolved in
|
|
287
|
+
// One element per track, grayed when disabled, built into the row by the family's
|
|
288
|
+
// initializer; each tile's image lands on its element.
|
|
289
|
+
func row<Element: CPListImageRowItemElement>(
|
|
290
|
+
_ build: (String?, [Element], Bool) -> CPListImageRowItem,
|
|
291
|
+
element make: (Track, TrackStyle) -> Element,
|
|
292
|
+
) -> CPListImageRowItem {
|
|
293
|
+
let elements = zip(tracks, resolvedStyles).map { track, resolved in
|
|
291
294
|
let element = make(track, resolved)
|
|
292
295
|
if track.disabled == true {
|
|
293
296
|
element.isEnabled = false
|
|
294
297
|
}
|
|
295
298
|
return element
|
|
296
299
|
}
|
|
297
|
-
|
|
298
|
-
// The image setter every element family shares (`image` is on the base
|
|
299
|
-
// class); bounds-checked against the elements the row was built with.
|
|
300
|
-
func imageApplier(_ elements: [some CPListImageRowItemElement]) -> @MainActor @Sendable (Int, UIImage) -> Void {
|
|
301
|
-
{ index, image in
|
|
300
|
+
applyImage = { index, image in
|
|
302
301
|
guard index < elements.count else { return }
|
|
303
302
|
elements[index].image = image
|
|
304
303
|
}
|
|
304
|
+
return build(rowText, elements, !singleLine)
|
|
305
305
|
}
|
|
306
|
-
//
|
|
307
|
-
// family takes either wrap mode. Pre-26 the treatment drops and the
|
|
308
|
-
// layout survives (the legacy branch below).
|
|
306
|
+
// Pre-26 the family's treatment drops and the layout survives (the legacy branch below).
|
|
309
307
|
switch family {
|
|
310
308
|
case .cardElements:
|
|
311
|
-
// Cards have no shape and no accessory slot;
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
let elements = makeElements { track, resolved in
|
|
309
|
+
// Cards have no shape and no accessory slot; 'background' fills the card full-height
|
|
310
|
+
// and fades the tint in behind the labels.
|
|
311
|
+
item = row(CPListImageRowItem.init(text:cardElements:allowsMultipleLines:)) { track, resolved in
|
|
315
312
|
CPListImageRowItemCardElement(
|
|
316
313
|
image: placeholder(),
|
|
317
314
|
showsImageFullHeight: resolved.cardImage == .background,
|
|
@@ -320,12 +317,8 @@ final class CarPlayListItemFactory {
|
|
|
320
317
|
tintColor: resolved.cardTint.flatMap { UIColor(declaredHex: $0) },
|
|
321
318
|
)
|
|
322
319
|
}
|
|
323
|
-
item = CPListImageRowItem(
|
|
324
|
-
text: rowText, cardElements: elements, allowsMultipleLines: !singleLine,
|
|
325
|
-
)
|
|
326
|
-
applyImage = imageApplier(elements)
|
|
327
320
|
case .condensedElements:
|
|
328
|
-
|
|
321
|
+
item = row(CPListImageRowItem.init(text:condensedElements:allowsMultipleLines:)) { track, resolved in
|
|
329
322
|
CPListImageRowItemCondensedElement(
|
|
330
323
|
image: placeholder(),
|
|
331
324
|
imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
|
|
@@ -334,14 +327,10 @@ final class CarPlayListItemFactory {
|
|
|
334
327
|
accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
|
|
335
328
|
)
|
|
336
329
|
}
|
|
337
|
-
item = CPListImageRowItem(
|
|
338
|
-
text: rowText, condensedElements: elements, allowsMultipleLines: !singleLine,
|
|
339
|
-
)
|
|
340
|
-
applyImage = imageApplier(elements)
|
|
341
330
|
case .imageGridElements:
|
|
342
331
|
// imageGridElements, never the title-less gridElements: an
|
|
343
332
|
// artwork-less track must keep its name on screen (ADR 0010).
|
|
344
|
-
|
|
333
|
+
item = row(CPListImageRowItem.init(text:imageGridElements:allowsMultipleLines:)) { track, resolved in
|
|
345
334
|
CPListImageRowItemImageGridElement(
|
|
346
335
|
image: placeholder(),
|
|
347
336
|
imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
|
|
@@ -349,22 +338,14 @@ final class CarPlayListItemFactory {
|
|
|
349
338
|
accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
|
|
350
339
|
)
|
|
351
340
|
}
|
|
352
|
-
item = CPListImageRowItem(
|
|
353
|
-
text: rowText, imageGridElements: elements, allowsMultipleLines: !singleLine,
|
|
354
|
-
)
|
|
355
|
-
applyImage = imageApplier(elements)
|
|
356
341
|
case .rowElements:
|
|
357
342
|
// Titled, so an artwork-less track keeps its name on screen
|
|
358
343
|
// (ADR 0010); no shape and no accessory slot.
|
|
359
|
-
|
|
344
|
+
item = row(CPListImageRowItem.init(text:elements:allowsMultipleLines:)) { track, _ in
|
|
360
345
|
CPListImageRowItemRowElement(
|
|
361
346
|
image: placeholder(), title: track.title, subtitle: track.subtitle,
|
|
362
347
|
)
|
|
363
348
|
}
|
|
364
|
-
item = CPListImageRowItem(
|
|
365
|
-
text: rowText, elements: elements, allowsMultipleLines: !singleLine,
|
|
366
|
-
)
|
|
367
|
-
applyImage = imageApplier(elements)
|
|
368
349
|
}
|
|
369
350
|
} else {
|
|
370
351
|
let placeholders = tracks.map { _ in placeholder() }
|
|
@@ -386,9 +367,8 @@ final class CarPlayListItemFactory {
|
|
|
386
367
|
}
|
|
387
368
|
}
|
|
388
369
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// the surface delivers the tap anyway.
|
|
370
|
+
// A disabled tile is grayed and inert (iOS 26 elements); the guard keeps it inert even if
|
|
371
|
+
// the surface delivers the tap.
|
|
392
372
|
item.listImageRowHandler = { [onItemSelected] _, index, completion in
|
|
393
373
|
guard index < tracks.count, tracks[index].disabled != true else {
|
|
394
374
|
completion()
|
|
@@ -397,28 +377,23 @@ final class CarPlayListItemFactory {
|
|
|
397
377
|
onItemSelected(tracks[index], completion)
|
|
398
378
|
}
|
|
399
379
|
|
|
400
|
-
// Load artwork for each rendered tile asynchronously
|
|
401
380
|
for (index, track) in tracks.enumerated() {
|
|
402
381
|
guard track.artwork != nil || track.artworkSource != nil else { continue }
|
|
403
382
|
|
|
404
383
|
let resolved = resolvedStyles[index]
|
|
405
|
-
|
|
384
|
+
loadArtwork(
|
|
406
385
|
for: track,
|
|
407
386
|
style: resolved,
|
|
408
387
|
size: Self.tileImageSize(family: family, cardImage: resolved.cardImage),
|
|
409
388
|
) { [weak item, applyImage] image in
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if index < images.count {
|
|
419
|
-
images[index] = image
|
|
420
|
-
item.update(images)
|
|
421
|
-
}
|
|
389
|
+
guard let item, let image else { return }
|
|
390
|
+
if let applyImage {
|
|
391
|
+
applyImage(index, image)
|
|
392
|
+
} else {
|
|
393
|
+
var images = item.gridImages
|
|
394
|
+
if index < images.count {
|
|
395
|
+
images[index] = image
|
|
396
|
+
item.update(images)
|
|
422
397
|
}
|
|
423
398
|
}
|
|
424
399
|
}
|
|
@@ -19,20 +19,12 @@ final class CarPlayNowPlayingManager {
|
|
|
19
19
|
private var subscriptions: [Subscription] = []
|
|
20
20
|
private var nowPlayingObserver: NowPlayingObserver?
|
|
21
21
|
private weak var upNextTemplate: CPListTemplate?
|
|
22
|
-
///
|
|
23
|
-
///
|
|
24
|
-
/// skipped while this is unchanged (`configChanged` fires on unrelated
|
|
25
|
-
/// churn, and every `updateNowPlayingButtons` call re-renders ALL buttons).
|
|
22
|
+
/// What the built button row renders. A rebuild is skipped while it is unchanged: every
|
|
23
|
+
/// `updateNowPlayingButtons` re-renders all the buttons.
|
|
26
24
|
///
|
|
27
|
-
///
|
|
28
|
-
///
|
|
29
|
-
///
|
|
30
|
-
/// stateful buttons first draw from their serialized `isSelected`, and only
|
|
31
|
-
/// then overlay the live state the app reports via the command center. A
|
|
32
|
-
/// button built with the default `isSelected = false` while repeat/shuffle
|
|
33
|
-
/// is on therefore flashes background-off → background-on at every
|
|
34
|
-
/// re-render. Stamping `isSelected` at build time (and rebuilding when the
|
|
35
|
-
/// mode changes) makes the first-frame render match the live state.
|
|
25
|
+
/// Shuffle and repeat carry their `isSelected` because CarPlay serializes the buttons to its
|
|
26
|
+
/// UI process and draws each re-render from the serialized state before the live one: a
|
|
27
|
+
/// button built unselected while its mode is on flashes off, then on.
|
|
36
28
|
private struct BuiltButtonRow: Equatable {
|
|
37
29
|
let types: [CarPlayNowPlayingButton]
|
|
38
30
|
let favorited: Bool?
|
|
@@ -131,16 +123,9 @@ final class CarPlayNowPlayingManager {
|
|
|
131
123
|
private func refreshButtons() {
|
|
132
124
|
// A gate hides the custom buttons; the transport controls stay, as a gate never blocks
|
|
133
125
|
// playback.
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// config comes straight from JS, where duplicates and excess are
|
|
138
|
-
// representable, so dedupe (order preserved) and clamp here.
|
|
139
|
-
var deduped: [CarPlayNowPlayingButton] = []
|
|
140
|
-
for button in configured where !deduped.contains(button) {
|
|
141
|
-
deduped.append(button)
|
|
142
|
-
}
|
|
143
|
-
let buttons = Array(deduped.prefix(5))
|
|
126
|
+
let buttons = NowPlayingButtonRules.buttons(
|
|
127
|
+
audioBrowser.browseGate.isActive ? [] : audioBrowser.playerOptions.carPlayNowPlayingButtons,
|
|
128
|
+
)
|
|
144
129
|
let row = BuiltButtonRow(
|
|
145
130
|
types: buttons,
|
|
146
131
|
favorited: buttons.contains(.favorite) ? audioBrowser.isActiveTrackFavorited() : nil,
|
|
@@ -296,15 +281,7 @@ final class CarPlayNowPlayingManager {
|
|
|
296
281
|
private func handleRepeatButtonTapped() {
|
|
297
282
|
guard let player else { return }
|
|
298
283
|
let currentMode = player.repeatMode
|
|
299
|
-
let newMode
|
|
300
|
-
case .off:
|
|
301
|
-
.track
|
|
302
|
-
case .track:
|
|
303
|
-
.queue
|
|
304
|
-
case .queue:
|
|
305
|
-
.off
|
|
306
|
-
}
|
|
307
|
-
|
|
284
|
+
let newMode = NowPlayingButtonRules.repeatMode(after: currentMode)
|
|
308
285
|
player.repeatMode = newMode
|
|
309
286
|
logger.info("CarPlay repeat mode changed: \(currentMode.stringValue) → \(newMode.stringValue)")
|
|
310
287
|
}
|
|
@@ -319,19 +296,9 @@ final class CarPlayNowPlayingManager {
|
|
|
319
296
|
}
|
|
320
297
|
|
|
321
298
|
private func handlePlaybackRateButtonTapped() {
|
|
322
|
-
let
|
|
323
|
-
guard !rates.isEmpty, let player else { return }
|
|
324
|
-
|
|
299
|
+
guard let player else { return }
|
|
325
300
|
let currentRate = Double(player.rate)
|
|
326
|
-
let nextRate:
|
|
327
|
-
|
|
328
|
-
if let currentIndex = rates.firstIndex(where: { (currentRate - $0).magnitude < 0.01 }) {
|
|
329
|
-
let nextIndex = (currentIndex + 1) % rates.count
|
|
330
|
-
nextRate = rates[nextIndex]
|
|
331
|
-
} else {
|
|
332
|
-
nextRate = rates.first { $0 > currentRate } ?? rates[0]
|
|
333
|
-
}
|
|
334
|
-
|
|
301
|
+
guard let nextRate = NowPlayingButtonRules.rate(after: currentRate, in: audioBrowser.playerOptions.playbackRates) else { return }
|
|
335
302
|
player.rate = Float(nextRate)
|
|
336
303
|
logger.info("CarPlay playback rate changed: \(currentRate) → \(nextRate)")
|
|
337
304
|
}
|
|
@@ -22,7 +22,7 @@ extension CarPlaySession {
|
|
|
22
22
|
// A playable track always lands on Now Playing, so it is pushed now with the tapped
|
|
23
23
|
// track's fields: the queue expansion and media resolve may both hit the network.
|
|
24
24
|
if track.src != nil {
|
|
25
|
-
|
|
25
|
+
player?.previewNowPlaying(track)
|
|
26
26
|
nowPlaying.prepare(for: track)
|
|
27
27
|
nowPlaying.showNowPlaying()
|
|
28
28
|
}
|
|
@@ -32,7 +32,7 @@ extension CarPlaySession {
|
|
|
32
32
|
guard let player = session.player else { return }
|
|
33
33
|
let result = await session.trackSelector.select(track: track, player: player)
|
|
34
34
|
guard !Task.isCancelled else {
|
|
35
|
-
if track.src != nil { session.
|
|
35
|
+
if track.src != nil { session.player?.endNowPlayingPreview() }
|
|
36
36
|
return
|
|
37
37
|
}
|
|
38
38
|
switch result {
|
|
@@ -49,17 +49,6 @@ extension CarPlaySession {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/// Puts the current track back on the now-playing surface, over a tapped one that won't play.
|
|
53
|
-
private func restoreNowPlaying() {
|
|
54
|
-
guard let engine = audioBrowser.engine else { return }
|
|
55
|
-
if let current = player?.currentTrack {
|
|
56
|
-
engine.loadNowPlayingMetadata(for: current)
|
|
57
|
-
engine.nowPlayingUpdater.render()
|
|
58
|
-
} else {
|
|
59
|
-
engine.clearNowPlaying()
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
52
|
/// Pushes `path`'s page as an empty, spinning template that fills in once it appears; or the
|
|
64
53
|
/// Browse Gate's page when the gate gates it; or selects the tab that is that path.
|
|
65
54
|
func navigate(to path: String, title: String) async {
|