react-native-audio-browser 0.2.0-next.6 → 0.2.0-next.8

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 (162) hide show
  1. package/android/build.gradle +7 -1
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
  3. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
  4. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
  5. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
  6. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
  7. package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
  8. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  9. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  10. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +0 -1
  11. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  12. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  13. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  14. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  15. package/ios/Browser/BrowserManager.swift +2 -2
  16. package/ios/Browser/SearchDrillIn.swift +1 -1
  17. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  18. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  19. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  20. package/ios/HybridAudioBrowser.swift +8 -7
  21. package/ios/Model/RemoteCommand.swift +18 -0
  22. package/ios/Model/TrackPlayerError.swift +2 -2
  23. package/ios/Option/SessionCategories.swift +3 -2
  24. package/ios/Player/MediaIntentCriteria.swift +4 -4
  25. package/ios/Player/PlaybackErrorHandler.swift +2 -2
  26. package/ios/Player/RetryManager.swift +2 -1
  27. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  28. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  29. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  30. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  31. package/ios/Tests/PlaybackCoordinatorTests.swift +1 -1
  32. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  33. package/ios/Tests/RemoteCommandTests.swift +67 -0
  34. package/ios/Tests/SectionScopeTests.swift +2 -2
  35. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  36. package/ios/TrackPlayer.swift +1 -1
  37. package/ios/TrackPlayerCallbacks.swift +5 -38
  38. package/lib/commonjs/AudioBrowser.js +0 -11
  39. package/lib/commonjs/AudioBrowser.js.map +1 -1
  40. package/lib/commonjs/features/carConnection.js +1 -1
  41. package/lib/commonjs/features/equalizer.js +1 -1
  42. package/lib/commonjs/features/errors.js +3 -3
  43. package/lib/commonjs/features/favorites.js +1 -1
  44. package/lib/commonjs/features/gate.js +1 -1
  45. package/lib/commonjs/features/metadata.js +3 -3
  46. package/lib/commonjs/features/network.js +1 -1
  47. package/lib/commonjs/features/nowPlaying.js +1 -1
  48. package/lib/commonjs/features/output.js +1 -1
  49. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  50. package/lib/commonjs/features/playback/playing.js +1 -1
  51. package/lib/commonjs/features/playback/progress.js +1 -1
  52. package/lib/commonjs/features/playback/state.js +1 -1
  53. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  54. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  55. package/lib/commonjs/features/playback/volume.js +1 -1
  56. package/lib/commonjs/features/player/options.js +3 -19
  57. package/lib/commonjs/features/player/options.js.map +1 -1
  58. package/lib/commonjs/features/player/setup.js +2 -2
  59. package/lib/commonjs/features/player/setup.js.map +1 -1
  60. package/lib/commonjs/features/player/validateOptions.js +24 -0
  61. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  62. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  63. package/lib/commonjs/features/queue/queue.js +2 -2
  64. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  65. package/lib/commonjs/features/queue/shuffle.js +1 -1
  66. package/lib/commonjs/features/remoteControls.js +13 -92
  67. package/lib/commonjs/features/remoteControls.js.map +1 -1
  68. package/lib/commonjs/features/sleepTimer.js +1 -1
  69. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  70. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  71. package/lib/module/AudioBrowser.js +4 -1
  72. package/lib/module/AudioBrowser.js.map +1 -1
  73. package/lib/module/features/carConnection.js +1 -1
  74. package/lib/module/features/equalizer.js +1 -1
  75. package/lib/module/features/errors.js +3 -3
  76. package/lib/module/features/favorites.js +1 -1
  77. package/lib/module/features/gate.js +1 -1
  78. package/lib/module/features/metadata.js +3 -3
  79. package/lib/module/features/network.js +1 -1
  80. package/lib/module/features/nowPlaying.js +1 -1
  81. package/lib/module/features/output.js +1 -1
  82. package/lib/module/features/playback/playWhenReady.js +1 -1
  83. package/lib/module/features/playback/playing.js +1 -1
  84. package/lib/module/features/playback/progress.js +1 -1
  85. package/lib/module/features/playback/state.js +1 -1
  86. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  87. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  88. package/lib/module/features/playback/volume.js +1 -1
  89. package/lib/module/features/player/options.js +2 -17
  90. package/lib/module/features/player/options.js.map +1 -1
  91. package/lib/module/features/player/setup.js +1 -1
  92. package/lib/module/features/player/setup.js.map +1 -1
  93. package/lib/module/features/player/validateOptions.js +20 -0
  94. package/lib/module/features/player/validateOptions.js.map +1 -0
  95. package/lib/module/features/queue/activeTrack.js +1 -1
  96. package/lib/module/features/queue/queue.js +2 -2
  97. package/lib/module/features/queue/repeatMode.js +1 -1
  98. package/lib/module/features/queue/shuffle.js +1 -1
  99. package/lib/module/features/remoteControls.js +13 -92
  100. package/lib/module/features/remoteControls.js.map +1 -1
  101. package/lib/module/features/sleepTimer.js +1 -1
  102. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  103. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  104. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  105. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  106. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  107. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  108. package/lib/typescript/src/features/errors.d.ts +3 -3
  109. package/lib/typescript/src/features/favorites.d.ts +1 -1
  110. package/lib/typescript/src/features/gate.d.ts +3 -2
  111. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  112. package/lib/typescript/src/features/metadata.d.ts +3 -3
  113. package/lib/typescript/src/features/network.d.ts +1 -1
  114. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  115. package/lib/typescript/src/features/output.d.ts +1 -1
  116. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  117. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  118. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  119. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  120. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  121. package/lib/typescript/src/features/player/options.d.ts +1 -1
  122. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  123. package/lib/typescript/src/features/player/validateOptions.d.ts +2 -0
  124. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  125. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  126. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  127. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  128. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  129. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  130. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  131. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  132. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  133. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  134. package/package.json +3 -1
  135. package/src/AudioBrowser.ts +4 -1
  136. package/src/features/carConnection.ts +1 -1
  137. package/src/features/equalizer.ts +1 -1
  138. package/src/features/errors.ts +3 -3
  139. package/src/features/favorites.ts +1 -1
  140. package/src/features/gate.ts +1 -1
  141. package/src/features/metadata.ts +3 -3
  142. package/src/features/network.ts +1 -1
  143. package/src/features/nowPlaying.ts +1 -1
  144. package/src/features/output.ts +1 -1
  145. package/src/features/playback/playWhenReady.ts +1 -1
  146. package/src/features/playback/playing.ts +1 -1
  147. package/src/features/playback/progress.ts +1 -1
  148. package/src/features/playback/state.ts +1 -1
  149. package/src/features/playback/trackPlaybackTime.test.ts +20 -6
  150. package/src/features/playback/trackPlaybackTime.ts +0 -5
  151. package/src/features/playback/volume.ts +1 -1
  152. package/src/features/player/options.ts +2 -20
  153. package/src/features/player/setup.ts +1 -1
  154. package/src/features/player/validateOptions.ts +22 -0
  155. package/src/features/queue/activeTrack.ts +1 -1
  156. package/src/features/queue/queue.ts +2 -2
  157. package/src/features/queue/repeatMode.ts +1 -1
  158. package/src/features/queue/shuffle.ts +1 -1
  159. package/src/features/remoteControls.ts +13 -92
  160. package/src/features/sleepTimer.ts +1 -1
  161. package/src/public-surface.test.ts +116 -0
  162. package/src/utils/LazyNativeEmitter.ts +9 -6
