react-native-audio-browser 0.2.0-next.1 → 0.2.0-next.2
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/player/TransformingDataSource.kt +2 -1
- package/ios/Player/MediaLoader.swift +3 -1
- package/ios/Player/PlaybackCoordinator.swift +4 -1
- package/ios/Player/SleepTimerManager.swift +43 -15
- package/ios/Tests/MediaLoaderTests.swift +10 -13
- package/ios/Tests/PlaybackCoordinatorTests.swift +9 -6
- package/ios/Tests/SleepTimerManagerTests.swift +22 -19
- package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
- package/package.json +1 -1
|
@@ -113,7 +113,8 @@ class TransformingDataSource(private val upstream: DataSource, private val facto
|
|
|
113
113
|
* installs the `IcyDataSource` that emits in-stream ICY/Shoutcast song metadata via
|
|
114
114
|
* `Player.Listener.onMetadata`. The `DataSource` default returns an empty map, so without this
|
|
115
115
|
* override this transparent wrapper would swallow the ICY headers — playback keeps working but
|
|
116
|
-
* `onMetadata` never fires (live "now playing" song goes missing).
|
|
116
|
+
* `onMetadata` never fires (live "now playing" song goes missing). Swallowing them also leaves
|
|
117
|
+
* the interleaved metadata bytes in the audio stream, where they decode as audible chirping.
|
|
117
118
|
*/
|
|
118
119
|
override fun getResponseHeaders(): Map<String, List<String>> {
|
|
119
120
|
return upstream.responseHeaders
|
|
@@ -25,7 +25,9 @@ final class MediaLoader {
|
|
|
25
25
|
|
|
26
26
|
weak var delegate: MediaLoaderDelegate?
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/// The in-flight resolve. Readable so tests can await the resolve-then-load
|
|
29
|
+
/// chain instead of sleeping past it; only this type starts and cancels it.
|
|
30
|
+
private(set) var mediaResolverTask: Task<Void, Never>?
|
|
29
31
|
private var metadataLoadTask: Task<Void, Never>?
|
|
30
32
|
private var playableLoadTask: Task<Void, Never>?
|
|
31
33
|
private var url: URL?
|
|
@@ -76,7 +76,10 @@ class PlaybackCoordinator {
|
|
|
76
76
|
/// the window/attempt counters otherwise reset only on track change.
|
|
77
77
|
/// Overridable in tests.
|
|
78
78
|
var healthyPlaybackDuration: TimeInterval = 20
|
|
79
|
-
|
|
79
|
+
|
|
80
|
+
/// The pending refill. Readable so tests can await the window instead of
|
|
81
|
+
/// sleeping past it; only this type arms and cancels it.
|
|
82
|
+
private(set) var healthyPlaybackTask: Task<Void, Never>?
|
|
80
83
|
|
|
81
84
|
/// Playback rate (1.0 = normal speed).
|
|
82
85
|
var rate: Float = 1.0
|
|
@@ -7,15 +7,51 @@ import Foundation
|
|
|
7
7
|
/// Type alias for the Nitro SleepTimer variant type
|
|
8
8
|
typealias SleepTimerState = SleepTimer
|
|
9
9
|
|
|
10
|
+
/// A scheduled job that has not run yet. `cancel()` is nonisolated so `deinit`
|
|
11
|
+
/// — always nonisolated in Swift 6 — can tear pending work down.
|
|
12
|
+
protocol SleepTimerJob: AnyObject {
|
|
13
|
+
func cancel()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
extension DispatchWorkItem: SleepTimerJob {}
|
|
17
|
+
|
|
18
|
+
/// Runs work after a delay. Injected into `SleepTimerManager` so tests can
|
|
19
|
+
/// drive the clock instead of racing it: production schedules on the main
|
|
20
|
+
/// queue, tests hand in a scheduler they advance by hand.
|
|
21
|
+
@MainActor
|
|
22
|
+
protocol SleepTimerScheduling {
|
|
23
|
+
func schedule(
|
|
24
|
+
after delay: TimeInterval,
|
|
25
|
+
_ work: @escaping @Sendable @MainActor () -> Void,
|
|
26
|
+
) -> any SleepTimerJob
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
struct MainQueueSleepTimerScheduler: SleepTimerScheduling {
|
|
30
|
+
func schedule(
|
|
31
|
+
after delay: TimeInterval,
|
|
32
|
+
_ work: @escaping @Sendable @MainActor () -> Void,
|
|
33
|
+
) -> any SleepTimerJob {
|
|
34
|
+
let job = DispatchWorkItem { MainActor.assumeIsolated { work() } }
|
|
35
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: job)
|
|
36
|
+
return job
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
/// Manages sleep timer functionality for the audio player.
|
|
11
41
|
/// Supports both time-based timers and end-of-track timers.
|
|
12
42
|
@MainActor
|
|
13
43
|
class SleepTimerManager: SleepTimerHandling {
|
|
14
44
|
// MARK: - Properties
|
|
15
45
|
|
|
46
|
+
private let scheduler: any SleepTimerScheduling
|
|
47
|
+
|
|
16
48
|
// nonisolated(unsafe) for deinit cleanup — deinit is always nonisolated in Swift 6.
|
|
17
|
-
private nonisolated(unsafe) var sleepTimerJob:
|
|
18
|
-
private nonisolated(unsafe) var fadeJob:
|
|
49
|
+
private nonisolated(unsafe) var sleepTimerJob: (any SleepTimerJob)?
|
|
50
|
+
private nonisolated(unsafe) var fadeJob: (any SleepTimerJob)?
|
|
51
|
+
|
|
52
|
+
init(scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
|
|
53
|
+
self.scheduler = scheduler
|
|
54
|
+
}
|
|
19
55
|
|
|
20
56
|
/// The time when playback should stop (seconds since epoch), or -1 if inactive
|
|
21
57
|
private(set) var sleepTimerTime: TimeInterval = -1 {
|
|
@@ -94,23 +130,15 @@ class SleepTimerManager: SleepTimerHandling {
|
|
|
94
130
|
func set(seconds: TimeInterval, fadeDuration: TimeInterval? = nil) {
|
|
95
131
|
cancelSleepTimerJob()
|
|
96
132
|
onFadeCancel?()
|
|
97
|
-
let job = DispatchWorkItem { [weak self] in
|
|
98
|
-
MainActor.assumeIsolated {
|
|
99
|
-
self?.complete()
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
sleepTimerJob = job
|
|
103
133
|
sleepTimerTime = Date().timeIntervalSince1970 + seconds
|
|
104
|
-
|
|
134
|
+
sleepTimerJob = scheduler.schedule(after: seconds) { [weak self] in
|
|
135
|
+
self?.complete()
|
|
136
|
+
}
|
|
105
137
|
if let fadeDuration, fadeDuration > 0 {
|
|
106
138
|
let fade = min(fadeDuration, seconds)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
self?.onFadeStart?(fade)
|
|
110
|
-
}
|
|
139
|
+
fadeJob = scheduler.schedule(after: seconds - fade) { [weak self] in
|
|
140
|
+
self?.onFadeStart?(fade)
|
|
111
141
|
}
|
|
112
|
-
self.fadeJob = fadeJob
|
|
113
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + (seconds - fade), execute: fadeJob)
|
|
114
142
|
}
|
|
115
143
|
if let state = get() { onChanged?(state) }
|
|
116
144
|
}
|
|
@@ -123,8 +123,7 @@ struct ResolveAndLoadTests {
|
|
|
123
123
|
}
|
|
124
124
|
loader.resolveAndLoad(src: "https://example.com/audio.mp3")
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
await Task.yield()
|
|
126
|
+
await loader.mediaResolverTask?.value
|
|
128
127
|
|
|
129
128
|
#expect(resolverCalled == true)
|
|
130
129
|
}
|
|
@@ -136,12 +135,9 @@ struct ResolveAndLoadTests {
|
|
|
136
135
|
}
|
|
137
136
|
loader.resolveAndLoad(src: "https://example.com/audio.mp3")
|
|
138
137
|
|
|
139
|
-
// The
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
await Task.yield()
|
|
143
|
-
if !spy.playbackErrors.isEmpty { break }
|
|
144
|
-
}
|
|
138
|
+
// The task awaits the resolver then hops back to the main actor, so the
|
|
139
|
+
// delegate call has landed by the time the task itself completes.
|
|
140
|
+
await loader.mediaResolverTask?.value
|
|
145
141
|
|
|
146
142
|
#expect(spy.playbackErrors.count == 1)
|
|
147
143
|
if case let .invalidSourceUrl(url) = spy.playbackErrors.first {
|
|
@@ -166,8 +162,8 @@ struct ResolveAndLoadTests {
|
|
|
166
162
|
// Immediately start second — should cancel first
|
|
167
163
|
loader.resolveAndLoad(src: "https://example.com/second.mp3")
|
|
168
164
|
|
|
169
|
-
//
|
|
170
|
-
|
|
165
|
+
// Await the surviving task; the cancelled one bailed before its resolver.
|
|
166
|
+
await loader.mediaResolverTask?.value
|
|
171
167
|
|
|
172
168
|
// Only the second resolver should have run (first was cancelled before calling resolver)
|
|
173
169
|
#expect(callCount == 1)
|
|
@@ -188,10 +184,11 @@ struct CancelAllTests {
|
|
|
188
184
|
return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
189
185
|
}
|
|
190
186
|
loader.resolveAndLoad(src: "https://example.com/audio.mp3")
|
|
187
|
+
// cancelAll() drops the handle, so grab it first to await the point where
|
|
188
|
+
// the callback would have fired.
|
|
189
|
+
let resolve = loader.mediaResolverTask
|
|
191
190
|
loader.cancelAll()
|
|
192
|
-
|
|
193
|
-
// Wait for what would have been the callback
|
|
194
|
-
try? await Task.sleep(for: .milliseconds(100))
|
|
191
|
+
await resolve?.value
|
|
195
192
|
|
|
196
193
|
#expect(spy.preparedItems.isEmpty)
|
|
197
194
|
#expect(spy.playbackErrors.isEmpty)
|
|
@@ -743,7 +743,7 @@ struct NaturalEndPlayIntentTests {
|
|
|
743
743
|
@Suite("PlaybackCoordinator - healthy playback resets retry budget")
|
|
744
744
|
struct HealthyPlaybackRetryResetTests {
|
|
745
745
|
@Test @MainActor
|
|
746
|
-
func sustainedPlayback_resetsRetryBudget() async
|
|
746
|
+
func sustainedPlayback_resetsRetryBudget() async {
|
|
747
747
|
let retryHandler = MockRetryHandling()
|
|
748
748
|
let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
|
|
749
749
|
let coordinator = PlaybackCoordinator(
|
|
@@ -754,7 +754,7 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
754
754
|
coordinator.healthyPlaybackDuration = 0.01
|
|
755
755
|
|
|
756
756
|
coordinator.transition(.avPlayerPlaying)
|
|
757
|
-
|
|
757
|
+
await coordinator.healthyPlaybackTask?.value
|
|
758
758
|
|
|
759
759
|
#expect(retryHandler.resetCallCount == 1)
|
|
760
760
|
}
|
|
@@ -762,7 +762,7 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
762
762
|
/// resetRetry() cancels a pending retry — firing the refill mid-episode
|
|
763
763
|
/// would kill an in-flight reconnect silently (stuck .playing forever).
|
|
764
764
|
@Test @MainActor
|
|
765
|
-
func refill_skipsWhileRetryIsPending() async
|
|
765
|
+
func refill_skipsWhileRetryIsPending() async {
|
|
766
766
|
let retryHandler = MockRetryHandling()
|
|
767
767
|
retryHandler.isRetryableResult = true
|
|
768
768
|
retryHandler.attemptRetryDelayNs = 1_000_000_000
|
|
@@ -776,14 +776,14 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
776
776
|
|
|
777
777
|
coordinator.transition(.avPlayerPlaying)
|
|
778
778
|
errorHandler.handleError(URLError(.timedOut), context: .playback)
|
|
779
|
-
|
|
779
|
+
await coordinator.healthyPlaybackTask?.value
|
|
780
780
|
|
|
781
781
|
#expect(retryHandler.resetCallCount == 0)
|
|
782
782
|
#expect(errorHandler.pendingRetryTask != nil)
|
|
783
783
|
}
|
|
784
784
|
|
|
785
785
|
@Test @MainActor
|
|
786
|
-
func playbackInterruptedBeforeThreshold_doesNotReset() async
|
|
786
|
+
func playbackInterruptedBeforeThreshold_doesNotReset() async {
|
|
787
787
|
let retryHandler = MockRetryHandling()
|
|
788
788
|
let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
|
|
789
789
|
let coordinator = PlaybackCoordinator(
|
|
@@ -795,9 +795,12 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
795
795
|
coordinator.healthyPlaybackDuration = 0.5
|
|
796
796
|
|
|
797
797
|
coordinator.transition(.avPlayerPlaying)
|
|
798
|
+
let refill = coordinator.healthyPlaybackTask
|
|
798
799
|
coordinator.transition(.avPlayerWaiting) // stalls before the threshold
|
|
799
800
|
|
|
800
|
-
|
|
801
|
+
// Leaving .playing cancels the refill; awaiting the cancelled task is the
|
|
802
|
+
// deterministic way to reach the point where it would have fired.
|
|
803
|
+
await refill?.value
|
|
801
804
|
|
|
802
805
|
#expect(retryHandler.resetCallCount == 0)
|
|
803
806
|
}
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
import Foundation
|
|
3
3
|
import Testing
|
|
4
4
|
|
|
5
|
-
// Timing tests
|
|
6
|
-
//
|
|
7
|
-
// run the manager's scheduled jobs.
|
|
5
|
+
// Timing tests drive a ManualSleepTimerScheduler rather than the main queue:
|
|
6
|
+
// every deadline below is virtual, so nothing races the real clock.
|
|
8
7
|
|
|
9
8
|
@Suite("SleepTimerManager - fade scheduling")
|
|
10
9
|
struct SleepTimerManagerTests {
|
|
11
10
|
@Test @MainActor
|
|
12
|
-
func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel()
|
|
13
|
-
let
|
|
11
|
+
func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel() {
|
|
12
|
+
let clock = ManualSleepTimerScheduler()
|
|
13
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
14
14
|
var fadeStarts: [TimeInterval] = []
|
|
15
15
|
var completes = 0
|
|
16
16
|
var cancels = 0
|
|
@@ -20,35 +20,37 @@ struct SleepTimerManagerTests {
|
|
|
20
20
|
// Attach after set() — replacing a timer emits a defensive onFadeCancel.
|
|
21
21
|
manager.onFadeCancel = { cancels += 1 }
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
clock.advance(to: 0.05) // before the fade window
|
|
24
24
|
#expect(fadeStarts.isEmpty)
|
|
25
25
|
#expect(manager.get() != nil)
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
clock.advance(to: 0.22) // inside the fade window
|
|
28
28
|
#expect(fadeStarts == [0.15])
|
|
29
29
|
#expect(completes == 0)
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
clock.advance(to: 0.4) // past the deadline
|
|
32
32
|
#expect(completes == 1)
|
|
33
33
|
#expect(cancels == 0)
|
|
34
34
|
#expect(manager.get() == nil)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
@Test @MainActor
|
|
38
|
-
func fadeLongerThanTimer_clampsAndStartsImmediately()
|
|
39
|
-
let
|
|
38
|
+
func fadeLongerThanTimer_clampsAndStartsImmediately() {
|
|
39
|
+
let clock = ManualSleepTimerScheduler()
|
|
40
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
40
41
|
var fadeStarts: [TimeInterval] = []
|
|
41
42
|
manager.onFadeStart = { fadeStarts.append($0) }
|
|
42
43
|
|
|
43
44
|
manager.set(seconds: 0.2, fadeDuration: 5)
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
clock.advance(to: 0.1) // fade should have begun
|
|
46
47
|
#expect(fadeStarts == [0.2])
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
@Test @MainActor
|
|
50
|
-
func clear_emitsFadeCancel_andCancelsScheduledFade()
|
|
51
|
-
let
|
|
51
|
+
func clear_emitsFadeCancel_andCancelsScheduledFade() {
|
|
52
|
+
let clock = ManualSleepTimerScheduler()
|
|
53
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
52
54
|
var fadeStarts = 0
|
|
53
55
|
manager.set(seconds: 0.2, fadeDuration: 0.15)
|
|
54
56
|
var cancels = 0
|
|
@@ -58,13 +60,13 @@ struct SleepTimerManagerTests {
|
|
|
58
60
|
#expect(manager.clear())
|
|
59
61
|
#expect(cancels == 1)
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
clock.advance(to: 0.3) // well past the would-be deadline
|
|
62
64
|
#expect(fadeStarts == 0)
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
@Test @MainActor
|
|
66
68
|
func replacingTimer_emitsFadeCancel() {
|
|
67
|
-
let manager = SleepTimerManager()
|
|
69
|
+
let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
|
|
68
70
|
manager.set(seconds: 60, fadeDuration: 10)
|
|
69
71
|
var cancels = 0
|
|
70
72
|
manager.onFadeCancel = { cancels += 1 }
|
|
@@ -76,7 +78,7 @@ struct SleepTimerManagerTests {
|
|
|
76
78
|
|
|
77
79
|
@Test @MainActor
|
|
78
80
|
func switchingToEndOfTrack_emitsFadeCancel() {
|
|
79
|
-
let manager = SleepTimerManager()
|
|
81
|
+
let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
|
|
80
82
|
manager.set(seconds: 60, fadeDuration: 10)
|
|
81
83
|
var cancels = 0
|
|
82
84
|
manager.onFadeCancel = { cancels += 1 }
|
|
@@ -88,8 +90,9 @@ struct SleepTimerManagerTests {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
@Test @MainActor
|
|
91
|
-
func noFadeDuration_schedulesNoFade()
|
|
92
|
-
let
|
|
93
|
+
func noFadeDuration_schedulesNoFade() {
|
|
94
|
+
let clock = ManualSleepTimerScheduler()
|
|
95
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
93
96
|
var fadeStarts = 0
|
|
94
97
|
var completes = 0
|
|
95
98
|
manager.onFadeStart = { _ in fadeStarts += 1 }
|
|
@@ -97,7 +100,7 @@ struct SleepTimerManagerTests {
|
|
|
97
100
|
|
|
98
101
|
manager.set(seconds: 0.1)
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
clock.advance(to: 0.2)
|
|
101
104
|
#expect(fadeStarts == 0)
|
|
102
105
|
#expect(completes == 1)
|
|
103
106
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
@testable import AudioBrowserTestable
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// A job handed back by `ManualSleepTimerScheduler`.
|
|
5
|
+
private final class ManualJob: SleepTimerJob {
|
|
6
|
+
nonisolated(unsafe) var isCancelled = false
|
|
7
|
+
func cancel() { isCancelled = true }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Deterministic stand-in for the main-queue scheduler: jobs fire only when the
|
|
11
|
+
/// test advances the clock, so an assertion can never lose a race against a
|
|
12
|
+
/// real deadline on a loaded CI runner.
|
|
13
|
+
@MainActor
|
|
14
|
+
final class ManualSleepTimerScheduler: SleepTimerScheduling {
|
|
15
|
+
private struct Entry {
|
|
16
|
+
let deadline: TimeInterval
|
|
17
|
+
let job: ManualJob
|
|
18
|
+
let work: @MainActor () -> Void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private var entries: [Entry] = []
|
|
22
|
+
private(set) var now: TimeInterval = 0
|
|
23
|
+
|
|
24
|
+
func schedule(
|
|
25
|
+
after delay: TimeInterval,
|
|
26
|
+
_ work: @escaping @Sendable @MainActor () -> Void,
|
|
27
|
+
) -> any SleepTimerJob {
|
|
28
|
+
let job = ManualJob()
|
|
29
|
+
entries.append(Entry(deadline: now + delay, job: job, work: work))
|
|
30
|
+
return job
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Moves the virtual clock to `time`, firing every due job in deadline order.
|
|
34
|
+
/// Jobs run one at a time so a job that schedules or cancels another (the
|
|
35
|
+
/// fade landing before completion) sees the same ordering it would in
|
|
36
|
+
/// production.
|
|
37
|
+
func advance(to time: TimeInterval) {
|
|
38
|
+
now = time
|
|
39
|
+
while let next = entries
|
|
40
|
+
.filter({ !$0.job.isCancelled && $0.deadline <= now })
|
|
41
|
+
.min(by: { $0.deadline < $1.deadline })
|
|
42
|
+
{
|
|
43
|
+
entries.removeAll { $0.job === next.job }
|
|
44
|
+
next.work()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|