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
@@ -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
+ }