react-native-audio-browser 0.2.0-next.31 → 0.2.0-next.33
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/ios/AVPlayerEngine.swift +4 -8
- package/ios/Browser/BrowserConfig.swift +0 -5
- package/ios/Browser/BrowserError.swift +16 -0
- package/ios/Browser/BrowserManager.swift +36 -141
- package/ios/Browser/BrowserPathHelper.swift +25 -12
- package/ios/Browser/FavoriteSet.swift +46 -0
- package/ios/Browser/JsonModels.swift +172 -381
- package/ios/Browser/PageResolver.swift +16 -96
- package/ios/Browser/RequestLayer.swift +97 -2
- package/ios/Browser/RequestPipeline.swift +78 -227
- package/ios/Browser/SectionScope.swift +25 -0
- package/ios/Browser/SimpleRouter.swift +73 -144
- package/ios/Extension/SearchParams+Query.swift +28 -0
- package/ios/Extension/Track+Copying.swift +3 -1
- package/ios/Extension/Track+Identity.swift +7 -0
- package/ios/Http/HttpClient.swift +12 -30
- package/ios/HybridAudioBrowser+Browser.swift +5 -21
- package/ios/HybridAudioBrowser+Player.swift +3 -3
- package/ios/Model/NitroTypeStubs.swift +78 -0
- package/ios/Player/AudioSessionController.swift +2 -0
- package/ios/Player/ErrorStatus.swift +25 -2
- package/ios/Player/MediaLoader.swift +23 -71
- package/ios/Player/NowPlayingLines.swift +36 -0
- package/ios/Player/NowPlayingUpdater.swift +27 -85
- package/ios/Player/Player.swift +108 -261
- package/ios/Player/RetryLoop.swift +26 -0
- package/ios/Player/SleepTimerManager.swift +13 -15
- package/ios/Player/VolumeFader.swift +5 -0
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/ErrorStatusTests.swift +50 -0
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/JsonModelsTests.swift +30 -0
- package/ios/Tests/MediaLoaderTests.swift +26 -21
- package/ios/Tests/NowPlayingLinesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +19 -3
- package/ios/Tests/PlayerTests.swift +81 -74
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/RetryLoopTests.swift +35 -0
- package/ios/Tests/SearchParamsQueryTests.swift +48 -0
- package/ios/Tests/SectionScopeTests.swift +53 -0
- package/ios/Tests/SimpleRouterTests.swift +38 -3
- package/ios/Tests/SleepTimerManagerTests.swift +65 -41
- package/ios/Tests/Support/TestTypes.swift +2 -2
- package/package.json +1 -1
|
@@ -84,6 +84,13 @@ final class RetryLoop {
|
|
|
84
84
|
/// Retries executed since the budgets last reset.
|
|
85
85
|
private(set) var attempts = 0
|
|
86
86
|
|
|
87
|
+
/// Continuous audible playback that proves the stream recovered, so the budgets refill
|
|
88
|
+
/// (Android's HEALTHY_PLAYBACK_MS); without it a long-lived stream keeps its spent budget until
|
|
89
|
+
/// the track changes.
|
|
90
|
+
var healthyPlaybackDuration: Duration = .seconds(20)
|
|
91
|
+
/// The pending refill. Readable so tests can await the window instead of sleeping past it.
|
|
92
|
+
private(set) var healthyPlaybackTask: Task<Void, Never>?
|
|
93
|
+
|
|
87
94
|
private var policy: Policy = .disabled
|
|
88
95
|
private var firstRetryTime: Date?
|
|
89
96
|
/// First failure observed while online for the current load: the first-connect budget's
|
|
@@ -167,6 +174,25 @@ final class RetryLoop {
|
|
|
167
174
|
isWaitingForNetwork = false
|
|
168
175
|
}
|
|
169
176
|
|
|
177
|
+
// MARK: - Healthy Playback
|
|
178
|
+
|
|
179
|
+
/// Starts the healthy-playback window; when it elapses between failures, the budgets refill.
|
|
180
|
+
func playbackBecameAudible() {
|
|
181
|
+
healthyPlaybackTask?.cancel()
|
|
182
|
+
healthyPlaybackTask = Task { [weak self, healthyPlaybackDuration] in
|
|
183
|
+
try? await Task.sleep(for: healthyPlaybackDuration)
|
|
184
|
+
guard let self, !Task.isCancelled else { return }
|
|
185
|
+
// reset() cancels a pending retry, which would silently kill an in-flight reconnect.
|
|
186
|
+
guard pendingTask == nil else { return }
|
|
187
|
+
reset()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func playbackStoppedBeingAudible() {
|
|
192
|
+
healthyPlaybackTask?.cancel()
|
|
193
|
+
healthyPlaybackTask = nil
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
// MARK: - Budgets
|
|
171
197
|
|
|
172
198
|
private var isOnline: Bool { networkMonitor?.isOnline ?? true }
|
|
@@ -44,15 +44,21 @@ final class SleepTimerManager {
|
|
|
44
44
|
// MARK: - Properties
|
|
45
45
|
|
|
46
46
|
private let scheduler: any SleepTimerScheduling
|
|
47
|
+
/// Fades the volume out before a time-based timer's deadline.
|
|
48
|
+
private let fader: VolumeFader
|
|
47
49
|
|
|
48
50
|
// nonisolated(unsafe) for deinit cleanup — deinit is always nonisolated in Swift 6.
|
|
49
51
|
private nonisolated(unsafe) var sleepTimerJob: (any SleepTimerJob)?
|
|
50
52
|
private nonisolated(unsafe) var fadeJob: (any SleepTimerJob)?
|
|
51
53
|
|
|
52
|
-
init(scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
|
|
54
|
+
init(fader: VolumeFader, scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
|
|
55
|
+
self.fader = fader
|
|
53
56
|
self.scheduler = scheduler
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/// Whether the fade before the deadline is running.
|
|
60
|
+
var isFading: Bool { fader.isActive }
|
|
61
|
+
|
|
56
62
|
/// The time when playback should stop (seconds since epoch), or -1 if inactive
|
|
57
63
|
private(set) var sleepTimerTime: TimeInterval = -1 {
|
|
58
64
|
didSet {
|
|
@@ -73,17 +79,9 @@ final class SleepTimerManager {
|
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
///
|
|
82
|
+
/// Halts playback when the timer fires; the pre-fade volume is restored after it returns.
|
|
77
83
|
var onComplete: (() -> Void)?
|
|
78
84
|
|
|
79
|
-
/// Callback invoked when the fade-out window of a time-based timer begins
|
|
80
|
-
var onFadeStart: ((_ duration: TimeInterval) -> Void)?
|
|
81
|
-
|
|
82
|
-
/// Callback invoked when a timer is cancelled/replaced while its fade may be
|
|
83
|
-
/// running — the listener restores the pre-fade volume. Not invoked on
|
|
84
|
-
/// completion: there the pause lands first, then the volume is restored.
|
|
85
|
-
var onFadeCancel: (() -> Void)?
|
|
86
|
-
|
|
87
85
|
/// Callback invoked when the sleep timer state changes
|
|
88
86
|
var onChanged: ((SleepTimerState) -> Void)?
|
|
89
87
|
|
|
@@ -97,7 +95,7 @@ final class SleepTimerManager {
|
|
|
97
95
|
if !hasSleepTimer { return false }
|
|
98
96
|
sleepTimerTime = -1
|
|
99
97
|
willSleepWhenCurrentItemReachesEnd = false
|
|
100
|
-
|
|
98
|
+
fader.cancel(restoringVolume: true)
|
|
101
99
|
onChanged?(.first(NullType.null))
|
|
102
100
|
return true
|
|
103
101
|
}
|
|
@@ -129,7 +127,7 @@ final class SleepTimerManager {
|
|
|
129
127
|
/// deadline (clamped to `seconds`); silence lands exactly at the deadline
|
|
130
128
|
func set(seconds: TimeInterval, fadeDuration: TimeInterval? = nil) {
|
|
131
129
|
cancelSleepTimerJob()
|
|
132
|
-
|
|
130
|
+
fader.cancel(restoringVolume: true)
|
|
133
131
|
sleepTimerTime = Date().timeIntervalSince1970 + seconds
|
|
134
132
|
sleepTimerJob = scheduler.schedule(after: seconds) { [weak self] in
|
|
135
133
|
self?.complete()
|
|
@@ -137,7 +135,7 @@ final class SleepTimerManager {
|
|
|
137
135
|
if let fadeDuration, fadeDuration > 0 {
|
|
138
136
|
let fade = min(fadeDuration, seconds)
|
|
139
137
|
fadeJob = scheduler.schedule(after: seconds - fade) { [weak self] in
|
|
140
|
-
self?.
|
|
138
|
+
self?.fader.start(duration: fade)
|
|
141
139
|
}
|
|
142
140
|
}
|
|
143
141
|
if let state = get() { onChanged?(state) }
|
|
@@ -148,7 +146,7 @@ final class SleepTimerManager {
|
|
|
148
146
|
let changed = !willSleepWhenCurrentItemReachesEnd
|
|
149
147
|
willSleepWhenCurrentItemReachesEnd = true
|
|
150
148
|
if changed {
|
|
151
|
-
|
|
149
|
+
fader.cancel(restoringVolume: true)
|
|
152
150
|
if let state = get() { onChanged?(state) }
|
|
153
151
|
}
|
|
154
152
|
}
|
|
@@ -182,7 +180,7 @@ final class SleepTimerManager {
|
|
|
182
180
|
sleepTimerTime = -1
|
|
183
181
|
willSleepWhenCurrentItemReachesEnd = false
|
|
184
182
|
onChanged?(.first(NullType.null))
|
|
185
|
-
onComplete?()
|
|
183
|
+
fader.resolve { onComplete?() }
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
deinit {
|
|
@@ -19,6 +19,9 @@ final class VolumeFader {
|
|
|
19
19
|
|
|
20
20
|
var isActive: Bool { originalVolume != nil }
|
|
21
21
|
|
|
22
|
+
/// The length of the running fade.
|
|
23
|
+
private(set) var duration: TimeInterval?
|
|
24
|
+
|
|
22
25
|
init(getVolume: @escaping () -> Float, setVolume: @escaping (Float) -> Void) {
|
|
23
26
|
self.getVolume = getVolume
|
|
24
27
|
self.setVolume = setVolume
|
|
@@ -30,6 +33,7 @@ final class VolumeFader {
|
|
|
30
33
|
let baseVolume = originalVolume ?? getVolume()
|
|
31
34
|
cancel(restoringVolume: false)
|
|
32
35
|
originalVolume = baseVolume
|
|
36
|
+
self.duration = duration
|
|
33
37
|
guard duration > 0 else { return }
|
|
34
38
|
|
|
35
39
|
let startDate = Date()
|
|
@@ -57,6 +61,7 @@ final class VolumeFader {
|
|
|
57
61
|
timer = nil
|
|
58
62
|
guard let originalVolume else { return }
|
|
59
63
|
self.originalVolume = nil
|
|
64
|
+
duration = nil
|
|
60
65
|
if restoringVolume { setVolume(originalVolume) }
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -177,3 +177,36 @@ import Testing
|
|
|
177
177
|
#expect(BrowserPathHelper.isSearchPath("/search?q=jazz") == false)
|
|
178
178
|
#expect(BrowserPathHelper.isSearchPath("/__searchable") == false)
|
|
179
179
|
}
|
|
180
|
+
|
|
181
|
+
// MARK: - stampingContextualPaths
|
|
182
|
+
|
|
183
|
+
@Test func stampsPlayableOnlyTracksWithTheirFlatPagePosition() throws {
|
|
184
|
+
let sections = [
|
|
185
|
+
Section(children: [Track(path: "/a", title: "A"), Track(src: "one.mp3", title: "One")]),
|
|
186
|
+
Section(children: [Track(id: "two", src: "two.mp3", title: "Two")]),
|
|
187
|
+
]
|
|
188
|
+
let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
|
|
189
|
+
#expect(stamped.map { $0.children.map(\.path) } == [
|
|
190
|
+
["/a", "/page?__trackId=one.mp3&__index=1"],
|
|
191
|
+
["/page?__trackId=two&__index=2"],
|
|
192
|
+
])
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@Test func keepsTheSectionsAndATrackThatHasAPath() throws {
|
|
196
|
+
let track = Track(path: "/a", src: "a.mp3", title: "A")
|
|
197
|
+
let sections = [Section(title: "Top", path: "/top", children: [track])]
|
|
198
|
+
#expect(try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page") == sections)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test func stampsBesideAnExistingQuery() throws {
|
|
202
|
+
let sections = [Section(children: [Track(src: "s.mp3", title: "S")])]
|
|
203
|
+
let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/search?q=jazz")
|
|
204
|
+
#expect(stamped[0].children[0].path == "/search?q=jazz&__trackId=s.mp3&__index=0")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Test func rejectsATrackWithNeitherPathNorSrc() {
|
|
208
|
+
let sections = [Section(children: [Track(id: "only-id", title: "Nowhere")])]
|
|
209
|
+
#expect(throws: BrowserError.self) {
|
|
210
|
+
try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
@testable import AudioBrowserTestable
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@Suite("ErrorStatus.after(event:from:to:)")
|
|
5
|
+
struct ErrorStatusTests {
|
|
6
|
+
private let retrying = ErrorStatus.retrying(.hostUnreachable)
|
|
7
|
+
|
|
8
|
+
@Test func anErrorEventIsATerminalReport() {
|
|
9
|
+
let status = ErrorStatus.none.after(event: .errorOccurred(.playbackFailed), from: .playing, to: .error)
|
|
10
|
+
#expect(status.error == .playbackFailed)
|
|
11
|
+
#expect(!status.isRetrying)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Test func aRepeatedTerminalReportDiffersFromTheLast() {
|
|
15
|
+
let first = ErrorStatus.none.after(event: .errorOccurred(.playbackFailed), from: .playing, to: .error)
|
|
16
|
+
let repeated = first.after(event: .errorOccurred(.playbackFailed), from: .error, to: .error)
|
|
17
|
+
#expect(repeated.error == .playbackFailed)
|
|
18
|
+
#expect(repeated != first)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test func anErrorEventReplacesARetryingError() {
|
|
22
|
+
let status = retrying.after(event: .errorOccurred(.playbackFailed), from: .loading, to: .error)
|
|
23
|
+
#expect(status.error == .playbackFailed)
|
|
24
|
+
#expect(!status.isRetrying)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Test func leavingErrorClearsIt() {
|
|
28
|
+
let terminal = ErrorStatus.none.after(event: .errorOccurred(.playbackFailed), from: .playing, to: .error)
|
|
29
|
+
#expect(terminal.after(event: .trackLoading, from: .error, to: .loading) == .none)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Test(arguments: [PlaybackState.loading, .buffering, .paused])
|
|
33
|
+
func aRetryingErrorRidesAlongWhereItShows(_ state: PlaybackState) {
|
|
34
|
+
#expect(retrying.after(event: .trackLoading, from: .loading, to: state) == retrying)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test(arguments: [PlaybackState.ready, .playing, .stopped, .none, .ended])
|
|
38
|
+
func aRetryingErrorClearsWhereItDoesNotShow(_ state: PlaybackState) {
|
|
39
|
+
#expect(retrying.after(event: .avPlayerPlaying, from: .loading, to: state) == .none)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test func otherwiseTheStatusHolds() {
|
|
43
|
+
#expect(ErrorStatus.none.after(event: .avPlayerPlaying, from: .ready, to: .playing) == .none)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test(arguments: [PlaybackState.none, .loading, .ready, .buffering, .playing, .paused, .stopped, .ended, .error])
|
|
47
|
+
func onlyLoadingBufferingAndPausedCarryAnAdvisoryError(_ state: PlaybackState) {
|
|
48
|
+
#expect(state.carriesAdvisoryError == [.loading, .buffering, .paused].contains(state))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Testing
|
|
2
|
+
|
|
3
|
+
@testable import AudioBrowserTestable
|
|
4
|
+
|
|
5
|
+
struct FavoriteSetTests {
|
|
6
|
+
private func favorites(_ identities: [String], enabled: Bool = true) -> FavoriteSet {
|
|
7
|
+
var set = FavoriteSet()
|
|
8
|
+
set.enabled = enabled
|
|
9
|
+
_ = set.replace(with: identities)
|
|
10
|
+
return set
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Test func replaceReportsWhetherTheIdentitiesChanged() {
|
|
14
|
+
var set = FavoriteSet()
|
|
15
|
+
let changes = [["a", "b"], ["b", "a"], ["a", "b", "a"], []].map { set.replace(with: $0) }
|
|
16
|
+
#expect(changes == [true, false, false, true])
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Test func hydratesTrueOrFalseByIdentity() {
|
|
20
|
+
let set = favorites(["a", "stable"])
|
|
21
|
+
#expect(set.hydrate(Track(src: "a")).favorited == true)
|
|
22
|
+
#expect(set.hydrate(Track(src: "b")).favorited == false)
|
|
23
|
+
#expect(set.hydrate(Track(id: "stable", src: "x")).favorited == true)
|
|
24
|
+
#expect(set.hydrate(Track(id: "other", src: "a")).favorited == false)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Test func leavesATrackAloneWhenDisabled() {
|
|
28
|
+
#expect(favorites(["a"], enabled: false).hydrate(Track(src: "a")).favorited == nil)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test func leavesATrackWithoutIdentityAlone() {
|
|
32
|
+
#expect(favorites(["a"]).hydrate(Track(path: "/browse")).favorited == nil)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test func aFavoritedTheTrackCarriesWins() {
|
|
36
|
+
let set = favorites(["a"])
|
|
37
|
+
#expect(set.hydrate(Track(src: "a", favorited: false)).favorited == false)
|
|
38
|
+
#expect(set.hydrate(Track(src: "b", favorited: true)).favorited == true)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Test func aLocalToggleHoldsUntilTheNextReplace() {
|
|
42
|
+
var set = favorites(["a"])
|
|
43
|
+
set.set("b", favorited: true)
|
|
44
|
+
set.set("a", favorited: false)
|
|
45
|
+
#expect(set.hydrate(Track(src: "a")).favorited == false)
|
|
46
|
+
#expect(set.hydrate(Track(src: "b")).favorited == true)
|
|
47
|
+
_ = set.replace(with: ["a"])
|
|
48
|
+
#expect(set.hydrate(Track(src: "b")).favorited == false)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test func hydratesEverySectionOfAPage() {
|
|
52
|
+
let page = ResolvedTrack(
|
|
53
|
+
path: "/p",
|
|
54
|
+
sections: [Section(children: [Track(src: "a")]), Section(children: [Track(src: "b")])],
|
|
55
|
+
title: "P",
|
|
56
|
+
)
|
|
57
|
+
let hydrated = favorites(["b"]).hydrate(page)
|
|
58
|
+
#expect(hydrated.sections?.map { $0.children.map(\.favorited) } == [[false], [true]])
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -219,3 +219,33 @@ struct JsonStyleTests {
|
|
|
219
219
|
#expect(snapshot?.cardImage == "background")
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
@Suite("JsonTrack persistence")
|
|
224
|
+
struct JsonTrackPersistenceTests {
|
|
225
|
+
/// The snapshot a resume restores keeps every field a track persists.
|
|
226
|
+
@Test func aTrackSurvivesTheRoundTripThroughItsSnapshot() throws {
|
|
227
|
+
let track = Track(
|
|
228
|
+
id: "t1", path: "/station/1", src: "https://example.com/a.mp3",
|
|
229
|
+
artwork: .first("https://example.com/a.jpg"),
|
|
230
|
+
request: TrackRequest(userAgent: "Agent", headers: ["X": "1"], query: ["q": "2"]),
|
|
231
|
+
title: "Title", subtitle: "Subtitle", artist: "Artist", albumPath: "/album/1", album: "Album",
|
|
232
|
+
description: "Description", genre: "Genre", duration: 123,
|
|
233
|
+
disabled: false, live: true, icyCharset: "windows-1251",
|
|
234
|
+
)
|
|
235
|
+
let data = try JSONEncoder().encode(JsonTrack(from: track))
|
|
236
|
+
let restored = try JSONDecoder().decode(JsonTrack.self, from: data).toNitro()
|
|
237
|
+
#expect(restored == track)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// A favorite is the app's state, never persisted with the track; neither is the resolved artwork.
|
|
241
|
+
@Test func favoritedAndTheResolvedArtworkAreNotPersisted() throws {
|
|
242
|
+
let track = Track(
|
|
243
|
+
id: "t1", src: "https://example.com/a.mp3",
|
|
244
|
+
artworkSource: ImageSource(uri: "https://example.com/r.jpg", method: nil, headers: nil, body: nil),
|
|
245
|
+
title: "Title", favorited: true,
|
|
246
|
+
)
|
|
247
|
+
let restored = JsonTrack(from: track).toNitro()
|
|
248
|
+
#expect(restored.favorited == nil)
|
|
249
|
+
#expect(restored.artworkSource == nil)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -23,9 +23,20 @@ private final class MediaLoaderSpy {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/// Resolves and loads `src`, returning once the resolved URL is handed to the asset load.
|
|
26
27
|
@MainActor
|
|
27
|
-
private func
|
|
28
|
-
|
|
28
|
+
private func load(_ loader: MediaLoader, _ src: String) async {
|
|
29
|
+
loader.resolveAndLoad(src: src)
|
|
30
|
+
await loader.mediaResolverTask?.value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@MainActor
|
|
34
|
+
private func makeLoader(
|
|
35
|
+
resolveUrl: @escaping (String, Track?) async -> MediaResolvedUrl = { src, _ in
|
|
36
|
+
MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
37
|
+
},
|
|
38
|
+
) -> (MediaLoader, MediaLoaderSpy) {
|
|
39
|
+
let loader = MediaLoader(resolveUrl: resolveUrl)
|
|
29
40
|
let spy = MediaLoaderSpy()
|
|
30
41
|
loader.onEvent = { spy.record($0) }
|
|
31
42
|
return (loader, spy)
|
|
@@ -52,9 +63,9 @@ struct MediaLoaderInitialStateTests {
|
|
|
52
63
|
@Suite("resolveAndLoad")
|
|
53
64
|
@MainActor
|
|
54
65
|
struct ResolveAndLoadTests {
|
|
55
|
-
@Test func invalidUrl_callsDelegateWithError() {
|
|
66
|
+
@Test func invalidUrl_callsDelegateWithError() async {
|
|
56
67
|
let (loader, spy) = makeLoader()
|
|
57
|
-
loader
|
|
68
|
+
await load(loader, "")
|
|
58
69
|
#expect(spy.playbackErrors.count == 1)
|
|
59
70
|
if case let .invalidSourceUrl(url) = spy.playbackErrors.first {
|
|
60
71
|
#expect(url == "")
|
|
@@ -63,25 +74,24 @@ struct ResolveAndLoadTests {
|
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
@Test func validHttpUrl_createsAsset() {
|
|
77
|
+
@Test func validHttpUrl_createsAsset() async {
|
|
67
78
|
let (loader, _) = makeLoader()
|
|
68
|
-
loader
|
|
79
|
+
await load(loader, "https://example.com/audio.mp3")
|
|
69
80
|
#expect(loader.asset != nil)
|
|
70
81
|
#expect(loader.asset?.url.absoluteString == "https://example.com/audio.mp3")
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
@Test func fileUrl_convertsCorrectly() {
|
|
84
|
+
@Test func fileUrl_convertsCorrectly() async {
|
|
74
85
|
let (loader, _) = makeLoader()
|
|
75
|
-
loader
|
|
86
|
+
await load(loader, "file:///tmp/audio.mp3")
|
|
76
87
|
#expect(loader.asset != nil)
|
|
77
88
|
#expect(loader.asset?.url.isFileURL == true)
|
|
78
89
|
#expect(loader.asset?.url.path == "/tmp/audio.mp3")
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
@Test func withResolver_callsResolver() async {
|
|
82
|
-
let (loader, _) = makeLoader()
|
|
83
93
|
var resolverCalled = false
|
|
84
|
-
loader
|
|
94
|
+
let (loader, _) = makeLoader { src, _ in
|
|
85
95
|
resolverCalled = true
|
|
86
96
|
return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
87
97
|
}
|
|
@@ -93,14 +103,11 @@ struct ResolveAndLoadTests {
|
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
@Test func withResolver_returningInvalidUrl_callsErrorDelegate() async {
|
|
96
|
-
let (loader, spy) = makeLoader
|
|
97
|
-
loader.mediaUrlResolver = { _, _ in
|
|
106
|
+
let (loader, spy) = makeLoader { _, _ in
|
|
98
107
|
MediaResolvedUrl(url: "", headers: nil, userAgent: nil)
|
|
99
108
|
}
|
|
100
109
|
loader.resolveAndLoad(src: "https://example.com/audio.mp3")
|
|
101
110
|
|
|
102
|
-
// The task awaits the resolver then hops back to the main actor, so the
|
|
103
|
-
// delegate call has landed by the time the task itself completes.
|
|
104
111
|
await loader.mediaResolverTask?.value
|
|
105
112
|
|
|
106
113
|
#expect(spy.playbackErrors.count == 1)
|
|
@@ -112,9 +119,8 @@ struct ResolveAndLoadTests {
|
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
@Test func cancelsPreviousResolverTask() async {
|
|
115
|
-
let (loader, _) = makeLoader()
|
|
116
122
|
var callCount = 0
|
|
117
|
-
loader
|
|
123
|
+
let (loader, _) = makeLoader { src, _ in
|
|
118
124
|
callCount += 1
|
|
119
125
|
// Simulate slow resolution
|
|
120
126
|
try? await Task.sleep(for: .milliseconds(100))
|
|
@@ -142,8 +148,7 @@ struct ResolveAndLoadTests {
|
|
|
142
148
|
@MainActor
|
|
143
149
|
struct CancelAllTests {
|
|
144
150
|
@Test func preventsDelegateCallbacksAfterResolveAndLoad() async {
|
|
145
|
-
let (loader, spy) = makeLoader
|
|
146
|
-
loader.mediaUrlResolver = { src, _ in
|
|
151
|
+
let (loader, spy) = makeLoader { src, _ in
|
|
147
152
|
try? await Task.sleep(for: .milliseconds(50))
|
|
148
153
|
return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
149
154
|
}
|
|
@@ -164,9 +169,9 @@ struct CancelAllTests {
|
|
|
164
169
|
@Suite("clearAsset")
|
|
165
170
|
@MainActor
|
|
166
171
|
struct ClearAssetTests {
|
|
167
|
-
@Test func nilsOutAsset() {
|
|
172
|
+
@Test func nilsOutAsset() async {
|
|
168
173
|
let (loader, _) = makeLoader()
|
|
169
|
-
loader
|
|
174
|
+
await load(loader, "https://example.com/audio.mp3")
|
|
170
175
|
#expect(loader.asset != nil)
|
|
171
176
|
loader.clearAsset()
|
|
172
177
|
#expect(loader.asset == nil)
|
|
@@ -187,7 +192,7 @@ struct ClearAssetTests {
|
|
|
187
192
|
struct AsyncLoadingTests {
|
|
188
193
|
@Test func nonExistentLocalFile_eventuallyCallsRetryableError() async {
|
|
189
194
|
let (loader, spy) = makeLoader()
|
|
190
|
-
loader
|
|
195
|
+
await load(loader, "file:///nonexistent/path.mp3")
|
|
191
196
|
|
|
192
197
|
// Poll for delegate callback — asset loading is async
|
|
193
198
|
for _ in 0 ..< 50 {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
@testable import AudioBrowserTestable
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@Suite("NowPlayingLines.resolve")
|
|
5
|
+
struct NowPlayingLinesTests {
|
|
6
|
+
private let track = Track(title: "Station", artist: "City", album: "Country")
|
|
7
|
+
|
|
8
|
+
@Test func withoutLayersTheTrackShows() {
|
|
9
|
+
#expect(NowPlayingLines.resolve(track: track) == NowPlayingLines(title: "Station", artist: "City", album: "Country"))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Test func theOverrideOutranksTheTrack() {
|
|
13
|
+
let lines = NowPlayingLines.resolve(track: track, override: NowPlayingUpdate(title: "Show", artist: nil, album: nil))
|
|
14
|
+
#expect(lines == NowPlayingLines(title: "Show", artist: "City", album: "Country"))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test func theFormatterOutranksTheOverride() {
|
|
18
|
+
let lines = NowPlayingLines.resolve(
|
|
19
|
+
track: track,
|
|
20
|
+
formatted: NowPlayingUpdate(title: "Song", artist: nil, album: nil),
|
|
21
|
+
override: NowPlayingUpdate(title: "Show", artist: "Host", album: nil),
|
|
22
|
+
)
|
|
23
|
+
#expect(lines == NowPlayingLines(title: "Song", artist: "Host", album: "Country"))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test func theFlashOutranksEverything() {
|
|
27
|
+
let lines = NowPlayingLines.resolve(
|
|
28
|
+
track: track,
|
|
29
|
+
flash: NowPlayingUpdate(title: nil, artist: "Can't skip", album: nil),
|
|
30
|
+
formatted: NowPlayingUpdate(title: "Song", artist: "Artist", album: nil),
|
|
31
|
+
override: NowPlayingUpdate(title: "Show", artist: "Host", album: "Album"),
|
|
32
|
+
)
|
|
33
|
+
#expect(lines == NowPlayingLines(title: "Song", artist: "Can't skip", album: "Album"))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test func eachLineFallsBackSeparately() {
|
|
37
|
+
let lines = NowPlayingLines.resolve(
|
|
38
|
+
track: Track(title: "Station"),
|
|
39
|
+
flash: NowPlayingUpdate(title: nil, artist: nil, album: nil),
|
|
40
|
+
)
|
|
41
|
+
#expect(lines == NowPlayingLines(title: "Station", artist: nil, album: nil))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -119,19 +119,19 @@ struct PlayerEngineEventTests {
|
|
|
119
119
|
let (player, engine, _) = makeLoading()
|
|
120
120
|
engine.emit(.unplayable)
|
|
121
121
|
#expect(player.state == .error)
|
|
122
|
-
#expect(player.errorStatus == .terminal(.trackWasUnplayable))
|
|
122
|
+
#expect(player.errorStatus == .terminal(.trackWasUnplayable, report: 1))
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
@Test func anInvalidSourceIsTerminal() {
|
|
126
126
|
let (player, engine, _) = makeLoading()
|
|
127
127
|
engine.emit(.invalidSource("::"))
|
|
128
|
-
#expect(player.errorStatus == .terminal(.invalidSourceUrl("::")))
|
|
128
|
+
#expect(player.errorStatus == .terminal(.invalidSourceUrl("::"), report: 1))
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
@Test func aPlayerFailureCarriesItsHTTPStatus() {
|
|
132
132
|
let (player, engine, _) = makePlaying()
|
|
133
133
|
engine.emit(.failed(nil, httpStatusCode: 404))
|
|
134
|
-
#expect(player.errorStatus == .terminal(.httpStatus(404)))
|
|
134
|
+
#expect(player.errorStatus == .terminal(.httpStatus(404), report: 1))
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
@Test func aGenuineEndAdvances_aLiveEndReconnects() {
|
|
@@ -406,6 +406,22 @@ struct PlayerReviewRegressionTests {
|
|
|
406
406
|
#expect(makePlayer(retries: false).persistedSession() == nil)
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
/// A track change while the old track loads with a retrying error publishes the new track's
|
|
410
|
+
/// loading state without that error.
|
|
411
|
+
@Test func aTrackChangeClearsTheRetryingErrorOfTheTrackItLeaves() async {
|
|
412
|
+
let (player, engine, callbacks) = makeLoading()
|
|
413
|
+
player.retry.shouldRetry = { true }
|
|
414
|
+
player.setPlayWhenReady(true)
|
|
415
|
+
engine.emit(.loadFailed(URLError(.timedOut)))
|
|
416
|
+
#expect(callbacks.playbackChanges.last?.error != nil)
|
|
417
|
+
|
|
418
|
+
player.setQueue([Track(id: "t2", src: "https://example.com/b.mp3", title: "B")])
|
|
419
|
+
|
|
420
|
+
#expect(callbacks.playbackChanges.last?.state == .loading)
|
|
421
|
+
#expect(callbacks.playbackChanges.last?.error == nil)
|
|
422
|
+
player.retry.cancel()
|
|
423
|
+
}
|
|
424
|
+
|
|
409
425
|
/// Leaving an errored track publishes the new track's loading state once, never an error
|
|
410
426
|
/// state without its error.
|
|
411
427
|
@Test func skippingAwayFromAnErrorNeverPublishesAnErrorlessError() {
|