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.
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /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
|
-
}
|