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.
Files changed (44) hide show
  1. package/ios/AVPlayerEngine.swift +4 -8
  2. package/ios/Browser/BrowserConfig.swift +0 -5
  3. package/ios/Browser/BrowserError.swift +16 -0
  4. package/ios/Browser/BrowserManager.swift +36 -141
  5. package/ios/Browser/BrowserPathHelper.swift +25 -12
  6. package/ios/Browser/FavoriteSet.swift +46 -0
  7. package/ios/Browser/JsonModels.swift +172 -381
  8. package/ios/Browser/PageResolver.swift +16 -96
  9. package/ios/Browser/RequestLayer.swift +97 -2
  10. package/ios/Browser/RequestPipeline.swift +78 -227
  11. package/ios/Browser/SectionScope.swift +25 -0
  12. package/ios/Browser/SimpleRouter.swift +73 -144
  13. package/ios/Extension/SearchParams+Query.swift +28 -0
  14. package/ios/Extension/Track+Copying.swift +3 -1
  15. package/ios/Extension/Track+Identity.swift +7 -0
  16. package/ios/Http/HttpClient.swift +12 -30
  17. package/ios/HybridAudioBrowser+Browser.swift +5 -21
  18. package/ios/HybridAudioBrowser+Player.swift +3 -3
  19. package/ios/Model/NitroTypeStubs.swift +78 -0
  20. package/ios/Player/AudioSessionController.swift +2 -0
  21. package/ios/Player/ErrorStatus.swift +25 -2
  22. package/ios/Player/MediaLoader.swift +23 -71
  23. package/ios/Player/NowPlayingLines.swift +36 -0
  24. package/ios/Player/NowPlayingUpdater.swift +27 -85
  25. package/ios/Player/Player.swift +108 -261
  26. package/ios/Player/RetryLoop.swift +26 -0
  27. package/ios/Player/SleepTimerManager.swift +13 -15
  28. package/ios/Player/VolumeFader.swift +5 -0
  29. package/ios/Tests/BrowserPathHelperTests.swift +33 -0
  30. package/ios/Tests/ErrorStatusTests.swift +50 -0
  31. package/ios/Tests/FavoriteSetTests.swift +60 -0
  32. package/ios/Tests/JsonModelsTests.swift +30 -0
  33. package/ios/Tests/MediaLoaderTests.swift +26 -21
  34. package/ios/Tests/NowPlayingLinesTests.swift +43 -0
  35. package/ios/Tests/PlayerPolicyTests.swift +19 -3
  36. package/ios/Tests/PlayerTests.swift +81 -74
  37. package/ios/Tests/RequestLayerTests.swift +116 -0
  38. package/ios/Tests/RetryLoopTests.swift +35 -0
  39. package/ios/Tests/SearchParamsQueryTests.swift +48 -0
  40. package/ios/Tests/SectionScopeTests.swift +53 -0
  41. package/ios/Tests/SimpleRouterTests.swift +38 -3
  42. package/ios/Tests/SleepTimerManagerTests.swift +65 -41
  43. package/ios/Tests/Support/TestTypes.swift +2 -2
  44. 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
- /// Simple route matcher for path patterns with parameter extraction.
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
- /// Route specificity rules:
33
- /// - More path segments = higher specificity
34
- /// - Constant segments > parameter segments > wildcards > tail wildcards
35
- /// - Most specific route wins
36
- final class SimpleRouter {
37
- /// Finds the best matching route for the given path.
38
- ///
39
- /// - Parameters:
40
- /// - path: The incoming path to match (e.g., "/artists/123")
41
- /// - routes: Array of route entries to match against
42
- /// - Returns: Tuple of (route, match result) or nil if no match
43
- func findBestMatch<T: RouteEntry>(path: String, routes: [T]) -> (route: T, match: RouteMatch)? {
44
- var bestMatch: (route: T, match: RouteMatch)?
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
- if let match = matchPattern(pattern: route.path, path: path) {
48
- if bestMatch == nil || match.specificity > bestMatch!.match.specificity {
49
- bestMatch = (route, match)
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
- /// Attempts to match a single pattern against a path.
58
- ///
59
- /// - Parameters:
60
- /// - pattern: Route pattern (e.g., "/artists/{id}", "/files/*", "/api/**")
61
- /// - path: Incoming path (e.g., "/artists/123")
62
- /// - Returns: RouteMatch if successful, nil otherwise
63
- private func matchPattern(pattern: String, path: String) -> RouteMatch? {
64
- let patternSegments = pattern.split(separator: "/").map(String.init)
65
- let pathSegments = path.split(separator: "/").map(String.init)
66
-
67
- var params: [String: String] = [:]
68
- var constantSegments = 0
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: &params,
89
- )
90
- else {
91
- return nil
92
- }
93
-
94
- updateSegmentCounts(
95
- patternSegment: patternSegments[i],
96
- constantSegments: &constantSegments,
97
- parameterSegments: &parameterSegments,
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
- for i in patternSegments.indices {
114
- guard
115
- matchSingleSegment(
116
- patternSegment: patternSegments[i],
117
- pathSegment: pathSegments[i],
118
- params: &params,
119
- )
120
- else {
121
- return nil
122
- }
123
-
124
- updateSegmentCounts(
125
- patternSegment: patternSegments[i],
126
- constantSegments: &constantSegments,
127
- parameterSegments: &parameterSegments,
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 matchSingleSegment(
150
- patternSegment: String,
151
- pathSegment: String,
152
- params: inout [String: String],
153
- ) -> Bool {
154
- // Parameter segment: {paramName}
155
- if patternSegment.hasPrefix("{"), patternSegment.hasSuffix("}") {
156
- let paramName = String(patternSegment.dropFirst().dropLast())
157
- params[paramName] = pathSegment
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
- // Single wildcard: *
162
- if patternSegment == "*" {
163
- return true
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
- // Constant segment: must match exactly
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
+ }
@@ -1,4 +1,6 @@
1
- import NitroModules
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
2
4
 
3
5
  extension Track {
4
6
  /// Returns a copy of this Track with only the specified fields changed.
@@ -16,3 +16,10 @@ extension Track {
16
16
  return src
17
17
  }
18
18
  }
19
+
20
+ extension Track {
21
+ /// Playable and not Disabled: a track the library may queue and play.
22
+ var isPlayable: Bool {
23
+ src != nil && disabled != true
24
+ }
25
+ }
@@ -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: String
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(String)
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
- let method = httpRequest.method.uppercased()
91
- switch method {
92
- case "GET":
93
- urlRequest.httpMethod = method
94
- case "POST", "PUT", "PATCH":
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 "DELETE":
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
- default:
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 { browserManager.clearContentCache() }
97
-
98
- // Tell external controllers (CarPlay) to refresh every displayed template.
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?.sleepTimer ?? .first(NullType.null)
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
- case terminal(TrackPlayerError.PlaybackError)
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
  }