react-native-audio-browser 0.2.0-next.32 → 0.2.0-next.34
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/ios/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- package/ios/Browser/BrowserConfig.swift +0 -5
- package/ios/Browser/BrowserError.swift +16 -0
- package/ios/Browser/BrowserManager.swift +36 -141
- package/ios/Browser/BrowserPathHelper.swift +25 -12
- package/ios/Browser/FavoriteSet.swift +46 -0
- package/ios/Browser/JsonModels.swift +79 -199
- package/ios/Browser/PageResolver.swift +16 -96
- package/ios/Browser/RequestLayer.swift +97 -2
- package/ios/Browser/RequestPipeline.swift +78 -227
- package/ios/Browser/SectionScope.swift +25 -0
- package/ios/Browser/SimpleRouter.swift +73 -144
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
- package/ios/CarPlay/CarPlaySession.swift +3 -3
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- package/ios/Extension/Capability+RemoteCommand.swift +22 -69
- package/ios/Extension/SearchParams+Query.swift +28 -0
- package/ios/Extension/Track+AVPlayer.swift +0 -16
- package/ios/Extension/Track+Copying.swift +3 -1
- package/ios/Extension/Track+Identity.swift +7 -0
- package/ios/Http/HttpClient.swift +12 -30
- package/ios/HybridAudioBrowser+Browser.swift +21 -38
- package/ios/HybridAudioBrowser+Options.swift +27 -66
- package/ios/HybridAudioBrowser+Player.swift +4 -60
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
- package/ios/HybridAudioBrowser.swift +36 -99
- package/ios/Model/NitroTypeStubs.swift +80 -0
- package/ios/Model/PlayerUpdateOptions.swift +2 -0
- package/ios/Model/RemoteCommand.swift +6 -78
- package/ios/Player/NowPlayingUpdater.swift +13 -7
- package/ios/Player/Player.swift +26 -10
- package/ios/Player/PlayerCallbacks.swift +1 -4
- package/ios/Player/PlayerEngine.swift +17 -0
- package/ios/Player/RetryLoop.swift +5 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +95 -17
- package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/SearchParamsQueryTests.swift +48 -0
- package/ios/Tests/SectionScopeTests.swift +53 -0
- package/ios/Tests/SimpleRouterTests.swift +38 -3
- package/ios/Tests/Support/TestTypes.swift +44 -3
- package/ios/TrackSelector.swift +34 -52
- package/ios/Util/Emitter.swift +0 -14
- package/ios/Util/NetworkMonitor.swift +8 -25
- package/ios/Util/OutputPicker.swift +48 -0
- package/package.json +1 -1
- package/ios/Model/SourceType.swift +0 -6
- package/ios/Tests/RemoteCommandTests.swift +0 -65
|
@@ -8,11 +8,6 @@ struct RouteMatch {
|
|
|
8
8
|
|
|
9
9
|
/// Specificity score (higher = more specific match)
|
|
10
10
|
let specificity: Int
|
|
11
|
-
|
|
12
|
-
init(params: [String: String] = [:], specificity: Int = 0) {
|
|
13
|
-
self.params = params
|
|
14
|
-
self.specificity = specificity
|
|
15
|
-
}
|
|
16
11
|
}
|
|
17
12
|
|
|
18
13
|
/// Protocol for route entries that can be matched.
|
|
@@ -20,165 +15,99 @@ protocol RouteEntry {
|
|
|
20
15
|
var path: String { get }
|
|
21
16
|
}
|
|
22
17
|
|
|
23
|
-
///
|
|
18
|
+
/// Route pattern matching with parameter extraction.
|
|
24
19
|
///
|
|
25
20
|
/// Supports patterns like:
|
|
26
21
|
/// - /artists - exact match
|
|
27
22
|
/// - /artists/{id} - parameter extraction
|
|
28
|
-
/// - /artists/{id}/albums - multiple segments with parameters
|
|
29
23
|
/// - /artists/* - wildcard match (matches any single segment)
|
|
30
|
-
/// - /files/** - tail wildcard (matches remaining path segments)
|
|
24
|
+
/// - /files/** - tail wildcard (matches remaining path segments, captured as `tail`)
|
|
31
25
|
///
|
|
32
|
-
///
|
|
33
|
-
///
|
|
34
|
-
|
|
35
|
-
/// -
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
///
|
|
41
|
-
///
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
26
|
+
/// The most specific route wins, the first on a tie: constant segments over parameters over
|
|
27
|
+
/// wildcards over the tail wildcard, then more segments.
|
|
28
|
+
enum SimpleRouter {
|
|
29
|
+
/// The route the top-level `browse:` source is flattened into.
|
|
30
|
+
static let defaultRoutePath = "__default__"
|
|
31
|
+
static let tabsRoutePath = "__tabs__"
|
|
32
|
+
static let searchRoutePath = "__search__"
|
|
33
|
+
|
|
34
|
+
/// The route that browses `path`: the best match among the routes that are not tabs, search or
|
|
35
|
+
/// the default, else the default route with `path` as its `path` param.
|
|
36
|
+
static func browseRoute<T: RouteEntry>(for path: String, in routes: [T]) -> (route: T, match: RouteMatch)? {
|
|
37
|
+
let browseRoutes = routes.filter {
|
|
38
|
+
$0.path != tabsRoutePath && $0.path != searchRoutePath && $0.path != defaultRoutePath
|
|
39
|
+
}
|
|
40
|
+
if let match = findBestMatch(path: path, routes: browseRoutes) {
|
|
41
|
+
return match
|
|
42
|
+
}
|
|
43
|
+
guard let defaultRoute = routes.first(where: { $0.path == defaultRoutePath }) else { return nil }
|
|
44
|
+
return (defaultRoute, RouteMatch(params: ["path": path], specificity: 0))
|
|
45
|
+
}
|
|
45
46
|
|
|
47
|
+
/// The most specific of `routes` matching `path`, or nil when none does.
|
|
48
|
+
static func findBestMatch<T: RouteEntry>(path: String, routes: [T]) -> (route: T, match: RouteMatch)? {
|
|
49
|
+
var best: (route: T, match: RouteMatch)?
|
|
46
50
|
for route in routes {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
+
guard let match = match(pattern: route.path, path: path) else { continue }
|
|
52
|
+
if best.map({ match.specificity > $0.match.specificity }) ?? true {
|
|
53
|
+
best = (route, match)
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
|
-
|
|
54
|
-
return bestMatch
|
|
56
|
+
return best
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
var parameterSegments = 0
|
|
70
|
-
var wildcardSegments = 0
|
|
71
|
-
var hasTailWildcard = false
|
|
72
|
-
|
|
73
|
-
// Check for tail wildcard at end
|
|
74
|
-
if let lastSegment = patternSegments.last, lastSegment == "**" {
|
|
75
|
-
hasTailWildcard = true
|
|
76
|
-
|
|
77
|
-
// For tail wildcard, pattern must have <= path segments
|
|
78
|
-
if patternSegments.count - 1 > pathSegments.count {
|
|
79
|
-
return nil
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Match all segments except the tail wildcard
|
|
83
|
-
for i in 0 ..< (patternSegments.count - 1) {
|
|
84
|
-
guard
|
|
85
|
-
matchSingleSegment(
|
|
86
|
-
patternSegment: patternSegments[i],
|
|
87
|
-
pathSegment: pathSegments[i],
|
|
88
|
-
params: ¶ms,
|
|
89
|
-
)
|
|
90
|
-
else {
|
|
91
|
-
return nil
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
updateSegmentCounts(
|
|
95
|
-
patternSegment: patternSegments[i],
|
|
96
|
-
constantSegments: &constantSegments,
|
|
97
|
-
parameterSegments: ¶meterSegments,
|
|
98
|
-
wildcardSegments: &wildcardSegments,
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Capture remaining path segments as "tail" parameter (matches Kotlin behavior)
|
|
103
|
-
if pathSegments.count > patternSegments.count - 1 {
|
|
104
|
-
let remainingSegments = pathSegments.dropFirst(patternSegments.count - 1)
|
|
105
|
-
params["tail"] = remainingSegments.joined(separator: "/")
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
// Normal matching - must have same number of segments
|
|
109
|
-
if patternSegments.count != pathSegments.count {
|
|
110
|
-
return nil
|
|
59
|
+
private enum Segment {
|
|
60
|
+
case constant(Substring)
|
|
61
|
+
case param(String)
|
|
62
|
+
case wildcard
|
|
63
|
+
|
|
64
|
+
init(_ segment: Substring) {
|
|
65
|
+
if segment.hasPrefix("{"), segment.hasSuffix("}") {
|
|
66
|
+
self = .param(String(segment.dropFirst().dropLast()))
|
|
67
|
+
} else if segment == "*" {
|
|
68
|
+
self = .wildcard
|
|
69
|
+
} else {
|
|
70
|
+
self = .constant(segment)
|
|
111
71
|
}
|
|
72
|
+
}
|
|
112
73
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
params: ¶ms,
|
|
119
|
-
)
|
|
120
|
-
else {
|
|
121
|
-
return nil
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
updateSegmentCounts(
|
|
125
|
-
patternSegment: patternSegments[i],
|
|
126
|
-
constantSegments: &constantSegments,
|
|
127
|
-
parameterSegments: ¶meterSegments,
|
|
128
|
-
wildcardSegments: &wildcardSegments,
|
|
129
|
-
)
|
|
74
|
+
var specificity: Int {
|
|
75
|
+
switch self {
|
|
76
|
+
case .constant: 1000
|
|
77
|
+
case .param: 100
|
|
78
|
+
case .wildcard: 10
|
|
130
79
|
}
|
|
131
80
|
}
|
|
132
|
-
|
|
133
|
-
// Specificity calculation (higher = more specific):
|
|
134
|
-
// - Constants: 1000 points each
|
|
135
|
-
// - Parameters: 100 points each
|
|
136
|
-
// - Single wildcards: 10 points each
|
|
137
|
-
// - Tail wildcard: 1 point
|
|
138
|
-
// - Base path length: segments count
|
|
139
|
-
let specificity =
|
|
140
|
-
(constantSegments * 1000)
|
|
141
|
-
+ (parameterSegments * 100)
|
|
142
|
-
+ (wildcardSegments * 10)
|
|
143
|
-
+ (hasTailWildcard ? 1 : 0)
|
|
144
|
-
+ patternSegments.count
|
|
145
|
-
|
|
146
|
-
return RouteMatch(params: params, specificity: specificity)
|
|
147
81
|
}
|
|
148
82
|
|
|
149
|
-
private func
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return true
|
|
159
|
-
}
|
|
83
|
+
private static func match(pattern: String, path: String) -> RouteMatch? {
|
|
84
|
+
var patternSegments = pattern.split(separator: "/")
|
|
85
|
+
let hasTailWildcard = patternSegments.last == "**"
|
|
86
|
+
if hasTailWildcard { patternSegments.removeLast() }
|
|
87
|
+
let pathSegments = path.split(separator: "/")
|
|
88
|
+
guard hasTailWildcard
|
|
89
|
+
? pathSegments.count >= patternSegments.count
|
|
90
|
+
: pathSegments.count == patternSegments.count
|
|
91
|
+
else { return nil }
|
|
160
92
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
93
|
+
var params: [String: String] = [:]
|
|
94
|
+
// Each segment counts once more, the tail wildcard twice (its segment and its own point).
|
|
95
|
+
var specificity = patternSegments.count + (hasTailWildcard ? 2 : 0)
|
|
96
|
+
for (patternSegment, pathSegment) in zip(patternSegments, pathSegments) {
|
|
97
|
+
let segment = Segment(patternSegment)
|
|
98
|
+
switch segment {
|
|
99
|
+
case let .constant(constant):
|
|
100
|
+
guard constant == pathSegment else { return nil }
|
|
101
|
+
case let .param(name):
|
|
102
|
+
params[name] = String(pathSegment)
|
|
103
|
+
case .wildcard:
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
specificity += segment.specificity
|
|
164
107
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return patternSegment == pathSegment
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
private func updateSegmentCounts(
|
|
171
|
-
patternSegment: String,
|
|
172
|
-
constantSegments: inout Int,
|
|
173
|
-
parameterSegments: inout Int,
|
|
174
|
-
wildcardSegments: inout Int,
|
|
175
|
-
) {
|
|
176
|
-
if patternSegment.hasPrefix("{"), patternSegment.hasSuffix("}") {
|
|
177
|
-
parameterSegments += 1
|
|
178
|
-
} else if patternSegment == "*" {
|
|
179
|
-
wildcardSegments += 1
|
|
180
|
-
} else {
|
|
181
|
-
constantSegments += 1
|
|
108
|
+
if hasTailWildcard, pathSegments.count > patternSegments.count {
|
|
109
|
+
params["tail"] = pathSegments.dropFirst(patternSegments.count).joined(separator: "/")
|
|
182
110
|
}
|
|
111
|
+
return RouteMatch(params: params, specificity: specificity)
|
|
183
112
|
}
|
|
184
113
|
}
|
|
@@ -11,7 +11,7 @@ final class CarPlayNowPlayingManager {
|
|
|
11
11
|
private let interfaceController: CPInterfaceController
|
|
12
12
|
private let audioBrowser: HybridAudioBrowser
|
|
13
13
|
/// Read on each use: a re-setup replaces the bridge's player.
|
|
14
|
-
private var player: Player? { audioBrowser.
|
|
14
|
+
private var player: Player? { audioBrowser.player }
|
|
15
15
|
private let listItem: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
|
|
16
16
|
/// Pushes a browse destination: the album line's.
|
|
17
17
|
private let navigate: (_ path: String, _ title: String) -> Void
|
|
@@ -131,7 +131,7 @@ final class CarPlayNowPlayingManager {
|
|
|
131
131
|
private func refreshButtons() {
|
|
132
132
|
// A gate hides the custom buttons; the transport controls stay, as a gate never blocks
|
|
133
133
|
// playback.
|
|
134
|
-
let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.carPlayNowPlayingButtons
|
|
134
|
+
let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.playerOptions.carPlayNowPlayingButtons
|
|
135
135
|
// The template caps custom buttons at 5 (CPNowPlayingTemplate.h) and says
|
|
136
136
|
// nothing about overflow — unlike lists (trim) or the tab bar (throw). The
|
|
137
137
|
// config comes straight from JS, where duplicates and excess are
|
|
@@ -319,7 +319,7 @@ final class CarPlayNowPlayingManager {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
private func handlePlaybackRateButtonTapped() {
|
|
322
|
-
let rates = audioBrowser.playbackRates
|
|
322
|
+
let rates = audioBrowser.playerOptions.playbackRates
|
|
323
323
|
guard !rates.isEmpty, let player else { return }
|
|
324
324
|
|
|
325
325
|
let currentRate = Double(player.rate)
|
|
@@ -349,7 +349,7 @@ final class CarPlayNowPlayingManager {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
private func updateUpNextButton() {
|
|
352
|
-
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
|
|
352
|
+
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.playerOptions.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
fileprivate func handleUpNextButtonTapped() {
|
|
@@ -12,7 +12,7 @@ final class CarPlaySession {
|
|
|
12
12
|
let interfaceController: CPInterfaceController
|
|
13
13
|
let audioBrowser: HybridAudioBrowser
|
|
14
14
|
/// Read on each use: a re-setup replaces the bridge's player.
|
|
15
|
-
var player: Player? { audioBrowser.
|
|
15
|
+
var player: Player? { audioBrowser.player }
|
|
16
16
|
let trackSelector: TrackSelector
|
|
17
17
|
let imageLoader: CarPlayImageLoader
|
|
18
18
|
let listItemFactory: CarPlayListItemFactory
|
|
@@ -58,7 +58,7 @@ final class CarPlaySession {
|
|
|
58
58
|
)
|
|
59
59
|
listItemFactory = CarPlayListItemFactory(
|
|
60
60
|
isActiveTrack: { [weak audioBrowser] identity in
|
|
61
|
-
identity != nil && audioBrowser?.
|
|
61
|
+
identity != nil && audioBrowser?.player?.currentTrack?.identity == identity
|
|
62
62
|
},
|
|
63
63
|
)
|
|
64
64
|
listItemFactory.imageLoader = imageLoader
|
|
@@ -156,7 +156,7 @@ final class CarPlaySession {
|
|
|
156
156
|
// MARK: - Playing Indicators
|
|
157
157
|
|
|
158
158
|
var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
|
|
159
|
-
switch audioBrowser.carPlayPlayingIndicatorLocation {
|
|
159
|
+
switch audioBrowser.playerOptions.carPlayPlayingIndicatorLocation {
|
|
160
160
|
case .trailing: .trailing
|
|
161
161
|
case .leading: .leading
|
|
162
162
|
}
|
|
@@ -69,6 +69,6 @@ enum MediaIntentPlayback {
|
|
|
69
69
|
/// destroyed player.
|
|
70
70
|
@MainActor
|
|
71
71
|
private static func livePlayer(of browser: HybridAudioBrowser) -> Player? {
|
|
72
|
-
HybridAudioBrowser.shared === browser ? browser.
|
|
72
|
+
HybridAudioBrowser.shared === browser ? browser.player : nil
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -1,74 +1,27 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
-
import NitroModules
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/// All capabilities are enabled by default - only false values disable them
|
|
8
|
-
func buildRemoteCommands(
|
|
9
|
-
forwardJumpInterval: NSNumber?,
|
|
10
|
-
backwardJumpInterval: NSNumber?,
|
|
11
|
-
playbackRates: [Double],
|
|
12
|
-
) -> [RemoteCommand] {
|
|
13
|
-
var commands: [RemoteCommand] = []
|
|
14
|
-
|
|
15
|
-
// Play/Pause/Stop
|
|
16
|
-
if play != false {
|
|
17
|
-
commands.append(.play)
|
|
18
|
-
}
|
|
19
|
-
if pause != false {
|
|
20
|
-
commands.append(.pause)
|
|
21
|
-
}
|
|
22
|
-
if stop != false {
|
|
23
|
-
commands.append(.stop)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Navigation
|
|
27
|
-
if skipToNext != false {
|
|
28
|
-
commands.append(.next)
|
|
29
|
-
}
|
|
30
|
-
if skipToPrevious != false {
|
|
31
|
-
commands.append(.previous)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Seeking
|
|
35
|
-
if seekTo != false {
|
|
36
|
-
commands.append(.changePlaybackPosition)
|
|
37
|
-
}
|
|
3
|
+
#if canImport(NitroModules)
|
|
4
|
+
import NitroModules
|
|
5
|
+
#endif
|
|
38
6
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// Mode controls
|
|
60
|
-
if shuffleMode != false {
|
|
61
|
-
commands.append(.changeShuffleMode)
|
|
62
|
-
}
|
|
63
|
-
if repeatMode != false {
|
|
64
|
-
commands.append(.changeRepeatMode)
|
|
65
|
-
}
|
|
66
|
-
if playbackRate != false {
|
|
67
|
-
commands.append(.changePlaybackRate(
|
|
68
|
-
supportedPlaybackRates: playbackRates.map { NSNumber(value: $0) },
|
|
69
|
-
))
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return commands
|
|
7
|
+
extension PlayerCapabilities {
|
|
8
|
+
/// The remote commands the capabilities enable: each one unless set to false. `favorite`
|
|
9
|
+
/// enables none: no iOS 16+ surface invokes `likeCommand`; CarPlay has its own heart button and
|
|
10
|
+
/// Siri its affinity intent.
|
|
11
|
+
var remoteCommands: Set<RemoteCommand> {
|
|
12
|
+
let enabled: [(Bool?, RemoteCommand)] = [
|
|
13
|
+
(play, .play),
|
|
14
|
+
(pause, .pause),
|
|
15
|
+
(stop, .stop),
|
|
16
|
+
(skipToNext, .next),
|
|
17
|
+
(skipToPrevious, .previous),
|
|
18
|
+
(seekTo, .changePlaybackPosition),
|
|
19
|
+
(jumpForward, .skipForward),
|
|
20
|
+
(jumpBackward, .skipBackward),
|
|
21
|
+
(shuffleMode, .changeShuffleMode),
|
|
22
|
+
(repeatMode, .changeRepeatMode),
|
|
23
|
+
(playbackRate, .changePlaybackRate),
|
|
24
|
+
]
|
|
25
|
+
return Set(enabled.compactMap { $0.0 == false ? nil : $0.1 })
|
|
73
26
|
}
|
|
74
27
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#if canImport(NitroModules)
|
|
2
|
+
import NitroModules
|
|
3
|
+
#endif
|
|
4
|
+
|
|
5
|
+
extension SearchParams {
|
|
6
|
+
/// The query params this search sends: `q` plus whichever filters are set, as on Android and
|
|
7
|
+
/// web. Also the search's cache identity, so a field added here distinguishes both.
|
|
8
|
+
var queryParams: [String: String] {
|
|
9
|
+
var query: [String: String] = ["q": query]
|
|
10
|
+
if let mode { query["mode"] = mode.stringValue }
|
|
11
|
+
if reference == .my { query["reference"] = "my" }
|
|
12
|
+
if let genre { query["genre"] = genre }
|
|
13
|
+
if let artist { query["artist"] = artist }
|
|
14
|
+
if let album { query["album"] = album }
|
|
15
|
+
if let title { query["title"] = title }
|
|
16
|
+
if let playlist { query["playlist"] = playlist }
|
|
17
|
+
return query
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// The cache identity: `queryParams`, sorted so key order is stable and `\u{1}`-delimited so
|
|
21
|
+
/// two param sets cannot serialize alike.
|
|
22
|
+
var cacheKey: String {
|
|
23
|
+
queryParams
|
|
24
|
+
.sorted { $0.key < $1.key }
|
|
25
|
+
.map { "\($0.key)=\($0.value)" }
|
|
26
|
+
.joined(separator: "\u{1}")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -4,22 +4,6 @@ import NitroModules
|
|
|
4
4
|
|
|
5
5
|
/// Extension to make Nitro Track work with AVPlayer
|
|
6
6
|
extension Track {
|
|
7
|
-
/// The URL for AVPlayer playback (uses src for playback URL)
|
|
8
|
-
var playbackUrl: URL? {
|
|
9
|
-
guard let src else { return nil }
|
|
10
|
-
return URL(string: src)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/// Whether this is a local file URL
|
|
14
|
-
var isLocalFile: Bool {
|
|
15
|
-
playbackUrl?.isFileURL ?? false
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/// The source type (file or stream) based on the URL scheme
|
|
19
|
-
var sourceType: SourceType {
|
|
20
|
-
isLocalFile ? .file : .stream
|
|
21
|
-
}
|
|
22
|
-
|
|
23
7
|
/// Builds an `ImageSource` from the track's artwork fields, skipping SF Symbol strings.
|
|
24
8
|
var artworkImageSource: ImageSource? {
|
|
25
9
|
if let source = artworkSource {
|
|
@@ -13,27 +13,11 @@ final class HttpClient: @unchecked Sendable {
|
|
|
13
13
|
/// HTTP request configuration (matches Kotlin HttpClient.HttpRequest).
|
|
14
14
|
struct HttpRequest {
|
|
15
15
|
let url: String
|
|
16
|
-
let method:
|
|
16
|
+
let method: HttpMethod
|
|
17
17
|
let headers: [String: String]?
|
|
18
18
|
let body: String?
|
|
19
19
|
let contentType: String
|
|
20
20
|
let userAgent: String
|
|
21
|
-
|
|
22
|
-
init(
|
|
23
|
-
url: String,
|
|
24
|
-
method: String = "GET",
|
|
25
|
-
headers: [String: String]? = nil,
|
|
26
|
-
body: String? = nil,
|
|
27
|
-
contentType: String = HttpClient.defaultContentType,
|
|
28
|
-
userAgent: String = HttpClient.defaultUserAgent,
|
|
29
|
-
) {
|
|
30
|
-
self.url = url
|
|
31
|
-
self.method = method
|
|
32
|
-
self.headers = headers
|
|
33
|
-
self.body = body
|
|
34
|
-
self.contentType = contentType
|
|
35
|
-
self.userAgent = userAgent
|
|
36
|
-
}
|
|
37
21
|
}
|
|
38
22
|
|
|
39
23
|
/// A non-2xx response (matches Kotlin HttpClient.HttpException).
|
|
@@ -55,14 +39,14 @@ final class HttpClient: @unchecked Sendable {
|
|
|
55
39
|
|
|
56
40
|
enum HttpClientError: LocalizedError {
|
|
57
41
|
case invalidUrl(String)
|
|
58
|
-
case unsupportedMethod(
|
|
42
|
+
case unsupportedMethod(HttpMethod)
|
|
59
43
|
case notHttp
|
|
60
44
|
case undecodable(String)
|
|
61
45
|
|
|
62
46
|
var errorDescription: String? {
|
|
63
47
|
switch self {
|
|
64
48
|
case let .invalidUrl(url): "Invalid URL: \(url)"
|
|
65
|
-
case let .unsupportedMethod(method): "Unsupported HTTP method: \(method)"
|
|
49
|
+
case let .unsupportedMethod(method): "Unsupported HTTP method: \(method.stringValue)"
|
|
66
50
|
case .notHttp: "Invalid response type"
|
|
67
51
|
case let .undecodable(reason): reason
|
|
68
52
|
}
|
|
@@ -74,7 +58,7 @@ final class HttpClient: @unchecked Sendable {
|
|
|
74
58
|
}
|
|
75
59
|
|
|
76
60
|
/// Sends `httpRequest` and returns the status code and body, whatever the status.
|
|
77
|
-
func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
|
|
61
|
+
private func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
|
|
78
62
|
guard let url = URL(string: httpRequest.url) else { throw HttpClientError.invalidUrl(httpRequest.url) }
|
|
79
63
|
|
|
80
64
|
var urlRequest = URLRequest(url: url)
|
|
@@ -87,21 +71,19 @@ final class HttpClient: @unchecked Sendable {
|
|
|
87
71
|
urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
|
|
88
72
|
}
|
|
89
73
|
|
|
90
|
-
|
|
91
|
-
switch method {
|
|
92
|
-
case
|
|
93
|
-
|
|
94
|
-
case
|
|
95
|
-
urlRequest.httpMethod = method
|
|
74
|
+
urlRequest.httpMethod = httpRequest.method.stringValue
|
|
75
|
+
switch httpRequest.method {
|
|
76
|
+
case .get:
|
|
77
|
+
break
|
|
78
|
+
case .post, .put, .patch:
|
|
96
79
|
urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
|
|
97
80
|
urlRequest.httpBody = httpRequest.body?.data(using: .utf8)
|
|
98
|
-
case
|
|
99
|
-
urlRequest.httpMethod = method
|
|
81
|
+
case .delete:
|
|
100
82
|
if let body = httpRequest.body {
|
|
101
83
|
urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
|
|
102
84
|
urlRequest.httpBody = body.data(using: .utf8)
|
|
103
85
|
}
|
|
104
|
-
|
|
86
|
+
case .head, .options:
|
|
105
87
|
throw HttpClientError.unsupportedMethod(httpRequest.method)
|
|
106
88
|
}
|
|
107
89
|
|
|
@@ -143,7 +125,7 @@ final class HttpClient: @unchecked Sendable {
|
|
|
143
125
|
|
|
144
126
|
extension HttpClient {
|
|
145
127
|
private func logRequest(_ request: HttpRequest) {
|
|
146
|
-
logger.debug("→ \(request.method) \(request.url)")
|
|
128
|
+
logger.debug("→ \(request.method.stringValue) \(request.url)")
|
|
147
129
|
for (key, value) in request.headers ?? [:] {
|
|
148
130
|
logger.debug(" \(key): \(value)")
|
|
149
131
|
}
|