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.
@@ -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
- /// True the first time this load asks, so the silent primer runs once per load.
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
- /// Seconds of continuous audible playback after which the retry budget is
91
- /// refilled — proof the stream actually delivered audio, not a brief blip
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: - Queue Forwarding Properties
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 nextTracks: [Track] { queue.nextTracks }
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() // repeat-all wrap changes next/previous availability
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() // shuffle reorders playback → boundary changes
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 takes ownership of the intent: the user's later pause must
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
- // Terminal states need a reload, not a bare play(): the item is gone (.stopped/.error) or
209
- // parked at its end (.ended), where startPlayback() is a silent no-op. Only .error resumes
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
- // An explicit pause during the sleep fade is the timer's goal arriving early: clear the
222
- // timer (restores the pre-fade volume via onFadeCancel).
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
- sleepTimerManager: SleepTimerManager = SleepTimerManager(),
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
- self.sleepTimerManager = sleepTimerManager
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
- // Configure sleep timer
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] error in
281
- self?.transition(.errorOccurred(error))
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
- /// Surfaces a failure the retry loop is still working on: attached to the
295
- /// current (non-terminal) state so UIs can show the cause over the spinner.
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
- // Only attach while the failure is what the listener is looking at (matches
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
- // Allow error-to-error transitions to update the error and emit callbacks,
315
- // even though the state enum value doesn't change.
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, event: event)
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, event: PlaybackEvent) {
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
- healthyPlaybackTask?.cancel()
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
- healthyPlaybackTask?.cancel()
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 — nothing is left to play.
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
- // Cancel before the intent drop: a surviving retry would surface .error
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 active output route disappeared (headphones unplugged, Bluetooth
510
- /// speaker off). A deliberate output loss: drop the intent and any pending
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
- /// An audio-session interruption began (a phone call, or another app such as
519
- /// Music/Spotify taking over playback). iOS has already paused our AVPlayer,
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
- // OR: a nested interruption's second .began sees the already-paused
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 an interruption-end will resume playback — the intent captured at
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 an audio-session release when playback is deliberately stopped: play intent is off,
553
- /// it isn't an interruption-driven pause (those resume — `shouldResumeAfterInterruption` is set
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
- /// Handles the `playWhenReady` setting while executing a given action.
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
- // `currentIndex` is invariantly -1 or in bounds, and -1 took the branch
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 handleTrackDidPlayToEndTime() {
539
+ private func trackDidPlayToEnd() {
696
540
  sleepTimerManager.onTrackPlayedToEnd()
697
541
 
698
542
  if repeatMode == .track {
699
- // The end-of-track sleep pause lands just above and clears the intent;
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
- if errorStatus != .none {
725
- errorStatus = .none
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
- // Set loading state before playWhenReady so the setter's guard
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("Failed to load track - no src")
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(at: seconds, finished: finished)
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(at seconds: TimeInterval, finished: Bool) {
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 { avItemDidUpdatePlaybackLikelyToKeepUp(true) }
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
- if beginDeferredSeek() { return }
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
- avPlayerDidChangeTimeControlStatus(status)
749
+ timeControlStatusDidChange(status)
928
750
  case .audioDidStart:
929
- audioDidStart()
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: handleTrackDidPlayToEndTime()
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
- // MARK: - TrackSelectionPlayer
1077
-
1078
- extension Player: TrackSelectionPlayer {}
925
+ extension Player: TrackSelectionPlayer, NowPlayingSource {}