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.
@@ -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
  }
@@ -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
- 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
  }
@@ -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
+ }