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
|
@@ -1,761 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import os.log
|
|
3
|
-
|
|
4
|
-
#if canImport(NitroModules)
|
|
5
|
-
import NitroModules
|
|
6
|
-
#endif
|
|
7
|
-
|
|
8
|
-
/// Owns the playback state machine, side effect dispatch, observer context guards,
|
|
9
|
-
/// and track management orchestration. Testable without AVFoundation by injecting
|
|
10
|
-
/// a `PlaybackEffectHandler` (TrackPlayer in production, mock in tests).
|
|
11
|
-
@MainActor
|
|
12
|
-
class PlaybackCoordinator {
|
|
13
|
-
let logger = Logger(subsystem: "com.audiobrowser", category: "PlaybackCoordinator")
|
|
14
|
-
|
|
15
|
-
// MARK: - Dependencies
|
|
16
|
-
|
|
17
|
-
weak var effectHandler: PlaybackEffectHandler?
|
|
18
|
-
weak var callbacks: PlaybackCoordinatorCallbacks?
|
|
19
|
-
|
|
20
|
-
let queue = QueueManager()
|
|
21
|
-
let errorHandler: PlaybackErrorHandler
|
|
22
|
-
let sleepTimerManager: any SleepTimerHandling
|
|
23
|
-
let loadSeekCoordinator = LoadSeekCoordinator()
|
|
24
|
-
|
|
25
|
-
lazy var volumeFader = VolumeFader(
|
|
26
|
-
getVolume: { [weak self] in self?.effectHandler?.volume ?? 1 },
|
|
27
|
-
setVolume: { [weak self] level in self?.effectHandler?.volume = level },
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
lazy var playingStateManager: PlayingStateManager = PlayingStateManager { [weak self] state in
|
|
31
|
-
self?.callbacks?.playerDidChangePlayingState(state)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
private lazy var progressTimer = PlaybackTimer(
|
|
35
|
-
isActive: { $0 == .loading || $0 == .buffering || $0 == .playing },
|
|
36
|
-
) { [weak self] in
|
|
37
|
-
guard let self, currentIndex >= 0, let effectHandler else { return }
|
|
38
|
-
let progressEvent = PlaybackProgressUpdatedEvent(
|
|
39
|
-
track: Double(currentIndex),
|
|
40
|
-
position: effectHandler.currentTime,
|
|
41
|
-
duration: effectHandler.duration,
|
|
42
|
-
buffered: effectHandler.bufferedPosition,
|
|
43
|
-
)
|
|
44
|
-
callbacks?.playerDidUpdateProgress(progressEvent)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private lazy var intervalTimer = PlaybackTimer(
|
|
48
|
-
isActive: { $0 == .playing },
|
|
49
|
-
) { [weak self] in
|
|
50
|
-
self?.callbacks?.playerDidFirePlaybackInterval()
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// MARK: - State
|
|
54
|
-
|
|
55
|
-
private(set) var state: PlaybackState = .none
|
|
56
|
-
var playbackError: TrackPlayerError.PlaybackError?
|
|
57
|
-
/// True while `playbackError` is advisory — the retry loop is still working
|
|
58
|
-
/// on it and the state stays non-terminal. Terminal errors (state == .error)
|
|
59
|
-
/// always carry false.
|
|
60
|
-
private(set) var isRetryingError = false
|
|
61
|
-
|
|
62
|
-
/// The JS-facing form of `playbackError`, carrying the retrying flag.
|
|
63
|
-
func nitroPlaybackError() -> PlaybackError? {
|
|
64
|
-
playbackError?.toNitroError(retrying: isRetryingError)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/// Whether playback should resume when the current audio-session interruption
|
|
68
|
-
/// ends — set to the play intent captured at interruption start, so we only
|
|
69
|
-
/// auto-resume if we were actually playing when interrupted.
|
|
70
|
-
private var shouldResumeAfterInterruption = false
|
|
71
|
-
|
|
72
|
-
/// Seconds of continuous audible playback after which the retry budget is
|
|
73
|
-
/// refilled — proof the stream actually delivered audio, not a brief blip
|
|
74
|
-
/// (mirrors Android's HEALTHY_PLAYBACK_MS). Without the refill a long-lived
|
|
75
|
-
/// stream permanently loses retry after its first recovered error, because
|
|
76
|
-
/// the window/attempt counters otherwise reset only on track change.
|
|
77
|
-
/// Overridable in tests.
|
|
78
|
-
var healthyPlaybackDuration: TimeInterval = 20
|
|
79
|
-
|
|
80
|
-
/// The pending refill. Readable so tests can await the window instead of
|
|
81
|
-
/// sleeping past it; only this type arms and cancels it.
|
|
82
|
-
private(set) var healthyPlaybackTask: Task<Void, Never>?
|
|
83
|
-
|
|
84
|
-
/// Playback rate (1.0 = normal speed).
|
|
85
|
-
var rate: Float = 1.0
|
|
86
|
-
|
|
87
|
-
/// Audio time pitch algorithm identifier (stored as a String to avoid AVFoundation import).
|
|
88
|
-
var audioTimePitchAlgorithm: String = "TimeDomain"
|
|
89
|
-
|
|
90
|
-
// MARK: - Track State (for active track changed events)
|
|
91
|
-
|
|
92
|
-
var lastIndex: Int = -1
|
|
93
|
-
var lastTrack: Track?
|
|
94
|
-
|
|
95
|
-
// MARK: - Queue Forwarding Properties
|
|
96
|
-
|
|
97
|
-
var tracks: [Track] { queue.tracks }
|
|
98
|
-
var currentIndex: Int { queue.currentIndex }
|
|
99
|
-
var currentTrack: Track? { queue.currentTrack }
|
|
100
|
-
var queueSourcePath: String? { queue.queueSourcePath }
|
|
101
|
-
var nextTracks: [Track] { queue.nextTracks }
|
|
102
|
-
var previousTracks: [Track] { queue.previousTracks }
|
|
103
|
-
var isLastInPlaybackOrder: Bool { queue.isLastInPlaybackOrder }
|
|
104
|
-
var canNext: Bool { queue.canNext }
|
|
105
|
-
var canPrevious: Bool { queue.canPrevious }
|
|
106
|
-
|
|
107
|
-
/// The repeat mode for the queue player.
|
|
108
|
-
var repeatMode: RepeatMode {
|
|
109
|
-
get { queue.repeatMode }
|
|
110
|
-
set {
|
|
111
|
-
guard queue.repeatMode != newValue else { return }
|
|
112
|
-
queue.repeatMode = newValue
|
|
113
|
-
effectHandler?.updateRemoteRepeatMode(newValue)
|
|
114
|
-
pushSkipAvailability() // repeat-all wrap changes next/previous availability
|
|
115
|
-
callbacks?.playerDidChangeRepeatMode(
|
|
116
|
-
RepeatModeChangedEvent(repeatMode: newValue),
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/// Whether shuffle mode is enabled.
|
|
122
|
-
var shuffleEnabled: Bool {
|
|
123
|
-
get { queue.shuffleEnabled }
|
|
124
|
-
set {
|
|
125
|
-
guard queue.shuffleEnabled != newValue else { return }
|
|
126
|
-
queue.shuffleEnabled = newValue
|
|
127
|
-
effectHandler?.updateRemoteShuffleMode(newValue)
|
|
128
|
-
pushSkipAvailability() // shuffle reorders playback → boundary changes
|
|
129
|
-
callbacks?.playerDidChangeShuffleEnabled(newValue)
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/// Pushes current next/previous availability to the remote command center so
|
|
134
|
-
/// CarPlay / lock-screen grey out the buttons when there's nowhere to skip.
|
|
135
|
-
private func pushSkipAvailability() {
|
|
136
|
-
effectHandler?.updateSkipAvailability(canNext: queue.canNext, canPrevious: queue.canPrevious)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// MARK: - playWhenReady
|
|
140
|
-
|
|
141
|
-
var playWhenReady: Bool = false {
|
|
142
|
-
didSet {
|
|
143
|
-
// A play during an interruption takes ownership of the intent — the
|
|
144
|
-
// user's subsequent pause must not be overridden by auto-resume.
|
|
145
|
-
// (handleInterruptionEnded consumes its flag before its own play().)
|
|
146
|
-
if playWhenReady { shouldResumeAfterInterruption = false }
|
|
147
|
-
|
|
148
|
-
// Terminal states need a reload, not a bare play(): the item is gone
|
|
149
|
-
// (.stopped/.error) or parked at its end (.ended), where startPlayback()
|
|
150
|
-
// is a silent no-op. Only .error resumes from the last position.
|
|
151
|
-
if playWhenReady == true, state == .error || state == .stopped || state == .ended {
|
|
152
|
-
effectHandler?.reloadTrack(startFromCurrentTime: state == .error)
|
|
153
|
-
}
|
|
154
|
-
if state != .loading {
|
|
155
|
-
if playWhenReady {
|
|
156
|
-
effectHandler?.startPlayback()
|
|
157
|
-
} else {
|
|
158
|
-
effectHandler?.pausePlayback()
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if oldValue != playWhenReady {
|
|
163
|
-
// An explicit pause during the sleep fade is the timer's goal arriving
|
|
164
|
-
// early: clear the timer (restores the pre-fade volume via onFadeCancel).
|
|
165
|
-
if !playWhenReady, volumeFader.isActive {
|
|
166
|
-
sleepTimerManager.clear()
|
|
167
|
-
}
|
|
168
|
-
callbacks?.playerDidChangePlayWhenReady(playWhenReady)
|
|
169
|
-
playingStateManager.update(playWhenReady: playWhenReady, state: state)
|
|
170
|
-
// Reflect play/pause intent immediately — even while the new item is
|
|
171
|
-
// still loading. Auto-publishing doesn't set the explicit now-playing
|
|
172
|
-
// playback state, and the player's timeControlStatus can't report
|
|
173
|
-
// "playing" yet during loading. Guarded to active states so a play()
|
|
174
|
-
// from a terminal state doesn't leave a phantom "playing" button.
|
|
175
|
-
if playbackActive {
|
|
176
|
-
effectHandler?.updateNowPlayingState(playWhenReady: playWhenReady)
|
|
177
|
-
}
|
|
178
|
-
evaluateSessionRelease()
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
var playbackActive: Bool {
|
|
184
|
-
switch state {
|
|
185
|
-
case .none, .stopped, .ended, .error:
|
|
186
|
-
false
|
|
187
|
-
default: true
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// MARK: - Init
|
|
192
|
-
|
|
193
|
-
init(errorHandler: PlaybackErrorHandler, sleepTimerManager: any SleepTimerHandling) {
|
|
194
|
-
self.errorHandler = errorHandler
|
|
195
|
-
self.sleepTimerManager = sleepTimerManager
|
|
196
|
-
|
|
197
|
-
queue.delegate = self
|
|
198
|
-
|
|
199
|
-
// Configure sleep timer
|
|
200
|
-
sleepTimerManager.onComplete = { [weak self] in
|
|
201
|
-
guard let self else { return }
|
|
202
|
-
self.volumeFader.resolve { self.pause() }
|
|
203
|
-
}
|
|
204
|
-
sleepTimerManager.onFadeStart = { [weak self] duration in
|
|
205
|
-
self?.volumeFader.start(duration: duration)
|
|
206
|
-
}
|
|
207
|
-
sleepTimerManager.onFadeCancel = { [weak self] in
|
|
208
|
-
self?.volumeFader.cancel(restoringVolume: true)
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Wire error handler to state machine
|
|
212
|
-
errorHandler.onError = { [weak self] error in
|
|
213
|
-
self?.transition(.errorOccurred(error))
|
|
214
|
-
}
|
|
215
|
-
errorHandler.onRetryingError = { [weak self] error in
|
|
216
|
-
self?.reportRetryingError(error)
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/// Surfaces a failure the retry loop is still working on: attached to the
|
|
221
|
-
/// current (non-terminal) state so UIs can show the cause over the spinner.
|
|
222
|
-
/// Not a state transition — the state machine never sees it — and not a
|
|
223
|
-
/// `playerDidError`, which stays reserved for terminal errors.
|
|
224
|
-
private func reportRetryingError(_ error: TrackPlayerError.PlaybackError) {
|
|
225
|
-
// Only attach while the failure is what the listener is looking at (matches
|
|
226
|
-
// Android). During .playing buffered audio is still audibly fine — the
|
|
227
|
-
// advisory would replace the song line over working sound — and a
|
|
228
|
-
// terminal/stopped state must not regain an advisory error.
|
|
229
|
-
guard state == .loading || state == .buffering || state == .paused else { return }
|
|
230
|
-
// Each failed attempt re-reports; identical repeats add nothing.
|
|
231
|
-
if isRetryingError, playbackError == error { return }
|
|
232
|
-
playbackError = error
|
|
233
|
-
isRetryingError = true
|
|
234
|
-
callbacks?.playerDidChangePlayback(
|
|
235
|
-
Playback(state: state, error: nitroPlaybackError()),
|
|
236
|
-
)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// MARK: - Playback State Machine
|
|
240
|
-
|
|
241
|
-
func transition(_ event: PlaybackEvent) {
|
|
242
|
-
guard let newState = nextPlaybackState(from: state, on: event) else { return }
|
|
243
|
-
|
|
244
|
-
// Allow error-to-error transitions to update the error and emit callbacks,
|
|
245
|
-
// even though the state enum value doesn't change.
|
|
246
|
-
if newState == state, case let .errorOccurred(error) = event {
|
|
247
|
-
playbackError = error
|
|
248
|
-
isRetryingError = false
|
|
249
|
-
callbacks?.playerDidChangePlayback(
|
|
250
|
-
Playback(state: state, error: nitroPlaybackError()),
|
|
251
|
-
)
|
|
252
|
-
callbacks?.playerDidError(
|
|
253
|
-
PlaybackErrorEvent(error: nitroPlaybackError()),
|
|
254
|
-
)
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
guard newState != state else { return }
|
|
259
|
-
let oldState = state
|
|
260
|
-
state = newState
|
|
261
|
-
applySideEffects(old: oldState, new: newState, event: event)
|
|
262
|
-
emitStateChange(old: oldState, new: newState)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
private func applySideEffects(old: PlaybackState, new: PlaybackState, event: PlaybackEvent) {
|
|
266
|
-
// Error lifecycle
|
|
267
|
-
if old == .error, new != .error {
|
|
268
|
-
playbackError = nil
|
|
269
|
-
}
|
|
270
|
-
// A retrying error rides along through loading/buffering, but any state
|
|
271
|
-
// that proves recovery (.ready/.playing — data flowed) or abandons the
|
|
272
|
-
// attempt (.stopped/.none/.ended) clears it. Runs before emitStateChange,
|
|
273
|
-
// so the emitted Playback is already clean.
|
|
274
|
-
if isRetryingError, new != .loading, new != .buffering, new != .paused {
|
|
275
|
-
playbackError = nil
|
|
276
|
-
isRetryingError = false
|
|
277
|
-
}
|
|
278
|
-
if case let .errorOccurred(error) = event {
|
|
279
|
-
playbackError = error
|
|
280
|
-
isRetryingError = false
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Retry-budget refill: sustained audible playback proves the stream
|
|
284
|
-
// recovered, so the next error starts a fresh retry window. Leaving
|
|
285
|
-
// .playing before the threshold cancels the pending refill.
|
|
286
|
-
if new == .playing {
|
|
287
|
-
healthyPlaybackTask?.cancel()
|
|
288
|
-
healthyPlaybackTask = Task { [weak self] in
|
|
289
|
-
guard let self else { return }
|
|
290
|
-
try? await Task.sleep(nanoseconds: UInt64(healthyPlaybackDuration * 1_000_000_000))
|
|
291
|
-
guard !Task.isCancelled else { return }
|
|
292
|
-
// Refill only between episodes: resetRetry() cancels a pending retry,
|
|
293
|
-
// which would silently kill an in-flight reconnect.
|
|
294
|
-
guard errorHandler.pendingRetryTask == nil else { return }
|
|
295
|
-
errorHandler.resetRetry()
|
|
296
|
-
}
|
|
297
|
-
} else if old == .playing {
|
|
298
|
-
healthyPlaybackTask?.cancel()
|
|
299
|
-
healthyPlaybackTask = nil
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// State-specific effects
|
|
303
|
-
switch new {
|
|
304
|
-
case .ready:
|
|
305
|
-
effectHandler?.setTimePitchingAlgorithmForCurrentItem()
|
|
306
|
-
if playWhenReady { effectHandler?.startPlayback() }
|
|
307
|
-
case .loading:
|
|
308
|
-
effectHandler?.setTimePitchingAlgorithmForCurrentItem()
|
|
309
|
-
case .ended:
|
|
310
|
-
// A natural end exhausts the play intent — nothing is left to play.
|
|
311
|
-
// Keeping playWhenReady true would invert the play/pause toggle, hold
|
|
312
|
-
// the audio session forever (release is intent-gated), and arm
|
|
313
|
-
// interruption auto-resume into silence. The setter also requests the
|
|
314
|
-
// session release.
|
|
315
|
-
playWhenReady = false
|
|
316
|
-
default: break
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Reflect play/pause (auto-publishing doesn't set the explicit now-playing
|
|
320
|
-
// playback state). Active states show the user's intent — incl. .loading, so
|
|
321
|
-
// the button flips to "playing" the moment a play-intent load begins; terminal
|
|
322
|
-
// states (error/stopped/ended) resolve to paused so the button never sticks on
|
|
323
|
-
// "playing" after playback fails or stops.
|
|
324
|
-
effectHandler?.updateNowPlayingState(playWhenReady: playbackActive && playWhenReady)
|
|
325
|
-
|
|
326
|
-
progressTimer.onPlaybackStateChanged(new)
|
|
327
|
-
intervalTimer.onPlaybackStateChanged(new)
|
|
328
|
-
playingStateManager.update(playWhenReady: playWhenReady, state: new)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
private func emitStateChange(old: PlaybackState, new: PlaybackState) {
|
|
332
|
-
// Playback state change — always emitted
|
|
333
|
-
callbacks?.playerDidChangePlayback(
|
|
334
|
-
Playback(state: new, error: nitroPlaybackError()),
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
// Error callback — emitted when entering or leaving error state
|
|
338
|
-
if new == .error || (old == .error && new != .error) {
|
|
339
|
-
callbacks?.playerDidError(
|
|
340
|
-
PlaybackErrorEvent(error: nitroPlaybackError()),
|
|
341
|
-
)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Queue ended — when playback ends on the last track. Repeat modes never
|
|
345
|
-
// conceptually end the queue (matches web's endsQueue()): a repeat-track
|
|
346
|
-
// pause at the track boundary (e.g. the end-of-track sleep timer) must not
|
|
347
|
-
// read as "playlist over".
|
|
348
|
-
if new == .ended, isLastInPlaybackOrder, repeatMode == .off, let effectHandler {
|
|
349
|
-
callbacks?.playerDidEndQueue(
|
|
350
|
-
PlaybackQueueEndedEvent(track: Double(currentIndex), position: effectHandler.currentTime),
|
|
351
|
-
)
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// MARK: - Observer Context Guards
|
|
356
|
-
|
|
357
|
-
func avPlayerDidChangeTimeControlStatus(_ status: PlayerTimeControlStatus) {
|
|
358
|
-
// During loading, ignore stale timeControlStatus changes from old items.
|
|
359
|
-
if state == .loading { return }
|
|
360
|
-
|
|
361
|
-
switch status {
|
|
362
|
-
case .paused:
|
|
363
|
-
guard let effectHandler else { return }
|
|
364
|
-
let currentState = state
|
|
365
|
-
let currentTime = effectHandler.currentTime
|
|
366
|
-
let duration = effectHandler.duration
|
|
367
|
-
// Ignore pauses when near track end
|
|
368
|
-
let nearTrackEnd = currentTime >= duration - 0.5 && duration > 0
|
|
369
|
-
|
|
370
|
-
if nearTrackEnd {
|
|
371
|
-
// Ignore - track completion will be handled by handleTrackDidPlayToEndTime
|
|
372
|
-
} else if !effectHandler.hasLoadedAsset, currentState != .stopped {
|
|
373
|
-
transition(.avPlayerPaused(hasAsset: false))
|
|
374
|
-
} else if currentState != .error, currentState != .stopped {
|
|
375
|
-
if !playWhenReady {
|
|
376
|
-
transition(.avPlayerPaused(hasAsset: true))
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
case .waitingToPlayAtSpecifiedRate:
|
|
380
|
-
if effectHandler?.hasLoadedAsset == true {
|
|
381
|
-
transition(.avPlayerWaiting)
|
|
382
|
-
}
|
|
383
|
-
case .playing:
|
|
384
|
-
transition(.avPlayerPlaying)
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
func avPlayerStatusDidFail(error: Error?, httpStatusCode: Int? = nil) {
|
|
389
|
-
errorHandler.handleError(error, context: .playback, httpStatusCode: httpStatusCode)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
func avItemStatusDidChange(_ status: PlayerItemStatus, error: Error?, httpStatusCode: Int? = nil) {
|
|
393
|
-
if status == .failed {
|
|
394
|
-
errorHandler.handleError(error, context: .playback, httpStatusCode: httpStatusCode)
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
func audioDidStart() {
|
|
399
|
-
// Don't override loading state
|
|
400
|
-
if state == .loading { return }
|
|
401
|
-
transition(.audioFrameDecoded)
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
func avItemDidUpdatePlaybackLikelyToKeepUp(_ playbackLikelyToKeepUp: Bool) {
|
|
405
|
-
guard playbackLikelyToKeepUp else { return }
|
|
406
|
-
guard effectHandler?.hasLoadedAsset == true else { return }
|
|
407
|
-
|
|
408
|
-
if !loadSeekCoordinator.shouldDeferReadyTransition, state != .playing {
|
|
409
|
-
logger.debug("avItemDidUpdatePlaybackLikelyToKeepUp → .ready")
|
|
410
|
-
transition(.bufferingSufficient)
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// MARK: - Player Actions
|
|
415
|
-
|
|
416
|
-
func play() {
|
|
417
|
-
playWhenReady = true
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
func pause() {
|
|
421
|
-
playWhenReady = false
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
func togglePlayback() {
|
|
425
|
-
playWhenReady = !playWhenReady
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
func stop() {
|
|
429
|
-
// Cancel before the intent drop: a surviving retry would surface .error
|
|
430
|
-
// over the stop, and a pending task blocks the session release below.
|
|
431
|
-
errorHandler.cancelPendingRetry()
|
|
432
|
-
// A stop is terminal — interruption-end must not resume over it.
|
|
433
|
-
shouldResumeAfterInterruption = false
|
|
434
|
-
transition(.stopped)
|
|
435
|
-
playWhenReady = false
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// MARK: - Audio Session Interruptions
|
|
439
|
-
|
|
440
|
-
/// The active output route disappeared (headphones unplugged, Bluetooth
|
|
441
|
-
/// speaker off). A deliberate output loss: drop the intent and any pending
|
|
442
|
-
/// interruption auto-resume, so a call ending can't blast the built-in
|
|
443
|
-
/// speaker.
|
|
444
|
-
func handleRouteDisconnected() {
|
|
445
|
-
shouldResumeAfterInterruption = false
|
|
446
|
-
pause()
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/// An audio-session interruption began (a phone call, or another app such as
|
|
450
|
-
/// Music/Spotify taking over playback). iOS has already paused our AVPlayer,
|
|
451
|
-
/// but that pause arrives as a `timeControlStatus` change that's swallowed
|
|
452
|
-
/// while `playWhenReady` is still true — so the state would otherwise stay
|
|
453
|
-
/// `.playing` and any UI driven by it stays stuck. Drop the play intent and
|
|
454
|
-
/// force the paused state, independent of notification delivery order.
|
|
455
|
-
func handleInterruptionBegan() {
|
|
456
|
-
// OR: a nested interruption's second .began sees the already-paused
|
|
457
|
-
// intent and must not wipe the flag the first one captured.
|
|
458
|
-
shouldResumeAfterInterruption = shouldResumeAfterInterruption || playWhenReady
|
|
459
|
-
pause()
|
|
460
|
-
if playbackActive, state != .loading {
|
|
461
|
-
transition(.avPlayerPaused(hasAsset: effectHandler?.hasLoadedAsset ?? false))
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/// Whether an interruption-end will resume playback — the intent captured at
|
|
466
|
-
/// interruption start. Lets the host gate side effects that must precede the
|
|
467
|
-
/// resume (audio-session reactivation) on whether one will actually happen.
|
|
468
|
-
var willResumeAfterInterruption: Bool { shouldResumeAfterInterruption }
|
|
469
|
-
|
|
470
|
-
/// An audio-session interruption ended. Resume only if we were playing when it
|
|
471
|
-
/// began and the system indicates resumption is appropriate (`shouldResume`).
|
|
472
|
-
func handleInterruptionEnded(shouldResume: Bool) {
|
|
473
|
-
guard shouldResumeAfterInterruption else { return }
|
|
474
|
-
shouldResumeAfterInterruption = false
|
|
475
|
-
if shouldResume {
|
|
476
|
-
play()
|
|
477
|
-
} else {
|
|
478
|
-
// Interruption ended and we're staying paused: the session can be released now.
|
|
479
|
-
evaluateSessionRelease()
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/// Requests an audio-session release when playback is deliberately stopped: play intent is off,
|
|
484
|
-
/// it isn't an interruption-driven pause (those resume — `shouldResumeAfterInterruption` is set
|
|
485
|
-
/// before the interruption's `pause()`, so this skips it), and no retry is pending (a live-stream
|
|
486
|
-
/// reconnect keeps `playWhenReady` true and must keep the session). The host debounces and cancels
|
|
487
|
-
/// on the next activation, so a quick pause→resume never actually releases.
|
|
488
|
-
private func evaluateSessionRelease() {
|
|
489
|
-
guard !playWhenReady,
|
|
490
|
-
!shouldResumeAfterInterruption,
|
|
491
|
-
errorHandler.pendingRetryTask == nil
|
|
492
|
-
else { return }
|
|
493
|
-
callbacks?.playerShouldReleaseSession()
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
func getPlayback() -> Playback {
|
|
497
|
-
Playback(state: state, error: nitroPlaybackError())
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
func getRepeatMode() -> RepeatMode {
|
|
501
|
-
repeatMode
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
func setRepeatMode(_ mode: RepeatMode) {
|
|
505
|
-
repeatMode = mode
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/// Handles the `playWhenReady` setting while executing a given action.
|
|
509
|
-
func handlePlayWhenReady(_ playWhenReady: Bool?, action: () throws -> Void) rethrows {
|
|
510
|
-
if playWhenReady == false {
|
|
511
|
-
self.playWhenReady = false
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
try action()
|
|
515
|
-
|
|
516
|
-
if playWhenReady == true {
|
|
517
|
-
self.playWhenReady = true
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// MARK: - Queue Methods
|
|
522
|
-
|
|
523
|
-
func load(_ track: Track, playWhenReady: Bool? = nil) {
|
|
524
|
-
handlePlayWhenReady(playWhenReady) {
|
|
525
|
-
if queue.currentIndex == -1 {
|
|
526
|
-
let changed = queue.add([track], initialIndex: 0)
|
|
527
|
-
if changed { handleCurrentTrackChanged() }
|
|
528
|
-
} else {
|
|
529
|
-
// `currentIndex` is invariantly -1 or in bounds, and -1 took the branch
|
|
530
|
-
// above, so this cannot fail — but surface it rather than reloading the
|
|
531
|
-
// old track if that invariant is ever broken.
|
|
532
|
-
do {
|
|
533
|
-
try queue.replace(queue.currentIndex, track)
|
|
534
|
-
handleCurrentTrackChanged()
|
|
535
|
-
} catch {
|
|
536
|
-
logger.error("load: replace at \(self.queue.currentIndex) failed: \(error)")
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
func replace(_ index: Int, _ track: Track) throws {
|
|
543
|
-
try queue.replace(index, track)
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
func setQueue(
|
|
547
|
-
_ newTracks: [Track],
|
|
548
|
-
initialIndex: Int = 0,
|
|
549
|
-
startPositionMs: Double? = nil,
|
|
550
|
-
playWhenReady: Bool? = nil,
|
|
551
|
-
sourcePath: String? = nil,
|
|
552
|
-
) {
|
|
553
|
-
guard !newTracks.isEmpty else {
|
|
554
|
-
clear()
|
|
555
|
-
return
|
|
556
|
-
}
|
|
557
|
-
handlePlayWhenReady(playWhenReady) {
|
|
558
|
-
queue.setQueue(newTracks, initialIndex: initialIndex, sourcePath: sourcePath)
|
|
559
|
-
handleCurrentTrackChanged()
|
|
560
|
-
// Start position is applied as part of the load: captured as a pending
|
|
561
|
-
// seek that runs once the item is ready, deferring the ready/play
|
|
562
|
-
// transition until it lands (no start-at-0 flash).
|
|
563
|
-
if let ms = startPositionMs, ms > 0 {
|
|
564
|
-
loadSeekCoordinator.capture(position: ms / 1000)
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
func add(_ tracks: [Track], initialIndex: Int? = nil, playWhenReady: Bool? = nil) {
|
|
570
|
-
handlePlayWhenReady(playWhenReady) {
|
|
571
|
-
let changed = queue.add(tracks, initialIndex: initialIndex ?? 0)
|
|
572
|
-
if changed { handleCurrentTrackChanged() }
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
func add(_ tracks: [Track], at index: Int) throws {
|
|
577
|
-
let changed = try queue.addAt(tracks, at: index)
|
|
578
|
-
if changed { handleCurrentTrackChanged() }
|
|
579
|
-
pushSkipAvailability() // didSet pushed pre-settlement state
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
func next() {
|
|
583
|
-
let result = queue.next()
|
|
584
|
-
switch result {
|
|
585
|
-
case .trackChanged: handleCurrentTrackChanged()
|
|
586
|
-
case .sameTrackReplay: if playWhenReady { replay() }
|
|
587
|
-
case .noChange: break
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
func previous() {
|
|
592
|
-
let result = queue.previous()
|
|
593
|
-
switch result {
|
|
594
|
-
case .trackChanged: handleCurrentTrackChanged()
|
|
595
|
-
case .sameTrackReplay: if playWhenReady { replay() }
|
|
596
|
-
case .noChange: break
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
func remove(_ index: Int) throws {
|
|
601
|
-
let changed = try queue.remove(index)
|
|
602
|
-
if changed { handleCurrentTrackChanged() }
|
|
603
|
-
pushSkipAvailability() // didSet pushed pre-settlement state
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
func skipTo(_ index: Int, playWhenReady: Bool? = nil) throws {
|
|
607
|
-
try handlePlayWhenReady(playWhenReady) {
|
|
608
|
-
if index == queue.currentIndex {
|
|
609
|
-
effectHandler?.seekToStart()
|
|
610
|
-
} else {
|
|
611
|
-
try queue.skipTo(index)
|
|
612
|
-
handleCurrentTrackChanged()
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
func move(fromIndex: Int, toIndex: Int) throws {
|
|
618
|
-
let changed = try queue.move(fromIndex: fromIndex, toIndex: toIndex)
|
|
619
|
-
if changed { handleCurrentTrackChanged() }
|
|
620
|
-
// tracks.didSet pushed availability mid-mutation, before the pointer and
|
|
621
|
-
// shuffle order settled — re-push the real post-mutation state.
|
|
622
|
-
pushSkipAvailability()
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
func removeUpcomingTracks() {
|
|
626
|
-
queue.removeUpcomingTracks()
|
|
627
|
-
pushSkipAvailability() // didSet pushed pre-settlement state
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
func replay() {
|
|
631
|
-
effectHandler?.replayCurrentTrack()
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
func clear() {
|
|
635
|
-
let changed = queue.clear()
|
|
636
|
-
if changed { handleCurrentTrackChanged() }
|
|
637
|
-
effectHandler?.unloadTrack()
|
|
638
|
-
effectHandler?.clearNowPlaying()
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// MARK: - Track Loading
|
|
642
|
-
|
|
643
|
-
func handleTrackDidPlayToEndTime() {
|
|
644
|
-
sleepTimerManager.onTrackPlayedToEnd()
|
|
645
|
-
|
|
646
|
-
if repeatMode == .track {
|
|
647
|
-
// The end-of-track sleep pause lands just above and clears the intent;
|
|
648
|
-
// without it, settle in .ended (where play() reloads) instead of
|
|
649
|
-
// replaying past the sleep timer.
|
|
650
|
-
if playWhenReady {
|
|
651
|
-
replay()
|
|
652
|
-
} else {
|
|
653
|
-
transition(.trackEndedNaturally)
|
|
654
|
-
}
|
|
655
|
-
} else if repeatMode == .queue || !isLastInPlaybackOrder {
|
|
656
|
-
next()
|
|
657
|
-
} else {
|
|
658
|
-
transition(.trackEndedNaturally)
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
func handleCurrentTrackChanged() {
|
|
663
|
-
sleepTimerManager.onTrackChanged()
|
|
664
|
-
|
|
665
|
-
effectHandler?.cancelMediaLoading()
|
|
666
|
-
loadSeekCoordinator.reset()
|
|
667
|
-
|
|
668
|
-
errorHandler.resetRetry()
|
|
669
|
-
|
|
670
|
-
if playbackError != nil {
|
|
671
|
-
playbackError = nil
|
|
672
|
-
// A retrying error can be cleared while already .loading — the
|
|
673
|
-
// .trackLoading transition below then won't fire, so emit the clear
|
|
674
|
-
// here or the old track's error lingers over the new one's spinner.
|
|
675
|
-
if isRetryingError, state == .loading {
|
|
676
|
-
isRetryingError = false
|
|
677
|
-
callbacks?.playerDidChangePlayback(Playback(state: state, error: nil))
|
|
678
|
-
}
|
|
679
|
-
isRetryingError = false
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
let lastPosition = effectHandler?.currentTime ?? 0
|
|
683
|
-
let shouldContinuePlayback = playWhenReady
|
|
684
|
-
if let currentTrack {
|
|
685
|
-
effectHandler?.stopObservingCurrentItem()
|
|
686
|
-
effectHandler?.pausePlayback()
|
|
687
|
-
|
|
688
|
-
// Set loading state before playWhenReady so the setter's guard
|
|
689
|
-
// prevents a no-op startPlayback() on the now-nil item.
|
|
690
|
-
transition(.trackLoading)
|
|
691
|
-
|
|
692
|
-
// Ensure playWhenReady is set before loading to preserve playback state
|
|
693
|
-
playWhenReady = shouldContinuePlayback
|
|
694
|
-
|
|
695
|
-
effectHandler?.loadNowPlayingMetadata(for: currentTrack)
|
|
696
|
-
|
|
697
|
-
// Validate source URL before handing off to MediaLoader
|
|
698
|
-
guard let src = currentTrack.src else {
|
|
699
|
-
logger.error("Failed to load track - no src")
|
|
700
|
-
logger.error(" track.title: \(currentTrack.title)")
|
|
701
|
-
logger.error(" track.path: \(currentTrack.path ?? "nil")")
|
|
702
|
-
effectHandler?.clearCurrentItem()
|
|
703
|
-
transition(.errorOccurred(.invalidSourceUrl("nil")))
|
|
704
|
-
return
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
logger.debug("Loading track: \(currentTrack.title)")
|
|
708
|
-
logger.debug(" track.path: \(currentTrack.path ?? "nil")")
|
|
709
|
-
logger.debug(" track.src: \(src)")
|
|
710
|
-
|
|
711
|
-
effectHandler?.loadTrack(src: src, track: currentTrack)
|
|
712
|
-
} else {
|
|
713
|
-
effectHandler?.unloadTrack()
|
|
714
|
-
effectHandler?.clearNowPlaying()
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
let eventData = PlaybackActiveTrackChangedEvent(
|
|
718
|
-
lastIndex: lastIndex == -1 ? nil : Double(lastIndex),
|
|
719
|
-
lastTrack: lastTrack,
|
|
720
|
-
lastPosition: lastPosition,
|
|
721
|
-
index: currentIndex == -1 ? nil : Double(currentIndex),
|
|
722
|
-
track: currentTrack,
|
|
723
|
-
)
|
|
724
|
-
callbacks?.playerDidChangeActiveTrack(eventData)
|
|
725
|
-
lastTrack = currentTrack
|
|
726
|
-
lastIndex = currentIndex
|
|
727
|
-
pushSkipAvailability() // position moved → next/previous availability may change
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// MARK: - Seek Completion (called by TrackPlayer after AVPlayer seek lands)
|
|
731
|
-
|
|
732
|
-
func handleSeekCompleted(to seconds: Double, didFinish: Bool) {
|
|
733
|
-
if state == .loading {
|
|
734
|
-
logger.debug("[loadSeek] seek landed at \(seconds)s (finished=\(didFinish)) → .ready")
|
|
735
|
-
transition(.loadSeekCompleted)
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// MARK: - Progress Updates
|
|
740
|
-
|
|
741
|
-
func setProgressUpdateInterval(_ interval: TimeInterval?) {
|
|
742
|
-
progressTimer.setInterval(interval)
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
func setPlaybackIntervalEnabled(_ enabled: Bool) {
|
|
746
|
-
intervalTimer.setInterval(enabled ? 1 : nil)
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// MARK: - QueueManagerDelegate
|
|
751
|
-
|
|
752
|
-
extension PlaybackCoordinator: QueueManagerDelegate {
|
|
753
|
-
func queueDidChangeTracks(_ tracks: [Track]) {
|
|
754
|
-
callbacks?.playerDidChangeQueue(tracks)
|
|
755
|
-
pushSkipAvailability() // queue contents changed → boundaries moved
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// MARK: - TrackSelectionPlayer
|
|
760
|
-
|
|
761
|
-
extension PlaybackCoordinator: TrackSelectionPlayer {}
|