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.
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
package/ios/TrackPlayer.swift
DELETED
|
@@ -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
|
-
}
|