react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
|
|
3
|
+
/// What a CarPlay template shows, kept in its `userInfo`.
|
|
4
|
+
struct CarPlayTemplateInfo {
|
|
5
|
+
enum Kind {
|
|
6
|
+
/// Content that `path` resolves to; loaded lazily and refreshed.
|
|
7
|
+
case content
|
|
8
|
+
/// The Browse Gate's page for `path`, which is never loaded.
|
|
9
|
+
case gate
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// The browse path the template stands for. Nil only for a gate page shown at the root.
|
|
13
|
+
let path: String?
|
|
14
|
+
let kind: Kind
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
extension CPTemplate {
|
|
18
|
+
var carPlayInfo: CarPlayTemplateInfo? {
|
|
19
|
+
get { userInfo as? CarPlayTemplateInfo }
|
|
20
|
+
set { userInfo = newValue }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// The path this template loads content from; nil for gate and message pages.
|
|
24
|
+
var contentPath: String? {
|
|
25
|
+
guard let info = carPlayInfo, info.kind == .content else { return nil }
|
|
26
|
+
return info.path
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
extension CPListTemplate {
|
|
31
|
+
/// A list template whose content is still resolving: the system spinner on iOS 18.4+, and
|
|
32
|
+
/// the app's `loadingTitle`, when it set one, as the empty-view subtitle. A title-slot hint
|
|
33
|
+
/// would render at CarPlay's large empty-state size.
|
|
34
|
+
static func loading(title: String?, path: String?, loadingTitle: String?) -> CPListTemplate {
|
|
35
|
+
let template = CPListTemplate(title: title, sections: [])
|
|
36
|
+
if let path { template.carPlayInfo = CarPlayTemplateInfo(path: path, kind: .content) }
|
|
37
|
+
if let loadingTitle { template.emptyViewSubtitleVariants = [loadingTitle] }
|
|
38
|
+
if #available(iOS 18.4, *) {
|
|
39
|
+
template.showsSpinnerWhileEmpty = true
|
|
40
|
+
}
|
|
41
|
+
return template
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// An empty list template showing a centered message. CarPlay renders an empty view reliably
|
|
45
|
+
/// only as a template's initial state, so a pushed page's message is a new template.
|
|
46
|
+
static func message(
|
|
47
|
+
_ title: String,
|
|
48
|
+
subtitle: String?,
|
|
49
|
+
listTitle: String? = nil,
|
|
50
|
+
info: CarPlayTemplateInfo? = nil,
|
|
51
|
+
) -> CPListTemplate {
|
|
52
|
+
let template = CPListTemplate(title: listTitle, sections: [])
|
|
53
|
+
template.setMessage(title, subtitle: subtitle)
|
|
54
|
+
template.carPlayInfo = info
|
|
55
|
+
return template
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Newlines collapse to spaces: the variants are width alternatives, not lines.
|
|
59
|
+
func setMessage(_ title: String, subtitle: String?) {
|
|
60
|
+
emptyViewTitleVariants = [title]
|
|
61
|
+
emptyViewSubtitleVariants = subtitle.flatMap { $0.isEmpty ? nil : [$0.replacingOccurrences(of: "\n", with: " ")] } ?? []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os.log
|
|
3
|
+
|
|
4
|
+
private let intentLogger = Logger(subsystem: "com.audiobrowser", category: "MediaIntent")
|
|
5
|
+
|
|
6
|
+
extension HybridAudioBrowser {
|
|
7
|
+
/// Sets the favorite state of the playing track for a Siri intent, through the same path as
|
|
8
|
+
/// the CarPlay favorite button. False when nothing is playing, the playing track has no
|
|
9
|
+
/// identity to favorite, or the change throws.
|
|
10
|
+
@MainActor
|
|
11
|
+
static func favoriteCurrentTrack(_ favorited: Bool) -> Bool {
|
|
12
|
+
guard let browser = shared else { return false }
|
|
13
|
+
do {
|
|
14
|
+
return try browser.favoriteActiveTrack(favorited)
|
|
15
|
+
} catch {
|
|
16
|
+
intentLogger.error("favoriteCurrentTrack(\(favorited)) failed: \(error.localizedDescription)")
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import os.log
|
|
4
|
+
|
|
5
|
+
private let logger = Logger(subsystem: "com.audiobrowser", category: "MediaIntentPlayback")
|
|
6
|
+
|
|
7
|
+
/// Plays a Siri play-media intent through the single voice play funnel (ADR 0002): resume, or
|
|
8
|
+
/// search → queue → play, then surface Now Playing.
|
|
9
|
+
enum MediaIntentPlayback {
|
|
10
|
+
/// Callable before the bridge exists: it waits, within the assistant's budget, for the player
|
|
11
|
+
/// and a configured browser, which come up once React Native boots. Each await may outlive the
|
|
12
|
+
/// bridge or its player (a JS reload or a second setup replaces them), so the player is looked
|
|
13
|
+
/// up again after one, and the intent fails once the bridge is gone.
|
|
14
|
+
@MainActor
|
|
15
|
+
static func play(criteria: MediaIntentCriteria) async -> Bool {
|
|
16
|
+
guard let (browser, player) = await playerAndConfiguredBrowser.wait(timeout: .seconds(8)) else {
|
|
17
|
+
logger.error("browser/player not ready within budget")
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
// Resume is not gated: the gate blocks finding content, never hearing the active or
|
|
21
|
+
// persisted track.
|
|
22
|
+
if criteria.isResume {
|
|
23
|
+
if player.currentTrack != nil {
|
|
24
|
+
logger.info("resume: warm — playing current track")
|
|
25
|
+
player.play()
|
|
26
|
+
} else {
|
|
27
|
+
guard let session = player.persistedSession() else { return false }
|
|
28
|
+
var expanded: (tracks: [Track], selectedIndex: Int)?
|
|
29
|
+
if let path = session.track.path {
|
|
30
|
+
expanded = try? await browser.browserManager.expandQueueFromContextualPath(path)
|
|
31
|
+
}
|
|
32
|
+
guard let player = livePlayer(of: browser) else { return false }
|
|
33
|
+
player.resume(session, queue: expanded)
|
|
34
|
+
}
|
|
35
|
+
browser.showNowPlayingRequestedEmitter.emit(())
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The API still text-searches `q`; the structured fields are forward-compatible, as on
|
|
40
|
+
// Android.
|
|
41
|
+
let params = SearchParams(
|
|
42
|
+
mode: criteria.searchMode.flatMap { SearchMode(fromString: $0) },
|
|
43
|
+
query: criteria.query,
|
|
44
|
+
genre: criteria.genre,
|
|
45
|
+
artist: criteria.artist,
|
|
46
|
+
album: criteria.album,
|
|
47
|
+
title: criteria.title,
|
|
48
|
+
playlist: criteria.playlist,
|
|
49
|
+
reference: criteria.reference == .my ? .my : .unknown,
|
|
50
|
+
)
|
|
51
|
+
guard await browser.browseGate.decide(NativeGateRequest(reason: .search, path: nil, search: params)) == nil else {
|
|
52
|
+
logger.info("search refused — gated")
|
|
53
|
+
browser.onGate(GateEvent(reason: .search))
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
do {
|
|
57
|
+
guard let tracks = try await browser.browserManager.searchPlayable(params),
|
|
58
|
+
let player = livePlayer(of: browser) else { return false }
|
|
59
|
+
player.setQueue(tracks, initialIndex: 0, playWhenReady: true)
|
|
60
|
+
browser.showNowPlayingRequestedEmitter.emit(())
|
|
61
|
+
return true
|
|
62
|
+
} catch {
|
|
63
|
+
logger.error("search failed: \(error.localizedDescription)")
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// `browser`'s player while `browser` is still the live bridge; a replaced bridge keeps its
|
|
69
|
+
/// destroyed player.
|
|
70
|
+
@MainActor
|
|
71
|
+
private static func livePlayer(of browser: HybridAudioBrowser) -> Player? {
|
|
72
|
+
HybridAudioBrowser.shared === browser ? browser.getPlayer() : nil
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -28,24 +28,8 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
|
|
|
28
28
|
Self.logger.info("Add media — favorite current track")
|
|
29
29
|
|
|
30
30
|
Task { @MainActor in
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
guard let browser = HybridAudioBrowser.shared,
|
|
34
|
-
browser.getPlayer()?.currentTrack != nil
|
|
35
|
-
else {
|
|
36
|
-
completion(INAddMediaIntentResponse(code: .failure, userActivity: nil))
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Add = favorite the currently-playing track (same path as the CarPlay
|
|
41
|
-
// favorite button / affinity "like"): flips the now-playing heart and
|
|
42
|
-
// fires `onFavoriteChanged` so the consumer persists it — no extra handler.
|
|
43
|
-
do {
|
|
44
|
-
try browser.setActiveTrackFavorited(favorited: true)
|
|
45
|
-
completion(INAddMediaIntentResponse(code: .success, userActivity: nil))
|
|
46
|
-
} catch {
|
|
47
|
-
completion(INAddMediaIntentResponse(code: .failure, userActivity: nil))
|
|
48
|
-
}
|
|
31
|
+
let applied = HybridAudioBrowser.favoriteCurrentTrack(true)
|
|
32
|
+
completion(INAddMediaIntentResponse(code: applied ? .success : .failure, userActivity: nil))
|
|
49
33
|
}
|
|
50
34
|
}
|
|
51
35
|
|
|
@@ -27,28 +27,12 @@ class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
|
|
|
27
27
|
Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
|
|
28
28
|
|
|
29
29
|
Task { @MainActor in
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
else {
|
|
35
|
-
completion(INUpdateMediaAffinityIntentResponse(code: .failure, userActivity: nil))
|
|
36
|
-
return
|
|
30
|
+
let applied = switch affinity {
|
|
31
|
+
case .like: HybridAudioBrowser.favoriteCurrentTrack(true)
|
|
32
|
+
case .dislike: HybridAudioBrowser.favoriteCurrentTrack(false)
|
|
33
|
+
default: false
|
|
37
34
|
}
|
|
38
|
-
|
|
39
|
-
// Map affinity onto the library-managed favorite state (same path as the
|
|
40
|
-
// CarPlay favorite button): flips the now-playing heart and fires
|
|
41
|
-
// `onFavoriteChanged` so the consumer persists it — no extra handler.
|
|
42
|
-
switch affinity {
|
|
43
|
-
case .like:
|
|
44
|
-
try? browser.setActiveTrackFavorited(favorited: true)
|
|
45
|
-
case .dislike:
|
|
46
|
-
try? browser.setActiveTrackFavorited(favorited: false)
|
|
47
|
-
default:
|
|
48
|
-
completion(INUpdateMediaAffinityIntentResponse(code: .failure, userActivity: nil))
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
completion(INUpdateMediaAffinityIntentResponse(code: .success, userActivity: nil))
|
|
35
|
+
completion(INUpdateMediaAffinityIntentResponse(code: applied ? .success : .failure, userActivity: nil))
|
|
52
36
|
}
|
|
53
37
|
}
|
|
54
38
|
|
|
@@ -25,9 +25,8 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
|
|
|
25
25
|
)
|
|
26
26
|
Self.logger.info("Play media intent — query=\(criteria.query) matchesApp=\(criteria.matchesAppName) resume=\(criteria.isResume)")
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
HybridAudioBrowser.handlePlayMediaIntent(criteria: criteria) { success in
|
|
28
|
+
Task { @MainActor in
|
|
29
|
+
let success = await MediaIntentPlayback.play(criteria: criteria)
|
|
31
30
|
completion(INPlayMediaIntentResponse(code: success ? .success : .failure, userActivity: nil))
|
|
32
31
|
}
|
|
33
32
|
}
|
|
@@ -8,10 +8,6 @@ extension NavigationError {
|
|
|
8
8
|
switch browserError {
|
|
9
9
|
case .contentNotFound:
|
|
10
10
|
NavigationError(code: .contentNotFound, message: browserError.localizedDescription, statusCode: nil, statusCodeSuccess: nil)
|
|
11
|
-
case let .httpError(code, _):
|
|
12
|
-
NavigationError(code: .httpError, message: browserError.localizedDescription, statusCode: Double(code), statusCodeSuccess: (200 ... 299).contains(code))
|
|
13
|
-
case .networkError:
|
|
14
|
-
NavigationError(code: .networkError, message: browserError.localizedDescription, statusCode: nil, statusCodeSuccess: nil)
|
|
15
11
|
case .invalidConfiguration:
|
|
16
12
|
NavigationError(code: .unknownError, message: browserError.localizedDescription, statusCode: nil, statusCodeSuccess: nil)
|
|
17
13
|
case .callbackError:
|
|
@@ -55,6 +51,13 @@ extension NavigationError {
|
|
|
55
51
|
}
|
|
56
52
|
}
|
|
57
53
|
|
|
54
|
+
extension NavigationError {
|
|
55
|
+
/// A code-only error: the copy comes from the app's `formatNavigationError` (ADR 0001).
|
|
56
|
+
static func code(_ code: NavigationErrorType) -> NavigationError {
|
|
57
|
+
NavigationError(code: code, message: "", statusCode: nil, statusCodeSuccess: nil)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
58
61
|
extension NavigationErrorKind {
|
|
59
62
|
/// The kind of request a navigation error on `path` came from.
|
|
60
63
|
init(path: String) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
extension UIImage {
|
|
4
|
+
/// One image that shows `light` or `dark` by the appearance of the trait collection it is
|
|
5
|
+
/// displayed in, starting from `traits`; an appearance change re-resolves it in place.
|
|
6
|
+
static func adaptive(light: UIImage, dark: UIImage, for traits: UITraitCollection) -> UIImage {
|
|
7
|
+
let asset = UIImageAsset()
|
|
8
|
+
asset.register(light, with: UITraitCollection(userInterfaceStyle: .light))
|
|
9
|
+
asset.register(dark, with: UITraitCollection(userInterfaceStyle: .dark))
|
|
10
|
+
return asset.image(with: traits)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
extension URLError {
|
|
4
|
+
/// The request failed for want of a connection to the host, so it is worth repeating once one
|
|
5
|
+
/// is back.
|
|
6
|
+
var isConnectivity: Bool {
|
|
7
|
+
switch code {
|
|
8
|
+
case .timedOut, .networkConnectionLost, .notConnectedToInternet, .cannotConnectToHost, .cannotFindHost,
|
|
9
|
+
.dnsLookupFailed:
|
|
10
|
+
true
|
|
11
|
+
default:
|
|
12
|
+
false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -36,180 +36,107 @@ final class HttpClient: @unchecked Sendable {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
///
|
|
40
|
-
struct
|
|
41
|
-
let code: Int
|
|
42
|
-
let body: String
|
|
43
|
-
let headers: [String: String]
|
|
44
|
-
|
|
45
|
-
var isSuccessful: Bool {
|
|
46
|
-
(200 ..< 300).contains(code)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/// HTTP exception (matches Kotlin HttpClient.HttpException).
|
|
51
|
-
final class HttpException: Error, Sendable {
|
|
39
|
+
/// A non-2xx response (matches Kotlin HttpClient.HttpException).
|
|
40
|
+
struct HttpException: LocalizedError {
|
|
52
41
|
let code: Int
|
|
53
42
|
let responseBody: String
|
|
54
43
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
self.responseBody = responseBody
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
var localizedDescription: String {
|
|
61
|
-
// Try to extract error message from JSON response: { "error": "message" }
|
|
44
|
+
/// The API's `{ "error": "message" }`, else the localized HTTP status.
|
|
45
|
+
var errorDescription: String? {
|
|
62
46
|
if let data = responseBody.data(using: .utf8),
|
|
63
47
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
64
|
-
let
|
|
48
|
+
let message = json["error"] as? String
|
|
65
49
|
{
|
|
66
|
-
return
|
|
50
|
+
return message
|
|
67
51
|
}
|
|
68
|
-
// Fall back to localized HTTP status description
|
|
69
52
|
return HTTPURLResponse.localizedString(forStatusCode: code)
|
|
70
53
|
}
|
|
71
54
|
}
|
|
72
55
|
|
|
73
|
-
|
|
56
|
+
enum HttpClientError: LocalizedError {
|
|
57
|
+
case invalidUrl(String)
|
|
58
|
+
case unsupportedMethod(String)
|
|
59
|
+
case notHttp
|
|
60
|
+
case undecodable(String)
|
|
61
|
+
|
|
62
|
+
var errorDescription: String? {
|
|
63
|
+
switch self {
|
|
64
|
+
case let .invalidUrl(url): "Invalid URL: \(url)"
|
|
65
|
+
case let .unsupportedMethod(method): "Unsupported HTTP method: \(method)"
|
|
66
|
+
case .notHttp: "Invalid response type"
|
|
67
|
+
case let .undecodable(reason): reason
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
74
72
|
init(session: URLSession = .shared) {
|
|
75
73
|
self.session = session
|
|
76
74
|
}
|
|
77
75
|
|
|
78
|
-
///
|
|
79
|
-
func request(_ httpRequest: HttpRequest) async ->
|
|
80
|
-
guard let url = URL(string: httpRequest.url) else {
|
|
81
|
-
return .failure(NSError(
|
|
82
|
-
domain: "HttpClient",
|
|
83
|
-
code: -1,
|
|
84
|
-
userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(httpRequest.url)"],
|
|
85
|
-
))
|
|
86
|
-
}
|
|
76
|
+
/// Sends `httpRequest` and returns the status code and body, whatever the status.
|
|
77
|
+
func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
|
|
78
|
+
guard let url = URL(string: httpRequest.url) else { throw HttpClientError.invalidUrl(httpRequest.url) }
|
|
87
79
|
|
|
88
80
|
var urlRequest = URLRequest(url: url)
|
|
89
81
|
urlRequest.timeoutInterval = Self.timeoutSeconds
|
|
90
|
-
|
|
91
|
-
// Add headers
|
|
92
82
|
httpRequest.headers?.forEach { name, value in
|
|
93
83
|
urlRequest.setValue(value, forHTTPHeaderField: name)
|
|
94
84
|
}
|
|
95
|
-
//
|
|
96
|
-
// (MediaLoader.buildAssetOptions and the artwork path in
|
|
97
|
-
// BrowserManager+URLResolution). `userAgent` carries a non-nil default, so
|
|
98
|
-
// overwriting unconditionally would replace a consumer's header with
|
|
99
|
-
// "react-native-audio-browser" whenever they set only the header.
|
|
85
|
+
// A `User-Agent` header wins over `userAgent`, as in the media and artwork paths.
|
|
100
86
|
if httpRequest.headers?["User-Agent"] == nil {
|
|
101
87
|
urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
|
|
102
88
|
}
|
|
103
89
|
|
|
104
|
-
|
|
105
|
-
switch
|
|
90
|
+
let method = httpRequest.method.uppercased()
|
|
91
|
+
switch method {
|
|
106
92
|
case "GET":
|
|
107
|
-
urlRequest.httpMethod =
|
|
93
|
+
urlRequest.httpMethod = method
|
|
108
94
|
case "POST", "PUT", "PATCH":
|
|
109
|
-
urlRequest.httpMethod =
|
|
95
|
+
urlRequest.httpMethod = method
|
|
110
96
|
urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
|
|
111
|
-
|
|
112
|
-
urlRequest.httpBody = body.data(using: .utf8)
|
|
113
|
-
}
|
|
97
|
+
urlRequest.httpBody = httpRequest.body?.data(using: .utf8)
|
|
114
98
|
case "DELETE":
|
|
115
|
-
urlRequest.httpMethod =
|
|
99
|
+
urlRequest.httpMethod = method
|
|
116
100
|
if let body = httpRequest.body {
|
|
117
101
|
urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
|
|
118
102
|
urlRequest.httpBody = body.data(using: .utf8)
|
|
119
103
|
}
|
|
120
104
|
default:
|
|
121
|
-
|
|
122
|
-
domain: "HttpClient",
|
|
123
|
-
code: -1,
|
|
124
|
-
userInfo: [NSLocalizedDescriptionKey: "Unsupported HTTP method: \(httpRequest.method)"],
|
|
125
|
-
))
|
|
105
|
+
throw HttpClientError.unsupportedMethod(httpRequest.method)
|
|
126
106
|
}
|
|
127
107
|
|
|
128
|
-
// Log request
|
|
129
108
|
logRequest(httpRequest)
|
|
130
|
-
|
|
131
109
|
do {
|
|
132
110
|
let (data, response) = try await session.data(for: urlRequest)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
domain: "HttpClient",
|
|
137
|
-
code: -1,
|
|
138
|
-
userInfo: [NSLocalizedDescriptionKey: "Invalid response type"],
|
|
139
|
-
))
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
|
143
|
-
|
|
144
|
-
// Extract response headers
|
|
145
|
-
var headers: [String: String] = [:]
|
|
146
|
-
for (key, value) in httpResponse.allHeaderFields {
|
|
147
|
-
if let keyString = key as? String, let valueString = value as? String {
|
|
148
|
-
headers[keyString] = valueString
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
let httpResp = HttpResponse(
|
|
153
|
-
code: httpResponse.statusCode,
|
|
154
|
-
body: responseBody,
|
|
155
|
-
headers: headers,
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
// Log response
|
|
159
|
-
logResponse(httpResp, url: httpRequest.url)
|
|
160
|
-
|
|
161
|
-
return .success(httpResp)
|
|
111
|
+
guard let response = response as? HTTPURLResponse else { throw HttpClientError.notHttp }
|
|
112
|
+
logResponse(code: response.statusCode, data: data, url: httpRequest.url)
|
|
113
|
+
return (response.statusCode, data)
|
|
162
114
|
} catch {
|
|
163
|
-
|
|
164
|
-
|
|
115
|
+
logger.error("✗ Error for \(httpRequest.url): \(error.localizedDescription)")
|
|
116
|
+
throw error
|
|
165
117
|
}
|
|
166
118
|
}
|
|
167
119
|
|
|
168
|
-
///
|
|
169
|
-
func requestJson<T: Decodable>(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
domain: "HttpClient",
|
|
183
|
-
code: -1,
|
|
184
|
-
userInfo: [NSLocalizedDescriptionKey: "Failed to convert response body to data"],
|
|
185
|
-
)
|
|
186
|
-
}
|
|
187
|
-
let decoder = JSONDecoder()
|
|
188
|
-
do {
|
|
189
|
-
return try decoder.decode(type, from: data)
|
|
190
|
-
} catch let DecodingError.keyNotFound(key, context) {
|
|
191
|
-
throw NSError(
|
|
192
|
-
domain: "HttpClient",
|
|
193
|
-
code: -1,
|
|
194
|
-
userInfo: [NSLocalizedDescriptionKey: "Missing required key '\(key.stringValue)' at path: \(context.codingPath.map(\.stringValue).joined(separator: "."))"],
|
|
195
|
-
)
|
|
196
|
-
} catch let DecodingError.typeMismatch(type, context) {
|
|
197
|
-
throw NSError(
|
|
198
|
-
domain: "HttpClient",
|
|
199
|
-
code: -1,
|
|
200
|
-
userInfo: [NSLocalizedDescriptionKey: "Type mismatch for \(type) at path: \(context.codingPath.map(\.stringValue).joined(separator: "."))"],
|
|
201
|
-
)
|
|
202
|
-
} catch let DecodingError.valueNotFound(type, context) {
|
|
203
|
-
throw NSError(
|
|
204
|
-
domain: "HttpClient",
|
|
205
|
-
code: -1,
|
|
206
|
-
userInfo: [NSLocalizedDescriptionKey: "Value not found for \(type) at path: \(context.codingPath.map(\.stringValue).joined(separator: "."))"],
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
case let .failure(error):
|
|
210
|
-
throw error
|
|
120
|
+
/// Sends `httpRequest` and decodes a 2xx body as `T`; any other status throws `HttpException`.
|
|
121
|
+
func requestJson<T: Decodable>(_ httpRequest: HttpRequest, as type: T.Type) async throws -> T {
|
|
122
|
+
let (code, data) = try await request(httpRequest)
|
|
123
|
+
guard (200 ..< 300).contains(code) else {
|
|
124
|
+
throw HttpException(code: code, responseBody: String(decoding: data, as: UTF8.self))
|
|
125
|
+
}
|
|
126
|
+
do {
|
|
127
|
+
return try JSONDecoder().decode(type, from: data)
|
|
128
|
+
} catch let DecodingError.keyNotFound(key, context) {
|
|
129
|
+
throw HttpClientError.undecodable("Missing required key '\(key.stringValue)' at path: \(Self.path(context))")
|
|
130
|
+
} catch let DecodingError.typeMismatch(type, context) {
|
|
131
|
+
throw HttpClientError.undecodable("Type mismatch for \(type) at path: \(Self.path(context))")
|
|
132
|
+
} catch let DecodingError.valueNotFound(type, context) {
|
|
133
|
+
throw HttpClientError.undecodable("Value not found for \(type) at path: \(Self.path(context))")
|
|
211
134
|
}
|
|
212
135
|
}
|
|
136
|
+
|
|
137
|
+
private static func path(_ context: DecodingError.Context) -> String {
|
|
138
|
+
context.codingPath.map(\.stringValue).joined(separator: ".")
|
|
139
|
+
}
|
|
213
140
|
}
|
|
214
141
|
|
|
215
142
|
// MARK: - Logging
|
|
@@ -217,27 +144,18 @@ final class HttpClient: @unchecked Sendable {
|
|
|
217
144
|
extension HttpClient {
|
|
218
145
|
private func logRequest(_ request: HttpRequest) {
|
|
219
146
|
logger.debug("→ \(request.method) \(request.url)")
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
logger.debug(" \(key): \(value)")
|
|
223
|
-
}
|
|
147
|
+
for (key, value) in request.headers ?? [:] {
|
|
148
|
+
logger.debug(" \(key): \(value)")
|
|
224
149
|
}
|
|
225
150
|
if let body = request.body {
|
|
226
|
-
|
|
227
|
-
logger.debug(" Body: \(truncated)\(body.count > 500 ? "..." : "")")
|
|
151
|
+
logger.debug(" Body: \(body.prefix(500))\(body.count > 500 ? "..." : "")")
|
|
228
152
|
}
|
|
229
153
|
}
|
|
230
154
|
|
|
231
|
-
private func logResponse(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
let truncated = response.body.prefix(500)
|
|
236
|
-
logger.debug(" Body: \(truncated)\(response.body.count > 500 ? "..." : "")")
|
|
155
|
+
private func logResponse(code: Int, data: Data, url: String) {
|
|
156
|
+
logger.debug("← \((200 ..< 300).contains(code) ? "✓" : "✗") \(code) \(url)")
|
|
157
|
+
if !data.isEmpty {
|
|
158
|
+
logger.debug(" Body: \(String(decoding: data.prefix(500), as: UTF8.self))\(data.count > 500 ? "..." : "")")
|
|
237
159
|
}
|
|
238
160
|
}
|
|
239
|
-
|
|
240
|
-
private func logError(_ error: Error, url: String) {
|
|
241
|
-
logger.error("✗ Error for \(url): \(error.localizedDescription)")
|
|
242
|
-
}
|
|
243
161
|
}
|