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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A seek that arrives while an item loads. AVPlayer ignores `seek(to:)` without a loaded item,
|
|
4
|
+
/// so the target is held until the item is ready, and the ready transition waits until it lands.
|
|
5
|
+
/// Pure state: the caller issues the seek this hands out and reports when it lands.
|
|
6
|
+
struct DeferredSeek {
|
|
7
|
+
enum State: Equatable {
|
|
8
|
+
case idle
|
|
9
|
+
case pending(TimeInterval)
|
|
10
|
+
case inFlight(TimeInterval)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// What to do once an issued seek lands.
|
|
14
|
+
enum Landing: Equatable {
|
|
15
|
+
/// The target is reached: the load may become ready.
|
|
16
|
+
case done
|
|
17
|
+
/// A newer target arrived meanwhile: issue it.
|
|
18
|
+
case reseek(TimeInterval)
|
|
19
|
+
/// No deferred seek was in flight.
|
|
20
|
+
case none
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private(set) var state: State = .idle
|
|
24
|
+
|
|
25
|
+
/// Identifies the item a seek was issued against. `reset()` bumps it, so the completion of a
|
|
26
|
+
/// seek against the previous item can tell it is stale.
|
|
27
|
+
private(set) var generation = 0
|
|
28
|
+
|
|
29
|
+
/// The target, pending or in flight; `seekBy` offsets from it.
|
|
30
|
+
var target: TimeInterval? {
|
|
31
|
+
switch state {
|
|
32
|
+
case let .pending(time), let .inFlight(time): time
|
|
33
|
+
case .idle: nil
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
var defersReady: Bool {
|
|
38
|
+
state != .idle
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Holds `position` as the target, replacing a pending one or one in flight.
|
|
42
|
+
mutating func capture(_ position: TimeInterval) {
|
|
43
|
+
state = .pending(position)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Hands out the pending target to issue, marking it in flight; nil when none is pending.
|
|
47
|
+
mutating func begin() -> TimeInterval? {
|
|
48
|
+
guard case let .pending(time) = state else { return nil }
|
|
49
|
+
state = .inFlight(time)
|
|
50
|
+
return time
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
mutating func land() -> Landing {
|
|
54
|
+
switch state {
|
|
55
|
+
case .inFlight:
|
|
56
|
+
state = .idle
|
|
57
|
+
return .done
|
|
58
|
+
case .pending:
|
|
59
|
+
guard let time = begin() else { return .none }
|
|
60
|
+
return .reseek(time)
|
|
61
|
+
case .idle:
|
|
62
|
+
return .none
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mutating func reset() {
|
|
67
|
+
state = .idle
|
|
68
|
+
generation += 1
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#if canImport(NitroModules)
|
|
2
|
+
import NitroModules
|
|
3
|
+
#endif
|
|
4
|
+
|
|
5
|
+
/// The player's error: none, one the retry loop is still working on (shown over a non-terminal
|
|
6
|
+
/// state, an Advisory error), or terminal (the `.error` state).
|
|
7
|
+
enum ErrorStatus: Equatable {
|
|
8
|
+
case none
|
|
9
|
+
case retrying(TrackPlayerError.PlaybackError)
|
|
10
|
+
case terminal(TrackPlayerError.PlaybackError)
|
|
11
|
+
|
|
12
|
+
var error: TrackPlayerError.PlaybackError? {
|
|
13
|
+
switch self {
|
|
14
|
+
case .none: nil
|
|
15
|
+
case let .retrying(error), let .terminal(error): error
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
var isRetrying: Bool {
|
|
20
|
+
if case .retrying = self { true } else { false }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// The JS-facing error, carrying the retrying flag.
|
|
24
|
+
var nitroError: PlaybackError? {
|
|
25
|
+
error?.toNitroError(retrying: isRetrying)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -17,13 +17,26 @@ final class MediaLoader {
|
|
|
17
17
|
/// consumer-supplied `media.resolve(track)` as the final media layer.
|
|
18
18
|
var mediaUrlResolver: ((String, Track?) async -> MediaResolvedUrl)?
|
|
19
19
|
|
|
20
|
-
/// Internal access so
|
|
20
|
+
/// Internal access so AVPlayerEngine can read it.
|
|
21
21
|
private(set) var asset: AVURLAsset?
|
|
22
22
|
|
|
23
23
|
/// Milliseconds; applied to AVPlayerItem's preferredForwardBufferDuration.
|
|
24
24
|
var bufferDuration: Double = 0
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
enum Event {
|
|
27
|
+
case prepared(AVPlayerItem)
|
|
28
|
+
/// Loading failed; the retry loop decides whether another attempt could help.
|
|
29
|
+
case failed(Error)
|
|
30
|
+
/// The asset loaded but cannot play.
|
|
31
|
+
case unplayable
|
|
32
|
+
/// The source is not a URL, so no asset exists.
|
|
33
|
+
case invalidSource(String)
|
|
34
|
+
case commonMetadata([AVMetadataItem])
|
|
35
|
+
case chapterMetadata([AVTimedMetadataGroup])
|
|
36
|
+
case timedMetadata([AVTimedMetadataGroup])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var onEvent: (@MainActor (Event) -> Void)?
|
|
27
40
|
|
|
28
41
|
/// The in-flight resolve. Readable so tests can await the resolve-then-load
|
|
29
42
|
/// chain instead of sleeping past it; only this type starts and cancels it.
|
|
@@ -89,7 +102,7 @@ final class MediaLoader {
|
|
|
89
102
|
guard let url else {
|
|
90
103
|
// Callers (reload-from-terminal) have already transitioned to .loading;
|
|
91
104
|
// a silent return would strand that state with no load in flight.
|
|
92
|
-
|
|
105
|
+
onEvent?(.invalidSource("nil"))
|
|
93
106
|
return
|
|
94
107
|
}
|
|
95
108
|
let pendingAsset = AVURLAsset(url: url, options: urlOptions)
|
|
@@ -108,7 +121,7 @@ final class MediaLoader {
|
|
|
108
121
|
guard isCurrent(pendingAsset) else { return }
|
|
109
122
|
|
|
110
123
|
if !commonMetadata.isEmpty {
|
|
111
|
-
|
|
124
|
+
onEvent?(.commonMetadata(commonMetadata))
|
|
112
125
|
}
|
|
113
126
|
|
|
114
127
|
if !chapterLocales.isEmpty {
|
|
@@ -119,7 +132,7 @@ final class MediaLoader {
|
|
|
119
132
|
containingItemsWithCommonKeys: [],
|
|
120
133
|
) {
|
|
121
134
|
guard isCurrent(pendingAsset) else { return }
|
|
122
|
-
|
|
135
|
+
onEvent?(.chapterMetadata(chapters))
|
|
123
136
|
}
|
|
124
137
|
}
|
|
125
138
|
} else {
|
|
@@ -133,7 +146,7 @@ final class MediaLoader {
|
|
|
133
146
|
end: duration,
|
|
134
147
|
)
|
|
135
148
|
let group = AVTimedMetadataGroup(items: metadata, timeRange: timeRange)
|
|
136
|
-
|
|
149
|
+
onEvent?(.timedMetadata([group]))
|
|
137
150
|
}
|
|
138
151
|
}
|
|
139
152
|
}
|
|
@@ -151,20 +164,20 @@ final class MediaLoader {
|
|
|
151
164
|
guard pendingAsset == self.asset else { return }
|
|
152
165
|
|
|
153
166
|
if !isPlayable {
|
|
154
|
-
self.
|
|
167
|
+
self.onEvent?(.unplayable)
|
|
155
168
|
return
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
let avItem = AVPlayerItem(asset: pendingAsset)
|
|
159
172
|
avItem.preferredForwardBufferDuration = self.bufferDuration / 1000.0
|
|
160
|
-
self.
|
|
173
|
+
self.onEvent?(.prepared(avItem))
|
|
161
174
|
}
|
|
162
175
|
} catch {
|
|
163
176
|
guard !Task.isCancelled else { return }
|
|
164
177
|
|
|
165
178
|
await MainActor.run {
|
|
166
179
|
guard pendingAsset == self.asset else { return }
|
|
167
|
-
self.
|
|
180
|
+
self.onEvent?(.failed(error))
|
|
168
181
|
}
|
|
169
182
|
}
|
|
170
183
|
}
|
|
@@ -207,7 +220,7 @@ final class MediaLoader {
|
|
|
207
220
|
|
|
208
221
|
/// Builds AVURLAsset options, merging any explicit headers with a resolved
|
|
209
222
|
/// User-Agent. An explicit `User-Agent` in `headers` wins (mirrors the artwork
|
|
210
|
-
/// path in
|
|
223
|
+
/// path in RequestPipeline). A nil/empty userAgent contributes
|
|
211
224
|
/// nothing, so "no UA configured" still falls through to AVPlayer's default.
|
|
212
225
|
nonisolated static func buildAssetOptions(
|
|
213
226
|
headers: [String: String]?,
|
|
@@ -225,7 +238,7 @@ final class MediaLoader {
|
|
|
225
238
|
private func loadWithResolvedUrl(_ resolved: MediaResolvedUrl) {
|
|
226
239
|
guard let mediaUrl = URL(string: resolved.url) else {
|
|
227
240
|
logger.error("Invalid media URL: \(resolved.url)")
|
|
228
|
-
|
|
241
|
+
onEvent?(.invalidSource(resolved.url))
|
|
229
242
|
return
|
|
230
243
|
}
|
|
231
244
|
|
|
@@ -3,46 +3,118 @@ import MediaPlayer
|
|
|
3
3
|
import NitroModules
|
|
4
4
|
import os.log
|
|
5
5
|
|
|
6
|
+
/// What the now-playing surface renders from: the player's track and state.
|
|
7
|
+
@MainActor
|
|
8
|
+
protocol NowPlayingSource: AnyObject {
|
|
9
|
+
var currentTrack: Track? { get }
|
|
10
|
+
var currentTime: Double { get }
|
|
11
|
+
var playWhenReady: Bool { get }
|
|
12
|
+
var isStalled: Bool { get }
|
|
13
|
+
var nitroPlaybackError: PlaybackError? { get }
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
/// Owns the now-playing surface: publishes a track's static fields + artwork, and renders the
|
|
7
|
-
/// title/artist line — the formatter's result,
|
|
8
|
-
///
|
|
9
|
-
///
|
|
10
|
-
/// Does not exclusively own NowPlayingInfoController -- other callers (e.g. HybridAudioBrowser for
|
|
11
|
-
/// overrides) can still access it directly.
|
|
17
|
+
/// title/artist line — a flash, else the formatter's result, else the override or the track's own
|
|
18
|
+
/// fields. Holds that state (override, flash, latest timed metadata, formatter) and reports each
|
|
19
|
+
/// change of the rendered lines through `onChanged`.
|
|
12
20
|
@MainActor
|
|
13
21
|
final class NowPlayingUpdater {
|
|
14
22
|
private let logger = Logger(subsystem: "com.audiobrowser", category: "NowPlayingUpdater")
|
|
15
23
|
private let nowPlayingInfoController: NowPlayingInfoController
|
|
16
24
|
|
|
25
|
+
weak var source: NowPlayingSource?
|
|
17
26
|
var artworkUrlResolver: ((Track, ImageContext?) async -> ImageSource?)?
|
|
18
|
-
///
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
/// Called with the JS-facing metadata whenever the rendered lines or the track change.
|
|
28
|
+
var onChanged: ((NowPlayingMetadata) -> Void)?
|
|
29
|
+
|
|
30
|
+
/// When false the surface shows the track's own fields: the override and formatter are ignored.
|
|
31
|
+
var enabled = true
|
|
32
|
+
var formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
|
|
33
|
+
|
|
34
|
+
/// Whether the device is online, which tells a stall apart: offline or buffering.
|
|
35
|
+
var isOnline = true {
|
|
36
|
+
didSet { if isOnline != oldValue { render() } }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private var override: NowPlayingUpdate?
|
|
40
|
+
private var timedMetadata: TimedMetadata?
|
|
41
|
+
/// A transient line (e.g. feedback for a refused remote command) that outranks the formatter
|
|
42
|
+
/// and the override, reverted by a native timer: JS timers pause in the background.
|
|
43
|
+
private var flash: NowPlayingUpdate?
|
|
44
|
+
private var flashRevert: DispatchWorkItem?
|
|
21
45
|
|
|
22
46
|
private var artworkLoadTask: Task<Void, Never>?
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
|
|
48
|
+
/// The track `onChanged` last reported, so a new track with the same text still reports.
|
|
49
|
+
private var publishedTrackId: String?
|
|
50
|
+
|
|
51
|
+
func setOverride(_ update: NowPlayingUpdate?) {
|
|
52
|
+
override = update
|
|
53
|
+
render()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func flash(_ update: NowPlayingUpdate, durationMs: Double) {
|
|
57
|
+
flashRevert?.cancel()
|
|
58
|
+
flash = update
|
|
59
|
+
let revert = DispatchWorkItem { [weak self] in
|
|
60
|
+
self?.flash = nil
|
|
61
|
+
self?.flashRevert = nil
|
|
62
|
+
self?.render()
|
|
63
|
+
}
|
|
64
|
+
flashRevert = revert
|
|
65
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + durationMs / 1000, execute: revert)
|
|
66
|
+
render()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func clearFlash() {
|
|
70
|
+
guard flash != nil else { return }
|
|
71
|
+
cancelFlash()
|
|
72
|
+
render()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func receivedTimedMetadata(_ metadata: TimedMetadata) {
|
|
76
|
+
timedMetadata = metadata
|
|
77
|
+
render()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// A new track drops the override, flash and timed metadata of the last one.
|
|
81
|
+
func trackChanged() {
|
|
82
|
+
override = nil
|
|
83
|
+
cancelFlash()
|
|
84
|
+
timedMetadata = nil
|
|
85
|
+
render()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// The metadata JS sees for the current track, before any formatter result.
|
|
89
|
+
var nowPlaying: NowPlayingMetadata? {
|
|
90
|
+
guard let track = source?.currentTrack else { return nil }
|
|
91
|
+
let lines = defaultLines(for: track)
|
|
92
|
+
return metadata(track: track, title: lines.title, artist: lines.artist, album: lines.album)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Re-renders the text lines for the current track and state. Called on every playback
|
|
96
|
+
/// transition; unchanged lines are not re-published.
|
|
97
|
+
func render() {
|
|
98
|
+
guard let source, let track = source.currentTrack else { return }
|
|
99
|
+
render(
|
|
100
|
+
track: track,
|
|
101
|
+
timedMetadata: timedMetadata,
|
|
102
|
+
playWhenReady: source.playWhenReady,
|
|
103
|
+
stalled: source.isStalled ? (isOnline ? .buffering : .offline) : nil,
|
|
104
|
+
error: source.nitroPlaybackError,
|
|
105
|
+
flash: flash,
|
|
106
|
+
override: enabled ? override : nil,
|
|
107
|
+
formatter: enabled ? formatter : nil,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Empties the now-playing surface and drops any artwork fetch or formatter result in flight.
|
|
43
112
|
func clearNowPlaying() {
|
|
113
|
+
renderGeneration &+= 1
|
|
114
|
+
artworkLoadTask?.cancel()
|
|
115
|
+
artworkLoadTask = nil
|
|
44
116
|
nowPlayingInfoController.clear()
|
|
45
|
-
|
|
117
|
+
publishedTrackId = nil
|
|
46
118
|
formattedTrackId = nil
|
|
47
119
|
}
|
|
48
120
|
|
|
@@ -79,9 +151,7 @@ final class NowPlayingUpdater {
|
|
|
79
151
|
/// lines are owned by `render` (routed through `applyFields` here so it dedupes against later
|
|
80
152
|
/// renders).
|
|
81
153
|
func loadMetaValues(for track: Track) {
|
|
82
|
-
nowPlayingInfoController.set(
|
|
83
|
-
NowPlayingInfoProperty.isLiveStream(track.live),
|
|
84
|
-
])
|
|
154
|
+
nowPlayingInfoController.set([MPNowPlayingInfoPropertyIsLiveStream: track.live])
|
|
85
155
|
applyFields(track: track, title: track.title, artist: track.artist, album: track.album)
|
|
86
156
|
loadArtwork(for: track)
|
|
87
157
|
}
|
|
@@ -92,13 +162,13 @@ final class NowPlayingUpdater {
|
|
|
92
162
|
/// command) outranks both: while one is active the formatter pass is skipped entirely, so its
|
|
93
163
|
/// async result can't land on top. Safe to call on every playback transition; redundant writes
|
|
94
164
|
/// are deduped.
|
|
95
|
-
func render(
|
|
165
|
+
private func render(
|
|
96
166
|
track: Track,
|
|
97
167
|
timedMetadata: TimedMetadata?,
|
|
98
168
|
playWhenReady: Bool,
|
|
99
169
|
stalled: StallReason?,
|
|
100
170
|
error: PlaybackError?,
|
|
101
|
-
flash: NowPlayingUpdate
|
|
171
|
+
flash: NowPlayingUpdate?,
|
|
102
172
|
override: NowPlayingUpdate?,
|
|
103
173
|
formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
|
|
104
174
|
) {
|
|
@@ -174,27 +244,58 @@ final class NowPlayingUpdater {
|
|
|
174
244
|
|
|
175
245
|
// MARK: - Private
|
|
176
246
|
|
|
177
|
-
/// Stamps the title/artist/album (each falling back to the track's own) onto
|
|
178
|
-
///
|
|
247
|
+
/// Stamps the title/artist/album (each falling back to the track's own) onto the now-playing
|
|
248
|
+
/// surface, and notifies `onChanged` when the text or the track changed.
|
|
179
249
|
private func applyFields(track: Track, title: String?, artist: String?, album: String?) {
|
|
180
250
|
let resolvedTitle = title ?? track.title
|
|
181
251
|
let resolvedArtist = artist ?? track.artist
|
|
182
252
|
let resolvedAlbum = album ?? track.album
|
|
183
|
-
let
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
253
|
+
let textChanged = nowPlayingInfoController.set([
|
|
254
|
+
MPMediaItemPropertyTitle: resolvedTitle,
|
|
255
|
+
MPMediaItemPropertyArtist: resolvedArtist,
|
|
256
|
+
MPMediaItemPropertyAlbumTitle: resolvedAlbum,
|
|
257
|
+
])
|
|
258
|
+
let trackId = track.identity ?? track.path
|
|
259
|
+
guard textChanged || trackId != publishedTrackId else { return }
|
|
260
|
+
publishedTrackId = trackId
|
|
261
|
+
onChanged?(metadata(track: track, title: resolvedTitle, artist: resolvedArtist, album: resolvedAlbum))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func cancelFlash() {
|
|
265
|
+
flashRevert?.cancel()
|
|
266
|
+
flashRevert = nil
|
|
267
|
+
flash = nil
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Flash over override over the track's own fields.
|
|
271
|
+
private func defaultLines(for track: Track) -> (title: String, artist: String?, album: String?) {
|
|
272
|
+
(
|
|
273
|
+
flash?.title ?? override?.title ?? track.title,
|
|
274
|
+
flash?.artist ?? override?.artist ?? track.artist,
|
|
275
|
+
flash?.album ?? override?.album ?? track.album,
|
|
188
276
|
)
|
|
189
|
-
|
|
190
|
-
lastPublished = published
|
|
277
|
+
}
|
|
191
278
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
279
|
+
private func metadata(track: Track, title: String, artist: String?, album: String?) -> NowPlayingMetadata {
|
|
280
|
+
NowPlayingMetadata(
|
|
281
|
+
elapsedTime: source?.currentTime,
|
|
282
|
+
title: title,
|
|
283
|
+
album: album,
|
|
284
|
+
artist: artist,
|
|
285
|
+
duration: track.duration,
|
|
286
|
+
artwork: Self.jsArtwork(for: track),
|
|
287
|
+
description: track.description,
|
|
288
|
+
mediaId: track.src ?? track.path,
|
|
289
|
+
genre: track.genre,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// The artwork URI JS can load: the resolved `artworkSource` (a rendered SF Symbol's `file://`
|
|
294
|
+
/// included), else the raw artwork unless it is an SF Symbol.
|
|
295
|
+
private static func jsArtwork(for track: Track) -> String? {
|
|
296
|
+
if let uri = track.artworkSource?.uri { return uri }
|
|
297
|
+
guard let artwork = track.artwork?.url else { return nil }
|
|
298
|
+
return SFSymbolRenderer.isSFSymbol(artwork) ? nil : artwork
|
|
198
299
|
}
|
|
199
300
|
|
|
200
301
|
private func loadArtwork(for track: Track) {
|
|
@@ -207,8 +308,6 @@ final class NowPlayingUpdater {
|
|
|
207
308
|
let nowPlayingSize = ImageContext(width: artworkSize, height: artworkSize)
|
|
208
309
|
|
|
209
310
|
artworkLoadTask?.cancel()
|
|
210
|
-
artworkGeneration &+= 1
|
|
211
|
-
let expectedGeneration = artworkGeneration
|
|
212
311
|
artworkLoadTask = Task {
|
|
213
312
|
let image: UIImage?
|
|
214
313
|
|
|
@@ -216,19 +315,19 @@ final class NowPlayingUpdater {
|
|
|
216
315
|
if let resolver = artworkUrlResolver,
|
|
217
316
|
let imageSource = await resolver(track, nowPlayingSize)
|
|
218
317
|
{
|
|
219
|
-
guard !Task.isCancelled
|
|
318
|
+
guard !Task.isCancelled else { return }
|
|
220
319
|
logger.debug("loadArtwork: using resolved URL: \(imageSource.uri)")
|
|
221
|
-
image = await ArtworkImageFetcher.fetchImage(from: imageSource)
|
|
320
|
+
image = await ArtworkImageFetcher.fetchImage(from: imageSource, waitsForConnectivity: true)
|
|
222
321
|
} else {
|
|
223
|
-
guard !Task.isCancelled
|
|
322
|
+
guard !Task.isCancelled else { return }
|
|
224
323
|
if let source = track.artworkImageSource {
|
|
225
|
-
image = await ArtworkImageFetcher.fetchImage(from: source)
|
|
324
|
+
image = await ArtworkImageFetcher.fetchImage(from: source, waitsForConnectivity: true)
|
|
226
325
|
} else {
|
|
227
326
|
image = nil
|
|
228
327
|
}
|
|
229
328
|
}
|
|
230
329
|
|
|
231
|
-
guard !Task.isCancelled
|
|
330
|
+
guard !Task.isCancelled else { return }
|
|
232
331
|
|
|
233
332
|
if let image {
|
|
234
333
|
logger.debug("loadArtwork: loaded image \(image.size.width)x\(image.size.height)")
|
|
@@ -237,10 +336,10 @@ final class NowPlayingUpdater {
|
|
|
237
336
|
let artwork = MPMediaItemArtwork(boundsSize: image.size) { @Sendable requestedSize in
|
|
238
337
|
return image
|
|
239
338
|
}
|
|
240
|
-
nowPlayingInfoController.set(
|
|
339
|
+
nowPlayingInfoController.set([MPMediaItemPropertyArtwork: artwork])
|
|
241
340
|
} else {
|
|
242
341
|
logger.debug("loadArtwork: no image loaded")
|
|
243
|
-
nowPlayingInfoController.set(
|
|
342
|
+
nowPlayingInfoController.set([MPMediaItemPropertyArtwork: nil])
|
|
244
343
|
}
|
|
245
344
|
}
|
|
246
345
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// What a playback failure is reported as, and whether another attempt could fix it: one table,
|
|
5
|
+
/// so the error JS receives and the retry decision are read off the same evidence.
|
|
6
|
+
enum PlaybackFailure {
|
|
7
|
+
struct Classification: Equatable {
|
|
8
|
+
/// The narrowest error the evidence supports, falling back to the context's error.
|
|
9
|
+
let error: TrackPlayerError.PlaybackError
|
|
10
|
+
let retryable: Bool
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Classifies a failure. AVFoundation reports HTTP failures as opaque CoreMedia errors, so
|
|
14
|
+
/// `httpStatusCode` comes from the item's error log, passed in by the caller.
|
|
15
|
+
///
|
|
16
|
+
/// Both read the error and the errors it wraps, but rank the evidence differently, on purpose:
|
|
17
|
+
/// - The error: a transport failure first (no response arrived, so a status left in the log
|
|
18
|
+
/// describes an earlier attempt), then a non-2xx status, then media that cannot be decoded.
|
|
19
|
+
/// - Retrying: a status first (a retry cannot conjure content the server refused), then the
|
|
20
|
+
/// error and the errors it wraps. An unrecognized failure is transient, bounded by the retry
|
|
21
|
+
/// budgets, as on Android: AVFoundation reports most stream failures opaquely.
|
|
22
|
+
static func classify(
|
|
23
|
+
_ error: Error?,
|
|
24
|
+
httpStatusCode: Int?,
|
|
25
|
+
fallback: TrackPlayerError.PlaybackError,
|
|
26
|
+
) -> Classification {
|
|
27
|
+
Classification(
|
|
28
|
+
error: reportedError(error, httpStatusCode: httpStatusCode, fallback: fallback),
|
|
29
|
+
retryable: isRetryable(error, httpStatusCode: httpStatusCode),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Picks the HTTP status out of an `AVPlayerItem` error log's status codes. The codes share
|
|
34
|
+
/// their space with negative OSStatus values, so only in-range values count, and the newest
|
|
35
|
+
/// wins: a live stream reconnects, and older entries describe earlier attempts.
|
|
36
|
+
static func httpStatusCode(fromErrorStatusCodes codes: [Int]) -> Int? {
|
|
37
|
+
codes.last { (100 ... 599).contains($0) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - The error
|
|
41
|
+
|
|
42
|
+
private static func reportedError(
|
|
43
|
+
_ error: Error?,
|
|
44
|
+
httpStatusCode: Int?,
|
|
45
|
+
fallback: TrackPlayerError.PlaybackError,
|
|
46
|
+
) -> TrackPlayerError.PlaybackError {
|
|
47
|
+
let chain = error.map(errorChain) ?? []
|
|
48
|
+
// `as?` bridges a plain NSError in NSURLErrorDomain too, AVFoundation's usual form.
|
|
49
|
+
for case let urlError as URLError in chain {
|
|
50
|
+
switch urlError.code {
|
|
51
|
+
case .notConnectedToInternet:
|
|
52
|
+
return .notConnectedToInternet
|
|
53
|
+
case .timedOut, .cannotFindHost, .cannotConnectToHost, .networkConnectionLost, .dnsLookupFailed,
|
|
54
|
+
.secureConnectionFailed:
|
|
55
|
+
return .hostUnreachable
|
|
56
|
+
default:
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if let httpStatusCode, !(200 ... 299).contains(httpStatusCode) {
|
|
61
|
+
return .httpStatus(httpStatusCode)
|
|
62
|
+
}
|
|
63
|
+
let unplayable = chain.contains {
|
|
64
|
+
let nsError = $0 as NSError
|
|
65
|
+
return nsError.domain == AVFoundationErrorDomain && unplayableAVErrorCodes.contains(nsError.code)
|
|
66
|
+
}
|
|
67
|
+
return unplayable ? .trackWasUnplayable : fallback
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// The stream arrived but cannot be decoded: unknown container, unsupported codec, or no
|
|
71
|
+
/// decoder.
|
|
72
|
+
private static let unplayableAVErrorCodes: Set<Int> = [
|
|
73
|
+
AVError.fileFormatNotRecognized.rawValue,
|
|
74
|
+
AVError.failedToParse.rawValue,
|
|
75
|
+
AVError.decoderNotFound.rawValue,
|
|
76
|
+
AVError.decoderTemporarilyUnavailable.rawValue,
|
|
77
|
+
AVError.formatUnsupported.rawValue,
|
|
78
|
+
AVError.contentIsUnavailable.rawValue,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
// MARK: - Retrying
|
|
82
|
+
|
|
83
|
+
/// Beyond the 500–599 range. Android shares 408/429 but lists only 500/502/503/504 of 5xx.
|
|
84
|
+
private static let retryableHTTPStatusCodes: Set<Int> = [408, 429]
|
|
85
|
+
|
|
86
|
+
/// AVFoundation failures a retry cannot fix: the media is unusable, not the transport
|
|
87
|
+
/// unreliable. Every unplayable code but the temporary decoder shortage is here, so a failure
|
|
88
|
+
/// reported as unplayable is never also retried. `AVError.unknown` is absent on purpose: it
|
|
89
|
+
/// is the wrapper around opaque CoreMedia transport failures.
|
|
90
|
+
private static let fatalAVErrorCodes: Set<Int> = unplayableAVErrorCodes
|
|
91
|
+
.subtracting([AVError.decoderTemporarilyUnavailable.rawValue])
|
|
92
|
+
.union([
|
|
93
|
+
AVError.decodeFailed.rawValue,
|
|
94
|
+
AVError.contentIsProtected.rawValue,
|
|
95
|
+
AVError.contentIsNotAuthorized.rawValue,
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
private static func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
|
|
99
|
+
if let status = httpStatusCode {
|
|
100
|
+
return retryableHTTPStatusCodes.contains(status) || (500 ... 599).contains(status)
|
|
101
|
+
}
|
|
102
|
+
guard let error else { return false }
|
|
103
|
+
// AVFoundation routinely wraps the real cause (a URLError, a CoreMedia code) in an AVError.
|
|
104
|
+
for candidate in errorChain(error) {
|
|
105
|
+
if let urlError = candidate as? URLError { return urlError.isConnectivity }
|
|
106
|
+
let nsError = candidate as NSError
|
|
107
|
+
if nsError.domain == AVFoundationErrorDomain, fatalAVErrorCodes.contains(nsError.code) {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// The error and its `NSUnderlyingError` ancestors, outermost first, bounded so a malformed
|
|
115
|
+
/// cycle cannot spin.
|
|
116
|
+
private static func errorChain(_ error: Error) -> [Error] {
|
|
117
|
+
var chain: [Error] = []
|
|
118
|
+
var current: Error? = error
|
|
119
|
+
while let error = current, chain.count < 8 {
|
|
120
|
+
chain.append(error)
|
|
121
|
+
current = (error as NSError).userInfo[NSUnderlyingErrorKey] as? Error
|
|
122
|
+
}
|
|
123
|
+
return chain
|
|
124
|
+
}
|
|
125
|
+
}
|