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
|
@@ -30,10 +30,29 @@ final class CarPlayImageLoader {
|
|
|
30
30
|
UIImage(systemName: symbolName)
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/// Renders an SF Symbol from an artwork string (e.g. "sf:heart.fill?bg=#000&fg=#fff").
|
|
33
|
+
/// Renders an SF Symbol from an artwork string (e.g. "sf:heart.fill?bg=#000&fg=#fff"). With an
|
|
34
|
+
/// `fg` it is drawn in that color; without one it is black or white by appearance, over a grey
|
|
35
|
+
/// tile.
|
|
34
36
|
func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
|
|
35
|
-
let (
|
|
36
|
-
|
|
37
|
+
let (name, background, tint) = SFSymbolRenderer.parseArtwork(artwork)
|
|
38
|
+
let scale = carTraitCollection.displayScale
|
|
39
|
+
let lightTile = UIColor(white: 0.92, alpha: 1)
|
|
40
|
+
let darkTile = UIColor(white: 0.15, alpha: 1)
|
|
41
|
+
if let tint {
|
|
42
|
+
return SFSymbolRenderer.image(
|
|
43
|
+
name: name, canvasSize: canvasSize, scale: scale, tint: tint,
|
|
44
|
+
background: background ?? (tint == .white ? darkTile : lightTile),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
guard
|
|
48
|
+
let light = SFSymbolRenderer.image(
|
|
49
|
+
name: name, canvasSize: canvasSize, scale: scale, tint: .black, background: lightTile,
|
|
50
|
+
),
|
|
51
|
+
let dark = SFSymbolRenderer.image(
|
|
52
|
+
name: name, canvasSize: canvasSize, scale: scale, tint: .white, background: darkTile,
|
|
53
|
+
)
|
|
54
|
+
else { return nil }
|
|
55
|
+
return .adaptive(light: light, dark: dark, for: carTraitCollection)
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
/// Cached empty placeholders, keyed by point size — list items and image-row
|
|
@@ -72,27 +91,22 @@ final class CarPlayImageLoader {
|
|
|
72
91
|
return
|
|
73
92
|
}
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
// @Sendable isn't inferred through `map`, and omitting it fails with
|
|
77
|
-
// "type of expression is ambiguous" — which doesn't mention @Sendable.
|
|
78
|
-
let urlResolver = browserManager.map { bm -> @Sendable (Double, Double) async -> ArtworkResolvedImage? in
|
|
79
|
-
{ [weak bm] pixelWidth, pixelHeight in
|
|
80
|
-
guard let bm else { return nil }
|
|
81
|
-
let imageContext = ImageContext(width: pixelWidth, height: pixelHeight)
|
|
82
|
-
guard let source = await bm.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: imageContext) else { return nil }
|
|
83
|
-
return ArtworkResolvedImage(uri: source.uri, headers: source.headers)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
94
|
+
let artwork = track.artwork?.url
|
|
87
95
|
Task {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
var resolved: ArtworkResolvedImage?
|
|
97
|
+
if CarPlayArtworkResolver.needsResolve(artwork: artwork), let browserManager = self.browserManager {
|
|
98
|
+
let scale = Double(self.carTraitCollection.displayScale)
|
|
99
|
+
let context = ImageContext(width: Double(size.width) * scale, height: Double(size.height) * scale)
|
|
100
|
+
resolved = await browserManager.requests.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: context)
|
|
101
|
+
.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) }
|
|
102
|
+
}
|
|
103
|
+
let action = CarPlayArtworkResolver.action(
|
|
104
|
+
artwork: artwork,
|
|
105
|
+
artworkSource: track.artworkSource.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) },
|
|
106
|
+
resolved: resolved,
|
|
91
107
|
stencil: (style ?? track.style)?.artworkRendering == .stencil,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
displayScale: Double(self.carTraitCollection.displayScale),
|
|
95
|
-
urlResolver: urlResolver,
|
|
108
|
+
width: Double(size.width),
|
|
109
|
+
height: Double(size.height),
|
|
96
110
|
)
|
|
97
111
|
|
|
98
112
|
switch action {
|
|
@@ -145,10 +159,7 @@ final class CarPlayImageLoader {
|
|
|
145
159
|
return
|
|
146
160
|
}
|
|
147
161
|
|
|
148
|
-
|
|
149
|
-
asset.register(lightImage, with: UITraitCollection(userInterfaceStyle: .light))
|
|
150
|
-
asset.register(darkImage, with: UITraitCollection(userInterfaceStyle: .dark))
|
|
151
|
-
completion(asset.image(with: self.carTraitCollection))
|
|
162
|
+
completion(.adaptive(light: lightImage, dark: darkImage, for: self.carTraitCollection))
|
|
152
163
|
}
|
|
153
164
|
}
|
|
154
165
|
|
|
@@ -174,55 +185,6 @@ final class CarPlayImageLoader {
|
|
|
174
185
|
return await ArtworkImageFetcher.fetchImage(from: source, downsampleTo: (size, scale))
|
|
175
186
|
}
|
|
176
187
|
|
|
177
|
-
/// Creates an SF Symbol image rendered at the given canvas size.
|
|
178
|
-
/// When explicit colors are provided, uses them directly.
|
|
179
|
-
/// Otherwise falls back to automatic light/dark variants.
|
|
180
|
-
private func sfSymbolImageForSize(_ symbolName: String, canvasSize: CGSize, backgroundColor: UIColor? = nil, symbolColor: UIColor? = nil) -> UIImage? {
|
|
181
|
-
let scale = carTraitCollection.displayScale
|
|
182
|
-
let symbolPointSize = min(canvasSize.width, canvasSize.height) * 0.45
|
|
183
|
-
|
|
184
|
-
let config = UIImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .medium)
|
|
185
|
-
guard let symbol = UIImage(systemName: symbolName, withConfiguration: config) else { return nil }
|
|
186
|
-
|
|
187
|
-
// Explicit colors: single image, no light/dark variants needed
|
|
188
|
-
if let symbolColor {
|
|
189
|
-
return renderSymbolInCanvas(symbol, tintColor: symbolColor, backgroundColor: backgroundColor, canvasSize: canvasSize, scale: scale)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Auto light/dark variants
|
|
193
|
-
let lightImage = renderSymbolInCanvas(symbol, tintColor: .black, backgroundColor: nil, canvasSize: canvasSize, scale: scale)
|
|
194
|
-
let darkImage = renderSymbolInCanvas(symbol, tintColor: .white, backgroundColor: nil, canvasSize: canvasSize, scale: scale)
|
|
195
|
-
|
|
196
|
-
let asset = UIImageAsset()
|
|
197
|
-
asset.register(lightImage, with: UITraitCollection(userInterfaceStyle: .light))
|
|
198
|
-
asset.register(darkImage, with: UITraitCollection(userInterfaceStyle: .dark))
|
|
199
|
-
|
|
200
|
-
return asset.image(with: carTraitCollection)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/// Renders an SF Symbol centered in a canvas with a background fill.
|
|
204
|
-
/// When `backgroundColor` is nil, derives a default from the tint color (light/dark).
|
|
205
|
-
private nonisolated func renderSymbolInCanvas(_ symbol: UIImage, tintColor: UIColor, backgroundColor: UIColor?, canvasSize: CGSize, scale: CGFloat) -> UIImage {
|
|
206
|
-
let renderer = UIGraphicsImageRenderer(size: canvasSize, format: {
|
|
207
|
-
let fmt = UIGraphicsImageRendererFormat()
|
|
208
|
-
fmt.scale = scale
|
|
209
|
-
return fmt
|
|
210
|
-
}())
|
|
211
|
-
|
|
212
|
-
return renderer.image { _ in
|
|
213
|
-
let bgColor = backgroundColor ?? (tintColor == .white ? UIColor(white: 0.15, alpha: 1) : UIColor(white: 0.92, alpha: 1))
|
|
214
|
-
bgColor.setFill()
|
|
215
|
-
UIRectFill(CGRect(origin: .zero, size: canvasSize))
|
|
216
|
-
|
|
217
|
-
let symbolSize = symbol.size
|
|
218
|
-
let x = (canvasSize.width - symbolSize.width) / 2
|
|
219
|
-
let y = (canvasSize.height - symbolSize.height) / 2
|
|
220
|
-
|
|
221
|
-
tintColor.set()
|
|
222
|
-
symbol.withRenderingMode(.alwaysTemplate).draw(in: CGRect(x: x, y: y, width: symbolSize.width, height: symbolSize.height))
|
|
223
|
-
}.withRenderingMode(.alwaysOriginal)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
188
|
/// Renders an image to a bitmap with the specified tint color (for monochrome icons).
|
|
227
189
|
private nonisolated func renderImageToBitmap(_ image: UIImage, tintColor: UIColor) -> UIImage {
|
|
228
190
|
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
|
|
@@ -239,13 +201,10 @@ final class CarPlayImageLoader {
|
|
|
239
201
|
|
|
240
202
|
/// Creates light/dark tinted variants of an image and returns the appropriate one for current appearance
|
|
241
203
|
private nonisolated func createAdaptiveImage(_ image: UIImage, carTraitCollection: UITraitCollection) -> UIImage {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
asset.register(darkImage, with: UITraitCollection(userInterfaceStyle: .dark))
|
|
248
|
-
|
|
249
|
-
return asset.image(with: carTraitCollection)
|
|
204
|
+
.adaptive(
|
|
205
|
+
light: renderImageToBitmap(image, tintColor: .black),
|
|
206
|
+
dark: renderImageToBitmap(image, tintColor: .white),
|
|
207
|
+
for: carTraitCollection,
|
|
208
|
+
)
|
|
250
209
|
}
|
|
251
210
|
}
|
|
@@ -34,14 +34,13 @@ final class CarPlayListItemFactory {
|
|
|
34
34
|
/// `imageLoader` — the factory never reads options itself.
|
|
35
35
|
var playingIndicatorLocation: CPListItemPlayingIndicatorLocation = .leading
|
|
36
36
|
private let isActiveTrack: (_ identity: String?) -> Bool
|
|
37
|
-
|
|
37
|
+
/// A row or tile was tapped; call `completion` once CarPlay may take the next tap.
|
|
38
|
+
var onItemSelected: (Track, @escaping () -> Void) -> Void = { _, completion in completion() }
|
|
39
|
+
/// A section header's "view all" was tapped.
|
|
40
|
+
var onNavigate: (_ path: String, _ title: String) -> Void = { _, _ in }
|
|
38
41
|
|
|
39
|
-
init(
|
|
40
|
-
isActiveTrack: @escaping (_ identity: String?) -> Bool,
|
|
41
|
-
onItemSelected: @escaping (Track, @escaping () -> Void) -> Void,
|
|
42
|
-
) {
|
|
42
|
+
init(isActiveTrack: @escaping (_ identity: String?) -> Bool) {
|
|
43
43
|
self.isActiveTrack = isActiveTrack
|
|
44
|
-
self.onItemSelected = onItemSelected
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
// MARK: - Sections
|
|
@@ -380,9 +379,10 @@ final class CarPlayListItemFactory {
|
|
|
380
379
|
// Header tap → navigate to section.path ("view all"). A path-less
|
|
381
380
|
// section is a pure preview and gets no handler.
|
|
382
381
|
if let path = section.path {
|
|
383
|
-
let
|
|
384
|
-
item.handler = { [
|
|
385
|
-
|
|
382
|
+
let title = section.title ?? ""
|
|
383
|
+
item.handler = { [onNavigate] _, completion in
|
|
384
|
+
onNavigate(path, title)
|
|
385
|
+
completion()
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
388
|
|
|
@@ -426,18 +426,4 @@ final class CarPlayListItemFactory {
|
|
|
426
426
|
|
|
427
427
|
return item
|
|
428
428
|
}
|
|
429
|
-
|
|
430
|
-
/// A synthetic browsable Track for section-header navigation — the
|
|
431
|
-
/// section's "view all" surface has a path and a title, but no Track.
|
|
432
|
-
/// Style-less by design: CarPlay resolves presentation from the page it
|
|
433
|
-
/// navigates to. (Android's counterpart projects the section's block —
|
|
434
|
-
/// the promise only exists to feed Android Auto's parent-level hint.)
|
|
435
|
-
private static func navigationTrack(path: String, title: String?) -> Track {
|
|
436
|
-
Track(
|
|
437
|
-
id: nil, path: path, src: nil, artwork: nil, artworkSource: nil,
|
|
438
|
-
request: nil, title: title ?? "", subtitle: nil,
|
|
439
|
-
artist: nil, albumPath: nil, album: nil, description: nil, genre: nil,
|
|
440
|
-
duration: nil, style: nil, disabled: nil, favorited: nil, live: nil, icyCharset: nil,
|
|
441
|
-
)
|
|
442
|
-
}
|
|
443
429
|
}
|
|
@@ -2,29 +2,26 @@ import CarPlay
|
|
|
2
2
|
import Foundation
|
|
3
3
|
import os.log
|
|
4
4
|
|
|
5
|
-
///
|
|
6
|
-
///
|
|
7
|
-
/// Responsibilities:
|
|
8
|
-
/// - CPNowPlayingTemplate setup and button configuration
|
|
9
|
-
/// - Button action handlers (shuffle, repeat, favorite, playback rate)
|
|
10
|
-
/// - Button state updates (favorite appearance, Up Next enabled)
|
|
11
|
-
/// - Up Next template creation and queue change handling
|
|
12
|
-
/// - NowPlayingObserver for Up Next button taps
|
|
5
|
+
/// The CarPlay Now Playing template for one session: its custom buttons, the album line's
|
|
6
|
+
/// destination, Up Next, and the events that refresh them.
|
|
13
7
|
@MainActor
|
|
14
8
|
final class CarPlayNowPlayingManager {
|
|
15
9
|
private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayNowPlayingManager")
|
|
16
10
|
|
|
17
11
|
private let interfaceController: CPInterfaceController
|
|
18
|
-
|
|
19
|
-
///
|
|
20
|
-
var
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
private let audioBrowser: HybridAudioBrowser
|
|
13
|
+
/// Read on each use: a re-setup replaces the bridge's player.
|
|
14
|
+
private var player: Player? { audioBrowser.getPlayer() }
|
|
15
|
+
private let listItem: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
|
|
16
|
+
/// Pushes a browse destination: the album line's.
|
|
17
|
+
private let navigate: (_ path: String, _ title: String) -> Void
|
|
18
|
+
|
|
19
|
+
private var subscriptions: [Subscription] = []
|
|
23
20
|
private var nowPlayingObserver: NowPlayingObserver?
|
|
24
21
|
private weak var upNextTemplate: CPListTemplate?
|
|
25
22
|
/// Everything the built button row renders — button types, the heart's
|
|
26
23
|
/// favorited state, and the shuffle/repeat `isSelected` state. Rebuilds are
|
|
27
|
-
/// skipped while this is unchanged (`
|
|
24
|
+
/// skipped while this is unchanged (`configChanged` fires on unrelated
|
|
28
25
|
/// churn, and every `updateNowPlayingButtons` call re-renders ALL buttons).
|
|
29
26
|
///
|
|
30
27
|
/// shuffle/repeat selected state is part of the snapshot because CarPlay
|
|
@@ -60,57 +57,81 @@ final class CarPlayNowPlayingManager {
|
|
|
60
57
|
/// from the tapped track at push time. Cleared when the active track changes.
|
|
61
58
|
private var pendingAlbumArtistTrack: Track?
|
|
62
59
|
|
|
63
|
-
/// Convenience accessor for browser config
|
|
64
60
|
private var config: BrowserConfig {
|
|
65
|
-
audioBrowser
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/// The active track's favorited state, from the authoritative favorite set
|
|
69
|
-
/// (see `HybridAudioBrowser.isActiveTrackFavorited`).
|
|
70
|
-
private var isActiveTrackFavorited: Bool {
|
|
71
|
-
audioBrowser?.isActiveTrackFavorited() ?? false
|
|
61
|
+
audioBrowser.browserManager.config
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
init(
|
|
64
|
+
init(
|
|
65
|
+
interfaceController: CPInterfaceController,
|
|
66
|
+
audioBrowser: HybridAudioBrowser,
|
|
67
|
+
listItem: @escaping (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem,
|
|
68
|
+
navigate: @escaping (_ path: String, _ title: String) -> Void,
|
|
69
|
+
) {
|
|
75
70
|
self.interfaceController = interfaceController
|
|
76
|
-
self.
|
|
71
|
+
self.audioBrowser = audioBrowser
|
|
72
|
+
self.listItem = listItem
|
|
73
|
+
self.navigate = navigate
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
// MARK: - Lifecycle
|
|
80
77
|
|
|
81
|
-
func
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
func start() {
|
|
79
|
+
let observer = NowPlayingObserver(manager: self)
|
|
80
|
+
nowPlayingObserver = observer
|
|
81
|
+
CPNowPlayingTemplate.shared.add(observer)
|
|
82
|
+
|
|
83
|
+
let browserManager = audioBrowser.browserManager
|
|
84
|
+
subscriptions = [
|
|
85
|
+
browserManager.configChanged.subscribeOnMain { [weak self] _ in self?.refresh() },
|
|
86
|
+
audioBrowser.playerOptionsChangedEmitter.subscribeOnMain { [weak self] in self?.refresh() },
|
|
87
|
+
browserManager.favoritesChanged.subscribeOnMain { [weak self] in self?.refreshButtons() },
|
|
88
|
+
audioBrowser.favoriteChangedEmitter.subscribeOnMain { [weak self] _ in self?.refreshButtons() },
|
|
89
|
+
audioBrowser.repeatModeChangedEmitter.subscribeOnMain { [weak self] _ in self?.refreshButtons() },
|
|
90
|
+
audioBrowser.shuffleChangedEmitter.subscribeOnMain { [weak self] _ in self?.refreshButtons() },
|
|
91
|
+
audioBrowser.queueChangedEmitter.subscribeOnMain { [weak self] in self?.queueChanged($0) },
|
|
92
|
+
audioBrowser.showNowPlayingRequestedEmitter.subscribeOnMain { [weak self] in self?.showNowPlaying() },
|
|
93
|
+
audioBrowser.activeTrackChangedEmitter.subscribeOnMain { [weak self] _ in
|
|
94
|
+
// The player's current track is authoritative again.
|
|
95
|
+
self?.pendingAlbumArtistTrack = nil
|
|
96
|
+
self?.refresh()
|
|
97
|
+
},
|
|
98
|
+
]
|
|
99
|
+
refresh()
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
func
|
|
87
|
-
if let
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
102
|
+
func end() {
|
|
103
|
+
if let nowPlayingObserver { CPNowPlayingTemplate.shared.remove(nowPlayingObserver) }
|
|
104
|
+
nowPlayingObserver = nil
|
|
105
|
+
subscriptions = []
|
|
91
106
|
pendingAlbumArtistTrack = nil
|
|
92
|
-
|
|
107
|
+
// A resolve still in flight must not set the shared template for the next session.
|
|
108
|
+
albumArtistGeneration &+= 1
|
|
93
109
|
}
|
|
94
110
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
nowPlayingObserver = observer
|
|
102
|
-
template.add(observer)
|
|
111
|
+
/// Re-renders Up Next availability, the custom buttons and the album line's destination.
|
|
112
|
+
func refresh() {
|
|
113
|
+
updateUpNextButton()
|
|
114
|
+
refreshButtons()
|
|
115
|
+
updateAlbumArtistDestination()
|
|
116
|
+
}
|
|
103
117
|
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
/// Marks `track` as the just-tapped (still loading) track and derives the album line's
|
|
119
|
+
/// destination from it. Call before pushing Now Playing: CarPlay reads
|
|
120
|
+
/// `isAlbumArtistButtonEnabled` at display time, so enabling it after the push renders no
|
|
121
|
+
/// chevron.
|
|
122
|
+
func prepare(for track: Track) {
|
|
123
|
+
pendingAlbumArtistTrack = track
|
|
124
|
+
updateAlbumArtistDestination()
|
|
106
125
|
}
|
|
107
126
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
// MARK: - Buttons
|
|
128
|
+
|
|
129
|
+
/// Builds the custom button row from the configuration and player state, skipping the
|
|
130
|
+
/// rebuild when nothing the row renders changed.
|
|
131
|
+
private func refreshButtons() {
|
|
132
|
+
// A gate hides the custom buttons; the transport controls stay, as a gate never blocks
|
|
133
|
+
// playback.
|
|
134
|
+
let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.carPlayNowPlayingButtons
|
|
114
135
|
// The template caps custom buttons at 5 (CPNowPlayingTemplate.h) and says
|
|
115
136
|
// nothing about overflow — unlike lists (trim) or the tab bar (throw). The
|
|
116
137
|
// config comes straight from JS, where duplicates and excess are
|
|
@@ -120,12 +141,11 @@ final class CarPlayNowPlayingManager {
|
|
|
120
141
|
deduped.append(button)
|
|
121
142
|
}
|
|
122
143
|
let buttons = Array(deduped.prefix(5))
|
|
123
|
-
let player = audioBrowser?.getPlayer()
|
|
124
144
|
let row = BuiltButtonRow(
|
|
125
145
|
types: buttons,
|
|
126
|
-
favorited: buttons.contains(.favorite) ? isActiveTrackFavorited : nil,
|
|
127
|
-
shuffleSelected: buttons.contains(.shuffle) ?
|
|
128
|
-
repeatSelected: buttons.contains(.repeat) ? (player?.
|
|
146
|
+
favorited: buttons.contains(.favorite) ? audioBrowser.isActiveTrackFavorited() : nil,
|
|
147
|
+
shuffleSelected: buttons.contains(.shuffle) ? player?.shuffleEnabled ?? false : nil,
|
|
148
|
+
repeatSelected: buttons.contains(.repeat) ? (player?.repeatMode ?? .off) != .off : nil,
|
|
129
149
|
)
|
|
130
150
|
guard row != builtRow else { return }
|
|
131
151
|
builtRow = row
|
|
@@ -182,10 +202,10 @@ final class CarPlayNowPlayingManager {
|
|
|
182
202
|
/// stack but buried under templates pushed on top of it (Up Next, the album
|
|
183
203
|
/// line's browse destination): a user-initiated surface (tap, voice play)
|
|
184
204
|
/// pops back to it, while the deferred post-resolve calls in
|
|
185
|
-
/// `
|
|
205
|
+
/// `CarPlaySession.select` pass `false` — they run seconds after the tap, and
|
|
186
206
|
/// popping then would yank away a screen the user navigated to meanwhile.
|
|
187
207
|
func showNowPlaying(popToFront: Bool = true) {
|
|
188
|
-
|
|
208
|
+
refresh()
|
|
189
209
|
let nowPlayingTemplate = CPNowPlayingTemplate.shared
|
|
190
210
|
if interfaceController.templates.contains(where: { $0 === nowPlayingTemplate }) {
|
|
191
211
|
if popToFront {
|
|
@@ -196,48 +216,18 @@ final class CarPlayNowPlayingManager {
|
|
|
196
216
|
}
|
|
197
217
|
}
|
|
198
218
|
|
|
199
|
-
// MARK: -
|
|
200
|
-
|
|
201
|
-
/// Refreshes the row for a favorite/shuffle/repeat state change — the
|
|
202
|
-
/// snapshot diff in setupNowPlayingButtons makes this a no-op when nothing
|
|
203
|
-
/// the row renders actually changed.
|
|
204
|
-
func updateFavoriteButtonState() {
|
|
205
|
-
setupNowPlayingButtons()
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/// Updates Now Playing button states based on config and current queue
|
|
209
|
-
func updateNowPlayingButtonStates() {
|
|
210
|
-
updateNowPlayingUpNextButton()
|
|
211
|
-
setupNowPlayingButtons()
|
|
212
|
-
updateAlbumArtistButtonState()
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/// Re-resolves the album line's destination for the active track and
|
|
216
|
-
/// enables the (tappable) album/artist button only when one exists:
|
|
217
|
-
/// `track.albumPath`, or the app's `resolveAlbumPath` result.
|
|
218
|
-
/// Marks `track` as the just-tapped (still loading) track and derives the
|
|
219
|
-
/// album line's destination from it. Must run before the Now Playing
|
|
220
|
-
/// template is pushed — CarPlay reads `isAlbumArtistButtonEnabled` at display
|
|
221
|
-
/// time, so enabling after the push (when the load lands) renders no chevron.
|
|
222
|
-
func prepareAlbumArtistButton(for track: Track) {
|
|
223
|
-
pendingAlbumArtistTrack = track
|
|
224
|
-
updateAlbumArtistButtonState()
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/// Clears the tap-time pending track (the player's `currentTrack` is now
|
|
228
|
-
/// authoritative) and refreshes all button states.
|
|
229
|
-
func handleActiveTrackChanged() {
|
|
230
|
-
pendingAlbumArtistTrack = nil
|
|
231
|
-
updateNowPlayingButtonStates()
|
|
232
|
-
}
|
|
219
|
+
// MARK: - Album Line
|
|
233
220
|
|
|
234
|
-
|
|
221
|
+
/// Resolves the album line's destination for the active (or just-tapped) track and enables
|
|
222
|
+
/// the album/artist button only when one exists: `track.albumPath`, or the app's
|
|
223
|
+
/// `resolveAlbumPath` result.
|
|
224
|
+
private func updateAlbumArtistDestination() {
|
|
235
225
|
albumArtistGeneration &+= 1
|
|
236
226
|
let generation = albumArtistGeneration
|
|
237
227
|
|
|
238
228
|
// The just-tapped track wins while its load is in flight: currentTrack
|
|
239
229
|
// still points at the previous track (or nil) at push time.
|
|
240
|
-
guard let track = pendingAlbumArtistTrack ??
|
|
230
|
+
guard let track = pendingAlbumArtistTrack ?? player?.currentTrack else {
|
|
241
231
|
logger.debug("AlbumArtist: no current track → disabled")
|
|
242
232
|
setAlbumArtistDestination(nil)
|
|
243
233
|
return
|
|
@@ -276,18 +266,10 @@ final class CarPlayNowPlayingManager {
|
|
|
276
266
|
|
|
277
267
|
// MARK: - Queue Changes
|
|
278
268
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
guard let template = upNextTemplate,
|
|
284
|
-
let player = audioBrowser?.getPlayer()
|
|
285
|
-
else {
|
|
286
|
-
return
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
logger.debug("Queue changed, updating Up Next list with \(tracks.count) tracks")
|
|
290
|
-
template.updateSections([createUpNextSection(tracks: tracks, player: player)])
|
|
269
|
+
private func queueChanged(_ tracks: [Track]) {
|
|
270
|
+
updateUpNextButton()
|
|
271
|
+
guard let upNextTemplate else { return }
|
|
272
|
+
upNextTemplate.updateSections([createUpNextSection(tracks: tracks)])
|
|
291
273
|
}
|
|
292
274
|
|
|
293
275
|
// MARK: - Private - Button Handlers
|
|
@@ -295,7 +277,7 @@ final class CarPlayNowPlayingManager {
|
|
|
295
277
|
/// Returns the appropriate image for the favorite button based on state
|
|
296
278
|
private func favoriteButtonImage(isFavorited: Bool) -> UIImage {
|
|
297
279
|
let symbolName = isFavorited ? "heart.fill" : "heart"
|
|
298
|
-
guard let image = UIImage(systemName: symbolName)?.
|
|
280
|
+
guard let image = UIImage(systemName: symbolName)?.fitted(
|
|
299
281
|
to: CPNowPlayingButtonMaximumImageSize,
|
|
300
282
|
scale: interfaceController.carTraitCollection.displayScale,
|
|
301
283
|
) else {
|
|
@@ -305,17 +287,15 @@ final class CarPlayNowPlayingManager {
|
|
|
305
287
|
}
|
|
306
288
|
|
|
307
289
|
private func handleShuffleButtonTapped() {
|
|
308
|
-
guard let player
|
|
309
|
-
|
|
290
|
+
guard let player else { return }
|
|
310
291
|
let newEnabled = !player.shuffleEnabled
|
|
311
292
|
player.shuffleEnabled = newEnabled
|
|
312
293
|
logger.info("CarPlay shuffle mode changed: \(newEnabled)")
|
|
313
294
|
}
|
|
314
295
|
|
|
315
296
|
private func handleRepeatButtonTapped() {
|
|
316
|
-
guard let player
|
|
317
|
-
|
|
318
|
-
let currentMode = player.getRepeatMode()
|
|
297
|
+
guard let player else { return }
|
|
298
|
+
let currentMode = player.repeatMode
|
|
319
299
|
let newMode: RepeatMode = switch currentMode {
|
|
320
300
|
case .off:
|
|
321
301
|
.track
|
|
@@ -325,20 +305,22 @@ final class CarPlayNowPlayingManager {
|
|
|
325
305
|
.off
|
|
326
306
|
}
|
|
327
307
|
|
|
328
|
-
player.
|
|
308
|
+
player.repeatMode = newMode
|
|
329
309
|
logger.info("CarPlay repeat mode changed: \(currentMode.stringValue) → \(newMode.stringValue)")
|
|
330
310
|
}
|
|
331
311
|
|
|
332
312
|
private func handleFavoriteButtonTapped() {
|
|
333
|
-
|
|
313
|
+
do {
|
|
314
|
+
try audioBrowser.toggleActiveTrackFavorited()
|
|
315
|
+
} catch {
|
|
316
|
+
logger.error("favorite toggle failed: \(error.localizedDescription)")
|
|
317
|
+
}
|
|
334
318
|
logger.info("CarPlay favorite toggled")
|
|
335
319
|
}
|
|
336
320
|
|
|
337
321
|
private func handlePlaybackRateButtonTapped() {
|
|
338
|
-
guard let audioBrowser, let player = audioBrowser.getPlayer() else { return }
|
|
339
|
-
|
|
340
322
|
let rates = audioBrowser.playbackRates
|
|
341
|
-
guard !rates.isEmpty else { return }
|
|
323
|
+
guard !rates.isEmpty, let player else { return }
|
|
342
324
|
|
|
343
325
|
let currentRate = Double(player.rate)
|
|
344
326
|
let nextRate: Double
|
|
@@ -358,26 +340,20 @@ final class CarPlayNowPlayingManager {
|
|
|
358
340
|
|
|
359
341
|
fileprivate func handleAlbumArtistButtonTapped() {
|
|
360
342
|
guard let destination = albumArtistDestination else { return }
|
|
361
|
-
let track = pendingAlbumArtistTrack ??
|
|
343
|
+
let track = pendingAlbumArtistTrack ?? player?.currentTrack
|
|
362
344
|
// The album line is what was tapped, so its text is the natural title for
|
|
363
345
|
// the pushed destination (artist may be unset, or the live song).
|
|
364
346
|
let title = track?.album ?? track?.artist ?? track?.title ?? ""
|
|
365
347
|
logger.info("Album/Artist button tapped, navigating to \(destination)")
|
|
366
|
-
|
|
348
|
+
navigate(destination, title)
|
|
367
349
|
}
|
|
368
350
|
|
|
369
|
-
private func
|
|
370
|
-
|
|
371
|
-
template.isUpNextButtonEnabled = (audioBrowser?.carPlayUpNextButton ?? true) && (audioBrowser?.getPlayer()?.tracks.count ?? 0) > 1
|
|
351
|
+
private func updateUpNextButton() {
|
|
352
|
+
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
|
|
372
353
|
}
|
|
373
354
|
|
|
374
355
|
fileprivate func handleUpNextButtonTapped() {
|
|
375
|
-
|
|
376
|
-
logger.warning("Player not available for Up Next")
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
let tracks = player.tracks
|
|
356
|
+
let tracks = player?.tracks ?? []
|
|
381
357
|
|
|
382
358
|
guard !tracks.isEmpty else {
|
|
383
359
|
logger.debug("No tracks in queue for Up Next")
|
|
@@ -392,7 +368,7 @@ final class CarPlayNowPlayingManager {
|
|
|
392
368
|
let upNextTitle = CPNowPlayingTemplate.shared.upNextTitle
|
|
393
369
|
let template = CPListTemplate(
|
|
394
370
|
title: upNextTitle.isEmpty ? "Up Next" : upNextTitle,
|
|
395
|
-
sections: [createUpNextSection(tracks: tracks
|
|
371
|
+
sections: [createUpNextSection(tracks: tracks)],
|
|
396
372
|
)
|
|
397
373
|
|
|
398
374
|
upNextTemplate = template
|
|
@@ -400,7 +376,7 @@ final class CarPlayNowPlayingManager {
|
|
|
400
376
|
interfaceController.safePush(template, animated: true)
|
|
401
377
|
}
|
|
402
378
|
|
|
403
|
-
private func createUpNextSection(tracks: [Track]
|
|
379
|
+
private func createUpNextSection(tracks: [Track]) -> CPListSection {
|
|
404
380
|
// Clamp to CarPlay's list limit (createSections does the same for browse
|
|
405
381
|
// lists). Taking the prefix keeps item positions aligned with queue indices.
|
|
406
382
|
let maxItems = CPListTemplate.maximumItemCount
|
|
@@ -408,10 +384,10 @@ final class CarPlayNowPlayingManager {
|
|
|
408
384
|
logger.info("Up Next queue has \(tracks.count) tracks, clamping to CarPlay's limit of \(maxItems)")
|
|
409
385
|
}
|
|
410
386
|
let items = tracks.prefix(maxItems).enumerated().map { index, track -> CPListItem in
|
|
411
|
-
|
|
387
|
+
listItem(track) { [weak self] _, completion in
|
|
412
388
|
self?.logger.info("Skipping to track at index \(index): \(track.title)")
|
|
413
389
|
do {
|
|
414
|
-
try player
|
|
390
|
+
try self?.player?.skipTo(index, playWhenReady: true)
|
|
415
391
|
} catch {
|
|
416
392
|
self?.logger.error("Failed to skip to track: \(error.localizedDescription)")
|
|
417
393
|
}
|
|
@@ -447,3 +423,17 @@ private final class NowPlayingObserver: NSObject, CPNowPlayingTemplateObserver,
|
|
|
447
423
|
}
|
|
448
424
|
}
|
|
449
425
|
}
|
|
426
|
+
|
|
427
|
+
private extension UIImage {
|
|
428
|
+
/// Draws the image centered in `targetSize`, keeping its aspect ratio, at the car screen's
|
|
429
|
+
/// display scale.
|
|
430
|
+
func fitted(to targetSize: CGSize, scale: CGFloat) -> UIImage? {
|
|
431
|
+
UIGraphicsBeginImageContextWithOptions(targetSize, false, scale)
|
|
432
|
+
defer { UIGraphicsEndImageContext() }
|
|
433
|
+
let fitScale = min(targetSize.width / size.width, targetSize.height / size.height)
|
|
434
|
+
let scaledSize = CGSize(width: size.width * fitScale, height: size.height * fitScale)
|
|
435
|
+
let origin = CGPoint(x: (targetSize.width - scaledSize.width) / 2, y: (targetSize.height - scaledSize.height) / 2)
|
|
436
|
+
draw(in: CGRect(origin: origin, size: scaledSize))
|
|
437
|
+
return UIGraphicsGetImageFromCurrentImageContext()?.withRenderingMode(.alwaysTemplate)
|
|
438
|
+
}
|
|
439
|
+
}
|