react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29

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.
Files changed (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -0,0 +1,70 @@
1
+ import Foundation
2
+
3
+ /// A seek that arrives while an item loads. AVPlayer ignores `seek(to:)` without a loaded item,
4
+ /// so the target is held until the item is ready, and the ready transition waits until it lands.
5
+ /// Pure state: the caller issues the seek this hands out and reports when it lands.
6
+ struct DeferredSeek {
7
+ enum State: Equatable {
8
+ case idle
9
+ case pending(TimeInterval)
10
+ case inFlight(TimeInterval)
11
+ }
12
+
13
+ /// What to do once an issued seek lands.
14
+ enum Landing: Equatable {
15
+ /// The target is reached: the load may become ready.
16
+ case done
17
+ /// A newer target arrived meanwhile: issue it.
18
+ case reseek(TimeInterval)
19
+ /// No deferred seek was in flight.
20
+ case none
21
+ }
22
+
23
+ private(set) var state: State = .idle
24
+
25
+ /// Identifies the item a seek was issued against. `reset()` bumps it, so the completion of a
26
+ /// seek against the previous item can tell it is stale.
27
+ private(set) var generation = 0
28
+
29
+ /// The target, pending or in flight; `seekBy` offsets from it.
30
+ var target: TimeInterval? {
31
+ switch state {
32
+ case let .pending(time), let .inFlight(time): time
33
+ case .idle: nil
34
+ }
35
+ }
36
+
37
+ var defersReady: Bool {
38
+ state != .idle
39
+ }
40
+
41
+ /// Holds `position` as the target, replacing a pending one or one in flight.
42
+ mutating func capture(_ position: TimeInterval) {
43
+ state = .pending(position)
44
+ }
45
+
46
+ /// Hands out the pending target to issue, marking it in flight; nil when none is pending.
47
+ mutating func begin() -> TimeInterval? {
48
+ guard case let .pending(time) = state else { return nil }
49
+ state = .inFlight(time)
50
+ return time
51
+ }
52
+
53
+ mutating func land() -> Landing {
54
+ switch state {
55
+ case .inFlight:
56
+ state = .idle
57
+ return .done
58
+ case .pending:
59
+ guard let time = begin() else { return .none }
60
+ return .reseek(time)
61
+ case .idle:
62
+ return .none
63
+ }
64
+ }
65
+
66
+ mutating func reset() {
67
+ state = .idle
68
+ generation += 1
69
+ }
70
+ }
@@ -0,0 +1,27 @@
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
4
+
5
+ /// The player's error: none, one the retry loop is still working on (shown over a non-terminal
6
+ /// state, an Advisory error), or terminal (the `.error` state).
7
+ enum ErrorStatus: Equatable {
8
+ case none
9
+ case retrying(TrackPlayerError.PlaybackError)
10
+ case terminal(TrackPlayerError.PlaybackError)
11
+
12
+ var error: TrackPlayerError.PlaybackError? {
13
+ switch self {
14
+ case .none: nil
15
+ case let .retrying(error), let .terminal(error): error
16
+ }
17
+ }
18
+
19
+ var isRetrying: Bool {
20
+ if case .retrying = self { true } else { false }
21
+ }
22
+
23
+ /// The JS-facing error, carrying the retrying flag.
24
+ var nitroError: PlaybackError? {
25
+ error?.toNitroError(retrying: isRetrying)
26
+ }
27
+ }
@@ -17,13 +17,26 @@ final class MediaLoader {
17
17
  /// consumer-supplied `media.resolve(track)` as the final media layer.
18
18
  var mediaUrlResolver: ((String, Track?) async -> MediaResolvedUrl)?
19
19
 
20
- /// Internal access so TrackPlayer can read it for observer guards.
20
+ /// Internal access so AVPlayerEngine can read it.
21
21
  private(set) var asset: AVURLAsset?
22
22
 
23
23
  /// Milliseconds; applied to AVPlayerItem's preferredForwardBufferDuration.
24
24
  var bufferDuration: Double = 0
25
25
 
26
- weak var delegate: MediaLoaderDelegate?
26
+ enum Event {
27
+ case prepared(AVPlayerItem)
28
+ /// Loading failed; the retry loop decides whether another attempt could help.
29
+ case failed(Error)
30
+ /// The asset loaded but cannot play.
31
+ case unplayable
32
+ /// The source is not a URL, so no asset exists.
33
+ case invalidSource(String)
34
+ case commonMetadata([AVMetadataItem])
35
+ case chapterMetadata([AVTimedMetadataGroup])
36
+ case timedMetadata([AVTimedMetadataGroup])
37
+ }
38
+
39
+ var onEvent: (@MainActor (Event) -> Void)?
27
40
 
28
41
  /// The in-flight resolve. Readable so tests can await the resolve-then-load
29
42
  /// chain instead of sleeping past it; only this type starts and cancels it.
@@ -89,7 +102,7 @@ final class MediaLoader {
89
102
  guard let url else {
90
103
  // Callers (reload-from-terminal) have already transitioned to .loading;
91
104
  // a silent return would strand that state with no load in flight.
92
- delegate?.mediaLoaderDidFailWithError(.invalidSourceUrl("nil"))
105
+ onEvent?(.invalidSource("nil"))
93
106
  return
94
107
  }
95
108
  let pendingAsset = AVURLAsset(url: url, options: urlOptions)
@@ -108,7 +121,7 @@ final class MediaLoader {
108
121
  guard isCurrent(pendingAsset) else { return }
109
122
 
110
123
  if !commonMetadata.isEmpty {
111
- delegate?.mediaLoaderDidReceiveCommonMetadata(commonMetadata)
124
+ onEvent?(.commonMetadata(commonMetadata))
112
125
  }
113
126
 
114
127
  if !chapterLocales.isEmpty {
@@ -119,7 +132,7 @@ final class MediaLoader {
119
132
  containingItemsWithCommonKeys: [],
120
133
  ) {
121
134
  guard isCurrent(pendingAsset) else { return }
122
- delegate?.mediaLoaderDidReceiveChapterMetadata(chapters)
135
+ onEvent?(.chapterMetadata(chapters))
123
136
  }
124
137
  }
125
138
  } else {
@@ -133,7 +146,7 @@ final class MediaLoader {
133
146
  end: duration,
134
147
  )
135
148
  let group = AVTimedMetadataGroup(items: metadata, timeRange: timeRange)
136
- delegate?.mediaLoaderDidReceiveTimedMetadata([group])
149
+ onEvent?(.timedMetadata([group]))
137
150
  }
138
151
  }
139
152
  }
@@ -151,20 +164,20 @@ final class MediaLoader {
151
164
  guard pendingAsset == self.asset else { return }
152
165
 
153
166
  if !isPlayable {
154
- self.delegate?.mediaLoaderDidFailWithUnplayableTrack()
167
+ self.onEvent?(.unplayable)
155
168
  return
156
169
  }
157
170
 
158
171
  let avItem = AVPlayerItem(asset: pendingAsset)
159
172
  avItem.preferredForwardBufferDuration = self.bufferDuration / 1000.0
160
- self.delegate?.mediaLoaderDidPrepareItem(avItem)
173
+ self.onEvent?(.prepared(avItem))
161
174
  }
162
175
  } catch {
163
176
  guard !Task.isCancelled else { return }
164
177
 
165
178
  await MainActor.run {
166
179
  guard pendingAsset == self.asset else { return }
167
- self.delegate?.mediaLoaderDidFailWithRetryableError(error)
180
+ self.onEvent?(.failed(error))
168
181
  }
169
182
  }
