react-native-audio-browser 0.2.0-next.28 → 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 (137) hide show
  1. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  2. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  3. package/ios/AVPlayerEngine.swift +334 -0
  4. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  5. package/ios/Browser/BrowseGate.swift +58 -0
  6. package/ios/Browser/BrowserConfig.swift +31 -89
  7. package/ios/Browser/BrowserManager.swift +68 -487
  8. package/ios/Browser/NavigationErrorState.swift +41 -0
  9. package/ios/Browser/PageResolver.swift +227 -0
  10. package/ios/Browser/RequestLayer.swift +90 -0
  11. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  12. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  13. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  14. package/ios/CarPlay/CarPlayController.swift +42 -1442
  15. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  16. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  17. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  18. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  19. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  20. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  21. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  22. package/ios/CarPlay/CarPlaySession.swift +209 -0
  23. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  24. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  25. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  26. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  27. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  28. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  29. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  30. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  31. package/ios/Extension/URLError+Connectivity.swift +15 -0
  32. package/ios/Http/HttpClient.swift +63 -145
  33. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  34. package/ios/HybridAudioBrowser+Options.swift +144 -0
  35. package/ios/HybridAudioBrowser+Player.swift +397 -0
  36. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  37. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  38. package/ios/HybridAudioBrowser.swift +74 -1687
  39. package/ios/Model/NitroTypeStubs.swift +18 -1
  40. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  41. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  42. package/ios/Observer/ItemObserver.swift +90 -0
  43. package/ios/Observer/PlayerObserver.swift +59 -0
  44. package/ios/Option/SessionCategories.swift +41 -92
  45. package/ios/Player/AudioSessionController.swift +177 -0
  46. package/ios/Player/DeferredSeek.swift +70 -0
  47. package/ios/Player/ErrorStatus.swift +27 -0
  48. package/ios/Player/MediaLoader.swift +24 -11
  49. package/ios/Player/NowPlayingUpdater.swift +157 -58
  50. package/ios/Player/PlaybackFailure.swift +125 -0
  51. package/ios/Player/PlaybackPersistence.swift +73 -0
  52. package/ios/Player/Player.swift +1083 -0
  53. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  54. package/ios/Player/PlayerEngine.swift +89 -0
  55. package/ios/Player/PlayerStatusTypes.swift +2 -6
  56. package/ios/Player/QueueManager.swift +52 -59
  57. package/ios/Player/RetryLoop.swift +261 -0
  58. package/ios/Player/SleepTimerManager.swift +1 -1
  59. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  60. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  61. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  62. package/ios/Tests/DeferredSeekTests.swift +70 -0
  63. package/ios/Tests/EmitterTests.swift +29 -0
  64. package/ios/Tests/GenerationMemoTests.swift +121 -0
  65. package/ios/Tests/LRUCacheTests.swift +4 -5
  66. package/ios/Tests/MediaLoaderTests.swift +12 -33
  67. package/ios/Tests/ObserverTests.swift +88 -0
  68. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  69. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  70. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  71. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  72. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  73. package/ios/Tests/PlayingStateTests.swift +42 -0
  74. package/ios/Tests/QueueManagerTests.swift +76 -107
  75. package/ios/Tests/ReadyValueTests.swift +73 -0
  76. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  77. package/ios/Tests/RemoteCommandTests.swift +2 -4
  78. package/ios/Tests/RequestLayerTests.swift +112 -0
  79. package/ios/Tests/RetryLoopTests.swift +270 -0
  80. package/ios/Tests/Support/TestTypes.swift +111 -112
  81. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  82. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  83. package/ios/TrackSelector.swift +1 -1
  84. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  85. package/ios/Util/Emitter.swift +42 -0
  86. package/ios/Util/GenerationMemo.swift +36 -0
  87. package/ios/Util/LRUCache.swift +23 -162
  88. package/ios/Util/NetworkMonitor.swift +1 -1
  89. package/ios/Util/ReadyValue.swift +56 -0
  90. package/ios/Util/SFSymbolRenderer.swift +35 -29
  91. package/ios/Util/SVGProcessor.swift +0 -10
  92. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  93. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  94. package/lib/module/web/NativeAudioBrowser.js +1 -1
  95. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/typescript/src/types/browser.d.ts +3 -3
  97. package/package.json +1 -1
  98. package/src/types/browser.ts +3 -3
  99. package/src/web/NativeAudioBrowser.ts +1 -1
  100. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  101. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  102. package/ios/Bridge.h +0 -8
  103. package/ios/Browser/MediaResolveComposer.swift +0 -114
  104. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  105. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  106. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  107. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  108. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  109. package/ios/Observer/PlayerStateObserver.swift +0 -50
  110. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  111. package/ios/Option/PitchAlgorithms.swift +0 -17
  112. package/ios/Option/TimeEventFrequency.swift +0 -18
  113. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  114. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  115. package/ios/Player/PlaybackCoordinator.swift +0 -761
  116. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  117. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  118. package/ios/Player/PlaybackStateStore.swift +0 -27
  119. package/ios/Player/PlayingStateManager.swift +0 -33
  120. package/ios/Player/RetryManager.swift +0 -384
  121. package/ios/Player/SeekCompletionHandler.swift +0 -5
  122. package/ios/Player/SleepTimerHandling.swift +0 -28
  123. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  124. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  125. package/ios/Tests/MediaResolveTests.swift +0 -126
  126. package/ios/Tests/OnceValueTests.swift +0 -79
  127. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  128. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  129. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  130. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  131. package/ios/Tests/RetryManagerTests.swift +0 -178
  132. package/ios/TrackPlayer.swift +0 -1042
  133. package/ios/TrackPlayerCallbacks.swift +0 -53
  134. package/ios/Util/MetadataAdapter.swift +0 -131
  135. package/ios/Util/OnceValue.swift +0 -104
  136. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  137. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -2,10 +2,8 @@
