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.
@@ -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 = 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
- #if canImport(NitroModules)
262
- func toNitro() -> TrackRequest {
263
- TrackRequest(userAgent: userAgent, headers: headers, query: query)
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
- #if canImport(NitroModules)
395
-
396
- extension JsonTrackRequest {
397
- /// Snapshot a live TrackRequest back to its JSON model.
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
- extension JsonTrack {
441
- /// Snapshot the persistable subset of a live Track (inverse of `toNitro()`).
442
- /// SPM test-target stub — only fields present on the minimal `Track` stub.
443
- init(from track: Track) {
444
- self.init(
445
- id: track.id,
446
- path: track.path,
447
- title: track.title,
448
- artwork: JsonArtwork(track.artwork),
449
- artist: track.artist,
450
- albumPath: track.albumPath,
451
- album: track.album,
452
- src: track.src,
453
- request: track.request.map(JsonTrackRequest.init(from:)),
454
- style: JsonStyle(track.style),
455
- disabled: track.disabled,
456
- live: track.live,
457
- icyCharset: track.icyCharset,
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
- #if canImport(NitroModules)
467
-
468
- extension JsonSection {
469
- func toNitro() -> Section {
470
- Section(
471
- title: title,
472
- style: style?.toSectionStyle(),
473
- path: path,
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
- extension JsonSection {
557
- func toNitro() -> Section {
558
- Section(
559
- title: title,
560
- style: style?.toSectionStyle(),
561
- path: path,
562
- children: children.map { $0.toNitro() },
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
- extension JsonResolvedTrack {
568
- func toNitro() -> ResolvedTrack {
569
- ResolvedTrack(
570
- path: path,
571
- style: style?.toSectionStyle(),
572
- sections: sections?.map { $0.toNitro() },
573
- children: children?.map { $0.toNitro() },
574
- artwork: artwork?.toNitro(),
575
- title: title,
576
- disabled: disabled,
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?.sleepTimer ?? .first(NullType.null)
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 now so the surface can appear even if playback never produces
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
- func activate() {
91
- try? session.setActive(true)
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
- try? session.setActive(false, options: .notifyOthersOnDeactivation)
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: activating while staying paused would hold a
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
- try? session.setActive(true)
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
- case terminal(TrackPlayerError.PlaybackError)
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
- var mediaUrlResolver: ((String, Track?) async -> MediaResolvedUrl)?
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
- if let resolver = mediaUrlResolver {
51
- mediaResolverTask?.cancel()
52
- // `[weak self]` like the sibling load tasks below: the resolver is
53
- // consumer-supplied and `cancelAll()` cannot force an in-flight `await`
54
- // to return, so a strong capture let a resolver that never settles pin
55
- // this loader (which also retains the task) indefinitely.
56
- // `Logger` is a value type, so capturing it directly keeps the logging
57
- // off `self` — and `self` weak, so a consumer resolver that never settles
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 !Task.isCancelled else { return }
154
-
155
- await MainActor.run {
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 !Task.isCancelled else { return }
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: isLocalFile ? URL(fileURLWithPath: mediaUrl.path) : mediaUrl,
192
+ url: mediaUrl.isFileURL ? URL(fileURLWithPath: mediaUrl.path) : mediaUrl,
241
193
  options: Self.buildAssetOptions(headers: resolved.headers, userAgent: resolved.userAgent),
242
194
  )
243
195
  }