170
183
  }
@@ -207,7 +220,7 @@ final class MediaLoader {
207
220
 
208
221
  /// Builds AVURLAsset options, merging any explicit headers with a resolved
209
222
  /// User-Agent. An explicit `User-Agent` in `headers` wins (mirrors the artwork
210
- /// path in BrowserManager+URLResolution). A nil/empty userAgent contributes
223
+ /// path in RequestPipeline). A nil/empty userAgent contributes
211
224
  /// nothing, so "no UA configured" still falls through to AVPlayer's default.
212
225
  nonisolated static func buildAssetOptions(
213
226
  headers: [String: String]?,
@@ -225,7 +238,7 @@ final class MediaLoader {
225
238
  private func loadWithResolvedUrl(_ resolved: MediaResolvedUrl) {
226
239
  guard let mediaUrl = URL(string: resolved.url) else {
227
240
  logger.error("Invalid media URL: \(resolved.url)")
228
- delegate?.mediaLoaderDidFailWithError(.invalidSourceUrl(resolved.url))
241
+ onEvent?(.invalidSource(resolved.url))
229
242
  return
230
243
  }
231
244
 
@@ -3,46 +3,118 @@ import MediaPlayer
3
3
  import NitroModules
4
4
  import os.log
5
5
 
6
+ /// What the now-playing surface renders from: the player's track and state.
7
+ @MainActor
8
+ protocol NowPlayingSource: AnyObject {
9
+ var currentTrack: Track? { get }
10
+ var currentTime: Double { get }
11
+ var playWhenReady: Bool { get }
12
+ var isStalled: Bool { get }
13
+ var nitroPlaybackError: PlaybackError? { get }
14
+ }
15
+
6
16
  /// Owns the now-playing surface: publishes a track's static fields + artwork, and renders the
7
- /// title/artist line — the formatter's result, or the track/override default. The single writer of
8
- /// the title/artist fields, with a publish-dedupe so the frequent re-renders stay cheap.
9
- ///
10
- /// Does not exclusively own NowPlayingInfoController -- other callers (e.g. HybridAudioBrowser for
11
- /// overrides) can still access it directly.
17
+ /// title/artist line — a flash, else the formatter's result, else the override or the track's own
18
+ /// fields. Holds that state (override, flash, latest timed metadata, formatter) and reports each
19
+ /// change of the rendered lines through `onChanged`.
12
20
  @MainActor
13
21
  final class NowPlayingUpdater {
14
22
  private let logger = Logger(subsystem: "com.audiobrowser", category: "NowPlayingUpdater")
15
23
  private let nowPlayingInfoController: NowPlayingInfoController
16
24
 
25
+ weak var source: NowPlayingSource?
17
26
  var artworkUrlResolver: ((Track, ImageContext?) async -> ImageSource?)?
18
- /// Invoked when the published title/artist actually change, so the owner can emit the JS
19
- /// `onNowPlayingChanged` event (which it shapes with elapsed time / artwork / etc.).
20
- var onChanged: (@MainActor (_ track: Track, _ title: String, _ artist: String?, _ album: String?) -> Void)?
27
+ /// Called with the JS-facing metadata whenever the rendered lines or the track change.
28
+ var onChanged: ((NowPlayingMetadata) -> Void)?
29
+
30
+ /// When false the surface shows the track's own fields: the override and formatter are ignored.
31
+ var enabled = true
32
+ var formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
33
+
34
+ /// Whether the device is online, which tells a stall apart: offline or buffering.
35
+ var isOnline = true {
36
+ didSet { if isOnline != oldValue { render() } }
37
+ }
38
+
39
+ private var override: NowPlayingUpdate?
40
+ private var timedMetadata: TimedMetadata?
41
+ /// A transient line (e.g. feedback for a refused remote command) that outranks the formatter
42
+ /// and the override, reverted by a native timer: JS timers pause in the background.
43
+ private var flash: NowPlayingUpdate?
44
+ private var flashRevert: DispatchWorkItem?
21
45
 
22
46
  private var artworkLoadTask: Task<Void, Never>?
23
- private var artworkGeneration: UInt = 0
24
-
25
- /// Last published text lines, to dedupe redundant writes (render runs on every transition).
26
- private struct Published: Equatable {
27
- let trackId: String?
28
- let title: String?
29
- let artist: String?
30
- let album: String?
31
- }
32
-
33
- /// Dedupe baseline for `applyFields`. Only meaningful while it describes the
34
- /// controller's current info dict, so the two are cleared together in
35
- /// `clearNowPlaying()` — never separately.
36
- private var lastPublished: Published?
37
-
38
- /// Empties the now-playing surface and the dedupe baseline that describes it.
39
- /// One call, because clearing the dict without the baseline makes the next
40
- /// publish of the SAME track dedupe against a dict that no longer holds it —
41
- /// artwork lands (never deduped) but the text doesn't, and `onChanged`
42
- /// never fires.
47
+
48
+ /// The track `onChanged` last reported, so a new track with the same text still reports.
49
+ private var publishedTrackId: String?
50
+
51
+ func setOverride(_ update: NowPlayingUpdate?) {
52
+ override = update
53
+ render()
54
+ }
55
+
56
+ func flash(_ update: NowPlayingUpdate, durationMs: Double) {
57
+ flashRevert?.cancel()
58
+ flash = update
59
+ let revert = DispatchWorkItem { [weak self] in
60
+ self?.flash = nil
61
+ self?.flashRevert = nil
62
+ self?.render()
63
+ }
64
+ flashRevert = revert
65
+ DispatchQueue.main.asyncAfter(deadline: .now() + durationMs / 1000, execute: revert)
66
+ render()
67
+ }
68
+
69
+ func clearFlash() {
70
+ guard flash != nil else { return }
71
+ cancelFlash()
72
+ render()
73
+ }
74
+
75
+ func receivedTimedMetadata(_ metadata: TimedMetadata) {
76
+ timedMetadata = metadata
77
+ render()
78
+ }
79
+
80
+ /// A new track drops the override, flash and timed metadata of the last one.
81
+ func trackChanged() {
82
+ override = nil
83
+ cancelFlash()
84
+ timedMetadata = nil
85
+ render()
86
+ }
87
+
88
+ /// The metadata JS sees for the current track, before any formatter result.
89
+ var nowPlaying: NowPlayingMetadata? {
90
+ guard let track = source?.currentTrack else { return nil }
91
+ let lines = defaultLines(for: track)
92
+ return metadata(track: track, title: lines.title, artist: lines.artist, album: lines.album)
93
+ }
94
+
95
+ /// Re-renders the text lines for the current track and state. Called on every playback
96
+ /// transition; unchanged lines are not re-published.
97
+ func render() {
98
+ guard let source, let track = source.currentTrack else { return }
99
+ render(
100
+ track: track,
101
+ timedMetadata: timedMetadata,
102
+ playWhenReady: source.playWhenReady,
103
+ stalled: source.isStalled ? (isOnline ? .buffering : .offline) : nil,
104
+ error: source.nitroPlaybackError,
105
+ flash: flash,
106
+ override: enabled ? override : nil,
107
+ formatter: enabled ? formatter : nil,
108
+ )
109
+ }
110
+
111
+ /// Empties the now-playing surface and drops any artwork fetch or formatter result in flight.
43
112
  func clearNowPlaying() {
113
+ renderGeneration &+= 1
114
+ artworkLoadTask?.cancel()
115
+ artworkLoadTask = nil
44
116
  nowPlayingInfoController.clear()
45
- lastPublished = nil
117
+ publishedTrackId = nil
46
118
  formattedTrackId = nil
47
119
  }
48
120
 
@@ -79,9 +151,7 @@ final class NowPlayingUpdater {
79
151
  /// lines are owned by `render` (routed through `applyFields` here so it dedupes against later
80
152
  /// renders).
81
153
  func loadMetaValues(for track: Track) {
82
- nowPlayingInfoController.set(keyValues: [
83
- NowPlayingInfoProperty.isLiveStream(track.live),
84
- ])
154
+ nowPlayingInfoController.set([MPNowPlayingInfoPropertyIsLiveStream: track.live])
85
155
  applyFields(track: track, title: track.title, artist: track.artist, album: track.album)
86
156
  loadArtwork(for: track)
87
157
  }
@@ -92,13 +162,13 @@ final class NowPlayingUpdater {
92
162
  /// command) outranks both: while one is active the formatter pass is skipped entirely, so its
93
163
  /// async result can't land on top. Safe to call on every playback transition; redundant writes
94
164
  /// are deduped.
95
- func render(
165
+ private func render(
96
166
  track: Track,
97
167
  timedMetadata: TimedMetadata?,
98
168
  playWhenReady: Bool,
99
169
  stalled: StallReason?,
100
170
  error: PlaybackError?,
101
- flash: NowPlayingUpdate? = nil,
171
+ flash: NowPlayingUpdate?,
102
172
  override: NowPlayingUpdate?,
103
173
  formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
104
174
  ) {
@@ -174,27 +244,58 @@ final class NowPlayingUpdater {
174
244
 
175
245
  // MARK: - Private
176
246
 
177
- /// Stamps the title/artist/album (each falling back to the track's own) onto
178
- /// NowPlayingInfoCenter, deduped against the last publish, and notifies `onChanged`.
247
+ /// Stamps the title/artist/album (each falling back to the track's own) onto the now-playing
248
+ /// surface, and notifies `onChanged` when the text or the track changed.
179
249
  private func applyFields(track: Track, title: String?, artist: String?, album: String?) {
180
250
  let resolvedTitle = title ?? track.title
181
251
  let resolvedArtist = artist ?? track.artist
182
252
  let resolvedAlbum = album ?? track.album
183
- let published = Published(
184
- trackId: track.identity ?? track.path,
185
- title: resolvedTitle,
186
- artist: resolvedArtist,
187
- album: resolvedAlbum,
253
+ let textChanged = nowPlayingInfoController.set([
254
+ MPMediaItemPropertyTitle: resolvedTitle,
255
+ MPMediaItemPropertyArtist: resolvedArtist,
256
+ MPMediaItemPropertyAlbumTitle: resolvedAlbum,
257
+ ])
258
+ let trackId = track.identity ?? track.path
259
+ guard textChanged || trackId != publishedTrackId else { return }
260
+ publishedTrackId = trackId
261
+ onChanged?(metadata(track: track, title: resolvedTitle, artist: resolvedArtist, album: resolvedAlbum))
262
+ }
263
+
264
+ private func cancelFlash() {
265
+ flashRevert?.cancel()
266
+ flashRevert = nil
267
+ flash = nil
268
+ }
269
+
270
+ /// Flash over override over the track's own fields.
271
+ private func defaultLines(for track: Track) -> (title: String, artist: String?, album: String?) {
272
+ (
273
+ flash?.title ?? override?.title ?? track.title,
274
+ flash?.artist ?? override?.artist ?? track.artist,
275
+ flash?.album ?? override?.album ?? track.album,
188
276
  )
189
- guard published != lastPublished else { return }
190
- lastPublished = published
277
+ }
191
278
 
192
- nowPlayingInfoController.set(keyValues: [
193
- MediaItemProperty.title(resolvedTitle),
194
- MediaItemProperty.artist(resolvedArtist),
195
- MediaItemProperty.albumTitle(resolvedAlbum),
196
- ])
197
- onChanged?(track, resolvedTitle, resolvedArtist, resolvedAlbum)
279
+ private func metadata(track: Track, title: String, artist: String?, album: String?) -> NowPlayingMetadata {
280
+ NowPlayingMetadata(
281
+ elapsedTime: source?.currentTime,
282
+ title: title,
283
+ album: album,
284
+ artist: artist,
285
+ duration: track.duration,
286
+ artwork: Self.jsArtwork(for: track),
287
+ description: track.description,
288
+ mediaId: track.src ?? track.path,
289
+ genre: track.genre,
290
+ )
291
+ }
292
+
293
+ /// The artwork URI JS can load: the resolved `artworkSource` (a rendered SF Symbol's `file://`
294
+ /// included), else the raw artwork unless it is an SF Symbol.
295
+ private static func jsArtwork(for track: Track) -> String? {
296
+ if let uri = track.artworkSource?.uri { return uri }
297
+ guard let artwork = track.artwork?.url else { return nil }
298
+ return SFSymbolRenderer.isSFSymbol(artwork) ? nil : artwork
198
299
  }
199
300
 
200
301
  private func loadArtwork(for track: Track) {
@@ -207,8 +308,6 @@ final class NowPlayingUpdater {
207
308
  let nowPlayingSize = ImageContext(width: artworkSize, height: artworkSize)
208
309
 
209
310
  artworkLoadTask?.cancel()
210
- artworkGeneration &+= 1
211
- let expectedGeneration = artworkGeneration
212
311
  artworkLoadTask = Task {
213
312
  let image: UIImage?
214
313
 
@@ -216,19 +315,19 @@ final class NowPlayingUpdater {
216
315
  if let resolver = artworkUrlResolver,
217
316
  let imageSource = await resolver(track, nowPlayingSize)
218
317
  {
219
- guard !Task.isCancelled, artworkGeneration == expectedGeneration else { return }
318
+ guard !Task.isCancelled else { return }
220
319
  logger.debug("loadArtwork: using resolved URL: \(imageSource.uri)")
221
- image = await ArtworkImageFetcher.fetchImage(from: imageSource)
320
+ image = await ArtworkImageFetcher.fetchImage(from: imageSource, waitsForConnectivity: true)
222
321
  } else {
223
- guard !Task.isCancelled, artworkGeneration == expectedGeneration else { return }
322
+ guard !Task.isCancelled else { return }
224
323
  if let source = track.artworkImageSource {
225
- image = await ArtworkImageFetcher.fetchImage(from: source)
324
+ image = await ArtworkImageFetcher.fetchImage(from: source, waitsForConnectivity: true)
226
325
  } else {
227
326
  image = nil
228
327
  }
229
328
  }
230
329
 
231
- guard !Task.isCancelled, artworkGeneration == expectedGeneration else { return }
330
+ guard !Task.isCancelled else { return }
232
331
 
233
332
  if let image {
234
333
  logger.debug("loadArtwork: loaded image \(image.size.width)x\(image.size.height)")
@@ -237,10 +336,10 @@ final class NowPlayingUpdater {
237
336
  let artwork = MPMediaItemArtwork(boundsSize: image.size) { @Sendable requestedSize in
238
337
  return image
239
338
  }
240
- nowPlayingInfoController.set(keyValue: MediaItemProperty.artwork(artwork))
339
+ nowPlayingInfoController.set([MPMediaItemPropertyArtwork: artwork])
241
340
  } else {
242
341
  logger.debug("loadArtwork: no image loaded")
243
- nowPlayingInfoController.set(keyValue: MediaItemProperty.artwork(nil))
342
+ nowPlayingInfoController.set([MPMediaItemPropertyArtwork: nil])
244
343
  }
245
344
  }
246
345
  }
@@ -0,0 +1,125 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ /// What a playback failure is reported as, and whether another attempt could fix it: one table,
5
+ /// so the error JS receives and the retry decision are read off the same evidence.
6
+ enum PlaybackFailure {
7
+ struct Classification: Equatable {
8
+ /// The narrowest error the evidence supports, falling back to the context's error.
9
+ let error: TrackPlayerError.PlaybackError
10
+ let retryable: Bool
11
+ }
12
+
13
+ /// Classifies a failure. AVFoundation reports HTTP failures as opaque CoreMedia errors, so
14
+ /// `httpStatusCode` comes from the item's error log, passed in by the caller.
15
+ ///
16
+ /// Both read the error and the errors it wraps, but rank the evidence differently, on purpose:
17
+ /// - The error: a transport failure first (no response arrived, so a status left in the log
18
+ /// describes an earlier attempt), then a non-2xx status, then media that cannot be decoded.
19
+ /// - Retrying: a status first (a retry cannot conjure content the server refused), then the
20
+ /// error and the errors it wraps. An unrecognized failure is transient, bounded by the retry
21
+ /// budgets, as on Android: AVFoundation reports most stream failures opaquely.
22
+ static func classify(
23
+ _ error: Error?,
24
+ httpStatusCode: Int?,
25
+ fallback: TrackPlayerError.PlaybackError,
26
+ ) -> Classification {
27
+ Classification(
28
+ error: reportedError(error, httpStatusCode: httpStatusCode, fallback: fallback),
29
+ retryable: isRetryable(error, httpStatusCode: httpStatusCode),
30
+ )
31
+ }
32
+
33
+ /// Picks the HTTP status out of an `AVPlayerItem` error log's status codes. The codes share
34
+ /// their space with negative OSStatus values, so only in-range values count, and the newest
35
+ /// wins: a live stream reconnects, and older entries describe earlier attempts.
36
+ static func httpStatusCode(fromErrorStatusCodes codes: [Int]) -> Int? {
37
+ codes.last { (100 ... 599).contains($0) }
38
+ }
39
+
40
+ // MARK: - The error
41
+
42
+ private static func reportedError(
43
+ _ error: Error?,
44
+ httpStatusCode: Int?,
45
+ fallback: TrackPlayerError.PlaybackError,
46
+ ) -> TrackPlayerError.PlaybackError {
47
+ let chain = error.map(errorChain) ?? []
48
+ // `as?` bridges a plain NSError in NSURLErrorDomain too, AVFoundation's usual form.
49
+ for case let urlError as URLError in chain {
50
+ switch urlError.code {
51
+ case .notConnectedToInternet:
52
+ return .notConnectedToInternet
53
+ case .timedOut, .cannotFindHost, .cannotConnectToHost, .networkConnectionLost, .dnsLookupFailed,
54
+ .secureConnectionFailed:
55
+ return .hostUnreachable
56
+ default:
57
+ continue
58
+ }
59
+ }
60
+ if let httpStatusCode, !(200 ... 299).contains(httpStatusCode) {
61
+ return .httpStatus(httpStatusCode)
62
+ }
63
+ let unplayable = chain.contains {
64
+ let nsError = $0 as NSError
65
+ return nsError.domain == AVFoundationErrorDomain && unplayableAVErrorCodes.contains(nsError.code)
66
+ }
67
+ return unplayable ? .trackWasUnplayable : fallback
68
+ }
69
+
70
+ /// The stream arrived but cannot be decoded: unknown container, unsupported codec, or no
71
+ /// decoder.
72
+ private static let unplayableAVErrorCodes: Set<Int> = [
73
+ AVError.fileFormatNotRecognized.rawValue,
74
+ AVError.failedToParse.rawValue,
75
+ AVError.decoderNotFound.rawValue,
76
+ AVError.decoderTemporarilyUnavailable.rawValue,
77
+ AVError.formatUnsupported.rawValue,
78
+ AVError.contentIsUnavailable.rawValue,
79
+ ]
80
+
81
+ // MARK: - Retrying
82
+
83
+ /// Beyond the 500–599 range. Android shares 408/429 but lists only 500/502/503/504 of 5xx.
84
+ private static let retryableHTTPStatusCodes: Set<Int> = [408, 429]
85
+
86
+ /// AVFoundation failures a retry cannot fix: the media is unusable, not the transport
87
+ /// unreliable. Every unplayable code but the temporary decoder shortage is here, so a failure
88
+ /// reported as unplayable is never also retried. `AVError.unknown` is absent on purpose: it
89
+ /// is the wrapper around opaque CoreMedia transport failures.
90
+ private static let fatalAVErrorCodes: Set<Int> = unplayableAVErrorCodes
91
+ .subtracting([AVError.decoderTemporarilyUnavailable.rawValue])
92
+ .union([
93
+ AVError.decodeFailed.rawValue,
94
+ AVError.contentIsProtected.rawValue,
95
+ AVError.contentIsNotAuthorized.rawValue,
96
+ ])
97
+
98
+ private static func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
99
+ if let status = httpStatusCode {
100
+ return retryableHTTPStatusCodes.contains(status) || (500 ... 599).contains(status)
101
+ }
102
+ guard let error else { return false }
103
+ // AVFoundation routinely wraps the real cause (a URLError, a CoreMedia code) in an AVError.
104
+ for candidate in errorChain(error) {
105
+ if let urlError = candidate as? URLError { return urlError.isConnectivity }
106
+ let nsError = candidate as NSError
107
+ if nsError.domain == AVFoundationErrorDomain, fatalAVErrorCodes.contains(nsError.code) {
108
+ return false
109
+ }
110
+ }
111
+ return true
112
+ }
113
+
114
+ /// The error and its `NSUnderlyingError` ancestors, outermost first, bounded so a malformed
115
+ /// cycle cannot spin.
116
+ private static func errorChain(_ error: Error) -> [Error] {
117
+ var chain: [Error] = []
118
+ var current: Error? = error
119
+ while let error = current, chain.count < 8 {
120
+ chain.append(error)
121
+ current = (error as NSError).userInfo[NSUnderlyingErrorKey] as? Error
122
+ }
123
+ return chain
124
+ }
125
+ }