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
|
@@ -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
|
}
|
|
@@ -11,7 +11,7 @@ final class CarPlayNowPlayingManager {
|
|
|
11
11
|
private let interfaceController: CPInterfaceController
|
|
12
12
|
private let audioBrowser: HybridAudioBrowser
|
|
13
13
|
/// Read on each use: a re-setup replaces the bridge's player.
|
|
14
|
-
private var player: Player? { audioBrowser.
|
|
14
|
+
private var player: Player? { audioBrowser.player }
|
|
15
15
|
private let listItem: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
|
|
16
16
|
/// Pushes a browse destination: the album line's.
|
|
17
17
|
private let navigate: (_ path: String, _ title: String) -> Void
|
|
@@ -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
|
}
|
|
@@ -349,7 +316,7 @@ final class CarPlayNowPlayingManager {
|
|
|
349
316
|
}
|
|
350
317
|
|
|
351
318
|
private func updateUpNextButton() {
|
|
352
|
-
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
|
|
319
|
+
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.playerOptions.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
|
|
353
320
|
}
|
|
354
321
|
|
|
355
322
|
fileprivate func handleUpNextButtonTapped() {
|
|
@@ -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 {
|
|
@@ -26,19 +26,18 @@ extension CarPlaySession {
|
|
|
26
26
|
NavigationError(code: .timeout, message: "", statusCode: nil, statusCodeSuccess: nil), path: path, on: template,
|
|
27
27
|
)
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
// timeout must not overwrite.
|
|
31
|
-
defer { watchdog.cancel() }
|
|
32
|
-
|
|
29
|
+
let outcome: Result<ResolvedTrack, Error>
|
|
33
30
|
do {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
outcome = try await .success(audioBrowser.browserManager.resolve(path, useCache: true))
|
|
32
|
+
} catch {
|
|
33
|
+
outcome = .failure(error)
|
|
34
|
+
}
|
|
35
|
+
// As soon as the resolve settles: formatting its error is a JS round trip the timeout must
|
|
36
|
+
// not overwrite.
|
|
37
|
+
watchdog.cancel()
|
|
38
|
+
|
|
39
|
+
switch outcome {
|
|
40
|
+
case let .success(resolved):
|
|
42
41
|
guard !Task.isCancelled else { return }
|
|
43
42
|
// An assistant cell is content in itself, even with no rows; a page of empty sections is
|
|
44
43
|
// empty (ADR 0001).
|
|
@@ -48,7 +47,7 @@ extension CarPlaySession {
|
|
|
48
47
|
} else {
|
|
49
48
|
await showNavigationError(.code(.emptyContent), path: path, on: template)
|
|
50
49
|
}
|
|
51
|
-
|
|
50
|
+
case let .failure(error):
|
|
52
51
|
logger.error("Failed to load content for \(path): \(error.localizedDescription)")
|
|
53
52
|
await showNavigationError(NavigationError.from(error), path: path, on: template)
|
|
54
53
|
}
|
|
@@ -23,21 +23,21 @@ extension CarPlaySession {
|
|
|
23
23
|
/// invalidated (e.g. a locale change).
|
|
24
24
|
func refreshAllDisplayedTemplates() async {
|
|
25
25
|
let browserManager = audioBrowser.browserManager
|
|
26
|
-
let
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
})
|
|
26
|
+
let targets = displayedListTemplates().compactMap { template in template.contentPath.map { (template, $0) } }
|
|
27
|
+
let pages = await targets.map(\.1).concurrentMap { [logger] path -> ResolvedTrack? in
|
|
28
|
+
do {
|
|
29
|
+
return try await browserManager.resolve(path, useCache: false)
|
|
30
|
+
} catch {
|
|
31
|
+
if !(error is CancellationError || Task.isCancelled) {
|
|
32
|
+
logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
|
|
33
|
+
}
|
|
34
|
+
return nil
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
guard !Task.isCancelled else { return }
|
|
38
|
+
for ((template, _), page) in zip(targets, pages) {
|
|
39
|
+
guard let page else { continue }
|
|
40
|
+
updateTemplate(template, with: page)
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -39,9 +39,9 @@ extension CarPlaySession {
|
|
|
39
39
|
// A tab bar with no tabs renders blank. A gate shows one full-screen page instead;
|
|
40
40
|
// otherwise it is the empty-content error, like any other failure (ADR 0001).
|
|
41
41
|
guard !tabs.isEmpty else {
|
|
42
|
-
if gated, let chrome = await
|
|
42
|
+
if gated, let chrome = await browseGates(for: [nil])[0] {
|
|
43
43
|
guard isCurrent(build) else { return }
|
|
44
|
-
|
|
44
|
+
reportGates([chrome], reason: .browse)
|
|
45
45
|
interfaceController.safeSetRoot(makeGatePage(chrome, path: nil, tab: nil), animated: true)
|
|
46
46
|
} else {
|
|
47
47
|
await showRootError(.code(.emptyContent), build: build)
|
|
@@ -49,11 +49,9 @@ extension CarPlaySession {
|
|
|
49
49
|
return
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
let chromes = gated ? await
|
|
52
|
+
let chromes = gated ? await browseGates(for: tabs.map(\.path)) : tabs.map { _ in nil }
|
|
53
53
|
guard isCurrent(build) else { return }
|
|
54
|
-
|
|
55
|
-
audioBrowser.onGate(GateEvent(reason: .browse))
|
|
56
|
-
}
|
|
54
|
+
reportGates(chromes, reason: .browse)
|
|
57
55
|
let templates = zip(tabs, chromes).map { tab, chrome in
|
|
58
56
|
chrome.map { makeGatePage($0, path: tab.path, tab: tab) } ?? makeTabTemplate(for: tab)
|
|
59
57
|
}
|
|
@@ -123,24 +121,24 @@ extension CarPlaySession {
|
|
|
123
121
|
/// request is allowed.
|
|
124
122
|
func gate(_ reason: GateReason, path: String?, search: SearchParams? = nil) async -> Gate? {
|
|
125
123
|
let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: reason, path: path, search: search))
|
|
126
|
-
|
|
124
|
+
reportGates([chrome], reason: reason)
|
|
127
125
|
return chrome
|
|
128
126
|
}
|
|
129
127
|
|
|
130
|
-
/// One decision per
|
|
131
|
-
/// caller
|
|
132
|
-
private func
|
|
128
|
+
/// One browse decision per path, made concurrently and returned in path order. Reports
|
|
129
|
+
/// nothing: the caller reports once it knows its build is still current.
|
|
130
|
+
private func browseGates(for paths: [String?]) async -> [Gate?] {
|
|
133
131
|
let browseGate = audioBrowser.browseGate
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await browseGate.decide(NativeGateRequest(reason: .browse, path: tab.path, search: nil))
|
|
137
|
-
}
|
|
132
|
+
return await paths.concurrentMap { path in
|
|
133
|
+
await browseGate.decide(NativeGateRequest(reason: .browse, path: path, search: nil))
|
|
138
134
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Fires `onGate` once for each request the gate gated.
|
|
138
|
+
private func reportGates(_ chromes: [Gate?], reason: GateReason) {
|
|
139
|
+
for _ in chromes.compactMap(\.self) {
|
|
140
|
+
audioBrowser.onGate(GateEvent(reason: reason))
|
|
142
141
|
}
|
|
143
|
-
return chromes
|
|
144
142
|
}
|
|
145
143
|
|
|
146
144
|
/// A tab's content shell, which loads when it first appears.
|
|
@@ -168,15 +166,16 @@ extension CarPlaySession {
|
|
|
168
166
|
func applyTabBarEntry(to template: CPTemplate, for track: Track) {
|
|
169
167
|
template.tabTitle = track.title
|
|
170
168
|
if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
|
|
169
|
+
// A plain symbol: CarPlay tints the tab image itself.
|
|
171
170
|
let (symbolName, _, _) = SFSymbolRenderer.parseArtwork(artwork)
|
|
172
|
-
if let image =
|
|
171
|
+
if let image = UIImage(systemName: symbolName) {
|
|
173
172
|
template.tabImage = image
|
|
174
173
|
}
|
|
175
174
|
} else if track.artwork != nil || track.artworkSource != nil {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
175
|
+
spawn { [weak template] session in
|
|
176
|
+
let image = await session.imageLoader.artwork(for: track, size: CGSize(width: 24, height: 24))
|
|
177
|
+
guard !Task.isCancelled, let image else { return }
|
|
178
|
+
template?.tabImage = image
|
|
180
179
|
}
|
|
181
180
|
} else {
|
|
182
181
|
// A restamp must not leave an old icon on a now title-only tab.
|
|
@@ -12,10 +12,17 @@ final class CarPlaySession {
|
|
|
12
12
|
let interfaceController: CPInterfaceController
|
|
13
13
|
let audioBrowser: HybridAudioBrowser
|
|
14
14
|
/// Read on each use: a re-setup replaces the bridge's player.
|
|
15
|
-
var player: Player? { audioBrowser.
|
|
15
|
+
var player: Player? { audioBrowser.player }
|
|
16
16
|
let trackSelector: TrackSelector
|
|
17
17
|
let imageLoader: CarPlayImageLoader
|
|
18
|
-
|
|
18
|
+
private(set) lazy var listItemFactory = CarPlayListItemFactory(
|
|
19
|
+
imageLoader: imageLoader,
|
|
20
|
+
playingIndicatorLocation: playingIndicatorLocation,
|
|
21
|
+
isActiveTrack: { [weak self] identity in identity != nil && self?.player?.currentTrack?.identity == identity },
|
|
22
|
+
spawn: { [weak self] work in self?.spawn { _ in await work() } },
|
|
23
|
+
onItemSelected: { [weak self] track, completion in self?.select(track, completion: completion) },
|
|
24
|
+
onNavigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
|
|
25
|
+
)
|
|
19
26
|
private(set) var nowPlaying: CarPlayNowPlayingManager!
|
|
20
27
|
|
|
21
28
|
private var subscriptions: [Subscription] = []
|
|
@@ -56,12 +63,6 @@ final class CarPlaySession {
|
|
|
56
63
|
carTraitCollection: interfaceController.carTraitCollection,
|
|
57
64
|
browserManager: audioBrowser.browserManager,
|
|
58
65
|
)
|
|
59
|
-
listItemFactory = CarPlayListItemFactory(
|
|
60
|
-
isActiveTrack: { [weak audioBrowser] identity in
|
|
61
|
-
identity != nil && audioBrowser?.getPlayer()?.currentTrack?.identity == identity
|
|
62
|
-
},
|
|
63
|
-
)
|
|
64
|
-
listItemFactory.imageLoader = imageLoader
|
|
65
66
|
nowPlaying = CarPlayNowPlayingManager(
|
|
66
67
|
interfaceController: interfaceController,
|
|
67
68
|
audioBrowser: audioBrowser,
|
|
@@ -70,9 +71,6 @@ final class CarPlaySession {
|
|
|
70
71
|
},
|
|
71
72
|
navigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
|
|
72
73
|
)
|
|
73
|
-
listItemFactory.onItemSelected = { [weak self] track, completion in self?.select(track, completion: completion) }
|
|
74
|
-
listItemFactory.onNavigate = { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } }
|
|
75
|
-
listItemFactory.playingIndicatorLocation = playingIndicatorLocation
|
|
76
74
|
}
|
|
77
75
|
|
|
78
76
|
func start() {
|
|
@@ -156,7 +154,7 @@ final class CarPlaySession {
|
|
|
156
154
|
// MARK: - Playing Indicators
|
|
157
155
|
|
|
158
156
|
var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
|
|
159
|
-
switch audioBrowser.carPlayPlayingIndicatorLocation {
|
|
157
|
+
switch audioBrowser.playerOptions.carPlayPlayingIndicatorLocation {
|
|
160
158
|
case .trailing: .trailing
|
|
161
159
|
case .leading: .leading
|
|
162
160
|
}
|