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.
Files changed (137) hide show
  1. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  2. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  3. package/ios/AVPlayerEngine.swift +334 -0
  4. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  5. package/ios/Browser/BrowseGate.swift +58 -0
  6. package/ios/Browser/BrowserConfig.swift +31 -89
  7. package/ios/Browser/BrowserManager.swift +68 -487
  8. package/ios/Browser/NavigationErrorState.swift +41 -0
  9. package/ios/Browser/PageResolver.swift +227 -0
  10. package/ios/Browser/RequestLayer.swift +90 -0
  11. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  12. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  13. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  14. package/ios/CarPlay/CarPlayController.swift +42 -1442
  15. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  16. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  17. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  18. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  19. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  20. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  21. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  22. package/ios/CarPlay/CarPlaySession.swift +209 -0
  23. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  24. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  25. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  26. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  27. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  28. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  29. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  30. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  31. package/ios/Extension/URLError+Connectivity.swift +15 -0
  32. package/ios/Http/HttpClient.swift +63 -145
  33. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  34. package/ios/HybridAudioBrowser+Options.swift +144 -0
  35. package/ios/HybridAudioBrowser+Player.swift +397 -0
  36. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  37. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  38. package/ios/HybridAudioBrowser.swift +74 -1687
  39. package/ios/Model/NitroTypeStubs.swift +18 -1
  40. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  41. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  42. package/ios/Observer/ItemObserver.swift +90 -0
  43. package/ios/Observer/PlayerObserver.swift +59 -0
  44. package/ios/Option/SessionCategories.swift +41 -92
  45. package/ios/Player/AudioSessionController.swift +177 -0
  46. package/ios/Player/DeferredSeek.swift +70 -0
  47. package/ios/Player/ErrorStatus.swift +27 -0
  48. package/ios/Player/MediaLoader.swift +24 -11
  49. package/ios/Player/NowPlayingUpdater.swift +157 -58
  50. package/ios/Player/PlaybackFailure.swift +125 -0
  51. package/ios/Player/PlaybackPersistence.swift +73 -0
  52. package/ios/Player/Player.swift +1083 -0
  53. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  54. package/ios/Player/PlayerEngine.swift +89 -0
  55. package/ios/Player/PlayerStatusTypes.swift +2 -6
  56. package/ios/Player/QueueManager.swift +52 -59
  57. package/ios/Player/RetryLoop.swift +261 -0
  58. package/ios/Player/SleepTimerManager.swift +1 -1
  59. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  60. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  61. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  62. package/ios/Tests/DeferredSeekTests.swift +70 -0
  63. package/ios/Tests/EmitterTests.swift +29 -0
  64. package/ios/Tests/GenerationMemoTests.swift +121 -0
  65. package/ios/Tests/LRUCacheTests.swift +4 -5
  66. package/ios/Tests/MediaLoaderTests.swift +12 -33
  67. package/ios/Tests/ObserverTests.swift +88 -0
  68. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  69. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  70. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  71. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  72. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  73. package/ios/Tests/PlayingStateTests.swift +42 -0
  74. package/ios/Tests/QueueManagerTests.swift +76 -107
  75. package/ios/Tests/ReadyValueTests.swift +73 -0
  76. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  77. package/ios/Tests/RemoteCommandTests.swift +2 -4
  78. package/ios/Tests/RequestLayerTests.swift +112 -0
  79. package/ios/Tests/RetryLoopTests.swift +270 -0
  80. package/ios/Tests/Support/TestTypes.swift +111 -112
  81. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  82. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  83. package/ios/TrackSelector.swift +1 -1
  84. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  85. package/ios/Util/Emitter.swift +42 -0
  86. package/ios/Util/GenerationMemo.swift +36 -0
  87. package/ios/Util/LRUCache.swift +23 -162
  88. package/ios/Util/NetworkMonitor.swift +1 -1
  89. package/ios/Util/ReadyValue.swift +56 -0
  90. package/ios/Util/SFSymbolRenderer.swift +35 -29
  91. package/ios/Util/SVGProcessor.swift +0 -10
  92. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  93. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  94. package/lib/module/web/NativeAudioBrowser.js +1 -1
  95. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/typescript/src/types/browser.d.ts +3 -3
  97. package/package.json +1 -1
  98. package/src/types/browser.ts +3 -3
  99. package/src/web/NativeAudioBrowser.ts +1 -1
  100. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  101. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  102. package/ios/Bridge.h +0 -8
  103. package/ios/Browser/MediaResolveComposer.swift +0 -114
  104. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  105. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  106. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  107. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  108. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  109. package/ios/Observer/PlayerStateObserver.swift +0 -50
  110. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  111. package/ios/Option/PitchAlgorithms.swift +0 -17
  112. package/ios/Option/TimeEventFrequency.swift +0 -18
  113. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  114. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  115. package/ios/Player/PlaybackCoordinator.swift +0 -761
  116. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  117. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  118. package/ios/Player/PlaybackStateStore.swift +0 -27
  119. package/ios/Player/PlayingStateManager.swift +0 -33
  120. package/ios/Player/RetryManager.swift +0 -384
  121. package/ios/Player/SeekCompletionHandler.swift +0 -5
  122. package/ios/Player/SleepTimerHandling.swift +0 -28
  123. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  124. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  125. package/ios/Tests/MediaResolveTests.swift +0 -126
  126. package/ios/Tests/OnceValueTests.swift +0 -79
  127. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  128. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  129. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  130. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  131. package/ios/Tests/RetryManagerTests.swift +0 -178
  132. package/ios/TrackPlayer.swift +0 -1042
  133. package/ios/TrackPlayerCallbacks.swift +0 -53
  134. package/ios/Util/MetadataAdapter.swift +0 -131
  135. package/ios/Util/OnceValue.swift +0 -104
  136. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  137. /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
