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
@@ -1,54 +0,0 @@
1
- #if canImport(NitroModules)
2
- import NitroModules
3
- #endif
4
-
5
- /// Protocol through which PlaybackCoordinator triggers AVPlayer-specific operations.
6
- /// In production, TrackPlayer implements it. In tests, a mock does.
7
- @MainActor protocol PlaybackEffectHandler: AnyObject {
8
- // Playback control
9
- func startPlayback()
10
- func pausePlayback()
11
- func setTimePitchingAlgorithmForCurrentItem()
12
-
13
- // Player volume (0-1); ramped by the sleep-timer fade
14
- var volume: Float { get set }
15
-
16
- // AVPlayer state queries
17
- var currentTime: Double { get }
18
- var duration: Double { get }
19
- var bufferedPosition: Double { get }
20
- var hasLoadedAsset: Bool { get }
21
-
22
- // Item management
23
- func clearCurrentItem()
24
- func stopObservingCurrentItem()
25
-
26
- // Track loading. The full `track` is threaded alongside `src` so the
27
- // media-URL resolver can invoke the consumer's `media.resolve(track)`.
28
- func loadTrack(src: String, track: Track)
29
- /// Reloads the current track, re-running the media-URL resolver so a
30
- /// short-lived/expired URL is refreshed rather than replayed.
31
- func reloadTrack(startFromCurrentTime: Bool)
32
- func unloadTrack()
33
- func cancelMediaLoading()
34
-
35
- // Seek operations
36
- func seekToStart()
37
- func replayCurrentTrack()
38
-
39
- // Now Playing (behind protocol to avoid MediaPlayer import).
40
- // Elapsed/rate/duration are published automatically by MPNowPlayingSession;
41
- // only metadata and the explicit play/pause state flow through here.
42
- func loadNowPlayingMetadata(for track: Track)
43
- func clearNowPlaying()
44
- /// Reflects play/pause intent in the now-playing center. Auto-publishing fills
45
- /// the info dict but not the explicit playback state CarPlay reads for its button.
46
- func updateNowPlayingState(playWhenReady: Bool)
47
-
48
- // Remote commands (behind protocol to avoid MediaPlayer import)
49
- func updateRemoteRepeatMode(_ mode: RepeatMode)
50
- func updateRemoteShuffleMode(_ enabled: Bool)
51
- /// Greys out the remote/CarPlay next/previous buttons when the queue has no
52
- /// next/previous track to skip to.
53
- func updateSkipAvailability(canNext: Bool, canPrevious: Bool)
54
- }
@@ -1,175 +0,0 @@
1
- import AVFoundation
2
- import Foundation
3
- import os.log
4
-
5
- /// Abstracts RetryManager for testability.
6
- @MainActor public protocol RetryHandling: AnyObject {
7
- /// False when the configured policy is `disabled` — every error is then terminal.
8
- var isEnabled: Bool { get }
9
- /// Whether the current load has produced audio — selects the retry budget.
10
- var hasPlayed: Bool { get set }
11
- func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool
12
- func attemptRetry(startFromCurrentTime: Bool) async -> Bool
13
- func reset()
14
- }
15
-
16
- /// Distinguishes playback failures from media-load failures.
17
- /// Each context carries the retry strategy and the fallback error classification.
18
- public enum PlaybackErrorContext {
19
- /// AVPlayer/AVPlayerItem status failure — retry from current time, fallback `.playbackFailed`.
20
- case playback
21
- /// MediaLoader key-value load failure — retry from start, fallback `.failedToLoadKeyValue`.
22
- case mediaLoad
23
-
24
- var startFromCurrentTime: Bool {
25
- switch self {
26
- case .playback: true
27
- case .mediaLoad: false
28
- }
29
- }
30
-
31
- var fallbackError: TrackPlayerError.PlaybackError {
32
- switch self {
33
- case .playback: .playbackFailed
34
- case .mediaLoad: .failedToLoadKeyValue
35
- }
36
- }
37
- }
38
-
39
- /// Consolidates error retry/classification logic previously duplicated between
40
- /// `handlePlaybackFailure` and `mediaLoaderDidFailWithRetryableError`.
41
- @MainActor public class PlaybackErrorHandler {
42
- private let logger = Logger(subsystem: "com.audiobrowser", category: "PlaybackErrorHandler")
43
-
44
- var onError: ((TrackPlayerError.PlaybackError) -> Void)?
45
- /// A failure the retry loop is still working on. Fired when a retry is
46
- /// scheduled, before its backoff elapses, so UIs can show the cause while
47
- /// the playback state stays non-terminal. `onError` still fires if the
48
- /// retries give up.
49
- var onRetryingError: ((TrackPlayerError.PlaybackError) -> Void)?
50
- private(set) var pendingRetryTask: Task<Void, Never>?
51
- private let retryHandler: any RetryHandling
52
-
53
- init(retryHandler: any RetryHandling) {
54
- self.retryHandler = retryHandler
55
- }
56
-
57
- func handleError(_ error: Error?, context: PlaybackErrorContext, httpStatusCode: Int? = nil) {
58
- if let error {
59
- let nsError = error as NSError
60
- logger.error("[\(String(describing: context))] failure: domain=\(nsError.domain), code=\(nsError.code), localizedDescription=\(error.localizedDescription)")
61
- } else {
62
- logger.error("[\(String(describing: context))] failure with nil error")
63
- }
64
-
65
- if retryHandler.isEnabled, retryHandler.isRetryable(error, httpStatusCode: httpStatusCode) {
66
- onRetryingError?(PlaybackErrorHandler.classify(
67
- error: error, fallback: context.fallbackError, httpStatusCode: httpStatusCode,
68
- ))
69
- pendingRetryTask?.cancel()
70
- pendingRetryTask = Task { [weak self] in
71
- guard let self else { return }
72
- let retried = await retryHandler.attemptRetry(startFromCurrentTime: context.startFromCurrentTime)
73
- // Cancelled (track change / stop): stay silent — attemptRetry returns
74
- // false on a cancelled wait, and surfacing that would flip the new
75
- // track or a deliberate stop to .error. The canceller owns the handle.
76
- if Task.isCancelled { return }
77
- if !retried {
78
- let classified = PlaybackErrorHandler.classify(error: error, fallback: context.fallbackError, httpStatusCode: httpStatusCode)
79
- self.onError?(classified)
80
- }
81
- // Clear the handle: session release is gated on no pending retry.
82
- pendingRetryTask = nil
83
- }
84
- return
85
- }
86
-
87
- logger.warning("Error not retryable, surfacing \(String(describing: context.fallbackError))")
88
- let classified = PlaybackErrorHandler.classify(error: error, fallback: context.fallbackError, httpStatusCode: httpStatusCode)
89
- onError?(classified)
90
- }
91
-
92
- func cancelPendingRetry() {
93
- pendingRetryTask?.cancel()
94
- pendingRetryTask = nil
95
- }
96
-
97
- func resetRetry() {
98
- cancelPendingRetry()
99
- retryHandler.reset()
100
- }
101
-
102
- /// A restart from a terminal error begins a new load of the same track:
103
- /// fresh budgets and unproven playback, so the user's tap yields a visible
104
- /// first-connect retry window instead of a single silent attempt — the same
105
- /// behavior as re-selecting the track (ADR 0004).
106
- func resetForNewLoad() {
107
- resetRetry()
108
- retryHandler.hasPlayed = false
109
- }
110
-
111
- /// Classifies an error into the narrowest case the evidence supports, so JS
112
- /// receives a real `kind` rather than a context fallback.
113
- ///
114
- /// Order matters: a transport failure means we never got a response, so it
115
- /// outranks any status left in the item's error log. AVFoundation reports
116
- /// HTTP failures as opaque CoreMedia errors, which is why `httpStatusCode`
117
- /// is passed in separately by the caller rather than read off `error`.
118
- public static func classify(
119
- error: Error?,
120
- fallback: TrackPlayerError.PlaybackError,
121
- httpStatusCode: Int? = nil,
122
- ) -> TrackPlayerError.PlaybackError {
123
- // `as?` bridges plain `NSError`s in `NSURLErrorDomain` too, which is the
124
- // form AVFoundation usually hands back.
125
- if let urlError = error as? URLError {
126
- switch urlError.code {
127
- case .notConnectedToInternet:
128
- return .notConnectedToInternet
129
- case .timedOut,
130
- .cannotFindHost,
131
- .cannotConnectToHost,
132
- .networkConnectionLost,
133
- .dnsLookupFailed,
134
- .secureConnectionFailed:
135
- return .hostUnreachable
136
- default:
137
- break
138
- }
139
- }
140
-
141
- if let httpStatusCode, !(200 ... 299).contains(httpStatusCode) {
142
- return .httpStatus(httpStatusCode)
143
- }
144
-
145
- if isUnplayableMedia(error) {
146
- return .trackWasUnplayable
147
- }
148
-
149
- return fallback
150
- }
151
-
152
- /// Picks the HTTP status out of an `AVPlayerItem` error log's status codes.
153
- ///
154
- /// `errorStatusCode` is a mixed space — negative OSStatus values share it
155
- /// with HTTP statuses — so only in-range values count, and the newest wins:
156
- /// a live stream reconnects, and older entries describe earlier attempts.
157
- public static func httpStatusCode(fromErrorStatusCodes codes: [Int]) -> Int? {
158
- codes.last { (100 ... 599).contains($0) }
159
- }
160
-
161
- /// The stream was fetched but cannot be decoded — unknown container,
162
- /// unsupported codec, or a decoder that refused it.
163
- private static func isUnplayableMedia(_ error: Error?) -> Bool {
164
- guard let nsError = error as NSError? else { return false }
165
- guard nsError.domain == AVFoundationErrorDomain else { return false }
166
- return [
167
- AVError.fileFormatNotRecognized,
168
- AVError.failedToParse,
169
- AVError.decoderNotFound,
170
- AVError.decoderTemporarilyUnavailable,
171
- AVError.formatUnsupported,
172
- AVError.contentIsUnavailable,
173
- ].contains { $0.rawValue == nsError.code }
174
- }
175
- }
@@ -1,27 +0,0 @@
1
- import Foundation
2
-
3
- /// Persists the player's resumable state to UserDefaults so a cold-start
4
- /// "resume" intent can restore the last session without the JS runtime.
5
- /// Swift counterpart of Android's `PlaybackStateStore.kt`.
6
- final class PlaybackStateStore {
7
- private let defaults: UserDefaults
8
- private let key = "playbackState.v1"
9
-
10
- init(defaults: UserDefaults = UserDefaults(suiteName: "com.audiobrowser.playback") ?? .standard) {
11
- self.defaults = defaults
12
- }
13
-
14
- func save(_ state: PersistedPlaybackState) {
15
- guard let data = try? JSONEncoder().encode(state) else { return }
16
- defaults.set(data, forKey: key)
17
- }
18
-
19
- func load() -> PersistedPlaybackState? {
20
- guard let data = defaults.data(forKey: key) else { return nil }
21
- return try? JSONDecoder().decode(PersistedPlaybackState.self, from: data)
22
- }
23
-
24
- func clear() {
25
- defaults.removeObject(forKey: key)
26
- }
27
- }
@@ -1,33 +0,0 @@
1
- import Foundation
2
- #if canImport(NitroModules)
3
- import NitroModules
4
- #endif
5
-
6
- /// Manages the playing state (playing and buffering flags) and notifies when they change.
7
- @MainActor
8
- class PlayingStateManager {
9
- private(set) var playing: Bool = false
10
- private(set) var buffering: Bool = false
11
-
12
- private let onChange: (PlayingState) -> Void
13
-
14
- init(onChange: @escaping (PlayingState) -> Void) {
15
- self.onChange = onChange
16
- }
17
-
18
- func update(playWhenReady: Bool, state: PlaybackState) {
19
- let newPlaying =
20
- playWhenReady && !(state == .error || state == .ended || state == .none || state == .stopped)
21
- let newBuffering = playWhenReady && (state == .loading || state == .buffering)
22
-
23
- if newPlaying != playing || newBuffering != buffering {
24
- playing = newPlaying
25
- buffering = newBuffering
26
- onChange(PlayingState(playing: playing, buffering: buffering))
27
- }
28
- }
29
-
30
- func toPlayingState() -> PlayingState {
31
- PlayingState(playing: playing, buffering: buffering)
32
- }
33
- }
@@ -1,384 +0,0 @@
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
- /// Manages retry logic for media load errors with exponential backoff.
15
- /// Similar to Android's RetryLoadErrorHandlingPolicy.
16
- ///
17
- /// Two duration budgets apply, chosen by whether the current load has ever
18
- /// produced audio (`hasPlayed`): a short first-connect budget — a stream that
19
- /// fails before ever playing is usually dead, and the listener is actively
20
- /// waiting for a verdict — and the full recovery budget for a stream that
21
- /// played and then dropped (tunnels, handovers, encoder restarts). The short
22
- /// budget counts only online time, so a station tapped in a tunnel still gets
23
- /// its online seconds once connectivity returns.
24
- ///
25
- /// When a network monitor is provided and the device is offline, the retry will
26
- /// trigger immediately when connectivity is restored instead of waiting for the
27
- /// full backoff delay.
28
- @MainActor
29
- class RetryManager {
30
- private let logger = Logger(subsystem: "com.audiobrowser", category: "RetryManager")
31
-
32
- enum Policy {
33
- case disabled
34
- case infinite
35
- case limited(maxRetries: Int)
36
- }
37
-
38
- private static let defaultMaxRetryDurationMs: Double = 120_000 // 2 minutes
39
- private static let defaultFirstConnectMaxRetryDurationMs: Double = 12_000
40
-
41
- /// Prevents surprising playback resumption after long periods offline.
42
- private var maxRetryDuration: TimeInterval = defaultMaxRetryDurationMs / 1000
43
-
44
- /// Bounds retries of a load that has never produced audio, while online.
45
- private var firstConnectMaxRetryDuration: TimeInterval = defaultFirstConnectMaxRetryDurationMs / 1000
46
-
47
- /// True once the current load has produced audio; selects between the
48
- /// first-connect and recovery budgets. Set by the owner when audio starts and
49
- /// cleared by the owner when a new track loads — deliberately NOT cleared in
50
- /// `reset()`, which the healthy-playback budget refill also calls mid-play.
51
- var hasPlayed = false
52
-
53
- /// First failure observed while online for the current load — the
54
- /// first-connect budget's clock. Never set while offline, and wiped by any
55
- /// offline observation, so an offline stretch cannot burn the short budget:
56
- /// the clock restarts at the next failure observed online.
57
- private var firstOnlineRetryTime: Date?
58
-
59
- private var policy: Policy = .disabled
60
- private var attemptCount = 0
61
- private var firstRetryTime: Date?
62
-
63
- weak var networkMonitor: (any NetworkStatusProviding)?
64
- /// True only while a scheduled retry is parked polling for connectivity to return. Exposed so the
65
- /// player's stall-driven reconnect can defer to an in-flight error retry (which owns its own
66
- /// reload) and avoid a double load.
67
- private(set) var isWaitingForNetwork = false
68
- /// Invalidates in-flight retries when reset() is called (e.g. track change)
69
- private var generation: Int = 0
70
-
71
- var shouldRetry: () -> Bool = { true }
72
- var onRetry: ((Bool) -> Void)?
73
-
74
- var isEnabled: Bool {
75
- if case .disabled = policy { return false }
76
- return true
77
- }
78
-
79
- // MARK: - Configuration
80
-
81
- func updatePolicy(from config: Variant_Bool_RetryConfig?) {
82
- guard let config else {
83
- policy = .disabled
84
- maxRetryDuration = Self.defaultMaxRetryDurationMs / 1000
85
- firstConnectMaxRetryDuration = Self.defaultFirstConnectMaxRetryDurationMs / 1000
86
- logger.debug("Retry policy: disabled")
87
- return
88
- }
89
-
90
- switch config {
91
- case let .first(enabled):
92
- policy = enabled ? .infinite : .disabled
93
- maxRetryDuration = Self.defaultMaxRetryDurationMs / 1000
94
- firstConnectMaxRetryDuration = Self.defaultFirstConnectMaxRetryDurationMs / 1000
95
- logger.debug("Retry policy: \(enabled ? "infinite" : "disabled")")
96
- case let .second(retryConfig):
97
- let durationMs = retryConfig.maxRetryDurationMs ?? Self.defaultMaxRetryDurationMs
98
- maxRetryDuration = durationMs / 1000
99
- let firstConnectMs = retryConfig.firstConnectMaxRetryDurationMs
100
- ?? Self.defaultFirstConnectMaxRetryDurationMs
101
- firstConnectMaxRetryDuration = firstConnectMs / 1000
102
- // No attempt cap = retry indefinitely, bounded only by the duration.
103
- if let maxRetries = retryConfig.maxRetries.map(Int.init) {
104
- policy = .limited(maxRetries: maxRetries)
105
- logger.debug("Retry policy: limited to \(maxRetries) retries, max duration \(self.maxRetryDuration)s")
106
- } else {
107
- policy = .infinite
108
- logger.debug("Retry policy: infinite, max duration \(self.maxRetryDuration)s")
109
- }
110
- }
111
- }
112
-
113
- // MARK: - Exponential Backoff
114
-
115
- /// Delays: 1s -> 1.5s -> 2.3s -> 3.4s -> 5s (capped)
116
- private func calculateDelaySeconds() -> Double {
117
- let baseDelay = 1.0
118
- let multiplier = 1.5
119
- let maxDelay = 5.0
120
-
121
- return min(baseDelay * pow(multiplier, Double(attemptCount)), maxDelay)
122
- }
123
-
124
- // MARK: - Error Classification
125
-
126
- /// HTTP statuses worth another attempt, beyond the 500–599 range checked in
127
- /// `isRetryable`. Android's `RetryLoadErrorHandlingPolicy` shares 408/429 but
128
- /// enumerates only 500/502/503/504 of the 5xx range, so uncommon 5xx codes
129
- /// (501, 505…) retry here and not there.
130
- private static let retryableHTTPStatusCodes: Set<Int> = [408, 429]
131
-
132
- /// AVFoundation failures a retry cannot fix: the media is unusable rather
133
- /// than the transport unreliable. Everything not listed falls through to
134
- /// retry — `AVErrorUnknown` especially, which is the wrapper AVFoundation
135
- /// puts around opaque CoreMedia transport failures.
136
- private static let fatalAVErrorCodes: Set<Int> = [
137
- AVError.fileFormatNotRecognized.rawValue,
138
- AVError.failedToParse.rawValue,
139
- AVError.decodeFailed.rawValue,
140
- AVError.contentIsProtected.rawValue,
141
- AVError.contentIsNotAuthorized.rawValue,
142
- ]
143
-
144
- /// Whether another attempt could plausibly succeed.
145
- ///
146
- /// Mirrors Android's `classifyError`, including its default: an unrecognized
147
- /// failure is treated as *transient* and retried, bounded by the retry
148
- /// budgets, rather than being terminal. Defaulting the other way made
149
- /// `retry: true` largely inert on iOS, because AVFoundation reports most
150
- /// stream failures as opaque `CoreMediaErrorDomain` / `AVFoundationErrorDomain`
151
- /// errors rather than `URLError`.
152
- ///
153
- /// - Parameter httpStatusCode: The status from the item's error log, when the
154
- /// caller has one. `AVPlayerItem.error` never carries it — a 404 arrives as
155
- /// an opaque CoreMedia error — so this is the only reliable HTTP signal and
156
- /// it outranks the error itself.
157
- func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
158
- if let status = httpStatusCode {
159
- let retryable = Self.retryableHTTPStatusCodes.contains(status) || (500 ... 599).contains(status)
160
- logger.debug("HTTP \(status) \(retryable ? "is" : "not") retryable")
161
- return retryable
162
- }
163
-
164
- guard let error else { return false }
165
-
166
- // Walk the underlying-error chain: AVFoundation routinely wraps the real
167
- // cause (a URLError, or a CoreMedia code) in an AVError.
168
- for candidate in Self.errorChain(error) {
169
- if let urlError = candidate as? URLError {
170
- switch urlError.code {
171
- // Transient network errors - safe to retry
172
- case .timedOut,
173
- .networkConnectionLost,
174
- .notConnectedToInternet,
175
- .cannotConnectToHost,
176
- .cannotFindHost,
177
- .dnsLookupFailed:
178
- logger.debug("URLError is retryable: code=\(urlError.code.rawValue)")
179
- return true
180
- default:
181
- logger.debug("URLError not retryable: code=\(urlError.code.rawValue)")
182
- return false
183
- }
184
- }
185
-
186
- let nsError = candidate as NSError
187
- if nsError.domain == AVFoundationErrorDomain,
188
- Self.fatalAVErrorCodes.contains(nsError.code)
189
- {
190
- logger.debug("AVError not retryable: code=\(nsError.code)")
191
- return false
192
- }
193
- }
194
-
195
- let nsError = error as NSError
196
- logger.debug("Treating as transient: domain=\(nsError.domain), code=\(nsError.code)")
197
- return true
198
- }
199
-
200
- /// The error and its `NSUnderlyingError` ancestors, outermost first.
201
- private static func errorChain(_ error: Error) -> [Error] {
202
- var chain: [Error] = []
203
- var current: Error? = error
204
- // Bounded: a malformed cycle must not spin here.
205
- while let error = current, chain.count < 8 {
206
- chain.append(error)
207
- current = (error as NSError).userInfo[NSUnderlyingErrorKey] as? Error
208
- }
209
- return chain
210
- }
211
-
212
- // MARK: - Retry Management
213
-
214
- func reset() {
215
- attemptCount = 0
216
- firstRetryTime = nil
217
- firstOnlineRetryTime = nil
218
- isWaitingForNetwork = false
219
- generation += 1
220
- logger.debug("Retry count reset (generation \(self.generation))")
221
- }
222
-
223
- private var isOnline: Bool { networkMonitor?.isOnline ?? true }
224
-
225
- /// A description of the exhausted budget, or nil while budget remains.
226
- /// The recovery duration bounds everything (including offline waits); the
227
- /// first-connect duration additionally bounds never-played loads, online only.
228
- private func exhaustedBudget() -> String? {
229
- let now = Date()
230
- if let start = firstRetryTime, now.timeIntervalSince(start) >= maxRetryDuration {
231
- return "max retry duration (\(maxRetryDuration)s)"
232
- }
233
- if !hasPlayed, isOnline, let start = firstOnlineRetryTime,
234
- now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
235
- {
236
- return "first-connect retry duration (\(firstConnectMaxRetryDuration)s)"
237
- }
238
- return nil
239
- }
240
-
241
- func attemptRetry(startFromCurrentTime: Bool) async -> Bool {
242
- if case .disabled = policy {
243
- logger.debug("Retry disabled, not retrying")
244
- return false
245
- }
246
-
247
- guard shouldRetry() else {
248
- logger.debug("shouldRetry returned false, not retrying")
249
- return false
250
- }
251
-
252
- if case let .limited(maxRetries) = policy {
253
- if attemptCount >= maxRetries {
254
- logger.info("Max retries (\(maxRetries)) exceeded, giving up")
255
- return false
256
- }
257
- }
258
-
259
- if firstRetryTime == nil {
260
- firstRetryTime = Date()
261
- }
262
- if isOnline {
263
- if firstOnlineRetryTime == nil { firstOnlineRetryTime = Date() }
264
- } else {
265
- // Seeing the device offline restarts the first-connect clock: a station
266
- // that lost connectivity mid-budget gets its full online seconds again
267
- // after restoration, instead of the offline gap counting against it.
268
- firstOnlineRetryTime = nil
269
- }
270
-
271
- // Check if we've been retrying too long (prevents surprising resumption
272
- // after long offline periods, and grants dead-on-arrival streams a fast verdict)
273
- if let budget = exhaustedBudget() {
274
- logger.info("\(budget) exceeded, giving up")
275
- return false
276
- }
277
-
278
- let delaySeconds = calculateDelaySeconds()
279
- let currentGeneration = generation
280
- logger.info("Scheduling retry after \(delaySeconds)s")
281
-
282
- // Race between: backoff delay OR network restored (if offline)
283
- let waitCancelled = await waitForDelayOrNetworkRestored(delaySeconds: delaySeconds)
284
-
285
- if waitCancelled {
286
- logger.debug("Retry wait cancelled")
287
- return false
288
- }
289
-
290
- // Check if reset() was called while we were waiting
291
- guard generation == currentGeneration else {
292
- logger.debug("Retry invalidated by reset (generation changed)")
293
- return false
294
- }
295
-
296
- // Cancellation can land after the sleep completed — without this check a
297
- // replaced retry (new error right as the old backoff expires) still fires
298
- // onRetry, double-reloading and burning the attempt count twice.
299
- guard !Task.isCancelled else { return false }
300
-
301
- guard shouldRetry() else {
302
- logger.debug("shouldRetry returned false after delay, cancelling retry")
303
- return false
304
- }
305
-
306
- // Re-check the budgets — we may have waited a long time for network restoration
307
- if let budget = exhaustedBudget() {
308
- logger.info("\(budget) exceeded after waiting, giving up")
309
- return false
310
- }
311
-
312
- attemptCount += 1
313
- logger.info("Executing retry #\(self.attemptCount)")
314
- onRetry?(startFromCurrentTime)
315
- return true
316
- }
317
-
318
- // MARK: - Network-Aware Waiting
319
-
320
- /// Returns true if cancelled, false if ready to retry.
321
- private func waitForDelayOrNetworkRestored(delaySeconds: Double) async -> Bool {
322
- let isOffline = !(networkMonitor?.isOnline ?? true)
323
-
324
- guard isOffline, let monitor = networkMonitor else {
325
- do {
326
- let nanoseconds = UInt64(delaySeconds * 1_000_000_000)
327
- try await Task.sleep(nanoseconds: nanoseconds)
328
- return false
329
- } catch {
330
- return true // Cancelled
331
- }
332
- }
333
-
334
- logger.debug("Device is offline, will retry immediately when connectivity is restored")
335
-
336
- return await withTaskGroup(of: Bool.self, returning: Bool.self) { group in
337
- group.addTask {
338
- do {
339
- let nanoseconds = UInt64(delaySeconds * 1_000_000_000)
340
- try await Task.sleep(nanoseconds: nanoseconds)
341
- return false
342
- } catch {
343
- return true
344
- }
345
- }
346
-
347
- group.addTask { [weak self] in
348
- await self?.waitForNetworkRestored(monitor: monitor) ?? true
349
- }
350
-
351
- let result = await group.next() ?? true
352
- group.cancelAll()
353
- return result
354
- }
355
- }
356
-
357
- /// Returns false when network is restored, true if cancelled.
358
- @MainActor
359
- private func waitForNetworkRestored(monitor: any NetworkStatusProviding) async -> Bool {
360
- if monitor.isOnline {
361
- return false
362
- }
363
-
364
- isWaitingForNetwork = true
365
- defer { isWaitingForNetwork = false }
366
-
367
- logger.debug("Device is offline, polling for network restoration")
368
-
369
- while !monitor.isOnline {
370
- do {
371
- try await Task.sleep(nanoseconds: 500_000_000) // 0.5s poll
372
- } catch {
373
- return true // Task cancelled
374
- }
375
- }
376
-
377
- logger.info("Network restored, accelerating retry")
378
- return false
379
- }
380
- }
381
-
382
- // MARK: - RetryHandling
383
-
384
- extension RetryManager: RetryHandling {}
@@ -1,5 +0,0 @@
1
- // Sendable so LoadSeekCoordinator can carry a delegate into AVPlayer's
2
- // @Sendable seek completion closure; @MainActor is what keeps that sound.
3
- @MainActor protocol SeekCompletionHandler: AnyObject, Sendable {
4
- func handleSeekCompleted(to seconds: Double, didFinish: Bool)
5
- }