react-native-audio-browser 0.2.0-next.28 → 0.2.0-next.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  2. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  3. package/ios/AVPlayerEngine.swift +334 -0
  4. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  5. package/ios/Browser/BrowseGate.swift +58 -0
  6. package/ios/Browser/BrowserConfig.swift +31 -89
  7. package/ios/Browser/BrowserManager.swift +68 -487
  8. package/ios/Browser/NavigationErrorState.swift +41 -0
  9. package/ios/Browser/PageResolver.swift +227 -0
  10. package/ios/Browser/RequestLayer.swift +90 -0
  11. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  12. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  13. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  14. package/ios/CarPlay/CarPlayController.swift +42 -1442
  15. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  16. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  17. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  18. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  19. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  20. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  21. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  22. package/ios/CarPlay/CarPlaySession.swift +209 -0
  23. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  24. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  25. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  26. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  27. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  28. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  29. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  30. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  31. package/ios/Extension/URLError+Connectivity.swift +15 -0
  32. package/ios/Http/HttpClient.swift +63 -145
  33. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  34. package/ios/HybridAudioBrowser+Options.swift +144 -0
  35. package/ios/HybridAudioBrowser+Player.swift +397 -0
  36. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  37. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  38. package/ios/HybridAudioBrowser.swift +74 -1687
  39. package/ios/Model/NitroTypeStubs.swift +18 -1
  40. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  41. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  42. package/ios/Observer/ItemObserver.swift +90 -0
  43. package/ios/Observer/PlayerObserver.swift +59 -0
  44. package/ios/Option/SessionCategories.swift +41 -92
  45. package/ios/Player/AudioSessionController.swift +177 -0
  46. package/ios/Player/DeferredSeek.swift +70 -0
  47. package/ios/Player/ErrorStatus.swift +27 -0
  48. package/ios/Player/MediaLoader.swift +24 -11
  49. package/ios/Player/NowPlayingUpdater.swift +157 -58
  50. package/ios/Player/PlaybackFailure.swift +125 -0
  51. package/ios/Player/PlaybackPersistence.swift +73 -0
  52. package/ios/Player/Player.swift +1083 -0
  53. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  54. package/ios/Player/PlayerEngine.swift +89 -0
  55. package/ios/Player/PlayerStatusTypes.swift +2 -6
  56. package/ios/Player/QueueManager.swift +52 -59
  57. package/ios/Player/RetryLoop.swift +261 -0
  58. package/ios/Player/SleepTimerManager.swift +1 -1
  59. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  60. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  61. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  62. package/ios/Tests/DeferredSeekTests.swift +70 -0
  63. package/ios/Tests/EmitterTests.swift +29 -0
  64. package/ios/Tests/GenerationMemoTests.swift +121 -0
  65. package/ios/Tests/LRUCacheTests.swift +4 -5
  66. package/ios/Tests/MediaLoaderTests.swift +12 -33
  67. package/ios/Tests/ObserverTests.swift +88 -0
  68. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  69. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  70. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  71. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  72. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  73. package/ios/Tests/PlayingStateTests.swift +42 -0
  74. package/ios/Tests/QueueManagerTests.swift +76 -107
  75. package/ios/Tests/ReadyValueTests.swift +73 -0
  76. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  77. package/ios/Tests/RemoteCommandTests.swift +2 -4
  78. package/ios/Tests/RequestLayerTests.swift +112 -0
  79. package/ios/Tests/RetryLoopTests.swift +270 -0
  80. package/ios/Tests/Support/TestTypes.swift +111 -112
  81. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  82. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  83. package/ios/TrackSelector.swift +1 -1
  84. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  85. package/ios/Util/Emitter.swift +42 -0
  86. package/ios/Util/GenerationMemo.swift +36 -0
  87. package/ios/Util/LRUCache.swift +23 -162
  88. package/ios/Util/NetworkMonitor.swift +1 -1
  89. package/ios/Util/ReadyValue.swift +56 -0
  90. package/ios/Util/SFSymbolRenderer.swift +35 -29
  91. package/ios/Util/SVGProcessor.swift +0 -10
  92. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  93. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  94. package/lib/module/web/NativeAudioBrowser.js +1 -1
  95. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/typescript/src/types/browser.d.ts +3 -3
  97. package/package.json +1 -1
  98. package/src/types/browser.ts +3 -3
  99. package/src/web/NativeAudioBrowser.ts +1 -1
  100. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  101. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  102. package/ios/Bridge.h +0 -8
  103. package/ios/Browser/MediaResolveComposer.swift +0 -114
  104. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  105. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  106. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  107. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  108. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  109. package/ios/Observer/PlayerStateObserver.swift +0 -50
  110. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  111. package/ios/Option/PitchAlgorithms.swift +0 -17
  112. package/ios/Option/TimeEventFrequency.swift +0 -18
  113. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  114. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  115. package/ios/Player/PlaybackCoordinator.swift +0 -761
  116. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  117. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  118. package/ios/Player/PlaybackStateStore.swift +0 -27
  119. package/ios/Player/PlayingStateManager.swift +0 -33
  120. package/ios/Player/RetryManager.swift +0 -384
  121. package/ios/Player/SeekCompletionHandler.swift +0 -5
  122. package/ios/Player/SleepTimerHandling.swift +0 -28
  123. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  124. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  125. package/ios/Tests/MediaResolveTests.swift +0 -126
  126. package/ios/Tests/OnceValueTests.swift +0 -79
  127. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  128. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  129. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  130. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  131. package/ios/Tests/RetryManagerTests.swift +0 -178
  132. package/ios/TrackPlayer.swift +0 -1042
  133. package/ios/TrackPlayerCallbacks.swift +0 -53
  134. package/ios/Util/MetadataAdapter.swift +0 -131
  135. package/ios/Util/OnceValue.swift +0 -104
  136. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  137. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -1,1042 +0,0 @@
