react-native-audio-browser 0.2.0-next.31 → 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/Model/NitroTypeStubs.swift +13 -0
- package/ios/Player/AudioSessionController.swift +2 -0
- 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
|
}
|
|
@@ -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
|
///
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#if canImport(NitroModules)
|
|
2
|
+
import NitroModules
|
|
3
|
+
#endif
|
|
4
|
+
|
|
5
|
+
/// The title, artist and album lines the now-playing surface shows.
|
|
6
|
+
struct NowPlayingLines: Equatable {
|
|
7
|
+
let title: String
|
|
8
|
+
let artist: String?
|
|
9
|
+
let album: String?
|
|
10
|
+
|
|
11
|
+
/// Each line from the first of `flash`, `formatted` and `override` that sets it, else the
|
|
12
|
+
/// track's own.
|
|
13
|
+
static func resolve(
|
|
14
|
+
track: Track,
|
|
15
|
+
flash: NowPlayingUpdate? = nil,
|
|
16
|
+
formatted: NowPlayingUpdate? = nil,
|
|
17
|
+
override: NowPlayingUpdate? = nil,
|
|
18
|
+
) -> NowPlayingLines {
|
|
19
|
+
let layers = [flash, formatted, override].compactMap(\.self)
|
|
20
|
+
return NowPlayingLines(
|
|
21
|
+
title: layers.lazy.compactMap(\.title).first ?? track.title,
|
|
22
|
+
artist: layers.lazy.compactMap(\.artist).first ?? track.artist,
|
|
23
|
+
album: layers.lazy.compactMap(\.album).first ?? track.album,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// What the now-playing surface renders from: the player's track and state.
|
|
29
|
+
@MainActor
|
|
30
|
+
protocol NowPlayingSource: AnyObject {
|
|
31
|
+
var currentTrack: Track? { get }
|
|
32
|
+
var currentTime: Double { get }
|
|
33
|
+
var playWhenReady: Bool { get }
|
|
34
|
+
var isStalled: Bool { get }
|
|
35
|
+
var nitroPlaybackError: PlaybackError? { get }
|
|
36
|
+
}
|