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
@@ -4,6 +4,7 @@ import Testing
4
4
  @testable import AudioBrowserTestable
5
5
 
6
6
  @Suite("LRUCache")
7
+ @MainActor
7
8
  struct LRUCacheTests {
8
9
  /// A class so the tests can observe release through a weak reference.
9
10
  private final class Box {
@@ -49,9 +50,7 @@ struct LRUCacheTests {
49
50
  #expect(cache.count == 2)
50
51
  }
51
52
 
52
- /// The nodes form a doubly-linked list. If both directions were strong, adjacent nodes would
53
- /// retain each other and clearing would drop only the dictionary and the two ends — the chain,
54
- /// and everything it holds, would survive (#96).
53
+ /// Clearing releases every value the cache held (#96).
55
54
  @Test func clearReleasesEveryNode() {
56
55
  let cache = LRUCache<String, Box>(maxSize: 10)
57
56
  weak var first: Box?
@@ -76,7 +75,7 @@ struct LRUCacheTests {
76
75
  #expect(cache.count == 0)
77
76
  }
78
77
 
79
- /// Same cycle, reached by dropping the cache itself rather than clearing it.
78
+ /// So does dropping the cache itself.
80
79
  @Test func droppingTheCacheReleasesEveryNode() {
81
80
  weak var first: Box?
82
81
  weak var last: Box?
@@ -94,7 +93,7 @@ struct LRUCacheTests {
94
93
  #expect(last == nil)
95
94
  }
96
95
 
97
- /// Eviction has always unlinked the node it drops; this pins that down alongside the rest.
96
+ /// And evicting an entry releases its value.
98
97
  @Test func evictionReleasesTheEvictedNode() {
99
98
  let cache = LRUCache<String, Box>(maxSize: 1)
100
99
  weak var evicted: Box?
@@ -6,49 +6,28 @@ import Testing
6
6
  // MARK: - Test Spy
7
7
 
8
8
  @MainActor
9
- private final class MediaLoaderDelegateSpy: MediaLoaderDelegate {
9
+ private final class MediaLoaderSpy {
10
10
  var preparedItems: [AVPlayerItem] = []
11
11
  var unplayableCount = 0
12
12
  var retryableErrors: [Error] = []
13
13
  var playbackErrors: [TrackPlayerError.PlaybackError] = []
14
- var commonMetadata: [[AVMetadataItem]] = []
15
- var chapterMetadata: [[AVTimedMetadataGroup]] = []
16
- var timedMetadata: [[AVTimedMetadataGroup]] = []
17
14
 
18
- func mediaLoaderDidPrepareItem(_ item: AVPlayerItem) {
19
- preparedItems.append(item)
20
- }
21
-
22
- func mediaLoaderDidFailWithUnplayableTrack() {
23
- unplayableCount += 1
24
- }
25
-
26
- func mediaLoaderDidFailWithRetryableError(_ error: Error) {
27
- retryableErrors.append(error)
28
- }
29
-
30
- func mediaLoaderDidFailWithError(_ error: TrackPlayerError.PlaybackError) {
31
- playbackErrors.append(error)
32
- }
33
-
34
- func mediaLoaderDidReceiveCommonMetadata(_ items: [AVMetadataItem]) {
35
- commonMetadata.append(items)
36
- }
37
-
38
- func mediaLoaderDidReceiveChapterMetadata(_ groups: [AVTimedMetadataGroup]) {
39
- chapterMetadata.append(groups)
40
- }
41
-
42
- func mediaLoaderDidReceiveTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
43
- timedMetadata.append(groups)
15
+ func record(_ event: MediaLoader.Event) {
16
+ switch event {
17
+ case let .prepared(item): preparedItems.append(item)
18
+ case .unplayable: unplayableCount += 1
19
+ case let .failed(error): retryableErrors.append(error)
20
+ case let .invalidSource(url): playbackErrors.append(.invalidSourceUrl(url))
21
+ case .commonMetadata, .chapterMetadata, .timedMetadata: break
22
+ }
44
23
  }
45
24
  }
46
25
 
47
26
  @MainActor
48
- private func makeLoader() -> (MediaLoader, MediaLoaderDelegateSpy) {
27
+ private func makeLoader() -> (MediaLoader, MediaLoaderSpy) {
49
28
  let loader = MediaLoader()
50
- let spy = MediaLoaderDelegateSpy()
51
- loader.delegate = spy
29
+ let spy = MediaLoaderSpy()
30
+ loader.onEvent = { spy.record($0) }
52
31
  return (loader, spy)
53
32
  }
54
33
 
@@ -0,0 +1,88 @@
1
+ import AVFoundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ @MainActor
7
+ @Suite("PlayerObserver")
8
+ struct PlayerObserverTests {
9
+ /// An AVPlayer retains its time observers, so one left registered outlives the observer (#96).
10
+ @Test func invalidatingRemovesTheBoundaryObserver() {
11
+ let observer = PlayerObserver(player: AVPlayer()) { _ in }
12
+ #expect(observer.boundaryObserverToken != nil)
13
+ observer.invalidate()
14
+ #expect(observer.boundaryObserverToken == nil)
15
+ }
16
+
17
+ @Test func reportsTheInitialStatus_andNothingOnceInvalidated() async {
18
+ var events: [PlayerObserver.Event] = []
19
+ let observer = PlayerObserver(player: AVPlayer()) { events.append($0) }
20
+ await drain()
21
+ #expect(events.contains { if case .status = $0 { true } else { false } })
22
+
23
+ events = []
24
+ let late = PlayerObserver(player: AVPlayer()) { events.append($0) }
25
+ late.invalidate()
26
+ await drain()
27
+ #expect(events.isEmpty)
28
+ _ = observer
29
+ }
30
+ }
31
+
32
+ @MainActor
33
+ @Suite("ItemObserver")
34
+ struct ItemObserverTests {
35
+ private func makeItem() -> AVPlayerItem {
36
+ AVPlayerItem(url: URL(fileURLWithPath: "/nonexistent.mp3"))
37
+ }
38
+
39
+ @Test func deliversTheItemsNotifications() async {
40
+ let item = makeItem()
41
+ var events: [ItemObserver.Event] = []
42
+ let observer = ItemObserver(item: item) { events.append($0) }
43
+ NotificationCenter.default.post(name: .AVPlayerItemPlaybackStalled, object: item)
44
+ NotificationCenter.default.post(name: .AVPlayerItemDidPlayToEndTime, object: item)
45
+ NotificationCenter.default.post(name: .AVPlayerItemPlaybackStalled, object: makeItem())
46
+ await drain()
47
+ #expect(events.map(label) == ["stalled", "playedToEnd"])
48
+ observer.invalidate()
49
+ }
50
+
51
+ /// A notification posted off the main thread hops to it; one landing after the switch to the
52
+ /// next item belongs to the old item and is dropped.
53
+ @Test func dropsADeliveryThatLandsAfterInvalidate() async {
54
+ let item = makeItem()
55
+ var events: [ItemObserver.Event] = []
56
+ let observer = ItemObserver(item: item) { events.append($0) }
57
+ NotificationCenter.default.post(name: .AVPlayerItemFailedToPlayToEndTime, object: item)
58
+ observer.invalidate()
59
+ await drain()
60
+ #expect(events.isEmpty)
61
+ }
62
+
63
+ @Test func invalidateRemovesTheMetadataOutput() {
64
+ let item = makeItem()
65
+ let observer = ItemObserver(item: item) { _ in }
66
+ #expect(item.outputs.contains { $0 is AVPlayerItemMetadataOutput })
67
+ observer.invalidate()
68
+ #expect(!item.outputs.contains { $0 is AVPlayerItemMetadataOutput })
69
+ }
70
+
71
+ private func label(_ event: ItemObserver.Event) -> String {
72
+ switch event {
73
+ case .likelyToKeepUp: "likelyToKeepUp"
74
+ case .failed: "failed"
75
+ case .playedToEnd: "playedToEnd"
76
+ case .failedToPlayToEnd: "failedToPlayToEnd"
77
+ case .stalled: "stalled"
78
+ case .timedMetadata: "timedMetadata"
79
+ }
80
+ }
81
+ }
82
+
83
+ @MainActor
84
+ private func drain() async {
85
+ for _ in 0 ..< 20 {
86
+ await Task.yield()
87
+ }
88
+ }
@@ -0,0 +1,76 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import Testing
4
+
5
+ @testable import AudioBrowserTestable
6
+
7
+ @Suite("PlaybackError.kind")
8
+ @MainActor
9
+ struct PlaybackErrorKindTests {
10
+ @Test func httpStatus_mapsToKind() {
11
+ // Not parameterized: the generated Nitro enum is not Sendable, so it
12
+ // cannot cross a `@Test(arguments:)` isolation boundary.
13
+ let expectations: [(Int, PlaybackErrorKind)] = [
14
+ // Ordering: these must not be swallowed by the 400...499 arm.
15
+ (404, .notFound),
16
+ (410, .notFound),
17
+ // Range edges.
18
+ (400, .rejected),
19
+ (499, .rejected),
20
+ (500, .serverError),
21
+ (599, .serverError),
22
+ (302, .unknown),
23
+ (600, .unknown),
24
+ ]
25
+ for (status, expected) in expectations {
26
+ #expect(
27
+ TrackPlayerError.PlaybackError.httpStatus(status).kind == expected,
28
+ "HTTP \(status)",
29
+ )
30
+ }
31
+ }
32
+ }
33
+
34
+ @Suite("PlaybackError.toNitroError")
35
+ @MainActor
36
+ struct PlaybackErrorNitroCodeTests {
37
+ @Test func httpStatus_producesTheFullPayload() {
38
+ let payload = TrackPlayerError.PlaybackError.httpStatus(404).toNitroError()
39
+ #expect(payload.kind == .notFound)
40
+ #expect(payload.code == "http-status")
41
+ #expect(payload.message == "Server responded with HTTP 404")
42
+ #expect(payload.statusCode == 404)
43
+ }
44
+
45
+ @Test func nonHttpError_carriesNoStatusCode() {
46
+ #expect(TrackPlayerError.PlaybackError.playbackStalled.toNitroError().statusCode == nil)
47
+ }
48
+
49
+ /// `retrying` is nil — not false — on a terminal payload, so JS consumers
50
+ /// see the field only while it means something.
51
+ @Test func retryingFlag_isNilUnlessSet() {
52
+ #expect(TrackPlayerError.PlaybackError.hostUnreachable.toNitroError().retrying == nil)
53
+ #expect(
54
+ TrackPlayerError.PlaybackError.hostUnreachable.toNitroError(retrying: true).retrying == true,
55
+ )
56
+ }
57
+
58
+ /// `code` is an untyped wire contract: nothing on either side of the bridge
59
+ /// catches a typo, and telemetry groups issues by it. Worth an exhaustive
60
+ /// table even though `kind` is what the UI branches on.
61
+ @Test func codes_areStable() {
62
+ let expectations: [(TrackPlayerError.PlaybackError, String)] = [
63
+ (.failedToLoadKeyValue, "failed-to-load"),
64
+ (.invalidSourceUrl("nil"), "invalid-source-url"),
65
+ (.notConnectedToInternet, "not-connected-to-internet"),
66
+ (.hostUnreachable, "host-unreachable"),
67
+ (.httpStatus(500), "http-status"),
68
+ (.playbackFailed, "playback-failed"),
69
+ (.trackWasUnplayable, "track-unplayable"),
70
+ (.playbackStalled, "playback-stalled"),
71
+ ]
72
+ for (error, expected) in expectations {
73
+ #expect(error.toNitroError().code == expected, "\(error)")
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,189 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import Testing
4
+
5
+ @testable import AudioBrowserTestable
6
+
7
+ private func reported(
8
+ _ error: Error?, status: Int? = nil, fallback: TrackPlayerError.PlaybackError = .playbackFailed,
9
+ ) -> TrackPlayerError.PlaybackError {
10
+ PlaybackFailure.classify(error, httpStatusCode: status, fallback: fallback).error
11
+ }
12
+
13
+ private func retryable(_ error: Error?, status: Int? = nil) -> Bool {
14
+ PlaybackFailure.classify(error, httpStatusCode: status, fallback: .playbackFailed).retryable
15
+ }
16
+
17
+ private func avError(_ code: AVError.Code) -> NSError {
18
+ NSError(domain: AVFoundationErrorDomain, code: code.rawValue)
19
+ }
20
+
21
+ @Suite("PlaybackFailure: the reported error")
22
+ struct PlaybackFailureReportedErrorTests {
23
+ @Test func noInternet() {
24
+ #expect(reported(URLError(.notConnectedToInternet)) == .notConnectedToInternet)
25
+ }
26
+
27
+ /// Both fallbacks pin that `fallback` is plumbed, not hardcoded.
28
+ @Test func anUnclassifiableErrorIsTheFallback() {
29
+ #expect(reported(URLError(.badURL)) == .playbackFailed)
30
+ #expect(reported(URLError(.badURL), fallback: .failedToLoadKeyValue) == .failedToLoadKeyValue)
31
+ }
32
+
33
+ /// A TLS failure counts as unreachable: no usable connection was made.
34
+ @Test(arguments: [URLError.Code.timedOut, .cannotFindHost, .secureConnectionFailed])
35
+ func aTransportErrorIsHostUnreachable(code: URLError.Code) {
36
+ #expect(reported(URLError(code)) == .hostUnreachable)
37
+ }
38
+
39
+ /// AVFoundation hands back plain NSErrors in NSURLErrorDomain rather than bridged URLErrors.
40
+ @Test func anUnbridgedURLDomainErrorIsClassified() {
41
+ #expect(reported(NSError(domain: NSURLErrorDomain, code: URLError.Code.cannotFindHost.rawValue)) == .hostUnreachable)
42
+ }
43
+
44
+ @Test func anHTTPStatusIsReported() {
45
+ #expect(reported(nil, status: 404, fallback: .failedToLoadKeyValue) == .httpStatus(404))
46
+ }
47
+
48
+ @Test func aSuccessStatusOrNothingIsTheFallback() {
49
+ #expect(reported(nil, status: 200, fallback: .failedToLoadKeyValue) == .failedToLoadKeyValue)
50
+ #expect(reported(nil) == .playbackFailed)
51
+ }
52
+
53
+ /// No response arrived, so a status left in the error log must not outrank the transport error.
54
+ @Test func aTransportErrorOutranksAStatus() {
55
+ #expect(reported(URLError(.notConnectedToInternet), status: 500) == .notConnectedToInternet)
56
+ }
57
+
58
+ @Test func anUnclassifiableURLErrorFallsThroughToTheStatus() {
59
+ #expect(reported(URLError(.badURL), status: 500) == .httpStatus(500))
60
+ }
61
+
62
+ /// The server's answer beats "the decoder choked", a symptom of being handed an error page.
63
+ @Test func aStatusOutranksUnplayableMedia() {
64
+ #expect(reported(avError(.fileFormatNotRecognized), status: 404) == .httpStatus(404))
65
+ }
66
+
67
+ @Test(arguments: [
68
+ AVError.Code.fileFormatNotRecognized, .failedToParse, .decoderNotFound, .decoderTemporarilyUnavailable,
69
+ .formatUnsupported, .contentIsUnavailable,
70
+ ])
71
+ func undecodableMediaIsUnplayable(code: AVError.Code) {
72
+ #expect(reported(avError(code)) == .trackWasUnplayable)
73
+ }
74
+
75
+ /// In-domain is not enough: `AVError.unknown` tells us nothing.
76
+ @Test func anUnrelatedAVErrorIsTheFallback() {
77
+ #expect(reported(avError(.unknown)) == .playbackFailed)
78
+ }
79
+ }
80
+
81
+ @Suite("PlaybackFailure: retrying")
82
+ struct PlaybackFailureRetryTests {
83
+ /// The status, read off the error log, outranks the opaque error.
84
+ @Test(arguments: [408, 429, 500, 502, 503, 504])
85
+ func aRetryableStatusRetries(status: Int) {
86
+ #expect(retryable(NSError(domain: "CoreMediaErrorDomain", code: -12660), status: status))
87
+ }
88
+
89
+ /// A retry cannot conjure content that isn't there, whatever the error says.
90
+ @Test(arguments: [400, 401, 403, 404, 410, 451])
91
+ func aPermanentStatusDoesNotRetry(status: Int) {
92
+ #expect(!retryable(URLError(.timedOut), status: status))
93
+ }
94
+
95
+ @Test func aTransientURLErrorRetries() {
96
+ #expect(retryable(URLError(.networkConnectionLost)))
97
+ }
98
+
99
+ @Test func aFatalURLErrorDoesNotRetry() {
100
+ #expect(!retryable(URLError(.badURL)))
101
+ }
102
+
103
+ /// An opaque CoreMedia failure without a status used to be terminal, so a station that dropped
104
+ /// mid-song never came back on iOS while Android reconnected.
105
+ @Test func anOpaqueCoreMediaErrorRetries() {
106
+ #expect(retryable(NSError(domain: "CoreMediaErrorDomain", code: -12939)))
107
+ }
108
+
109
+ /// `AVError.unknown` wraps transport failures, so it stays retryable.
110
+ @Test func avErrorUnknownRetries() {
111
+ #expect(retryable(avError(.unknown)))
112
+ }
113
+
114
+ @Test(arguments: [
115
+ AVError.Code.fileFormatNotRecognized, .failedToParse, .decodeFailed, .decoderNotFound, .formatUnsupported,
116
+ .contentIsUnavailable, .contentIsProtected, .contentIsNotAuthorized,
117
+ ])
118
+ func unusableMediaDoesNotRetry(code: AVError.Code) {
119
+ #expect(!retryable(avError(code)))
120
+ }
121
+
122
+ /// A failure reported as unplayable is never also retried, except a decoder that is only
123
+ /// temporarily unavailable.
124
+ @Test(arguments: [
125
+ AVError.Code.fileFormatNotRecognized, .failedToParse, .decoderNotFound, .formatUnsupported,
126
+ .contentIsUnavailable,
127
+ ])
128
+ func whatIsReportedUnplayableIsNotRetried(code: AVError.Code) {
129
+ let classification = PlaybackFailure.classify(avError(code), httpStatusCode: nil, fallback: .playbackFailed)
130
+ #expect(classification == .init(error: .trackWasUnplayable, retryable: false))
131
+ }
132
+
133
+ @Test func aTemporarilyUnavailableDecoderIsRetried() {
134
+ #expect(retryable(avError(.decoderTemporarilyUnavailable)))
135
+ }
136
+
137
+ /// AVFoundation wraps the real cause, so the chain of underlying errors is walked.
138
+ @Test func anUnderlyingURLErrorDecides() {
139
+ let wrappingFatal = NSError(
140
+ domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue,
141
+ userInfo: [NSUnderlyingErrorKey: URLError(.badURL)],
142
+ )
143
+ #expect(!retryable(wrappingFatal))
144
+ let wrappingTransient = NSError(
145
+ domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue,
146
+ userInfo: [NSUnderlyingErrorKey: URLError(.dnsLookupFailed)],
147
+ )
148
+ #expect(retryable(wrappingTransient))
149
+ }
150
+
151
+ /// The report reads the same chain as the retry decision, so a wrapped cause that stops
152
+ /// retrying is also what is reported.
153
+ @Test func aWrappedCauseIsWhatIsReported() {
154
+ let wrappedUnplayable = NSError(
155
+ domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue,
156
+ userInfo: [NSUnderlyingErrorKey: avError(.contentIsUnavailable)],
157
+ )
158
+ #expect(!retryable(wrappedUnplayable))
159
+ #expect(reported(wrappedUnplayable) == .trackWasUnplayable)
160
+ let wrappedOffline = NSError(
161
+ domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue,
162
+ userInfo: [NSUnderlyingErrorKey: URLError(.notConnectedToInternet)],
163
+ )
164
+ #expect(reported(wrappedOffline) == .notConnectedToInternet)
165
+ }
166
+
167
+ @Test func nothingToClassifyDoesNotRetry() {
168
+ #expect(!retryable(nil))
169
+ }
170
+ }
171
+
172
+ @Suite("PlaybackFailure.httpStatusCode(fromErrorStatusCodes:)")
173
+ struct ErrorLogStatusTests {
174
+ /// `errorStatusCode` carries negative OSStatus values in the same field.
175
+ @Test func skipsNonHttpStatusCodes() {
176
+ #expect(PlaybackFailure.httpStatusCode(fromErrorStatusCodes: [-12939, -1008]) == nil)
177
+ #expect(PlaybackFailure.httpStatusCode(fromErrorStatusCodes: [-12939, 404]) == 404)
178
+ }
179
+
180
+ /// A live stream reconnects; older entries describe earlier attempts.
181
+ @Test func prefersTheNewestInRangeStatus() {
182
+ #expect(PlaybackFailure.httpStatusCode(fromErrorStatusCodes: [500, 404]) == 404)
183
+ #expect(PlaybackFailure.httpStatusCode(fromErrorStatusCodes: [404, 500, -12939]) == 500)
184
+ }
185
+
186
+ @Test func nilWhenNothingQualifies() {
187
+ #expect(PlaybackFailure.httpStatusCode(fromErrorStatusCodes: []) == nil)
188
+ }
189
+ }
@@ -0,0 +1,93 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ @Suite("PlaybackPersistence", .serialized)
7
+ @MainActor
8
+ struct PlaybackPersistenceTests {
9
+ private func freshDefaults() -> UserDefaults {
10
+ let suite = "PlaybackPersistenceTests"
11
+ let d = UserDefaults(suiteName: suite)!
12
+ d.removePersistentDomain(forName: suite)
13
+ return d
14
+ }
15
+
16
+ private func sampleTrack() -> JsonTrack {
17
+ JsonTrack(
18
+ id: "abc", path: nil, title: "Test FM", subtitle: nil, artwork: nil,
19
+ artist: "City, Country", albumPath: nil, album: nil, description: nil,
20
+ genre: nil, duration: nil, src: "/listen/abc/channel.mp3", request: nil,
21
+ style: JsonStyle(display: "grid", artworkRendering: "stencil"),
22
+ disabled: false, live: true, icyCharset: "windows-1251",
23
+ )
24
+ }
25
+
26
+ private func state(positionMs: Double? = nil) -> PersistedPlaybackState {
27
+ PersistedPlaybackState(
28
+ track: sampleTrack(), positionMs: positionMs, repeatMode: "off", shuffleEnabled: false, playbackSpeed: 1.0,
29
+ )
30
+ }
31
+
32
+ @Test func roundTrip_preservesState() {
33
+ let persistence = PlaybackPersistence(defaults: freshDefaults()) { state() }
34
+ persistence.save()
35
+ let loaded = persistence.load()
36
+ #expect(loaded?.track.src == "/listen/abc/channel.mp3")
37
+ #expect(loaded?.track.live == true)
38
+ #expect(loaded?.positionMs == nil)
39
+ #expect(loaded?.playbackSpeed == 1.0)
40
+ }
41
+
42
+ @Test func load_isNilBeforeAnySave() {
43
+ #expect(PlaybackPersistence(defaults: freshDefaults()) { state() }.load() == nil)
44
+ }
45
+
46
+ @Test func nothingToResume_keepsThePreviousSave() {
47
+ let defaults = freshDefaults()
48
+ var current: PersistedPlaybackState? = state(positionMs: 5000)
49
+ let persistence = PlaybackPersistence(defaults: defaults) { current }
50
+ persistence.save()
51
+ current = nil
52
+ persistence.save()
53
+ #expect(persistence.load()?.positionMs == 5000)
54
+ }
55
+
56
+ @Test func anActivePlayIntentSavesPeriodically_andOnceWhenItEnds() async throws {
57
+ var positionMs = 0.0
58
+ let persistence = PlaybackPersistence(defaults: freshDefaults(), saveInterval: .milliseconds(10)) {
59
+ positionMs += 1000
60
+ return state(positionMs: positionMs)
61
+ }
62
+ persistence.playIntentDidChange(active: true)
63
+ try await Task.sleep(for: .milliseconds(100))
64
+ #expect((persistence.load()?.positionMs ?? 0) >= 1000)
65
+
66
+ persistence.playIntentDidChange(active: false)
67
+ let stopped = try #require(persistence.load()?.positionMs)
68
+ try await Task.sleep(for: .milliseconds(50))
69
+ #expect(persistence.load()?.positionMs == stopped)
70
+ }
71
+
72
+ @Test func aSnapshotOfALiveStreamKeepsNoPosition() {
73
+ let snapshot = PlaybackPersistence.snapshot(
74
+ of: Track(src: "s", live: true), state: .playing, position: 12, repeatMode: .off, shuffleEnabled: false,
75
+ rate: 1,
76
+ )
77
+ #expect(snapshot.positionMs == nil)
78
+ }
79
+
80
+ @Test func aSnapshotOfAnEndedTrackResumesFromTheStart() {
81
+ let ended = PlaybackPersistence.snapshot(
82
+ of: Track(src: "s"), state: .ended, position: 300, repeatMode: .off, shuffleEnabled: false, rate: 1,
83
+ )
84
+ let paused = PlaybackPersistence.snapshot(
85
+ of: Track(src: "s"), state: .paused, position: 12.5, repeatMode: .track, shuffleEnabled: true, rate: 1.5,
86
+ )
87
+ #expect(ended.positionMs == 0)
88
+ #expect(paused.positionMs == 12500)
89
+ #expect(paused.repeatMode == RepeatMode.track.stringValue)
90
+ #expect(paused.shuffleEnabled)
91
+ #expect(paused.playbackSpeed == 1.5)
92
+ }
93
+ }