@@ -33,13 +33,19 @@ if (isNewArchitectureEnabled()) {
33
33
  apply plugin: "com.facebook.react"
34
34
  }
35
35
 
36
- ktfmt { googleStyle()
36
+ ktfmt {
37
+ googleStyle()
37
38
  }
38
39
 
39
40
  tasks.register("format") {
40
41
  dependsOn "ktfmtFormat"
41
42
  }
42
43
 
44
+ // Fails instead of rewriting — what CI runs.
45
+ tasks.register("formatCheck") {
46
+ dependsOn "ktfmtCheck"
47
+ }
48
+
43
49
  def getExtOrDefault(name) {
44
50
  return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["AudioBrowser_" + name]
45
51
  }
@@ -1,7 +1,5 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
- import com.audiobrowser.util.artworkOf
4
-
5
3
  import android.util.LruCache
6
4
  import androidx.media3.common.MediaItem
7
5
  import androidx.media3.session.MediaSession
@@ -9,6 +7,7 @@ import com.audiobrowser.http.HttpClient
9
7
  import com.audiobrowser.http.RequestConfigBuilder
10
8
  import com.audiobrowser.util.BrowserPathHelper
11
9
  import com.audiobrowser.util.TrackFactory
10
+ import com.audiobrowser.util.artworkOf
12
11
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
13
12
  import com.margelo.nitro.audiobrowser.BrowserSourceCallbackParam
14
13
  import com.margelo.nitro.audiobrowser.FavoritesMatchMode
@@ -1,8 +1,7 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
- import com.audiobrowser.util.url
4
-
5
3
  import com.audiobrowser.http.RequestConfigBuilder
4
+ import com.audiobrowser.util.url
6
5
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
7
6
  import com.margelo.nitro.audiobrowser.ImageContext
8
7
  import com.margelo.nitro.audiobrowser.ImageQueryParams
@@ -4,9 +4,9 @@ import com.margelo.nitro.audiobrowser.ImageRowItem
4
4
  import com.margelo.nitro.audiobrowser.Track
5
5
 
6
6
  /**
7
- * Queue scope is the tapped section, not the whole page (ADR 0006): the
8
- * playback context a listener expects is the list they tapped in — a page
9
- * aggregating several sections must not leak next/previous across them.
7
+ * Queue scope is the tapped section, not the whole page (ADR 0006): the playback context a listener
8
+ * expects is the list they tapped in — a page aggregating several sections must not leak
9
+ * next/previous across them.
10
10
  */
11
11
  object SectionScope {
12
12
  sealed class Section {
@@ -14,15 +14,14 @@ object SectionScope {
14
14
  data class ImageRow(val items: List<ImageRowItem>) : Section()
15
15
 
16
16
  /**
17
- * The contiguous `groupTitle` run around the tapped child (items with no
18
- * group title form runs of their own).
17
+ * The contiguous `groupTitle` run around the tapped child (items with no group title form runs
18
+ * of their own).
19
19
  */
20
20
  data class Run(val tracks: List<Track>) : Section()
21
21
  }
22
22
 
23
23
  /**
24
- * The section of [children] containing the playable [trackId], or null
25
- * when the id is not found.
24
+ * The section of [children] containing the playable [trackId], or null when the id is not found.
26
25
  */
27
26
  fun section(children: List<Track>, trackId: String): Section? {
28
27
  for (child in children) {
@@ -1,7 +1,6 @@
1
1
  package com.audiobrowser.player
2
2
 
3
3
  import com.audiobrowser.util.url
4
-
5
4
  import com.margelo.nitro.audiobrowser.FormatNowPlayingParams
6
5
  import com.margelo.nitro.audiobrowser.NowPlayingMetadata
7
6
  import com.margelo.nitro.audiobrowser.NowPlayingUpdate
@@ -1,13 +1,11 @@
1
1
  package com.audiobrowser.player
2
2
 
3
- import com.audiobrowser.util.artworkOf
4
-
5
- import com.audiobrowser.util.url
6
-
7
3
  import android.content.Context
8
4
  import android.content.SharedPreferences
9
5
  import androidx.core.content.edit
10
6
  import androidx.media3.common.C
7
+ import com.audiobrowser.util.artworkOf
8
+ import com.audiobrowser.util.url
11
9
  import com.margelo.nitro.audiobrowser.PlaybackState
12
10
  import com.margelo.nitro.audiobrowser.RepeatMode
13
11
  import com.margelo.nitro.audiobrowser.Track
@@ -107,7 +107,8 @@ class RetryLoadErrorHandlingPolicy(
107
107
  */
108
108
  private fun exhaustedBudget(currentTime: Long): String? {
109
109
  firstErrorTime?.let { start ->
110
- if (currentTime - start >= maxRetryDurationMs) return "max retry duration ($maxRetryDurationMs ms)"
110
+ if (currentTime - start >= maxRetryDurationMs)
111
+ return "max retry duration ($maxRetryDurationMs ms)"
111
112
  }
112
113
  if (!hasPlayed() && isOnline()) {
113
114
  firstOnlineErrorTime?.let { start ->
@@ -5,11 +5,10 @@ import com.margelo.nitro.audiobrowser.Variant_String_ArtworkVariants
5
5
  /**
6
6
  * Reading `Track.artwork`, which is either one URL or a URL per appearance.
7
7
  *
8
- * Android Auto is dark-only, so there is nothing here to choose: a pair always
9
- * resolves to its dark URL, which is also what a single-URL track would have
10
- * shipped. The per-appearance half of the feature is iOS/CarPlay only — see
11
- * `ios/Extension/Variant+Artwork.swift`, where both URLs are kept so a
12
- * `UIImageAsset` can swap them without a re-fetch.
8
+ * Android Auto is dark-only, so there is nothing here to choose: a pair always resolves to its dark
9
+ * URL, which is also what a single-URL track would have shipped. The per-appearance half of the
10
+ * feature is iOS/CarPlay only — see `ios/Extension/Variant+Artwork.swift`, where both URLs are kept
11
+ * so a `UIImageAsset` can swap them without a re-fetch.
13
12
  */
14
13
  val Variant_String_ArtworkVariants.url: String
15
14
  get() = match(first = { it }, second = { it.dark })
@@ -17,10 +16,9 @@ val Variant_String_ArtworkVariants.url: String
17
16
  /**
18
17
  * Wraps a plain URL as the single-URL side of the union.
19
18
  *
20
- * For the places that build a `Track` from something that only ever has one
21
- * image — restored persistence, a `MediaMetadata` handed back by the platform.
22
- * Restoring collapses a pair to its dark URL, which costs nothing here: Android
23
- * Auto would have rendered that one anyway.
19
+ * For the places that build a `Track` from something that only ever has one image — restored
20
+ * persistence, a `MediaMetadata` handed back by the platform. Restoring collapses a pair to its
21
+ * dark URL, which costs nothing here: Android Auto would have rendered that one anyway.
24
22
  */
25
23
  fun artworkOf(url: String?): Variant_String_ArtworkVariants? =
26
24
  url?.let { Variant_String_ArtworkVariants.First(it) }
@@ -26,12 +26,12 @@ object TrackFactory {
26
26
  }
27
27
 
28
28
  /**
29
- * Android Auto has no image-row rendering; its closest equivalent is a grid of artwork
30
- * tiles. A track carrying `imageRow` expands into its items as grid-styled rows (the
31
- * per-item content-style hint — hosts that ignore it fall back to list rows) grouped under
32
- * the row's title, followed by the row itself as a browsable "view all" link when it has a
33
- * `url` (a url-less row is a pure preview and contributes only its items). Tracks without
34
- * an `imageRow` pass through unchanged.
29
+ * Android Auto has no image-row rendering; its closest equivalent is a grid of artwork tiles. A
30
+ * track carrying `imageRow` expands into its items as grid-styled rows (the per-item
31
+ * content-style hint — hosts that ignore it fall back to list rows) grouped under the row's
32
+ * title, followed by the row itself as a browsable "view all" link when it has a `url` (a
33
+ * url-less row is a pure preview and contributes only its items). Tracks without an `imageRow`
34
+ * pass through unchanged.
35
35
  */
36
36
  fun expandImageRows(tracks: List<Track>): List<Track> =
37
37
  tracks.flatMap { track ->
@@ -95,7 +95,8 @@ object TrackFactory {
95
95
  val scheme = rawUrl?.let { Uri.parse(it).scheme?.lowercase() }
96
96
  val builder = metadataBuilder(track)
97
97
  if (rawUrl != null && (scheme == "http" || scheme == "https")) {
98
- val isSvg = SvgArtworkRenderer.isSvgUrl(rawUrl) || SvgArtworkRenderer.isSvgUrl(track.artwork?.url)
98
+ val isSvg =
99
+ SvgArtworkRenderer.isSvgUrl(rawUrl) || SvgArtworkRenderer.isSvgUrl(track.artwork?.url)
99
100
  val token = ArtworkUris.tokenFor(rawUrl)
100
101
  registry.register(token, ResolvedArtwork(rawUrl, track.artworkSource?.headers, isSvg))
101
102
  builder.setArtworkUri(ArtworkUris.contentUri(authority, token).toUri())
@@ -1,7 +1,6 @@
1
1
  package com.audiobrowser
2
2
 
3
3
  import com.audiobrowser.util.artworkOf
4
-
5
4
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
6
5
  import com.margelo.nitro.audiobrowser.ImageQueryParams
7
6
  import com.margelo.nitro.audiobrowser.ImageRowItem
@@ -10,7 +10,8 @@ class JsonArtworkTest {
10
10
 
11
11
  @Test
12
12
  fun `decodes a single url`() {
13
- val track = json.decodeFromString<JsonTrack>("""{"title":"X","artwork":"https://e.example/a.png"}""")
13
+ val track =
14
+ json.decodeFromString<JsonTrack>("""{"title":"X","artwork":"https://e.example/a.png"}""")
14
15
  assertEquals(
15
16
  Variant_String_ArtworkVariants.First("https://e.example/a.png"),
16
17
  track.toNitro().artwork,
@@ -59,8 +59,7 @@ class SectionScopeTest {
59
59
  val row = track(title = "Most Played", src = null, imageRow = items)
60
60
  val children = listOf(row, track(src = "a"))
61
61
 
62
- val section =
63
- SectionScope.section(children, "s2") as SectionScope.Section.ImageRow
62
+ val section = SectionScope.section(children, "s2") as SectionScope.Section.ImageRow
64
63
  assertEquals(listOf("s1", "s2"), section.items.map { it.src })
65
64
  }
66
65
 
@@ -76,8 +75,7 @@ class SectionScopeTest {
76
75
  val row = track(title = "Row", src = null, imageRow = arrayOf(imageRowItem("dup")))
77
76
  val children = listOf(row, track(src = "dup"), track(src = "b"))
78
77
 
79
- val section =
80
- SectionScope.section(children, "dup") as SectionScope.Section.ImageRow
78
+ val section = SectionScope.section(children, "dup") as SectionScope.Section.ImageRow
81
79
  assertEquals(listOf("dup"), section.items.map { it.src })
82
80
  }
83
81
 
@@ -132,8 +132,8 @@ class PlaybackErrorClassifierTest {
132
132
 
133
133
  /**
134
134
  * Load errors are raw IOExceptions from the transport layer; the policy only retries network
135
- * ones, so the advisory classification is UNREACHABLE unless the device is offline or the
136
- * server actually answered.
135
+ * ones, so the advisory classification is UNREACHABLE unless the device is offline or the server
136
+ * actually answered.
137
137
  */
138
138
  @Test
139
139
  fun `retrying transport failure is unreachable`() {
@@ -158,8 +158,7 @@ class PlaybackErrorClassifierTest {
158
158
  /** The HTTP status is dug out of the cause chain and mapped through the shared status table. */
159
159
  @Test
160
160
  fun `retrying HTTP failure carries the status`() {
161
- val error =
162
- PlaybackErrorClassifier.retryingLoadError(httpException(503), online = true)
161
+ val error = PlaybackErrorClassifier.retryingLoadError(httpException(503), online = true)
163
162
  assertEquals(PlaybackErrorKind.SERVER_ERROR, error.kind)
164
163
  assertEquals(503.0, error.statusCode!!, 0.0)
165
164
  assertEquals(true, error.retrying)
@@ -201,9 +201,9 @@ class RetryLoadErrorHandlingPolicyBudgetTest {
201
201
  }
202
202
 
203
203
  /**
204
- * Offline failures must not burn the first-connect budget (a station tapped in a tunnel parks
205
- * for connectivity); the budget's clock starts at the first error observed online, so a dead
206
- * station still gets a fast verdict after restoration.
204
+ * Offline failures must not burn the first-connect budget (a station tapped in a tunnel parks for
205
+ * connectivity); the budget's clock starts at the first error observed online, so a dead station
206
+ * still gets a fast verdict after restoration.
207
207
  */
208
208
  @Test
209
209
  fun `offline time does not burn the first-connect budget`() {
@@ -770,9 +770,9 @@ final class BrowserManager {
770
770
  // silently queueing the changed list would resume the wrong station.
771
771
  let sectionTracks: [Track]
772
772
  switch SectionScope.section(of: children, containing: trackId) {
773
- case .imageRow(let items):
773
+ case let .imageRow(items):
774
774
  sectionTracks = items.map { $0.toTrack() }
775
- case .run(let tracks):
775
+ case let .run(tracks):
776
776
  sectionTracks = tracks
777
777
  case nil:
778
778
  return nil
@@ -20,7 +20,7 @@ enum SearchDrillIn {
20
20
  /// yields nothing. Returns nil when nothing is playable.
21
21
  static func playable<Item: PlayableSearchItem>(
22
22
  from children: [Item],
23
- resolveChildren: sending (String) async throws -> [Item]
23
+ resolveChildren: sending (String) async throws -> [Item],
24
24
  ) async throws -> [Item]? {
25
25
  guard !children.isEmpty else { return nil }
26
26
 
@@ -22,8 +22,8 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
22
22
  private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaAddHandler")
23
23
 
24
24
  func handle(
25
- intent: INAddMediaIntent,
26
- completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void
25
+ intent _: INAddMediaIntent,
26
+ completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void,
27
27
  ) {
28
28
  Self.logger.info("Add media — favorite current track")
29
29
 
@@ -57,7 +57,7 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
57
57
  @objc(resolveMediaItemsForAddMedia:withCompletion:)
58
58
  func resolveMediaItems(
59
59
  for intent: INAddMediaIntent,
60
- with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void
60
+ with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void,
61
61
  ) {
62
62
  let name = intent.mediaSearch?.mediaName ?? ""
63
63
  let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
@@ -66,10 +66,10 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
66
66
 
67
67
  @objc(resolveMediaDestinationForAddMedia:withCompletion:)
68
68
  func resolveMediaDestination(
69
- for intent: INAddMediaIntent,
70
- with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void
69
+ for _: INAddMediaIntent,
70
+ with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void,
71
71
  ) {
72
72
  // One flat favorites collection — always the user's library, no playlists.
73
- completion(.success(with: INMediaDestination.library))
73
+ completion(.success(with: INMediaDestination.library))
74
74
  }
75
75
  }
@@ -21,7 +21,7 @@ class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
21
21
 
22
22
  func handle(
23
23
  intent: INUpdateMediaAffinityIntent,
24
- completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void
24
+ completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void,
25
25
  ) {
26
26
  let affinity = intent.affinityType
27
27
  Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
@@ -60,7 +60,7 @@ class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
60
60
  @objc(resolveMediaItemsForUpdateMediaAffinity:withCompletion:)
61
61
  func resolveMediaItems(
62
62
  for intent: INUpdateMediaAffinityIntent,
63
- with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void
63
+ with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void,
64
64
  ) {
65
65
  let name = intent.mediaSearch?.mediaName ?? ""
66
66
  let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
@@ -21,7 +21,7 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
21
21
  mediaTypeMode: Self.mediaTypeMode(s?.mediaType ?? .unknown),
22
22
  reference: Self.reference(s?.reference ?? .unknown),
23
23
  hasMediaType: (s?.mediaType ?? .unknown) != .unknown,
24
- appName: Self.hostAppName()
24
+ appName: Self.hostAppName(),
25
25
  )
26
26
  Self.logger.info("Play media intent — query=\(criteria.query) matchesApp=\(criteria.matchesAppName) resume=\(criteria.isResume)")
27
27
 
@@ -37,18 +37,18 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
37
37
  /// `album` are filters, not verticals — they yield no mode.
38
38
  private static func mediaTypeMode(_ type: INMediaItemType) -> String? {
39
39
  switch type {
40
- case .station, .radioStation, .algorithmicRadioStation, .musicStation: return "station"
41
- case .podcastShow, .podcastEpisode, .podcastPlaylist, .podcastStation: return "podcast"
42
- case .audioBook: return "audiobook"
43
- case .news: return "news"
44
- case .music: return "music"
45
- case .song: return "song"
46
- case .playlist: return "playlist"
47
- case .musicVideo: return "music-video"
48
- case .movie: return "movie"
49
- case .tvShow: return "tv-show"
50
- case .tvShowEpisode: return "tv-show-episode"
51
- default: return nil // album/artist/genre/unknown
40
+ case .station, .radioStation, .algorithmicRadioStation, .musicStation: "station"
41
+ case .podcastShow, .podcastEpisode, .podcastPlaylist, .podcastStation: "podcast"
42
+ case .audioBook: "audiobook"
43
+ case .news: "news"
44
+ case .music: "music"
45
+ case .song: "song"
46
+ case .playlist: "playlist"
47
+ case .musicVideo: "music-video"
48
+ case .movie: "movie"
49
+ case .tvShow: "tv-show"
50
+ case .tvShowEpisode: "tv-show-episode"
51
+ default: nil // album/artist/genre/unknown
52
52
  }
53
53
  }
54
54
 
@@ -56,9 +56,9 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
56
56
  /// routes to native resume; `.my` to the consumer; everything else is unknown.
57
57
  private static func reference(_ ref: INMediaReference) -> MediaIntentCriteria.Reference {
58
58
  switch ref {
59
- case .currentlyPlaying: return .currentlyPlaying
60
- case .my: return .my
61
- default: return .unknown
59
+ case .currentlyPlaying: .currentlyPlaying
60
+ case .my: .my
61
+ default: .unknown
62
62
  }
63
63
  }
64
64
 
@@ -601,7 +601,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
601
601
  // built-in so the gate page still renders rather than leaking content.
602
602
  return GateOutcome(gated: true, chrome: defaultChrome ?? Self.builtInGate)
603
603
  }
604
- guard decision.gated else { return GateOutcome(gated: false, chrome: nil) } // explicit allow
604
+ guard decision.gated else { return GateOutcome(gated: false, chrome: nil) } // explicit allow
605
605
  return GateOutcome(gated: true, chrome: decision.gate ?? defaultChrome ?? Self.builtInGate)
606
606
  }
607
607
 
@@ -649,7 +649,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
649
649
  category: AVAudioSession.Category,
650
650
  mode: AVAudioSession.Mode,
651
651
  policy: AVAudioSession.RouteSharingPolicy,
652
- options: AVAudioSession.CategoryOptions
652
+ options: AVAudioSession.CategoryOptions,
653
653
  ) = options.ios?.resolveAudioSessionConfig()
654
654
  ?? (category: .playback, mode: .default, policy: .default, options: [])
655
655
  // Capture category application once so a media-services reset (which
@@ -1394,7 +1394,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1394
1394
 
1395
1395
  public func supportsOutputSwitcher() throws -> Bool {
1396
1396
  // iOS can always present the system route picker (AVRoutePickerView).
1397
- return true
1397
+ true
1398
1398
  }
1399
1399
 
1400
1400
  public func openOutputPicker() throws {
@@ -1786,7 +1786,8 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1786
1786
  // URL (parent container → siblings + selected index). Fall back to the single
1787
1787
  // track when the url isn't contextual or expansion fails.
1788
1788
  if let url = state.track.url,
1789
- let expanded = try? await browser.browserManager.expandQueueFromContextualUrl(url) {
1789
+ let expanded = try? await browser.browserManager.expandQueueFromContextualUrl(url)
1790
+ {
1790
1791
  player.setQueue(expanded.tracks, initialIndex: expanded.selectedIndex, startPositionMs: startMs, playWhenReady: true)
1791
1792
  } else {
1792
1793
  player.setQueue([track], initialIndex: 0, startPositionMs: startMs, playWhenReady: true)
@@ -1795,7 +1796,7 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1795
1796
  completion(true)
1796
1797
  } else {
1797
1798
  browser.logger.error("resume: nothing playing and nothing persisted → no-op")
1798
- completion(false) // nothing playing and nothing persisted
1799
+ completion(false) // nothing playing and nothing persisted
1799
1800
  }
1800
1801
  return
1801
1802
  }
@@ -1812,14 +1813,14 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1812
1813
  title: criteria.title,
1813
1814
  playlist: criteria.playlist,
1814
1815
  // .currentlyPlaying can't reach here — isResume routed it to resume.
1815
- reference: criteria.reference == .my ? .my : .unknown
1816
+ reference: criteria.reference == .my ? .my : .unknown,
1816
1817
  )
1817
1818
 
1818
1819
  // Search is *finding* new content — refused when the gate decides this
1819
1820
  // request is gated (resume above is unaffected: the gate never blocks
1820
1821
  // hearing, and the gate check stays after the resume check).
1821
1822
  let outcome = await browser.gateDecision(
1822
- for: NativeGateRequest(reason: .search, path: nil, search: params)
1823
+ for: NativeGateRequest(reason: .search, path: nil, search: params),
1823
1824
  )
1824
1825
  guard !outcome.gated else {
1825
1826
  browser.logger.info("handlePlayMediaIntent: search refused — gated")
@@ -48,6 +48,24 @@ enum RemoteCommand: CustomStringConvertible, Equatable {
48
48
 
49
49
  var key: String { description }
50
50
 
51
+ /**
52
+ Commands in `self` that are absent from `next` and so should be disabled.
53
+
54
+ Compared by `key`, not by `==`. `RemoteCommand` is `Equatable` including its
55
+ associated values, but every case with associated values maps onto a single
56
+ `MPRemoteCommand` addressed by `key`: `.skipForward([15])` and
57
+ `.skipForward([30])` are two different values of one command. Diffing by `==`
58
+ would report the old interval as removed and disable the command that the new
59
+ interval had just re-enabled.
60
+ */
61
+ static func commandsToDisable(
62
+ enabled: [RemoteCommand],
63
+ replacedBy next: [RemoteCommand],
64
+ ) -> [RemoteCommand] {
65
+ let nextKeys = Set(next.map(\.key))
66
+ return enabled.filter { !nextKeys.contains($0.key) }
67
+ }
68
+
51
69
  /**
52
70
  All values in an array for convenience.
53
71
  Don't use for associated values.
@@ -103,10 +103,10 @@ public extension TrackPlayerError.PlaybackError {
103
103
  static func kind(forHttpStatus status: Int) -> PlaybackErrorKind {
104
104
  switch status {
105
105
  case 404, 410: .notFound
106
- case 500...599: .serverError
106
+ case 500 ... 599: .serverError
107
107
  // Every other 4xx is the server refusing us — auth, geo-blocking, a
108
108
  // rate limit. All of them mean "you can't have this stream", not "retry".
109
- case 400...499: .rejected
109
+ case 400 ... 499: .rejected
110
110
  default: .unknown
111
111
  }
112
112
  }
@@ -99,7 +99,7 @@ extension NativeIOSSetupOptions {
99
99
  category: AVAudioSession.Category,
100
100
  mode: AVAudioSession.Mode,
101
101
  policy: AVAudioSession.RouteSharingPolicy,
102
- options: AVAudioSession.CategoryOptions
102
+ options: AVAudioSession.CategoryOptions,
103
103
  ) {
104
104
  let category = self.category
105
105
  .flatMap { SessionCategory(rawValue: $0.stringValue) }?
@@ -113,7 +113,8 @@ extension NativeIOSSetupOptions {
113
113
  let options = (self.categoryOptions ?? [])
114
114
  .reduce(into: AVAudioSession.CategoryOptions()) { acc, option in
115
115
  if let mapped = SessionCategoryOptions(rawValue: option.stringValue)?
116
- .mapConfigToAVAudioSessionCategoryOptions() {
116
+ .mapConfigToAVAudioSessionCategoryOptions()
117
+ {
117
118
  acc.insert(mapped)
118
119
  }
119
120
  }
@@ -43,7 +43,7 @@ public struct MediaIntentCriteria: Sendable {
43
43
  artist: String? = nil,
44
44
  album: String? = nil,
45
45
  title: String? = nil,
46
- playlist: String? = nil
46
+ playlist: String? = nil,
47
47
  ) {
48
48
  self.query = query
49
49
  self.reference = reference
@@ -90,7 +90,7 @@ public struct MediaIntentCriteria: Sendable {
90
90
  mediaTypeMode: String?,
91
91
  reference: Reference,
92
92
  hasMediaType: Bool,
93
- appName: String?
93
+ appName: String?,
94
94
  ) -> MediaIntentCriteria {
95
95
  let name = (mediaName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
96
96
  let trimmedNonEmpty: (String?) -> String? = {
@@ -115,12 +115,12 @@ public struct MediaIntentCriteria: Sendable {
115
115
  hasGenres: !genreNames.isEmpty,
116
116
  hasMediaType: hasMediaType,
117
117
  matchesAppName: queryMatchesAppName(query, appName: appName),
118
- searchMode: mediaTypeMode, // mode is the vertical, verbatim — no derivation
118
+ searchMode: mediaTypeMode, // mode is the vertical, verbatim — no derivation
119
119
  genre: genre,
120
120
  artist: artist,
121
121
  album: album,
122
122
  title: title,
123
- playlist: playlist
123
+ playlist: playlist,
124
124
  )
125
125
  }
126
126
 
@@ -138,7 +138,7 @@ public enum PlaybackErrorContext {
138
138
  }
139
139
  }
140
140
 
141
- if let httpStatusCode, !(200...299).contains(httpStatusCode) {
141
+ if let httpStatusCode, !(200 ... 299).contains(httpStatusCode) {
142
142
  return .httpStatus(httpStatusCode)
143
143
  }
144
144
 
@@ -155,7 +155,7 @@ public enum PlaybackErrorContext {
155
155
  /// with HTTP statuses — so only in-range values count, and the newest wins:
156
156
  /// a live stream reconnects, and older entries describe earlier attempts.
157
157
  public static func httpStatusCode(fromErrorStatusCodes codes: [Int]) -> Int? {
158
- codes.last { (100...599).contains($0) }
158
+ codes.last { (100 ... 599).contains($0) }
159
159
  }
160
160
 
161
161
  /// The stream was fetched but cannot be decoded — unknown container,
@@ -170,7 +170,8 @@ class RetryManager {
170
170
  return "max retry duration (\(maxRetryDuration)s)"
171
171
  }
172
172
  if !hasPlayed, isOnline, let start = firstOnlineRetryTime,
173
- now.timeIntervalSince(start) >= firstConnectMaxRetryDuration {
173
+ now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
174
+ {
174
175
  return "first-connect retry duration (\(firstConnectMaxRetryDuration)s)"
175
176
  }
176
177
  return nil
@@ -0,0 +1,47 @@
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
4
+
5
+ /// The callbacks `RemoteCommandController` invokes when the system delivers a
6
+ /// remote command (lock screen, Control Center, CarPlay, headset).
7
+ ///
8
+ /// Split out of `TrackPlayerCallbacks` — which the controller does not otherwise
9
+ /// use — so the controller depends only on what it calls. `TrackPlayerCallbacks`
10
+ /// extends this protocol, so conformers implement both at once.
11
+ @MainActor protocol RemoteCommandCallbacks: AnyObject {
12
+ /// Called when play is triggered remotely.
13
+ func remotePlay()
14
+
15
+ /// Called when pause is triggered remotely.
16
+ func remotePause()
17
+
18
+ /// Called when stop is triggered remotely.
19
+ func remoteStop()
20
+
21
+ /// Called when toggle play/pause is triggered remotely.
22
+ func remotePlayPause()
23
+
24
+ /// Called when next is triggered remotely.
25
+ func remoteNext()
26
+
27
+ /// Called when previous is triggered remotely.
28
+ func remotePrevious()
29
+
30
+ /// Called when jump forward is triggered remotely.
31
+ func remoteJumpForward(interval: Double)
32
+
33
+ /// Called when jump backward is triggered remotely.
34
+ func remoteJumpBackward(interval: Double)
35
+
36
+ /// Called when seek is triggered remotely.
37
+ func remoteSeek(position: Double)
38
+
39
+ /// Called when repeat mode change is triggered remotely (CarPlay/lock screen).
40
+ func remoteChangeRepeatMode(mode: RepeatMode)
41
+
42
+ /// Called when shuffle mode change is triggered remotely (CarPlay/lock screen).
43
+ func remoteChangeShuffleMode(enabled: Bool)
44
+
45
+ /// Called when playback rate change is triggered remotely (CarPlay/lock screen).
46
+ func remoteChangePlaybackRate(rate: Float)
47
+ }