2
2
  import NitroModules
3
3
  #endif
4
4
 
5
- /// Subset of TrackPlayerCallbacks containing only the callbacks the coordinator fires.
6
- /// Uses Nitro types (no AVFoundation). TrackPlayerCallbacks extends this protocol
7
- /// so HybridAudioBrowser implements both automatically.
8
- @MainActor protocol PlaybackCoordinatorCallbacks: AnyObject {
5
+ /// What the Player reports, in Nitro types. HybridAudioBrowser implements it.
6
+ @MainActor protocol PlayerCallbacks: AnyObject {
9
7
  func playerDidChangePlayback(_ playback: Playback)
10
8
  func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent)
11
9
  func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent)
@@ -21,4 +19,5 @@
21
19
  /// (those resume), and no retry is pending (a live-stream reconnect must keep the session). The
22
20
  /// host may release the audio session (debounced) so other apps' audio can resume.
23
21
  func playerShouldReleaseSession()
22
+ func playerDidChangeSleepTimer(_ state: SleepTimerState?)
24
23
  }
@@ -0,0 +1,89 @@
1
+ import Foundation
2
+
3
+ #if canImport(NitroModules)
4
+ import NitroModules
5
+ #endif
6
+
7
+ /// What the media stack reports to the Player.
8
+ enum EngineEvent {
9
+ /// The loaded item is now current and observed.
10
+ case itemPrepared(likelyToKeepUp: Bool)
11
+ /// Loading the asset failed; the retry loop classifies the error.
12
+ case loadFailed(Error)
13
+ /// The asset loaded but cannot play.
14
+ case unplayable
15
+ /// The source is not a URL.
16
+ case invalidSource(String)
17
+ /// The player or its item failed, with the HTTP status the server last answered, if any.
18
+ case failed(Error?, httpStatusCode: Int?)
19
+ case likelyToKeepUp(Bool)
20
+ /// `waitingForNoItem`: the player waits because it has no item to play.
21
+ case timeControlStatus(PlayerTimeControlStatus, waitingForNoItem: Bool)
22
+ /// Playback crossed the first millisecond.
23
+ case audioDidStart
24
+ case playedToEnd
25
+ /// The buffer emptied mid-playback.
26
+ case stalled
27
+ }
28
+
29
+ /// The media stack under the Player: the AVPlayer and its items, media loading, now playing and
30
+ /// the remote commands. It carries out what the Player decides and reports what it observes
31
+ /// through `onEvent`; it holds no playback policy. AVPlayerEngine is the one implementation;
32
+ /// tests use a mock.
33
+ @MainActor protocol PlayerEngine: AnyObject {
34
+ var onEvent: (@MainActor (EngineEvent) -> Void)? { get set }
35
+
36
+ // MARK: The current item
37
+
38
+ var hasItem: Bool { get }
39
+ var hasLoadedAsset: Bool { get }
40
+ var currentTime: Double { get }
41
+ var duration: Double { get }
42
+ var bufferedPosition: Double { get }
43
+ /// The position a reload resumes from: nil without an item, or for an indefinite duration.
44
+ var resumePosition: Double? { get }
45
+ /// The end of a live stream's seekable window, when it has one.
46
+ var liveEdge: Double? { get }
47
+ var isPlaybackLikelyToKeepUp: Bool { get }
48
+ var isAirPlayRoute: Bool { get }
49
+ /// 0–1; ramped by the sleep-timer fade.
50
+ var volume: Float { get set }
51
+
52
+ // MARK: Loading
53
+
54
+ /// Runs the media-URL resolver for `track`, so a short-lived URL is minted afresh, and loads
55
+ /// the result.
56
+ func resolveAndLoad(src: String, track: Track)
57
+ /// Loads the last resolved URL again.
58
+ func loadResolved()
59
+ func cancelLoading()
60
+ /// Drops the asset and stops observing the item, leaving it in place for the reload.
61
+ func dropAsset()
62
+ /// Drops the asset and removes the item.
63
+ func clearItem()
64
+ func stopObservingItem()
65
+ /// Replaces the AVPlayer: every handle to the old one is invalid.
66
+ func recreate()
67
+
68
+ // MARK: Transport
69
+
70
+ func play()
71
+ func pause()
72
+ /// Stages `rate` for the next play; applies it now only when `playing`, since a nonzero
73
+ /// AVPlayer rate is a play.
74
+ func setRate(_ rate: Float, playing: Bool)
75
+ /// Seeks the current item exactly; `completion` runs on the main actor.
76
+ func seek(to seconds: Double, completion: @escaping @MainActor (Bool) -> Void)
77
+ func applyTimePitchAlgorithm()
78
+
79
+ // MARK: Now playing and remote commands
80
+
81
+ /// Renders silence so iOS elects the app onto the now-playing surfaces (ADR 0005).
82
+ func primeNowPlaying()
83
+ func loadNowPlayingMetadata(for track: Track)
84
+ func clearNowPlaying()
85
+ func updateRemoteRepeatMode(_ mode: RepeatMode)
86
+ func updateRemoteShuffleMode(_ enabled: Bool)
87
+ /// Greys out next/previous on the remote surfaces when the queue has nowhere to skip.
88
+ func updateSkipAvailability(canNext: Bool, canPrevious: Bool)
89
+ }
@@ -1,9 +1,5 @@
1
- /// Decouples PlaybackCoordinator from AVFoundation enum types.
2
- /// TrackPlayer maps from AVPlayer.TimeControlStatus → PlayerTimeControlStatus at the call boundary.
1
+ /// Decouples Player from AVFoundation enum types.
2
+ /// AVPlayerEngine maps from AVPlayer.TimeControlStatus → PlayerTimeControlStatus at the call boundary.
3
3
  enum PlayerTimeControlStatus {
4
4
  case paused, waitingToPlayAtSpecifiedRate, playing
5
5
  }