- // "Add" targets the current track; if nothing is playing there's nothing
32
- // to add, so the assistant reports failure rather than a silent no-op.
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
- // Affinity targets the current track; if nothing is playing there's nothing
31
- // to like, so the assistant reports failure rather than a silent no-op.
32
- guard let browser = HybridAudioBrowser.shared,
33
- browser.getPlayer()?.currentTrack != nil
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
- // Static + gate-waiting, so it works even before the shared instance exists
29
- // (background intent launch, RN not booted yet).
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
- /// HTTP response data (matches Kotlin HttpClient.HttpResponse).
40
- struct HttpResponse {
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
- init(code: Int, responseBody: String) {
56
- self.code = code
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 errorMessage = json["error"] as? String
48
+ let message = json["error"] as? String
65
49
  {
66
- return errorMessage
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
- /// Creates a new HTTP client.
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
- /// Executes an HTTP request (matches Kotlin request() signature).
79
- func request(_ httpRequest: HttpRequest) async -> Result<HttpResponse, Error> {
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
- // An explicit `User-Agent` in `headers` wins, matching the two sibling paths
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
- // Add body for non-GET requests
105
- switch httpRequest.method.uppercased() {
90
+ let method = httpRequest.method.uppercased()
91
+ switch method {
106
92
  case "GET":
107
- urlRequest.httpMethod = "GET"
93
+ urlRequest.httpMethod = method
108
94
  case "POST", "PUT", "PATCH":
109
- urlRequest.httpMethod = httpRequest.method.uppercased()
95
+ urlRequest.httpMethod = method
110
96
  urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
111
- if let body = httpRequest.body {
112
- urlRequest.httpBody = body.data(using: .utf8)
113
- }
97
+ urlRequest.httpBody = httpRequest.body?.data(using: .utf8)
114
98
  case "DELETE":
115
- urlRequest.httpMethod = "DELETE"
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
- return .failure(NSError(
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
- guard let httpResponse = response as? HTTPURLResponse else {
135
- return .failure(NSError(
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
- logError(error, url: httpRequest.url)
164
- return .failure(error)
115
+ logger.error("✗ Error for \(httpRequest.url): \(error.localizedDescription)")
116
+ throw error
165
117
  }
166
118
  }
167
119
 
168
- /// Executes an HTTP request and decodes the JSON response (matches Kotlin requestJson()).
169
- func requestJson<T: Decodable>(
170
- _ httpRequest: HttpRequest,
171
- as type: T.Type,
172
- ) async throws -> T {
173
- let result = await request(httpRequest)
174
-
175
- switch result {
176
- case let .success(response):
177
- if !response.isSuccessful {
178
- throw HttpException(code: response.code, responseBody: response.body)
179
- }
180
- guard let data = response.body.data(using: .utf8) else {
181
- throw NSError(
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
- if let headers = request.headers, !headers.isEmpty {
221
- for (key, value) in headers {
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
- let truncated = body.prefix(500)
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(_ response: HttpResponse, url: String) {
232
- let emoji = response.isSuccessful ? "✓" : "✗"
233
- logger.debug("← \(emoji) \(response.code) \(url)")
234
- if !response.body.isEmpty {
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
  }