react-native-audio-browser 0.2.0-next.27 → 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/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- 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
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os.log
|
|
3
|
+
|
|
4
|
+
#if canImport(NitroModules)
|
|
5
|
+
import NitroModules
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// The player: the playback state machine, the queue, retries, the sleep timer, persistence,
|
|
9
|
+
/// and every decision about loading, seeking and recovering. The media stack under it is a
|
|
10
|
+
/// `PlayerEngine` (AVPlayerEngine in production, a mock in tests), which carries the decisions out
|
|
11
|
+
/// and reports what it observes.
|
|
12
|
+
@MainActor
|
|
13
|
+
final class Player {
|
|
14
|
+
let logger = Logger(subsystem: "com.audiobrowser", category: "Player")
|
|
15
|
+
|
|
16
|
+
// MARK: - Dependencies
|
|
17
|
+
|
|
18
|
+
let engine: any PlayerEngine
|
|
19
|
+
weak var callbacks: PlayerCallbacks?
|
|
20
|
+
|
|
21
|
+
let queue = QueueManager()
|
|
22
|
+
let retry: RetryLoop
|
|
23
|
+
let sleepTimerManager: SleepTimerManager
|
|
24
|
+
var deferredSeek = DeferredSeek()
|
|
25
|
+
|
|
26
|
+
lazy var volumeFader = VolumeFader(
|
|
27
|
+
getVolume: { [weak self] in self?.engine.volume ?? 1 },
|
|
28
|
+
setVolume: { [weak self] level in self?.engine.volume = level },
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
private lazy var progressTimer = PlaybackTimer(
|
|
32
|
+
isActive: { $0 == .loading || $0 == .buffering || $0 == .playing },
|
|
33
|
+
) { [weak self] in
|
|
34
|
+
guard let self, currentIndex >= 0 else { return }
|
|
35
|
+
let progressEvent = PlaybackProgressUpdatedEvent(
|
|
36
|
+
track: Double(currentIndex),
|
|
37
|
+
position: engine.currentTime,
|
|
38
|
+
duration: engine.duration,
|
|
39
|
+
buffered: engine.bufferedPosition,
|
|
40
|
+
)
|
|
41
|
+
callbacks?.playerDidUpdateProgress(progressEvent)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private lazy var intervalTimer = PlaybackTimer(
|
|
45
|
+
isActive: { $0 == .playing },
|
|
46
|
+
) { [weak self] in
|
|
47
|
+
self?.callbacks?.playerDidFirePlaybackInterval()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MARK: - State
|
|
51
|
+
|
|
52
|
+
private(set) var state: PlaybackState = .none
|
|
53
|
+
private(set) var errorStatus: ErrorStatus = .none
|
|
54
|
+
|
|
55
|
+
/// One track's playback session, which automatic retry reloads keep (glossary: Load).
|
|
56
|
+
struct Load: Equatable {
|
|
57
|
+
let id: UInt
|
|
58
|
+
/// Audio has rendered: retries get the recovery budget, not the first-connect one (ADR 0004).
|
|
59
|
+
var hasPlayed = false
|
|
60
|
+
/// The silent primer has run for this load's first failure (ADR 0005).
|
|
61
|
+
var primedNowPlaying = false
|
|
62
|
+
/// One of this load's items is in the engine; until then it reports the previous one's position.
|
|
63
|
+
var itemPrepared = false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private(set) var load: Load?
|
|
67
|
+
private var loadCount: UInt = 0
|
|
68
|
+
|
|
69
|
+
/// Starts a Load: a track becoming current, or a restart of this one from a terminal error.
|
|
70
|
+
/// Fresh retry budgets, no pending seek, unproven playback.
|
|
71
|
+
func beginLoad() {
|
|
72
|
+
loadCount &+= 1
|
|
73
|
+
load = Load(id: loadCount)
|
|
74
|
+
retry.reset()
|
|
75
|
+
deferredSeek.reset()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// True the first time this load asks, so the silent primer runs once per load.
|
|
79
|
+
func claimNowPlayingPrime() -> Bool {
|
|
80
|
+
guard load?.primedNowPlaying == false else { return false }
|
|
81
|
+
load?.primedNowPlaying = true
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Whether playback should resume when the current audio-session interruption
|
|
86
|
+
/// ends — set to the play intent captured at interruption start, so we only
|
|
87
|
+
/// auto-resume if we were actually playing when interrupted.
|
|
88
|
+
private var shouldResumeAfterInterruption = false
|
|
89
|
+
|
|
90
|
+
/// Seconds of continuous audible playback after which the retry budget is
|
|
91
|
+
/// refilled — proof the stream actually delivered audio, not a brief blip
|
|
92
|
+
/// (mirrors Android's HEALTHY_PLAYBACK_MS). Without the refill a long-lived
|
|
93
|
+
/// stream permanently loses retry after its first recovered error, because
|
|
94
|
+
/// the window/attempt counters otherwise reset only on track change.
|
|
95
|
+
/// Overridable in tests.
|
|
96
|
+
var healthyPlaybackDuration: TimeInterval = 20
|
|
97
|
+
|
|
98
|
+
/// The pending refill. Readable so tests can await the window instead of
|
|
99
|
+
/// sleeping past it; only this type arms and cancels it.
|
|
100
|
+
private(set) var healthyPlaybackTask: Task<Void, Never>?
|
|
101
|
+
|
|
102
|
+
/// Playback rate (1.0 = normal speed). A non-positive rate would leave the player unable to
|
|
103
|
+
/// play, so it is refused here, where every source (JS, remote command, CarPlay, a restored
|
|
104
|
+
/// snapshot) funnels through. Speed and play intent are independent, as on Android and web.
|
|
105
|
+
var rate: Float = 1.0 {
|
|
106
|
+
didSet {
|
|
107
|
+
guard rate > 0 else {
|
|
108
|
+
rate = oldValue
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
engine.setRate(rate, playing: playWhenReady)
|
|
112
|
+
persistence.save()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private let persistenceDefaults: UserDefaults
|
|
117
|
+
private let persistenceSaveInterval: Duration
|
|
118
|
+
private lazy var persistence = PlaybackPersistence(
|
|
119
|
+
defaults: persistenceDefaults, saveInterval: persistenceSaveInterval,
|
|
120
|
+
) { [unowned self] in
|
|
121
|
+
currentTrack.map {
|
|
122
|
+
PlaybackPersistence.snapshot(
|
|
123
|
+
of: $0, state: state, position: enginePositionIsCurrent ? engine.currentTime : deferredSeek.target ?? 0,
|
|
124
|
+
repeatMode: repeatMode,
|
|
125
|
+
shuffleEnabled: shuffleEnabled, rate: rate,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Whether the engine reports this Load's position: not before one of its items is prepared,
|
|
131
|
+
/// nor while one reloads.
|
|
132
|
+
private var enginePositionIsCurrent: Bool {
|
|
133
|
+
state != .loading && load?.itemPrepared == true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Where a reload resumes this Load.
|
|
137
|
+
private var resumePosition: TimeInterval? {
|
|
138
|
+
enginePositionIsCurrent ? engine.resumePosition : deferredSeek.target
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// MARK: - Track State (for active track changed events)
|
|
142
|
+
|
|
143
|
+
var lastIndex: Int = -1
|
|
144
|
+
var lastTrack: Track?
|
|
145
|
+
|
|
146
|
+
// MARK: - Queue Forwarding Properties
|
|
147
|
+
|
|
148
|
+
var tracks: [Track] { queue.tracks }
|
|
149
|
+
var currentIndex: Int { queue.currentIndex }
|
|
150
|
+
var currentTrack: Track? { queue.currentTrack }
|
|
151
|
+
var queueSourcePath: String? { queue.queueSourcePath }
|
|
152
|
+
var nextTracks: [Track] { queue.nextTracks }
|
|
153
|
+
var previousTracks: [Track] { queue.previousTracks }
|
|
154
|
+
var isLastInPlaybackOrder: Bool { queue.isLastInPlaybackOrder }
|
|
155
|
+
var canNext: Bool { queue.canNext }
|
|
156
|
+
var currentTime: Double { engine.currentTime }
|
|
157
|
+
var nitroPlaybackError: PlaybackError? { errorStatus.nitroError }
|
|
158
|
+
var sleepTimer: SleepTimerState? { sleepTimerManager.get() }
|
|
159
|
+
var canPrevious: Bool { queue.canPrevious }
|
|
160
|
+
|
|
161
|
+
/// The repeat mode for the queue player.
|
|
162
|
+
var repeatMode: RepeatMode {
|
|
163
|
+
get { queue.repeatMode }
|
|
164
|
+
set {
|
|
165
|
+
guard queue.repeatMode != newValue else { return }
|
|
166
|
+
queue.repeatMode = newValue
|
|
167
|
+
engine.updateRemoteRepeatMode(newValue)
|
|
168
|
+
// Persisted on each change, or a mode toggled while paused would miss the resume snapshot.
|
|
169
|
+
persistence.save()
|
|
170
|
+
pushSkipAvailability() // repeat-all wrap changes next/previous availability
|
|
171
|
+
callbacks?.playerDidChangeRepeatMode(
|
|
172
|
+
RepeatModeChangedEvent(repeatMode: newValue),
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Whether shuffle mode is enabled.
|
|
178
|
+
var shuffleEnabled: Bool {
|
|
179
|
+
get { queue.shuffleEnabled }
|
|
180
|
+
set {
|
|
181
|
+
guard queue.shuffleEnabled != newValue else { return }
|
|
182
|
+
queue.shuffleEnabled = newValue
|
|
183
|
+
engine.updateRemoteShuffleMode(newValue)
|
|
184
|
+
persistence.save()
|
|
185
|
+
pushSkipAvailability() // shuffle reorders playback → boundary changes
|
|
186
|
+
callbacks?.playerDidChangeShuffleEnabled(newValue)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// Pushes current next/previous availability to the remote command center so
|
|
191
|
+
/// CarPlay / lock-screen grey out the buttons when there's nowhere to skip.
|
|
192
|
+
private func pushSkipAvailability() {
|
|
193
|
+
engine.updateSkipAvailability(canNext: queue.canNext, canPrevious: queue.canPrevious)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// MARK: - playWhenReady
|
|
197
|
+
|
|
198
|
+
/// The intent to play, applied through `setPlayWhenReady`.
|
|
199
|
+
private(set) var playWhenReady = false
|
|
200
|
+
|
|
201
|
+
func setPlayWhenReady(_ newValue: Bool) {
|
|
202
|
+
let changed = newValue != playWhenReady
|
|
203
|
+
playWhenReady = newValue
|
|
204
|
+
// A play during an interruption takes ownership of the intent: the user's later pause must
|
|
205
|
+
// not be overridden by auto-resume. (handleInterruptionEnded consumes its flag first.)
|
|
206
|
+
if newValue { shouldResumeAfterInterruption = false }
|
|
207
|
+
|
|
208
|
+
// Terminal states need a reload, not a bare play(): the item is gone (.stopped/.error) or
|
|
209
|
+
// parked at its end (.ended), where startPlayback() is a silent no-op. Only .error resumes
|
|
210
|
+
// from the last position.
|
|
211
|
+
if newValue, state == .error || state == .stopped || state == .ended {
|
|
212
|
+
// Re-resolve rather than replay the cached URL: play-from-.error typically comes long
|
|
213
|
+
// after the failure, when a signed URL has expired.
|
|
214
|
+
reloadResolving(startFromCurrentTime: state == .error)
|
|
215
|
+
}
|
|
216
|
+
if state != .loading {
|
|
217
|
+
if newValue { engine.play() } else { engine.pause() }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
guard changed else { return }
|
|
221
|
+
// An explicit pause during the sleep fade is the timer's goal arriving early: clear the
|
|
222
|
+
// timer (restores the pre-fade volume via onFadeCancel).
|
|
223
|
+
if !newValue, volumeFader.isActive { sleepTimerManager.clear() }
|
|
224
|
+
publish()
|
|
225
|
+
evaluateSessionRelease()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// Playing with intent, in a state that can play; buffering while loading or rebuffering.
|
|
229
|
+
static func playingState(playWhenReady: Bool, state: PlaybackState) -> PlayingState {
|
|
230
|
+
PlayingState(
|
|
231
|
+
playing: playWhenReady && ![.error, .ended, .none, .stopped].contains(state),
|
|
232
|
+
buffering: playWhenReady && (state == .loading || state == .buffering),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
var playingState: PlayingState {
|
|
237
|
+
Self.playingState(playWhenReady: playWhenReady, state: state)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
var playbackActive: Bool {
|
|
241
|
+
switch state {
|
|
242
|
+
case .none, .stopped, .ended, .error:
|
|
243
|
+
false
|
|
244
|
+
default: true
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// MARK: - Init
|
|
249
|
+
|
|
250
|
+
/// - Parameters:
|
|
251
|
+
/// - persistenceDefaults: Where the resume snapshot is kept.
|
|
252
|
+
/// - persistenceSaveInterval: How often the snapshot is saved while the play intent is active.
|
|
253
|
+
init(
|
|
254
|
+
engine: any PlayerEngine,
|
|
255
|
+
retry: RetryLoop = RetryLoop(),
|
|
256
|
+
sleepTimerManager: SleepTimerManager = SleepTimerManager(),
|
|
257
|
+
persistenceDefaults: UserDefaults = PlaybackPersistence.standardDefaults,
|
|
258
|
+
persistenceSaveInterval: Duration = .seconds(5),
|
|
259
|
+
) {
|
|
260
|
+
self.engine = engine
|
|
261
|
+
self.retry = retry
|
|
262
|
+
self.sleepTimerManager = sleepTimerManager
|
|
263
|
+
self.persistenceDefaults = persistenceDefaults
|
|
264
|
+
self.persistenceSaveInterval = persistenceSaveInterval
|
|
265
|
+
engine.onEvent = { [weak self] in self?.engineDidEmit($0) }
|
|
266
|
+
|
|
267
|
+
// Configure sleep timer
|
|
268
|
+
sleepTimerManager.onComplete = { [weak self] in
|
|
269
|
+
guard let self else { return }
|
|
270
|
+
self.volumeFader.resolve { self.pause() }
|
|
271
|
+
}
|
|
272
|
+
sleepTimerManager.onFadeStart = { [weak self] duration in
|
|
273
|
+
self?.volumeFader.start(duration: duration)
|
|
274
|
+
}
|
|
275
|
+
sleepTimerManager.onFadeCancel = { [weak self] in
|
|
276
|
+
self?.volumeFader.cancel(restoringVolume: true)
|
|
277
|
+
}
|
|
278
|
+
sleepTimerManager.onChanged = { [weak self] in self?.callbacks?.playerDidChangeSleepTimer($0) }
|
|
279
|
+
|
|
280
|
+
retry.onError = { [weak self] error in
|
|
281
|
+
self?.transition(.errorOccurred(error))
|
|
282
|
+
}
|
|
283
|
+
retry.onRetryingError = { [weak self] error in
|
|
284
|
+
self?.reportRetryingError(error)
|
|
285
|
+
}
|
|
286
|
+
retry.onRetry = { [weak self] startFromCurrentTime in
|
|
287
|
+
// Re-resolve: a retry may be recovering from an expired short-lived URL or token.
|
|
288
|
+
self?.reloadResolving(startFromCurrentTime: startFromCurrentTime)
|
|
289
|
+
}
|
|
290
|
+
retry.shouldRetry = { [weak self] in self?.playWhenReady ?? false }
|
|
291
|
+
retry.hasPlayed = { [weak self] in self?.load?.hasPlayed ?? false }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// Surfaces a failure the retry loop is still working on: attached to the
|
|
295
|
+
/// current (non-terminal) state so UIs can show the cause over the spinner.
|
|
296
|
+
/// Not a state transition — the state machine never sees it — and not a
|
|
297
|
+
/// `playerDidError`, which stays reserved for terminal errors.
|
|
298
|
+
private func reportRetryingError(_ error: TrackPlayerError.PlaybackError) {
|
|
299
|
+
// Only attach while the failure is what the listener is looking at (matches
|
|
300
|
+
// Android). During .playing buffered audio is still audibly fine — the
|
|
301
|
+
// advisory would replace the song line over working sound — and a
|
|
302
|
+
// terminal/stopped state must not regain an advisory error.
|
|
303
|
+
guard state == .loading || state == .buffering || state == .paused else { return }
|
|
304
|
+
// Each failed attempt re-reports; identical repeats add nothing.
|
|
305
|
+
errorStatus = .retrying(error)
|
|
306
|
+
publish()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// MARK: - Playback State Machine
|
|
310
|
+
|
|
311
|
+
func transition(_ event: PlaybackEvent) {
|
|
312
|
+
guard let newState = nextPlaybackState(from: state, on: event) else { return }
|
|
313
|
+
|
|
314
|
+
// Allow error-to-error transitions to update the error and emit callbacks,
|
|
315
|
+
// even though the state enum value doesn't change.
|
|
316
|
+
if newState == state, case let .errorOccurred(error) = event {
|
|
317
|
+
errorStatus = .terminal(error)
|
|
318
|
+
published.errorStatus = .none // each terminal report reaches JS, a repeat included
|
|
319
|
+
publish()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
guard newState != state else { return }
|
|
324
|
+
let oldState = state
|
|
325
|
+
state = newState
|
|
326
|
+
applySideEffects(old: oldState, new: newState, event: event)
|
|
327
|
+
publish()
|
|
328
|
+
// While nothing plays, no periodic save runs: every transition rewrites the snapshot, so it
|
|
329
|
+
// never keeps a position from before a track change or a seek.
|
|
330
|
+
if !(playbackActive && playWhenReady) { persistence.save() }
|
|
331
|
+
// Repeat modes never end the queue (matches web's endsQueue()): a repeat-track pause at the
|
|
332
|
+
// track boundary (e.g. the end-of-track sleep timer) must not read as "playlist over".
|
|
333
|
+
if newState == .ended, isLastInPlaybackOrder, repeatMode == .off {
|
|
334
|
+
callbacks?.playerDidEndQueue(
|
|
335
|
+
PlaybackQueueEndedEvent(track: Double(currentIndex), position: engine.currentTime),
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private struct Published: Equatable {
|
|
341
|
+
var state = PlaybackState.none
|
|
342
|
+
var errorStatus = ErrorStatus.none
|
|
343
|
+
var playWhenReady = false
|
|
344
|
+
var playing = false
|
|
345
|
+
var buffering = false
|
|
346
|
+
var playIntentActive = false
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private var published = Published()
|
|
350
|
+
|
|
351
|
+
/// Emits what changed since the last publish, in order: the play intent, the playing state,
|
|
352
|
+
/// the playback (state and error), the terminal error, the active play intent. The one place
|
|
353
|
+
/// these leave the Player, so each goes out once per change.
|
|
354
|
+
private func publish() {
|
|
355
|
+
let playing = playingState
|
|
356
|
+
let now = Published(
|
|
357
|
+
state: state, errorStatus: errorStatus, playWhenReady: playWhenReady,
|
|
358
|
+
playing: playing.playing, buffering: playing.buffering,
|
|
359
|
+
playIntentActive: playbackActive && playWhenReady,
|
|
360
|
+
)
|
|
361
|
+
let old = published
|
|
362
|
+
guard now != old else { return }
|
|
363
|
+
published = now
|
|
364
|
+
if now.playWhenReady != old.playWhenReady {
|
|
365
|
+
callbacks?.playerDidChangePlayWhenReady(now.playWhenReady)
|
|
366
|
+
}
|
|
367
|
+
if now.playing != old.playing || now.buffering != old.buffering {
|
|
368
|
+
callbacks?.playerDidChangePlayingState(playing)
|
|
369
|
+
}
|
|
370
|
+
if now.state != old.state || now.errorStatus != old.errorStatus {
|
|
371
|
+
callbacks?.playerDidChangePlayback(Playback(state: now.state, error: now.errorStatus.nitroError))
|
|
372
|
+
}
|
|
373
|
+
let erred = now.state == .error, wasErred = old.state == .error
|
|
374
|
+
if erred != wasErred || (erred && now.errorStatus != old.errorStatus) {
|
|
375
|
+
callbacks?.playerDidError(PlaybackErrorEvent(error: now.errorStatus.nitroError))
|
|
376
|
+
}
|
|
377
|
+
if now.playIntentActive != old.playIntentActive {
|
|
378
|
+
persistence.playIntentDidChange(active: now.playIntentActive)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private func applySideEffects(old: PlaybackState, new: PlaybackState, event: PlaybackEvent) {
|
|
383
|
+
// Error lifecycle. A retrying error rides along through loading/buffering, but any state
|
|
384
|
+
// that proves recovery (.ready/.playing: data flowed) or abandons the attempt
|
|
385
|
+
// (.stopped/.none/.ended) clears it. Runs before publish, so the emitted Playback
|
|
386
|
+
// is already clean.
|
|
387
|
+
if case let .errorOccurred(error) = event {
|
|
388
|
+
errorStatus = .terminal(error)
|
|
389
|
+
} else if old == .error || (errorStatus.isRetrying && new != .loading && new != .buffering && new != .paused) {
|
|
390
|
+
errorStatus = .none
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Retry-budget refill: sustained audible playback proves the stream
|
|
394
|
+
// recovered, so the next error starts a fresh retry window. Leaving
|
|
395
|
+
// .playing before the threshold cancels the pending refill.
|
|
396
|
+
if new == .playing {
|
|
397
|
+
healthyPlaybackTask?.cancel()
|
|
398
|
+
healthyPlaybackTask = Task { [weak self] in
|
|
399
|
+
guard let self else { return }
|
|
400
|
+
try? await Task.sleep(nanoseconds: UInt64(healthyPlaybackDuration * 1_000_000_000))
|
|
401
|
+
guard !Task.isCancelled else { return }
|
|
402
|
+
// Refill only between episodes: reset() cancels a pending retry,
|
|
403
|
+
// which would silently kill an in-flight reconnect.
|
|
404
|
+
guard retry.pendingTask == nil else { return }
|
|
405
|
+
retry.reset()
|
|
406
|
+
}
|
|
407
|
+
} else if old == .playing {
|
|
408
|
+
healthyPlaybackTask?.cancel()
|
|
409
|
+
healthyPlaybackTask = nil
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// State-specific effects
|
|
413
|
+
switch new {
|
|
414
|
+
case .ready:
|
|
415
|
+
engine.applyTimePitchAlgorithm()
|
|
416
|
+
if playWhenReady { engine.play() }
|
|
417
|
+
case .loading:
|
|
418
|
+
engine.applyTimePitchAlgorithm()
|
|
419
|
+
case .ended:
|
|
420
|
+
// A natural end exhausts the play intent — nothing is left to play.
|
|
421
|
+
// Keeping playWhenReady true would invert the play/pause toggle, hold
|
|
422
|
+
// the audio session forever (release is intent-gated), and arm
|
|
423
|
+
// interruption auto-resume into silence. The setter also requests the
|
|
424
|
+
// session release.
|
|
425
|
+
setPlayWhenReady(false)
|
|
426
|
+
default: break
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
progressTimer.onPlaybackStateChanged(new)
|
|
430
|
+
intervalTimer.onPlaybackStateChanged(new)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// MARK: - Observer Context Guards
|
|
434
|
+
|
|
435
|
+
func avPlayerDidChangeTimeControlStatus(_ status: PlayerTimeControlStatus) {
|
|
436
|
+
// During loading, ignore stale timeControlStatus changes from old items.
|
|
437
|
+
if state == .loading { return }
|
|
438
|
+
|
|
439
|
+
switch status {
|
|
440
|
+
case .paused:
|
|
441
|
+
let currentState = state
|
|
442
|
+
let currentTime = engine.currentTime
|
|
443
|
+
let duration = engine.duration
|
|
444
|
+
// Ignore pauses when near track end
|
|
445
|
+
let nearTrackEnd = currentTime >= duration - 0.5 && duration > 0
|
|
446
|
+
|
|
447
|
+
if nearTrackEnd {
|
|
448
|
+
// Ignore - track completion will be handled by handleTrackDidPlayToEndTime
|
|
449
|
+
} else if !engine.hasLoadedAsset, currentState != .stopped {
|
|
450
|
+
transition(.avPlayerPaused(hasAsset: false))
|
|
451
|
+
} else if currentState != .error, currentState != .stopped {
|
|
452
|
+
if !playWhenReady {
|
|
453
|
+
transition(.avPlayerPaused(hasAsset: true))
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
case .waitingToPlayAtSpecifiedRate:
|
|
457
|
+
if engine.hasLoadedAsset {
|
|
458
|
+
transition(.avPlayerWaiting)
|
|
459
|
+
}
|
|
460
|
+
case .playing:
|
|
461
|
+
transition(.avPlayerPlaying)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
func audioDidStart() {
|
|
466
|
+
load?.hasPlayed = true
|
|
467
|
+
// Don't override loading state
|
|
468
|
+
if state == .loading { return }
|
|
469
|
+
transition(.audioFrameDecoded)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func avItemDidUpdatePlaybackLikelyToKeepUp(_ playbackLikelyToKeepUp: Bool) {
|
|
473
|
+
guard playbackLikelyToKeepUp else { return }
|
|
474
|
+
guard engine.hasLoadedAsset else { return }
|
|
475
|
+
|
|
476
|
+
if !deferredSeek.defersReady, state != .playing {
|
|
477
|
+
logger.debug("avItemDidUpdatePlaybackLikelyToKeepUp → .ready")
|
|
478
|
+
transition(.bufferingSufficient)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// MARK: - Player Actions
|
|
483
|
+
|
|
484
|
+
func play() {
|
|
485
|
+
setPlayWhenReady(true)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
func pause() {
|
|
489
|
+
setPlayWhenReady(false)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
func togglePlayback() {
|
|
493
|
+
setPlayWhenReady(!playWhenReady)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
func stop() {
|
|
497
|
+
// Cancel before the intent drop: a surviving retry would surface .error
|
|
498
|
+
// over the stop, and a pending task blocks the session release below.
|
|
499
|
+
retry.cancel()
|
|
500
|
+
// A stop is terminal — interruption-end must not resume over it.
|
|
501
|
+
shouldResumeAfterInterruption = false
|
|
502
|
+
transition(.stopped)
|
|
503
|
+
setPlayWhenReady(false)
|
|
504
|
+
if currentTrack?.live != true { seek(to: 0) }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// MARK: - Audio Session Interruptions
|
|
508
|
+
|
|
509
|
+
/// The active output route disappeared (headphones unplugged, Bluetooth
|
|
510
|
+
/// speaker off). A deliberate output loss: drop the intent and any pending
|
|
511
|
+
/// interruption auto-resume, so a call ending can't blast the built-in
|
|
512
|
+
/// speaker.
|
|
513
|
+
func handleRouteDisconnected() {
|
|
514
|
+
shouldResumeAfterInterruption = false
|
|
515
|
+
pause()
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/// An audio-session interruption began (a phone call, or another app such as
|
|
519
|
+
/// Music/Spotify taking over playback). iOS has already paused our AVPlayer,
|
|
520
|
+
/// but that pause arrives as a `timeControlStatus` change that's swallowed
|
|
521
|
+
/// while `playWhenReady` is still true — so the state would otherwise stay
|
|
522
|
+
/// `.playing` and any UI driven by it stays stuck. Drop the play intent and
|
|
523
|
+
/// force the paused state, independent of notification delivery order.
|
|
524
|
+
func handleInterruptionBegan() {
|
|
525
|
+
// OR: a nested interruption's second .began sees the already-paused
|
|
526
|
+
// intent and must not wipe the flag the first one captured.
|
|
527
|
+
shouldResumeAfterInterruption = shouldResumeAfterInterruption || playWhenReady
|
|
528
|
+
pause()
|
|
529
|
+
if playbackActive, state != .loading {
|
|
530
|
+
transition(.avPlayerPaused(hasAsset: engine.hasLoadedAsset))
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/// Whether an interruption-end will resume playback — the intent captured at
|
|
535
|
+
/// interruption start. Lets the host gate side effects that must precede the
|
|
536
|
+
/// resume (audio-session reactivation) on whether one will actually happen.
|
|
537
|
+
var willResumeAfterInterruption: Bool { shouldResumeAfterInterruption }
|
|
538
|
+
|
|
539
|
+
/// An audio-session interruption ended. Resume only if we were playing when it
|
|
540
|
+
/// began and the system indicates resumption is appropriate (`shouldResume`).
|
|
541
|
+
func handleInterruptionEnded(shouldResume: Bool) {
|
|
542
|
+
guard shouldResumeAfterInterruption else { return }
|
|
543
|
+
shouldResumeAfterInterruption = false
|
|
544
|
+
if shouldResume {
|
|
545
|
+
play()
|
|
546
|
+
} else {
|
|
547
|
+
// Interruption ended and we're staying paused: the session can be released now.
|
|
548
|
+
evaluateSessionRelease()
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// Requests an audio-session release when playback is deliberately stopped: play intent is off,
|
|
553
|
+
/// it isn't an interruption-driven pause (those resume — `shouldResumeAfterInterruption` is set
|
|
554
|
+
/// before the interruption's `pause()`, so this skips it), and no retry is pending (a live-stream
|
|
555
|
+
/// reconnect keeps `playWhenReady` true and must keep the session). The host debounces and cancels
|
|
556
|
+
/// on the next activation, so a quick pause→resume never actually releases.
|
|
557
|
+
private func evaluateSessionRelease() {
|
|
558
|
+
guard !playWhenReady,
|
|
559
|
+
!shouldResumeAfterInterruption,
|
|
560
|
+
retry.pendingTask == nil
|
|
561
|
+
else { return }
|
|
562
|
+
callbacks?.playerShouldReleaseSession()
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
func getPlayback() -> Playback {
|
|
566
|
+
Playback(state: state, error: errorStatus.nitroError)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/// Handles the `playWhenReady` setting while executing a given action.
|
|
570
|
+
func handlePlayWhenReady(_ playWhenReady: Bool?, action: () throws -> Void) rethrows {
|
|
571
|
+
if playWhenReady == false { setPlayWhenReady(false) }
|
|
572
|
+
try action()
|
|
573
|
+
if playWhenReady == true { setPlayWhenReady(true) }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// MARK: - Queue Methods
|
|
577
|
+
|
|
578
|
+
func load(_ track: Track, playWhenReady: Bool? = nil) {
|
|
579
|
+
handlePlayWhenReady(playWhenReady) {
|
|
580
|
+
if queue.currentIndex == -1 {
|
|
581
|
+
afterQueueMutation(queue.add([track], initialIndex: 0))
|
|
582
|
+
} else {
|
|
583
|
+
// `currentIndex` is invariantly -1 or in bounds, and -1 took the branch
|
|
584
|
+
// above, so this cannot fail — but surface it rather than reloading the
|
|
585
|
+
// old track if that invariant is ever broken.
|
|
586
|
+
do {
|
|
587
|
+
try queue.replace(queue.currentIndex, track)
|
|
588
|
+
afterQueueMutation(.tracksAndCurrent)
|
|
589
|
+
} catch {
|
|
590
|
+
logger.error("load: replace at \(self.queue.currentIndex) failed: \(error)")
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
func replace(_ index: Int, _ track: Track) throws {
|
|
597
|
+
try afterQueueMutation(queue.replace(index, track))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
func setQueue(
|
|
601
|
+
_ newTracks: [Track],
|
|
602
|
+
initialIndex: Int = 0,
|
|
603
|
+
startPositionMs: Double? = nil,
|
|
604
|
+
playWhenReady: Bool? = nil,
|
|
605
|
+
sourcePath: String? = nil,
|
|
606
|
+
) {
|
|
607
|
+
guard !newTracks.isEmpty else {
|
|
608
|
+
clear()
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
handlePlayWhenReady(playWhenReady) {
|
|
612
|
+
afterQueueMutation(
|
|
613
|
+
queue.setQueue(newTracks, initialIndex: initialIndex, sourcePath: sourcePath),
|
|
614
|
+
startingAt: startPositionMs.flatMap { $0 > 0 ? $0 / 1000 : nil },
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
func add(_ tracks: [Track], initialIndex: Int? = nil, playWhenReady: Bool? = nil) {
|
|
620
|
+
handlePlayWhenReady(playWhenReady) {
|
|
621
|
+
afterQueueMutation(queue.add(tracks, initialIndex: initialIndex ?? 0))
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
func add(_ tracks: [Track], at index: Int) throws {
|
|
626
|
+
try afterQueueMutation(queue.addAt(tracks, at: index))
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
func next() {
|
|
630
|
+
let result = queue.next()
|
|
631
|
+
switch result {
|
|
632
|
+
case .trackChanged: handleCurrentTrackChanged()
|
|
633
|
+
case .sameTrackReplay: if playWhenReady { replay() }
|
|
634
|
+
case .noChange: break
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
func previous() {
|
|
639
|
+
let result = queue.previous()
|
|
640
|
+
switch result {
|
|
641
|
+
case .trackChanged: handleCurrentTrackChanged()
|
|
642
|
+
case .sameTrackReplay: if playWhenReady { replay() }
|
|
643
|
+
case .noChange: break
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
func remove(_ index: Int) throws {
|
|
648
|
+
try afterQueueMutation(queue.remove(index))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
func skipTo(_ index: Int, playWhenReady: Bool? = nil) throws {
|
|
652
|
+
try handlePlayWhenReady(playWhenReady) {
|
|
653
|
+
if index == queue.currentIndex {
|
|
654
|
+
seek(to: 0)
|
|
655
|
+
} else {
|
|
656
|
+
try queue.skipTo(index)
|
|
657
|
+
handleCurrentTrackChanged()
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
func move(fromIndex: Int, toIndex: Int) throws {
|
|
663
|
+
try afterQueueMutation(queue.move(fromIndex: fromIndex, toIndex: toIndex))
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
func removeUpcomingTracks() {
|
|
667
|
+
afterQueueMutation(queue.removeUpcomingTracks())
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
func replay() {
|
|
671
|
+
seek(to: 0) { [weak self] succeeded in
|
|
672
|
+
if succeeded { self?.play() }
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/// The one place a settled queue mutation is published: the new track list, then the
|
|
677
|
+
/// current-track change, then next/previous availability.
|
|
678
|
+
private func afterQueueMutation(_ change: QueueChange, startingAt start: TimeInterval? = nil) {
|
|
679
|
+
if change.tracks { callbacks?.playerDidChangeQueue(queue.tracks) }
|
|
680
|
+
if change.currentTrack {
|
|
681
|
+
handleCurrentTrackChanged(startingAt: start)
|
|
682
|
+
} else if change.tracks {
|
|
683
|
+
pushSkipAvailability()
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
func clear() {
|
|
688
|
+
afterQueueMutation(queue.clear())
|
|
689
|
+
unload()
|
|
690
|
+
engine.clearNowPlaying()
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// MARK: - Track Loading
|
|
694
|
+
|
|
695
|
+
func handleTrackDidPlayToEndTime() {
|
|
696
|
+
sleepTimerManager.onTrackPlayedToEnd()
|
|
697
|
+
|
|
698
|
+
if repeatMode == .track {
|
|
699
|
+
// The end-of-track sleep pause lands just above and clears the intent;
|
|
700
|
+
// without it, settle in .ended (where play() reloads) instead of
|
|
701
|
+
// replaying past the sleep timer.
|
|
702
|
+
if playWhenReady {
|
|
703
|
+
replay()
|
|
704
|
+
} else {
|
|
705
|
+
transition(.trackEndedNaturally)
|
|
706
|
+
}
|
|
707
|
+
} else if repeatMode == .queue || !isLastInPlaybackOrder {
|
|
708
|
+
next()
|
|
709
|
+
} else {
|
|
710
|
+
transition(.trackEndedNaturally)
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/// Loads the new current track. `start` is held as the deferred seek, so the ready transition
|
|
715
|
+
/// waits for it (no flash at 0) and the snapshot saved on the way records it.
|
|
716
|
+
func handleCurrentTrackChanged(startingAt start: TimeInterval? = nil) {
|
|
717
|
+
defer { pushSkipAvailability() }
|
|
718
|
+
sleepTimerManager.onTrackChanged()
|
|
719
|
+
|
|
720
|
+
engine.cancelLoading()
|
|
721
|
+
beginLoad()
|
|
722
|
+
if let start { deferredSeek.capture(start) }
|
|
723
|
+
|
|
724
|
+
if errorStatus != .none {
|
|
725
|
+
errorStatus = .none
|
|
726
|
+
// Already .loading, the .trackLoading transition below won't fire: publish the clear here,
|
|
727
|
+
// or the old track's error lingers over the new one's spinner. In any other state the
|
|
728
|
+
// transition publishes it, with the state it belongs to.
|
|
729
|
+
if state == .loading { publish() }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let lastPosition = engine.currentTime
|
|
733
|
+
let shouldContinuePlayback = playWhenReady
|
|
734
|
+
if let currentTrack {
|
|
735
|
+
engine.stopObservingItem()
|
|
736
|
+
engine.pause()
|
|
737
|
+
|
|
738
|
+
// Set loading state before playWhenReady so the setter's guard
|
|
739
|
+
// prevents a no-op startPlayback() on the now-nil item.
|
|
740
|
+
transition(.trackLoading)
|
|
741
|
+
|
|
742
|
+
// Ensure playWhenReady is set before loading to preserve playback state
|
|
743
|
+
setPlayWhenReady(shouldContinuePlayback)
|
|
744
|
+
|
|
745
|
+
engine.loadNowPlayingMetadata(for: currentTrack)
|
|
746
|
+
persistence.save()
|
|
747
|
+
|
|
748
|
+
// Validate source URL before handing off to MediaLoader
|
|
749
|
+
guard let src = currentTrack.src else {
|
|
750
|
+
logger.error("Failed to load track - no src")
|
|
751
|
+
logger.error(" track.title: \(currentTrack.title)")
|
|
752
|
+
logger.error(" track.path: \(currentTrack.path ?? "nil")")
|
|
753
|
+
clearItem()
|
|
754
|
+
transition(.errorOccurred(.invalidSourceUrl("nil")))
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
logger.debug("Loading track: \(currentTrack.title)")
|
|
759
|
+
logger.debug(" track.path: \(currentTrack.path ?? "nil")")
|
|
760
|
+
logger.debug(" track.src: \(src)")
|
|
761
|
+
|
|
762
|
+
engine.resolveAndLoad(src: src, track: currentTrack)
|
|
763
|
+
} else {
|
|
764
|
+
unload()
|
|
765
|
+
engine.clearNowPlaying()
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
let eventData = PlaybackActiveTrackChangedEvent(
|
|
769
|
+
lastIndex: lastIndex == -1 ? nil : Double(lastIndex),
|
|
770
|
+
lastTrack: lastTrack,
|
|
771
|
+
lastPosition: lastPosition,
|
|
772
|
+
index: currentIndex == -1 ? nil : Double(currentIndex),
|
|
773
|
+
track: currentTrack,
|
|
774
|
+
)
|
|
775
|
+
callbacks?.playerDidChangeActiveTrack(eventData)
|
|
776
|
+
lastTrack = currentTrack
|
|
777
|
+
lastIndex = currentIndex
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// MARK: - Seeking
|
|
781
|
+
|
|
782
|
+
/// Seeks the current item. While loading, the target is held until the item is ready and the
|
|
783
|
+
/// ready transition waits for it (`DeferredSeek`); `completion` then reports false at once.
|
|
784
|
+
func seek(to seconds: TimeInterval, completion: @escaping @MainActor (Bool) -> Void = { _ in }) {
|
|
785
|
+
if state == .loading {
|
|
786
|
+
deferredSeek.capture(seconds)
|
|
787
|
+
completion(false)
|
|
788
|
+
} else if engine.hasItem {
|
|
789
|
+
seekItem(to: seconds, completion: completion)
|
|
790
|
+
} else {
|
|
791
|
+
completion(false)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
func seek(by offset: TimeInterval) {
|
|
796
|
+
if state == .loading {
|
|
797
|
+
seek(to: (deferredSeek.target ?? 0) + offset)
|
|
798
|
+
} else if engine.hasItem {
|
|
799
|
+
seek(to: engine.currentTime + offset)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/// Jumps to the live edge: the end of the seekable window when there is one; without one
|
|
804
|
+
/// (ICY) there is nothing to seek within, so it reconnects. No-op for a track that isn't live.
|
|
805
|
+
func seekToLiveEdge() {
|
|
806
|
+
guard currentTrack?.live == true, engine.hasItem else { return }
|
|
807
|
+
if let edge = engine.liveEdge {
|
|
808
|
+
engine.seek(to: edge) { _ in }
|
|
809
|
+
} else {
|
|
810
|
+
reloadResolving(startFromCurrentTime: false)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/// A completion arriving after a track change belongs to the old item, and reports false
|
|
815
|
+
/// without touching the new one's deferred seek.
|
|
816
|
+
private func seekItem(to seconds: TimeInterval, completion: @escaping @MainActor (Bool) -> Void) {
|
|
817
|
+
let generation = deferredSeek.generation
|
|
818
|
+
engine.seek(to: seconds) { [weak self] finished in
|
|
819
|
+
guard let self, deferredSeek.generation == generation else {
|
|
820
|
+
completion(false)
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
seekDidLand(at: seconds, finished: finished)
|
|
824
|
+
completion(finished)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/// Issues the deferred seek if one is pending; true when it did, so ready waits for it.
|
|
829
|
+
private func beginDeferredSeek() -> Bool {
|
|
830
|
+
guard let time = deferredSeek.begin() else { return false }
|
|
831
|
+
seekItem(to: time) { _ in }
|
|
832
|
+
return true
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private func seekDidLand(at seconds: TimeInterval, finished: Bool) {
|
|
836
|
+
switch deferredSeek.land() {
|
|
837
|
+
case .done:
|
|
838
|
+
if state == .loading {
|
|
839
|
+
logger.debug("[loadSeek] seek landed at \(seconds)s (finished=\(finished)) → .ready")
|
|
840
|
+
transition(.loadSeekCompleted)
|
|
841
|
+
}
|
|
842
|
+
case let .reseek(time):
|
|
843
|
+
seekItem(to: time) { _ in }
|
|
844
|
+
case .none:
|
|
845
|
+
break
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// MARK: - Reloading
|
|
850
|
+
|
|
851
|
+
/// Loads the current track again from its resolved URL.
|
|
852
|
+
func reload(startFromCurrentTime: Bool) {
|
|
853
|
+
prepareForReload(resumingAt: startFromCurrentTime ? resumePosition : nil)
|
|
854
|
+
engine.loadResolved()
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/// Loads the current track again, re-running the media-URL resolver, so a consumer whose
|
|
858
|
+
/// resolver mints short-lived URLs or tokens recovers once they expire. Falls back to
|
|
859
|
+
/// `reload` when the track has no `src` to resolve.
|
|
860
|
+
func reloadResolving(startFromCurrentTime: Bool) {
|
|
861
|
+
guard let track = currentTrack, let src = track.src else {
|
|
862
|
+
reload(startFromCurrentTime: startFromCurrentTime)
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
prepareForReload(resumingAt: startFromCurrentTime ? resumePosition : nil)
|
|
866
|
+
engine.resolveAndLoad(src: src, track: track)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/// A restart from a terminal error begins a new Load on a new AVPlayer; automatic retries
|
|
870
|
+
/// never get here in .error, since the retry loop keeps the state non-terminal. `time` is held
|
|
871
|
+
/// as the deferred seek before the loading transition saves the snapshot.
|
|
872
|
+
private func prepareForReload(resumingAt time: TimeInterval?) {
|
|
873
|
+
if state == .error {
|
|
874
|
+
beginLoad()
|
|
875
|
+
clearItem()
|
|
876
|
+
engine.recreate()
|
|
877
|
+
} else {
|
|
878
|
+
engine.cancelLoading()
|
|
879
|
+
engine.dropAsset()
|
|
880
|
+
engine.pause()
|
|
881
|
+
}
|
|
882
|
+
if let time { deferredSeek.capture(time) }
|
|
883
|
+
transition(.trackLoading)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private func clearItem() {
|
|
887
|
+
engine.clearItem()
|
|
888
|
+
deferredSeek.reset()
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
private func unload() {
|
|
892
|
+
clearItem()
|
|
893
|
+
transition(.trackUnloaded)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/// Recovers from a media-services reset: every AVPlayer and session handle is invalid, so a
|
|
897
|
+
/// new player reloads the current track, keeping the play intent, so a live stream reconnects
|
|
898
|
+
/// instead of going silent. No-op when nothing is loaded.
|
|
899
|
+
func mediaServicesWereReset() {
|
|
900
|
+
guard currentTrack != nil else { return }
|
|
901
|
+
logger.info("Media services were reset — recreating player and reloading current track")
|
|
902
|
+
clearItem()
|
|
903
|
+
engine.recreate()
|
|
904
|
+
reloadResolving(startFromCurrentTime: false)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// MARK: - Engine Events
|
|
908
|
+
|
|
909
|
+
private func engineDidEmit(_ event: EngineEvent) {
|
|
910
|
+
switch event {
|
|
911
|
+
case let .itemPrepared(likelyToKeepUp):
|
|
912
|
+
load?.itemPrepared = true
|
|
913
|
+
if playWhenReady { engine.play() }
|
|
914
|
+
if !beginDeferredSeek(), likelyToKeepUp { avItemDidUpdatePlaybackLikelyToKeepUp(true) }
|
|
915
|
+
case let .loadFailed(error):
|
|
916
|
+
primeNowPlayingIfNeeded()
|
|
917
|
+
retry.handleError(error, context: .mediaLoad)
|
|
918
|
+
case .unplayable:
|
|
919
|
+
primeNowPlayingIfNeeded()
|
|
920
|
+
transition(.errorOccurred(.trackWasUnplayable))
|
|
921
|
+
case let .invalidSource(url):
|
|
922
|
+
primeNowPlayingIfNeeded()
|
|
923
|
+
transition(.errorOccurred(.invalidSourceUrl(url)))
|
|
924
|
+
case let .failed(error, httpStatusCode):
|
|
925
|
+
retry.handleError(error, context: .playback, httpStatusCode: httpStatusCode)
|
|
926
|
+
case let .likelyToKeepUp(isLikely):
|
|
927
|
+
guard isLikely, engine.hasItem else { return }
|
|
928
|
+
if beginDeferredSeek() { return }
|
|
929
|
+
avItemDidUpdatePlaybackLikelyToKeepUp(isLikely)
|
|
930
|
+
case let .timeControlStatus(status, waitingForNoItem):
|
|
931
|
+
recoverIfAirPlayStalled(transitioningTo: status, waitingForNoItem: waitingForNoItem)
|
|
932
|
+
avPlayerDidChangeTimeControlStatus(status)
|
|
933
|
+
case .audioDidStart:
|
|
934
|
+
audioDidStart()
|
|
935
|
+
case .playedToEnd:
|
|
936
|
+
itemDidPlayToEnd()
|
|
937
|
+
case .stalled:
|
|
938
|
+
// Nudge, don't reconnect: a genuine drop is recovered by the retry and network paths.
|
|
939
|
+
recoverFromStall(reconnectIfLive: false)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/// A failed load renders no audio, and without it iOS never elects the app onto the
|
|
944
|
+
/// now-playing surfaces (ADR 0005). Once per load: one burst either elects it or never will.
|
|
945
|
+
private func primeNowPlayingIfNeeded() {
|
|
946
|
+
guard playWhenReady, claimNowPlayingPrime() else { return }
|
|
947
|
+
engine.primeNowPlaying()
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/// A genuine end advances the queue; a dropped live stream or an underrun reported as an end
|
|
951
|
+
/// is recovered instead (see `EndOfTrackJudgement`).
|
|
952
|
+
private func itemDidPlayToEnd() {
|
|
953
|
+
let judgement = EndOfTrackJudgement(
|
|
954
|
+
isLive: currentTrack?.live == true,
|
|
955
|
+
currentTime: engine.currentTime,
|
|
956
|
+
duration: engine.duration,
|
|
957
|
+
)
|
|
958
|
+
switch judgement.outcome {
|
|
959
|
+
case .ended: handleTrackDidPlayToEndTime()
|
|
960
|
+
case .stalled: recoverFromStall(reconnectIfLive: true)
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/// Recovers from a stall while the play intent holds: a live stream that ran out of data
|
|
965
|
+
/// (`reconnectIfLive`) rejoins the edge; otherwise play() un-parks AVPlayer from waiting.
|
|
966
|
+
private func recoverFromStall(reconnectIfLive: Bool) {
|
|
967
|
+
guard playWhenReady else { return }
|
|
968
|
+
if reconnectIfLive, currentTrack?.live == true {
|
|
969
|
+
seekToLiveEdge()
|
|
970
|
+
} else {
|
|
971
|
+
engine.play()
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private var previousTimeControlStatus: PlayerTimeControlStatus?
|
|
976
|
+
private var previousWaitingForNoItem = false
|
|
977
|
+
|
|
978
|
+
/// AVPlayer can strand itself in .paused after a `.noItemToPlay` wait over AirPlay while the
|
|
979
|
+
/// intent is to play: play() is re-issued on exactly that transition.
|
|
980
|
+
private func recoverIfAirPlayStalled(transitioningTo current: PlayerTimeControlStatus, waitingForNoItem: Bool) {
|
|
981
|
+
let stalled = AirPlayStallJudgement(
|
|
982
|
+
previous: previousTimeControlStatus,
|
|
983
|
+
current: current,
|
|
984
|
+
previousWaitingReasonWasNoItemToPlay: previousWaitingForNoItem,
|
|
985
|
+
isAirPlay: engine.isAirPlayRoute,
|
|
986
|
+
playWhenReady: playWhenReady,
|
|
987
|
+
).shouldNudge
|
|
988
|
+
previousTimeControlStatus = current
|
|
989
|
+
previousWaitingForNoItem = current == .waitingToPlayAtSpecifiedRate && waitingForNoItem
|
|
990
|
+
if stalled {
|
|
991
|
+
logger.info("AirPlay waiting→paused stall detected — nudging play()")
|
|
992
|
+
engine.play()
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// MARK: - Connectivity
|
|
997
|
+
|
|
998
|
+
/// Re-establishes a stalled stream when connectivity returns. AVPlayer doesn't reliably report
|
|
999
|
+
/// a live stream's mid-stream drop as a failure: it waits with no error, so the retry loop
|
|
1000
|
+
/// never engages. Only while stalled (.buffering) with play intent, so a pause or a retry that
|
|
1001
|
+
/// gave up isn't resumed; and not while the retry loop is itself waiting for the network,
|
|
1002
|
+
/// which owns that reload.
|
|
1003
|
+
func networkDidReturn() {
|
|
1004
|
+
guard playWhenReady, state == .buffering, !retry.isWaitingForNetwork else { return }
|
|
1005
|
+
logger.info("Connectivity restored while stalled — re-resolving to reconnect")
|
|
1006
|
+
reloadResolving(startFromCurrentTime: true)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/// Time playback must have progressed before a buffer dip counts as a stall, so the initial
|
|
1010
|
+
/// connect or a seek doesn't read as one.
|
|
1011
|
+
private static let stallGraceSeconds: TimeInterval = 0.5
|
|
1012
|
+
|
|
1013
|
+
/// Ongoing playback is waiting for data. Mirrors Android's `stalled`, which ExoPlayer
|
|
1014
|
+
/// distinguishes natively; iOS has no rebuffer-vs-initial signal.
|
|
1015
|
+
var isStalled: Bool {
|
|
1016
|
+
guard engine.hasItem else { return false }
|
|
1017
|
+
return engine.currentTime > Self.stallGraceSeconds && playWhenReady && !engine.isPlaybackLikelyToKeepUp
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// MARK: - Resume
|
|
1021
|
+
|
|
1022
|
+
/// The last persisted session; nil when nothing was persisted, or its track has since become
|
|
1023
|
+
/// `disabled`, which never plays.
|
|
1024
|
+
func persistedSession() -> PersistedPlaybackState? {
|
|
1025
|
+
guard let snapshot = persistence.load() else {
|
|
1026
|
+
logger.error("resume: nothing persisted")
|
|
1027
|
+
return nil
|
|
1028
|
+
}
|
|
1029
|
+
guard snapshot.track.disabled != true else {
|
|
1030
|
+
logger.info("resume: persisted track is disabled — refusing")
|
|
1031
|
+
return nil
|
|
1032
|
+
}
|
|
1033
|
+
return snapshot
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/// Plays a persisted session (a cold-start resume): its settings, then its queue — `expanded`,
|
|
1037
|
+
/// the expansion of the track's contextual path, or the track alone.
|
|
1038
|
+
func resume(_ snapshot: PersistedPlaybackState, queue expanded: (tracks: [Track], selectedIndex: Int)?) {
|
|
1039
|
+
let track = snapshot.track.toNitro()
|
|
1040
|
+
logger.info("resume: cold — restoring persisted track")
|
|
1041
|
+
// Settings first: `setQueue` builds the shuffle order, and the mode events then precede the
|
|
1042
|
+
// queue change, as on Android.
|
|
1043
|
+
repeatMode = RepeatMode(fromString: snapshot.repeatMode) ?? .off
|
|
1044
|
+
shuffleEnabled = snapshot.shuffleEnabled
|
|
1045
|
+
rate = snapshot.playbackSpeed
|
|
1046
|
+
let startMs = track.live == true ? nil : snapshot.positionMs
|
|
1047
|
+
if let expanded {
|
|
1048
|
+
setQueue(expanded.tracks, initialIndex: expanded.selectedIndex, startPositionMs: startMs, playWhenReady: true)
|
|
1049
|
+
} else {
|
|
1050
|
+
setQueue([track], initialIndex: 0, startPositionMs: startMs, playWhenReady: true)
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/// Plays what `TrackSelector` resolved a picked track to.
|
|
1055
|
+
func execute(_ intent: TrackSelector.PlaybackIntent) {
|
|
1056
|
+
switch intent {
|
|
1057
|
+
case let .skipTo(index):
|
|
1058
|
+
do {
|
|
1059
|
+
try skipTo(index, playWhenReady: true)
|
|
1060
|
+
} catch {
|
|
1061
|
+
logger.error("Failed to skip to track at index \(index): \(error)")
|
|
1062
|
+
}
|
|
1063
|
+
case let .setQueue(tracks, startIndex, sourcePath):
|
|
1064
|
+
setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: sourcePath)
|
|
1065
|
+
case let .loadTrack(track):
|
|
1066
|
+
load(track, playWhenReady: true)
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// MARK: - Progress Updates
|
|
1071
|
+
|
|
1072
|
+
func setProgressUpdateInterval(_ interval: TimeInterval?) {
|
|
1073
|
+
progressTimer.setInterval(interval)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
func setPlaybackIntervalEnabled(_ enabled: Bool) {
|
|
1077
|
+
intervalTimer.setInterval(enabled ? 1 : nil)
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// MARK: - TrackSelectionPlayer
|
|
1082
|
+
|
|
1083
|
+
extension Player: TrackSelectionPlayer {}
|