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
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
import NitroModules
|
|
3
3
|
#endif
|
|
4
4
|
|
|
5
|
-
///
|
|
6
|
-
|
|
7
|
-
/// so HybridAudioBrowser implements both automatically.
|
|
8
|
-
@MainActor protocol PlaybackCoordinatorCallbacks: AnyObject {
|
|
5
|
+
/// What the Player reports, in Nitro types. HybridAudioBrowser implements it.
|
|
6
|
+
@MainActor protocol PlayerCallbacks: AnyObject {
|
|
9
7
|
func playerDidChangePlayback(_ playback: Playback)
|
|
10
8
|
func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent)
|
|
11
9
|
func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent)
|
|
@@ -21,4 +19,5 @@
|
|
|
21
19
|
/// (those resume), and no retry is pending (a live-stream reconnect must keep the session). The
|
|
22
20
|
/// host may release the audio session (debounced) so other apps' audio can resume.
|
|
23
21
|
func playerShouldReleaseSession()
|
|
22
|
+
func playerDidChangeSleepTimer(_ state: SleepTimerState?)
|
|
24
23
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
#if canImport(NitroModules)
|
|
4
|
+
import NitroModules
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
/// What the media stack reports to the Player.
|
|
8
|
+
enum EngineEvent {
|
|
9
|
+
/// The loaded item is now current and observed.
|
|
10
|
+
case itemPrepared(likelyToKeepUp: Bool)
|
|
11
|
+
/// Loading the asset failed; the retry loop classifies the error.
|
|
12
|
+
case loadFailed(Error)
|
|
13
|
+
/// The asset loaded but cannot play.
|
|
14
|
+
case unplayable
|
|
15
|
+
/// The source is not a URL.
|
|
16
|
+
case invalidSource(String)
|
|
17
|
+
/// The player or its item failed, with the HTTP status the server last answered, if any.
|
|
18
|
+
case failed(Error?, httpStatusCode: Int?)
|
|
19
|
+
case likelyToKeepUp(Bool)
|
|
20
|
+
/// `waitingForNoItem`: the player waits because it has no item to play.
|
|
21
|
+
case timeControlStatus(PlayerTimeControlStatus, waitingForNoItem: Bool)
|
|
22
|
+
/// Playback crossed the first millisecond.
|
|
23
|
+
case audioDidStart
|
|
24
|
+
case playedToEnd
|
|
25
|
+
/// The buffer emptied mid-playback.
|
|
26
|
+
case stalled
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// The media stack under the Player: the AVPlayer and its items, media loading, now playing and
|
|
30
|
+
/// the remote commands. It carries out what the Player decides and reports what it observes
|
|
31
|
+
/// through `onEvent`; it holds no playback policy. AVPlayerEngine is the one implementation;
|
|
32
|
+
/// tests use a mock.
|
|
33
|
+
@MainActor protocol PlayerEngine: AnyObject {
|
|
34
|
+
var onEvent: (@MainActor (EngineEvent) -> Void)? { get set }
|
|
35
|
+
|
|
36
|
+
// MARK: The current item
|
|
37
|
+
|
|
38
|
+
var hasItem: Bool { get }
|
|
39
|
+
var hasLoadedAsset: Bool { get }
|
|
40
|
+
var currentTime: Double { get }
|
|
41
|
+
var duration: Double { get }
|
|
42
|
+
var bufferedPosition: Double { get }
|
|
43
|
+
/// The position a reload resumes from: nil without an item, or for an indefinite duration.
|
|
44
|
+
var resumePosition: Double? { get }
|
|
45
|
+
/// The end of a live stream's seekable window, when it has one.
|
|
46
|
+
var liveEdge: Double? { get }
|
|
47
|
+
var isPlaybackLikelyToKeepUp: Bool { get }
|
|
48
|
+
var isAirPlayRoute: Bool { get }
|
|
49
|
+
/// 0–1; ramped by the sleep-timer fade.
|
|
50
|
+
var volume: Float { get set }
|
|
51
|
+
|
|
52
|
+
// MARK: Loading
|
|
53
|
+
|
|
54
|
+
/// Runs the media-URL resolver for `track`, so a short-lived URL is minted afresh, and loads
|
|
55
|
+
/// the result.
|
|
56
|
+
func resolveAndLoad(src: String, track: Track)
|
|
57
|
+
/// Loads the last resolved URL again.
|
|
58
|
+
func loadResolved()
|
|
59
|
+
func cancelLoading()
|
|
60
|
+
/// Drops the asset and stops observing the item, leaving it in place for the reload.
|
|
61
|
+
func dropAsset()
|
|
62
|
+
/// Drops the asset and removes the item.
|
|
63
|
+
func clearItem()
|
|
64
|
+
func stopObservingItem()
|
|
65
|
+
/// Replaces the AVPlayer: every handle to the old one is invalid.
|
|
66
|
+
func recreate()
|
|
67
|
+
|
|
68
|
+
// MARK: Transport
|
|
69
|
+
|
|
70
|
+
func play()
|
|
71
|
+
func pause()
|
|
72
|
+
/// Stages `rate` for the next play; applies it now only when `playing`, since a nonzero
|
|
73
|
+
/// AVPlayer rate is a play.
|
|
74
|
+
func setRate(_ rate: Float, playing: Bool)
|
|
75
|
+
/// Seeks the current item exactly; `completion` runs on the main actor.
|
|
76
|
+
func seek(to seconds: Double, completion: @escaping @MainActor (Bool) -> Void)
|
|
77
|
+
func applyTimePitchAlgorithm()
|
|
78
|
+
|
|
79
|
+
// MARK: Now playing and remote commands
|
|
80
|
+
|
|
81
|
+
/// Renders silence so iOS elects the app onto the now-playing surfaces (ADR 0005).
|
|
82
|
+
func primeNowPlaying()
|
|
83
|
+
func loadNowPlayingMetadata(for track: Track)
|
|
84
|
+
func clearNowPlaying()
|
|
85
|
+
func updateRemoteRepeatMode(_ mode: RepeatMode)
|
|
86
|
+
func updateRemoteShuffleMode(_ enabled: Bool)
|
|
87
|
+
/// Greys out next/previous on the remote surfaces when the queue has nowhere to skip.
|
|
88
|
+
func updateSkipAvailability(canNext: Bool, canPrevious: Bool)
|
|
89
|
+
}
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
/// Decouples
|
|
2
|
-
///
|
|
1
|
+
/// Decouples Player from AVFoundation enum types.
|
|
2
|
+
/// AVPlayerEngine maps from AVPlayer.TimeControlStatus → PlayerTimeControlStatus at the call boundary.
|
|
3
3
|
enum PlayerTimeControlStatus {
|
|
4
4
|
case paused, waitingToPlayAtSpecifiedRate, playing
|
|
5
5
|
}
|
|
6
|
-
|
|
7
|
-
enum PlayerItemStatus {
|
|
8
|
-
case unknown, readyToPlay, failed
|
|
9
|
-
}
|
|
@@ -3,9 +3,16 @@ import Foundation
|
|
|
3
3
|
import NitroModules
|
|
4
4
|
#endif
|
|
5
5
|
|
|
6
|
-
///
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/// What a queue mutation changed, reported once the queue has settled.
|
|
7
|
+
struct QueueChange: Equatable {
|
|
8
|
+
/// The track list changed.
|
|
9
|
+
var tracks = false
|
|
10
|
+
/// A different track became current, or the current track was cleared.
|
|
11
|
+
var currentTrack = false
|
|
12
|
+
|
|
13
|
+
static let none = QueueChange()
|
|
14
|
+
static let tracksOnly = QueueChange(tracks: true)
|
|
15
|
+
static let tracksAndCurrent = QueueChange(tracks: true, currentTrack: true)
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
/// Result of a navigation action (next/previous/skipTo).
|
|
@@ -19,11 +26,9 @@ enum QueueNavigationResult {
|
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
/// Pure queue logic: tracks, indices, shuffle, repeat.
|
|
22
|
-
/// Has no knowledge of AVPlayer or playback —
|
|
29
|
+
/// Has no knowledge of AVPlayer or playback — Player and its engine own that.
|
|
23
30
|
@MainActor
|
|
24
31
|
class QueueManager {
|
|
25
|
-
weak var delegate: QueueManagerDelegate?
|
|
26
|
-
|
|
27
32
|
// MARK: - Stored Properties
|
|
28
33
|
|
|
29
34
|
/// The index of the current track. `-1` when there is no current track.
|
|
@@ -33,11 +38,7 @@ class QueueManager {
|
|
|
33
38
|
private(set) var queueSourcePath: String?
|
|
34
39
|
|
|
35
40
|
/// All tracks held by the queue.
|
|
36
|
-
private(set) var tracks: [Track] = []
|
|
37
|
-
didSet {
|
|
38
|
-
delegate?.queueDidChangeTracks(tracks)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
+
private(set) var tracks: [Track] = []
|
|
41
42
|
|
|
42
43
|
/// The shuffle order for randomized playback.
|
|
43
44
|
private(set) var shuffleOrder = ShuffleOrder()
|
|
@@ -203,38 +204,36 @@ class QueueManager {
|
|
|
203
204
|
return .trackChanged
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
// MARK: - Mutations
|
|
207
|
+
// MARK: - Mutations
|
|
207
208
|
|
|
208
|
-
/// Replace the entire queue with new tracks.
|
|
209
|
-
/// Returns `true` (current track always changes).
|
|
209
|
+
/// Replace the entire queue with new tracks. The current track always changes.
|
|
210
210
|
@discardableResult
|
|
211
|
-
func setQueue(_ newTracks: [Track], initialIndex: Int = 0, sourcePath: String? = nil) ->
|
|
211
|
+
func setQueue(_ newTracks: [Track], initialIndex: Int = 0, sourcePath: String? = nil) -> QueueChange {
|
|
212
212
|
let clampedIndex = max(0, min(initialIndex, newTracks.count - 1))
|
|
213
213
|
currentIndex = clampedIndex
|
|
214
214
|
queueSourcePath = sourcePath
|
|
215
215
|
tracks = newTracks
|
|
216
216
|
shuffleOrder = ShuffleOrder(length: newTracks.count, firstIndex: clampedIndex)
|
|
217
|
-
return
|
|
217
|
+
return .tracksAndCurrent
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
/// Add tracks to the end of the queue.
|
|
221
|
-
/// Returns `true` if the queue was empty (a new current track was set).
|
|
220
|
+
/// Add tracks to the end of the queue. The current track changes only when the queue was empty.
|
|
222
221
|
@discardableResult
|
|
223
|
-
func add(_ newTracks: [Track], initialIndex: Int = 0) ->
|
|
224
|
-
guard !newTracks.isEmpty else { return
|
|
222
|
+
func add(_ newTracks: [Track], initialIndex: Int = 0) -> QueueChange {
|
|
223
|
+
guard !newTracks.isEmpty else { return .none }
|
|
225
224
|
let wasEmpty = tracks.isEmpty
|
|
226
225
|
let insertIndex = tracks.count
|
|
227
226
|
tracks.append(contentsOf: newTracks)
|
|
228
227
|
if wasEmpty { return adoptInitialCurrent(initialIndex) }
|
|
229
228
|
shuffleOrder.insert(at: insertIndex, count: newTracks.count)
|
|
230
|
-
return
|
|
229
|
+
return .tracksOnly
|
|
231
230
|
}
|
|
232
231
|
|
|
233
|
-
/// Add tracks at a specific index in the queue.
|
|
234
|
-
///
|
|
232
|
+
/// Add tracks at a specific index in the queue. The current track changes only when the queue
|
|
233
|
+
/// was empty.
|
|
235
234
|
@discardableResult
|
|
236
|
-
func addAt(_ newTracks: [Track], at index: Int) throws ->
|
|
237
|
-
guard !newTracks.isEmpty else { return
|
|
235
|
+
func addAt(_ newTracks: [Track], at index: Int) throws -> QueueChange {
|
|
236
|
+
guard !newTracks.isEmpty else { return .none }
|
|
238
237
|
guard index >= 0, tracks.count >= index else {
|
|
239
238
|
throw TrackPlayerError.QueueError.invalidIndex(
|
|
240
239
|
index: index,
|
|
@@ -249,7 +248,7 @@ class QueueManager {
|
|
|
249
248
|
tracks.insert(contentsOf: newTracks, at: index)
|
|
250
249
|
if wasEmpty { return adoptInitialCurrent(0) }
|
|
251
250
|
shuffleOrder.insert(at: index, count: newTracks.count)
|
|
252
|
-
return
|
|
251
|
+
return .tracksOnly
|
|
253
252
|
}
|
|
254
253
|
|
|
255
254
|
/// The queue was empty: `index` becomes the current track AND leads the
|
|
@@ -264,44 +263,39 @@ class QueueManager {
|
|
|
264
263
|
/// shuffle is on right now, since `shuffleEnabled`'s didSet only re-pins on
|
|
265
264
|
/// the off→on edge.
|
|
266
265
|
///
|
|
267
|
-
/// Returns
|
|
268
|
-
|
|
269
|
-
private func adoptInitialCurrent(_ index: Int) -> Bool {
|
|
266
|
+
/// Returns the change, so callers can `return adoptInitialCurrent(…)`.
|
|
267
|
+
private func adoptInitialCurrent(_ index: Int) -> QueueChange {
|
|
270
268
|
currentIndex = max(0, min(index, tracks.count - 1))
|
|
271
269
|
shuffleOrder = ShuffleOrder(length: tracks.count, firstIndex: currentIndex)
|
|
272
|
-
return
|
|
270
|
+
return .tracksAndCurrent
|
|
273
271
|
}
|
|
274
272
|
|
|
275
|
-
/// Remove a track from the queue.
|
|
276
|
-
/// Returns `true` if the current track changed.
|
|
273
|
+
/// Remove a track from the queue. The current track changes when it is the one removed.
|
|
277
274
|
@discardableResult
|
|
278
|
-
func remove(_ index: Int) throws ->
|
|
275
|
+
func remove(_ index: Int) throws -> QueueChange {
|
|
279
276
|
try throwIfQueueEmpty()
|
|
280
277
|
try throwIfIndexInvalid(index: index)
|
|
281
278
|
tracks.remove(at: index)
|
|
282
279
|
shuffleOrder.remove(from: index, to: index + 1)
|
|
283
280
|
if index == currentIndex {
|
|
284
281
|
currentIndex = tracks.count > 0 ? currentIndex % tracks.count : -1
|
|
285
|
-
return
|
|
282
|
+
return .tracksAndCurrent
|
|
286
283
|
} else if index < currentIndex {
|
|
287
284
|
currentIndex -= 1
|
|
288
285
|
}
|
|
289
|
-
return
|
|
286
|
+
return .tracksOnly
|
|
290
287
|
}
|
|
291
288
|
|
|
292
|
-
/// Move a track in the queue from one position to another.
|
|
293
|
-
///
|
|
289
|
+
/// Move a track in the queue from one position to another. The current track never changes:
|
|
290
|
+
/// the pointer follows the playing track.
|
|
294
291
|
@discardableResult
|
|
295
|
-
func move(fromIndex: Int, toIndex: Int) throws ->
|
|
292
|
+
func move(fromIndex: Int, toIndex: Int) throws -> QueueChange {
|
|
296
293
|
try throwIfQueueEmpty()
|
|
297
294
|
try throwIfIndexInvalid(index: fromIndex, name: "fromIndex")
|
|
298
295
|
try throwIfIndexInvalid(index: toIndex, name: "toIndex", max: Int.max)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
let insertion = min(newTracks.count, toIndex)
|
|
303
|
-
newTracks.insert(track, at: insertion)
|
|
304
|
-
tracks = newTracks
|
|
296
|
+
let track = tracks.remove(at: fromIndex)
|
|
297
|
+
let insertion = min(tracks.count, toIndex)
|
|
298
|
+
tracks.insert(track, at: insertion)
|
|
305
299
|
// Carry the moved track's shuffle position across the move. `insert(at:count:)`
|
|
306
300
|
// would drop it at a RANDOM position instead — reordering the queue would then
|
|
307
301
|
// silently reorder shuffled playback, and moving the playing track could land it
|
|
@@ -314,8 +308,6 @@ class QueueManager {
|
|
|
314
308
|
shuffleOrder.insert(
|
|
315
309
|
at: insertion, atShufflePosition: shufflePosition ?? shuffleOrder.count,
|
|
316
310
|
)
|
|
317
|
-
// The pointer follows the playing track; its identity never changes on a
|
|
318
|
-
// move, so no caller needs to reload (returning true reloads).
|
|
319
311
|
if currentIndex == fromIndex {
|
|
320
312
|
currentIndex = insertion
|
|
321
313
|
} else if fromIndex < currentIndex, insertion >= currentIndex {
|
|
@@ -323,37 +315,38 @@ class QueueManager {
|
|
|
323
315
|
} else if fromIndex > currentIndex, insertion <= currentIndex {
|
|
324
316
|
currentIndex += 1
|
|
325
317
|
}
|
|
326
|
-
return
|
|
318
|
+
return .tracksOnly
|
|
327
319
|
}
|
|
328
320
|
|
|
329
|
-
/// Remove all tracks and reset state.
|
|
330
|
-
/// Returns `true` if there was a current track to clear.
|
|
321
|
+
/// Remove all tracks and reset state. Does nothing when there is no current track.
|
|
331
322
|
@discardableResult
|
|
332
|
-
func clear() ->
|
|
333
|
-
guard currentIndex != -1 else { return
|
|
323
|
+
func clear() -> QueueChange {
|
|
324
|
+
guard currentIndex != -1 else { return .none }
|
|
334
325
|
currentIndex = -1
|
|
335
326
|
tracks.removeAll()
|
|
336
327
|
shuffleOrder.clear()
|
|
337
328
|
queueSourcePath = nil
|
|
338
|
-
return
|
|
329
|
+
return .tracksAndCurrent
|
|
339
330
|
}
|
|
340
331
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
/// Replace the track at a specific index.
|
|
332
|
+
/// Replace the track at a specific index; which index is current does not change.
|
|
344
333
|
/// Throws like every other index-taking mutation here, rather than trapping.
|
|
345
|
-
|
|
334
|
+
@discardableResult
|
|
335
|
+
func replace(_ index: Int, _ track: Track) throws -> QueueChange {
|
|
346
336
|
try throwIfIndexInvalid(index: index)
|
|
347
337
|
tracks[index] = track
|
|
338
|
+
return .tracksOnly
|
|
348
339
|
}
|
|
349
340
|
|
|
350
341
|
/// Remove all upcoming tracks (those after currentIndex).
|
|
351
|
-
|
|
352
|
-
|
|
342
|
+
@discardableResult
|
|
343
|
+
func removeUpcomingTracks() -> QueueChange {
|
|
344
|
+
guard !tracks.isEmpty else { return .none }
|
|
353
345
|
let nextIndex = currentIndex + 1
|
|
354
|
-
guard nextIndex < tracks.count else { return }
|
|
346
|
+
guard nextIndex < tracks.count else { return .none }
|
|
355
347
|
let end = tracks.count
|
|
356
348
|
tracks.removeSubrange(nextIndex ..< end)
|
|
357
349
|
shuffleOrder.remove(from: nextIndex, to: end)
|
|
350
|
+
return .tracksOnly
|
|
358
351
|
}
|
|
359
352
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
#if canImport(NitroModules)
|
|
4
|
+
import NitroModules
|
|
5
|
+
#endif
|
|
6
|
+
import os.log
|
|
7
|
+
|
|
8
|
+
/// Abstracts `NetworkMonitor` so tests can fake connectivity. Sendable because
|
|
9
|
+
/// the network-restore race reads it from a task-group child.
|
|
10
|
+
protocol NetworkStatusProviding: AnyObject, Sendable {
|
|
11
|
+
var isOnline: Bool { get }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// Distinguishes playback failures from media-load failures.
|
|
15
|
+
/// Each context carries the retry strategy and the fallback error classification.
|
|
16
|
+
public enum PlaybackErrorContext {
|
|
17
|
+
/// AVPlayer/AVPlayerItem status failure — retry from current time, fallback `.playbackFailed`.
|
|
18
|
+
case playback
|
|
19
|
+
/// MediaLoader key-value load failure — retry from start, fallback `.failedToLoadKeyValue`.
|
|
20
|
+
case mediaLoad
|
|
21
|
+
|
|
22
|
+
var startFromCurrentTime: Bool {
|
|
23
|
+
switch self {
|
|
24
|
+
case .playback: true
|
|
25
|
+
case .mediaLoad: false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var fallbackError: TrackPlayerError.PlaybackError {
|
|
30
|
+
switch self {
|
|
31
|
+
case .playback: .playbackFailed
|
|
32
|
+
case .mediaLoad: .failedToLoadKeyValue
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Retries failed loads: classifies each failure (`PlaybackFailure`), reports the ones it is
|
|
38
|
+
/// working on as advisory, and reloads after an exponential backoff until a budget runs out,
|
|
39
|
+
/// when it reports the error as terminal. Similar to Android's RetryLoadErrorHandlingPolicy.
|
|
40
|
+
///
|
|
41
|
+
/// Two duration budgets apply, chosen by whether the current Load has produced audio
|
|
42
|
+
/// (`hasPlayed`, ADR 0004): a short first-connect budget, since a stream that fails before ever
|
|
43
|
+
/// playing is usually dead and the listener is waiting for a verdict, and the full recovery
|
|
44
|
+
/// budget for a stream that played and dropped. The short budget counts only online time, and
|
|
45
|
+
/// while offline a scheduled retry fires as soon as connectivity returns.
|
|
46
|
+
@MainActor
|
|
47
|
+
final class RetryLoop {
|
|
48
|
+
private let logger = Logger(subsystem: "com.audiobrowser", category: "RetryLoop")
|
|
49
|
+
|
|
50
|
+
enum Policy {
|
|
51
|
+
case disabled
|
|
52
|
+
case infinite
|
|
53
|
+
case limited(maxRetries: Int)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private static let defaultMaxRetryDurationMs: Double = 120_000
|
|
57
|
+
private static let defaultFirstConnectMaxRetryDurationMs: Double = 12_000
|
|
58
|
+
|
|
59
|
+
/// Prevents surprising playback resumption after long periods offline.
|
|
60
|
+
private var maxRetryDuration: TimeInterval = defaultMaxRetryDurationMs / 1000
|
|
61
|
+
/// Bounds retries of a load that has never produced audio, while online.
|
|
62
|
+
private var firstConnectMaxRetryDuration: TimeInterval = defaultFirstConnectMaxRetryDurationMs / 1000
|
|
63
|
+
|
|
64
|
+
/// Whether the current Load has produced audio. Read from the Load, which a mid-play `reset()`
|
|
65
|
+
/// (the healthy-playback refill) leaves alone.
|
|
66
|
+
var hasPlayed: () -> Bool = { false }
|
|
67
|
+
/// Whether a retry is still wanted (the play intent); checked before and after the backoff.
|
|
68
|
+
var shouldRetry: () -> Bool = { true }
|
|
69
|
+
/// Reloads, from the current time or from the start.
|
|
70
|
+
var onRetry: ((Bool) -> Void)?
|
|
71
|
+
/// The retries gave up, or the failure cannot be retried.
|
|
72
|
+
var onError: ((TrackPlayerError.PlaybackError) -> Void)?
|
|
73
|
+
/// A failure a retry is now scheduled for, reported before the backoff so UIs can show the
|
|
74
|
+
/// cause while the state stays non-terminal.
|
|
75
|
+
var onRetryingError: ((TrackPlayerError.PlaybackError) -> Void)?
|
|
76
|
+
|
|
77
|
+
weak var networkMonitor: (any NetworkStatusProviding)?
|
|
78
|
+
|
|
79
|
+
/// The retry in flight; session release waits while there is one.
|
|
80
|
+
private(set) var pendingTask: Task<Void, Never>?
|
|
81
|
+
/// True only while a scheduled retry waits for connectivity to return, so a stall-driven
|
|
82
|
+
/// reconnect can defer to it rather than load twice.
|
|
83
|
+
private(set) var isWaitingForNetwork = false
|
|
84
|
+
/// Retries executed since the budgets last reset.
|
|
85
|
+
private(set) var attempts = 0
|
|
86
|
+
|
|
87
|
+
private var policy: Policy = .disabled
|
|
88
|
+
private var firstRetryTime: Date?
|
|
89
|
+
/// First failure observed while online for the current load: the first-connect budget's
|
|
90
|
+
/// clock. Never set while offline, and wiped by any offline observation, so an offline stretch
|
|
91
|
+
/// cannot burn the short budget.
|
|
92
|
+
private var firstOnlineRetryTime: Date?
|
|
93
|
+
|
|
94
|
+
private let sleep: @Sendable (TimeInterval) async throws -> Void
|
|
95
|
+
|
|
96
|
+
/// - Parameter sleep: Waits out a backoff or a connectivity poll; tests make it instant.
|
|
97
|
+
init(sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) {
|
|
98
|
+
self.sleep = sleep
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var isEnabled: Bool {
|
|
102
|
+
if case .disabled = policy { return false }
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: - Configuration
|
|
107
|
+
|
|
108
|
+
func updatePolicy(from config: Variant_Bool_RetryConfig?) {
|
|
109
|
+
maxRetryDuration = Self.defaultMaxRetryDurationMs / 1000
|
|
110
|
+
firstConnectMaxRetryDuration = Self.defaultFirstConnectMaxRetryDurationMs / 1000
|
|
111
|
+
switch config {
|
|
112
|
+
case nil:
|
|
113
|
+
policy = .disabled
|
|
114
|
+
case let .first(enabled):
|
|
115
|
+
policy = enabled ? .infinite : .disabled
|
|
116
|
+
case let .second(retryConfig):
|
|
117
|
+
maxRetryDuration = (retryConfig.maxRetryDurationMs ?? Self.defaultMaxRetryDurationMs) / 1000
|
|
118
|
+
firstConnectMaxRetryDuration =
|
|
119
|
+
(retryConfig.firstConnectMaxRetryDurationMs ?? Self.defaultFirstConnectMaxRetryDurationMs) / 1000
|
|
120
|
+
// No attempt cap: retry indefinitely, bounded only by the duration.
|
|
121
|
+
policy = retryConfig.maxRetries.map { .limited(maxRetries: Int($0)) } ?? .infinite
|
|
122
|
+
}
|
|
123
|
+
logger.debug("Retry policy: \(String(describing: self.policy)), max duration \(self.maxRetryDuration)s")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: - Failures
|
|
127
|
+
|
|
128
|
+
func handleError(_ error: Error?, context: PlaybackErrorContext, httpStatusCode: Int? = nil) {
|
|
129
|
+
if let error {
|
|
130
|
+
let nsError = error as NSError
|
|
131
|
+
logger.error("[\(String(describing: context))] failure: domain=\(nsError.domain), code=\(nsError.code), localizedDescription=\(error.localizedDescription)")
|
|
132
|
+
} else {
|
|
133
|
+
logger.error("[\(String(describing: context))] failure with nil error")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let failure = PlaybackFailure.classify(error, httpStatusCode: httpStatusCode, fallback: context.fallbackError)
|
|
137
|
+
guard isEnabled, failure.retryable else {
|
|
138
|
+
logger.warning("Error not retryable, surfacing \(String(describing: failure.error))")
|
|
139
|
+
onError?(failure.error)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
onRetryingError?(failure.error)
|
|
143
|
+
pendingTask?.cancel()
|
|
144
|
+
pendingTask = Task { [weak self] in
|
|
145
|
+
guard let self else { return }
|
|
146
|
+
let retried = await attemptRetry(startFromCurrentTime: context.startFromCurrentTime)
|
|
147
|
+
// Cancelled (track change, stop): stay silent, or the new track or a deliberate stop
|
|
148
|
+
// would flip to .error. The canceller owns the handle.
|
|
149
|
+
if Task.isCancelled { return }
|
|
150
|
+
if !retried { onError?(failure.error) }
|
|
151
|
+
pendingTask = nil
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// Drops the retry in flight without touching the budgets.
|
|
156
|
+
func cancel() {
|
|
157
|
+
pendingTask?.cancel()
|
|
158
|
+
pendingTask = nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Drops the retry in flight and restarts the budgets.
|
|
162
|
+
func reset() {
|
|
163
|
+
cancel()
|
|
164
|
+
attempts = 0
|
|
165
|
+
firstRetryTime = nil
|
|
166
|
+
firstOnlineRetryTime = nil
|
|
167
|
+
isWaitingForNetwork = false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// MARK: - Budgets
|
|
171
|
+
|
|
172
|
+
private var isOnline: Bool { networkMonitor?.isOnline ?? true }
|
|
173
|
+
|
|
174
|
+
/// The exhausted budget, or nil while budget remains. The recovery duration bounds everything,
|
|
175
|
+
/// offline waits included; the first-connect duration also bounds never-played loads, online
|
|
176
|
+
/// only.
|
|
177
|
+
private func exhaustedBudget() -> String? {
|
|
178
|
+
let now = Date()
|
|
179
|
+
if let start = firstRetryTime, now.timeIntervalSince(start) >= maxRetryDuration {
|
|
180
|
+
return "max retry duration (\(maxRetryDuration)s)"
|
|
181
|
+
}
|
|
182
|
+
if !hasPlayed(), isOnline, let start = firstOnlineRetryTime,
|
|
183
|
+
now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
|
|
184
|
+
{
|
|
185
|
+
return "first-connect retry duration (\(firstConnectMaxRetryDuration)s)"
|
|
186
|
+
}
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// 1 s, 1.5 s, 2.3 s, 3.4 s, then 5 s.
|
|
191
|
+
private var backoff: TimeInterval {
|
|
192
|
+
min(1.0 * pow(1.5, Double(attempts)), 5.0)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Waits out the backoff, or connectivity's return, then reloads. False when the policy, the
|
|
196
|
+
/// play intent or a budget says stop, or the wait was cancelled.
|
|
197
|
+
func attemptRetry(startFromCurrentTime: Bool) async -> Bool {
|
|
198
|
+
guard isEnabled, shouldRetry() else { return false }
|
|
199
|
+
if case let .limited(maxRetries) = policy, attempts >= maxRetries {
|
|
200
|
+
logger.info("Max retries (\(maxRetries)) exceeded, giving up")
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if firstRetryTime == nil { firstRetryTime = Date() }
|
|
205
|
+
if isOnline {
|
|
206
|
+
if firstOnlineRetryTime == nil { firstOnlineRetryTime = Date() }
|
|
207
|
+
} else {
|
|
208
|
+
// Offline restarts the first-connect clock: the station gets its full online seconds
|
|
209
|
+
// again once connectivity returns.
|
|
210
|
+
firstOnlineRetryTime = nil
|
|
211
|
+
}
|
|
212
|
+
if let budget = exhaustedBudget() {
|
|
213
|
+
logger.info("\(budget) exceeded, giving up")
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
logger.info("Scheduling retry after \(self.backoff)s")
|
|
218
|
+
guard await waitForDelayOrNetworkRestored(delaySeconds: backoff), !Task.isCancelled, shouldRetry() else {
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
// Re-checked: the wait for connectivity may have been long.
|
|
222
|
+
if let budget = exhaustedBudget() {
|
|
223
|
+
logger.info("\(budget) exceeded after waiting, giving up")
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
attempts += 1
|
|
228
|
+
logger.info("Executing retry #\(self.attempts)")
|
|
229
|
+
onRetry?(startFromCurrentTime)
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// MARK: - Network-Aware Waiting
|
|
234
|
+
|
|
235
|
+
/// True when it is time to retry, false when the wait was cancelled.
|
|
236
|
+
private func waitForDelayOrNetworkRestored(delaySeconds: TimeInterval) async -> Bool {
|
|
237
|
+
guard let monitor = networkMonitor, !monitor.isOnline else {
|
|
238
|
+
return await (try? sleep(delaySeconds)) != nil
|
|
239
|
+
}
|
|
240
|
+
logger.debug("Device is offline, will retry immediately when connectivity is restored")
|
|
241
|
+
let sleep = sleep
|
|
242
|
+
return await withTaskGroup(of: Bool.self, returning: Bool.self) { group in
|
|
243
|
+
group.addTask { await (try? sleep(delaySeconds)) != nil }
|
|
244
|
+
group.addTask { [weak self] in await self?.waitForNetworkRestored(monitor: monitor) ?? false }
|
|
245
|
+
let result = await group.next() ?? false
|
|
246
|
+
group.cancelAll()
|
|
247
|
+
return result
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// True when connectivity returned, false when cancelled.
|
|
252
|
+
private func waitForNetworkRestored(monitor: any NetworkStatusProviding) async -> Bool {
|
|
253
|
+
isWaitingForNetwork = true
|
|
254
|
+
defer { isWaitingForNetwork = false }
|
|
255
|
+
while !monitor.isOnline {
|
|
256
|
+
guard await (try? sleep(0.5)) != nil else { return false }
|
|
257
|
+
}
|
|
258
|
+
logger.info("Network restored, accelerating retry")
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -40,7 +40,7 @@ struct MainQueueSleepTimerScheduler: SleepTimerScheduling {
|
|
|
40
40
|
/// Manages sleep timer functionality for the audio player.
|
|
41
41
|
/// Supports both time-based timers and end-of-track timers.
|
|
42
42
|
@MainActor
|
|
43
|
-
class SleepTimerManager
|
|
43
|
+
final class SleepTimerManager {
|
|
44
44
|
// MARK: - Properties
|
|
45
45
|
|
|
46
46
|
private let scheduler: any SleepTimerScheduling
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
/// The callbacks `RemoteCommandController` invokes when the system delivers a
|
|
6
6
|
/// remote command (lock screen, Control Center, CarPlay, headset).
|
|
7
7
|
///
|
|
8
|
-
///
|
|
9
|
-
///
|
|
10
|
-
/// extends this protocol, so conformers implement both at once.
|
|
8
|
+
/// Its own protocol so the controller depends only on what it calls.
|
|
9
|
+
/// `AVPlayerEngineCallbacks` extends it, so conformers implement both at once.
|
|
11
10
|
@MainActor protocol RemoteCommandCallbacks: AnyObject {
|
|
12
11
|
/// Called when play is triggered remotely.
|
|
13
12
|
func remotePlay()
|