react-native-audio-browser 0.2.0-next.7 → 0.2.0-next.9
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/AudioBrowser.kt +33 -8
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +46 -0
- 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/ServiceBindingTest.kt +68 -0
- 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 +86 -55
- 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 +123 -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
|
}
|
|
@@ -90,6 +90,7 @@ import kotlin.coroutines.suspendCoroutine
|
|
|
90
90
|
import kotlinx.coroutines.CancellationException
|
|
91
91
|
import kotlinx.coroutines.Job
|
|
92
92
|
import kotlinx.coroutines.MainScope
|
|
93
|
+
import kotlinx.coroutines.cancel
|
|
93
94
|
import kotlinx.coroutines.cancelChildren
|
|
94
95
|
import kotlinx.coroutines.delay
|
|
95
96
|
import kotlinx.coroutines.launch
|
|
@@ -100,10 +101,37 @@ import timber.log.Timber
|
|
|
100
101
|
@DoNotStrip
|
|
101
102
|
class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
102
103
|
|
|
103
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Called by Nitro when the JS object is destroyed (incl. JS runtime reloads).
|
|
106
|
+
*
|
|
107
|
+
* The Service outlives this object, so everything registered in `init` or on connect has to be
|
|
108
|
+
* undone here. Left alone, the running [Player] goes on invoking callbacks into a dead JSI
|
|
109
|
+
* runtime, and every reload strands another ServiceConnection and lifecycle observer, each
|
|
110
|
+
* retaining a BrowserManager.
|
|
111
|
+
*/
|
|
104
112
|
override fun dispose() {
|
|
105
113
|
systemVolumeMonitor.destroy()
|
|
106
114
|
outputMonitor.destroy()
|
|
115
|
+
|
|
116
|
+
connectedService?.let { service ->
|
|
117
|
+
service.player.setCallbacks(null)
|
|
118
|
+
service.player.browser = null
|
|
119
|
+
service.onBatteryWarningPendingChanged = null
|
|
120
|
+
}
|
|
121
|
+
connectedService = null
|
|
122
|
+
mediaBrowserFuture = null
|
|
123
|
+
|
|
124
|
+
serviceBinding.unbind(this)
|
|
125
|
+
|
|
126
|
+
// Only when still ours: on a reload the replacement instance may already own the slot.
|
|
127
|
+
if (carConnectionTarget === onCarConnectedChanged) carConnectionTarget = null
|
|
128
|
+
|
|
129
|
+
// Drop queued work first, then post the removal — LifecycleRegistry is main-thread only,
|
|
130
|
+
// which is why the observer is added the same way.
|
|
131
|
+
handler.removeCallbacksAndMessages(null)
|
|
132
|
+
handler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) }
|
|
133
|
+
|
|
134
|
+
mainScope.cancel()
|
|
107
135
|
super.dispose()
|
|
108
136
|
}
|
|
109
137
|
|
|
@@ -150,6 +178,8 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
|
150
178
|
private var mediaBrowserFuture: ListenableFuture<MediaBrowser>? = null
|
|
151
179
|
private var setupOptions = PlayerSetupOptions()
|
|
152
180
|
private var connectedService: Service? = null
|
|
181
|
+
|
|
182
|
+
private val serviceBinding = ServiceBinding(context)
|
|
153
183
|
private var setupPromise: ((Unit) -> Unit)? = null
|
|
154
184
|
|
|
155
185
|
// Initial player state staged before the player exists — from setup options or the imperative
|
|
@@ -244,7 +274,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
|
244
274
|
try {
|
|
245
275
|
Timber.d("Attempting to auto-bind to existing AudioBrowserService from AudioBrowser")
|
|
246
276
|
val intent = Intent(context, Service::class.java)
|
|
247
|
-
val bound =
|
|
277
|
+
val bound = serviceBinding.bind(intent, this@AudioBrowser)
|
|
248
278
|
Timber.d("Auto-bind result: $bound")
|
|
249
279
|
|
|
250
280
|
if (!bound) {
|
|
@@ -811,12 +841,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
|
811
841
|
// Service not connected yet, bind to service
|
|
812
842
|
suspendCoroutine<Unit> { continuation ->
|
|
813
843
|
Timber.d("Binding to AudioBrowserService")
|
|
814
|
-
val bound =
|
|
815
|
-
context.bindService(
|
|
816
|
-
Intent(context, Service::class.java),
|
|
817
|
-
this@AudioBrowser,
|
|
818
|
-
Context.BIND_AUTO_CREATE,
|
|
819
|
-
)
|
|
844
|
+
val bound = serviceBinding.bind(Intent(context, Service::class.java), this@AudioBrowser)
|
|
820
845
|
|
|
821
846
|
if (!bound) {
|
|
822
847
|
continuation.resumeWithException(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package com.audiobrowser
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.ServiceConnection
|
|
6
|
+
import timber.log.Timber
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Exactly-once bookkeeping for a [ServiceConnection].
|
|
10
|
+
*
|
|
11
|
+
* `bindService` may be called from more than one place — an auto-bind at construction and again
|
|
12
|
+
* from setup — but the binding must be released exactly once, and only if one was ever taken:
|
|
13
|
+
* [Context.unbindService] throws when the connection was never registered. Kept apart from the
|
|
14
|
+
* caller so the rule is one object's job, and so it can be tested without a JSI runtime.
|
|
15
|
+
*/
|
|
16
|
+
internal class ServiceBinding(private val context: Context) {
|
|
17
|
+
|
|
18
|
+
@Volatile
|
|
19
|
+
var isBound: Boolean = false
|
|
20
|
+
private set
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Binds if not already bound, and returns whether a binding is held afterwards. A second call
|
|
24
|
+
* while bound is a no-op: Android would otherwise count the extra bind and expect a matching
|
|
25
|
+
* unbind.
|
|
26
|
+
*/
|
|
27
|
+
fun bind(intent: Intent, connection: ServiceConnection): Boolean {
|
|
28
|
+
if (isBound) return true
|
|
29
|
+
val bound = context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
|
30
|
+
if (bound) isBound = true
|
|
31
|
+
return bound
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Releases the binding if one is held. Safe to call repeatedly, and safe when the service has
|
|
36
|
+
* already died — [onServiceDisconnected][ServiceConnection.onServiceDisconnected] means the
|
|
37
|
+
* service went away, not that the binding did, so the unbind is still required.
|
|
38
|
+
*/
|
|
39
|
+
fun unbind(connection: ServiceConnection) {
|
|
40
|
+
if (!isBound) return
|
|
41
|
+
isBound = false
|
|
42
|
+
// Throws if the binding is somehow already gone, which is the outcome we wanted anyway.
|
|
43
|
+
runCatching { context.unbindService(connection) }
|
|
44
|
+
.onFailure { Timber.w(it, "unbindService failed") }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -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())
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package com.audiobrowser
|
|
2
|
+
|
|
3
|
+
import android.content.ComponentName
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.ServiceConnection
|
|
6
|
+
import android.os.IBinder
|
|
7
|
+
import org.junit.Assert.assertEquals
|
|
8
|
+
import org.junit.Assert.assertFalse
|
|
9
|
+
import org.junit.Assert.assertTrue
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
import org.junit.runner.RunWith
|
|
12
|
+
import org.robolectric.RobolectricTestRunner
|
|
13
|
+
import org.robolectric.RuntimeEnvironment
|
|
14
|
+
import org.robolectric.Shadows.shadowOf
|
|
15
|
+
|
|
16
|
+
// Robolectric: bindService/unbindService only exist under the framework, and ShadowApplication
|
|
17
|
+
// records both sides — which is precisely the leak being guarded against.
|
|
18
|
+
@RunWith(RobolectricTestRunner::class)
|
|
19
|
+
class ServiceBindingTest {
|
|
20
|
+
|
|
21
|
+
private val app = RuntimeEnvironment.getApplication()
|
|
22
|
+
private val intent = Intent(app, Service::class.java)
|
|
23
|
+
|
|
24
|
+
private val connection =
|
|
25
|
+
object : ServiceConnection {
|
|
26
|
+
override fun onServiceConnected(name: ComponentName?, service: IBinder?) = Unit
|
|
27
|
+
|
|
28
|
+
override fun onServiceDisconnected(name: ComponentName?) = Unit
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Both the auto-bind at construction and setup's bind reach the same object. */
|
|
32
|
+
@Test
|
|
33
|
+
fun bindingTwiceTakesOnlyOneBinding() {
|
|
34
|
+
val binding = ServiceBinding(app)
|
|
35
|
+
|
|
36
|
+
assertTrue(binding.bind(intent, connection))
|
|
37
|
+
assertTrue(binding.bind(intent, connection))
|
|
38
|
+
|
|
39
|
+
assertEquals(1, shadowOf(app).boundServiceConnections.size)
|
|
40
|
+
|
|
41
|
+
binding.unbind(connection)
|
|
42
|
+
|
|
43
|
+
assertTrue(shadowOf(app).boundServiceConnections.isEmpty())
|
|
44
|
+
assertEquals(1, shadowOf(app).unboundServiceConnections.size)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** dispose() must not throw, and Android throws when unbinding a connection it never had. */
|
|
48
|
+
@Test
|
|
49
|
+
fun unbindWithoutBindIsANoOp() {
|
|
50
|
+
val binding = ServiceBinding(app)
|
|
51
|
+
|
|
52
|
+
binding.unbind(connection)
|
|
53
|
+
|
|
54
|
+
assertFalse(binding.isBound)
|
|
55
|
+
assertTrue(shadowOf(app).unboundServiceConnections.isEmpty())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun unbindIsIdempotent() {
|
|
60
|
+
val binding = ServiceBinding(app)
|
|
61
|
+
binding.bind(intent, connection)
|
|
62
|
+
|
|
63
|
+
binding.unbind(connection)
|
|
64
|
+
binding.unbind(connection)
|
|
65
|
+
|
|
66
|
+
assertEquals(1, shadowOf(app).unboundServiceConnections.size)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -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
|
|