react-native-audio-browser 0.2.0-next.31 → 0.2.0-next.33
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 +4 -8
- 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 +172 -381
- 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/Extension/SearchParams+Query.swift +28 -0
- 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 +5 -21
- package/ios/HybridAudioBrowser+Player.swift +3 -3
- package/ios/Model/NitroTypeStubs.swift +78 -0
- package/ios/Player/AudioSessionController.swift +2 -0
- package/ios/Player/ErrorStatus.swift +25 -2
- package/ios/Player/MediaLoader.swift +23 -71
- package/ios/Player/NowPlayingLines.swift +36 -0
- package/ios/Player/NowPlayingUpdater.swift +27 -85
- package/ios/Player/Player.swift +108 -261
- package/ios/Player/RetryLoop.swift +26 -0
- package/ios/Player/SleepTimerManager.swift +13 -15
- package/ios/Player/VolumeFader.swift +5 -0
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/ErrorStatusTests.swift +50 -0
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/JsonModelsTests.swift +30 -0
- package/ios/Tests/MediaLoaderTests.swift +26 -21
- package/ios/Tests/NowPlayingLinesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +19 -3
- package/ios/Tests/PlayerTests.swift +81 -74
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/RetryLoopTests.swift +35 -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/SleepTimerManagerTests.swift +65 -41
- package/ios/Tests/Support/TestTypes.swift +2 -2
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -93,29 +93,13 @@ public extension HybridAudioBrowser {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
func invalidateAllContent() throws {
|
|
96
|
-
onMainActor {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
100
|
-
|
|
101
|
-
// Re-query the tabs source too: a callback source can resolve to new
|
|
102
|
-
// titles after an invalidation (e.g. a locale switch). BrowserManager
|
|
103
|
-
// emits tabsChanged only when the result actually differs, and a failed
|
|
104
|
-
// re-query keeps the current tabs. (Android needs no equivalent — its
|
|
105
|
-
// root re-query already runs queryTabs(); the web stub re-queries tabs on
|
|
106
|
-
// the re-navigation.)
|
|
107
|
-
Task {
|
|
108
|
-
_ = try? await browserManager.queryTabs()
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Re-resolve the JS-facing current path with the cache cleared.
|
|
112
|
-
Task { @MainActor in
|
|
113
|
-
do {
|
|
114
|
-
try await browserManager.load(browserManager.path, bypassCache: true)
|
|
115
|
-
} catch {
|
|
116
|
-
await reportNavigationError(error, path: browserManager.path)
|
|
96
|
+
onMainActor {
|
|
97
|
+
browserManager.invalidateAll { error, path in
|
|
98
|
+
await self.reportNavigationError(error, path: path)
|
|
117
99
|
}
|
|
118
100
|
}
|
|
101
|
+
// Tell external controllers (CarPlay) to refresh every displayed template.
|
|
102
|
+
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
119
103
|
}
|
|
120
104
|
|
|
121
105
|
func setFavorites(favorites: [String]) throws {
|
|
@@ -131,7 +131,7 @@ public extension HybridAudioBrowser {
|
|
|
131
131
|
|
|
132
132
|
func getSleepTimer() throws -> SleepTimer {
|
|
133
133
|
onMainActor {
|
|
134
|
-
player?.
|
|
134
|
+
player?.sleepTimerManager.get() ?? .first(NullType.null)
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -227,7 +227,7 @@ public extension HybridAudioBrowser {
|
|
|
227
227
|
browserManager.setFavorited(identity: identity, favorited: favorited)
|
|
228
228
|
// Back in the queue carrying the flag, since hydration never overrides a track's own value.
|
|
229
229
|
let updated = track.copying(favorited: favorited)
|
|
230
|
-
try player.replace(player.currentIndex, updated)
|
|
230
|
+
try player.replace(player.queue.currentIndex, updated)
|
|
231
231
|
// The only emit: a favorite toggle mutates the active track in place, so
|
|
232
232
|
// onPlaybackActiveTrackChanged stays transition-only.
|
|
233
233
|
favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updated, favorited: favorited))
|
|
@@ -271,7 +271,7 @@ public extension HybridAudioBrowser {
|
|
|
271
271
|
|
|
272
272
|
func getActiveTrackIndex() throws -> Double? {
|
|
273
273
|
onMainActor {
|
|
274
|
-
guard let index = player?.currentIndex, index >= 0 else { return nil }
|
|
274
|
+
guard let index = player?.queue.currentIndex, index >= 0 else { return nil }
|
|
275
275
|
return Double(index)
|
|
276
276
|
}
|
|
277
277
|
}
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
var query: [String: String]?
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/// Every property of the generated type, in its init's order, so the shared conversions in
|
|
32
|
+
/// JsonModels compile against both.
|
|
31
33
|
struct Track: Equatable {
|
|
32
34
|
var id: String?
|
|
33
35
|
var path: String?
|
|
@@ -36,15 +38,26 @@
|
|
|
36
38
|
var artworkSource: ImageSource?
|
|
37
39
|
var request: TrackRequest?
|
|
38
40
|
var title: String = ""
|
|
41
|
+
var subtitle: String?
|
|
39
42
|
var artist: String?
|
|
40
43
|
var albumPath: String?
|
|
41
44
|
var album: String?
|
|
45
|
+
var description: String?
|
|
46
|
+
var genre: String?
|
|
47
|
+
var duration: Double?
|
|
42
48
|
var style: TrackStyle?
|
|
43
49
|
var disabled: Bool?
|
|
50
|
+
var favorited: Bool?
|
|
44
51
|
var live: Bool?
|
|
45
52
|
var icyCharset: String?
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
struct NowPlayingUpdate: Equatable {
|
|
56
|
+
var title: String?
|
|
57
|
+
var artist: String?
|
|
58
|
+
var album: String?
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
struct TrackLoadEvent {
|
|
49
62
|
var track: Track
|
|
50
63
|
var queue: [Track]
|
|
@@ -97,6 +110,71 @@
|
|
|
97
110
|
case options
|
|
98
111
|
}
|
|
99
112
|
|
|
113
|
+
enum SearchMode {
|
|
114
|
+
case any
|
|
115
|
+
case song
|
|
116
|
+
case playlist
|
|
117
|
+
case station
|
|
118
|
+
case podcast
|
|
119
|
+
case audiobook
|
|
120
|
+
case news
|
|
121
|
+
case music
|
|
122
|
+
case musicVideo
|
|
123
|
+
case movie
|
|
124
|
+
case tvShow
|
|
125
|
+
case tvShowEpisode
|
|
126
|
+
|
|
127
|
+
var stringValue: String {
|
|
128
|
+
switch self {
|
|
129
|
+
case .any: "any"
|
|
130
|
+
case .song: "song"
|
|
131
|
+
case .playlist: "playlist"
|
|
132
|
+
case .station: "station"
|
|
133
|
+
case .podcast: "podcast"
|
|
134
|
+
case .audiobook: "audiobook"
|
|
135
|
+
case .news: "news"
|
|
136
|
+
case .music: "music"
|
|
137
|
+
case .musicVideo: "music-video"
|
|
138
|
+
case .movie: "movie"
|
|
139
|
+
case .tvShow: "tv-show"
|
|
140
|
+
case .tvShowEpisode: "tv-show-episode"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
enum MediaReference {
|
|
146
|
+
case my
|
|
147
|
+
case unknown
|
|
148
|
+
|
|
149
|
+
var stringValue: String {
|
|
150
|
+
switch self {
|
|
151
|
+
case .my: "my"
|
|
152
|
+
case .unknown: "unknown"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
struct SearchParams {
|
|
158
|
+
var mode: SearchMode?
|
|
159
|
+
var query: String
|
|
160
|
+
var genre: String?
|
|
161
|
+
var artist: String?
|
|
162
|
+
var album: String?
|
|
163
|
+
var title: String?
|
|
164
|
+
var playlist: String?
|
|
165
|
+
var reference: MediaReference
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
struct ImageContext {
|
|
169
|
+
var width: Double?
|
|
170
|
+
var height: Double?
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
struct ImageQueryParams {
|
|
174
|
+
var width: String?
|
|
175
|
+
var height: String?
|
|
176
|
+
}
|
|
177
|
+
|
|
100
178
|
struct ImageSource: Equatable {
|
|
101
179
|
var uri: String
|
|
102
180
|
var method: HttpMethod?
|
|
@@ -12,6 +12,8 @@ protocol AudioSessionPlayer: AnyObject {
|
|
|
12
12
|
func mediaServicesWereReset()
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
extension Player: AudioSessionPlayer {}
|
|
16
|
+
|
|
15
17
|
/// Owns the app's `AVAudioSession`: its category, activation and release, and the interruption,
|
|
16
18
|
/// route-change, media-services-reset and volume observers, installed once at init.
|
|
17
19
|
///
|
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
enum ErrorStatus: Equatable {
|
|
8
8
|
case none
|
|
9
9
|
case retrying(TrackPlayerError.PlaybackError)
|
|
10
|
-
|
|
10
|
+
/// `report` tells each terminal report from the last, so a repeat of the same error is a change.
|
|
11
|
+
case terminal(TrackPlayerError.PlaybackError, report: UInt)
|
|
11
12
|
|
|
12
13
|
var error: TrackPlayerError.PlaybackError? {
|
|
13
14
|
switch self {
|
|
14
15
|
case .none: nil
|
|
15
|
-
case let .retrying(error), let .terminal(error): error
|
|
16
|
+
case let .retrying(error), let .terminal(error, _): error
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -24,4 +25,26 @@ enum ErrorStatus: Equatable {
|
|
|
24
25
|
var nitroError: PlaybackError? {
|
|
25
26
|
error?.toNitroError(retrying: isRetrying)
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
/// The status once `event` moves the state from `old` to `new`: an error event is a new
|
|
30
|
+
/// terminal report; leaving `.error`, or reaching a state without an advisory error, clears it.
|
|
31
|
+
func after(event: PlaybackEvent, from old: PlaybackState, to new: PlaybackState) -> ErrorStatus {
|
|
32
|
+
if case let .errorOccurred(error) = event {
|
|
33
|
+
return .terminal(error, report: terminalReport &+ 1)
|
|
34
|
+
}
|
|
35
|
+
if old == .error || (isRetrying && !new.carriesAdvisoryError) { return .none }
|
|
36
|
+
return self
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private var terminalReport: UInt {
|
|
40
|
+
if case let .terminal(_, report) = self { report } else { 0 }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
extension PlaybackState {
|
|
45
|
+
/// Whether a retrying error shows over this state: the listener is waiting on it, and no audio
|
|
46
|
+
/// is playing to contradict it.
|
|
47
|
+
var carriesAdvisoryError: Bool {
|
|
48
|
+
self == .loading || self == .buffering || self == .paused
|
|
49
|
+
}
|
|
27
50
|
}
|