6
-
7
- enum PlayerItemStatus {
8
- case unknown, readyToPlay, failed
9
- }
@@ -3,9 +3,16 @@ import Foundation
3
3
  import NitroModules
4
4
  #endif
5
5
 
6
- /// Notifies the owner when the track list changes.
7
- @MainActor protocol QueueManagerDelegate: AnyObject {
8
- func queueDidChangeTracks(_ tracks: [Track])
6
+ /// What a queue mutation changed, reported once the queue has settled.
7
+ struct QueueChange: Equatable {
8
+ /// The track list changed.
9
+ var tracks = false
10
+ /// A different track became current, or the current track was cleared.
11
+ var currentTrack = false
12
+
13
+ static let none = QueueChange()
14
+ static let tracksOnly = QueueChange(tracks: true)
15
+ static let tracksAndCurrent = QueueChange(tracks: true, currentTrack: true)
9
16
  }
10
17
 
11
18
  /// Result of a navigation action (next/previous/skipTo).
@@ -19,11 +26,9 @@ enum QueueNavigationResult {
19
26
  }
20
27
 
21
28
  /// Pure queue logic: tracks, indices, shuffle, repeat.
22
- /// Has no knowledge of AVPlayer or playback — TrackPlayer owns that.
29
+ /// Has no knowledge of AVPlayer or playback — Player and its engine own that.
23
30
  @MainActor
24
31
  class QueueManager {
25
- weak var delegate: QueueManagerDelegate?
26
-
27
32
  // MARK: - Stored Properties
28
33
 
29
34
  /// The index of the current track. `-1` when there is no current track.
@@ -33,11 +38,7 @@ class QueueManager {
33
38
  private(set) var queueSourcePath: String?
34
39
 
35
40
  /// All tracks held by the queue.
36
- private(set) var tracks: [Track] = [] {
37
- didSet {
38
- delegate?.queueDidChangeTracks(tracks)
39
- }
40
- }
41
+ private(set) var tracks: [Track] = []
41
42
 
42
43
  /// The shuffle order for randomized playback.
43
44
  private(set) var shuffleOrder = ShuffleOrder()
@@ -203,38 +204,36 @@ class QueueManager {
203
204
  return .trackChanged
204
205
  }
205
206
 
206
- // MARK: - Mutations (returns Bool: whether current track changed)
207
+ // MARK: - Mutations
207
208
 
208
- /// Replace the entire queue with new tracks.
209
- /// Returns `true` (current track always changes).
209
+ /// Replace the entire queue with new tracks. The current track always changes.
210
210
  @discardableResult
211
- func setQueue(_ newTracks: [Track], initialIndex: Int = 0, sourcePath: String? = nil) -> Bool {
211
+ func setQueue(_ newTracks: [Track], initialIndex: Int = 0, sourcePath: String? = nil) -> QueueChange {
212
212
  let clampedIndex = max(0, min(initialIndex, newTracks.count - 1))
213
213
  currentIndex = clampedIndex
214
214
  queueSourcePath = sourcePath
215
215
  tracks = newTracks
216
216
  shuffleOrder = ShuffleOrder(length: newTracks.count, firstIndex: clampedIndex)
217
- return true
217
+ return .tracksAndCurrent
218
218
  }
219
219
 
220
- /// Add tracks to the end of the queue.
221
- /// Returns `true` if the queue was empty (a new current track was set).
220
+ /// Add tracks to the end of the queue. The current track changes only when the queue was empty.
222
221
  @discardableResult
223
- func add(_ newTracks: [Track], initialIndex: Int = 0) -> Bool {
224
- guard !newTracks.isEmpty else { return false }
222
+ func add(_ newTracks: [Track], initialIndex: Int = 0) -> QueueChange {
223
+ guard !newTracks.isEmpty else { return .none }
225
224
  let wasEmpty = tracks.isEmpty
226
225
  let insertIndex = tracks.count
227
226
  tracks.append(contentsOf: newTracks)
228
227
  if wasEmpty { return adoptInitialCurrent(initialIndex) }
229
228
  shuffleOrder.insert(at: insertIndex, count: newTracks.count)
230
- return false
229
+ return .tracksOnly
231
230
  }
232
231
 
233
- /// Add tracks at a specific index in the queue.
234
- /// Returns `true` if the queue was empty (a new current track was set).
232
+ /// Add tracks at a specific index in the queue. The current track changes only when the queue
233
+ /// was empty.
235
234
  @discardableResult
236
- func addAt(_ newTracks: [Track], at index: Int) throws -> Bool {
237
- guard !newTracks.isEmpty else { return false }
235
+ func addAt(_ newTracks: [Track], at index: Int) throws -> QueueChange {
236
+ guard !newTracks.isEmpty else { return .none }
238
237
  guard index >= 0, tracks.count >= index else {
239
238
  throw TrackPlayerError.QueueError.invalidIndex(
240
239
  index: index,
@@ -249,7 +248,7 @@ class QueueManager {
249
248
  tracks.insert(contentsOf: newTracks, at: index)
250
249
  if wasEmpty { return adoptInitialCurrent(0) }
251
250
  shuffleOrder.insert(at: index, count: newTracks.count)
252
- return false
251
+ return .tracksOnly
253
252
  }
254
253
 
255
254
  /// The queue was empty: `index` becomes the current track AND leads the
@@ -264,44 +263,39 @@ class QueueManager {
264
263
  /// shuffle is on right now, since `shuffleEnabled`'s didSet only re-pins on
265
264
  /// the off→on edge.
266
265
  ///
267
- /// Returns true, so callers can `return adoptInitialCurrent(…)` to report the
268
- /// current track changed.
269
- private func adoptInitialCurrent(_ index: Int) -> Bool {
266
+ /// Returns the change, so callers can `return adoptInitialCurrent(…)`.
267
+ private func adoptInitialCurrent(_ index: Int) -> QueueChange {
270
268
  currentIndex = max(0, min(index, tracks.count - 1))
271
269
  shuffleOrder = ShuffleOrder(length: tracks.count, firstIndex: currentIndex)
272
- return true
270
+ return .tracksAndCurrent
273
271
  }
274
272
 
275
- /// Remove a track from the queue.
276
- /// Returns `true` if the current track changed.
273
+ /// Remove a track from the queue. The current track changes when it is the one removed.
277
274
  @discardableResult
278
- func remove(_ index: Int) throws -> Bool {
275
+ func remove(_ index: Int) throws -> QueueChange {
279
276
  try throwIfQueueEmpty()
280
277
  try throwIfIndexInvalid(index: index)
281
278
  tracks.remove(at: index)
282
279
  shuffleOrder.remove(from: index, to: index + 1)
283
280
  if index == currentIndex {
284
281
  currentIndex = tracks.count > 0 ? currentIndex % tracks.count : -1
285
- return true
282
+ return .tracksAndCurrent
286
283
  } else if index < currentIndex {
287
284
  currentIndex -= 1
288
285
  }
289
- return false
286
+ return .tracksOnly
290
287
  }
291
288
 
292
- /// Move a track in the queue from one position to another.
293
- /// Returns `true` if the current track changed (fromIndex was currentIndex).
289
+ /// Move a track in the queue from one position to another. The current track never changes:
290
+ /// the pointer follows the playing track.
294
291
  @discardableResult
295
- func move(fromIndex: Int, toIndex: Int) throws -> Bool {
292
+ func move(fromIndex: Int, toIndex: Int) throws -> QueueChange {
296
293
  try throwIfQueueEmpty()
297
294
  try throwIfIndexInvalid(index: fromIndex, name: "fromIndex")
298
295
  try throwIfIndexInvalid(index: toIndex, name: "toIndex", max: Int.max)
299
- // Mutate a copy and assign once to trigger didSet only once
300
- var newTracks = tracks
301
- let track = newTracks.remove(at: fromIndex)
302
- let insertion = min(newTracks.count, toIndex)
303
- newTracks.insert(track, at: insertion)
304
- tracks = newTracks
296
+ let track = tracks.remove(at: fromIndex)
297
+ let insertion = min(tracks.count, toIndex)
298
+ tracks.insert(track, at: insertion)
305
299
  // Carry the moved track's shuffle position across the move. `insert(at:count:)`
306
300
  // would drop it at a RANDOM position instead — reordering the queue would then
307
301
  // silently reorder shuffled playback, and moving the playing track could land it
@@ -314,8 +308,6 @@ class QueueManager {
314
308
  shuffleOrder.insert(
315
309
  at: insertion, atShufflePosition: shufflePosition ?? shuffleOrder.count,
316
310
  )
317
- // The pointer follows the playing track; its identity never changes on a
318
- // move, so no caller needs to reload (returning true reloads).
319
311
  if currentIndex == fromIndex {
320
312
  currentIndex = insertion
321
313
  } else if fromIndex < currentIndex, insertion >= currentIndex {
@@ -323,37 +315,38 @@ class QueueManager {
323
315
  } else if fromIndex > currentIndex, insertion <= currentIndex {
324
316
  currentIndex += 1
325
317
  }
326
- return false
318
+ return .tracksOnly
327
319
  }
328
320
 
329
- /// Remove all tracks and reset state.
330
- /// Returns `true` if there was a current track to clear.
321
+ /// Remove all tracks and reset state. Does nothing when there is no current track.
331
322
  @discardableResult
332
- func clear() -> Bool {
333
- guard currentIndex != -1 else { return false }
323
+ func clear() -> QueueChange {
324
+ guard currentIndex != -1 else { return .none }
334
325
  currentIndex = -1
335
326
  tracks.removeAll()
336
327
  shuffleOrder.clear()
337
328
  queueSourcePath = nil
338
- return true
329
+ return .tracksAndCurrent
339
330
  }
340
331
 
341
- // MARK: - Other Mutations
342
-
343
- /// Replace the track at a specific index.
332
+ /// Replace the track at a specific index; which index is current does not change.
344
333
  /// Throws like every other index-taking mutation here, rather than trapping.
345
- func replace(_ index: Int, _ track: Track) throws {
334
+ @discardableResult
335
+ func replace(_ index: Int, _ track: Track) throws -> QueueChange {
346
336
  try throwIfIndexInvalid(index: index)
347
337
  tracks[index] = track
338
+ return .tracksOnly
348
339
  }
349
340
 
350
341
  /// Remove all upcoming tracks (those after currentIndex).
351
- func removeUpcomingTracks() {
352
- guard !tracks.isEmpty else { return }
342
+ @discardableResult
343
+ func removeUpcomingTracks() -> QueueChange {
344
+ guard !tracks.isEmpty else { return .none }
353
345
  let nextIndex = currentIndex + 1
354
- guard nextIndex < tracks.count else { return }
346
+ guard nextIndex < tracks.count else { return .none }
355
347
  let end = tracks.count
356
348
  tracks.removeSubrange(nextIndex ..< end)
357
349
  shuffleOrder.remove(from: nextIndex, to: end)
350
+ return .tracksOnly
358
351
  }
359
352
  }
@@ -0,0 +1,261 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ #if canImport(NitroModules)
4
+ import NitroModules
5
+ #endif
6
+ import os.log
7
+
8
+ /// Abstracts `NetworkMonitor` so tests can fake connectivity. Sendable because
9
+ /// the network-restore race reads it from a task-group child.
10
+ protocol NetworkStatusProviding: AnyObject, Sendable {
11
+ var isOnline: Bool { get }
12
+ }
13
+
14
+ /// Distinguishes playback failures from media-load failures.
15
+ /// Each context carries the retry strategy and the fallback error classification.
16
+ public enum PlaybackErrorContext {
17
+ /// AVPlayer/AVPlayerItem status failure — retry from current time, fallback `.playbackFailed`.
18
+ case playback
19
+ /// MediaLoader key-value load failure — retry from start, fallback `.failedToLoadKeyValue`.
20
+ case mediaLoad
21
+
22
+ var startFromCurrentTime: Bool {
23
+ switch self {
24
+ case .playback: true
25
+ case .mediaLoad: false
26
+ }
27
+ }
28
+
29
+ var fallbackError: TrackPlayerError.PlaybackError {
30
+ switch self {
31
+ case .playback: .playbackFailed
32
+ case .mediaLoad: .failedToLoadKeyValue
33
+ }
34
+ }
35
+ }
36
+
37
+ /// Retries failed loads: classifies each failure (`PlaybackFailure`), reports the ones it is
38
+ /// working on as advisory, and reloads after an exponential backoff until a budget runs out,
39
+ /// when it reports the error as terminal. Similar to Android's RetryLoadErrorHandlingPolicy.
40
+ ///
41
+ /// Two duration budgets apply, chosen by whether the current Load has produced audio
42
+ /// (`hasPlayed`, ADR 0004): a short first-connect budget, since a stream that fails before ever
43
+ /// playing is usually dead and the listener is waiting for a verdict, and the full recovery
44
+ /// budget for a stream that played and dropped. The short budget counts only online time, and
45
+ /// while offline a scheduled retry fires as soon as connectivity returns.
46
+ @MainActor
47
+ final class RetryLoop {
48
+ private let logger = Logger(subsystem: "com.audiobrowser", category: "RetryLoop")
49
+
50
+ enum Policy {
51
+ case disabled
52
+ case infinite
53
+ case limited(maxRetries: Int)
54
+ }
55
+
56
+ private static let defaultMaxRetryDurationMs: Double = 120_000
57
+ private static let defaultFirstConnectMaxRetryDurationMs: Double = 12_000
58
+
59
+ /// Prevents surprising playback resumption after long periods offline.
60
+ private var maxRetryDuration: TimeInterval = defaultMaxRetryDurationMs / 1000
61
+ /// Bounds retries of a load that has never produced audio, while online.
62
+ private var firstConnectMaxRetryDuration: TimeInterval = defaultFirstConnectMaxRetryDurationMs / 1000
63
+
64
+ /// Whether the current Load has produced audio. Read from the Load, which a mid-play `reset()`
65
+ /// (the healthy-playback refill) leaves alone.
66
+ var hasPlayed: () -> Bool = { false }
67
+ /// Whether a retry is still wanted (the play intent); checked before and after the backoff.
68
+ var shouldRetry: () -> Bool = { true }
69
+ /// Reloads, from the current time or from the start.
70
+ var onRetry: ((Bool) -> Void)?
71
+ /// The retries gave up, or the failure cannot be retried.
72
+ var onError: ((TrackPlayerError.PlaybackError) -> Void)?
73
+ /// A failure a retry is now scheduled for, reported before the backoff so UIs can show the
74
+ /// cause while the state stays non-terminal.
75
+ var onRetryingError: ((TrackPlayerError.PlaybackError) -> Void)?
76
+
77
+ weak var networkMonitor: (any NetworkStatusProviding)?
78
+
79
+ /// The retry in flight; session release waits while there is one.
80
+ private(set) var pendingTask: Task<Void, Never>?
81
+ /// True only while a scheduled retry waits for connectivity to return, so a stall-driven
82
+ /// reconnect can defer to it rather than load twice.
83
+ private(set) var isWaitingForNetwork = false
84
+ /// Retries executed since the budgets last reset.
85
+ private(set) var attempts = 0
86
+
87
+ private var policy: Policy = .disabled
88
+ private var firstRetryTime: Date?
89
+ /// First failure observed while online for the current load: the first-connect budget's
90
+ /// clock. Never set while offline, and wiped by any offline observation, so an offline stretch
91
+ /// cannot burn the short budget.
92
+ private var firstOnlineRetryTime: Date?
93
+
94
+ private let sleep: @Sendable (TimeInterval) async throws -> Void
95
+
96
+ /// - Parameter sleep: Waits out a backoff or a connectivity poll; tests make it instant.
97
+ init(sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) {
98
+ self.sleep = sleep
99
+ }
100
+
101
+ var isEnabled: Bool {
102
+ if case .disabled = policy { return false }
103
+ return true
104
+ }
105
+
106
+ // MARK: - Configuration
107
+
108
+ func updatePolicy(from config: Variant_Bool_RetryConfig?) {
109
+ maxRetryDuration = Self.defaultMaxRetryDurationMs / 1000
110
+ firstConnectMaxRetryDuration = Self.defaultFirstConnectMaxRetryDurationMs / 1000
111
+ switch config {
112
+ case nil:
113
+ policy = .disabled
114
+ case let .first(enabled):
115
+ policy = enabled ? .infinite : .disabled
116
+ case let .second(retryConfig):
117
+ maxRetryDuration = (retryConfig.maxRetryDurationMs ?? Self.defaultMaxRetryDurationMs) / 1000
118
+ firstConnectMaxRetryDuration =
119
+ (retryConfig.firstConnectMaxRetryDurationMs ?? Self.defaultFirstConnectMaxRetryDurationMs) / 1000
120
+ // No attempt cap: retry indefinitely, bounded only by the duration.
121
+ policy = retryConfig.maxRetries.map { .limited(maxRetries: Int($0)) } ?? .infinite
122
+ }
123
+ logger.debug("Retry policy: \(String(describing: self.policy)), max duration \(self.maxRetryDuration)s")
124
+ }
125
+
126
+ // MARK: - Failures
127
+
128
+ func handleError(_ error: Error?, context: PlaybackErrorContext, httpStatusCode: Int? = nil) {
129
+ if let error {
130
+ let nsError = error as NSError
131
+ logger.error("[\(String(describing: context))] failure: domain=\(nsError.domain), code=\(nsError.code), localizedDescription=\(error.localizedDescription)")
132
+ } else {
133
+ logger.error("[\(String(describing: context))] failure with nil error")
134
+ }
135
+
136
+ let failure = PlaybackFailure.classify(error, httpStatusCode: httpStatusCode, fallback: context.fallbackError)
137
+ guard isEnabled, failure.retryable else {
138
+ logger.warning("Error not retryable, surfacing \(String(describing: failure.error))")
139
+ onError?(failure.error)
140
+ return
141
+ }
142
+ onRetryingError?(failure.error)
143
+ pendingTask?.cancel()
144
+ pendingTask = Task { [weak self] in
145
+ guard let self else { return }
146
+ let retried = await attemptRetry(startFromCurrentTime: context.startFromCurrentTime)
147
+ // Cancelled (track change, stop): stay silent, or the new track or a deliberate stop
148
+ // would flip to .error. The canceller owns the handle.
149
+ if Task.isCancelled { return }
150
+ if !retried { onError?(failure.error) }
151
+ pendingTask = nil
152
+ }
153
+ }
154
+
155
+ /// Drops the retry in flight without touching the budgets.
156
+ func cancel() {
157
+ pendingTask?.cancel()
158
+ pendingTask = nil
159
+ }
160
+
161
+ /// Drops the retry in flight and restarts the budgets.
162
+ func reset() {
163
+ cancel()
164
+ attempts = 0
165
+ firstRetryTime = nil
166
+ firstOnlineRetryTime = nil
167
+ isWaitingForNetwork = false
168
+ }
169
+
170
+ // MARK: - Budgets
171
+
172
+ private var isOnline: Bool { networkMonitor?.isOnline ?? true }
173
+
174
+ /// The exhausted budget, or nil while budget remains. The recovery duration bounds everything,
175
+ /// offline waits included; the first-connect duration also bounds never-played loads, online
176
+ /// only.
177
+ private func exhaustedBudget() -> String? {
178
+ let now = Date()
179
+ if let start = firstRetryTime, now.timeIntervalSince(start) >= maxRetryDuration {
180
+ return "max retry duration (\(maxRetryDuration)s)"
181
+ }
182
+ if !hasPlayed(), isOnline, let start = firstOnlineRetryTime,
183
+ now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
184
+ {
185
+ return "first-connect retry duration (\(firstConnectMaxRetryDuration)s)"
186
+ }
187
+ return nil
188
+ }
189
+
190
+ /// 1 s, 1.5 s, 2.3 s, 3.4 s, then 5 s.
191
+ private var backoff: TimeInterval {
192
+ min(1.0 * pow(1.5, Double(attempts)), 5.0)
193
+ }
194
+
195
+ /// Waits out the backoff, or connectivity's return, then reloads. False when the policy, the
196
+ /// play intent or a budget says stop, or the wait was cancelled.
197
+ func attemptRetry(startFromCurrentTime: Bool) async -> Bool {
198
+ guard isEnabled, shouldRetry() else { return false }
199
+ if case let .limited(maxRetries) = policy, attempts >= maxRetries {
200
+ logger.info("Max retries (\(maxRetries)) exceeded, giving up")
201
+ return false
202
+ }
203
+
204
+ if firstRetryTime == nil { firstRetryTime = Date() }
205
+ if isOnline {
206
+ if firstOnlineRetryTime == nil { firstOnlineRetryTime = Date() }
207
+ } else {
208
+ // Offline restarts the first-connect clock: the station gets its full online seconds
209
+ // again once connectivity returns.
210
+ firstOnlineRetryTime = nil
211
+ }
212
+ if let budget = exhaustedBudget() {
213
+ logger.info("\(budget) exceeded, giving up")
214
+ return false
215
+ }
216
+
217
+ logger.info("Scheduling retry after \(self.backoff)s")
218
+ guard await waitForDelayOrNetworkRestored(delaySeconds: backoff), !Task.isCancelled, shouldRetry() else {
219
+ return false
220
+ }
221
+ // Re-checked: the wait for connectivity may have been long.
222
+ if let budget = exhaustedBudget() {
223
+ logger.info("\(budget) exceeded after waiting, giving up")
224
+ return false
225
+ }
226
+
227
+ attempts += 1
228
+ logger.info("Executing retry #\(self.attempts)")
229
+ onRetry?(startFromCurrentTime)
230
+ return true
231
+ }
232
+
233
+ // MARK: - Network-Aware Waiting
234
+
235
+ /// True when it is time to retry, false when the wait was cancelled.
236
+ private func waitForDelayOrNetworkRestored(delaySeconds: TimeInterval) async -> Bool {
237
+ guard let monitor = networkMonitor, !monitor.isOnline else {
238
+ return await (try? sleep(delaySeconds)) != nil
239
+ }
240
+ logger.debug("Device is offline, will retry immediately when connectivity is restored")
241
+ let sleep = sleep
242
+ return await withTaskGroup(of: Bool.self, returning: Bool.self) { group in
243
+ group.addTask { await (try? sleep(delaySeconds)) != nil }
244
+ group.addTask { [weak self] in await self?.waitForNetworkRestored(monitor: monitor) ?? false }
245
+ let result = await group.next() ?? false
246
+ group.cancelAll()
247
+ return result
248
+ }
249
+ }
250
+
251
+ /// True when connectivity returned, false when cancelled.
252
+ private func waitForNetworkRestored(monitor: any NetworkStatusProviding) async -> Bool {
253
+ isWaitingForNetwork = true
254
+ defer { isWaitingForNetwork = false }
255
+ while !monitor.isOnline {
256
+ guard await (try? sleep(0.5)) != nil else { return false }
257
+ }
258
+ logger.info("Network restored, accelerating retry")
259
+ return true
260
+ }
261
+ }
@@ -40,7 +40,7 @@ struct MainQueueSleepTimerScheduler: SleepTimerScheduling {
40
40
  /// Manages sleep timer functionality for the audio player.
41
41
  /// Supports both time-based timers and end-of-track timers.
42
42
  @MainActor
43
- class SleepTimerManager: SleepTimerHandling {
43
+ final class SleepTimerManager {
44
44
  // MARK: - Properties
45
45
 
46
46
  private let scheduler: any SleepTimerScheduling
@@ -5,9 +5,8 @@
5
5
  /// The callbacks `RemoteCommandController` invokes when the system delivers a
6
6
  /// remote command (lock screen, Control Center, CarPlay, headset).
7
7
  ///
8
- /// Split out of `TrackPlayerCallbacks` — which the controller does not otherwise
9
- /// use — so the controller depends only on what it calls. `TrackPlayerCallbacks`
10
- /// extends this protocol, so conformers implement both at once.
8
+ /// Its own protocol so the controller depends only on what it calls.
9
+ /// `AVPlayerEngineCallbacks` extends it, so conformers implement both at once.
11
10
  @MainActor protocol RemoteCommandCallbacks: AnyObject {
12
11
  /// Called when play is triggered remotely.
13
12
  func remotePlay()