1
- @preconcurrency import AVFoundation
2
- import Foundation
3
- import MediaPlayer
4
- import NitroModules
5
- import os.log
6
-
7
- @MainActor
8
- class TrackPlayer {
9
- let logger = Logger(subsystem: "com.audiobrowser", category: "TrackPlayer")
10
-
11
- // MARK: - Coordinator
12
-
13
- let coordinator: PlaybackCoordinator
14
-
15
- // MARK: - Dependencies
16
-
17
- let nowPlayingInfoController: NowPlayingInfoController
18
- let remoteCommandController: RemoteCommandController
19
- private let retryManager = RetryManager()
20
- private let playbackStateStore = PlaybackStateStore()
21
-
22
- // MARK: - Periodic Persist
23
-
24
- /// Cancelled when playback stops; recreated when it starts.
25
- private var periodicSaveTask: Task<Void, Never>?
26
-
27
- /// Retry configuration for load errors (network failures, timeouts, etc.)
28
- var retryConfig: Variant_Bool_RetryConfig? {
29
- didSet {
30
- retryManager.updatePolicy(from: retryConfig)
31
- }
32
- }
33
-
34
- /// Network monitor for accelerating retries when connectivity is restored.
35
- weak var networkMonitor: NetworkMonitor? {
36
- didSet {
37
- retryManager.networkMonitor = networkMonitor
38
- }
39
- }
40
-
41
- /// Handles media URL resolution, asset creation, and async loading.
42
- let mediaLoader = MediaLoader()
43
-
44
- /// Handles Now Playing metadata and artwork updates.
45
- let nowPlayingUpdater: NowPlayingUpdater
46
-
47
- // MARK: - AVPlayer Properties
48
-
49
- var avPlayer = AVPlayer()
50
-
51
- private lazy var playerObserver: PlayerStateObserver = .init(
52
- onStatusChange: { [weak self] status in
53
- self?.avPlayerStatusDidChange(status)
54
- },
55
- onTimeControlStatusChange: { [weak self] status in
56
- self?.avPlayerDidChangeTimeControlStatus(status)
57
- },
58
- )
59
-
60
- private lazy var playerTimeObserver: PlayerTimeObserver = .init(
61
- onAudioDidStart: { [weak self] in
62
- // Audio rendered: this load has proven itself — retries from here on get
63
- // the full recovery budget, not the short first-connect one.
64
- self?.retryManager.hasPlayed = true
65
- self?.coordinator.audioDidStart()
66
- },
67
- )
68
-
69
- private lazy var playerItemNotificationObserver: PlayerItemNotificationObserver = .init(
70
- onDidPlayToEndTime: { [weak self] in self?.handleDidPlayToEndTime() },
71
- onFailedToPlayToEndTime: { [weak self] error in
72
- let effectiveError = error ?? self?.avPlayer.currentItem?.error
73
- self?.coordinator.errorHandler.handleError(
74
- effectiveError,
75
- context: .playback,
76
- httpStatusCode: self?.currentItemHttpStatusCode,
77
- )
78
- },
79
- // Buffer emptied mid-playback: nudge the player, but don't reconnect — a
80
- // genuine drop is recovered by the retry / network-restore paths.
81
- onPlaybackStalled: { [weak self] in self?.recoverFromStall(reconnectIfLive: false) },
82
- )
83
-
84
- private lazy var playerItemObserver: PlayerItemPropertyObserver = .init(
85
- onDurationUpdate: { [weak self] duration in
86
- self?.callbacks?.playerDidUpdateDuration(duration)
87
- },
88
- onPlaybackLikelyToKeepUpUpdate: { [weak self] isLikely in
89
- self?.avItemDidUpdatePlaybackLikelyToKeepUp(isLikely)
90
- },
91
- onStatusChange: { [weak self] status, error in
92
- self?.avItemStatusDidChange(status, error: error)
93
- },
94
- onTimedMetadataReceived: { [weak self] groups in
95
- self?.callbacks?.playerDidReceiveTimedMetadata(groups)
96
- },
97
- )
98
-
99
- // MARK: - Now-Playing Election
100
-
101
- private let silentAudioPrimer = SilentAudioPrimer()
102
- /// Latched on the first failure of each load; reset when a new track loads.
103
- private var didPrimeNowPlayingElection = false
104
-
105
- // MARK: - Callbacks
106
-
107
- weak var callbacks: TrackPlayerCallbacks? {
108
- didSet {
109
- coordinator.callbacks = callbacks
110
- }
111
- }
112
-
113
- // MARK: - Coordinator Forwarding Properties
114
-
115
- var state: PlaybackState { coordinator.state }
116
- var playbackError: TrackPlayerError.PlaybackError? {
117
- get { coordinator.playbackError }
118
- set { coordinator.playbackError = newValue }
119
- }
120
-
121
- var lastIndex: Int {
122
- get { coordinator.lastIndex }
123
- set { coordinator.lastIndex = newValue }
124
- }
125
-
126
- var lastTrack: Track? {
127
- get { coordinator.lastTrack }
128
- set { coordinator.lastTrack = newValue }
129
- }
130
-
131
- var tracks: [Track] { coordinator.tracks }
132
- var currentIndex: Int { coordinator.currentIndex }
133
- var currentTrack: Track? { coordinator.currentTrack }
134
- var queueSourcePath: String? { coordinator.queueSourcePath }
135
- var nextTracks: [Track] { coordinator.nextTracks }
136
- var previousTracks: [Track] { coordinator.previousTracks }
137
- var isLastInPlaybackOrder: Bool { coordinator.isLastInPlaybackOrder }
138
- var playbackActive: Bool { coordinator.playbackActive }
139
- var sleepTimerManager: any SleepTimerHandling { coordinator.sleepTimerManager }
140
- var queue: QueueManager { coordinator.queue }
141
- var loadSeekCoordinator: LoadSeekCoordinator { coordinator.loadSeekCoordinator }
142
- var playingStateManager: PlayingStateManager { coordinator.playingStateManager }
143
- var errorHandler: PlaybackErrorHandler { coordinator.errorHandler }
144
-
145
- var repeatMode: RepeatMode {
146
- get { coordinator.repeatMode }
147
- set { coordinator.repeatMode = newValue }
148
- }
149
-
150
- var shuffleEnabled: Bool {
151
- get { coordinator.shuffleEnabled }
152
- set { coordinator.shuffleEnabled = newValue }
153
- }
154
-
155
- var playWhenReady: Bool {
156
- get { coordinator.playWhenReady }
157
- set { coordinator.playWhenReady = newValue }
158
- }
159
-
160
- /// Time playback must have progressed before a buffer dip counts as a stall, so the initial
161
- /// connect / a seek doesn't read as one. iOS has no native rebuffer-vs-initial signal.
162
- private static let stallGraceSeconds: TimeInterval = 0.5
163
-
164
- /// True while ongoing playback has stalled waiting for data. Approximated from
165
- /// `isPlaybackLikelyToKeepUp`, gated on having actually started and the play intent (so an
166
- /// initial connect / seek doesn't read as a stall). Mirrors the Android `stalled` signal, which
167
- /// the load control distinguishes natively.
168
- var isStalled: Bool {
169
- guard let item = avPlayer.currentItem else { return false }
170
- return currentTime > Self.stallGraceSeconds && playWhenReady && !item.isPlaybackLikelyToKeepUp
171
- }
172
-
173
- /**
174
- Controls the time pitch algorithm applied to each track loaded into the player.
175
- */
176
- var audioTimePitchAlgorithm: AVAudioTimePitchAlgorithm = .timeDomain
177
-
178
- /**
179
- Default remote commands to use for each playing track
180
- */
181
- var remoteCommands: [RemoteCommand] = [] {
182
- didSet {
183
- enableRemoteCommands(remoteCommands)
184
- }
185
- }
186
-
187
- var reasonForWaitingToPlay: AVPlayer.WaitingReason? {
188
- avPlayer.reasonForWaitingToPlay
189
- }
190
-
191
- // MARK: - Getters from AVPlayer
192
-
193
- var currentTime: Double {
194
- let seconds = avPlayer.currentTime().seconds
195
- return seconds.isNaN ? 0 : seconds
196
- }
197
-
198
- /// The current item's duration, or 0 when it has none (a live stream).
199
- ///
200
- /// Don't reintroduce `item.asset.duration`: it is deprecated as of the iOS 16
201
- /// minimum and blocks the main actor fetching a remote asset's duration. Read
202
- /// on every progress tick.
203
- var duration: Double {
204
- guard let item = avPlayer.currentItem else { return 0.0 }
205
-
206
- if !item.duration.seconds.isNaN {
207
- return item.duration.seconds
208
- }
209
- if let seekable = item.seekableTimeRanges.last?.timeRangeValue.duration.seconds,
210
- !seekable.isNaN
211
- {
212
- return seekable
213
- }
214
- return 0.0
215
- }
216
-
217
- var bufferedPosition: Double {
218
- avPlayer.currentItem?.loadedTimeRanges.last?.timeRangeValue.end.seconds ?? 0
219
- }
220
-
221
- var playerState: PlaybackState {
222
- state
223
- }
224
-
225
- // MARK: - Setters for AVPlayer
226
-
227
- var bufferDuration: Double = 0 {
228
- didSet {
229
- avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
230
- mediaLoader.bufferDuration = bufferDuration
231
- }
232
- }
233
-
234
- var automaticallyWaitsToMinimizeStalling: Bool {
235
- get { avPlayer.automaticallyWaitsToMinimizeStalling }
236
- set {
237
- if newValue {
238
- bufferDuration = 0
239
- }
240
- avPlayer.automaticallyWaitsToMinimizeStalling = newValue
241
- }
242
- }
243
-
244
- var volume: Float {
245
- get { avPlayer.volume }
246
- set { avPlayer.volume = newValue }
247
- }
248
-
249
- var isMuted: Bool {
250
- get { avPlayer.isMuted }
251
- set { avPlayer.isMuted = newValue }
252
- }
253
-
254
- var rate: Float {
255
- get { coordinator.rate }
256
- set {
257
- // A non-positive rate leaves the player unable to play at all — and would
258
- // poison the next play() through `defaultRate` below. Rejected here, at
259
- // the one setter every source funnels through (JS, remote command, CarPlay
260
- // rate cycling, a restored snapshot), rather than at each of them.
261
- guard newValue > 0 else { return }
262
- coordinator.rate = newValue
263
- // Assigning a nonzero `AVPlayer.rate` IS `play()` — so setting the speed
264
- // while paused would start audible playback behind the user's back, with
265
- // `playWhenReady` still false (the lock screen would read "paused" over
266
- // sound). On Android and web the speed control is independent of the play
267
- // intent; this keeps iOS the same.
268
- avPlayer.defaultRate = newValue
269
- if playWhenReady { avPlayer.rate = newValue }
270
- // Same reason as the repeat/shuffle edges below — a speed changed while
271
- // paused would otherwise not reach the snapshot the cold resume restores.
272
- persistPlaybackState()
273
- }
274
- }
275
-
276
- // MARK: - Init
277
-
278
- init(
279
- nowPlayingInfoController: NowPlayingInfoController = NowPlayingInfoController(),
280
- callbacks: TrackPlayerCallbacks? = nil,
281
- ) {
282
- self.nowPlayingInfoController = nowPlayingInfoController
283
- nowPlayingUpdater = NowPlayingUpdater(nowPlayingInfoController: nowPlayingInfoController)
284
- remoteCommandController = RemoteCommandController(callbacks: callbacks)
285
- self.callbacks = callbacks
286
-
287
- let errorHandler = PlaybackErrorHandler(retryHandler: retryManager)
288
- coordinator = PlaybackCoordinator(errorHandler: errorHandler, sleepTimerManager: SleepTimerManager())
289
- coordinator.effectHandler = self
290
- coordinator.callbacks = callbacks
291
-
292
- mediaLoader.delegate = self
293
-
294
- // Configure retry manager
295
- retryManager.shouldRetry = { [weak self] in
296
- self?.playWhenReady ?? false
297
- }
298
- retryManager.onRetry = { [weak self] startFromCurrentTime in
299
- // Re-resolve rather than replay the cached URL: a retry may be recovering
300
- // from an expired short-lived URL/token that only a fresh resolve fixes.
301
- self?.reloadResolving(startFromCurrentTime: startFromCurrentTime)
302
- }
303
-
304
- // Handle command center changes when MPNowPlayingSession is created/destroyed (iOS 16+)
305
- nowPlayingInfoController.onRemoteCommandCenterChanged = { [weak self] newCommandCenter in
306
- MainActor.assumeIsolated {
307
- self?.remoteCommandController.switchCommandCenter(newCommandCenter)
308
- }
309
- }
310
-
311
- setupAVPlayer()
312
- }
313
-
314
- // MARK: - Coordinator Forwarding Methods
315
-
316
- func getPlayback() -> Playback { coordinator.getPlayback() }
317
- func getRepeatMode() -> RepeatMode { coordinator.getRepeatMode() }
318
- func setRepeatMode(_ mode: RepeatMode) { coordinator.setRepeatMode(mode) }
319
- func handlePlayWhenReady(_ playWhenReady: Bool?, action: () throws -> Void) rethrows {
320
- try coordinator.handlePlayWhenReady(playWhenReady, action: action)
321
- }
322
-
323
- func transition(_ event: PlaybackEvent) { coordinator.transition(event) }
324
-
325
- // MARK: - Player Actions
326
-
327
- func load(_ track: Track, playWhenReady: Bool? = nil) {
328
- coordinator.load(track, playWhenReady: playWhenReady)
329
- }
330
-
331
- func play() { coordinator.play() }
332
- func pause() { coordinator.pause() }
333
- func togglePlayback() { coordinator.togglePlayback() }
334
-
335
- func handleInterruptionBegan() { coordinator.handleInterruptionBegan() }
336
- func handleRouteDisconnected() { coordinator.handleRouteDisconnected() }
337
- var willResumeAfterInterruption: Bool { coordinator.willResumeAfterInterruption }
338
- func handleInterruptionEnded(shouldResume: Bool) {
339
- coordinator.handleInterruptionEnded(shouldResume: shouldResume)
340
- }
341
-
342
- func stop() {
343
- coordinator.stop()
344
- if currentTrack?.live != true {
345
- seekTo(0)
346
- }
347
- }
348
-
349
- func reload(startFromCurrentTime: Bool) {
350
- let time = startFromCurrentTime ? resumeTime() : nil
351
- loadAVPlayer()
352
- if let time {
353
- seekTo(time)
354
- }
355
- }
356
-
357
- /// Playback position to resume from on reload, or nil when there is nothing
358
- /// seekable to resume (no item, or an indefinite/live duration).
359
- private func resumeTime() -> Double? {
360
- guard let currentItem = avPlayer.currentItem, !currentItem.duration.isIndefinite else {
361
- return nil
362
- }
363
- return currentItem.currentTime().seconds
364
- }
365
-
366
- /// Re-establish a stalled stream when connectivity is restored.
367
- ///
368
- /// AVPlayer doesn't reliably surface a mid-stream connectivity loss on a live stream as an item
369
- /// failure — it sits in `.waitingToPlayAtSpecifiedRate` (a buffering stall) with no error, so the
370
- /// error-driven `RetryManager` never engages. Without this, the stream stays "reconnecting"
371
- /// indefinitely after the network returns. Mirrors Android, where ExoPlayer surfaces the drop as a
372
- /// retryable load error and re-prepares on restore.
373
- ///
374
- /// Guards: reload only while actually stalled (`.buffering`) with play intent — so a user-initiated
375
- /// pause, or a retry that already gave up (`.error`, past the max retry duration) isn't surprisingly
376
- /// resumed. Skipped when `RetryManager` is already parked waiting for the network: an error *did*
377
- /// schedule a retry that owns its own reload, so we'd otherwise load twice.
378
- func handleNetworkRestored() {
379
- guard playWhenReady,
380
- coordinator.state == .buffering,
381
- !retryManager.isWaitingForNetwork
382
- else { return }
383
- logger.info("Connectivity restored while stalled — re-resolving to reconnect")
384
- // Re-resolve rather than replay the cached URL: a resolver that mints a short-lived URL/token
385
- // may have had it expire during the offline gap, so reconnecting with the stale URL would just
386
- // fail (then recover only via the error path). Matches the error-retry path here, and Android,
387
- // which re-resolves on its network-error reconnect. `reloadResolving` falls back to a plain
388
- // reload when there's no track `src` to resolve.
389
- reloadResolving(startFromCurrentTime: true)
390
- }
391
-
392
- /// Jump to the live edge. No-op for non-live tracks. Live with a seekable
393
- /// window (HLS) seeks to the window end; live without one (non-seekable, e.g.
394
- /// ICY) has no window to seek within, so reconnect to rejoin live.
395
- func seekToLiveEdge() {
396
- guard currentTrack?.live == true, let item = avPlayer.currentItem else { return }
397
- if let range = item.seekableTimeRanges.last?.timeRangeValue, range.duration.seconds > 0 {
398
- avPlayer.seek(to: range.end, toleranceBefore: .zero, toleranceAfter: .zero)
399
- } else {
400
- // Re-resolve on reconnect: this is the stall-recovery path for non-seekable live
401
- // streams, where a short-lived stream URL may have expired during the outage.
402
- reloadResolving(startFromCurrentTime: false)
403
- }
404
- }
405
-
406
- func seekTo(_ seconds: TimeInterval) {
407
- seekTo(seconds, completion: { _ in })
408
- }
409
-
410
- func seekTo(_ seconds: TimeInterval, completion: @escaping @MainActor (Bool) -> Void) {
411
- if state == .loading {
412
- loadSeekCoordinator.capture(position: seconds)
413
- completion(false)
414
- } else if avPlayer.currentItem != nil {
415
- let time = CMTime(seconds: seconds, preferredTimescale: 1000)
416
- let seekSeconds = seconds
417
- let generation = loadSeekCoordinator.generation
418
- avPlayer
419
- .seek(to: time, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) { [weak self] finished in
420
- Task { @MainActor in
421
- guard let self else {
422
- completion(false)
423
- return
424
- }
425
- // A completion from before a track change (reset() bumped the
426
- // generation) belongs to the old item — delivering it would emit a
427
- // bogus seek-completed and consume the new track's pending start seek.
428
- guard self.loadSeekCoordinator.isCurrentGeneration(generation) else {
429
- completion(false)
430
- return
431
- }
432
- self.handleSeekCompleted(to: Double(seekSeconds), didFinish: finished)
433
- completion(finished)
434
- }
435
- }
436
- } else {
437
- completion(false)
438
- }
439
- }
440
-
441
- func seekBy(_ offset: TimeInterval) {
442
- let targetTime: TimeInterval
443
- if state == .loading {
444
- targetTime = (loadSeekCoordinator.pendingTime ?? 0) + offset
445
- } else if let currentItem = avPlayer.currentItem {
446
- targetTime = currentItem.currentTime().seconds + offset
447
- } else {
448
- return
449
- }
450
- seekTo(targetTime)
451
- }
452
-
453
- // MARK: - Remote Command Center
454
-
455
- func enableRemoteCommands(_ commands: [RemoteCommand]) {
456
- remoteCommandController.enable(commands: commands)
457
- // Apply current next/previous availability so re-/late-configured commands
458
- // start in the correct enabled state (not unconditionally enabled).
459
- remoteCommandController.setSkipAvailability(
460
- canNext: coordinator.canNext, canPrevious: coordinator.canPrevious,
461
- )
462
- }
463
-
464
- /// Empties the queue and the published now-playing info. Unlinking the
465
- /// session belongs in `destroy()`: `avPlayer` survives a clear, and only
466
- /// `setupAVPlayer()` re-links.
467
- func clear() {
468
- coordinator.clear()
469
- }
470
-
471
- func destroy() {
472
- clear()
473
- // The player is going away for good — now the session must go with it.
474
- nowPlayingInfoController.unlinkPlayer()
475
- // An AVPlayer retains its time observers: one left registered is still attached when the
476
- // player is released, and outlives this object.
477
- playerTimeObserver.unregisterForBoundaryTimeEvents()
478
- playerObserver.stopObserving()
479
- stopObservingAVPlayerItem()
480
- remoteCommandController.disableAll()
481
- }
482
-
483
- // MARK: - Progress Updates
484
-
485
- func setProgressUpdateInterval(_ interval: TimeInterval?) {
486
- coordinator.setProgressUpdateInterval(interval)
487
- }
488
-
489
- func setPlaybackIntervalEnabled(_ enabled: Bool) {
490
- coordinator.setPlaybackIntervalEnabled(enabled)
491
- }
492
-
493
- // MARK: - AVPlayer Management
494
-
495
- func setTimePitchingAlgorithmForCurrentItem() {
496
- avPlayer.currentItem?.audioTimePitchAlgorithm = audioTimePitchAlgorithm
497
- }
498
-
499
- func startPlayback() {
500
- avPlayer.play()
501
- if rate != 1.0 {
502
- avPlayer.rate = rate
503
- }
504
- }
505
-
506
- func pausePlayback() {
507
- avPlayer.pause()
508
- }
509
-
510
- func clearCurrentAVItem() {
511
- stopObservingAVPlayerItem()
512
- mediaLoader.clearAsset()
513
- loadSeekCoordinator.reset()
514
- avPlayer.replaceCurrentItem(with: nil)
515
- }
516
-
517
- func startObservingAVPlayerItem(_ avItem: AVPlayerItem) {
518
- playerItemObserver.startObserving(item: avItem)
519
- playerItemNotificationObserver.startObserving(item: avItem)
520
- }
521
-
522
- func stopObservingAVPlayerItem() {
523
- playerItemObserver.stopObservingCurrentItem()
524
- playerItemNotificationObserver.stopObservingCurrentItem()
525
- }
526
-
527
- private func recreateAVPlayer() {
528
- coordinator.playbackError = nil
529
- playerTimeObserver.unregisterForBoundaryTimeEvents()
530
- playerObserver.stopObserving()
531
- stopObservingAVPlayerItem()
532
- clearCurrentAVItem()
533
-
534
- nowPlayingInfoController.unlinkPlayer()
535
-
536
- avPlayer = AVPlayer()
537
- setupAVPlayer()
538
- }
539
-
540
- /// Recover from a media-services reset (`mediaserverd` crashed/reset): every
541
- /// AVPlayer/session handle is invalid, so recreate the player and reload the
542
- /// current track, preserving play intent, so a live stream reconnects instead
543
- /// of going permanently silent. No-op when nothing is loaded.
544
- func handleMediaServicesReset() {
545
- guard currentTrack != nil else { return }
546
- logger.info("Media services were reset — recreating player and reloading current track")
547
- recreateAVPlayer()
548
- reloadResolving(startFromCurrentTime: false)
549
- }
550
-
551
- private func setupAVPlayer() {
552
- avPlayer.allowsExternalPlayback = false
553
-
554
- playerObserver.avPlayer = avPlayer
555
- playerObserver.startObserving()
556
-
557
- playerTimeObserver.avPlayer = avPlayer
558
- playerTimeObserver.registerForBoundaryTimeEvents()
559
-
560
- nowPlayingInfoController.linkPlayer(avPlayer)
561
-
562
- // Stage the rate unconditionally, so a freshly recreated player carries it
563
- // before anything plays — `rate`'s setter holds the same invariant, and on
564
- // the playWhenReady branch below `defaultRate` was previously left at 1.0.
565
- avPlayer.defaultRate = rate
566
- if playWhenReady {
567
- startPlayback()
568
- }
569
- }
570
-
571
- func loadAVPlayer() {
572
- prepareForReload()
573
- mediaLoader.loadAsset()
574
- }
575
-
576
- /// Teardown shared by the two reload paths: recreate the player after a
577
- /// terminal error, otherwise cancel in-flight loading and drop the current
578
- /// asset, then enter the loading state.
579
- private func prepareForReload() {
580
- if state == .error {
581
- // A restart from terminal error begins a new load of the same track:
582
- // fresh budgets, unproven playback (see resetForNewLoad). Automatic
583
- // retries never enter this branch — the retry loop keeps the state
584
- // non-terminal — so only retry() / play-from-error land here.
585
- coordinator.errorHandler.resetForNewLoad()
586
- recreateAVPlayer()
587
- } else {
588
- mediaLoader.cancelAll()
589
- stopObservingAVPlayerItem()
590
- pausePlayback()
591
- mediaLoader.clearAsset()
592
- }
593
- transition(.trackLoading)
594
- }
595
-
596
- /// Reload by re-running the resolver (fresh URL / headers / user-agent)
597
- /// instead of recreating the asset from the already-resolved URL. Used by the
598
- /// retry path so consumers whose resolver mints short-lived URLs or auth
599
- /// tokens recover once those expire — a plain `reload()` would replay the
600
- /// stale URL and keep failing. Falls back to `reload()` when there's no track
601
- /// `src` to resolve.
602
- func reloadResolving(startFromCurrentTime: Bool) {
603
- guard let track = currentTrack, let src = track.src else {
604
- reload(startFromCurrentTime: startFromCurrentTime)
605
- return
606
- }
607
- let time = startFromCurrentTime ? resumeTime() : nil
608
- prepareForReload()
609
- mediaLoader.resolveAndLoad(src: src, track: track)
610
- if let time {
611
- seekTo(time)
612
- }
613
- }
614
-
615
- func unloadAVPlayer() {
616
- clearCurrentAVItem()
617
- transition(.trackUnloaded)
618
- }
619
-
620
- // MARK: - Observer Callbacks (map AVFoundation → coordinator)
621
-
622
- /// Resolve an end-of-item notification: a genuine end advances the queue; a
623
- /// dropped live stream or a mid-stream underrun reported as EOF is recovered
624
- /// instead of advancing/stopping (see `EndOfTrackJudgement`).
625
- private func handleDidPlayToEndTime() {
626
- let judgement = EndOfTrackJudgement(
627
- isLive: currentTrack?.live == true,
628
- currentTime: currentTime,
629
- duration: duration,
630
- )
631
- switch judgement.outcome {
632
- case .ended:
633
- coordinator.handleTrackDidPlayToEndTime()
634
- case .stalled:
635
- recoverFromStall(reconnectIfLive: true)
636
- }
637
- }
638
-
639
- /// Recover from a stall while the play intent still holds. A live stream that
640
- /// ran out of data (`reconnectIfLive`) rejoins the edge — reloading when there
641
- /// is no seekable window; otherwise just re-issue play() to un-park AVPlayer
642
- /// from `.waitingToPlayAtSpecifiedRate` (data may resume on the same
643
- /// connection; a genuine drop is handled by the retry / network-restore paths).
644
- private func recoverFromStall(reconnectIfLive: Bool) {
645
- guard playWhenReady else { return }
646
- if reconnectIfLive, currentTrack?.live == true {
647
- seekToLiveEdge()
648
- } else {
649
- startPlayback()
650
- }
651
- }
652
-
653
- /// Previous time-control transition + whether its wait reason was
654
- /// `.noItemToPlay`, retained to detect the AirPlay waiting→paused stall.
655
- private var previousTimeControlStatus: PlayerTimeControlStatus?
656
- private var previousWaitingReasonWasNoItemToPlay = false
657
-
658
- /// True when the active audio route is AirPlay.
659
- private var isAirPlayRoute: Bool {
660
- AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .airPlay }
661
- }
662
-
663
- private func avPlayerDidChangeTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
664
- let mapped: PlayerTimeControlStatus
665
- switch status {
666
- case .paused: mapped = .paused
667
- case .waitingToPlayAtSpecifiedRate: mapped = .waitingToPlayAtSpecifiedRate
668
- case .playing: mapped = .playing
669
- @unknown default: return
670
- }
671
- recoverIfAirPlayStalled(transitioningTo: mapped)
672
- coordinator.avPlayerDidChangeTimeControlStatus(mapped)
673
- }
674
-
675
- /// AVPlayer can silently strand us in .paused after a `.noItemToPlay` wait
676
- /// over AirPlay while we still intend to play — re-issue play() on exactly
677
- /// that transition. Also records this transition for the next comparison.
678
- private func recoverIfAirPlayStalled(transitioningTo current: PlayerTimeControlStatus) {
679
- let stalled = AirPlayStallJudgement(
680
- previous: previousTimeControlStatus,
681
- current: current,
682
- previousWaitingReasonWasNoItemToPlay: previousWaitingReasonWasNoItemToPlay,
683
- isAirPlay: isAirPlayRoute,
684
- playWhenReady: playWhenReady,
685
- ).shouldNudge
686
- previousTimeControlStatus = current
687
- previousWaitingReasonWasNoItemToPlay =
688
- current == .waitingToPlayAtSpecifiedRate && avPlayer.reasonForWaitingToPlay == .noItemToPlay
689
- if stalled {
690
- logger.info("AirPlay waiting→paused stall detected — nudging play()")
691
- startPlayback()
692
- }
693
- }
694
-
695
- private func avPlayerStatusDidChange(_ status: AVPlayer.Status) {
696
- if status == .failed {
697
- coordinator.avPlayerStatusDidFail(
698
- error: avPlayer.currentItem?.error,
699
- httpStatusCode: currentItemHttpStatusCode,
700
- )
701
- }
702
- }
703
-
704
- private func avItemStatusDidChange(_ status: AVPlayerItem.Status, error: Error?) {
705
- let mapped: PlayerItemStatus
706
- switch status {
707
- case .unknown: mapped = .unknown
708
- case .readyToPlay: mapped = .readyToPlay
709
- case .failed: mapped = .failed
710
- @unknown default: return
711
- }
712
- coordinator.avItemStatusDidChange(
713
- mapped,
714
- error: error ?? avPlayer.currentItem?.error,
715
- httpStatusCode: currentItemHttpStatusCode,
716
- )
717
- }
718
-
719
- /// The HTTP status the server last answered the current item with, if any.
720
- ///
721
- /// `AVPlayerItem.error` never carries it — a 404 arrives as an opaque
722
- /// CoreMedia error — so the error log is the only place it surfaces. The log
723
- /// is per-item, so its entries belong to the track that just failed; picking
724
- /// among them is `PlaybackErrorHandler`'s job, where it is testable
725
- /// (`AVPlayerItemErrorLogEvent` cannot be constructed).
726
- private var currentItemHttpStatusCode: Int? {
727
- guard let events = avPlayer.currentItem?.errorLog()?.events else { return nil }
728
- return PlaybackErrorHandler.httpStatusCode(
729
- fromErrorStatusCodes: events.map(\.errorStatusCode),
730
- )
731
- }
732
-
733
- private func avItemDidUpdatePlaybackLikelyToKeepUp(_ playbackLikelyToKeepUp: Bool) {
734
- guard playbackLikelyToKeepUp else { return }
735
- guard avPlayer.currentItem != nil else { return }
736
-
737
- // Execute any pending seek that arrived after MediaLoader completed
738
- if loadSeekCoordinator.executeIfPending(on: avPlayer, delegate: self) {
739
- return
740
- }
741
-
742
- coordinator.avItemDidUpdatePlaybackLikelyToKeepUp(playbackLikelyToKeepUp)
743
- }
744
-
745
- func handleSeekCompleted(to seconds: Double, didFinish: Bool) {
746
- if loadSeekCoordinator.seekDidComplete(on: avPlayer, delegate: self), state == .loading {
747
- coordinator.handleSeekCompleted(to: seconds, didFinish: didFinish)
748
- }
749
- callbacks?.playerDidCompleteSeek(position: seconds, didFinish: didFinish)
750
- }
751
- }
752
-
753
- // MARK: - Playback State Persistence
754
-
755
- extension TrackPlayer {
756
- /// Snapshot the current player state to UserDefaults so a cold-start resume
757
- /// can restore it without the JS runtime. Live streams persist `positionMs = nil`.
758
- private func persistPlaybackState() {
759
- guard let track = currentTrack else { return }
760
- // At .ended the position equals the duration — persisting it would make a
761
- // cold-start resume seek to the end and instantly re-end (Android twin:
762
- // savePositionZero).
763
- let positionMs: Double? =
764
- (track.live == true) ? nil : (state == .ended ? 0 : currentTime * 1000)
765
- playbackStateStore.save(
766
- PersistedPlaybackState(
767
- track: JsonTrack(from: track),
768
- positionMs: positionMs,
769
- repeatMode: repeatMode.stringValue,
770
- shuffleEnabled: shuffleEnabled,
771
- playbackSpeed: rate,
772
- ),
773
- )
774
- }
775
-
776
- /// Applies the player settings a snapshot carries — the inverse of
777
- /// `persistPlaybackState()`, and the twin of the Kotlin store's `restore()`.
778
- ///
779
- /// Call BEFORE building the queue: `setQueue` rebuilds the shuffle order
780
- /// pinned to the starting track, so shuffle has to be set for that order to
781
- /// be the one playback uses (and the mode events then precede the
782
- /// queue-changed event, as on Android). An unrecognized repeat mode restores
783
- /// as `.off` rather than failing the whole resume over one field; a
784
- /// non-positive speed is rejected by `rate`'s own setter.
785
- func restorePlaybackSettings(from state: PersistedPlaybackState) {
786
- repeatMode = RepeatMode(fromString: state.repeatMode) ?? .off
787
- shuffleEnabled = state.shuffleEnabled
788
- rate = state.playbackSpeed
789
- }
790
-
791
- /// Start a 5 s repeating save while playback is active. A previous task is
792
- /// cancelled first so there is never more than one running at a time.
793
- private func startPeriodicSave() {
794
- periodicSaveTask?.cancel()
795
- periodicSaveTask = Task { @MainActor [weak self] in
796
- while !Task.isCancelled {
797
- try? await Task.sleep(nanoseconds: 5_000_000_000)
798
- guard !Task.isCancelled else { return }
799
- self?.persistPlaybackState()
800
- }
801
- }
802
- }
803
-
804
- private func stopPeriodicSave() {
805
- periodicSaveTask?.cancel()
806
- periodicSaveTask = nil
807
- }
808
- }
809
-
810
- // MARK: - PlaybackEffectHandler
811
-
812
- extension TrackPlayer: PlaybackEffectHandler {
813
- var hasLoadedAsset: Bool {
814
- mediaLoader.asset != nil
815
- }
816
-
817
- func clearCurrentItem() {
818
- clearCurrentAVItem()
819
- }
820
-
821
- func stopObservingCurrentItem() {
822
- stopObservingAVPlayerItem()
823
- }
824
-
825
- func loadTrack(src: String, track: Track) {
826
- didPrimeNowPlayingElection = false
827
- // A new track starts unproven: its failures get the short first-connect
828
- // budget until it renders audio. Reloads of the current track (retry,
829
- // play-from-error) deliberately keep the flag.
830
- retryManager.hasPlayed = false
831
- mediaLoader.resolveAndLoad(src: src, track: track)
832
- }
833
-
834
- func reloadTrack(startFromCurrentTime: Bool) {
835
- // Re-resolve rather than replay the cached URL: play-from-.error typically happens long
836
- // after the failure (an expired signed URL would just re-fail, often non-retryably),
837
- // matching every other retry path (retry, network restore, media-services reset).
838
- reloadResolving(startFromCurrentTime: startFromCurrentTime)
839
- }
840
-
841
- func unloadTrack() {
842
- unloadAVPlayer()
843
- }
844
-
845
- func cancelMediaLoading() {
846
- mediaLoader.cancelAll()
847
- }
848
-
849
- func seekToStart() {
850
- seekTo(0)
851
- }
852
-
853
- func replayCurrentTrack() {
854
- seekTo(0) { [weak self] succeeded in
855
- if succeeded { self?.play() }
856
- }
857
- }
858
-
859
- func updateNowPlayingState(playWhenReady: Bool) {
860
- nowPlayingInfoController.setPlaybackState(playing: playWhenReady)
861
- if playWhenReady {
862
- startPeriodicSave()
863
- } else {
864
- stopPeriodicSave()
865
- persistPlaybackState()
866
- }
867
- }
868
-
869
- func loadNowPlayingMetadata(for track: Track) {
870
- nowPlayingUpdater.loadMetaValues(for: track)
871
- persistPlaybackState()
872
- }
873
-
874
- func clearNowPlaying() {
875
- nowPlayingUpdater.clearNowPlaying()
876
- }
877
-
878
- // Both persist: a snapshot is otherwise only written on track load, on pause,
879
- // and every 5s while playing, so a mode toggled while paused was lost — the
880
- // cold-start resume would restore the value from before the toggle. Android
881
- // persists on every setting change (`PlayerListener.onRepeatModeChanged` /
882
- // `onShuffleModeEnabledChanged`); these are the same two edges, and the
883
- // coordinator only calls them on a real change.
884
- func updateRemoteRepeatMode(_ mode: RepeatMode) {
885
- remoteCommandController.updateRepeatMode(mode)
886
- persistPlaybackState()
887
- }
888
-
889
- func updateRemoteShuffleMode(_ enabled: Bool) {
890
- remoteCommandController.updateShuffleMode(enabled)
891
- persistPlaybackState()
892
- }
893
-
894
- func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
895
- remoteCommandController.setSkipAvailability(canNext: canNext, canPrevious: canPrevious)
896
- }
897
- }
898
-
899
- // MARK: - SeekCompletionHandler
900
-
901
- extension TrackPlayer: SeekCompletionHandler {}
902
-
903
- // MARK: - TrackSelectionPlayer
904
-
905
- extension TrackPlayer: TrackSelectionPlayer {}
906
-
907
- // MARK: - Queue Methods (thin forwarders)
908
-
909
- extension TrackPlayer {
910
- func replace(_ index: Int, _ track: Track) throws {
911
- try coordinator.replace(index, track)
912
- }
913
-
914
- func setQueue(
915
- _ newTracks: [Track],
916
- initialIndex: Int = 0,
917
- startPositionMs: Double? = nil,
918
- playWhenReady: Bool? = nil,
919
- sourcePath: String? = nil,
920
- ) {
921
- coordinator.setQueue(
922
- newTracks,
923
- initialIndex: initialIndex,
924
- startPositionMs: startPositionMs,
925
- playWhenReady: playWhenReady,
926
- sourcePath: sourcePath,
927
- )
928
- }
929
-
930
- func add(_ tracks: [Track], initialIndex: Int? = nil, playWhenReady: Bool? = nil) {
931
- coordinator.add(tracks, initialIndex: initialIndex, playWhenReady: playWhenReady)
932
- }
933
-
934
- func add(_ tracks: [Track], at index: Int) throws {
935
- try coordinator.add(tracks, at: index)
936
- }
937
-
938
- func next() {
939
- coordinator.next()
940
- }
941
-
942
- func previous() {
943
- coordinator.previous()
944
- }
945
-
946
- func remove(_ index: Int) throws {
947
- try coordinator.remove(index)
948
- }
949
-
950
- func skipTo(_ index: Int, playWhenReady: Bool? = nil) throws {
951
- try coordinator.skipTo(index, playWhenReady: playWhenReady)
952
- }
953
-
954
- func move(fromIndex: Int, toIndex: Int) throws {
955
- try coordinator.move(fromIndex: fromIndex, toIndex: toIndex)
956
- }
957
-
958
- func removeUpcomingTracks() {
959
- coordinator.removeUpcomingTracks()
960
- }
961
-
962
- func replay() {
963
- coordinator.replay()
964
- }
965
- }
966
-
967
- // MARK: - MediaLoaderDelegate
968
-
969
- extension TrackPlayer: MediaLoaderDelegate {
970
- func mediaLoaderDidPrepareItem(_ item: AVPlayerItem) {
971
- nowPlayingInfoController.prepareItem(item)
972
- avPlayer.replaceCurrentItem(with: item)
973
- startObservingAVPlayerItem(item)
974
- if playWhenReady { startPlayback() }
975
-
976
- if !loadSeekCoordinator.executeIfPending(on: avPlayer, delegate: self) {
977
- if item.isPlaybackLikelyToKeepUp {
978
- avItemDidUpdatePlaybackLikelyToKeepUp(true)
979
- }
980
- }
981
- }
982
-
983
- func mediaLoaderDidFailWithRetryableError(_ error: Error) {
984
- attachPublishingCarrierItemIfNeeded()
985
- primeNowPlayingElectionIfNeeded()
986
- coordinator.errorHandler.handleError(error, context: .mediaLoad)
987
- }
988
-
989
- func mediaLoaderDidFailWithUnplayableTrack() {
990
- attachPublishingCarrierItemIfNeeded()
991
- primeNowPlayingElectionIfNeeded()
992
- transition(.errorOccurred(.trackWasUnplayable))
993
- }
994
-
995
- /// `MPNowPlayingSession` publishes only through the linked player's current item, so
996
- /// a load that fails before item creation (a dead host on a cold start) leaves the
997
- /// player item-less: the system never surfaces the app on the lock screen / CarPlay
998
- /// — no metadata, no transport buttons — and the failure line has nowhere to appear.
999
- /// Attach the failed asset's item purely as a publishing carrier. Deliberately not
1000
- /// observed: its `.failed` status must not re-report the error the loader already
1001
- /// delivered. A successful load (retry, or the next track) replaces it with the
1002
- /// real, observed item via `mediaLoaderDidPrepareItem`; retry reloads leave it in
1003
- /// place (`prepareForReload` drops only the asset), so the surface doesn't flicker.
1004
- private func attachPublishingCarrierItemIfNeeded() {
1005
- guard avPlayer.currentItem == nil, let asset = mediaLoader.asset else { return }
1006
- logger.notice("Attaching publishing-carrier item for failed load")
1007
- let item = AVPlayerItem(asset: asset)
1008
- nowPlayingInfoController.prepareItem(item)
1009
- avPlayer.replaceCurrentItem(with: item)
1010
- }
1011
-
1012
- /// A failed load renders no audio, and without rendered audio iOS never elects the
1013
- /// app onto the system now-playing surfaces — the published failure metadata and
1014
- /// transport controls stay invisible (see `SilentAudioPrimer`). On the first
1015
- /// failure of a play-intended load, render a silence burst to claim the surface.
1016
- /// Once per load: the retry loop re-fails every few seconds, and one burst either
1017
- /// elected us or never will. Does not touch the retry loop itself.
1018
- private func primeNowPlayingElectionIfNeeded() {
1019
- guard playWhenReady, !didPrimeNowPlayingElection else { return }
1020
- didPrimeNowPlayingElection = true
1021
- silentAudioPrimer.prime()
1022
- }
1023
-
1024
- func mediaLoaderDidFailWithError(_ error: TrackPlayerError.PlaybackError) {
1025
- // No carrier here: an invalid/missing src never produced an asset. The
1026
- // direct-center publish still carries the metadata once elected.
1027
- primeNowPlayingElectionIfNeeded()
1028
- transition(.errorOccurred(error))
1029
- }
1030
-
1031
- func mediaLoaderDidReceiveCommonMetadata(_ items: [AVMetadataItem]) {
1032
- callbacks?.playerDidReceiveCommonMetadata(items)
1033
- }
1034
-
1035
- func mediaLoaderDidReceiveChapterMetadata(_ groups: [AVTimedMetadataGroup]) {
1036
- callbacks?.playerDidReceiveChapterMetadata(groups)
1037
- }
1038
-
1039
- func mediaLoaderDidReceiveTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
1040
- callbacks?.playerDidReceiveTimedMetadata(groups)
1041
- }
1042
- }