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.
- package/android/build.gradle +7 -1
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
- package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
- package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
- package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
- package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
- package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
- package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +0 -1
- package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
- package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
- package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
- package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
- package/ios/Browser/BrowserManager.swift +2 -2
- package/ios/Browser/SearchDrillIn.swift +1 -1
- package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
- package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
- package/ios/HybridAudioBrowser.swift +8 -7
- package/ios/Model/RemoteCommand.swift +18 -0
- package/ios/Model/TrackPlayerError.swift +2 -2
- package/ios/Option/SessionCategories.swift +3 -2
- package/ios/Player/MediaIntentCriteria.swift +4 -4
- package/ios/Player/PlaybackErrorHandler.swift +2 -2
- package/ios/Player/RetryManager.swift +2 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
- package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
- package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
- package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
- package/ios/Tests/PlaybackCoordinatorTests.swift +1 -1
- package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
- package/ios/Tests/RemoteCommandTests.swift +67 -0
- package/ios/Tests/SectionScopeTests.swift +2 -2
- package/ios/Tests/TabBarEntriesTests.swift +7 -7
- package/ios/TrackPlayer.swift +1 -1
- package/ios/TrackPlayerCallbacks.swift +5 -38
- package/lib/commonjs/AudioBrowser.js +0 -11
- package/lib/commonjs/AudioBrowser.js.map +1 -1
- package/lib/commonjs/features/carConnection.js +1 -1
- package/lib/commonjs/features/equalizer.js +1 -1
- package/lib/commonjs/features/errors.js +3 -3
- package/lib/commonjs/features/favorites.js +1 -1
- package/lib/commonjs/features/gate.js +1 -1
- package/lib/commonjs/features/metadata.js +3 -3
- package/lib/commonjs/features/network.js +1 -1
- package/lib/commonjs/features/nowPlaying.js +1 -1
- package/lib/commonjs/features/output.js +1 -1
- package/lib/commonjs/features/playback/playWhenReady.js +1 -1
- package/lib/commonjs/features/playback/playing.js +1 -1
- package/lib/commonjs/features/playback/progress.js +1 -1
- package/lib/commonjs/features/playback/state.js +1 -1
- package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
- package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/commonjs/features/playback/volume.js +1 -1
- package/lib/commonjs/features/player/options.js +3 -19
- package/lib/commonjs/features/player/options.js.map +1 -1
- package/lib/commonjs/features/player/setup.js +2 -2
- package/lib/commonjs/features/player/setup.js.map +1 -1
- package/lib/commonjs/features/player/validateOptions.js +24 -0
- package/lib/commonjs/features/player/validateOptions.js.map +1 -0
- package/lib/commonjs/features/queue/activeTrack.js +1 -1
- package/lib/commonjs/features/queue/queue.js +2 -2
- package/lib/commonjs/features/queue/repeatMode.js +1 -1
- package/lib/commonjs/features/queue/shuffle.js +1 -1
- package/lib/commonjs/features/remoteControls.js +13 -92
- package/lib/commonjs/features/remoteControls.js.map +1 -1
- package/lib/commonjs/features/sleepTimer.js +1 -1
- package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
- package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/module/AudioBrowser.js +4 -1
- package/lib/module/AudioBrowser.js.map +1 -1
- package/lib/module/features/carConnection.js +1 -1
- package/lib/module/features/equalizer.js +1 -1
- package/lib/module/features/errors.js +3 -3
- package/lib/module/features/favorites.js +1 -1
- package/lib/module/features/gate.js +1 -1
- package/lib/module/features/metadata.js +3 -3
- package/lib/module/features/network.js +1 -1
- package/lib/module/features/nowPlaying.js +1 -1
- package/lib/module/features/output.js +1 -1
- package/lib/module/features/playback/playWhenReady.js +1 -1
- package/lib/module/features/playback/playing.js +1 -1
- package/lib/module/features/playback/progress.js +1 -1
- package/lib/module/features/playback/state.js +1 -1
- package/lib/module/features/playback/trackPlaybackTime.js +0 -5
- package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/module/features/playback/volume.js +1 -1
- package/lib/module/features/player/options.js +2 -17
- package/lib/module/features/player/options.js.map +1 -1
- package/lib/module/features/player/setup.js +1 -1
- package/lib/module/features/player/setup.js.map +1 -1
- package/lib/module/features/player/validateOptions.js +20 -0
- package/lib/module/features/player/validateOptions.js.map +1 -0
- package/lib/module/features/queue/activeTrack.js +1 -1
- package/lib/module/features/queue/queue.js +2 -2
- package/lib/module/features/queue/repeatMode.js +1 -1
- package/lib/module/features/queue/shuffle.js +1 -1
- package/lib/module/features/remoteControls.js +13 -92
- package/lib/module/features/remoteControls.js.map +1 -1
- package/lib/module/features/sleepTimer.js +1 -1
- package/lib/module/utils/LazyNativeEmitter.js +8 -5
- package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/typescript/src/AudioBrowser.d.ts +0 -1
- package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
- package/lib/typescript/src/features/carConnection.d.ts +1 -1
- package/lib/typescript/src/features/equalizer.d.ts +1 -1
- package/lib/typescript/src/features/errors.d.ts +3 -3
- package/lib/typescript/src/features/favorites.d.ts +1 -1
- package/lib/typescript/src/features/gate.d.ts +3 -2
- package/lib/typescript/src/features/gate.d.ts.map +1 -1
- package/lib/typescript/src/features/metadata.d.ts +3 -3
- package/lib/typescript/src/features/network.d.ts +1 -1
- package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
- package/lib/typescript/src/features/output.d.ts +1 -1
- package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
- package/lib/typescript/src/features/playback/playing.d.ts +1 -1
- package/lib/typescript/src/features/playback/progress.d.ts +1 -1
- package/lib/typescript/src/features/playback/state.d.ts +1 -1
- package/lib/typescript/src/features/playback/volume.d.ts +1 -1
- package/lib/typescript/src/features/player/options.d.ts +1 -1
- package/lib/typescript/src/features/player/options.d.ts.map +1 -1
- package/lib/typescript/src/features/player/validateOptions.d.ts +2 -0
- package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
- package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
- package/lib/typescript/src/features/queue/queue.d.ts +2 -2
- package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
- package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
- package/lib/typescript/src/features/remoteControls.d.ts +23 -22
- package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
- package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/AudioBrowser.ts +4 -1
- package/src/features/carConnection.ts +1 -1
- package/src/features/equalizer.ts +1 -1
- package/src/features/errors.ts +3 -3
- package/src/features/favorites.ts +1 -1
- package/src/features/gate.ts +1 -1
- package/src/features/metadata.ts +3 -3
- package/src/features/network.ts +1 -1
- package/src/features/nowPlaying.ts +1 -1
- package/src/features/output.ts +1 -1
- package/src/features/playback/playWhenReady.ts +1 -1
- package/src/features/playback/playing.ts +1 -1
- package/src/features/playback/progress.ts +1 -1
- package/src/features/playback/state.ts +1 -1
- package/src/features/playback/trackPlaybackTime.test.ts +20 -6
- package/src/features/playback/trackPlaybackTime.ts +0 -5
- package/src/features/playback/volume.ts +1 -1
- package/src/features/player/options.ts +2 -20
- package/src/features/player/setup.ts +1 -1
- package/src/features/player/validateOptions.ts +22 -0
- package/src/features/queue/activeTrack.ts +1 -1
- package/src/features/queue/queue.ts +2 -2
- package/src/features/queue/repeatMode.ts +1 -1
- package/src/features/queue/shuffle.ts +1 -1
- package/src/features/remoteControls.ts +13 -92
- package/src/features/sleepTimer.ts +1 -1
- package/src/public-surface.test.ts +116 -0
- package/src/utils/LazyNativeEmitter.ts +9 -6
package/android/build.gradle
CHANGED
|
@@ -33,13 +33,19 @@ if (isNewArchitectureEnabled()) {
|
|
|
33
33
|
apply plugin: "com.facebook.react"
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
ktfmt {
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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,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)
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 =
|
|
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())
|
|
@@ -10,7 +10,8 @@ class JsonArtworkTest {
|
|
|
10
10
|
|
|
11
11
|
@Test
|
|
12
12
|
fun `decodes a single url`() {
|
|
13
|
-
val track =
|
|
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
|
-
*
|
|
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
|
-
*
|
|
206
|
-
*
|
|
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(
|
|
773
|
+
case let .imageRow(items):
|
|
774
774
|
sectionTracks = items.map { $0.toTrack() }
|
|
775
|
-
case .run(
|
|
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
|
|
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
|
-
|
|
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:
|
|
41
|
-
case .podcastShow, .podcastEpisode, .podcastPlaylist, .podcastStation:
|
|
42
|
-
case .audioBook:
|
|
43
|
-
case .news:
|
|
44
|
-
case .music:
|
|
45
|
-
case .song:
|
|
46
|
-
case .playlist:
|
|
47
|
-
case .musicVideo:
|
|
48
|
-
case .movie:
|
|
49
|
-
case .tvShow:
|
|
50
|
-
case .tvShowEpisode:
|
|
51
|
-
default:
|
|
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:
|
|
60
|
-
case .my:
|
|
61
|
-
default:
|
|
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) }
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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
|
+
}
|