react-native-audio-browser 0.2.0-next.12 → 0.2.0-next.14
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/README.md +10 -0
- package/android/build.gradle +20 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +8 -1
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +52 -0
- package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
- package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -11
- package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +1 -1
- package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
- package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
- package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +21 -22
- package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
- package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
- package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
- package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
- package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +16 -6
- package/android/src/main/res/values/strings.xml +37 -2
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +50 -5
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +9 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
- package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
- package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
- package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
- package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
- package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
- package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
- package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +10 -9
- package/ios/Browser/BrowserManager.swift +106 -45
- package/ios/Browser/InertStyleDiagnostic.swift +279 -0
- package/ios/Browser/JsonModels.swift +81 -15
- package/ios/Browser/SectionPresentation.swift +30 -0
- package/ios/Browser/StyleResolver.swift +11 -0
- package/ios/Browser/TabBarEntries.swift +7 -1
- package/ios/CarPlay/CarPlayController.swift +139 -35
- package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
- package/ios/Extension/Section+Copying.swift +1 -3
- package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
- package/ios/Http/HttpClient.swift +8 -2
- package/ios/HybridAudioBrowser.swift +56 -12
- package/ios/Model/NitroTypeStubs.swift +60 -1
- package/ios/Player/MediaIntentCriteria.swift +13 -5
- package/ios/Player/MediaLoader.swift +33 -9
- package/ios/Player/NowPlayingUpdater.swift +53 -5
- package/ios/Player/PlaybackCoordinator.swift +11 -4
- package/ios/Player/PlaybackTimer.swift +8 -2
- package/ios/Player/QueueManager.swift +36 -10
- package/ios/Player/ShuffleOrder.swift +23 -0
- package/ios/Player/SleepTimerHandling.swift +13 -0
- package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
- package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
- package/ios/Tests/JsonModelsTests.swift +76 -1
- package/ios/Tests/QueueManagerTests.swift +72 -6
- package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
- package/ios/Tests/ResolvedTrackCopyingTests.swift +2 -2
- package/ios/Tests/SectionPresentationTests.swift +29 -0
- package/ios/Tests/StyleResolverTests.swift +60 -0
- package/ios/Tests/Support/TestTypes.swift +7 -0
- package/ios/TrackPlayer.swift +57 -34
- package/ios/Util/NetworkMonitor.swift +43 -26
- package/ios/Util/SFSymbolRenderer.swift +24 -2
- package/ios/Util/SVGProcessor.swift +9 -2
- package/lib/commonjs/features/browser-config.js +1 -0
- package/lib/commonjs/features/browser-config.js.map +1 -1
- package/lib/commonjs/features/playback/progress.js +2 -1
- package/lib/commonjs/features/playback/progress.js.map +1 -1
- package/lib/commonjs/features/playback/rate.js +8 -1
- package/lib/commonjs/features/playback/rate.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
- package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
- package/lib/module/features/browser-config.js +1 -0
- package/lib/module/features/browser-config.js.map +1 -1
- package/lib/module/features/playback/progress.js +2 -1
- package/lib/module/features/playback/progress.js.map +1 -1
- package/lib/module/features/playback/rate.js +8 -1
- package/lib/module/features/playback/rate.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
- package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
- package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/rate.d.ts +8 -1
- package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
- package/lib/typescript/src/types/browser-native.d.ts +2 -1
- package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
- package/lib/typescript/src/types/browser-nodes.d.ts +116 -9
- package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
- package/lib/typescript/src/types/browser.d.ts +36 -0
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
- package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
- package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
- package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__string__.hpp +102 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +6 -0
- package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
- package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +13 -0
- package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
- package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
- package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +20 -1
- package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
- package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
- package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
- package/nitrogen/generated/android/c++/JSection.hpp +7 -5
- package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
- package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
- package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
- package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_std__shared_ptr_Promise_std__string__.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +7 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Section.kt +2 -5
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +9 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +104 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
- package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
- package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
- package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__string__.swift +54 -0
- package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
- package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
- package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +48 -1
- package/nitrogen/generated/ios/swift/Section.swift +1 -19
- package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
- package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
- package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
- package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
- package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
- package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -2
- package/nitrogen/generated/shared/c++/Section.hpp +1 -5
- package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
- package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
- package/package.json +7 -2
- package/src/features/browser-config.ts +1 -0
- package/src/features/playback/progress.ts +2 -1
- package/src/features/playback/rate.ts +8 -1
- package/src/style-resolution.test.ts +72 -1
- package/src/types/browser-native.ts +5 -1
- package/src/types/browser-nodes.ts +124 -9
- package/src/types/browser.ts +38 -0
- package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
- package/src/web/TrackPlayer/QueueManager.ts +14 -0
- package/website/guide/android-auto.md +114 -0
- package/website/guide/android-certificates.md +409 -0
- package/website/guide/artwork.md +255 -0
- package/website/guide/audio-output.md +135 -0
- package/website/guide/automotive.md +86 -0
- package/website/guide/basic-usage.md +151 -0
- package/website/guide/battery.md +115 -0
- package/website/guide/browser.md +581 -0
- package/website/guide/carplay.md +620 -0
- package/website/guide/configuration.md +173 -0
- package/website/guide/equalizer.md +231 -0
- package/website/guide/errors.md +310 -0
- package/website/guide/favorites.md +154 -0
- package/website/guide/gate.md +143 -0
- package/website/guide/getting-started.md +46 -0
- package/website/guide/hooks.md +238 -0
- package/website/guide/live-streams.md +125 -0
- package/website/guide/metadata.md +239 -0
- package/website/guide/migrating-from-track-player.md +244 -0
- package/website/guide/native-callback-fetch.md +127 -0
- package/website/guide/network.md +86 -0
- package/website/guide/now-playing.md +175 -0
- package/website/guide/playback.md +328 -0
- package/website/guide/queue.md +300 -0
- package/website/guide/remote-controls.md +256 -0
- package/website/guide/search.md +255 -0
- package/website/guide/sleep-timer.md +268 -0
- package/website/guide/track.md +246 -0
|
@@ -77,30 +77,50 @@ extension JsonArtwork {
|
|
|
77
77
|
struct JsonStyle: Codable, Equatable {
|
|
78
78
|
let display: String?
|
|
79
79
|
let gridWrap: Bool?
|
|
80
|
+
let gridTile: String?
|
|
80
81
|
let artworkRendering: String?
|
|
82
|
+
let imageShape: String?
|
|
83
|
+
let accessorySymbol: String?
|
|
84
|
+
let cardTint: String?
|
|
85
|
+
let cardImage: String?
|
|
81
86
|
|
|
82
87
|
init(
|
|
83
88
|
display: String? = nil,
|
|
84
89
|
gridWrap: Bool? = nil,
|
|
90
|
+
gridTile: String? = nil,
|
|
85
91
|
artworkRendering: String? = nil,
|
|
92
|
+
imageShape: String? = nil,
|
|
93
|
+
accessorySymbol: String? = nil,
|
|
94
|
+
cardTint: String? = nil,
|
|
95
|
+
cardImage: String? = nil,
|
|
86
96
|
) {
|
|
87
97
|
self.display = display
|
|
88
98
|
self.gridWrap = gridWrap
|
|
99
|
+
self.gridTile = gridTile
|
|
89
100
|
self.artworkRendering = artworkRendering
|
|
101
|
+
self.imageShape = imageShape
|
|
102
|
+
self.accessorySymbol = accessorySymbol
|
|
103
|
+
self.cardTint = cardTint
|
|
104
|
+
self.cardImage = cardImage
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
init(from decoder: Decoder) throws {
|
|
93
108
|
// A non-object value (the retired `style: 'grid'` string) decodes as an
|
|
94
109
|
// empty block rather than failing the parent.
|
|
95
110
|
guard let container = try? decoder.container(keyedBy: CodingKeys.self) else {
|
|
96
|
-
|
|
97
|
-
gridWrap = nil
|
|
98
|
-
artworkRendering = nil
|
|
111
|
+
self.init()
|
|
99
112
|
return
|
|
100
113
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
self.init(
|
|
115
|
+
display: (try? container.decodeIfPresent(String.self, forKey: .display)) ?? nil,
|
|
116
|
+
gridWrap: (try? container.decodeIfPresent(Bool.self, forKey: .gridWrap)) ?? nil,
|
|
117
|
+
gridTile: (try? container.decodeIfPresent(String.self, forKey: .gridTile)) ?? nil,
|
|
118
|
+
artworkRendering: (try? container.decodeIfPresent(String.self, forKey: .artworkRendering)) ?? nil,
|
|
119
|
+
imageShape: (try? container.decodeIfPresent(String.self, forKey: .imageShape)) ?? nil,
|
|
120
|
+
accessorySymbol: (try? container.decodeIfPresent(String.self, forKey: .accessorySymbol)) ?? nil,
|
|
121
|
+
cardTint: (try? container.decodeIfPresent(String.self, forKey: .cardTint)) ?? nil,
|
|
122
|
+
cardImage: (try? container.decodeIfPresent(String.self, forKey: .cardImage)) ?? nil,
|
|
123
|
+
)
|
|
104
124
|
}
|
|
105
125
|
}
|
|
106
126
|
|
|
@@ -114,20 +134,47 @@ extension JsonStyle {
|
|
|
114
134
|
func toTrackStyle() -> TrackStyle? {
|
|
115
135
|
let display = display.flatMap { StyleDisplay(fromString: $0.lowercased()) }
|
|
116
136
|
let artworkRendering = artworkRendering.flatMap { ArtworkRendering(fromString: $0.lowercased()) }
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
let imageShape = imageShape.flatMap { ImageShape(fromString: $0.lowercased()) }
|
|
138
|
+
// The 'none' sentinel is a legitimate value; only emptiness collapses.
|
|
139
|
+
let accessorySymbol = accessorySymbol?.isEmpty == false ? accessorySymbol : nil
|
|
140
|
+
let cardTint = cardTint?.isEmpty == false ? cardTint : nil
|
|
141
|
+
let cardImage = cardImage.flatMap { CardImage(fromString: $0.lowercased()) }
|
|
142
|
+
guard display != nil || artworkRendering != nil || imageShape != nil
|
|
143
|
+
|| accessorySymbol != nil || cardTint != nil || cardImage != nil
|
|
144
|
+
else { return nil }
|
|
145
|
+
return TrackStyle(
|
|
146
|
+
display: display,
|
|
147
|
+
artworkRendering: artworkRendering,
|
|
148
|
+
imageShape: imageShape,
|
|
149
|
+
accessorySymbol: accessorySymbol,
|
|
150
|
+
cardTint: cardTint,
|
|
151
|
+
cardImage: cardImage,
|
|
152
|
+
)
|
|
119
153
|
}
|
|
120
154
|
|
|
121
155
|
func toSectionStyle() -> SectionStyle? {
|
|
122
156
|
let display = display.flatMap { StyleDisplay(fromString: $0.lowercased()) }
|
|
157
|
+
let gridTile = gridTile.flatMap { GridTile(fromString: $0.lowercased()) }
|
|
123
158
|
let artworkRendering = artworkRendering.flatMap { ArtworkRendering(fromString: $0.lowercased()) }
|
|
124
|
-
|
|
159
|
+
let imageShape = imageShape.flatMap { ImageShape(fromString: $0.lowercased()) }
|
|
160
|
+
let accessorySymbol = accessorySymbol?.isEmpty == false ? accessorySymbol : nil
|
|
161
|
+
let cardTint = cardTint?.isEmpty == false ? cardTint : nil
|
|
162
|
+
let cardImage = cardImage.flatMap { CardImage(fromString: $0.lowercased()) }
|
|
163
|
+
guard display != nil || artworkRendering != nil || gridWrap != nil
|
|
164
|
+
|| gridTile != nil || imageShape != nil || accessorySymbol != nil
|
|
165
|
+
|| cardTint != nil || cardImage != nil
|
|
166
|
+
else { return nil }
|
|
125
167
|
// Argument order is the generated init's (own properties before
|
|
126
168
|
// inherited ones — Nitro flattens `extends` own-props-first).
|
|
127
169
|
return SectionStyle(
|
|
128
170
|
gridWrap: gridWrap,
|
|
171
|
+
gridTile: gridTile,
|
|
129
172
|
display: display,
|
|
130
173
|
artworkRendering: artworkRendering,
|
|
174
|
+
imageShape: imageShape,
|
|
175
|
+
accessorySymbol: accessorySymbol,
|
|
176
|
+
cardTint: cardTint,
|
|
177
|
+
cardImage: cardImage,
|
|
131
178
|
)
|
|
132
179
|
}
|
|
133
180
|
|
|
@@ -135,10 +182,18 @@ extension JsonStyle {
|
|
|
135
182
|
/// An all-nil block snapshots as no block — the decoders collapse it to
|
|
136
183
|
/// "no declaration" anyway, so never persist the distinction.
|
|
137
184
|
init?(_ style: TrackStyle?) {
|
|
138
|
-
guard let style,
|
|
185
|
+
guard let style,
|
|
186
|
+
style.display != nil || style.artworkRendering != nil
|
|
187
|
+
|| style.imageShape != nil || style.accessorySymbol != nil
|
|
188
|
+
|| style.cardTint != nil || style.cardImage != nil
|
|
189
|
+
else { return nil }
|
|
139
190
|
self.init(
|
|
140
191
|
display: Self.string(style.display),
|
|
141
192
|
artworkRendering: Self.string(style.artworkRendering),
|
|
193
|
+
imageShape: Self.string(style.imageShape),
|
|
194
|
+
accessorySymbol: style.accessorySymbol,
|
|
195
|
+
cardTint: style.cardTint,
|
|
196
|
+
cardImage: Self.string(style.cardImage),
|
|
142
197
|
)
|
|
143
198
|
}
|
|
144
199
|
|
|
@@ -157,6 +212,22 @@ extension JsonStyle {
|
|
|
157
212
|
case nil: nil
|
|
158
213
|
}
|
|
159
214
|
}
|
|
215
|
+
|
|
216
|
+
private static func string(_ shape: ImageShape?) -> String? {
|
|
217
|
+
switch shape {
|
|
218
|
+
case .circular: "circular"
|
|
219
|
+
case .roundedRectangle: "rounded-rectangle"
|
|
220
|
+
case nil: nil
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private static func string(_ mode: CardImage?) -> String? {
|
|
225
|
+
switch mode {
|
|
226
|
+
case .normal: "normal"
|
|
227
|
+
case .background: "background"
|
|
228
|
+
case nil: nil
|
|
229
|
+
}
|
|
230
|
+
}
|
|
160
231
|
}
|
|
161
232
|
|
|
162
233
|
/// JSON model for a page section (ADR 0010). Legacy `groupTitle`/`imageRow`
|
|
@@ -164,20 +235,17 @@ extension JsonStyle {
|
|
|
164
235
|
/// ignored dead weight.
|
|
165
236
|
struct JsonSection: Codable {
|
|
166
237
|
let title: String?
|
|
167
|
-
let subtitle: String?
|
|
168
238
|
let style: JsonStyle?
|
|
169
239
|
let path: String?
|
|
170
240
|
let children: [JsonTrack]
|
|
171
241
|
|
|
172
242
|
init(
|
|
173
243
|
title: String? = nil,
|
|
174
|
-
subtitle: String? = nil,
|
|
175
244
|
style: JsonStyle? = nil,
|
|
176
245
|
path: String? = nil,
|
|
177
246
|
children: [JsonTrack],
|
|
178
247
|
) {
|
|
179
248
|
self.title = title
|
|
180
|
-
self.subtitle = subtitle
|
|
181
249
|
self.style = style
|
|
182
250
|
self.path = path
|
|
183
251
|
self.children = children
|
|
@@ -393,7 +461,6 @@ struct JsonTrack: Codable {
|
|
|
393
461
|
func toNitro() -> Section {
|
|
394
462
|
Section(
|
|
395
463
|
title: title,
|
|
396
|
-
subtitle: subtitle,
|
|
397
464
|
style: style?.toSectionStyle(),
|
|
398
465
|
path: path,
|
|
399
466
|
children: children.map { $0.toNitro() },
|
|
@@ -479,7 +546,6 @@ struct JsonTrack: Codable {
|
|
|
479
546
|
func toNitro() -> Section {
|
|
480
547
|
Section(
|
|
481
548
|
title: title,
|
|
482
|
-
subtitle: subtitle,
|
|
483
549
|
style: style?.toSectionStyle(),
|
|
484
550
|
path: path,
|
|
485
551
|
children: children.map { $0.toNitro() },
|
|
@@ -31,4 +31,34 @@ enum SectionPresentation: Equatable {
|
|
|
31
31
|
self = .list
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/// The accessory symbol a resolved block actually draws: nil when
|
|
36
|
+
/// undeclared, and nil for the `'none'` sentinel — the inheritance escape
|
|
37
|
+
/// that restores the derived accessory (a browsable row's chevron). Here,
|
|
38
|
+
/// not in the CarPlay factory, so the sentinel rule is testable off-device.
|
|
39
|
+
static func effectiveAccessorySymbol(_ style: TrackStyle?) -> String? {
|
|
40
|
+
guard let symbol = style?.accessorySymbol, symbol != "none" else { return nil }
|
|
41
|
+
return symbol
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// The element family a tile presentation renders on iOS 26+ — here, not
|
|
45
|
+
/// in the CarPlay factory, so the style→family mapping is testable
|
|
46
|
+
/// off-device. `gridTile` picks the family (plain by default); the plain
|
|
47
|
+
/// single-line shelf keeps row elements, the one plain element with a
|
|
48
|
+
/// subtitle slot. Pre-26 has no element API at all: the treatment drops
|
|
49
|
+
/// with the legacy image row (decorations drop before layout).
|
|
50
|
+
enum TileElementFamily: Equatable {
|
|
51
|
+
case rowElements
|
|
52
|
+
case imageGridElements
|
|
53
|
+
case cardElements
|
|
54
|
+
case condensedElements
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static func tileFamily(for style: SectionStyle?, singleLine: Bool) -> TileElementFamily {
|
|
58
|
+
switch style?.gridTile {
|
|
59
|
+
case .card: .cardElements
|
|
60
|
+
case .condensed: .condensedElements
|
|
61
|
+
case .plain, nil: singleLine ? .rowElements : .imageGridElements
|
|
62
|
+
}
|
|
63
|
+
}
|
|
34
64
|
}
|
|
@@ -28,8 +28,13 @@ enum StyleResolver {
|
|
|
28
28
|
// ones — Nitro flattens `extends` own-props-first); the stub mirrors it.
|
|
29
29
|
SectionStyle(
|
|
30
30
|
gridWrap: section?.gridWrap ?? page?.gridWrap,
|
|
31
|
+
gridTile: section?.gridTile ?? page?.gridTile,
|
|
31
32
|
display: section?.display ?? page?.display,
|
|
32
33
|
artworkRendering: section?.artworkRendering ?? page?.artworkRendering,
|
|
34
|
+
imageShape: section?.imageShape ?? page?.imageShape,
|
|
35
|
+
accessorySymbol: section?.accessorySymbol ?? page?.accessorySymbol,
|
|
36
|
+
cardTint: section?.cardTint ?? page?.cardTint,
|
|
37
|
+
cardImage: section?.cardImage ?? page?.cardImage,
|
|
33
38
|
)
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -42,6 +47,12 @@ enum StyleResolver {
|
|
|
42
47
|
// for this item's own layout.
|
|
43
48
|
display: nil,
|
|
44
49
|
artworkRendering: track?.artworkRendering ?? section?.artworkRendering,
|
|
50
|
+
imageShape: track?.imageShape ?? section?.imageShape,
|
|
51
|
+
// `'none'` resolves like any value — it is the renderer that treats it
|
|
52
|
+
// as "no accessory, derived behavior" (the inheritance escape).
|
|
53
|
+
accessorySymbol: track?.accessorySymbol ?? section?.accessorySymbol,
|
|
54
|
+
cardTint: track?.cardTint ?? section?.cardTint,
|
|
55
|
+
cardImage: track?.cardImage ?? section?.cardImage,
|
|
45
56
|
)
|
|
46
57
|
}
|
|
47
58
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/// Compares the tab-list fields the CarPlay tab bar actually renders.
|
|
2
2
|
enum TabBarEntries {
|
|
3
3
|
/// Whether two tab lists produce identical tab-bar entries — title, path,
|
|
4
|
-
///
|
|
4
|
+
/// the artwork inputs of `applyTabBarEntry`, and `disabled`. Used to suppress
|
|
5
5
|
/// `tabsChanged` when a tabs re-query (e.g. from `invalidateAllContent()`)
|
|
6
6
|
/// resolves to the same entries, so an unchanged result never churns the
|
|
7
7
|
/// tab bar or resets its selection.
|
|
8
|
+
///
|
|
9
|
+
/// `disabled` counts even though it renders nothing itself: a disabled tab is
|
|
10
|
+
/// filtered out of the tab bar entirely (`showTabBar`), so a tab flipping it
|
|
11
|
+
/// with otherwise-identical fields changes which tabs exist. Omitting it here
|
|
12
|
+
/// swallowed the emit, and the tab never appeared or disappeared.
|
|
8
13
|
static func same(_ old: [Track]?, _ new: [Track]) -> Bool {
|
|
9
14
|
guard let old, old.count == new.count else { return false }
|
|
10
15
|
return zip(old, new).allSatisfy {
|
|
11
16
|
$0.title == $1.title && $0.path == $1.path && $0.artwork == $1.artwork
|
|
12
17
|
&& $0.artworkSource?.uri == $1.artworkSource?.uri
|
|
18
|
+
&& $0.disabled == $1.disabled
|
|
13
19
|
}
|
|
14
20
|
}
|
|
15
21
|
}
|
|
@@ -26,9 +26,22 @@ public final class RNABCarPlayController: NSObject {
|
|
|
26
26
|
private var trackSelector: TrackSelector?
|
|
27
27
|
|
|
28
28
|
/// Track content subscriptions
|
|
29
|
-
private var isStarted = false
|
|
30
29
|
private var listenerRemovals: [() -> Void] = []
|
|
31
30
|
|
|
31
|
+
/// Identifies the current `start()` run; nil while stopped.
|
|
32
|
+
///
|
|
33
|
+
/// A run needs an identity, not just a flag, because `start()` parks an
|
|
34
|
+
/// untracked Task on the uncancellable `playerAndConfiguredBrowser.wait()`,
|
|
35
|
+
/// which stays queued across a `reset()` and resumes every waiter. `restart()`
|
|
36
|
+
/// is `stop(); start()`, so a boolean is `true` again by the time the old
|
|
37
|
+
/// waiter resumes and both runs proceed — duplicate subscriptions and a leaked
|
|
38
|
+
/// `NowPlayingObserver` for the session. Comparing the captured run against
|
|
39
|
+
/// this answers "still running?" and "still me?" in one condition.
|
|
40
|
+
private var currentRun: UInt?
|
|
41
|
+
private var runCounter: UInt = 0
|
|
42
|
+
|
|
43
|
+
private var isStarted: Bool { currentRun != nil }
|
|
44
|
+
|
|
32
45
|
/// Paths whose loading template has been pushed but not yet appeared. Guards a
|
|
33
46
|
/// rapid double-tap from pushing the same destination twice (the first push is
|
|
34
47
|
/// async, so `topTemplate` may not reflect it yet). Cleared when it appears.
|
|
@@ -97,6 +110,19 @@ public final class RNABCarPlayController: NSObject {
|
|
|
97
110
|
}
|
|
98
111
|
}
|
|
99
112
|
|
|
113
|
+
/// The tabs the tab bar actually shows. A disabled tab hides rather than
|
|
114
|
+
/// greys: `CPTabBarTemplate` has no disabled affordance, so per `Track`'s
|
|
115
|
+
/// rendering ladder an unavailable tab must not render as a normal-looking
|
|
116
|
+
/// control (matches Android Auto, which filters before its 4-tab cap).
|
|
117
|
+
///
|
|
118
|
+
/// Named once because every site that reasons about the tab bar must agree on
|
|
119
|
+
/// it — `showTabBar` builds from this list, and `handleTabsChanged` compares
|
|
120
|
+
/// against it. Those two disagreeing is exactly what made the in-place
|
|
121
|
+
/// restamp dead code whenever any tab was disabled.
|
|
122
|
+
private static func visibleTabs(_ tabs: [Track]) -> [Track] {
|
|
123
|
+
tabs.filter { $0.disabled != true }
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
/// Whether a list item identifies the currently active (loaded) track.
|
|
101
127
|
///
|
|
102
128
|
/// Compares track identities (`id` when non-blank, else `src`): the active
|
|
@@ -140,7 +166,9 @@ public final class RNABCarPlayController: NSObject {
|
|
|
140
166
|
@objc
|
|
141
167
|
public func start() {
|
|
142
168
|
guard !isStarted else { return }
|
|
143
|
-
|
|
169
|
+
runCounter &+= 1
|
|
170
|
+
currentRun = runCounter
|
|
171
|
+
let run = runCounter
|
|
144
172
|
|
|
145
173
|
logger.info("Starting CarPlay controller")
|
|
146
174
|
|
|
@@ -167,7 +195,8 @@ public final class RNABCarPlayController: NSObject {
|
|
|
167
195
|
// Wait for both browser and player to be ready
|
|
168
196
|
Task { @MainActor in
|
|
169
197
|
let (browser, _) = await playerAndConfiguredBrowser.wait()
|
|
170
|
-
|
|
198
|
+
// Answers "still running?" and "still this run?" at once.
|
|
199
|
+
guard self.currentRun == run else { return }
|
|
171
200
|
self.logger.debug("AudioBrowser and player ready, setting up CarPlay")
|
|
172
201
|
self.audioBrowser = browser
|
|
173
202
|
self.isGated = browser.isGateActive
|
|
@@ -202,7 +231,7 @@ public final class RNABCarPlayController: NSObject {
|
|
|
202
231
|
@objc
|
|
203
232
|
public func stop() {
|
|
204
233
|
guard isStarted else { return }
|
|
205
|
-
|
|
234
|
+
currentRun = nil
|
|
206
235
|
|
|
207
236
|
logger.info("Stopping CarPlay controller")
|
|
208
237
|
|
|
@@ -465,11 +494,7 @@ public final class RNABCarPlayController: NSObject {
|
|
|
465
494
|
|
|
466
495
|
@MainActor
|
|
467
496
|
private func showTabBar(tabs allTabs: [Track]) async {
|
|
468
|
-
|
|
469
|
-
// Track.disabled's rendering ladder an unavailable tab must not render as
|
|
470
|
-
// a normal-looking control (matches Android Auto, which filters before
|
|
471
|
-
// its 4-tab cap).
|
|
472
|
-
let tabs = allTabs.filter { $0.disabled != true }
|
|
497
|
+
let tabs = Self.visibleTabs(allTabs)
|
|
473
498
|
|
|
474
499
|
// Serialize concurrent tab-bar builds across BOTH the gated and non-gated
|
|
475
500
|
// paths with one shared generation token: bump at entry, capture, and bail
|
|
@@ -484,26 +509,31 @@ public final class RNABCarPlayController: NSObject {
|
|
|
484
509
|
gateBuildGeneration &+= 1
|
|
485
510
|
let generation = gateBuildGeneration
|
|
486
511
|
|
|
487
|
-
// A zero-tab CPTabBarTemplate renders blank, with no copy, so empty routes
|
|
488
|
-
// to the .emptyContent error page like every other failure (formatted
|
|
489
|
-
// through the app's formatNavigationError, ADR 0001). The guard lives at
|
|
490
|
-
// this choke point — both the initial build and runtime tab changes pass
|
|
491
|
-
// through here — and sits before the gate check, since a gated build from
|
|
492
|
-
// zero tabs is equally blank.
|
|
493
|
-
guard !tabs.isEmpty else {
|
|
494
|
-
await showRootNavigationError(.code(.emptyContent))
|
|
495
|
-
return
|
|
496
|
-
}
|
|
497
|
-
|
|
498
512
|
// While gated, each tab path is resolved per-request: a gated tab renders
|
|
499
513
|
// the gate page with that request's chrome; an allowed tab shows real
|
|
500
514
|
// content. This generalizes the old "all tabs show the gate" — a path the
|
|
501
515
|
// resolver allows now shows real content.
|
|
516
|
+
//
|
|
517
|
+
// Ahead of the empty guard below, because a gated build from zero tabs is
|
|
518
|
+
// NOT blank: it paints a full-screen gate page as root (a gate raised
|
|
519
|
+
// before config loads is exactly that case). `showGatedTabBar` owns the
|
|
520
|
+
// empty-tabs decision and falls back to the same .emptyContent page when
|
|
521
|
+
// the resolver allows the root.
|
|
502
522
|
if isGated {
|
|
503
523
|
await showGatedTabBar(tabs: tabs, generation: generation)
|
|
504
524
|
return
|
|
505
525
|
}
|
|
506
526
|
|
|
527
|
+
// A zero-tab CPTabBarTemplate renders blank, with no copy, so empty routes
|
|
528
|
+
// to the .emptyContent error page like every other failure (formatted
|
|
529
|
+
// through the app's formatNavigationError, ADR 0001). The guard lives at
|
|
530
|
+
// this choke point — both the initial build and runtime tab changes pass
|
|
531
|
+
// through here.
|
|
532
|
+
guard !tabs.isEmpty else {
|
|
533
|
+
await showRootNavigationError(.code(.emptyContent))
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
|
|
507
537
|
logger.info("Building tab bar with \(tabs.count) tabs")
|
|
508
538
|
|
|
509
539
|
let maxTabs = CPTabBarTemplate.maximumTabCount
|
|
@@ -543,8 +573,7 @@ public final class RNABCarPlayController: NSObject {
|
|
|
543
573
|
|
|
544
574
|
guard !tabs.isEmpty else {
|
|
545
575
|
// Tabs unknown (config not loaded yet, or none) — resolve the root path
|
|
546
|
-
// and show a single gate page
|
|
547
|
-
// build once tabs arrive).
|
|
576
|
+
// and show a single gate page.
|
|
548
577
|
let outcome = await audioBrowser.gateDecision(
|
|
549
578
|
for: NativeGateRequest(reason: .browse, path: nil, search: nil),
|
|
550
579
|
)
|
|
@@ -554,6 +583,11 @@ public final class RNABCarPlayController: NSObject {
|
|
|
554
583
|
interfaceController.safeSetRoot(
|
|
555
584
|
makeGateTemplate(gate: outcome.chrome, tab: nil), animated: true,
|
|
556
585
|
)
|
|
586
|
+
} else {
|
|
587
|
+
// The resolver allows the root, and there are no tabs to show — the
|
|
588
|
+
// same nothing-to-render case the ungated build routes to .emptyContent.
|
|
589
|
+
// Without this the loading spinner would stay as root forever.
|
|
590
|
+
await showRootNavigationError(.code(.emptyContent))
|
|
557
591
|
}
|
|
558
592
|
return
|
|
559
593
|
}
|
|
@@ -835,6 +869,9 @@ public final class RNABCarPlayController: NSObject {
|
|
|
835
869
|
/// filled by `templateDidAppear` → `loadContentIfNeeded` → `loadContent`, which
|
|
836
870
|
/// runs once the template is on screen (the timing CarPlay needs: updates made
|
|
837
871
|
/// right after a push are dropped). Backing out and re-tapping retries.
|
|
872
|
+
///
|
|
873
|
+
/// One exception to pushing: a path that IS a tab selects that tab (see
|
|
874
|
+
/// `selectTab`).
|
|
838
875
|
@MainActor
|
|
839
876
|
private func navigateToPath(_ path: String, title: String) async {
|
|
840
877
|
// The gate is resolved per-request: a gated path pushes the gate page
|
|
@@ -854,6 +891,9 @@ public final class RNABCarPlayController: NSObject {
|
|
|
854
891
|
return
|
|
855
892
|
}
|
|
856
893
|
}
|
|
894
|
+
// A link to a tab's own path switches to that tab instead of pushing a
|
|
895
|
+
// second copy of it into the current tab's stack.
|
|
896
|
+
if selectTab(owning: path) { return }
|
|
857
897
|
// Avoid pushing a duplicate if the top template already shows this path.
|
|
858
898
|
if let top = interfaceController.topTemplate, getPath(from: top) == path {
|
|
859
899
|
return
|
|
@@ -877,6 +917,40 @@ public final class RNABCarPlayController: NSObject {
|
|
|
877
917
|
}
|
|
878
918
|
}
|
|
879
919
|
|
|
920
|
+
/// Switches to the tab that owns `path`, if any — a "view all" header or row
|
|
921
|
+
/// pointing at a tab's own path belongs in the tab bar, not pushed on top of
|
|
922
|
+
/// whichever tab the user tapped from (which would strand them one level deep
|
|
923
|
+
/// in a list the bar already offers, with a Back button to a page they never
|
|
924
|
+
/// came from). Any pushed stack is popped first, so the tab opens at its root.
|
|
925
|
+
///
|
|
926
|
+
/// Returns false — and the caller pushes as usual — when `path` is no tab,
|
|
927
|
+
/// when the root isn't the tab bar (the gate replaces it), or before iOS 17,
|
|
928
|
+
/// which has no tab-selection API.
|
|
929
|
+
@MainActor
|
|
930
|
+
private func selectTab(owning path: String) -> Bool {
|
|
931
|
+
guard #available(iOS 17.0, *),
|
|
932
|
+
let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
|
|
933
|
+
let tab = tabBar.templates.first(where: { getPath(from: $0) == path })
|
|
934
|
+
else { return false }
|
|
935
|
+
let isPushed = interfaceController.templates.count > 1
|
|
936
|
+
// Already showing: the destination is where the user asked to be, so this
|
|
937
|
+
// is handled — just don't animate a pop-and-select over it.
|
|
938
|
+
if tabBar.selectedTemplate === tab, !isPushed { return true }
|
|
939
|
+
logger.debug("Navigating to tab \(path) instead of pushing it")
|
|
940
|
+
guard isPushed else {
|
|
941
|
+
tabBar.select(tab)
|
|
942
|
+
return true
|
|
943
|
+
}
|
|
944
|
+
interfaceController.safePopToRoot(animated: true) { popped, _ in
|
|
945
|
+
// Selecting after a failed pop would leave the pushed stack over the
|
|
946
|
+
// newly selected tab; the caller has already returned, so a failure just
|
|
947
|
+
// leaves the user where they were.
|
|
948
|
+
guard popped else { return }
|
|
949
|
+
tabBar.select(tab)
|
|
950
|
+
}
|
|
951
|
+
return true
|
|
952
|
+
}
|
|
953
|
+
|
|
880
954
|
/// Builds an empty list template for content that is still resolving.
|
|
881
955
|
/// On iOS 18.4+ it shows the system spinner (`showsSpinnerWhileEmpty`). On older
|
|
882
956
|
/// iOS it shows the app-localized `carPlayLoadingTitle` as the centered empty
|
|
@@ -951,16 +1025,18 @@ public final class RNABCarPlayController: NSObject {
|
|
|
951
1025
|
@MainActor
|
|
952
1026
|
private func handleTabsChanged(_ tabs: [Track]) {
|
|
953
1027
|
logger.debug("Tabs changed: \(tabs.count) tabs")
|
|
1028
|
+
// Compare against the list showTabBar builds templates from, not the raw one.
|
|
1029
|
+
let visibleTabs = Self.visibleTabs(tabs)
|
|
954
1030
|
// Same tabs by path while ungated → only the tab-bar entries changed
|
|
955
1031
|
// (e.g. titles after a locale switch): restamp them in place. No rebuild,
|
|
956
1032
|
// so the selected tab and any pushed navigation stack survive. (Gate
|
|
957
1033
|
// templates carry no path, so a gated tab bar never matches here.)
|
|
958
1034
|
if !isGated,
|
|
959
1035
|
let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
|
|
960
|
-
tabBar.templates.count == min(
|
|
961
|
-
zip(tabBar.templates,
|
|
1036
|
+
tabBar.templates.count == min(visibleTabs.count, CPTabBarTemplate.maximumTabCount),
|
|
1037
|
+
zip(tabBar.templates, visibleTabs).allSatisfy({ getPath(from: $0) == $1.path })
|
|
962
1038
|
{
|
|
963
|
-
for (template, tab) in zip(tabBar.templates,
|
|
1039
|
+
for (template, tab) in zip(tabBar.templates, visibleTabs) {
|
|
964
1040
|
applyTabBarEntry(to: template, for: tab)
|
|
965
1041
|
}
|
|
966
1042
|
pendingTabs = nil
|
|
@@ -1242,15 +1318,29 @@ public final class RNABCarPlayController: NSObject {
|
|
|
1242
1318
|
logger.debug("Calling formatNavigationError callback...")
|
|
1243
1319
|
// Call the JS callback and handle result
|
|
1244
1320
|
let params = FormatNavigationErrorParams(error: error, defaultFormatted: defaultFormatted, path: path)
|
|
1321
|
+
// The result callbacks are @Sendable and hop back explicitly: Nitro resolves
|
|
1322
|
+
// its promises synchronously on the JS thread, so a plain closure would
|
|
1323
|
+
// inherit this type's MainActor isolation and trap on entry under Swift 6's
|
|
1324
|
+
// dynamic isolation check — and would otherwise drive UIKit off the main
|
|
1325
|
+
// thread. Same pattern as HybridAudioBrowser.handleNavigationError and
|
|
1326
|
+
// NowPlayingUpdater.render.
|
|
1245
1327
|
formatter(params)
|
|
1246
|
-
.then { [weak self] customDisplay in
|
|
1247
|
-
|
|
1248
|
-
|
|
1328
|
+
.then { @Sendable [weak self] customDisplay in
|
|
1329
|
+
let description = String(describing: customDisplay)
|
|
1330
|
+
Task { @MainActor in
|
|
1331
|
+
guard let self else { return }
|
|
1332
|
+
self.logger.debug("formatNavigationError returned: \(description)")
|
|
1333
|
+
self.presentErrorActionSheet(customDisplay: customDisplay ?? defaultFormatted)
|
|
1334
|
+
}
|
|
1249
1335
|
}
|
|
1250
|
-
.catch { [weak self] callbackError in
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1336
|
+
.catch { @Sendable [weak self] callbackError in
|
|
1337
|
+
let message = callbackError.localizedDescription
|
|
1338
|
+
Task { @MainActor in
|
|
1339
|
+
guard let self else { return }
|
|
1340
|
+
self.logger.error("formatNavigationError failed: \(message)")
|
|
1341
|
+
// On error, fall back to defaults
|
|
1342
|
+
self.presentErrorActionSheet(customDisplay: defaultFormatted)
|
|
1343
|
+
}
|
|
1254
1344
|
}
|
|
1255
1345
|
} else {
|
|
1256
1346
|
presentErrorActionSheet(customDisplay: defaultFormatted)
|
|
@@ -1291,9 +1381,22 @@ public final class RNABCarPlayController: NSObject {
|
|
|
1291
1381
|
/// view formatted via the app's `formatNavigationError` (path "/"), like every
|
|
1292
1382
|
/// other browse failure. The empty view renders reliably here because it's the
|
|
1293
1383
|
/// template's initial state at set-root time.
|
|
1384
|
+
///
|
|
1385
|
+
/// Formatting awaits the app's `formatNavigationError` callback, so a tab-bar
|
|
1386
|
+
/// build can start and paint during the hop — this error root must not
|
|
1387
|
+
/// clobber it. Captured here rather than passed in: the `showTabBar` /
|
|
1388
|
+
/// `showGatedTabBar` callers hold the newest generation when they call, so
|
|
1389
|
+
/// capturing is equivalent for them and covers the others for free.
|
|
1390
|
+
///
|
|
1391
|
+
/// Not airtight for `buildInitialInterface`'s two callers: that function
|
|
1392
|
+
/// doesn't participate in `gateBuildGeneration` and suspends at `queryTabs()`,
|
|
1393
|
+
/// so a build that superseded it *before* this was entered isn't caught.
|
|
1394
|
+
/// Closing that needs a generation captured at `buildInitialInterface` entry.
|
|
1294
1395
|
@MainActor
|
|
1295
1396
|
private func showRootNavigationError(_ navError: NavigationError) async {
|
|
1397
|
+
let generation = gateBuildGeneration
|
|
1296
1398
|
let formatted = await formattedNavigationError(navError, path: "/")
|
|
1399
|
+
guard gateBuildGeneration == generation else { return }
|
|
1297
1400
|
let template = CPListTemplate(title: nil, sections: [])
|
|
1298
1401
|
template.emptyViewTitleVariants = [formatted.title]
|
|
1299
1402
|
template.emptyViewSubtitleVariants = formatted.message.flatMap { $0.isEmpty ? nil : [$0] } ?? []
|
|
@@ -1382,12 +1485,13 @@ extension UIImage {
|
|
|
1382
1485
|
UIGraphicsBeginImageContextWithOptions(targetSize, false, scale)
|
|
1383
1486
|
defer { UIGraphicsEndImageContext() }
|
|
1384
1487
|
|
|
1385
|
-
// Scale to fit while maintaining aspect ratio
|
|
1488
|
+
// Scale to fit while maintaining aspect ratio. Named `fitScale` so it does
|
|
1489
|
+
// not shadow the `scale` parameter (the display scale) it sits next to.
|
|
1386
1490
|
let widthRatio = targetSize.width / size.width
|
|
1387
1491
|
let heightRatio = targetSize.height / size.height
|
|
1388
|
-
let
|
|
1492
|
+
let fitScale = min(widthRatio, heightRatio)
|
|
1389
1493
|
|
|
1390
|
-
let scaledSize = CGSize(width: size.width *
|
|
1494
|
+
let scaledSize = CGSize(width: size.width * fitScale, height: size.height * fitScale)
|
|
1391
1495
|
let origin = CGPoint(
|
|
1392
1496
|
x: (targetSize.width - scaledSize.width) / 2,
|
|
1393
1497
|
y: (targetSize.height - scaledSize.height) / 2,
|