react-native-audio-browser 0.2.0-next.30 → 0.2.0-next.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ios/AVPlayerEngine.swift +4 -8
- package/ios/Browser/JsonModels.swift +93 -182
- package/ios/HybridAudioBrowser+Player.swift +3 -3
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +3 -4
- package/ios/Model/NitroTypeStubs.swift +13 -0
- package/ios/Player/AudioSessionController.swift +22 -8
- package/ios/Player/ErrorStatus.swift +25 -2
- package/ios/Player/MediaLoader.swift +23 -71
- package/ios/Player/NowPlayingLines.swift +36 -0
- package/ios/Player/NowPlayingUpdater.swift +27 -85
- package/ios/Player/Player.swift +108 -261
- package/ios/Player/RetryLoop.swift +26 -0
- package/ios/Player/SleepTimerManager.swift +13 -15
- package/ios/Player/VolumeFader.swift +5 -0
- package/ios/Tests/ErrorStatusTests.swift +50 -0
- package/ios/Tests/JsonModelsTests.swift +30 -0
- package/ios/Tests/MediaLoaderTests.swift +26 -21
- package/ios/Tests/NowPlayingLinesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +19 -3
- package/ios/Tests/PlayerTests.swift +81 -74
- package/ios/Tests/RetryLoopTests.swift +35 -0
- package/ios/Tests/SleepTimerManagerTests.swift +65 -41
- package/ios/Tests/Support/TestTypes.swift +2 -2
- package/package.json +1 -1
package/ios/AVPlayerEngine.swift
CHANGED
|
@@ -17,7 +17,7 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
17
17
|
let nowPlayingInfoController: NowPlayingInfoController
|
|
18
18
|
let nowPlayingUpdater: NowPlayingUpdater
|
|
19
19
|
let remoteCommandController: RemoteCommandController
|
|
20
|
-
private let mediaLoader
|
|
20
|
+
private let mediaLoader: MediaLoader
|
|
21
21
|
private let silentAudioPrimer = SilentAudioPrimer()
|
|
22
22
|
|
|
23
23
|
private var avPlayer = AVPlayer()
|
|
@@ -40,11 +40,11 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
40
40
|
self.nowPlayingInfoController = nowPlayingInfoController
|
|
41
41
|
nowPlayingUpdater = NowPlayingUpdater(nowPlayingInfoController: nowPlayingInfoController)
|
|
42
42
|
remoteCommandController = RemoteCommandController(callbacks: callbacks)
|
|
43
|
-
|
|
44
|
-
mediaLoader.onEvent = { [weak self] in self?.mediaLoaderDidEmit($0) }
|
|
45
|
-
mediaLoader.mediaUrlResolver = { [weak resolver] src, track in
|
|
43
|
+
mediaLoader = MediaLoader { [weak resolver] src, track in
|
|
46
44
|
await resolver?.resolveMediaUrl(src, track: track) ?? MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
47
45
|
}
|
|
46
|
+
|
|
47
|
+
mediaLoader.onEvent = { [weak self] in self?.mediaLoaderDidEmit($0) }
|
|
48
48
|
nowPlayingUpdater.artworkUrlResolver = { [weak resolver] track, imageContext in
|
|
49
49
|
await resolver?.resolveNowPlayingArtwork(track: track, imageContext: imageContext)
|
|
50
50
|
}
|
|
@@ -318,10 +318,6 @@ final class AVPlayerEngine: PlayerEngine {
|
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
extension Player: AudioSessionPlayer {}
|
|
322
|
-
|
|
323
|
-
extension Player: NowPlayingSource {}
|
|
324
|
-
|
|
325
321
|
/// Resolves the URLs the player fetches.
|
|
326
322
|
@MainActor
|
|
327
323
|
protocol MediaResolving: AnyObject {
|
|
@@ -258,11 +258,9 @@ struct JsonTrackRequest: Codable {
|
|
|
258
258
|
let headers: [String: String]?
|
|
259
259
|
let query: [String: String]?
|
|
260
260
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
#endif
|
|
261
|
+
func toNitro() -> TrackRequest {
|
|
262
|
+
TrackRequest(userAgent: userAgent, headers: headers, query: query)
|
|
263
|
+
}
|
|
266
264
|
}
|
|
267
265
|
|
|
268
266
|
/// JSON model for resolved track (container with children).
|
|
@@ -391,191 +389,104 @@ struct JsonTrack: Codable {
|
|
|
391
389
|
|
|
392
390
|
// MARK: - Snapshot live Nitro types back to JSON models (for persistence)
|
|
393
391
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
init(from request: TrackRequest) {
|
|
399
|
-
self.init(userAgent: request.userAgent, headers: request.headers, query: request.query)
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
extension JsonTrack {
|
|
404
|
-
/// Snapshot the persistable subset of a live Track (inverse of `toNitro()`).
|
|
405
|
-
/// The track's own declared style block and `disabled` persist (ADR 0011);
|
|
406
|
-
/// section/page-inherited values are never stamped onto tracks and
|
|
407
|
-
/// re-resolve at render, exactly as they do live.
|
|
408
|
-
init(from track: Track) {
|
|
409
|
-
self.init(
|
|
410
|
-
id: track.id,
|
|
411
|
-
path: track.path,
|
|
412
|
-
title: track.title,
|
|
413
|
-
subtitle: track.subtitle,
|
|
414
|
-
artwork: JsonArtwork(track.artwork),
|
|
415
|
-
artist: track.artist,
|
|
416
|
-
albumPath: track.albumPath,
|
|
417
|
-
album: track.album,
|
|
418
|
-
description: track.description,
|
|
419
|
-
genre: track.genre,
|
|
420
|
-
duration: track.duration,
|
|
421
|
-
src: track.src,
|
|
422
|
-
request: track.request.map(JsonTrackRequest.init(from:)),
|
|
423
|
-
style: JsonStyle(track.style),
|
|
424
|
-
disabled: track.disabled,
|
|
425
|
-
live: track.live,
|
|
426
|
-
icyCharset: track.icyCharset,
|
|
427
|
-
)
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
#else
|
|
432
|
-
|
|
433
|
-
extension JsonTrackRequest {
|
|
434
|
-
/// Snapshot a live TrackRequest back to its JSON model (SPM test-target stub).
|
|
435
|
-
init(from request: TrackRequest) {
|
|
436
|
-
self.init(userAgent: request.userAgent, headers: request.headers, query: request.query)
|
|
437
|
-
}
|
|
392
|
+
extension JsonTrackRequest {
|
|
393
|
+
/// Snapshot a live TrackRequest back to its JSON model.
|
|
394
|
+
init(from request: TrackRequest) {
|
|
395
|
+
self.init(userAgent: request.userAgent, headers: request.headers, query: request.query)
|
|
438
396
|
}
|
|
397
|
+
}
|
|
439
398
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
399
|
+
extension JsonTrack {
|
|
400
|
+
/// Snapshot the persistable subset of a live Track (inverse of `toNitro()`).
|
|
401
|
+
/// The track's own declared style block and `disabled` persist (ADR 0011);
|
|
402
|
+
/// section/page-inherited values are never stamped onto tracks and
|
|
403
|
+
/// re-resolve at render, exactly as they do live.
|
|
404
|
+
init(from track: Track) {
|
|
405
|
+
self.init(
|
|
406
|
+
id: track.id,
|
|
407
|
+
path: track.path,
|
|
408
|
+
title: track.title,
|
|
409
|
+
subtitle: track.subtitle,
|
|
410
|
+
artwork: JsonArtwork(track.artwork),
|
|
411
|
+
artist: track.artist,
|
|
412
|
+
albumPath: track.albumPath,
|
|
413
|
+
album: track.album,
|
|
414
|
+
description: track.description,
|
|
415
|
+
genre: track.genre,
|
|
416
|
+
duration: track.duration,
|
|
417
|
+
src: track.src,
|
|
418
|
+
request: track.request.map(JsonTrackRequest.init(from:)),
|
|
419
|
+
style: JsonStyle(track.style),
|
|
420
|
+
disabled: track.disabled,
|
|
421
|
+
live: track.live,
|
|
422
|
+
icyCharset: track.icyCharset,
|
|
423
|
+
)
|
|
460
424
|
}
|
|
461
|
-
|
|
462
|
-
#endif
|
|
425
|
+
}
|
|
463
426
|
|
|
464
427
|
// MARK: - Convert JSON models to Nitro types
|
|
465
428
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
children: children.map { $0.toNitro() },
|
|
475
|
-
)
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
extension JsonResolvedTrack {
|
|
480
|
-
func toNitro() -> ResolvedTrack {
|
|
481
|
-
ResolvedTrack(
|
|
482
|
-
path: path,
|
|
483
|
-
style: style?.toSectionStyle(),
|
|
484
|
-
sections: sections?.map { $0.toNitro() },
|
|
485
|
-
children: children?.map { $0.toNitro() },
|
|
486
|
-
carPlaySiriListButton: carPlaySiriListButton.flatMap { CarPlaySiriListButtonPosition(fromString: $0) },
|
|
487
|
-
id: id,
|
|
488
|
-
src: src,
|
|
489
|
-
artwork: artwork?.toNitro(),
|
|
490
|
-
artworkSource: nil, request: nil,
|
|
491
|
-
title: title,
|
|
492
|
-
subtitle: subtitle,
|
|
493
|
-
artist: artist,
|
|
494
|
-
albumPath: albumPath,
|
|
495
|
-
album: album,
|
|
496
|
-
description: description,
|
|
497
|
-
genre: genre,
|
|
498
|
-
duration: duration,
|
|
499
|
-
disabled: disabled,
|
|
500
|
-
favorited: nil,
|
|
501
|
-
live: live,
|
|
502
|
-
icyCharset: icyCharset,
|
|
503
|
-
)
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
extension JsonTrack {
|
|
508
|
-
func toNitro() -> Track {
|
|
509
|
-
Track(
|
|
510
|
-
id: id,
|
|
511
|
-
path: path,
|
|
512
|
-
src: src,
|
|
513
|
-
artwork: artwork?.toNitro(),
|
|
514
|
-
artworkSource: nil,
|
|
515
|
-
request: request?.toNitro(),
|
|
516
|
-
title: title,
|
|
517
|
-
subtitle: subtitle,
|
|
518
|
-
artist: artist,
|
|
519
|
-
albumPath: albumPath,
|
|
520
|
-
album: album,
|
|
521
|
-
description: description,
|
|
522
|
-
genre: genre,
|
|
523
|
-
duration: duration,
|
|
524
|
-
style: style?.toTrackStyle(),
|
|
525
|
-
disabled: disabled,
|
|
526
|
-
favorited: nil,
|
|
527
|
-
live: live,
|
|
528
|
-
icyCharset: icyCharset,
|
|
529
|
-
)
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
#else
|
|
534
|
-
|
|
535
|
-
// Test-only path: construct minimal stubs for SPM test builds.
|
|
536
|
-
extension JsonTrack {
|
|
537
|
-
func toNitro() -> Track {
|
|
538
|
-
Track(
|
|
539
|
-
id: id ?? "",
|
|
540
|
-
path: path,
|
|
541
|
-
src: src,
|
|
542
|
-
artwork: artwork?.toNitro(),
|
|
543
|
-
request: request.map { TrackRequest(userAgent: $0.userAgent, headers: $0.headers, query: $0.query) },
|
|
544
|
-
title: title,
|
|
545
|
-
artist: artist,
|
|
546
|
-
albumPath: albumPath,
|
|
547
|
-
album: album,
|
|
548
|
-
style: style?.toTrackStyle(),
|
|
549
|
-
disabled: disabled,
|
|
550
|
-
live: live,
|
|
551
|
-
icyCharset: icyCharset,
|
|
552
|
-
)
|
|
553
|
-
}
|
|
429
|
+
extension JsonSection {
|
|
430
|
+
func toNitro() -> Section {
|
|
431
|
+
Section(
|
|
432
|
+
title: title,
|
|
433
|
+
style: style?.toSectionStyle(),
|
|
434
|
+
path: path,
|
|
435
|
+
children: children.map { $0.toNitro() },
|
|
436
|
+
)
|
|
554
437
|
}
|
|
438
|
+
}
|
|
555
439
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
)
|
|
564
|
-
|
|
440
|
+
extension JsonResolvedTrack {
|
|
441
|
+
func toNitro() -> ResolvedTrack {
|
|
442
|
+
ResolvedTrack(
|
|
443
|
+
path: path,
|
|
444
|
+
style: style?.toSectionStyle(),
|
|
445
|
+
sections: sections?.map { $0.toNitro() },
|
|
446
|
+
children: children?.map { $0.toNitro() },
|
|
447
|
+
carPlaySiriListButton: carPlaySiriListButton.flatMap { CarPlaySiriListButtonPosition(fromString: $0) },
|
|
448
|
+
id: id,
|
|
449
|
+
src: src,
|
|
450
|
+
artwork: artwork?.toNitro(),
|
|
451
|
+
artworkSource: nil, request: nil,
|
|
452
|
+
title: title,
|
|
453
|
+
subtitle: subtitle,
|
|
454
|
+
artist: artist,
|
|
455
|
+
albumPath: albumPath,
|
|
456
|
+
album: album,
|
|
457
|
+
description: description,
|
|
458
|
+
genre: genre,
|
|
459
|
+
duration: duration,
|
|
460
|
+
disabled: disabled,
|
|
461
|
+
favorited: nil,
|
|
462
|
+
live: live,
|
|
463
|
+
icyCharset: icyCharset,
|
|
464
|
+
)
|
|
565
465
|
}
|
|
466
|
+
}
|
|
566
467
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
468
|
+
extension JsonTrack {
|
|
469
|
+
func toNitro() -> Track {
|
|
470
|
+
Track(
|
|
471
|
+
id: id,
|
|
472
|
+
path: path,
|
|
473
|
+
src: src,
|
|
474
|
+
artwork: artwork?.toNitro(),
|
|
475
|
+
artworkSource: nil,
|
|
476
|
+
request: request?.toNitro(),
|
|
477
|
+
title: title,
|
|
478
|
+
subtitle: subtitle,
|
|
479
|
+
artist: artist,
|
|
480
|
+
albumPath: albumPath,
|
|
481
|
+
album: album,
|
|
482
|
+
description: description,
|
|
483
|
+
genre: genre,
|
|
484
|
+
duration: duration,
|
|
485
|
+
style: style?.toTrackStyle(),
|
|
486
|
+
disabled: disabled,
|
|
487
|
+
favorited: nil,
|
|
488
|
+
live: live,
|
|
489
|
+
icyCharset: icyCharset,
|
|
490
|
+
)
|
|
579
491
|
}
|
|
580
|
-
|
|
581
|
-
#endif
|
|
492
|
+
}
|
|
@@ -131,7 +131,7 @@ public extension HybridAudioBrowser {
|
|
|
131
131
|
|
|
132
132
|
func getSleepTimer() throws -> SleepTimer {
|
|
133
133
|
onMainActor {
|
|
134
|
-
player?.
|
|
134
|
+
player?.sleepTimerManager.get() ?? .first(NullType.null)
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -227,7 +227,7 @@ public extension HybridAudioBrowser {
|
|
|
227
227
|
browserManager.setFavorited(identity: identity, favorited: favorited)
|
|
228
228
|
// Back in the queue carrying the flag, since hydration never overrides a track's own value.
|
|
229
229
|
let updated = track.copying(favorited: favorited)
|
|
230
|
-
try player.replace(player.currentIndex, updated)
|
|
230
|
+
try player.replace(player.queue.currentIndex, updated)
|
|
231
231
|
// The only emit: a favorite toggle mutates the active track in place, so
|
|
232
232
|
// onPlaybackActiveTrackChanged stays transition-only.
|
|
233
233
|
favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updated, favorited: favorited))
|
|
@@ -271,7 +271,7 @@ public extension HybridAudioBrowser {
|
|
|
271
271
|
|
|
272
272
|
func getActiveTrackIndex() throws -> Double? {
|
|
273
273
|
onMainActor {
|
|
274
|
-
guard let index = player?.currentIndex, index >= 0 else { return nil }
|
|
274
|
+
guard let index = player?.queue.currentIndex, index >= 0 else { return nil }
|
|
275
275
|
return Double(index)
|
|
276
276
|
}
|
|
277
277
|
}
|
|
@@ -52,12 +52,11 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
52
52
|
// can be set at setup (before any track) while output only begins here. Idempotent:
|
|
53
53
|
// `setActive(true)` is a no-op when the session is already active.
|
|
54
54
|
if state.playing || state.buffering {
|
|
55
|
-
audioSession.activate()
|
|
56
55
|
// The now-playing session's activation request at link time predates this
|
|
57
56
|
// (the audio session was still inactive, and the system may have declined);
|
|
58
|
-
// re-request
|
|
59
|
-
// audio (first-ever load failing on a dead stream).
|
|
60
|
-
engine?.reactivateNowPlayingSession()
|
|
57
|
+
// re-request once the audio session is active, so the surface can appear even if
|
|
58
|
+
// playback never produces audio (first-ever load failing on a dead stream).
|
|
59
|
+
audioSession.activate { [weak self] in self?.engine?.reactivateNowPlayingSession() }
|
|
61
60
|
}
|
|
62
61
|
// A stall starting or recovering may not change the Player state.
|
|
63
62
|
engine?.nowPlayingUpdater.render()
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
var query: [String: String]?
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/// Every property of the generated type, in its init's order, so the shared conversions in
|
|
32
|
+
/// JsonModels compile against both.
|
|
31
33
|
struct Track: Equatable {
|
|
32
34
|
var id: String?
|
|
33
35
|
var path: String?
|
|
@@ -36,15 +38,26 @@
|
|
|
36
38
|
var artworkSource: ImageSource?
|
|
37
39
|
var request: TrackRequest?
|
|
38
40
|
var title: String = ""
|
|
41
|
+
var subtitle: String?
|
|
39
42
|
var artist: String?
|
|
40
43
|
var albumPath: String?
|
|
41
44
|
var album: String?
|
|
45
|
+
var description: String?
|
|
46
|
+
var genre: String?
|
|
47
|
+
var duration: Double?
|
|
42
48
|
var style: TrackStyle?
|
|
43
49
|
var disabled: Bool?
|
|
50
|
+
var favorited: Bool?
|
|
44
51
|
var live: Bool?
|
|
45
52
|
var icyCharset: String?
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
struct NowPlayingUpdate: Equatable {
|
|
56
|
+
var title: String?
|
|
57
|
+
var artist: String?
|
|
58
|
+
var album: String?
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
struct TrackLoadEvent {
|
|
49
62
|
var track: Track
|
|
50
63
|
var queue: [Track]
|
|
@@ -12,6 +12,8 @@ protocol AudioSessionPlayer: AnyObject {
|
|
|
12
12
|
func mediaServicesWereReset()
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
extension Player: AudioSessionPlayer {}
|
|
16
|
+
|
|
15
17
|
/// Owns the app's `AVAudioSession`: its category, activation and release, and the interruption,
|
|
16
18
|
/// route-change, media-services-reset and volume observers, installed once at init.
|
|
17
19
|
///
|
|
@@ -35,6 +37,11 @@ final class AudioSessionController {
|
|
|
35
37
|
|
|
36
38
|
private var releaseWork: DispatchWorkItem?
|
|
37
39
|
|
|
40
|
+
/// Where `setActive` runs: it blocks until the system has changed the session, which can take
|
|
41
|
+
/// long enough to stall the main thread. Serial, so a release and a quick re-activation keep
|
|
42
|
+
/// their order.
|
|
43
|
+
private let activationQueue = DispatchQueue(label: "com.audiobrowser.audio-session", qos: .userInitiated)
|
|
44
|
+
|
|
38
45
|
/// Touched only by `init` and `deinit`.
|
|
39
46
|
private nonisolated(unsafe) var observers: [NSObjectProtocol] = []
|
|
40
47
|
private var volumeObservation: NSKeyValueObservation?
|
|
@@ -86,10 +93,14 @@ final class AudioSessionController {
|
|
|
86
93
|
applyCategory()
|
|
87
94
|
}
|
|
88
95
|
|
|
89
|
-
/// Activates the session for output and cancels a pending release.
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
/// Activates the session for output and cancels a pending release. `then` runs on the main
|
|
97
|
+
/// actor once the activation has run, whether or not it succeeded.
|
|
98
|
+
func activate(then: (@MainActor @Sendable () -> Void)? = nil) {
|
|
92
99
|
cancelRelease()
|
|
100
|
+
activationQueue.async {
|
|
101
|
+
try? AVAudioSession.sharedInstance().setActive(true)
|
|
102
|
+
if let then { Task { @MainActor in then() } }
|
|
103
|
+
}
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
/// Releases the session after 3 s unless playback resumes, so other apps' audio can come back.
|
|
@@ -100,7 +111,9 @@ final class AudioSessionController {
|
|
|
100
111
|
let release = DispatchWorkItem { [weak self] in
|
|
101
112
|
guard let self, player?.playWhenReady == false else { return }
|
|
102
113
|
releaseWork = nil
|
|
103
|
-
|
|
114
|
+
activationQueue.async {
|
|
115
|
+
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
116
|
+
}
|
|
104
117
|
}
|
|
105
118
|
releaseWork = release
|
|
106
119
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: release)
|
|
@@ -155,12 +168,13 @@ final class AudioSessionController {
|
|
|
155
168
|
player?.handleInterruptionBegan()
|
|
156
169
|
case .ended:
|
|
157
170
|
let shouldResume = options.map { AVAudioSession.InterruptionOptions(rawValue: $0).contains(.shouldResume) } ?? false
|
|
158
|
-
// Reactivate only when a resume follows
|
|
159
|
-
// non-mixable session with nothing to release it.
|
|
171
|
+
// Reactivate only when a resume follows, and resume once it is active: activating while
|
|
172
|
+
// staying paused would hold a non-mixable session with nothing to release it.
|
|
160
173
|
if shouldResume, player?.willResumeAfterInterruption == true {
|
|
161
|
-
|
|
174
|
+
activate { [weak self] in self?.player?.handleInterruptionEnded(shouldResume: true) }
|
|
175
|
+
} else {
|
|
176
|
+
player?.handleInterruptionEnded(shouldResume: shouldResume)
|
|
162
177
|
}
|
|
163
|
-
player?.handleInterruptionEnded(shouldResume: shouldResume)
|
|
164
178
|
@unknown default:
|
|
165
179
|
break
|
|
166
180
|
}
|
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
enum ErrorStatus: Equatable {
|
|
8
8
|
case none
|
|
9
9
|
case retrying(TrackPlayerError.PlaybackError)
|
|
10
|
-
|
|
10
|
+
/// `report` tells each terminal report from the last, so a repeat of the same error is a change.
|
|
11
|
+
case terminal(TrackPlayerError.PlaybackError, report: UInt)
|
|
11
12
|
|
|
12
13
|
var error: TrackPlayerError.PlaybackError? {
|
|
13
14
|
switch self {
|
|
14
15
|
case .none: nil
|
|
15
|
-
case let .retrying(error), let .terminal(error): error
|
|
16
|
+
case let .retrying(error), let .terminal(error, _): error
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -24,4 +25,26 @@ enum ErrorStatus: Equatable {
|
|
|
24
25
|
var nitroError: PlaybackError? {
|
|
25
26
|
error?.toNitroError(retrying: isRetrying)
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
/// The status once `event` moves the state from `old` to `new`: an error event is a new
|
|
30
|
+
/// terminal report; leaving `.error`, or reaching a state without an advisory error, clears it.
|
|
31
|
+
func after(event: PlaybackEvent, from old: PlaybackState, to new: PlaybackState) -> ErrorStatus {
|
|
32
|
+
if case let .errorOccurred(error) = event {
|
|
33
|
+
return .terminal(error, report: terminalReport &+ 1)
|
|
34
|
+
}
|
|
35
|
+
if old == .error || (isRetrying && !new.carriesAdvisoryError) { return .none }
|
|
36
|
+
return self
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private var terminalReport: UInt {
|
|
40
|
+
if case let .terminal(_, report) = self { report } else { 0 }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
extension PlaybackState {
|
|
45
|
+
/// Whether a retrying error shows over this state: the listener is waiting on it, and no audio
|
|
46
|
+
/// is playing to contradict it.
|
|
47
|
+
var carriesAdvisoryError: Bool {
|
|
48
|
+
self == .loading || self == .buffering || self == .paused
|
|
49
|
+
}
|
|
27
50
|
}
|
|
@@ -15,7 +15,11 @@ final class MediaLoader {
|
|
|
15
15
|
/// Resolves a playback src into a concrete URL + headers/user-agent.
|
|
16
16
|
/// The `Track` is threaded through so the resolver can invoke the
|
|
17
17
|
/// consumer-supplied `media.resolve(track)` as the final media layer.
|
|
18
|
-
|
|
18
|
+
private let resolveUrl: (String, Track?) async -> MediaResolvedUrl
|
|
19
|
+
|
|
20
|
+
init(resolveUrl: @escaping (String, Track?) async -> MediaResolvedUrl) {
|
|
21
|
+
self.resolveUrl = resolveUrl
|
|
22
|
+
}
|
|
19
23
|
|
|
20
24
|
/// Internal access so AVPlayerEngine can read it.
|
|
21
25
|
private(set) var asset: AVURLAsset?
|
|
@@ -47,51 +51,14 @@ final class MediaLoader {
|
|
|
47
51
|
// MARK: - Public API
|
|
48
52
|
|
|
49
53
|
func resolveAndLoad(src: String, track: Track? = nil) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// can't pin the loader (`cancelAll()` can't force an in-flight `await` to
|
|
59
|
-
// return).
|
|
60
|
-
let logger = logger
|
|
61
|
-
mediaResolverTask = Task { [weak self] in
|
|
62
|
-
logger.debug("resolveAndLoad: starting resolution for \(src)")
|
|
63
|
-
|
|
64
|
-
guard !Task.isCancelled else {
|
|
65
|
-
logger.debug("resolveAndLoad: cancelled before start")
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
logger.debug("resolveAndLoad: calling resolver...")
|
|
70
|
-
let resolved = await resolver(src, track)
|
|
71
|
-
|
|
72
|
-
guard !Task.isCancelled, let self else {
|
|
73
|
-
logger.debug("resolveAndLoad: cancelled after resolver returned")
|
|
74
|
-
return
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
self.logger.debug("resolveAndLoad: resolver returned, resolved URL: \(resolved.url)")
|
|
78
|
-
if let headers = resolved.headers {
|
|
79
|
-
self.logger.debug(" headers: \(headers)")
|
|
80
|
-
}
|
|
81
|
-
if let userAgent = resolved.userAgent {
|
|
82
|
-
self.logger.debug(" userAgent: \(userAgent)")
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
await MainActor.run {
|
|
86
|
-
guard !Task.isCancelled else {
|
|
87
|
-
self.logger.debug("resolveAndLoad: cancelled before loadWithResolvedUrl")
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
self.loadWithResolvedUrl(resolved)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
} else {
|
|
94
|
-
let resolved = MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
54
|
+
mediaResolverTask?.cancel()
|
|
55
|
+
// `self` stays weak across the await: a consumer resolver that never settles must not pin
|
|
56
|
+
// the loader, and cancellation cannot force its `await` to return.
|
|
57
|
+
let resolveUrl = resolveUrl
|
|
58
|
+
mediaResolverTask = Task { [weak self] in
|
|
59
|
+
guard !Task.isCancelled else { return }
|
|
60
|
+
let resolved = await resolveUrl(src, track)
|
|
61
|
+
guard !Task.isCancelled, let self else { return }
|
|
95
62
|
loadWithResolvedUrl(resolved)
|
|
96
63
|
}
|
|
97
64
|
}
|
|
@@ -149,28 +116,17 @@ final class MediaLoader {
|
|
|
149
116
|
|
|
150
117
|
do {
|
|
151
118
|
let isPlayable = try await pendingAsset.load(.isPlayable)
|
|
152
|
-
|
|
153
|
-
guard
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
guard pendingAsset == self.asset else { return }
|
|
157
|
-
|
|
158
|
-
if !isPlayable {
|
|
159
|
-
self.onEvent?(.unplayable)
|
|
160
|
-
return
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
let avItem = AVPlayerItem(asset: pendingAsset)
|
|
164
|
-
avItem.preferredForwardBufferDuration = self.bufferDuration / 1000.0
|
|
165
|
-
self.onEvent?(.prepared(avItem))
|
|
119
|
+
guard isCurrent(pendingAsset) else { return }
|
|
120
|
+
guard isPlayable else {
|
|
121
|
+
onEvent?(.unplayable)
|
|
122
|
+
return
|
|
166
123
|
}
|
|
124
|
+
let avItem = AVPlayerItem(asset: pendingAsset)
|
|
125
|
+
avItem.preferredForwardBufferDuration = bufferDuration / 1000.0
|
|
126
|
+
onEvent?(.prepared(avItem))
|
|
167
127
|
} catch {
|
|
168
|
-
guard
|
|
169
|
-
|
|
170
|
-
await MainActor.run {
|
|
171
|
-
guard pendingAsset == self.asset else { return }
|
|
172
|
-
self.onEvent?(.failed(error))
|
|
173
|
-
}
|
|
128
|
+
guard isCurrent(pendingAsset) else { return }
|
|
129
|
+
onEvent?(.failed(error))
|
|
174
130
|
}
|
|
175
131
|
}
|
|
176
132
|
}
|
|
@@ -232,12 +188,8 @@ final class MediaLoader {
|
|
|
232
188
|
return
|
|
233
189
|
}
|
|
234
190
|
|
|
235
|
-
let isLocalFile = mediaUrl.isFileURL
|
|
236
|
-
logger.debug(" final playbackUrl: \(mediaUrl.absoluteString)")
|
|
237
|
-
logger.debug(" isLocalFile: \(isLocalFile)")
|
|
238
|
-
|
|
239
191
|
loadAsset(
|
|
240
|
-
url:
|
|
192
|
+
url: mediaUrl.isFileURL ? URL(fileURLWithPath: mediaUrl.path) : mediaUrl,
|
|
241
193
|
options: Self.buildAssetOptions(headers: resolved.headers, userAgent: resolved.userAgent),
|
|
242
194
|
)
|
|
243
195
|
}
|