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,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
|
+
}
|
package/ios/TrackSelector.swift
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/ios/Util/Emitter.swift
CHANGED
|
@@ -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
|
+
}
|
package/ios/Util/LRUCache.swift
CHANGED
|
@@ -1,185 +1,46 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
private var
|
|
30
|
-
private let lock = NSLock()
|
|
6
|
+
private var entries: [Key: Value] = [:]
|
|
7
|
+
/// Least recently used first.
|
|
8
|
+
private var order: [Key] = []
|
|
31
9
|
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
|
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
|
|
983
|
-
*
|
|
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
package/src/types/browser.ts
CHANGED
|
@@ -1074,7 +1074,7 @@ export type CarPlaySiriListButtonPosition = 'top' | 'bottom'
|
|
|
1074
1074
|
*/
|
|
1075
1075
|
export type FormattedNavigationError = {
|
|
1076
1076
|
/**
|
|
1077
|
-
* Title
|
|
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
|
|
1089
|
-
*
|
|
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
|
|
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
|
|
6
|
+
// ios Player.playingState) so web derives PlayingState identically.
|
|
7
7
|
const ALL_STATES: PlaybackState[] = [
|
|
8
8
|
'none',
|
|
9
9
|
'ready',
|