react-native-audio-browser 0.2.0-next.31 → 0.2.0-next.32
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/JsonModels.swift +93 -182
- package/ios/HybridAudioBrowser+Player.swift +3 -3
- package/ios/Model/NitroTypeStubs.swift +13 -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/ErrorStatusTests.swift +50 -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/RetryLoopTests.swift +35 -0
- package/ios/Tests/SleepTimerManagerTests.swift +65 -41
- package/ios/Tests/Support/TestTypes.swift +2 -2
- package/package.json +1 -1
package/ios/Player/Player.swift
CHANGED
|
@@ -23,17 +23,12 @@ final class Player {
|
|
|
23
23
|
let sleepTimerManager: SleepTimerManager
|
|
24
24
|
var deferredSeek = DeferredSeek()
|
|
25
25
|
|
|
26
|
-
lazy var volumeFader = VolumeFader(
|
|
27
|
-
getVolume: { [weak self] in self?.engine.volume ?? 1 },
|
|
28
|
-
setVolume: { [weak self] level in self?.engine.volume = level },
|
|
29
|
-
)
|
|
30
|
-
|
|
31
26
|
private lazy var progressTimer = PlaybackTimer(
|
|
32
27
|
isActive: { $0 == .loading || $0 == .buffering || $0 == .playing },
|
|
33
28
|
) { [weak self] in
|
|
34
|
-
guard let self, currentIndex >= 0 else { return }
|
|
29
|
+
guard let self, queue.currentIndex >= 0 else { return }
|
|
35
30
|
let progressEvent = PlaybackProgressUpdatedEvent(
|
|
36
|
-
track: Double(currentIndex),
|
|
31
|
+
track: Double(queue.currentIndex),
|
|
37
32
|
position: engine.currentTime,
|
|
38
33
|
duration: engine.duration,
|
|
39
34
|
buffered: engine.bufferedPosition,
|
|
@@ -61,6 +56,13 @@ final class Player {
|
|
|
61
56
|
var primedNowPlaying = false
|
|
62
57
|
/// One of this load's items is in the engine; until then it reports the previous one's position.
|
|
63
58
|
var itemPrepared = false
|
|
59
|
+
|
|
60
|
+
/// True the first time it is asked, so the silent primer runs once per load.
|
|
61
|
+
mutating func claimNowPlayingPrime() -> Bool {
|
|
62
|
+
guard !primedNowPlaying else { return false }
|
|
63
|
+
primedNowPlaying = true
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
private(set) var load: Load?
|
|
@@ -68,40 +70,18 @@ final class Player {
|
|
|
68
70
|
|
|
69
71
|
/// Starts a Load: a track becoming current, or a restart of this one from a terminal error.
|
|
70
72
|
/// Fresh retry budgets, no pending seek, unproven playback.
|
|
71
|
-
func beginLoad() {
|
|
73
|
+
private func beginLoad() {
|
|
72
74
|
loadCount &+= 1
|
|
73
75
|
load = Load(id: loadCount)
|
|
74
76
|
retry.reset()
|
|
75
77
|
deferredSeek.reset()
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
///
|
|
79
|
-
func claimNowPlayingPrime() -> Bool {
|
|
80
|
-
guard load?.primedNowPlaying == false else { return false }
|
|
81
|
-
load?.primedNowPlaying = true
|
|
82
|
-
return true
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/// Whether playback should resume when the current audio-session interruption
|
|
86
|
-
/// ends — set to the play intent captured at interruption start, so we only
|
|
87
|
-
/// auto-resume if we were actually playing when interrupted.
|
|
80
|
+
/// The play intent captured when the current interruption began; its end resumes only then.
|
|
88
81
|
private var shouldResumeAfterInterruption = false
|
|
89
82
|
|
|
90
|
-
///
|
|
91
|
-
///
|
|
92
|
-
/// (mirrors Android's HEALTHY_PLAYBACK_MS). Without the refill a long-lived
|
|
93
|
-
/// stream permanently loses retry after its first recovered error, because
|
|
94
|
-
/// the window/attempt counters otherwise reset only on track change.
|
|
95
|
-
/// Overridable in tests.
|
|
96
|
-
var healthyPlaybackDuration: TimeInterval = 20
|
|
97
|
-
|
|
98
|
-
/// The pending refill. Readable so tests can await the window instead of
|
|
99
|
-
/// sleeping past it; only this type arms and cancels it.
|
|
100
|
-
private(set) var healthyPlaybackTask: Task<Void, Never>?
|
|
101
|
-
|
|
102
|
-
/// Playback rate (1.0 = normal speed). A non-positive rate would leave the player unable to
|
|
103
|
-
/// play, so it is refused here, where every source (JS, remote command, CarPlay, a restored
|
|
104
|
-
/// snapshot) funnels through. Speed and play intent are independent, as on Android and web.
|
|
83
|
+
/// Playback rate (1.0 = normal speed). A non-positive rate, which could never play, is refused.
|
|
84
|
+
/// Speed and play intent are independent, as on Android and web.
|
|
105
85
|
var rate: Float = 1.0 {
|
|
106
86
|
didSet {
|
|
107
87
|
guard rate > 0 else {
|
|
@@ -140,25 +120,17 @@ final class Player {
|
|
|
140
120
|
|
|
141
121
|
// MARK: - Track State (for active track changed events)
|
|
142
122
|
|
|
143
|
-
var lastIndex: Int = -1
|
|
144
|
-
var lastTrack: Track?
|
|
123
|
+
private var lastIndex: Int = -1
|
|
124
|
+
private var lastTrack: Track?
|
|
145
125
|
|
|
146
|
-
// MARK: -
|
|
126
|
+
// MARK: - TrackSelectionPlayer and NowPlayingSource
|
|
147
127
|
|
|
148
128
|
var tracks: [Track] { queue.tracks }
|
|
149
|
-
var currentIndex: Int { queue.currentIndex }
|
|
150
|
-
var currentTrack: Track? { queue.currentTrack }
|
|
151
129
|
var queueSourcePath: String? { queue.queueSourcePath }
|
|
152
|
-
var
|
|
153
|
-
var previousTracks: [Track] { queue.previousTracks }
|
|
154
|
-
var isLastInPlaybackOrder: Bool { queue.isLastInPlaybackOrder }
|
|
155
|
-
var canNext: Bool { queue.canNext }
|
|
130
|
+
var currentTrack: Track? { queue.currentTrack }
|
|
156
131
|
var currentTime: Double { engine.currentTime }
|
|
157
132
|
var nitroPlaybackError: PlaybackError? { errorStatus.nitroError }
|
|
158
|
-
var sleepTimer: SleepTimerState? { sleepTimerManager.get() }
|
|
159
|
-
var canPrevious: Bool { queue.canPrevious }
|
|
160
133
|
|
|
161
|
-
/// The repeat mode for the queue player.
|
|
162
134
|
var repeatMode: RepeatMode {
|
|
163
135
|
get { queue.repeatMode }
|
|
164
136
|
set {
|
|
@@ -167,14 +139,13 @@ final class Player {
|
|
|
167
139
|
engine.updateRemoteRepeatMode(newValue)
|
|
168
140
|
// Persisted on each change, or a mode toggled while paused would miss the resume snapshot.
|
|
169
141
|
persistence.save()
|
|
170
|
-
pushSkipAvailability()
|
|
142
|
+
pushSkipAvailability()
|
|
171
143
|
callbacks?.playerDidChangeRepeatMode(
|
|
172
144
|
RepeatModeChangedEvent(repeatMode: newValue),
|
|
173
145
|
)
|
|
174
146
|
}
|
|
175
147
|
}
|
|
176
148
|
|
|
177
|
-
/// Whether shuffle mode is enabled.
|
|
178
149
|
var shuffleEnabled: Bool {
|
|
179
150
|
get { queue.shuffleEnabled }
|
|
180
151
|
set {
|
|
@@ -182,13 +153,11 @@ final class Player {
|
|
|
182
153
|
queue.shuffleEnabled = newValue
|
|
183
154
|
engine.updateRemoteShuffleMode(newValue)
|
|
184
155
|
persistence.save()
|
|
185
|
-
pushSkipAvailability()
|
|
156
|
+
pushSkipAvailability()
|
|
186
157
|
callbacks?.playerDidChangeShuffleEnabled(newValue)
|
|
187
158
|
}
|
|
188
159
|
}
|
|
189
160
|
|
|
190
|
-
/// Pushes current next/previous availability to the remote command center so
|
|
191
|
-
/// CarPlay / lock-screen grey out the buttons when there's nowhere to skip.
|
|
192
161
|
private func pushSkipAvailability() {
|
|
193
162
|
engine.updateSkipAvailability(canNext: queue.canNext, canPrevious: queue.canPrevious)
|
|
194
163
|
}
|
|
@@ -201,16 +170,12 @@ final class Player {
|
|
|
201
170
|
func setPlayWhenReady(_ newValue: Bool) {
|
|
202
171
|
let changed = newValue != playWhenReady
|
|
203
172
|
playWhenReady = newValue
|
|
204
|
-
// A play during an interruption
|
|
205
|
-
// not be overridden by auto-resume. (handleInterruptionEnded consumes its flag first.)
|
|
173
|
+
// A play during an interruption owns the intent: a later pause must not be auto-resumed.
|
|
206
174
|
if newValue { shouldResumeAfterInterruption = false }
|
|
207
175
|
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// from the last position.
|
|
176
|
+
// A terminal state has no item to play, or one parked at its end: reload, re-resolving a URL
|
|
177
|
+
// that may have expired since. Only .error resumes from the last position.
|
|
211
178
|
if newValue, state == .error || state == .stopped || state == .ended {
|
|
212
|
-
// Re-resolve rather than replay the cached URL: play-from-.error typically comes long
|
|
213
|
-
// after the failure, when a signed URL has expired.
|
|
214
179
|
reloadResolving(startFromCurrentTime: state == .error)
|
|
215
180
|
}
|
|
216
181
|
if state != .loading {
|
|
@@ -218,9 +183,8 @@ final class Player {
|
|
|
218
183
|
}
|
|
219
184
|
|
|
220
185
|
guard changed else { return }
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
if !newValue, volumeFader.isActive { sleepTimerManager.clear() }
|
|
186
|
+
// A pause during the sleep fade ends the timer early, restoring the pre-fade volume.
|
|
187
|
+
if !newValue, sleepTimerManager.isFading { sleepTimerManager.clear() }
|
|
224
188
|
publish()
|
|
225
189
|
evaluateSessionRelease()
|
|
226
190
|
}
|
|
@@ -253,55 +217,37 @@ final class Player {
|
|
|
253
217
|
init(
|
|
254
218
|
engine: any PlayerEngine,
|
|
255
219
|
retry: RetryLoop = RetryLoop(),
|
|
256
|
-
|
|
220
|
+
sleepTimerScheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler(),
|
|
257
221
|
persistenceDefaults: UserDefaults = PlaybackPersistence.standardDefaults,
|
|
258
222
|
persistenceSaveInterval: Duration = .seconds(5),
|
|
259
223
|
) {
|
|
260
224
|
self.engine = engine
|
|
261
225
|
self.retry = retry
|
|
262
|
-
|
|
226
|
+
sleepTimerManager = SleepTimerManager(
|
|
227
|
+
fader: VolumeFader(
|
|
228
|
+
getVolume: { [weak engine] in engine?.volume ?? 1 },
|
|
229
|
+
setVolume: { [weak engine] in engine?.volume = $0 },
|
|
230
|
+
),
|
|
231
|
+
scheduler: sleepTimerScheduler,
|
|
232
|
+
)
|
|
263
233
|
self.persistenceDefaults = persistenceDefaults
|
|
264
234
|
self.persistenceSaveInterval = persistenceSaveInterval
|
|
265
235
|
engine.onEvent = { [weak self] in self?.engineDidEmit($0) }
|
|
266
236
|
|
|
267
|
-
|
|
268
|
-
sleepTimerManager.onComplete = { [weak self] in
|
|
269
|
-
guard let self else { return }
|
|
270
|
-
self.volumeFader.resolve { self.pause() }
|
|
271
|
-
}
|
|
272
|
-
sleepTimerManager.onFadeStart = { [weak self] duration in
|
|
273
|
-
self?.volumeFader.start(duration: duration)
|
|
274
|
-
}
|
|
275
|
-
sleepTimerManager.onFadeCancel = { [weak self] in
|
|
276
|
-
self?.volumeFader.cancel(restoringVolume: true)
|
|
277
|
-
}
|
|
237
|
+
sleepTimerManager.onComplete = { [weak self] in self?.pause() }
|
|
278
238
|
sleepTimerManager.onChanged = { [weak self] in self?.callbacks?.playerDidChangeSleepTimer($0) }
|
|
279
239
|
|
|
280
|
-
retry.onError = { [weak self]
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
retry.onRetryingError = { [weak self] error in
|
|
284
|
-
self?.reportRetryingError(error)
|
|
285
|
-
}
|
|
286
|
-
retry.onRetry = { [weak self] startFromCurrentTime in
|
|
287
|
-
// Re-resolve: a retry may be recovering from an expired short-lived URL or token.
|
|
288
|
-
self?.reloadResolving(startFromCurrentTime: startFromCurrentTime)
|
|
289
|
-
}
|
|
240
|
+
retry.onError = { [weak self] in self?.transition(.errorOccurred($0)) }
|
|
241
|
+
retry.onRetryingError = { [weak self] in self?.reportRetryingError($0) }
|
|
242
|
+
retry.onRetry = { [weak self] in self?.reloadResolving(startFromCurrentTime: $0) }
|
|
290
243
|
retry.shouldRetry = { [weak self] in self?.playWhenReady ?? false }
|
|
291
244
|
retry.hasPlayed = { [weak self] in self?.load?.hasPlayed ?? false }
|
|
292
245
|
}
|
|
293
246
|
|
|
294
|
-
///
|
|
295
|
-
///
|
|
296
|
-
/// Not a state transition — the state machine never sees it — and not a
|
|
297
|
-
/// `playerDidError`, which stays reserved for terminal errors.
|
|
247
|
+
/// Shows a failure the retry loop is still working on over the current state: not a
|
|
248
|
+
/// transition, and not a `playerDidError`, which is for terminal errors.
|
|
298
249
|
private func reportRetryingError(_ error: TrackPlayerError.PlaybackError) {
|
|
299
|
-
|
|
300
|
-
// Android). During .playing buffered audio is still audibly fine — the
|
|
301
|
-
// advisory would replace the song line over working sound — and a
|
|
302
|
-
// terminal/stopped state must not regain an advisory error.
|
|
303
|
-
guard state == .loading || state == .buffering || state == .paused else { return }
|
|
304
|
-
// Each failed attempt re-reports; identical repeats add nothing.
|
|
250
|
+
guard state.carriesAdvisoryError else { return }
|
|
305
251
|
errorStatus = .retrying(error)
|
|
306
252
|
publish()
|
|
307
253
|
}
|
|
@@ -310,29 +256,23 @@ final class Player {
|
|
|
310
256
|
|
|
311
257
|
func transition(_ event: PlaybackEvent) {
|
|
312
258
|
guard let newState = nextPlaybackState(from: state, on: event) else { return }
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if newState == state, case let .errorOccurred(error) = event {
|
|
317
|
-
errorStatus = .terminal(error)
|
|
318
|
-
published.errorStatus = .none // each terminal report reaches JS, a repeat included
|
|
259
|
+
let oldState = state
|
|
260
|
+
errorStatus = errorStatus.after(event: event, from: oldState, to: newState)
|
|
261
|
+
guard newState != oldState else {
|
|
319
262
|
publish()
|
|
320
263
|
return
|
|
321
264
|
}
|
|
322
|
-
|
|
323
|
-
guard newState != state else { return }
|
|
324
|
-
let oldState = state
|
|
325
265
|
state = newState
|
|
326
|
-
applySideEffects(old: oldState, new: newState
|
|
266
|
+
applySideEffects(old: oldState, new: newState)
|
|
327
267
|
publish()
|
|
328
268
|
// While nothing plays, no periodic save runs: every transition rewrites the snapshot, so it
|
|
329
269
|
// never keeps a position from before a track change or a seek.
|
|
330
270
|
if !(playbackActive && playWhenReady) { persistence.save() }
|
|
331
271
|
// Repeat modes never end the queue (matches web's endsQueue()): a repeat-track pause at the
|
|
332
272
|
// track boundary (e.g. the end-of-track sleep timer) must not read as "playlist over".
|
|
333
|
-
if newState == .ended, isLastInPlaybackOrder, repeatMode == .off {
|
|
273
|
+
if newState == .ended, queue.isLastInPlaybackOrder, repeatMode == .off {
|
|
334
274
|
callbacks?.playerDidEndQueue(
|
|
335
|
-
PlaybackQueueEndedEvent(track: Double(currentIndex), position: engine.currentTime),
|
|
275
|
+
PlaybackQueueEndedEvent(track: Double(queue.currentIndex), position: engine.currentTime),
|
|
336
276
|
)
|
|
337
277
|
}
|
|
338
278
|
}
|
|
@@ -379,37 +319,13 @@ final class Player {
|
|
|
379
319
|
}
|
|
380
320
|
}
|
|
381
321
|
|
|
382
|
-
private func applySideEffects(old: PlaybackState, new: PlaybackState
|
|
383
|
-
// Error lifecycle. A retrying error rides along through loading/buffering, but any state
|
|
384
|
-
// that proves recovery (.ready/.playing: data flowed) or abandons the attempt
|
|
385
|
-
// (.stopped/.none/.ended) clears it. Runs before publish, so the emitted Playback
|
|
386
|
-
// is already clean.
|
|
387
|
-
if case let .errorOccurred(error) = event {
|
|
388
|
-
errorStatus = .terminal(error)
|
|
389
|
-
} else if old == .error || (errorStatus.isRetrying && new != .loading && new != .buffering && new != .paused) {
|
|
390
|
-
errorStatus = .none
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Retry-budget refill: sustained audible playback proves the stream
|
|
394
|
-
// recovered, so the next error starts a fresh retry window. Leaving
|
|
395
|
-
// .playing before the threshold cancels the pending refill.
|
|
322
|
+
private func applySideEffects(old: PlaybackState, new: PlaybackState) {
|
|
396
323
|
if new == .playing {
|
|
397
|
-
|
|
398
|
-
healthyPlaybackTask = Task { [weak self] in
|
|
399
|
-
guard let self else { return }
|
|
400
|
-
try? await Task.sleep(nanoseconds: UInt64(healthyPlaybackDuration * 1_000_000_000))
|
|
401
|
-
guard !Task.isCancelled else { return }
|
|
402
|
-
// Refill only between episodes: reset() cancels a pending retry,
|
|
403
|
-
// which would silently kill an in-flight reconnect.
|
|
404
|
-
guard retry.pendingTask == nil else { return }
|
|
405
|
-
retry.reset()
|
|
406
|
-
}
|
|
324
|
+
retry.playbackBecameAudible()
|
|
407
325
|
} else if old == .playing {
|
|
408
|
-
|
|
409
|
-
healthyPlaybackTask = nil
|
|
326
|
+
retry.playbackStoppedBeingAudible()
|
|
410
327
|
}
|
|
411
328
|
|
|
412
|
-
// State-specific effects
|
|
413
329
|
switch new {
|
|
414
330
|
case .ready:
|
|
415
331
|
engine.applyTimePitchAlgorithm()
|
|
@@ -417,11 +333,7 @@ final class Player {
|
|
|
417
333
|
case .loading:
|
|
418
334
|
engine.applyTimePitchAlgorithm()
|
|
419
335
|
case .ended:
|
|
420
|
-
// A natural end exhausts the play intent
|
|
421
|
-
// Keeping playWhenReady true would invert the play/pause toggle, hold
|
|
422
|
-
// the audio session forever (release is intent-gated), and arm
|
|
423
|
-
// interruption auto-resume into silence. The setter also requests the
|
|
424
|
-
// session release.
|
|
336
|
+
// A natural end exhausts the play intent; the setter also requests the session release.
|
|
425
337
|
setPlayWhenReady(false)
|
|
426
338
|
default: break
|
|
427
339
|
}
|
|
@@ -430,55 +342,6 @@ final class Player {
|
|
|
430
342
|
intervalTimer.onPlaybackStateChanged(new)
|
|
431
343
|
}
|
|
432
344
|
|
|
433
|
-
// MARK: - Observer Context Guards
|
|
434
|
-
|
|
435
|
-
func avPlayerDidChangeTimeControlStatus(_ status: PlayerTimeControlStatus) {
|
|
436
|
-
// During loading, ignore stale timeControlStatus changes from old items.
|
|
437
|
-
if state == .loading { return }
|
|
438
|
-
|
|
439
|
-
switch status {
|
|
440
|
-
case .paused:
|
|
441
|
-
let currentState = state
|
|
442
|
-
let currentTime = engine.currentTime
|
|
443
|
-
let duration = engine.duration
|
|
444
|
-
// Ignore pauses when near track end
|
|
445
|
-
let nearTrackEnd = currentTime >= duration - 0.5 && duration > 0
|
|
446
|
-
|
|
447
|
-
if nearTrackEnd {
|
|
448
|
-
// Ignore - track completion will be handled by handleTrackDidPlayToEndTime
|
|
449
|
-
} else if !engine.hasLoadedAsset, currentState != .stopped {
|
|
450
|
-
transition(.avPlayerPaused(hasAsset: false))
|
|
451
|
-
} else if currentState != .error, currentState != .stopped {
|
|
452
|
-
if !playWhenReady {
|
|
453
|
-
transition(.avPlayerPaused(hasAsset: true))
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
case .waitingToPlayAtSpecifiedRate:
|
|
457
|
-
if engine.hasLoadedAsset {
|
|
458
|
-
transition(.avPlayerWaiting)
|
|
459
|
-
}
|
|
460
|
-
case .playing:
|
|
461
|
-
transition(.avPlayerPlaying)
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
func audioDidStart() {
|
|
466
|
-
load?.hasPlayed = true
|
|
467
|
-
// Don't override loading state
|
|
468
|
-
if state == .loading { return }
|
|
469
|
-
transition(.audioFrameDecoded)
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
func avItemDidUpdatePlaybackLikelyToKeepUp(_ playbackLikelyToKeepUp: Bool) {
|
|
473
|
-
guard playbackLikelyToKeepUp else { return }
|
|
474
|
-
guard engine.hasLoadedAsset else { return }
|
|
475
|
-
|
|
476
|
-
if !deferredSeek.defersReady, state != .playing {
|
|
477
|
-
logger.debug("avItemDidUpdatePlaybackLikelyToKeepUp → .ready")
|
|
478
|
-
transition(.bufferingSufficient)
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
345
|
// MARK: - Player Actions
|
|
483
346
|
|
|
484
347
|
func play() {
|
|
@@ -494,10 +357,8 @@ final class Player {
|
|
|
494
357
|
}
|
|
495
358
|
|
|
496
359
|
func stop() {
|
|
497
|
-
//
|
|
498
|
-
// over the stop, and a pending task blocks the session release below.
|
|
360
|
+
// Before the intent drop: a pending retry would surface .error and hold the session.
|
|
499
361
|
retry.cancel()
|
|
500
|
-
// A stop is terminal — interruption-end must not resume over it.
|
|
501
362
|
shouldResumeAfterInterruption = false
|
|
502
363
|
transition(.stopped)
|
|
503
364
|
setPlayWhenReady(false)
|
|
@@ -506,24 +367,17 @@ final class Player {
|
|
|
506
367
|
|
|
507
368
|
// MARK: - Audio Session Interruptions
|
|
508
369
|
|
|
509
|
-
/// The
|
|
510
|
-
///
|
|
511
|
-
/// interruption auto-resume, so a call ending can't blast the built-in
|
|
512
|
-
/// speaker.
|
|
370
|
+
/// The output route went away (headphones unplugged): pause, and cancel any interruption
|
|
371
|
+
/// resume, so a call ending cannot start the built-in speaker.
|
|
513
372
|
func handleRouteDisconnected() {
|
|
514
373
|
shouldResumeAfterInterruption = false
|
|
515
374
|
pause()
|
|
516
375
|
}
|
|
517
376
|
|
|
518
|
-
///
|
|
519
|
-
///
|
|
520
|
-
/// but that pause arrives as a `timeControlStatus` change that's swallowed
|
|
521
|
-
/// while `playWhenReady` is still true — so the state would otherwise stay
|
|
522
|
-
/// `.playing` and any UI driven by it stays stuck. Drop the play intent and
|
|
523
|
-
/// force the paused state, independent of notification delivery order.
|
|
377
|
+
/// iOS has already paused the AVPlayer, but that pause is swallowed while the intent holds, so
|
|
378
|
+
/// the paused state is forced here.
|
|
524
379
|
func handleInterruptionBegan() {
|
|
525
|
-
//
|
|
526
|
-
// intent and must not wipe the flag the first one captured.
|
|
380
|
+
// A nested interruption keeps the intent the first one captured.
|
|
527
381
|
shouldResumeAfterInterruption = shouldResumeAfterInterruption || playWhenReady
|
|
528
382
|
pause()
|
|
529
383
|
if playbackActive, state != .loading {
|
|
@@ -531,29 +385,21 @@ final class Player {
|
|
|
531
385
|
}
|
|
532
386
|
}
|
|
533
387
|
|
|
534
|
-
/// Whether
|
|
535
|
-
/// interruption start. Lets the host gate side effects that must precede the
|
|
536
|
-
/// resume (audio-session reactivation) on whether one will actually happen.
|
|
388
|
+
/// Whether the interruption's end will resume playback, so the session reactivates first.
|
|
537
389
|
var willResumeAfterInterruption: Bool { shouldResumeAfterInterruption }
|
|
538
390
|
|
|
539
|
-
/// An audio-session interruption ended. Resume only if we were playing when it
|
|
540
|
-
/// began and the system indicates resumption is appropriate (`shouldResume`).
|
|
541
391
|
func handleInterruptionEnded(shouldResume: Bool) {
|
|
542
392
|
guard shouldResumeAfterInterruption else { return }
|
|
543
393
|
shouldResumeAfterInterruption = false
|
|
544
394
|
if shouldResume {
|
|
545
395
|
play()
|
|
546
396
|
} else {
|
|
547
|
-
// Interruption ended and we're staying paused: the session can be released now.
|
|
548
397
|
evaluateSessionRelease()
|
|
549
398
|
}
|
|
550
399
|
}
|
|
551
400
|
|
|
552
|
-
/// Requests
|
|
553
|
-
///
|
|
554
|
-
/// before the interruption's `pause()`, so this skips it), and no retry is pending (a live-stream
|
|
555
|
-
/// reconnect keeps `playWhenReady` true and must keep the session). The host debounces and cancels
|
|
556
|
-
/// on the next activation, so a quick pause→resume never actually releases.
|
|
401
|
+
/// Requests a session release once playback is deliberately stopped: no intent, no
|
|
402
|
+
/// interruption to resume from, no retry pending. The host debounces it.
|
|
557
403
|
private func evaluateSessionRelease() {
|
|
558
404
|
guard !playWhenReady,
|
|
559
405
|
!shouldResumeAfterInterruption,
|
|
@@ -566,8 +412,8 @@ final class Player {
|
|
|
566
412
|
Playback(state: state, error: errorStatus.nitroError)
|
|
567
413
|
}
|
|
568
414
|
|
|
569
|
-
///
|
|
570
|
-
func handlePlayWhenReady(_ playWhenReady: Bool?, action: () throws -> Void) rethrows {
|
|
415
|
+
/// Drops the intent before `action` or raises it after, as `playWhenReady` asks.
|
|
416
|
+
private func handlePlayWhenReady(_ playWhenReady: Bool?, action: () throws -> Void) rethrows {
|
|
571
417
|
if playWhenReady == false { setPlayWhenReady(false) }
|
|
572
418
|
try action()
|
|
573
419
|
if playWhenReady == true { setPlayWhenReady(true) }
|
|
@@ -580,9 +426,7 @@ final class Player {
|
|
|
580
426
|
if queue.currentIndex == -1 {
|
|
581
427
|
afterQueueMutation(queue.add([track], initialIndex: 0))
|
|
582
428
|
} else {
|
|
583
|
-
//
|
|
584
|
-
// above, so this cannot fail — but surface it rather than reloading the
|
|
585
|
-
// old track if that invariant is ever broken.
|
|
429
|
+
// In bounds, as -1 took the branch above.
|
|
586
430
|
do {
|
|
587
431
|
try queue.replace(queue.currentIndex, track)
|
|
588
432
|
afterQueueMutation(.tracksAndCurrent)
|
|
@@ -692,19 +536,17 @@ final class Player {
|
|
|
692
536
|
|
|
693
537
|
// MARK: - Track Loading
|
|
694
538
|
|
|
695
|
-
func
|
|
539
|
+
private func trackDidPlayToEnd() {
|
|
696
540
|
sleepTimerManager.onTrackPlayedToEnd()
|
|
697
541
|
|
|
698
542
|
if repeatMode == .track {
|
|
699
|
-
// The end-of-track sleep
|
|
700
|
-
// without it, settle in .ended (where play() reloads) instead of
|
|
701
|
-
// replaying past the sleep timer.
|
|
543
|
+
// The end-of-track sleep timer has just cleared the intent: settle in .ended, not replay.
|
|
702
544
|
if playWhenReady {
|
|
703
545
|
replay()
|
|
704
546
|
} else {
|
|
705
547
|
transition(.trackEndedNaturally)
|
|
706
548
|
}
|
|
707
|
-
} else if repeatMode == .queue || !isLastInPlaybackOrder {
|
|
549
|
+
} else if repeatMode == .queue || !queue.isLastInPlaybackOrder {
|
|
708
550
|
next()
|
|
709
551
|
} else {
|
|
710
552
|
transition(.trackEndedNaturally)
|
|
@@ -721,13 +563,8 @@ final class Player {
|
|
|
721
563
|
beginLoad()
|
|
722
564
|
if let start { deferredSeek.capture(start) }
|
|
723
565
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
// Already .loading, the .trackLoading transition below won't fire: publish the clear here,
|
|
727
|
-
// or the old track's error lingers over the new one's spinner. In any other state the
|
|
728
|
-
// transition publishes it, with the state it belongs to.
|
|
729
|
-
if state == .loading { publish() }
|
|
730
|
-
}
|
|
566
|
+
// Published by the .trackLoading or .trackUnloaded transition below, even when it is a no-op.
|
|
567
|
+
errorStatus = .none
|
|
731
568
|
|
|
732
569
|
let lastPosition = engine.currentTime
|
|
733
570
|
let shouldContinuePlayback = playWhenReady
|
|
@@ -735,30 +572,19 @@ final class Player {
|
|
|
735
572
|
engine.stopObservingItem()
|
|
736
573
|
engine.pause()
|
|
737
574
|
|
|
738
|
-
//
|
|
739
|
-
// prevents a no-op startPlayback() on the now-nil item.
|
|
575
|
+
// Loading first, so the setter does not play the item that is gone.
|
|
740
576
|
transition(.trackLoading)
|
|
741
|
-
|
|
742
|
-
// Ensure playWhenReady is set before loading to preserve playback state
|
|
743
577
|
setPlayWhenReady(shouldContinuePlayback)
|
|
744
578
|
|
|
745
579
|
engine.loadNowPlayingMetadata(for: currentTrack)
|
|
746
580
|
persistence.save()
|
|
747
581
|
|
|
748
|
-
// Validate source URL before handing off to MediaLoader
|
|
749
582
|
guard let src = currentTrack.src else {
|
|
750
|
-
logger.error("
|
|
751
|
-
logger.error(" track.title: \(currentTrack.title)")
|
|
752
|
-
logger.error(" track.path: \(currentTrack.path ?? "nil")")
|
|
583
|
+
logger.error("No src for track \(currentTrack.path ?? currentTrack.title)")
|
|
753
584
|
clearItem()
|
|
754
585
|
transition(.errorOccurred(.invalidSourceUrl("nil")))
|
|
755
586
|
return
|
|
756
587
|
}
|
|
757
|
-
|
|
758
|
-
logger.debug("Loading track: \(currentTrack.title)")
|
|
759
|
-
logger.debug(" track.path: \(currentTrack.path ?? "nil")")
|
|
760
|
-
logger.debug(" track.src: \(src)")
|
|
761
|
-
|
|
762
588
|
engine.resolveAndLoad(src: src, track: currentTrack)
|
|
763
589
|
} else {
|
|
764
590
|
unload()
|
|
@@ -769,12 +595,12 @@ final class Player {
|
|
|
769
595
|
lastIndex: lastIndex == -1 ? nil : Double(lastIndex),
|
|
770
596
|
lastTrack: lastTrack,
|
|
771
597
|
lastPosition: lastPosition,
|
|
772
|
-
index: currentIndex == -1 ? nil : Double(currentIndex),
|
|
598
|
+
index: queue.currentIndex == -1 ? nil : Double(queue.currentIndex),
|
|
773
599
|
track: currentTrack,
|
|
774
600
|
)
|
|
775
601
|
callbacks?.playerDidChangeActiveTrack(eventData)
|
|
776
602
|
lastTrack = currentTrack
|
|
777
|
-
lastIndex = currentIndex
|
|
603
|
+
lastIndex = queue.currentIndex
|
|
778
604
|
}
|
|
779
605
|
|
|
780
606
|
// MARK: - Seeking
|
|
@@ -820,7 +646,7 @@ final class Player {
|
|
|
820
646
|
completion(false)
|
|
821
647
|
return
|
|
822
648
|
}
|
|
823
|
-
seekDidLand(
|
|
649
|
+
seekDidLand()
|
|
824
650
|
completion(finished)
|
|
825
651
|
}
|
|
826
652
|
}
|
|
@@ -832,13 +658,10 @@ final class Player {
|
|
|
832
658
|
return true
|
|
833
659
|
}
|
|
834
660
|
|
|
835
|
-
private func seekDidLand(
|
|
661
|
+
private func seekDidLand() {
|
|
836
662
|
switch deferredSeek.land() {
|
|
837
663
|
case .done:
|
|
838
|
-
if state == .loading {
|
|
839
|
-
logger.debug("[loadSeek] seek landed at \(seconds)s (finished=\(finished)) → .ready")
|
|
840
|
-
transition(.loadSeekCompleted)
|
|
841
|
-
}
|
|
664
|
+
if state == .loading { transition(.loadSeekCompleted) }
|
|
842
665
|
case let .reseek(time):
|
|
843
666
|
seekItem(to: time) { _ in }
|
|
844
667
|
case .none:
|
|
@@ -906,7 +729,7 @@ final class Player {
|
|
|
906
729
|
case let .itemPrepared(likelyToKeepUp):
|
|
907
730
|
load?.itemPrepared = true
|
|
908
731
|
if playWhenReady { engine.play() }
|
|
909
|
-
if !beginDeferredSeek(), likelyToKeepUp {
|
|
732
|
+
if !beginDeferredSeek(), likelyToKeepUp { bufferBecameSufficient() }
|
|
910
733
|
case let .loadFailed(error):
|
|
911
734
|
primeNowPlayingIfNeeded()
|
|
912
735
|
retry.handleError(error, context: .mediaLoad)
|
|
@@ -919,14 +742,14 @@ final class Player {
|
|
|
919
742
|
case let .failed(error, httpStatusCode):
|
|
920
743
|
retry.handleError(error, context: .playback, httpStatusCode: httpStatusCode)
|
|
921
744
|
case let .likelyToKeepUp(isLikely):
|
|
922
|
-
guard isLikely, engine.hasItem else { return }
|
|
923
|
-
|
|
924
|
-
avItemDidUpdatePlaybackLikelyToKeepUp(isLikely)
|
|
745
|
+
guard isLikely, engine.hasItem, !beginDeferredSeek() else { return }
|
|
746
|
+
bufferBecameSufficient()
|
|
925
747
|
case let .timeControlStatus(status, waitingForNoItem):
|
|
926
748
|
recoverIfAirPlayStalled(transitioningTo: status, waitingForNoItem: waitingForNoItem)
|
|
927
|
-
|
|
749
|
+
timeControlStatusDidChange(status)
|
|
928
750
|
case .audioDidStart:
|
|
929
|
-
|
|
751
|
+
load?.hasPlayed = true
|
|
752
|
+
if state != .loading { transition(.audioFrameDecoded) }
|
|
930
753
|
case .playedToEnd:
|
|
931
754
|
itemDidPlayToEnd()
|
|
932
755
|
case .stalled:
|
|
@@ -935,10 +758,36 @@ final class Player {
|
|
|
935
758
|
}
|
|
936
759
|
}
|
|
937
760
|
|
|
761
|
+
/// The item can play through: ready, unless a seek still holds the ready transition.
|
|
762
|
+
private func bufferBecameSufficient() {
|
|
763
|
+
guard engine.hasLoadedAsset, !deferredSeek.defersReady, state != .playing else { return }
|
|
764
|
+
transition(.bufferingSufficient)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/// While loading, a status change belongs to the previous item. A pause near the end is the
|
|
768
|
+
/// end arriving, which `.playedToEnd` handles; one with the play intent is a buffer or a seek.
|
|
769
|
+
private func timeControlStatusDidChange(_ status: PlayerTimeControlStatus) {
|
|
770
|
+
guard state != .loading else { return }
|
|
771
|
+
switch status {
|
|
772
|
+
case .paused:
|
|
773
|
+
let duration = engine.duration
|
|
774
|
+
guard duration <= 0 || engine.currentTime < duration - 0.5 else { return }
|
|
775
|
+
if !engine.hasLoadedAsset {
|
|
776
|
+
if state != .stopped { transition(.avPlayerPaused(hasAsset: false)) }
|
|
777
|
+
} else if state != .error, state != .stopped, !playWhenReady {
|
|
778
|
+
transition(.avPlayerPaused(hasAsset: true))
|
|
779
|
+
}
|
|
780
|
+
case .waitingToPlayAtSpecifiedRate:
|
|
781
|
+
if engine.hasLoadedAsset { transition(.avPlayerWaiting) }
|
|
782
|
+
case .playing:
|
|
783
|
+
transition(.avPlayerPlaying)
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
938
787
|
/// A failed load renders no audio, and without it iOS never elects the app onto the
|
|
939
788
|
/// now-playing surfaces (ADR 0005). Once per load: one burst either elects it or never will.
|
|
940
789
|
private func primeNowPlayingIfNeeded() {
|
|
941
|
-
guard playWhenReady, claimNowPlayingPrime() else { return }
|
|
790
|
+
guard playWhenReady, load?.claimNowPlayingPrime() == true else { return }
|
|
942
791
|
engine.primeNowPlaying()
|
|
943
792
|
}
|
|
944
793
|
|
|
@@ -951,7 +800,7 @@ final class Player {
|
|
|
951
800
|
duration: engine.duration,
|
|
952
801
|
)
|
|
953
802
|
switch judgement.outcome {
|
|
954
|
-
case .ended:
|
|
803
|
+
case .ended: trackDidPlayToEnd()
|
|
955
804
|
case .stalled: recoverFromStall(reconnectIfLive: true)
|
|
956
805
|
}
|
|
957
806
|
}
|
|
@@ -1073,6 +922,4 @@ final class Player {
|
|
|
1073
922
|
}
|
|
1074
923
|
}
|
|
1075
924
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
extension Player: TrackSelectionPlayer {}
|
|
925
|
+
extension Player: TrackSelectionPlayer, NowPlayingSource {}
|