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.
Files changed (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -0,0 +1,20 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ @Suite("URLError.isConnectivity")
7
+ struct URLErrorConnectivityTests {
8
+ @Test(arguments: [
9
+ URLError.Code.timedOut, .networkConnectionLost, .notConnectedToInternet, .cannotConnectToHost,
10
+ .cannotFindHost, .dnsLookupFailed,
11
+ ])
12
+ func aMissingConnectionIsWorthRepeating(_ code: URLError.Code) {
13
+ #expect(URLError(code).isConnectivity)
14
+ }
15
+
16
+ @Test(arguments: [URLError.Code.badServerResponse, .fileDoesNotExist, .badURL, .cancelled, .userAuthenticationRequired])
17
+ func anyOtherFailureIsNot(_ code: URLError.Code) {
18
+ #expect(!URLError(code).isConnectivity)
19
+ }
20
+ }
@@ -14,7 +14,7 @@ import os.log
14
14
  }
15
15
 
16
16
  /// Encapsulates the shared track selection decision tree used by both
17
- /// HybridAudioBrowser and CarPlayController.
17
+ /// HybridAudioBrowser and CarPlaySession.
18
18
  ///
19
19
  /// Resolves a track selection to a concrete `SelectionResult` describing
20
20
  /// what the caller should do. Each caller then executes the action in its
@@ -1,4 +1,5 @@
1
1
  import Kingfisher
2
+ import Network
2
3
  import NitroModules
3
4
  import os.log
4
5
  import UIKit
@@ -7,11 +8,14 @@ import UIKit
7
8
  enum ArtworkImageFetcher {
8
9
  private static let logger = Logger(subsystem: "com.audiobrowser", category: "ArtworkImageFetcher")
9
10
 
11
+ /// - Parameter waitsForConnectivity: A fetch that fails for want of a connection waits for one
12
+ /// and tries again. A cancelled fetch that is waiting ends when the connection returns.
10
13
  @MainActor
11
14
  static func fetchImage(
12
15
  from source: ImageSource,
13
16
  svgScale: CGFloat? = nil,
14
17
  downsampleTo: (size: CGSize, scale: CGFloat)? = nil,
18
+ waitsForConnectivity: Bool = false,
15
19
  ) async -> UIImage? {
16
20
  guard let url = URL(string: source.uri) else { return nil }
17
21
 
@@ -41,6 +45,10 @@ enum ArtworkImageFetcher {
41
45
  options.append(.cacheOriginalImage)
42
46
  }
43
47
 
48
+ if waitsForConnectivity {
49
+ options.append(.retryStrategy(ConnectivityRetry()))
50
+ }
51
+
44
52
  do {
45
53
  let result = try await KingfisherManager.shared.retrieveImage(with: url, options: options)
46
54
  return result.image
@@ -50,3 +58,37 @@ enum ArtworkImageFetcher {
50
58
  }
51
59
  }
52
60
  }
61
+
62
+ /// Repeats a fetch that failed for want of a connection once one is back, a few times at most; any
63
+ /// other failure, a 404 included, stops at once.
64
+ private struct ConnectivityRetry: RetryStrategy {
65
+ private static let maxRetries = 3
66
+
67
+ func retry(context: RetryContext, retryHandler: @escaping @Sendable (RetryDecision) -> Void) {
68
+ guard context.retriedCount < Self.maxRetries,
69
+ case let .responseError(reason: .URLSessionError(error)) = context.error,
70
+ (error as? URLError)?.isConnectivity == true
71
+ else {
72
+ retryHandler(.stop)
73
+ return
74
+ }
75
+ Self.whenConnected { retryHandler(.retry(userInfo: nil)) }
76
+ }
77
+
78
+ /// Calls `body` once the device has a usable path, at once when it has one already: a monitor
79
+ /// reports the current path when it starts, so a reconnect cannot slip past it. The handler
80
+ /// holds the monitor until it fires.
81
+ private static func whenConnected(_ body: @escaping @Sendable () -> Void) {
82
+ let monitor = NWPathMonitor()
83
+ monitor.pathUpdateHandler = { path in
84
+ guard path.status == .satisfied, monitor.pathUpdateHandler != nil else { return }
85
+ monitor.pathUpdateHandler = nil
86
+ monitor.cancel()
87
+ body()
88
+ }
89
+ monitor.start(queue: queue)
90
+ }
91
+
92
+ /// Serial, so a monitor's handler never runs twice at once.
93
+ private static let queue = DispatchQueue(label: "com.audiobrowser.artwork-connectivity", qos: .utility)
94
+ }
@@ -66,3 +66,45 @@ public final class Emitter<T>: @unchecked Sendable {
66
66
  return snapshot.count
67
67
  }
68
68
  }
