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
|
@@ -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 }
|
|
@@ -9,14 +9,12 @@ extension Section {
|
|
|
9
9
|
/// `children` is single-optional since it is non-optional on Section.
|
|
10
10
|
func copying(
|
|
11
11
|
title: String?? = nil,
|
|
12
|
-
subtitle: String?? = nil,
|
|
13
12
|
style: SectionStyle?? = nil,
|
|
14
13
|
path: String?? = nil,
|
|
15
14
|
children: [Track]? = nil,
|
|
16
15
|
) -> Section {
|
|
17
16
|
Section(
|
|
18
17
|
title: title ?? self.title,
|
|
19
|
-
subtitle: subtitle ?? self.subtitle,
|
|
20
18
|
style: style ?? self.style,
|
|
21
19
|
path: path ?? self.path,
|
|
22
20
|
children: children ?? self.children,
|
|
@@ -26,6 +24,6 @@ extension Section {
|
|
|
26
24
|
/// One untitled section — the canonical wrap of a flat track list
|
|
27
25
|
/// (`children` authoring sugar, search results — ADR 0010).
|
|
28
26
|
static func untitled(_ children: [Track]) -> Section {
|
|
29
|
-
Section(title: nil,
|
|
27
|
+
Section(title: nil, style: nil, path: nil, children: children)
|
|
30
28
|
}
|
|
31
29
|
}
|
|
@@ -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() {
|
|
@@ -186,7 +186,8 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
186
186
|
public var configuration: NativeBrowserConfiguration = .init(
|
|
187
187
|
path: nil, request: nil, requestResolver: nil, browse: nil, browseResolver: nil, media: nil, artwork: nil, nowPlayingArtwork: nil, routes: nil,
|
|
188
188
|
singleTrack: nil, handleTrackLoad: nil,
|
|
189
|
-
androidControllerOfflineError: nil,
|
|
189
|
+
androidControllerOfflineError: nil, viewMoreTitle: nil, carPlayLoadingTitle: nil, resolveAlbumPath: nil,
|
|
190
|
+
formatNavigationError: nil,
|
|
190
191
|
) {
|
|
191
192
|
didSet {
|
|
192
193
|
// Copied here, on the setter's thread: the Task below outlives this call,
|
|
@@ -384,6 +385,19 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
384
385
|
}
|
|
385
386
|
|
|
386
387
|
deinit {
|
|
388
|
+
// Block-based notification observers are retained by the center until an
|
|
389
|
+
// explicit removeObserver — observer death isn't detected — so without this
|
|
390
|
+
// every JS reload strands another token + closure. They capture `self`
|
|
391
|
+
// weakly, so a stranded one no-ops rather than misbehaving; what accumulates
|
|
392
|
+
// is the tokens and a wasted main-queue dispatch per interruption / route
|
|
393
|
+
// change / media-services reset. (`volumeObservation` needs no counterpart:
|
|
394
|
+
// NSKeyValueObservation invalidates itself when this instance releases it.)
|
|
395
|
+
for observer in [interruptionObserver, routeChangeObserver, mediaServicesResetObserver]
|
|
396
|
+
.compactMap(\.self)
|
|
397
|
+
{
|
|
398
|
+
NotificationCenter.default.removeObserver(observer)
|
|
399
|
+
}
|
|
400
|
+
|
|
387
401
|
// Safety net: ensure the AVPlayer is stopped if this instance is deallocated.
|
|
388
402
|
let player = self.player
|
|
389
403
|
if Thread.isMainThread {
|
|
@@ -574,7 +588,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
574
588
|
static let invalidateAllSentinel = "__rnab_invalidate_all__"
|
|
575
589
|
|
|
576
590
|
public func notifyContentChanged(path: String) throws {
|
|
577
|
-
|
|
591
|
+
// Throws on a contextual path (matching Android's `require`) — the caller
|
|
592
|
+
// must pass the container path, or the invalidation silently hits nothing.
|
|
593
|
+
try onMainActor { try browserManager.invalidateContentCache(path) }
|
|
578
594
|
|
|
579
595
|
// Notify external controllers (CarPlay) that content changed
|
|
580
596
|
externalContentChangedEmitter.emit(path)
|
|
@@ -754,6 +770,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
754
770
|
guard let self else { return }
|
|
755
771
|
self.applyAudioSessionCategory = applyCategory
|
|
756
772
|
|
|
773
|
+
// A re-setup REPLACES the player, so the outgoing one has to be torn down
|
|
774
|
+
// and un-published first. `TrackPlayer` has no deinit: dropping the
|
|
775
|
+
// reference alone would leave its AVPlayer and MPNowPlayingSession alive.
|
|
776
|
+
// And `playerAndConfiguredBrowser` holds the old player strongly — its
|
|
777
|
+
// `resolve` is a one-shot, so without the reset the gate would keep
|
|
778
|
+
// handing the dead player to the CarPlay controller and Siri intents,
|
|
779
|
+
// which both await it. No-op on first setup.
|
|
780
|
+
player?.destroy()
|
|
781
|
+
playerAndConfiguredBrowser.reset()
|
|
782
|
+
|
|
757
783
|
// Create player with self as callbacks delegate
|
|
758
784
|
player = TrackPlayer(callbacks: self)
|
|
759
785
|
|
|
@@ -853,6 +879,13 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
853
879
|
if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
|
|
854
880
|
applyPendingPlayerState()
|
|
855
881
|
|
|
882
|
+
// Push the resolved event cadence unconditionally. `updateOptions` applies
|
|
883
|
+
// the interval only when it *differs* from the stored one, but the store
|
|
884
|
+
// outlives the player — so on a re-setup with unchanged options the new
|
|
885
|
+
// player would come up with no progress timer at all.
|
|
886
|
+
player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
|
|
887
|
+
player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
|
|
888
|
+
|
|
856
889
|
// Notify listeners that player is ready (e.g., CarPlay)
|
|
857
890
|
playerAndConfiguredBrowser.check()
|
|
858
891
|
}
|
|
@@ -917,8 +950,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
917
950
|
onMainActor { playerOptions.toOptions() }
|
|
918
951
|
}
|
|
919
952
|
|
|
953
|
+
/// Stored so a re-setup can re-apply it: unlike the other runtime options this
|
|
954
|
+
/// one has no home in `playerOptions`, and the player it was pushed to does not
|
|
955
|
+
/// survive `setupPlayer`.
|
|
956
|
+
@MainActor private var playbackIntervalEnabled = false
|
|
957
|
+
|
|
920
958
|
public func setPlaybackIntervalEnabled(enabled: Bool) {
|
|
921
|
-
onMainActor {
|
|
959
|
+
onMainActor {
|
|
960
|
+
playbackIntervalEnabled = enabled
|
|
961
|
+
player?.setPlaybackIntervalEnabled(enabled)
|
|
962
|
+
}
|
|
922
963
|
}
|
|
923
964
|
|
|
924
965
|
// MARK: - Playback Control
|
|
@@ -1132,7 +1173,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1132
1173
|
}
|
|
1133
1174
|
|
|
1134
1175
|
public func setActiveTrackFavorited(favorited: Bool) throws {
|
|
1135
|
-
onMainActor {
|
|
1176
|
+
try onMainActor {
|
|
1136
1177
|
guard let track = player?.currentTrack, let identity = track.identity else { return }
|
|
1137
1178
|
guard let index = player?.currentIndex, index >= 0 else { return }
|
|
1138
1179
|
// Optimistically reflect in the authoritative favorite set so the
|
|
@@ -1144,7 +1185,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1144
1185
|
// Hydration never overrides a caller-set `favorited`, so the queued
|
|
1145
1186
|
// track must itself carry the latest explicit value (as Android's tag
|
|
1146
1187
|
// swap and web's replaceTrack do).
|
|
1147
|
-
player?.replace(index, updatedTrack)
|
|
1188
|
+
try player?.replace(index, updatedTrack)
|
|
1148
1189
|
// The ONLY emit: a favorite toggle is an in-place mutation of the active
|
|
1149
1190
|
// track, not a transition — onPlaybackActiveTrackChanged stays
|
|
1150
1191
|
// transition-only, and the useActiveTrack / useQueue hooks subscribe to
|
|
@@ -1303,7 +1344,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1303
1344
|
// Classify a stall by connectivity so the formatter can show "No internet connection" vs
|
|
1304
1345
|
// "Reconnecting…" without a separate read. nil while not stalled.
|
|
1305
1346
|
let stalled: StallReason? =
|
|
1306
|
-
player.isStalled ? (networkMonitor.
|
|
1347
|
+
player.isStalled ? (networkMonitor.isOnline ? .buffering : .offline) : nil
|
|
1307
1348
|
player.nowPlayingUpdater.render(
|
|
1308
1349
|
track: track,
|
|
1309
1350
|
timedMetadata: latestTimedMetadata,
|
|
@@ -1319,7 +1360,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1319
1360
|
// MARK: - Network
|
|
1320
1361
|
|
|
1321
1362
|
public func getOnline() throws -> Bool {
|
|
1322
|
-
|
|
1363
|
+
// No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
|
|
1364
|
+
// cheap read on the JS thread instead of blocking on the main queue.
|
|
1365
|
+
networkMonitor.isOnline
|
|
1323
1366
|
}
|
|
1324
1367
|
|
|
1325
1368
|
// MARK: - System Volume
|
|
@@ -1499,11 +1542,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1499
1542
|
public func openOutputPicker() throws {
|
|
1500
1543
|
DispatchQueue.main.async { [weak self] in
|
|
1501
1544
|
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
|
|
1545
|
+
guard let windowScene = UIApplication.shared.activeWindowScene,
|
|
1546
|
+
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
|
1547
|
+
var topController = window.rootViewController
|
|
1507
1548
|
else {
|
|
1508
1549
|
self.logger.error("openOutputPicker: no active window scene / root view controller")
|
|
1509
1550
|
return
|
|
@@ -1889,6 +1930,9 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
|
|
|
1889
1930
|
return
|
|
1890
1931
|
}
|
|
1891
1932
|
browser.logger.info("resume: cold — restoring persisted track")
|
|
1933
|
+
// Repeat / shuffle / speed come back before the queue is built — see
|
|
1934
|
+
// `restorePlaybackSettings(from:)` for why that ordering matters.
|
|
1935
|
+
player.restorePlaybackSettings(from: state)
|
|
1892
1936
|
let startMs = (track.live == true) ? nil : state.positionMs
|
|
1893
1937
|
// Match Android resume: re-expand the full queue from the track's contextual
|
|
1894
1938
|
// 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,13 +178,17 @@
|
|
|
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 {
|
|
131
191
|
var title: String?
|
|
132
|
-
var subtitle: String?
|
|
133
192
|
var style: SectionStyle?
|
|
134
193
|
var path: String?
|
|
135
194
|
var children: [Track]
|
|
@@ -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
|