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.
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- 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
|
@@ -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
|
-
///
|
|
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
|
-
///
|
|
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
|
-
///
|
|
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
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
27
|
+
private func makeLoader() -> (MediaLoader, MediaLoaderSpy) {
|
|
49
28
|
let loader = MediaLoader()
|
|
50
|
-
let spy =
|
|
51
|
-
loader.
|
|
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
|
+
}
|