69
+
70
+ /// A listener registration that ends when cancelled or released.
71
+ final class Subscription {
72
+ private var cancelHandler: (() -> Void)?
73
+
74
+ init(cancel: @escaping () -> Void) {
75
+ cancelHandler = cancel
76
+ }
77
+
78
+ func cancel() {
79
+ cancelHandler?()
80
+ cancelHandler = nil
81
+ }
82
+
83
+ deinit {
84
+ cancelHandler?()
85
+ }
86
+ }
87
+
88
+ extension Emitter {
89
+ /// Adds `listener` until the returned subscription is cancelled or released.
90
+ func subscribe(_ listener: @escaping (T) -> Void) -> Subscription {
91
+ let token = addListener(listener)
92
+ return Subscription { [weak self] in self?.removeListener(token) }
93
+ }
94
+ }
95
+
96
+ extension Emitter {
97
+ /// Like `subscribe`, but `handler` runs on the main actor after the emit returns, whichever
98
+ /// thread emitted.
99
+ func subscribeOnMain(_ handler: @escaping @MainActor (T) -> Void) -> Subscription {
100
+ subscribe { value in
101
+ let event = MainActorHop(value: value)
102
+ Task { @MainActor in handler(event.value) }
103
+ }
104
+ }
105
+ }
106
+
107
+ /// Carries an emitted value to the main actor, where it is only read.
108
+ private struct MainActorHop<T>: @unchecked Sendable {
109
+ let value: T
110
+ }
@@ -0,0 +1,36 @@
1
+ /// A value computed at most once per generation and shared by every caller of that generation.
2
+ ///
3
+ /// `invalidate()` starts a new generation. A caller whose computation finishes or fails after
4
+ /// an invalidation computes again, so `value(_:)` never returns a stale result; `compute` must
5
+ /// therefore read the state it depends on when it runs, not capture it. A failure is not kept:
6
+ /// the next caller of the same generation tries again. A cancelled caller stops retrying.
7
+ @MainActor
8
+ final class GenerationMemo<Value: Sendable> {
9
+ private var generation = 0
10
+ private var current: (generation: Int, task: Task<Value, Error>)?
11
+
12
+ func invalidate() {
13
+ generation += 1
14
+ }
15
+
16
+ func value(_ compute: @escaping @MainActor () async throws -> Value) async throws -> Value {
17
+ while true {
18
+ try Task.checkCancellation()
19
+ let generation = generation
20
+ let task: Task<Value, Error>
21
+ if let current, current.generation == generation {
22
+ task = current.task
23
+ } else {
24
+ task = Task { try await compute() }
25
+ current = (generation, task)
26
+ }
27
+ do {
28
+ let value = try await task.value
29
+ if generation == self.generation { return value }
30
+ } catch {
31
+ if current?.task == task { current = nil }
32
+ if generation == self.generation { throw error }
33
+ }
34
+ }
35
+ }
36
+ }
@@ -1,185 +1,46 @@
1
- import Foundation
2
-
3
- /// A thread-safe Least Recently Used (LRU) cache with O(1) operations.
4
- ///
5
- /// When the cache exceeds its maximum size, the least recently accessed
6
- /// items are evicted to make room for new entries.
7
- ///
8
- /// Uses a doubly-linked list + dictionary for O(1) get/set/remove operations.
1
+ /// A cache of at most `maxSize` entries that evicts the least recently used one.
2
+ /// Sized for a handful of entries: a use moves the key within an array.
3
+ @MainActor
9
4
  final class LRUCache<Key: Hashable, Value> {
10
- /// Doubly-linked list node for O(1) removal and reordering.
11
- private final class Node {
12
- let key: Key
13
- var value: Value
14
- /// Weak: with both directions strong, any two adjacent nodes form a reference cycle, so the
15
- /// whole chain outlives `clear()` and the cache's own deinit. The list is kept alive from
16
- /// `head` forwards.
17
- weak var prev: Node?
18
- var next: Node?
19
-
20
- init(key: Key, value: Value) {
21
- self.key = key
22
- self.value = value
23
- }
24
- }
25
-
26
5
  private let maxSize: Int
27
- private var cache: [Key: Node] = [:]
28
- private var head: Node? // Most recently used
29
- private var tail: Node? // Least recently used
30
- private let lock = NSLock()
6
+ private var entries: [Key: Value] = [:]
7
+ /// Least recently used first.
8
+ private var order: [Key] = []
31
9
 
32
- /// Creates a new LRU cache with the specified maximum size.
33
- ///
34
- /// - Parameter maxSize: The maximum number of items the cache can hold
35
- init(maxSize: Int) {
10
+ nonisolated init(maxSize: Int) {
36
11
  self.maxSize = maxSize
37
12
  }
38
13
 
39
- /// Moves a node to the head (most recently used position).
40
- private func moveToHead(_ node: Node) {
41
- guard node !== head else { return }
42
-
43
- // Remove from current position
44
- node.prev?.next = node.next
45
- node.next?.prev = node.prev
46
-
47
- if node === tail {
48
- tail = node.prev
49
- }
50
-
51
- // Insert at head
52
- node.prev = nil
53
- node.next = head
54
- head?.prev = node
55
- head = node
56
-
57
- if tail == nil {
58
- tail = node
59
- }
60
- }
61
-
62
- /// Removes a node from the list.
63
- private func removeNode(_ node: Node) {
64
- node.prev?.next = node.next
65
- node.next?.prev = node.prev
66
-
67
- if node === head {
68
- head = node.next
69
- }
70
- if node === tail {
71
- tail = node.prev
72
- }
73
-
74
- node.prev = nil
75
- node.next = nil
76
- }
77
-
78
- /// Adds a node at the head (most recently used position).
79
- private func addToHead(_ node: Node) {
80
- node.prev = nil
81
- node.next = head
82
- head?.prev = node
83
- head = node
84
-
85
- if tail == nil {
86
- tail = node
87
- }
88
- }
89
-
90
- /// Removes and returns the tail node (least recently used).
91
- private func removeTail() -> Node? {
92
- guard let tailNode = tail else { return nil }
93
- removeNode(tailNode)
94
- return tailNode
14
+ var count: Int {
15
+ entries.count
95
16
  }
96
17
 
97
- /// Gets a value from the cache, updating its access order.
98
- ///
99
- /// - Parameter key: The key to look up
100
- /// - Returns: The cached value, or nil if not found
101
18
  func get(_ key: Key) -> Value? {
102
- lock.lock()
103
- defer { lock.unlock() }
104
-
105
- guard let node = cache[key] else {
106
- return nil
107
- }
108
-
109
- // Move to head (most recently used)
110
- moveToHead(node)
111
- return node.value
19
+ guard let value = entries[key] else { return nil }
20
+ touch(key)
21
+ return value
112
22
  }
113
23
 
114
- /// Sets a value in the cache.
115
- ///
116
- /// If the cache is full, the least recently used item is evicted.
117
- ///
118
- /// - Parameters:
119
- /// - key: The key to store
120
- /// - value: The value to store
121
24
  func set(_ key: Key, value: Value) {
122
- lock.lock()
123
- defer { lock.unlock() }
124
-
125
- // If key exists, update value and move to head
126
- if let existingNode = cache[key] {
127
- existingNode.value = value
128
- moveToHead(existingNode)
129
- return
25
+ entries[key] = value
26
+ touch(key)
27
+ if order.count > maxSize {
28
+ entries.removeValue(forKey: order.removeFirst())
130
29
  }
131
-
132
- // Evict oldest if at capacity
133
- if cache.count >= maxSize, let evicted = removeTail() {
134
- cache.removeValue(forKey: evicted.key)
135
- }
136
-
137
- // Add new entry at head
138
- let newNode = Node(key: key, value: value)
139
- cache[key] = newNode
140
- addToHead(newNode)
141
30
  }
142
31
 
143
- /// Removes a value from the cache.
144
- ///
145
- /// - Parameter key: The key to remove
146
32
  func remove(_ key: Key) {
147
- lock.lock()
148
- defer { lock.unlock() }
149
-
150
- guard let node = cache[key] else { return }
151
- removeNode(node)
152
- cache.removeValue(forKey: key)
33
+ entries.removeValue(forKey: key)
34
+ order.removeAll { $0 == key }
153
35
  }
154
36
 
155
- /// Clears all entries from the cache.
156
37
  func clear() {
157
- lock.lock()
158
- defer { lock.unlock() }
159
-
160
- cache.removeAll()
161
- head = nil
162
- tail = nil
163
- }
164
-
165
- /// The current number of items in the cache.
166
- var count: Int {
167
- lock.lock()
168
- defer { lock.unlock() }
169
- return cache.count
170
- }
171
-
172
- /// Returns all values in the cache (no particular order guaranteed).
173
- var values: [Value] {
174
- lock.lock()
175
- defer { lock.unlock() }
176
- return cache.values.map(\.value)
38
+ entries.removeAll()
39
+ order.removeAll()
177
40
  }
178
41
 
179
- /// Returns all keys in the cache (no particular order guaranteed).
180
- var keys: [Key] {
181
- lock.lock()
182
- defer { lock.unlock() }
183
- return Array(cache.keys)
42
+ private func touch(_ key: Key) {
43
+ order.removeAll { $0 == key }
44
+ order.append(key)
184
45
  }
185
46
  }
@@ -59,7 +59,7 @@ final class NetworkMonitor: @unchecked Sendable {
59
59
  // update with a `currentPath` the monitor hasn't finished evaluating. Nothing
60
60
  // recovers from that: the handler has already fired, `onChanged` isn't wired
61
61
  // until setupPlayer, and the stale value then drives `getOnline()`, the JS
62
- // event, and RetryManager's offline parking until the next genuine path
62
+ // event, and RetryLoop's offline parking until the next genuine path
63
63
  // change. Seeding first is a best-effort guess; the handler fires shortly
64
64
  // after `start` and is the authoritative last writer.
65
65
  let initialStatus = monitor.currentPath.status == .satisfied
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+
3
+ /// A value that exists once `provider` returns one. `wait` returns it, parking until `check()`
4
+ /// finds it; nothing is cached, so the provider's current answer always wins.
5
+ @MainActor
6
+ final class ReadyValue<T: Sendable> {
7
+ private let provider: () -> T?
8
+ private var waiters: [UUID: CheckedContinuation<T?, Never>] = [:]
9
+
10
+ init(provider: @escaping () -> T?) {
11
+ self.provider = provider
12
+ }
13
+
14
+ var waiterCount: Int {
15
+ waiters.count
16
+ }
17
+
18
+ /// Resumes every parked waiter if the provider now has a value.
19
+ func check() {
20
+ guard !waiters.isEmpty, let value = provider() else { return }
21
+ let pending = waiters
22
+ waiters = [:]
23
+ for waiter in pending.values {
24
+ waiter.resume(returning: value)
25
+ }
26
+ }
27
+
28
+ /// The provider's value, waiting for it when there is none yet. Nil when the calling task is
29
+ /// cancelled or `timeout` elapses first.
30
+ func wait(timeout: Duration? = nil) async -> T? {
31
+ if let value = provider() { return value }
32
+ let id = UUID()
33
+ let timer = timeout.map { timeout in
34
+ Task { [weak self] in
35
+ try? await Task.sleep(for: timeout)
36
+ self?.resume(id, with: nil)
37
+ }
38
+ }
39
+ defer { timer?.cancel() }
40
+ return await withTaskCancellationHandler {
41
+ await withCheckedContinuation { continuation in
42
+ if Task.isCancelled {
43
+ continuation.resume(returning: nil)
44
+ } else {
45
+ waiters[id] = continuation
46
+ }
47
+ }
48
+ } onCancel: {
49
+ Task { @MainActor [weak self] in self?.resume(id, with: nil) }
50
+ }
51
+ }
52
+
53
+ private func resume(_ id: UUID, with value: T?) {
54
+ waiters.removeValue(forKey: id)?.resume(returning: value)
55
+ }
56
+ }
@@ -41,15 +41,6 @@ final class SFSymbolRenderer: @unchecked Sendable {
41
41
  /// - canvasSize: The canvas size in points (rendered at device scale)
42
42
  /// - Returns: A file URI string, or nil if the symbol doesn't exist or rendering fails
43
43
  func render(_ artworkValue: String, canvasSize: CGSize = defaultCanvasSize) -> String? {
44
- let (symbolName, backgroundColor, symbolColor) = Self.parseArtwork(artworkValue)
45
-
46
- // Validate symbol exists before any filesystem work
47
- let symbolPointSize = min(canvasSize.width, canvasSize.height) * 0.45
48
- let config = UIImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .medium)
49
- guard let symbol = UIImage(systemName: symbolName, withConfiguration: config) else {
50
- return nil
51
- }
52
-
53
44
  let safeArtwork = artworkValue.unicodeScalars
54
45
  .filter { CharacterSet.alphanumerics.contains($0) || $0 == "_" }
55
46
  .map(String.init).joined()
@@ -61,26 +52,11 @@ final class SFSymbolRenderer: @unchecked Sendable {
61
52
  return fileURL.absoluteString
62
53
  }
63
54
 
64
- let renderer = UIGraphicsImageRenderer(size: canvasSize, format: {
65
- let fmt = UIGraphicsImageRendererFormat()
66
- fmt.scale = scale
67
- return fmt
68
- }())
69
-
70
- let pngData = renderer.pngData { _ in
71
- if let backgroundColor {
72
- backgroundColor.setFill()
73
- UIRectFill(CGRect(origin: .zero, size: canvasSize))
74
- }
75
-
76
- let symbolSize = symbol.size
77
- let x = (canvasSize.width - symbolSize.width) / 2
78
- let y = (canvasSize.height - symbolSize.height) / 2
79
-
80
- (symbolColor ?? .black).set()
81
- symbol.withRenderingMode(.alwaysTemplate)
82
- .draw(in: CGRect(x: x, y: y, width: symbolSize.width, height: symbolSize.height))
83
- }
55
+ let (symbolName, backgroundColor, symbolColor) = Self.parseArtwork(artworkValue)
56
+ guard let pngData = Self.image(
57
+ name: symbolName, canvasSize: canvasSize, scale: scale,
58
+ tint: symbolColor ?? .black, background: backgroundColor,
59
+ )?.pngData() else { return nil }
84
60
 
85
61
  do {
86
62
  try pngData.write(to: fileURL, options: .atomic)
@@ -92,6 +68,36 @@ final class SFSymbolRenderer: @unchecked Sendable {
92
68
  return fileURL.absoluteString
93
69
  }
94
70
 
71
+ /// Draws the symbol at 45% of the canvas, centred and tinted, over `background` (nil leaves it
72
+ /// transparent). Nil when there is no symbol of that name.
73
+ static func image(
74
+ name: String,
75
+ canvasSize: CGSize,
76
+ scale: CGFloat,
77
+ tint: UIColor,
78
+ background: UIColor?,
79
+ ) -> UIImage? {
80
+ let pointSize = min(canvasSize.width, canvasSize.height) * 0.45
81
+ let config = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
82
+ guard let symbol = UIImage(systemName: name, withConfiguration: config) else { return nil }
83
+ let format = UIGraphicsImageRendererFormat()
84
+ format.scale = scale
85
+ return UIGraphicsImageRenderer(size: canvasSize, format: format).image { _ in
86
+ if let background {
87
+ background.setFill()
88
+ UIRectFill(CGRect(origin: .zero, size: canvasSize))
89
+ }
90
+ let size = symbol.size
91
+ tint.set()
92
+ symbol.withRenderingMode(.alwaysTemplate).draw(in: CGRect(
93
+ x: (canvasSize.width - size.width) / 2,
94
+ y: (canvasSize.height - size.height) / 2,
95
+ width: size.width,
96
+ height: size.height,
97
+ ))
98
+ }.withRenderingMode(.alwaysOriginal)
99
+ }
100
+
95
101
  /// Whether the given artwork string is an SF Symbol reference.
96
102
  static func isSFSymbol(_ artwork: String) -> Bool {
97
103
  artwork.hasPrefix("sf:")
@@ -51,13 +51,3 @@ struct SVGProcessor: ImageProcessor {
51
51
  return svg.rasterize(size: renderSize, scale: scale)
52
52
  }
53
53
  }
54
-
55
- // MARK: - Kingfisher Options Extension
56
-
57
- extension KingfisherOptionsInfoItem {
58
- /// Returns an SVG processor option for Kingfisher.
59
- @MainActor
60
- static func svgProcessor(size: CGSize? = nil, scale: CGFloat? = nil) -> KingfisherOptionsInfoItem {
61
- .processor(SVGProcessor(size: size, scale: scale))
62
- }
63
- }
@@ -224,7 +224,7 @@ class NativeAudioBrowser extends _TrackPlayer.QueuePlayer {
224
224
  * Re-derives the playing state and emits on change. Called from both of its
225
225
  * inputs' change points — the state setter and the playWhenReady setter —
226
226
  * with a dedupe so identical derivations don't double-emit (parity with
227
- * Android's refreshPlayingState / iOS's PlayingStateManager).
227
+ * Android's refreshPlayingState / iOS's Player.playingState).
228
228
  */
229
229
  refreshPlayingState() {
230
230
  const next = (0, _PlayingStateFactory.derivePlayingState)(this._playWhenReady, this.state.state);
@@ -9,7 +9,7 @@ exports.derivePlayingState = derivePlayingState;
9
9
  * player's intent (`playWhenReady`) and its current {@link PlaybackState}.
10
10
  *
11
11
  * A faithful port of the native derivations (android `PlayingStateFactory.derive`,
12
- * ios `PlayingStateManager.update`) so all platforms agree on what "playing" and
12
+ * ios `Player.playingState`) so all platforms agree on what "playing" and
13
13
  * "buffering" mean for the now-playing UI.
14
14
  */
15
15
  function derivePlayingState(playWhenReady, state) {
@@ -221,7 +221,7 @@ export class NativeAudioBrowser extends QueuePlayer {
221
221
  * Re-derives the playing state and emits on change. Called from both of its
222
222
  * inputs' change points — the state setter and the playWhenReady setter —
223
223
  * with a dedupe so identical derivations don't double-emit (parity with
224
- * Android's refreshPlayingState / iOS's PlayingStateManager).
224
+ * Android's refreshPlayingState / iOS's Player.playingState).
225
225
  */
226
226
  refreshPlayingState() {
227
227
  const next = derivePlayingState(this._playWhenReady, this.state.state);
@@ -5,7 +5,7 @@
5
5
  * player's intent (`playWhenReady`) and its current {@link PlaybackState}.
6
6
  *
7
7
  * A faithful port of the native derivations (android `PlayingStateFactory.derive`,
8
- * ios `PlayingStateManager.update`) so all platforms agree on what "playing" and
8
+ * ios `Player.playingState`) so all platforms agree on what "playing" and
9
9
  * "buffering" mean for the now-playing UI.
10
10
  */
11
11
  export function derivePlayingState(playWhenReady, state) {
@@ -968,7 +968,7 @@ export type CarPlaySiriListButtonPosition = 'top' | 'bottom';
968
968
  */
969
969
  export type FormattedNavigationError = {
970
970
  /**
971
- * Title shown in the error action sheet header.
971
+ * Title of the error.
972
972
  *
973
973
  * Default values:
974
974
  * - `'content-not-found'`: "Content Not Found" (English)
@@ -979,8 +979,8 @@ export type FormattedNavigationError = {
979
979
  */
980
980
  title: string;
981
981
  /**
982
- * Optional second line — the body in the error action sheet, the subtitle in
983
- * the browse error/empty view. Omit it to show a title only.
982
+ * Optional second line — the subtitle in the browse error/empty view. Omit it
983
+ * to show a title only.
984
984
  *
985
985
  * Default value: `error.message` (omitted when that is empty).
986
986
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-browser",
3
- "version": "0.2.0-next.27",
3
+ "version": "0.2.0-next.29",
4
4
  "description": "react-native-audio-browser is a react native package built with Nitro",
5
5
  "keywords": [
6
6
  "react-native",
@@ -1074,7 +1074,7 @@ export type CarPlaySiriListButtonPosition = 'top' | 'bottom'
1074
1074
  */
1075
1075
  export type FormattedNavigationError = {
1076
1076
  /**
1077
- * Title shown in the error action sheet header.
1077
+ * Title of the error.
1078
1078
  *
1079
1079
  * Default values:
1080
1080
  * - `'content-not-found'`: "Content Not Found" (English)
@@ -1085,8 +1085,8 @@ export type FormattedNavigationError = {
1085
1085
  */
1086
1086
  title: string
1087
1087
  /**
1088
- * Optional second line — the body in the error action sheet, the subtitle in
1089
- * the browse error/empty view. Omit it to show a title only.
1088
+ * Optional second line — the subtitle in the browse error/empty view. Omit it
1089
+ * to show a title only.
1090
1090
  *
1091
1091
  * Default value: `error.message` (omitted when that is empty).
1092
1092
  */
@@ -328,7 +328,7 @@ export class NativeAudioBrowser
328
328
  * Re-derives the playing state and emits on change. Called from both of its
329
329
  * inputs' change points — the state setter and the playWhenReady setter —
330
330
  * with a dedupe so identical derivations don't double-emit (parity with
331
- * Android's refreshPlayingState / iOS's PlayingStateManager).
331
+ * Android's refreshPlayingState / iOS's Player.playingState).
332
332
  */
333
333
  private refreshPlayingState(): void {
334
334
  const next = derivePlayingState(this._playWhenReady, this.state.state)
@@ -3,7 +3,7 @@ import type { PlaybackState } from '../../features'
3
3
  import { derivePlayingState } from './PlayingStateFactory'
4
4
 
5
5
  // Ported from the native truth table (android PlayingStateFactoryTest.kt /
6
- // ios PlayingStateManager) so web derives PlayingState identically.
6
+ // ios Player.playingState) so web derives PlayingState identically.
7
7
  const ALL_STATES: PlaybackState[] = [
8
8
  'none',
9
9
  'ready',