react-native-audio-browser 0.2.0-next.12 → 0.2.0-next.13
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 +5 -1
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +8 -0
- package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
- package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -9
- 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/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 +8 -1
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +49 -3
- 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 +8 -8
- package/ios/Browser/BrowserManager.swift +106 -45
- package/ios/Browser/InertStyleDiagnostic.swift +279 -0
- package/ios/Browser/JsonModels.swift +81 -10
- 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/UIApplication+ActiveScene.swift +27 -0
- package/ios/Http/HttpClient.swift +8 -2
- package/ios/HybridAudioBrowser.swift +54 -11
- package/ios/Model/NitroTypeStubs.swift +60 -0
- 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/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/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/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/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-nodes.d.ts +104 -2
- package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
- 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__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 +12 -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 +6 -0
- 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 +6 -0
- 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/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/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.hpp +54 -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/GridTile.swift +44 -0
- package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
- 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++/SectionStyle.hpp +32 -2
- package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
- package/package.json +7 -2
- 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-nodes.ts +112 -2
- 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
|
@@ -154,6 +154,17 @@ final class CarPlayListItemFactory {
|
|
|
154
154
|
item.accessoryType = .disclosureIndicator
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// A resolved accessorySymbol replaces the derived accessory above
|
|
158
|
+
// (accessoryImage outranks accessoryType in the SDK). 'none' — the
|
|
159
|
+
// inheritance escape — keeps the derived behavior instead.
|
|
160
|
+
if let symbol = SectionPresentation.effectiveAccessorySymbol(style ?? track.style),
|
|
161
|
+
let accessory = UIImage(systemName: symbol)
|
|
162
|
+
{
|
|
163
|
+
// `accessoryImage` is readonly on CPListItem; post-init updates go
|
|
164
|
+
// through the setter (which also clears `accessoryType`, per the SDK).
|
|
165
|
+
item.setAccessoryImage(accessory)
|
|
166
|
+
}
|
|
167
|
+
|
|
157
168
|
// An unavailable track grays out and goes inert — list rows are the
|
|
158
169
|
// surface that CAN draw the fact, so it shows rather than hides
|
|
159
170
|
// (Track.disabled's rendering ladder).
|
|
@@ -202,6 +213,18 @@ final class CarPlayListItemFactory {
|
|
|
202
213
|
return CPListImageRowItem.maximumImageSize
|
|
203
214
|
}
|
|
204
215
|
|
|
216
|
+
/// Target artwork size for one tile. Cards read their own class maximums
|
|
217
|
+
/// ('background' selects the larger full-height target); every other
|
|
218
|
+
/// family uses the shared element size.
|
|
219
|
+
private static func tileImageSize(gridTile: GridTile?, cardImage: CardImage?) -> CGSize {
|
|
220
|
+
if #available(iOS 26.0, *), gridTile == .card {
|
|
221
|
+
return cardImage == .background
|
|
222
|
+
? CPListImageRowItemCardElement.maximumFullHeightImageSize
|
|
223
|
+
: CPListImageRowItemCardElement.maximumImageSize
|
|
224
|
+
}
|
|
225
|
+
return rowImageSize
|
|
226
|
+
}
|
|
227
|
+
|
|
205
228
|
/// Creates the image-row item rendering a tile-presented section: a single
|
|
206
229
|
/// truncating line (`.singleLineRow`) or a wrapping multi-line grid
|
|
207
230
|
/// (`.wrappingGrid` — constructed on iOS 26+ only, by `SectionPresentation`).
|
|
@@ -237,42 +260,90 @@ final class CarPlayListItemFactory {
|
|
|
237
260
|
// doesn't exist before iOS 26). nil = the legacy gridImages path.
|
|
238
261
|
// @MainActor makes the closure Sendable for the artwork completion hop.
|
|
239
262
|
var applyImage: (@MainActor (Int, UIImage) -> Void)?
|
|
263
|
+
// Resolved once per track, shared by element construction and the
|
|
264
|
+
// artwork loads below.
|
|
265
|
+
let resolvedStyles = tracks.map { StyleResolver.trackStyle(track: $0.style, section: style) }
|
|
240
266
|
if #available(iOS 26.0, *) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
267
|
+
// Builds a family's elements with the shared per-element tail: the
|
|
268
|
+
// disabled fact (isEnabled lives on the element base class) applied
|
|
269
|
+
// once, not per family.
|
|
270
|
+
func makeElements<Element: CPListImageRowItemElement>(
|
|
271
|
+
_ make: (Track, TrackStyle) -> Element,
|
|
272
|
+
) -> [Element] {
|
|
273
|
+
zip(tracks, resolvedStyles).map { track, resolved in
|
|
274
|
+
let element = make(track, resolved)
|
|
248
275
|
if track.disabled == true {
|
|
249
276
|
element.isEnabled = false
|
|
250
277
|
}
|
|
251
278
|
return element
|
|
252
279
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
280
|
+
}
|
|
281
|
+
// The image setter every element family shares (`image` is on the base
|
|
282
|
+
// class); bounds-checked against the elements the row was built with.
|
|
283
|
+
func imageApplier(_ elements: [some CPListImageRowItemElement]) -> @MainActor (Int, UIImage) -> Void {
|
|
284
|
+
{ index, image in
|
|
285
|
+
guard index < elements.count else { return }
|
|
286
|
+
elements[index].image = image
|
|
257
287
|
}
|
|
258
|
-
}
|
|
288
|
+
}
|
|
289
|
+
// The family is style-driven (`SectionPresentation.tileFamily`); both
|
|
290
|
+
// non-plain families take either wrap mode. Pre-26 the treatment drops
|
|
291
|
+
// and the layout survives (the legacy branch below).
|
|
292
|
+
switch SectionPresentation.tileFamily(for: style, singleLine: singleLine) {
|
|
293
|
+
case .cardElements:
|
|
294
|
+
// Cards have no shape and no accessory slot; their knobs are the
|
|
295
|
+
// tint and the image mode ('background' fills the card full-height
|
|
296
|
+
// and turns the tint into the color behind the labels).
|
|
297
|
+
let elements = makeElements { track, resolved in
|
|
298
|
+
CPListImageRowItemCardElement(
|
|
299
|
+
image: placeholder(),
|
|
300
|
+
showsImageFullHeight: resolved.cardImage == .background,
|
|
301
|
+
title: track.title,
|
|
302
|
+
subtitle: track.subtitle,
|
|
303
|
+
tintColor: resolved.cardTint.flatMap { UIColor(declaredHex: $0) },
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
item = CPListImageRowItem(
|
|
307
|
+
text: section.title, cardElements: elements, allowsMultipleLines: !singleLine,
|
|
308
|
+
)
|
|
309
|
+
applyImage = imageApplier(elements)
|
|
310
|
+
case .condensedElements:
|
|
311
|
+
let elements = makeElements { track, resolved in
|
|
312
|
+
CPListImageRowItemCondensedElement(
|
|
313
|
+
image: placeholder(),
|
|
314
|
+
imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
|
|
315
|
+
title: track.title,
|
|
316
|
+
subtitle: track.subtitle,
|
|
317
|
+
accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
item = CPListImageRowItem(
|
|
321
|
+
text: section.title, condensedElements: elements, allowsMultipleLines: !singleLine,
|
|
322
|
+
)
|
|
323
|
+
applyImage = imageApplier(elements)
|
|
324
|
+
case .rowElements:
|
|
325
|
+
// The only plain tile with a subtitle slot — the imageGridElements
|
|
326
|
+
// family has no equivalent, by SDK design.
|
|
327
|
+
let elements = makeElements { track, _ in
|
|
328
|
+
CPListImageRowItemRowElement(
|
|
329
|
+
image: placeholder(), title: track.title, subtitle: track.subtitle,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
item = CPListImageRowItem(text: section.title, elements: elements, allowsMultipleLines: false)
|
|
333
|
+
applyImage = imageApplier(elements)
|
|
334
|
+
case .imageGridElements:
|
|
259
335
|
// imageGridElements, never the title-less gridElements: an
|
|
260
336
|
// artwork-less track must keep its name on screen (ADR 0010).
|
|
261
|
-
let
|
|
262
|
-
|
|
263
|
-
image: placeholder(),
|
|
264
|
-
|
|
337
|
+
let elements = makeElements { track, resolved in
|
|
338
|
+
CPListImageRowItemImageGridElement(
|
|
339
|
+
image: placeholder(),
|
|
340
|
+
imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
|
|
341
|
+
title: track.title,
|
|
342
|
+
accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
|
|
265
343
|
)
|
|
266
|
-
if track.disabled == true {
|
|
267
|
-
element.isEnabled = false
|
|
268
|
-
}
|
|
269
|
-
return element
|
|
270
|
-
}
|
|
271
|
-
item = CPListImageRowItem(text: section.title, imageGridElements: gridElements, allowsMultipleLines: true)
|
|
272
|
-
applyImage = { index, image in
|
|
273
|
-
guard index < gridElements.count else { return }
|
|
274
|
-
gridElements[index].image = image
|
|
275
344
|
}
|
|
345
|
+
item = CPListImageRowItem(text: section.title, imageGridElements: elements, allowsMultipleLines: true)
|
|
346
|
+
applyImage = imageApplier(elements)
|
|
276
347
|
}
|
|
277
348
|
} else {
|
|
278
349
|
let placeholders = tracks.map { _ in placeholder() }
|
|
@@ -310,10 +381,11 @@ final class CarPlayListItemFactory {
|
|
|
310
381
|
for (index, track) in tracks.enumerated() {
|
|
311
382
|
guard track.artwork != nil || track.artworkSource != nil else { continue }
|
|
312
383
|
|
|
384
|
+
let resolved = resolvedStyles[index]
|
|
313
385
|
imageLoader?.loadArtwork(
|
|
314
386
|
for: track,
|
|
315
|
-
style:
|
|
316
|
-
size: Self.
|
|
387
|
+
style: resolved,
|
|
388
|
+
size: Self.tileImageSize(gridTile: style.gridTile, cardImage: resolved.cardImage),
|
|
317
389
|
) { [weak item] image in
|
|
318
390
|
Task { @MainActor in
|
|
319
391
|
guard let item, let image else { return }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
extension UIApplication {
|
|
4
|
+
/// The scene showing the app's own UI: the foreground-active one, else any
|
|
5
|
+
/// connected window scene. Nil when the app has no UI scene at all (running
|
|
6
|
+
/// headless for CarPlay), which callers must handle rather than assume.
|
|
7
|
+
///
|
|
8
|
+
/// Replaces the deprecated `UIScreen.main` / `keyWindow` idioms — and exists
|
|
9
|
+
/// once because the walk was previously written out at each use, so the two
|
|
10
|
+
/// copies had already drifted apart.
|
|
11
|
+
var activeWindowScene: UIWindowScene? {
|
|
12
|
+
let scenes = connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
13
|
+
return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// The scale to rasterise at for the app's own screen.
|
|
18
|
+
///
|
|
19
|
+
/// `UITraitCollection.current` rather than the deprecated `UIScreen.main.scale`
|
|
20
|
+
/// (also unavailable on visionOS, which the podspec targets): on the main actor
|
|
21
|
+
/// it already reflects the environment being rendered for. CarPlay surfaces do
|
|
22
|
+
/// NOT use this — they rasterise at `carTraitCollection.displayScale`, the car
|
|
23
|
+
/// screen's own scale.
|
|
24
|
+
@MainActor
|
|
25
|
+
var appDisplayScale: CGFloat {
|
|
26
|
+
UITraitCollection.current.displayScale
|
|
27
|
+
}
|
|
@@ -92,8 +92,14 @@ final class HttpClient: @unchecked Sendable {
|
|
|
92
92
|
httpRequest.headers?.forEach { name, value in
|
|
93
93
|
urlRequest.setValue(value, forHTTPHeaderField: name)
|
|
94
94
|
}
|
|
95
|
-
//
|
|
96
|
-
|
|
95
|
+
// An explicit `User-Agent` in `headers` wins, matching the two sibling paths
|
|
96
|
+
// (MediaLoader.buildAssetOptions and the artwork path in
|
|
97
|
+
// BrowserManager+URLResolution). `userAgent` carries a non-nil default, so
|
|
98
|
+
// overwriting unconditionally would replace a consumer's header with
|
|
99
|
+
// "react-native-audio-browser" whenever they set only the header.
|
|
100
|
+
if httpRequest.headers?["User-Agent"] == nil {
|
|
101
|
+
urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
|
|
102
|
+
}
|
|
97
103
|
|
|
98
104
|
// Add body for non-GET requests
|
|
99
105
|
switch httpRequest.method.uppercased() {
|
|
@@ -384,6 +384,19 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
deinit {
|
|
387
|
+
// Block-based notification observers are retained by the center until an
|
|
388
|
+
// explicit removeObserver — observer death isn't detected — so without this
|
|
389
|
+
// every JS reload strands another token + closure. They capture `self`
|
|
390
|
+
// weakly, so a stranded one no-ops rather than misbehaving; what accumulates
|
|
391
|
+
// is the tokens and a wasted main-queue dispatch per interruption / route
|
|
392
|
+
// change / media-services reset. (`volumeObservation` needs no counterpart:
|
|
393
|
+
// NSKeyValueObservation invalidates itself when this instance releases it.)
|
|
394
|
+
for observer in [interruptionObserver, routeChangeObserver, mediaServicesResetObserver]
|
|
395
|
+
.compactMap(\.self)
|
|
396
|
+
{
|
|
397
|
+
NotificationCenter.default.removeObserver(observer)
|
|
398
|
+
}
|
|
399
|
+
|
|
387
400
|
// Safety net: ensure the AVPlayer is stopped if this instance is deallocated.
|
|
388
401
|
let player = self.player
|
|
389
402
|
if Thread.isMainThread {
|
|
@@ -574,7 +587,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
574
587
|
static let invalidateAllSentinel = "__rnab_invalidate_all__"
|
|
575
588
|
|
|
576
589
|
public func notifyContentChanged(path: String) throws {
|
|
577
|
-
|
|
590
|
+
// Throws on a contextual path (matching Android's `require`) — the caller
|
|
591
|
+
// must pass the container path, or the invalidation silently hits nothing.
|
|
592
|
+
try onMainActor { try browserManager.invalidateContentCache(path) }
|
|
578
593
|
|
|
579
594
|
// Notify external controllers (CarPlay) that content changed
|
|
580
595
|
externalContentChangedEmitter.emit(path)
|
|
@@ -754,6 +769,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
754
769
|
guard let self else { return }
|
|
755
770
|
self.applyAudioSessionCategory = applyCategory
|
|
756
771
|
|
|
772
|
+
// A re-setup REPLACES the player, so the outgoing one has to be torn down
|
|
773
|
+
// and un-published first. `TrackPlayer` has no deinit: dropping the
|
|
774
|
+
// reference alone would leave its AVPlayer and MPNowPlayingSession alive.
|
|
775
|
+
// And `playerAndConfiguredBrowser` holds the old player strongly — its
|
|
776
|
+
// `resolve` is a one-shot, so without the reset the gate would keep
|
|
777
|
+
// handing the dead player to the CarPlay controller and Siri intents,
|
|
778
|
+
// which both await it. No-op on first setup.
|
|
779
|
+
player?.destroy()
|
|
780
|
+
playerAndConfiguredBrowser.reset()
|
|
781
|
+
|
|
757
782
|
// Create player with self as callbacks delegate
|
|
758
783
|
player = TrackPlayer(callbacks: self)
|
|
759
784
|
|
|
@@ -853,6 +878,13 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
853
878
|
if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
|
|
854
879
|
applyPendingPlayerState()
|
|
855
880
|
|
|
881
|
+
// Push the resolved event cadence unconditionally. `updateOptions` applies
|
|
882
|
+
// the interval only when it *differs* from the stored one, but the store
|
|
883
|
+
// outlives the player — so on a re-setup with unchanged options the new
|
|
884
|
+
// player would come up with no progress timer at all.
|
|
885
|
+
player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
|
|
886
|
+
player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
|
|
887
|
+
|
|
856
888
|
// Notify listeners that player is ready (e.g., CarPlay)
|
|
857
889
|
playerAndConfiguredBrowser.check()
|
|
858
890
|
}
|
|
@@ -917,8 +949,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
917
949
|
onMainActor { playerOptions.toOptions() }
|
|
918
950
|
}
|
|
919
951
|
|
|
952
|
+
/// Stored so a re-setup can re-apply it: unlike the other runtime options this
|
|
953
|
+
/// one has no home in `playerOptions`, and the player it was pushed to does not
|
|
954
|
+
/// survive `setupPlayer`.
|
|
955
|
+
@MainActor private var playbackIntervalEnabled = false
|
|
956
|
+
|
|
920
957
|
public func setPlaybackIntervalEnabled(enabled: Bool) {
|
|
921
|
-
onMainActor {
|
|
958
|
+
onMainActor {
|
|
959
|
+
playbackIntervalEnabled = enabled
|
|
960
|
+
player?.setPlaybackIntervalEnabled(enabled)
|
|
961
|
+
}
|
|
922
962
|
}
|
|
923
963
|
|
|
924
964
|
// MARK: - Playback Control
|
|
@@ -1132,7 +1172,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1132
1172
|
}
|
|
1133
1173
|
|
|
1134
1174
|
public func setActiveTrackFavorited(favorited: Bool) throws {
|
|
1135
|
-
onMainActor {
|
|
1175
|
+
try onMainActor {
|
|
1136
1176
|
guard let track = player?.currentTrack, let identity = track.identity else { return }
|
|
1137
1177
|
guard let index = player?.currentIndex, index >= 0 else { return }
|
|
1138
1178
|
// Optimistically reflect in the authoritative favorite set so the
|
|
@@ -1144,7 +1184,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1144
1184
|
// Hydration never overrides a caller-set `favorited`, so the queued
|
|
1145
1185
|
// track must itself carry the latest explicit value (as Android's tag
|
|
1146
1186
|
// swap and web's replaceTrack do).
|
|
1147
|
-
player?.replace(index, updatedTrack)
|
|
1187
|
+
try player?.replace(index, updatedTrack)
|
|
1148
1188
|
// The ONLY emit: a favorite toggle is an in-place mutation of the active
|
|
1149
1189
|
// track, not a transition — onPlaybackActiveTrackChanged stays
|
|
1150
1190
|
// transition-only, and the useActiveTrack / useQueue hooks subscribe to
|
|
@@ -1303,7 +1343,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1303
1343
|
// Classify a stall by connectivity so the formatter can show "No internet connection" vs
|
|
1304
1344
|
// "Reconnecting…" without a separate read. nil while not stalled.
|
|
1305
1345
|
let stalled: StallReason? =
|
|
1306
|
-
player.isStalled ? (networkMonitor.
|
|
1346
|
+
player.isStalled ? (networkMonitor.isOnline ? .buffering : .offline) : nil
|
|
1307
1347
|
player.nowPlayingUpdater.render(
|
|
1308
1348
|
track: track,
|
|
1309
1349
|
timedMetadata: latestTimedMetadata,
|
|
@@ -1319,7 +1359,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1319
1359
|
// MARK: - Network
|
|
1320
1360
|
|
|
1321
1361
|
public func getOnline() throws -> Bool {
|
|
1322
|
-
|
|
1362
|
+
// No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
|
|
1363
|
+
// cheap read on the JS thread instead of blocking on the main queue.
|
|
1364
|
+
networkMonitor.isOnline
|
|
1323
1365
|
}
|
|
1324
1366
|
|
|
1325
1367
|
// MARK: - System Volume
|
|
@@ -1499,11 +1541,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1499
1541
|
public func openOutputPicker() throws {
|
|
1500
1542
|
DispatchQueue.main.async { [weak self] in
|
|
1501
1543
|
guard let self else { return }
|
|
1502
|
-
guard let windowScene = UIApplication.shared.
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
|
1506
|
-
var topController = window.rootViewController
|
|
1544
|
+
guard let windowScene = UIApplication.shared.activeWindowScene,
|
|
1545
|
+
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
|
1546
|
+
var topController = window.rootViewController
|
|
1507
1547
|
else {
|
|
1508
1548
|
self.logger.error("openOutputPicker: no active window scene / root view controller")
|
|
1509
1549
|
return
|
|
@@ -1889,6 +1929,9 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
|
|
|
1889
1929
|
return
|
|
1890
1930
|
}
|
|
1891
1931
|
browser.logger.info("resume: cold — restoring persisted track")
|
|
1932
|
+
// Repeat / shuffle / speed come back before the queue is built — see
|
|
1933
|
+
// `restorePlaybackSettings(from:)` for why that ordering matters.
|
|
1934
|
+
player.restorePlaybackSettings(from: state)
|
|
1892
1935
|
let startMs = (track.live == true) ? nil : state.positionMs
|
|
1893
1936
|
// Match Android resume: re-expand the full queue from the track's contextual
|
|
1894
1937
|
// path (parent container → siblings + selected index). Fall back to the single
|
|
@@ -112,9 +112,64 @@
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
enum ImageShape: Equatable {
|
|
116
|
+
case circular
|
|
117
|
+
case roundedRectangle
|
|
118
|
+
|
|
119
|
+
init?(fromString string: String) {
|
|
120
|
+
switch string {
|
|
121
|
+
case "circular": self = .circular
|
|
122
|
+
case "rounded-rectangle": self = .roundedRectangle
|
|
123
|
+
default: return nil
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
enum GridTile: Equatable {
|
|
129
|
+
case plain
|
|
130
|
+
case card
|
|
131
|
+
case condensed
|
|
132
|
+
|
|
133
|
+
init?(fromString string: String) {
|
|
134
|
+
switch string {
|
|
135
|
+
case "plain": self = .plain
|
|
136
|
+
case "card": self = .card
|
|
137
|
+
case "condensed": self = .condensed
|
|
138
|
+
default: return nil
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Mirrors the generated extension (nitrogen emits `stringValue` beside
|
|
143
|
+
// `init?(fromString:)` for every string-backed enum).
|
|
144
|
+
var stringValue: String {
|
|
145
|
+
switch self {
|
|
146
|
+
case .plain: "plain"
|
|
147
|
+
case .card: "card"
|
|
148
|
+
case .condensed: "condensed"
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
enum CardImage: Equatable {
|
|
154
|
+
case normal
|
|
155
|
+
case background
|
|
156
|
+
|
|
157
|
+
init?(fromString string: String) {
|
|
158
|
+
switch string {
|
|
159
|
+
case "normal": self = .normal
|
|
160
|
+
case "background": self = .background
|
|
161
|
+
default: return nil
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
115
166
|
struct TrackStyle: Equatable {
|
|
116
167
|
var display: StyleDisplay?
|
|
117
168
|
var artworkRendering: ArtworkRendering?
|
|
169
|
+
var imageShape: ImageShape?
|
|
170
|
+
var accessorySymbol: String?
|
|
171
|
+
var cardTint: String?
|
|
172
|
+
var cardImage: CardImage?
|
|
118
173
|
}
|
|
119
174
|
|
|
120
175
|
// Member order matches the generated init (own properties before inherited
|
|
@@ -123,8 +178,13 @@
|
|
|
123
178
|
// `swift test` passes while the app build breaks on argument order.
|
|
124
179
|
struct SectionStyle: Equatable {
|
|
125
180
|
var gridWrap: Bool?
|
|
181
|
+
var gridTile: GridTile?
|
|
126
182
|
var display: StyleDisplay?
|
|
127
183
|
var artworkRendering: ArtworkRendering?
|
|
184
|
+
var imageShape: ImageShape?
|
|
185
|
+
var accessorySymbol: String?
|
|
186
|
+
var cardTint: String?
|
|
187
|
+
var cardImage: CardImage?
|
|
128
188
|
}
|
|
129
189
|
|
|
130
190
|
struct Section: Equatable {
|
|
@@ -126,14 +126,22 @@ public struct MediaIntentCriteria: Sendable {
|
|
|
126
126
|
|
|
127
127
|
/// Whether `query` is effectively the host app's own name. Case- and
|
|
128
128
|
/// diacritic-insensitive.
|
|
129
|
+
///
|
|
130
|
+
/// Whole-word subset match in either direction. Siri may deliver only part of
|
|
131
|
+
/// the name ("Name" for «App Name»), so a partial match must count — but a
|
|
132
|
+
/// substring match swallowed any query appearing inside the app name, making
|
|
133
|
+
/// one-word stations unreachable by voice.
|
|
129
134
|
private static func queryMatchesAppName(_ query: String, appName: String?) -> Bool {
|
|
130
135
|
guard let appName else { return false }
|
|
131
|
-
let
|
|
132
|
-
|
|
133
|
-
.
|
|
136
|
+
let tokens: (String) -> Set<String> = {
|
|
137
|
+
Set(
|
|
138
|
+
$0.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil)
|
|
139
|
+
.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
|
|
140
|
+
.map(String.init),
|
|
141
|
+
)
|
|
134
142
|
}
|
|
135
|
-
let q =
|
|
143
|
+
let q = tokens(query), a = tokens(appName)
|
|
136
144
|
guard !q.isEmpty, !a.isEmpty else { return false }
|
|
137
|
-
return
|
|
145
|
+
return q.isSubset(of: a) || a.isSubset(of: q)
|
|
138
146
|
}
|
|
139
147
|
}
|
|
@@ -38,19 +38,28 @@ final class MediaLoader {
|
|
|
38
38
|
func resolveAndLoad(src: String, track: Track? = nil) {
|
|
39
39
|
if let resolver = mediaUrlResolver {
|
|
40
40
|
mediaResolverTask?.cancel()
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// `[weak self]` like the sibling load tasks below: the resolver is
|
|
42
|
+
// consumer-supplied and `cancelAll()` cannot force an in-flight `await`
|
|
43
|
+
// to return, so a strong capture let a resolver that never settles pin
|
|
44
|
+
// this loader (which also retains the task) indefinitely.
|
|
45
|
+
// `Logger` is a value type, so capturing it directly keeps the logging
|
|
46
|
+
// off `self` — and `self` weak, so a consumer resolver that never settles
|
|
47
|
+
// can't pin the loader (`cancelAll()` can't force an in-flight `await` to
|
|
48
|
+
// return).
|
|
49
|
+
let logger = logger
|
|
50
|
+
mediaResolverTask = Task { [weak self] in
|
|
51
|
+
logger.debug("resolveAndLoad: starting resolution for \(src)")
|
|
43
52
|
|
|
44
53
|
guard !Task.isCancelled else {
|
|
45
|
-
|
|
54
|
+
logger.debug("resolveAndLoad: cancelled before start")
|
|
46
55
|
return
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
logger.debug("resolveAndLoad: calling resolver...")
|
|
50
59
|
let resolved = await resolver(src, track)
|
|
51
60
|
|
|
52
|
-
guard !Task.isCancelled else {
|
|
53
|
-
|
|
61
|
+
guard !Task.isCancelled, let self else {
|
|
62
|
+
logger.debug("resolveAndLoad: cancelled after resolver returned")
|
|
54
63
|
return
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -96,7 +105,7 @@ final class MediaLoader {
|
|
|
96
105
|
.availableMetadataFormats,
|
|
97
106
|
) else { return }
|
|
98
107
|
|
|
99
|
-
guard
|
|
108
|
+
guard isCurrent(pendingAsset) else { return }
|
|
100
109
|
|
|
101
110
|
if !commonMetadata.isEmpty {
|
|
102
111
|
delegate?.mediaLoaderDidReceiveCommonMetadata(commonMetadata)
|
|
@@ -104,19 +113,21 @@ final class MediaLoader {
|
|
|
104
113
|
|
|
105
114
|
if !chapterLocales.isEmpty {
|
|
106
115
|
for locale in chapterLocales {
|
|
107
|
-
guard
|
|
116
|
+
guard isCurrent(pendingAsset) else { return }
|
|
108
117
|
if let chapters = try? await pendingAsset.loadChapterMetadataGroups(
|
|
109
118
|
withTitleLocale: locale,
|
|
110
119
|
containingItemsWithCommonKeys: [],
|
|
111
120
|
) {
|
|
121
|
+
guard isCurrent(pendingAsset) else { return }
|
|
112
122
|
delegate?.mediaLoaderDidReceiveChapterMetadata(chapters)
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
} else {
|
|
116
126
|
let duration = await (try? pendingAsset.load(.duration)) ?? .zero
|
|
117
127
|
for format in metadataFormats {
|
|
118
|
-
guard
|
|
128
|
+
guard isCurrent(pendingAsset) else { return }
|
|
119
129
|
if let metadata = try? await pendingAsset.loadMetadata(for: format) {
|
|
130
|
+
guard isCurrent(pendingAsset) else { return }
|
|
120
131
|
let timeRange = CMTimeRange(
|
|
121
132
|
start: CMTime(seconds: 0, preferredTimescale: 1000),
|
|
122
133
|
end: duration,
|
|
@@ -159,6 +170,19 @@ final class MediaLoader {
|
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
|
|
173
|
+
/// Whether `candidate` is still the load this loader cares about — checked
|
|
174
|
+
/// after EVERY await, not just once before a loop.
|
|
175
|
+
///
|
|
176
|
+
/// Cancellation alone is not enough: it only sets a flag, and can't abort a
|
|
177
|
+
/// load already in flight inside AVFoundation (only `cancelLoading()` does,
|
|
178
|
+
/// and the track-change path deliberately doesn't call it — the old asset
|
|
179
|
+
/// still backs the playing item at that point). So a load that lands after
|
|
180
|
+
/// the track changed would otherwise attribute the old asset's metadata to
|
|
181
|
+
/// the newly loaded track.
|
|
182
|
+
private func isCurrent(_ candidate: AVURLAsset) -> Bool {
|
|
183
|
+
!Task.isCancelled && candidate == asset
|
|
184
|
+
}
|
|
185
|
+
|
|
162
186
|
func cancelAll() {
|
|
163
187
|
mediaResolverTask?.cancel()
|
|
164
188
|
mediaResolverTask = nil
|
|
@@ -30,17 +30,51 @@ final class NowPlayingUpdater {
|
|
|
30
30
|
let album: String?
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/// Dedupe baseline for `applyFields`. Only meaningful while it describes the
|
|
34
|
+
/// controller's current info dict, so the two are cleared together in
|
|
35
|
+
/// `clearNowPlaying()` — never separately.
|
|
33
36
|
private var lastPublished: Published?
|
|
34
37
|
|
|
38
|
+
/// Empties the now-playing surface and the dedupe baseline that describes it.
|
|
39
|
+
/// One call, because clearing the dict without the baseline makes the next
|
|
40
|
+
/// publish of the SAME track dedupe against a dict that no longer holds it —
|
|
41
|
+
/// artwork lands (never deduped) but the text doesn't, and `onChanged`
|
|
42
|
+
/// never fires.
|
|
43
|
+
func clearNowPlaying() {
|
|
44
|
+
nowPlayingInfoController.clear()
|
|
45
|
+
lastPublished = nil
|
|
46
|
+
formattedTrackId = nil
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
/// Bumped on every `render`; an async formatter result applies only if its render is still the
|
|
36
50
|
/// latest (latest-render-wins — drops a result whose track skipped or whose state moved on while
|
|
37
51
|
/// the formatter was in flight).
|
|
38
52
|
private var renderGeneration: UInt = 0
|
|
39
53
|
|
|
54
|
+
/// The track whose *formatted* line is currently on the surface. While this
|
|
55
|
+
/// matches the rendering track, `render` skips its interim default write — see
|
|
56
|
+
/// the comment at that site. Cleared whenever the surface stops showing a
|
|
57
|
+
/// formatter result (a flash overwrites it, `clearNowPlaying` empties it).
|
|
58
|
+
private var formattedTrackId: String?
|
|
59
|
+
|
|
40
60
|
init(nowPlayingInfoController: NowPlayingInfoController) {
|
|
41
61
|
self.nowPlayingInfoController = nowPlayingInfoController
|
|
42
62
|
}
|
|
43
63
|
|
|
64
|
+
/// Pixel width to request now-playing artwork at: the active scene's screen
|
|
65
|
+
/// width in pixels, capped at `maxArtworkPixelWidth`. With no foreground
|
|
66
|
+
/// scene (headless CarPlay) the cap stands in — every current device exceeds
|
|
67
|
+
/// it anyway, and this only sizes a CDN request.
|
|
68
|
+
private static var artworkPixelWidth: CGFloat {
|
|
69
|
+
guard let screen = UIApplication.shared.activeWindowScene?.screen else {
|
|
70
|
+
return maxArtworkPixelWidth
|
|
71
|
+
}
|
|
72
|
+
return min(screen.bounds.width * screen.scale, maxArtworkPixelWidth)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Mirrored by the Kotlin twin's `artworkSizePx`.
|
|
76
|
+
private static let maxArtworkPixelWidth: CGFloat = 1200
|
|
77
|
+
|
|
44
78
|
/// Publishes the static, per-track fields + artwork on a track change. The title/artist/album
|
|
45
79
|
/// lines are owned by `render` (routed through `applyFields` here so it dedupes against later
|
|
46
80
|
/// renders).
|
|
@@ -75,6 +109,8 @@ final class NowPlayingUpdater {
|
|
|
75
109
|
let defaultSecondary = override?.artist ?? track.artist
|
|
76
110
|
let defaultAlbum = override?.album ?? track.album
|
|
77
111
|
|
|
112
|
+
let trackId = track.identity ?? track.path
|
|
113
|
+
|
|
78
114
|
if let flash {
|
|
79
115
|
applyFields(
|
|
80
116
|
track: track,
|
|
@@ -82,10 +118,22 @@ final class NowPlayingUpdater {
|
|
|
82
118
|
artist: flash.artist ?? defaultSecondary,
|
|
83
119
|
album: flash.album ?? defaultAlbum,
|
|
84
120
|
)
|
|
121
|
+
// The flash replaced the formatter's line, so the surface no longer shows
|
|
122
|
+
// one: the render that follows the flash reverting must publish the default
|
|
123
|
+
// immediately rather than sit on stale flash text awaiting the formatter.
|
|
124
|
+
formattedTrackId = nil
|
|
85
125
|
return
|
|
86
126
|
}
|
|
87
127
|
|
|
88
|
-
|
|
128
|
+
// Publish the raw default only while no formatter result is on screen for this
|
|
129
|
+
// track. `render` runs on EVERY playback transition (state change, playing/
|
|
130
|
+
// buffering flip, timed-metadata tick, connectivity change); writing the
|
|
131
|
+
// default each time would flash the track title over the formatter's line
|
|
132
|
+
// until the async result lands, since the two differ by construction on a live
|
|
133
|
+
// stream (station name vs. song line).
|
|
134
|
+
if formatter == nil || formattedTrackId != trackId {
|
|
135
|
+
applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
|
|
136
|
+
}
|
|
89
137
|
|
|
90
138
|
guard let formatter else { return }
|
|
91
139
|
let params = FormatNowPlayingParams(
|
|
@@ -111,6 +159,9 @@ final class NowPlayingUpdater {
|
|
|
111
159
|
artist: formatted.artist ?? defaultSecondary,
|
|
112
160
|
album: formatted.album ?? defaultAlbum,
|
|
113
161
|
)
|
|
162
|
+
// A formatter result now owns the surface for this track, so later
|
|
163
|
+
// renders skip the interim default write.
|
|
164
|
+
self.formattedTrackId = trackId
|
|
114
165
|
}
|
|
115
166
|
}
|
|
116
167
|
.catch { @Sendable [weak self] error in
|
|
@@ -152,10 +203,7 @@ final class NowPlayingUpdater {
|
|
|
152
203
|
let artworkUrl = track.artworkSource?.uri ?? track.artwork?.url
|
|
153
204
|
logger.debug("loadArtwork: \(track.title), artworkUrl: \(artworkUrl ?? "nil")")
|
|
154
205
|
|
|
155
|
-
|
|
156
|
-
let screenScale = UIScreen.main.scale
|
|
157
|
-
let screenWidth = UIScreen.main.bounds.width * screenScale
|
|
158
|
-
let artworkSize = min(screenWidth, 1200)
|
|
206
|
+
let artworkSize = Self.artworkPixelWidth
|
|
159
207
|
let nowPlayingSize = ImageContext(width: artworkSize, height: artworkSize)
|
|
160
208
|
|
|
161
209
|
artworkLoadTask?.cancel()
|